cyberia 3.2.12 → 3.2.22

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 (55) hide show
  1. package/.github/workflows/engine-cyberia.cd.yml +1 -0
  2. package/.github/workflows/engine-cyberia.ci.yml +14 -2
  3. package/.github/workflows/ghpkg.ci.yml +1 -0
  4. package/.github/workflows/npmpkg.ci.yml +9 -5
  5. package/CHANGELOG.md +151 -1
  6. package/CLI-HELP.md +975 -1130
  7. package/bin/build.js +97 -136
  8. package/bin/build.template.js +25 -179
  9. package/bin/cyberia.js +11 -6
  10. package/bin/deploy.js +4 -1
  11. package/bin/index.js +11 -6
  12. package/conf.js +1 -0
  13. package/deployment.yaml +74 -2
  14. package/hardhat/package-lock.json +4 -4
  15. package/hardhat/package.json +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  17. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  18. package/manifests/deployment/dd-cyberia-development/deployment.yaml +74 -2
  19. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  20. package/package.json +7 -7
  21. package/scripts/link-local-underpost-cli.sh +6 -0
  22. package/scripts/test-monitor.sh +250 -0
  23. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +7 -0
  24. package/src/cli/deploy.js +200 -282
  25. package/src/cli/env.js +1 -4
  26. package/src/cli/image.js +58 -4
  27. package/src/cli/index.js +47 -0
  28. package/src/cli/monitor.js +387 -6
  29. package/src/cli/release.js +26 -11
  30. package/src/cli/repository.js +101 -7
  31. package/src/cli/run.js +159 -73
  32. package/src/client/components/core/PanelForm.js +44 -44
  33. package/src/client/components/cyberia/SharedDefaultsCyberia.js +1 -1
  34. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +55 -1
  35. package/src/client/public/cyberia-docs/ARCHITECTURE.md +272 -50
  36. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +20 -11
  37. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +23 -1
  38. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  39. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  40. package/src/db/mongo/MongooseDB.js +2 -1
  41. package/src/index.js +1 -1
  42. package/src/runtime/cyberia-client/Dockerfile +4 -22
  43. package/src/runtime/cyberia-client/Dockerfile.dev +3 -18
  44. package/src/runtime/cyberia-server/Dockerfile +3 -23
  45. package/src/runtime/cyberia-server/Dockerfile.dev +3 -27
  46. package/src/runtime/wp/Dockerfile +3 -3
  47. package/src/server/catalog-underpost.js +61 -0
  48. package/src/server/catalog.js +77 -0
  49. package/src/server/conf.js +414 -56
  50. package/src/server/ipfs-client.js +5 -3
  51. package/src/server/runtime-status.js +235 -0
  52. package/src/server/start.js +32 -11
  53. package/test/deploy-monitor.test.js +251 -0
  54. package/manifests/deployment/dd-test-development/deployment.yaml +0 -256
  55. package/manifests/deployment/dd-test-development/proxy.yaml +0 -102
package/src/cli/run.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  getNpmRootPath,
16
16
  isDeployRunnerContext,
17
17
  loadConfServerJson,
18
+ loadReplicas,
18
19
  writeEnv,
19
20
  } from '../server/conf.js';
20
21
  import { actionInitLog, loggerFactory } from '../server/logger.js';
@@ -119,6 +120,7 @@ const logger = loggerFactory(import.meta);
119
120
  * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
120
121
  * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
121
122
  * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
123
+ * @property {boolean} test - Whether to enable test/generic-purpose mode (e.g. use self-signed TLS instead of cert-manager).
122
124
  * @memberof UnderpostRun
123
125
  */
124
126
  const DEFAULT_OPTION = {
@@ -187,6 +189,7 @@ const DEFAULT_OPTION = {
187
189
  skipFullBuild: false,
188
190
  pullBundle: false,
189
191
  remove: false,
192
+ test: false,
190
193
  };
191
194
 
192
195
  /**
@@ -222,7 +225,7 @@ class UnderpostRun {
222
225
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
223
226
 
224
227
  shellExec(
225
- `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb4 --service-host ${mongoHosts.join(
228
+ `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --service-host ${mongoHosts.join(
226
229
  ',',
227
230
  )} --pull-image`,
228
231
  );
@@ -264,6 +267,16 @@ class UnderpostRun {
264
267
  logger.info(hostListenResult.renderHosts);
265
268
  },
266
269
 
270
+ /**
271
+ * @method etc-hosts
272
+ * @description Modifies the `/etc/hosts` file to add entries for local access to services,
273
+ * based on the provided path input.
274
+ * @param {string} path - The input value, identifier, or path for the operation (used to specify the entries to add to /etc/hosts).
275
+ */
276
+ 'etc-hosts': (path = '', options = DEFAULT_OPTION) => {
277
+ etcHostFactory(path.split(','));
278
+ },
279
+
267
280
  /**
268
281
  * @method ipfs-expose
269
282
  * @description Exposes IPFS Cluster services on specified ports for local access.
@@ -422,6 +435,7 @@ class UnderpostRun {
422
435
  return;
423
436
  }
424
437
  shellExec(`${baseCommand} run pull`);
438
+ shellExec(`${baseCommand} run shared-dir`);
425
439
 
426
440
  // Capture last N commit messages for propagation.
427
441
  // When --from-n-commit is not set, auto-detect unpushed commit count (same as --unpush flag).
@@ -502,6 +516,7 @@ class UnderpostRun {
502
516
  return;
503
517
  }
504
518
  shellExec(`${baseCommand} run pull`);
519
+ shellExec(`${baseCommand} run shared-dir`);
505
520
 
506
521
  // Capture last N commit messages from the engine repo.
507
522
  // When --from-n-commit is not set, auto-detect unpushed commit count (same as --unpush flag).
@@ -713,6 +728,11 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
713
728
  let targetTraffic = currentTraffic ? (currentTraffic === 'blue' ? 'green' : 'blue') : 'green';
714
729
  if (targetTraffic) versions = versions ? versions : targetTraffic;
715
730
 
731
+ const ignorePods =
732
+ isDeployRunnerContext(path, options) && targetTraffic
733
+ ? Underpost.kubectl.get(`${deployId}-${env}-${targetTraffic}`, 'pods', options.namespace).map((p) => p.NAME)
734
+ : [];
735
+
716
736
  const timeoutFlags = Underpost.deploy.timeoutFlagsFactory(options);
717
737
  const cmdString = options.cmd
718
738
  ? ' --cmd ' + (options.cmd.find((c) => c.match('"')) ? '"' + options.cmd + '"' : "'" + options.cmd + "'")
@@ -743,7 +763,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
743
763
  );
744
764
  if (!targetTraffic)
745
765
  targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
746
- await Underpost.deploy.monitorReadyRunner(deployId, env, targetTraffic, [], options.namespace, 'underpost');
766
+ await Underpost.monitor.monitorReadyRunner(deployId, env, targetTraffic, ignorePods, options.namespace);
747
767
  Underpost.deploy.switchTraffic(deployId, env, targetTraffic, replicas, options.namespace, options);
748
768
  } else
749
769
  logger.info('current traffic', Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace }));
@@ -989,8 +1009,19 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
989
1009
  // pathRewritePolicy,
990
1010
  });
991
1011
  if (options.tls) {
992
- shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
993
- proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1012
+ if (options.test) {
1013
+ const sslDir = `./engine-private/ssl/${_host}`;
1014
+ const nameSafe = _host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1015
+ fs.mkdirpSync(sslDir);
1016
+ shellExec(`bash ./scripts/ssl.sh "${sslDir}" "${_host}"`);
1017
+ shellExec(`kubectl delete secret ${_host} -n ${options.namespace} --ignore-not-found`);
1018
+ shellExec(
1019
+ `kubectl create secret tls ${_host} --cert="${sslDir}/${nameSafe}.pem" --key="${sslDir}/${nameSafe}-key.pem" -n ${options.namespace}`,
1020
+ );
1021
+ } else {
1022
+ shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1023
+ proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1024
+ }
994
1025
  }
995
1026
  // console.log(proxyYaml);
996
1027
  shellExec(`kubectl delete HTTPProxy ${_host} --namespace ${options.namespace} --ignore-not-found`);
@@ -1059,6 +1090,7 @@ EOF
1059
1090
  // Examples images:
1060
1091
  // `underpost/underpost-engine:${Underpost.version}`
1061
1092
  // `localhost/rockylinux9-underpost:${Underpost.version}`
1093
+ if (options.imageName) _image = options.imageName;
1062
1094
  if (!_image) _image = `underpost/underpost-engine:${Underpost.version}`;
1063
1095
 
1064
1096
  if (_image && !_image.startsWith('localhost'))
@@ -1154,12 +1186,16 @@ EOF
1154
1186
  `,
1155
1187
  { disableLog: true },
1156
1188
  );
1157
- const { ready, readyPods } = await Underpost.deploy.monitorReadyRunner(
1189
+ // Custom instances run a bare binary (no `underpost start` / internal
1190
+ // HTTP endpoint): Kubernetes readiness is the running signal and
1191
+ // container-status is read via exec. See `Deploy custom instance to K8S.md`.
1192
+ const { ready, readyPods } = await Underpost.monitor.monitorReadyRunner(
1158
1193
  _deployId,
1159
1194
  env,
1160
1195
  targetTraffic,
1161
1196
  ignorePods,
1162
1197
  options.namespace,
1198
+ { readyGate: 'kubernetes', statusTransport: 'exec' },
1163
1199
  );
1164
1200
 
1165
1201
  if (!ready) {
@@ -1169,7 +1205,7 @@ EOF
1169
1205
  shellExec(
1170
1206
  `${baseCommand} run${baseClusterCommand} --namespace ${options.namespace}` +
1171
1207
  `${options.nodeName ? ` --node-name ${options.nodeName}` : ''}` +
1172
- `${options.tls ? ` --tls` : ''}` +
1208
+ `${options.tls ? ` --tls ${options.test ? '--test' : ''}` : ''}` +
1173
1209
  ` instance-promote '${path}'`,
1174
1210
  );
1175
1211
  }
@@ -1436,8 +1472,8 @@ EOF`);
1436
1472
  const baseClusterCommand = options.dev ? ' --dev' : '';
1437
1473
  const currentImage = options.imageName
1438
1474
  ? options.imageName
1439
- : Underpost.deploy
1440
- .getCurrentLoadedImages(options.nodeName ? options.nodeName : 'kind-worker', false)
1475
+ : Underpost.image
1476
+ .getCurrentLoaded(options.nodeName ? options.nodeName : 'kind-worker', false)
1441
1477
  .find((o) => o.IMAGE.match('underpost'));
1442
1478
  const podName = options.podName || `underpost-dev-container`;
1443
1479
  const volumeHostPath = options.claimName || '/home/dd';
@@ -1714,20 +1750,13 @@ EOF`);
1714
1750
  const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
1715
1751
  const targetTraffic = currentTraffic === 'blue' ? 'green' : 'blue';
1716
1752
  const env = options.dev ? 'development' : 'production';
1717
- const ignorePods = Underpost.deploy
1753
+ const ignorePods = Underpost.kubectl
1718
1754
  .get(`${deployId}-${env}-${targetTraffic}`, 'pods', options.namespace)
1719
1755
  .map((p) => p.NAME);
1720
1756
 
1721
1757
  shellExec(`sudo kubectl rollout restart deployment/${deployId}-${env}-${targetTraffic} -n ${options.namespace}`);
1722
1758
 
1723
- await Underpost.deploy.monitorReadyRunner(
1724
- deployId,
1725
- env,
1726
- targetTraffic,
1727
- ignorePods,
1728
- options.namespace,
1729
- 'underpost',
1730
- );
1759
+ await Underpost.monitor.monitorReadyRunner(deployId, env, targetTraffic, ignorePods, options.namespace);
1731
1760
 
1732
1761
  Underpost.deploy.switchTraffic(deployId, env, targetTraffic, options.replicas, options.namespace, options);
1733
1762
  },
@@ -1819,7 +1848,7 @@ EOF`);
1819
1848
  }`;
1820
1849
  shellExec(cmd, { async: true });
1821
1850
  }
1822
- await awaitDeployMonitor();
1851
+ if ((await awaitDeployMonitor()) !== true) return;
1823
1852
  {
1824
1853
  const cmd = `npm run dev:client ${deployId} ${subConf} ${host} ${_path} proxy${options.tls ? ' tls' : ''}`;
1825
1854
 
@@ -1827,7 +1856,7 @@ EOF`);
1827
1856
  async: true,
1828
1857
  });
1829
1858
  }
1830
- await awaitDeployMonitor();
1859
+ if ((await awaitDeployMonitor()) !== true) return;
1831
1860
  shellExec(
1832
1861
  `NODE_ENV=development node src/proxy proxy ${deployId} ${subConf} ${host} ${_path}${options.tls ? ' tls' : ''}`,
1833
1862
  );
@@ -2504,7 +2533,8 @@ EOF`;
2504
2533
  /**
2505
2534
  * @method push-bundle
2506
2535
  * @description Builds the client zip for the specified deployment, splits it into parts, and uploads to file storage.
2507
- * Steps: set env, build+split zip, switch to cron env, upload parts to storage.
2536
+ * Steps: set env, build+split zip, upload only the zip parts belonging to the deploy-id's hosts (from conf.server.json).
2537
+ * Only files matching `<host>-<route>.zip.part*` or `<host>-<route>.zip` for each non-skipped route are uploaded.
2508
2538
  * @param {string} path - Optional `fsPath.splitOption` string.
2509
2539
  * Examples: `build` (default split 8), `build.16` (split 16 MB), `build.none-split` (no split flag).
2510
2540
  * @param {Object} options - The default underpost runner options for customizing workflow.
@@ -2513,7 +2543,7 @@ EOF`;
2513
2543
  * @memberof UnderpostRun
2514
2544
  */
2515
2545
  'push-bundle': (path = '', options = DEFAULT_OPTION) => {
2516
- const baseCommand = options.dev ? 'node bin' : 'underpost';
2546
+ const baseCommand = 'node bin'; // options.dev ? 'node bin' : 'underpost';
2517
2547
  const env = options.dev ? 'development' : 'production';
2518
2548
  const deployId = options.deployId || 'dd-default';
2519
2549
  const pathParts = (path || '').split('.');
@@ -2537,11 +2567,54 @@ EOF`;
2537
2567
  }
2538
2568
  }
2539
2569
 
2570
+ const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
2571
+ const confServer = fs.existsSync(confServerPath)
2572
+ ? loadReplicas(deployId, loadConfServerJson(confServerPath))
2573
+ : {};
2574
+ const storageFilePath = `engine-private/conf/${deployId}/storage.bundle.json`;
2575
+
2540
2576
  shellExec(`${baseCommand} env ${deployId} ${env}`);
2541
2577
  shellExec(`${baseCommand} client ${deployId} --build-zip${splitFlag ? ` ${splitFlag}` : ''}`);
2542
- shellExec(
2543
- `${baseCommand} fs ${fsPath} --recursive --deploy-id ${deployId} --storage-file-path engine-private/conf/${deployId}/storage.bundle.json --force`,
2544
- );
2578
+
2579
+ const pushBundleFiles = (host, routePath) => {
2580
+ const buildId = `${host}-${routePath.replaceAll('/', '')}`;
2581
+ const buildDir = `./${fsPath}`;
2582
+ if (!fs.existsSync(buildDir)) return;
2583
+ const partFiles = fs
2584
+ .readdirSync(buildDir)
2585
+ .filter(
2586
+ (name) =>
2587
+ name.startsWith(`${buildId}.zip.part`) ||
2588
+ name.startsWith(`${buildId}.zip-part`) ||
2589
+ name === `${buildId}.zip`,
2590
+ )
2591
+ .map((name) => `${fsPath}/${name}`);
2592
+ if (partFiles.length === 0) {
2593
+ logger.warn(`push-bundle: no bundle files found for '${host}${routePath}'`, { buildId });
2594
+ return;
2595
+ }
2596
+ for (const partFile of partFiles) {
2597
+ shellExec(
2598
+ `${baseCommand} fs ${partFile} --deploy-id ${deployId} --storage-file-path ${storageFilePath} --force`,
2599
+ );
2600
+ }
2601
+ };
2602
+
2603
+ for (const host of Object.keys(confServer)) {
2604
+ for (const routePath of Object.keys(confServer[host])) {
2605
+ const routeConf = confServer[host][routePath] || {};
2606
+ if (routeConf.redirect || routeConf.disabledRebuild) continue;
2607
+ if (routeConf.singleReplica) {
2608
+ if (routeConf.replicas) {
2609
+ for (const replica of routeConf.replicas) {
2610
+ pushBundleFiles(host, replica);
2611
+ }
2612
+ }
2613
+ continue;
2614
+ }
2615
+ pushBundleFiles(host, routePath);
2616
+ }
2617
+ }
2545
2618
  },
2546
2619
 
2547
2620
  /**
@@ -2558,11 +2631,13 @@ EOF`;
2558
2631
  * @memberof UnderpostRun
2559
2632
  */
2560
2633
  'pull-bundle': (path = '', options = DEFAULT_OPTION) => {
2561
- const baseCommand = options.dev ? 'node bin' : 'underpost';
2634
+ const baseCommand = 'node bin'; // options.dev ? 'node bin' : 'underpost';
2562
2635
  const env = options.dev ? 'development' : 'production';
2563
2636
  const deployId = options.deployId || 'dd-default';
2564
2637
  const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
2565
- const confServer = fs.existsSync(confServerPath) ? loadConfServerJson(confServerPath) : {};
2638
+ const confServer = fs.existsSync(confServerPath)
2639
+ ? loadReplicas(deployId, loadConfServerJson(confServerPath))
2640
+ : {};
2566
2641
  const hostsArg = path
2567
2642
  ? path
2568
2643
  .split(',')
@@ -2581,64 +2656,75 @@ EOF`;
2581
2656
  `${baseCommand} fs build --recursive --deploy-id ${deployId} --storage-file-path engine-private/conf/${deployId}/storage.bundle.json --pull --omit-unzip`,
2582
2657
  );
2583
2658
 
2659
+ const pullBundleRoute = (host, routePath) => {
2660
+ const buildId = `${host}-${routePath.replaceAll('/', '')}`;
2661
+ const zipPath = `build/${buildId}.zip`;
2662
+ const buildDir = './build';
2663
+ const hasZip = fs.existsSync(zipPath);
2664
+ const hasParts =
2665
+ fs.existsSync(buildDir) &&
2666
+ fs
2667
+ .readdirSync(buildDir)
2668
+ .some((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`));
2669
+
2670
+ if (!hasZip && !hasParts) {
2671
+ logger.warn(`Bundle not found for '${host}${routePath}'. Skipping.`, { zipPath, deployId });
2672
+ return;
2673
+ }
2674
+
2675
+ if (hasParts) shellExec(`${baseCommand} client --merge-zip ${zipPath}`);
2676
+ shellExec(`${baseCommand} client --unzip ${zipPath}`);
2677
+ shellExec(`sudo rm -rf ${zipPath}`);
2678
+
2679
+ if (fs.existsSync(buildDir)) {
2680
+ fs.readdirSync(buildDir)
2681
+ .filter((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`))
2682
+ .forEach((partFile) => shellExec(`sudo rm -rf ${buildDir}/${partFile}`));
2683
+ }
2684
+
2685
+ const extractedDir = `build/${buildId.replace(/-$/, '')}`;
2686
+ if (!fs.existsSync(extractedDir)) {
2687
+ logger.warn(`Extracted build dir not found: ${extractedDir}. Skipping move for '${host}${routePath}'.`);
2688
+ return;
2689
+ }
2690
+
2691
+ const publicDestPath = routePath === '/' ? `public/${host}` : `public/${host}${routePath}`;
2692
+ if (fs.existsSync(publicDestPath)) shellExec(`sudo rm -rf ${publicDestPath}`);
2693
+ if (routePath !== '/') shellExec(`sudo mkdir -p public/${host}`);
2694
+ fs.copySync(`${extractedDir}`, `${publicDestPath}`);
2695
+ };
2696
+
2584
2697
  for (const host of hostsArg) {
2585
- // Gather all routes for this host; fall back to root '/' when host is not in confServer
2586
- // (e.g. when hosts were provided explicitly via the path argument).
2587
2698
  const routePaths = confServer[host] ? Object.keys(confServer[host]) : ['/'];
2588
2699
 
2589
2700
  for (const routePath of routePaths) {
2590
2701
  const routeConf = confServer[host] ? confServer[host][routePath] || {} : {};
2591
- // Skip routes that are not built by buildClient (mirrors buildClient skip conditions)
2592
- if (routeConf.singleReplica || routeConf.redirect || routeConf.disabledRebuild) continue;
2593
-
2594
- // buildClient names the zip as "<host>-<path-no-slashes>.zip"
2595
- // e.g. host="underpost.net", path="/" → buildId="underpost.net-", zip="build/underpost.net-.zip"
2596
- // e.g. host="app.net", path="/admin" → buildId="app.net-admin", zip="build/app.net-admin.zip"
2597
- const buildId = `${host}-${routePath.replaceAll('/', '')}`;
2598
- const zipPath = `build/${buildId}.zip`;
2599
- const buildDir = './build';
2600
- const hasZip = fs.existsSync(zipPath);
2601
- const hasParts =
2602
- fs.existsSync(buildDir) &&
2603
- fs
2604
- .readdirSync(buildDir)
2605
- .some((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`));
2606
-
2607
- if (!hasZip && !hasParts) {
2608
- logger.warn(`Bundle not found for '${host}${routePath}'. Skipping.`, { zipPath, deployId });
2609
- continue;
2610
- }
2611
-
2612
- if (hasParts) shellExec(`${baseCommand} client --merge-zip ${zipPath}`);
2613
- shellExec(`${baseCommand} client --unzip ${zipPath}`);
2614
- shellExec(`sudo rm -rf ${zipPath}`);
2615
-
2616
- // Clean up downloaded part wrapper zips left by --omit-unzip pull
2617
- if (fs.existsSync(buildDir)) {
2618
- fs.readdirSync(buildDir)
2619
- .filter((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`))
2620
- .forEach((partFile) => shellExec(`sudo rm -rf ${buildDir}/${partFile}`));
2621
- }
2622
-
2623
- // unzipClientBuild extracts to buildId with trailing '-' stripped
2624
- // e.g. "build/underpost.net-" → "build/underpost.net"
2625
- // e.g. "build/app.net-admin" → "build/app.net-admin" (no trailing dash, no change)
2626
- const extractedDir = `build/${buildId.replace(/-$/, '')}`;
2627
- if (!fs.existsSync(extractedDir)) {
2628
- logger.warn(`Extracted build dir not found: ${extractedDir}. Skipping move for '${host}${routePath}'.`);
2702
+ if (routeConf.redirect || routeConf.disabledRebuild) continue;
2703
+ if (routeConf.singleReplica) {
2704
+ if (routeConf.replicas) {
2705
+ for (const replica of routeConf.replicas) {
2706
+ pullBundleRoute(host, replica);
2707
+ }
2708
+ }
2629
2709
  continue;
2630
2710
  }
2631
-
2632
- // Destination mirrors the public directory layout used by the server
2633
- const publicDestPath = routePath === '/' ? `public/${host}` : `public/${host}${routePath}`;
2634
- if (fs.existsSync(publicDestPath)) shellExec(`sudo rm -rf ${publicDestPath}`);
2635
- // Ensure parent directory exists for sub-paths
2636
- if (routePath !== '/') shellExec(`sudo mkdir -p public/${host}`);
2637
- shellExec(`sudo mv ${extractedDir} ${publicDestPath}`);
2711
+ pullBundleRoute(host, routePath);
2638
2712
  }
2639
2713
  }
2640
2714
  },
2641
2715
 
2716
+ /**
2717
+ * @method build-cluster-deployment-manifests
2718
+ * @description Builds deployment manifests for both production and development environments using `node bin deploy --build-manifest`, syncing them, and setting replicas to 1 for the `dd` deployment.
2719
+ * @param {string} path - Unused.
2720
+ * @param {Object} options - The default underpost runner options for customizing workflow.
2721
+ * @memberof UnderpostRun
2722
+ */
2723
+ 'build-cluster-deployment-manifests': (path = '', options = DEFAULT_OPTION) => {
2724
+ shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd development`);
2725
+ shellExec(`node bin deploy --build-manifest --sync --info-router --replicas 1 dd production --cert`);
2726
+ },
2727
+
2642
2728
  /**
2643
2729
  * @method monitor-ui
2644
2730
  * @description Installs and enables the Cockpit KVM Dashboard (cockpit, cockpit-machines, libvirt)
@@ -73,7 +73,7 @@ class PanelForm {
73
73
  parentIdModal: undefined,
74
74
  route: 'home',
75
75
  htmlFormHeader: async () => '',
76
- firsUpdateEvent: async () => { },
76
+ firsUpdateEvent: async () => {},
77
77
  share: {
78
78
  copyLink: false,
79
79
  copySourceMd: false,
@@ -196,12 +196,12 @@ class PanelForm {
196
196
  <img
197
197
  class="abs center"
198
198
  style="${renderCssAttr({
199
- style: {
200
- width: '100px',
201
- height: '100px',
202
- opacity: 0.2,
203
- },
204
- })}"
199
+ style: {
200
+ width: '100px',
201
+ height: '100px',
202
+ opacity: 0.2,
203
+ },
204
+ })}"
205
205
  src="${defaultUrlImage}"
206
206
  />
207
207
  `,
@@ -382,15 +382,15 @@ class PanelForm {
382
382
  // It will be filtered from the tags array to keep visibility control separate from content tags
383
383
  const tags = data.tags
384
384
  ? uniqueArray(
385
- data.tags
386
- .replaceAll('/', ',')
387
- .replaceAll('-', ',')
388
- .replaceAll(' ', ',')
389
- .split(',')
390
- .map((t) => t.trim())
391
- .filter((t) => t)
392
- .concat(prefixTags),
393
- )
385
+ data.tags
386
+ .replaceAll('/', ',')
387
+ .replaceAll('-', ',')
388
+ .replaceAll(' ', ',')
389
+ .split(',')
390
+ .map((t) => t.trim())
391
+ .filter((t) => t)
392
+ .concat(prefixTags),
393
+ )
394
394
  : prefixTags;
395
395
  let originObj, originFileObj, indexOriginObj;
396
396
  if (editId) {
@@ -432,8 +432,8 @@ class PanelForm {
432
432
  // In edit mode, null means user cleared the file - we need to tell server to remove it
433
433
  const isFileCleared = data.fileId === null && editId;
434
434
  await (async () => {
435
- // When file is null and not the first iteration or not in edit mode, skip upload
436
- if (!file && !isFileCleared) return;
435
+ // When file is null, no markdown content, and not clearing a file, skip upload
436
+ if (!file && !isFileCleared && !hasMdContent) return;
437
437
  // When user cleared file in edit mode, set fileId=null so server removes the reference
438
438
  if (isFileCleared) {
439
439
  fileId = null;
@@ -489,8 +489,8 @@ class PanelForm {
489
489
  message: documentMessage,
490
490
  data: documentData,
491
491
  } = originObj && indexFormDoc === 0
492
- ? await DocumentService.put({ id: originObj._id, body })
493
- : await DocumentService.post({
492
+ ? await DocumentService.put({ id: originObj._id, body })
493
+ : await DocumentService.post({
494
494
  body,
495
495
  });
496
496
  const newDoc = {
@@ -518,12 +518,12 @@ class PanelForm {
518
518
  fileId: {
519
519
  fileBlob: file
520
520
  ? {
521
- data: {
522
- data: await getDataFromInputFile(file),
523
- },
524
- mimetype: file.type,
525
- name: file.name,
526
- }
521
+ data: {
522
+ data: await getDataFromInputFile(file),
523
+ },
524
+ mimetype: file.type,
525
+ name: file.name,
526
+ }
527
527
  : undefined,
528
528
  filePlain: undefined,
529
529
  },
@@ -742,36 +742,36 @@ class PanelForm {
742
742
  <div
743
743
  class="in fll ssr-shimmer-search-box"
744
744
  style="${renderCssAttr({
745
- style: {
746
- width: '80%',
747
- height: '30px',
748
- top: '-13px',
749
- left: '10px',
750
- },
751
- })}"
745
+ style: {
746
+ width: '80%',
747
+ height: '30px',
748
+ top: '-13px',
749
+ left: '10px',
750
+ },
751
+ })}"
752
752
  ></div>
753
753
  </div>`,
754
754
  createdAt: html`<div class="fl">
755
755
  <div
756
756
  class="in fll ssr-shimmer-search-box"
757
757
  style="${renderCssAttr({
758
- style: {
759
- width: '50%',
760
- height: '30px',
761
- left: '-5px',
762
- },
763
- })}"
758
+ style: {
759
+ width: '50%',
760
+ height: '30px',
761
+ left: '-5px',
762
+ },
763
+ })}"
764
764
  ></div>
765
765
  </div>`,
766
766
  mdFileId: html`<div class="fl section-mp">
767
767
  <div
768
768
  class="in fll ssr-shimmer-search-box"
769
769
  style="${renderCssAttr({
770
- style: {
771
- width: '80%',
772
- height: '30px',
773
- },
774
- })}"
770
+ style: {
771
+ width: '80%',
772
+ height: '30px',
773
+ },
774
+ })}"
775
775
  ></div>
776
776
  </div>`.repeat(random(2, 4)),
777
777
  ssr: true,
@@ -109,7 +109,7 @@ export const ENTITY_TYPE_TO_ITEM_TYPES = Object.freeze({
109
109
  export const QUEST_STEPS_TYPES = Object.freeze(['collect', 'talk', 'kill']);
110
110
 
111
111
  /** Action categories accepted by the cyberia-action engine. */
112
- export const CYBERIA_ACTION_TYPES = Object.freeze(['craft', 'shop', 'storage', 'quest-talk']);
112
+ export const CYBERIA_ACTION_TYPES = Object.freeze(['craft', 'shop', 'storage', 'talk', 'quest-talk']);
113
113
 
114
114
  /**
115
115
  * Canonical (itemId → itemType) registry shipped with the engine. Used
@@ -8,7 +8,7 @@
8
8
 
9
9
  The Action System defines how NPC entities interact with players. An **Action** is a spatial, typed payload attached to a map entity that the player activates by tapping the NPC. Actions drive dialogue, shops, crafting, storage, and quest grant events.
10
10
 
11
- > **Implementation status — Pre-alpha:** The CyberiaAction and CyberiaDialogue MongoDB schemas and Engine REST API (`src/api/cyberia-action`, `src/api/cyberia-dialogue`) are defined. Go server integration (NPC tap routing to action handlers, shop/craft transaction processing, quest grant on dialogue completion) is planned for the **Alpha milestone**. The `freeze_start`/`freeze_end` WS messages for modal protection are implemented in the Go server today.
11
+ > **Implementation status — Alpha (talk / quest-talk):** The CyberiaAction and CyberiaDialogue MongoDB schemas and Engine REST API (`src/api/cyberia-action`, `src/api/cyberia-dialogue`) are defined. The `talk` and `quest-talk` paths are wired end-to-end: the Go server binds actions to entities at instance init, validates dialogue completion, grants quests, and advances `talk` objectives (see **Dialogue Interaction Protocol** below). Shop / craft / storage transaction processing remains planned for a later Alpha increment. The `freeze_start`/`freeze_end` WS messages for modal protection are implemented; dialogue freeze now rides on the `dlg_*` frames.
12
12
 
13
13
  ---
14
14
 
@@ -154,6 +154,60 @@ sequenceDiagram
154
154
 
155
155
  ---
156
156
 
157
+ ## Dialogue Interaction Protocol (talk / quest-talk)
158
+
159
+ Tapping an interaction bubble opens the Raylib-native **`modal_interact`** modal
160
+ first — the general-purpose entry point. Its `[Talk]` tab (shown only when the
161
+ entity has dialogue) opens `modal_dialogue`; its `[Chat / Profile]` tab opens the
162
+ JS overlay with no freeze.
163
+
164
+ The client is identical for `talk` and `quest-talk`; the **server** branches after
165
+ `dlg_complete`. The client never declares the action type, quest code, or quest
166
+ dialogue codes — the server resolves the bound action from its own
167
+ `entityId → CyberiaAction` cache (bound at instance init).
168
+
169
+ ### Wire messages
170
+
171
+ | Direction | Message | When | Payload |
172
+ | --------- | -------------- | ------------------------------------- | ------------------------------------ |
173
+ | C → S | `dlg_start` | `modal_dialogue` opens | `{ entityId, itemId }` |
174
+ | C → S | `dlg_complete` | player reads all lines, closes | `{ entityId, itemId, dialogCode }` |
175
+ | C → S | `dlg_cancel` | player dismisses early (✕ / outside) | `{ entityId, itemId }` |
176
+ | S → C | `dlg_ack` | after `dlg_complete` is processed | `{ questGranted, objectivesDone, quests[] }` |
177
+
178
+ Binary uplink opcodes: `dlg_start` `0x17`, `dlg_complete` `0x18`, `dlg_cancel`
179
+ `0x19` (JSON aliases of the same names are also accepted).
180
+
181
+ `dlg_ack` is notify-only — it carries the affected quest snapshot entries the
182
+ client upserts into its local `quest_store` (Quest Journal); it never gates
183
+ simulation state.
184
+
185
+ ### Server `dlg_complete` handling
186
+
187
+ 1. Validate `player.activeDialogueEntityID == msg.entityId`; drop otherwise.
188
+ 2. Clear the dialogue context and thaw the player (modal protection off).
189
+ 3. Resolve the action from `actionCache[entityId]`. `talk` → ack only.
190
+ 4. `quest-talk`: on first contact grant `grantQuestCode`; then for every active
191
+ quest whose **current step** has a `{ type: 'talk', itemId == provideItemId }`
192
+ objective, increment it — **only** when `msg.dialogCode` is in the action's
193
+ `questDialogueCodes`. On quest completion, deliver rewards (FCT) and unlock
194
+ successors.
195
+
196
+ > **Dialogue-code contract.** The C client fetches dialogue groups at
197
+ > `/api/cyberia-dialogue/code/default-<itemId>`, so the code it reports on
198
+ > `dlg_complete` is `default-<provideItemId>`. For a `quest-talk` objective to
199
+ > advance, the action's `questDialogueCodes` must contain that code.
200
+
201
+ ### Freeze semantics
202
+
203
+ | Event | Player state |
204
+ | ---------------------------------- | ------------------------- |
205
+ | `modal_interact` open | Active (no freeze) |
206
+ | `dlg_start` sent | Frozen — immune to damage |
207
+ | `dlg_complete` / `dlg_cancel` sent | Unfrozen |
208
+
209
+ ---
210
+
157
211
  ## Spatial Binding and Instance Init
158
212
 
159
213
  `sourceMapCode + sourceCellX + sourceCellY` links an Action to a specific entity cell in a specific map. During instance initialization: