@underpostnet/cyberia 3.2.80 → 3.3.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 (162) hide show
  1. package/.env.example +50 -15
  2. package/.github/workflows/coverall.cyberia.ci.yml +87 -0
  3. package/.github/workflows/cyberia-client.cd.yml +2 -7
  4. package/.github/workflows/cyberia-server.cd.yml +2 -7
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +4 -4
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +4 -4
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +4 -4
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +4 -4
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +3 -3
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +3 -3
  11. package/.github/workflows/engine-cyberia.cd.yml +3 -67
  12. package/.github/workflows/ghpkg.ci.yml +7 -1
  13. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  14. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  15. package/.github/workflows/release.cd.yml +1 -9
  16. package/CHANGELOG.md +291 -1
  17. package/CLI-HELP.md +174 -23
  18. package/Dockerfile +1 -1
  19. package/Dockerfile.dev +1 -1
  20. package/Dockerfile.test +1 -1
  21. package/bin/build.js +7 -5
  22. package/bin/cyberia.js +272 -98
  23. package/bin/deploy.js +19 -17
  24. package/bin/index.js +272 -98
  25. package/compose.env +50 -15
  26. package/conf.js +2 -0
  27. package/deploy/dd-cyberia/deploy.sh +42 -0
  28. package/deploy/dd-cyberia/init.sh +63 -0
  29. package/deploy/dd-cyberia/sync-deploy.sh +194 -0
  30. package/deploy/lib/logging.sh +96 -0
  31. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  32. package/deploy/release/deploy.sh +62 -0
  33. package/deployment.yaml +1 -210
  34. package/docker-compose.yml +90 -85
  35. package/hardhat/package-lock.json +139 -131
  36. package/hardhat/package.json +4 -4
  37. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  38. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  39. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  40. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -210
  41. package/manifests/deployment/dd-cyberia-development/gateway.yaml +80 -0
  42. package/manifests/deployment/dd-cyberia-development/httproute.yaml +504 -0
  43. package/manifests/deployment/dd-cyberia-development/proxy.yaml +12 -12
  44. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +0 -82
  45. package/manifests/deployment/dd-cyberia-development/traffic-service.yaml +121 -0
  46. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  47. package/manifests/deployment/playwright/deployment.yaml +1 -1
  48. package/manifests/mongodb/kustomization.yaml +4 -1
  49. package/manifests/mongodb/statefulset.yaml +4 -0
  50. package/manifests/mongodb/storage-class.yaml +9 -2
  51. package/nginx.conf +86 -16
  52. package/package.json +2 -2
  53. package/proxy.yaml +12 -12
  54. package/pv-pvc.yaml +0 -82
  55. package/scripts/audit-selinux.sh +64 -0
  56. package/scripts/coverall-test-cyberia.sh +24 -0
  57. package/scripts/coverall-test.sh +24 -0
  58. package/scripts/gpu-diag.sh +0 -0
  59. package/scripts/ip-info.sh +0 -0
  60. package/scripts/k3s-node-setup.sh +18 -15
  61. package/scripts/kubeadm-node-setup.sh +12 -23
  62. package/scripts/link-local-underpost-cli.sh +0 -0
  63. package/scripts/lxd-vm-setup.sh +0 -0
  64. package/scripts/maas-nat-firewalld.sh +0 -0
  65. package/scripts/nat-iptables.sh +12 -4
  66. package/scripts/rhel-grpc-setup.sh +0 -0
  67. package/scripts/rocky-kickstart.sh +25 -9
  68. package/scripts/test-monitor.sh +4 -3
  69. package/src/api/cyberia-action/cyberia-action.model.js +1 -0
  70. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +63 -0
  71. package/src/api/cyberia-instance/cyberia-fallback-world.js +49 -15
  72. package/src/api/cyberia-instance/cyberia-instance-map.service.js +8 -12
  73. package/src/api/cyberia-instance/cyberia-instance.controller.js +3 -0
  74. package/src/api/cyberia-instance/cyberia-instance.router.js +13 -0
  75. package/src/api/cyberia-instance/cyberia-instance.service.js +43 -0
  76. package/src/api/cyberia-instance/cyberia-portal-connector.js +7 -5
  77. package/src/api/cyberia-instance/cyberia-random-source.js +80 -0
  78. package/src/api/cyberia-instance/cyberia-world-generator.js +4 -3
  79. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +1 -0
  80. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +87 -1
  81. package/src/api/object-layer/object-layer.model.js +204 -3
  82. package/src/api/object-layer/object-layer.service.js +1 -14
  83. package/src/cli/baremetal.js +1 -2
  84. package/src/cli/cloud-init.js +1 -1
  85. package/src/cli/cluster.js +786 -96
  86. package/src/cli/db.js +11 -4
  87. package/src/cli/deploy.js +1698 -177
  88. package/src/cli/docker-compose.js +19 -178
  89. package/src/cli/env.js +1 -1
  90. package/src/cli/image.js +15 -7
  91. package/src/cli/index.js +245 -44
  92. package/src/cli/ipfs.js +82 -11
  93. package/src/cli/lxd.js +1 -1
  94. package/src/cli/monitor.js +2 -2
  95. package/src/cli/release.js +57 -22
  96. package/src/cli/repository.js +12 -10
  97. package/src/cli/run.js +2195 -427
  98. package/src/cli/secrets.js +969 -0
  99. package/src/cli/ssh.js +206 -105
  100. package/src/cli/system.js +26 -13
  101. package/src/cli/test.js +1 -1
  102. package/src/cli/vultr.js +583 -0
  103. package/src/cli/wireguard.js +2125 -0
  104. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +368 -0
  105. package/src/client/components/cyberia/InstanceSelectionView.js +11 -8
  106. package/src/client/components/cyberia/SharedDefaultsCyberia.js +5 -0
  107. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +33 -0
  108. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +4 -0
  109. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +4 -0
  110. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +106 -39
  111. package/src/client/public/cyberia-docs/ARCHITECTURE.md +35 -3
  112. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +44 -7
  113. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +1 -1
  114. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  115. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  116. package/src/client/services/cyberia-instance/cyberia-instance.service.js +40 -0
  117. package/src/client/services/object-layer/object-layer.management.js +4 -4
  118. package/src/client-builder/client-build.js +102 -13
  119. package/src/client-builder/ssr.js +27 -73
  120. package/src/db/mongo/MongoBootstrap.js +295 -54
  121. package/src/db/mongo/MongooseDB.js +51 -32
  122. package/src/index.js +25 -1
  123. package/src/projects/cyberia/besu-genesis-generator.js +3 -2
  124. package/src/projects/cyberia/catalog-cyberia.js +5 -0
  125. package/src/projects/cyberia/generate-saga.js +14 -23
  126. package/src/projects/cyberia/hot-reload-trigger.js +3 -3
  127. package/src/projects/cyberia/instance-data.js +63 -3
  128. package/src/projects/cyberia/object-layer.js +11 -21
  129. package/src/projects/underpost/catalog-underpost.js +4 -1
  130. package/src/runtime/cyberia-client/Dockerfile +1 -1
  131. package/src/runtime/cyberia-client/Dockerfile.dev +1 -1
  132. package/src/runtime/cyberia-server/Dockerfile +1 -1
  133. package/src/runtime/cyberia-server/Dockerfile.dev +1 -1
  134. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  135. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  136. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  137. package/src/runtime/engine-cyberia/compose.env +50 -15
  138. package/src/runtime/engine-cyberia/docker-compose.yml +90 -85
  139. package/src/runtime/engine-cyberia/nginx.conf +86 -16
  140. package/src/server/backup.js +1 -1
  141. package/src/server/conf.js +1216 -168
  142. package/src/server/cri.js +70 -0
  143. package/src/server/cron.js +249 -51
  144. package/src/server/dns.js +100 -6
  145. package/src/server/environment.js +98 -0
  146. package/src/server/forward-proxy.js +549 -0
  147. package/src/server/middlewares.js +56 -1
  148. package/src/server/process.js +0 -1
  149. package/src/server/selinux.js +185 -0
  150. package/src/server/systemd.js +205 -0
  151. package/src/server/underpost-compression.js +186 -0
  152. package/src/server/underpost-gateway.js +1083 -0
  153. package/src/server/underpost-ingress.js +380 -0
  154. package/test/cluster-instances.test.js +435 -0
  155. package/test/cyberia-instance-conf-defaults.test.js +1 -0
  156. package/test/deploy-node-placement.test.js +45 -0
  157. package/test/instance-traffic-plan.test.js +710 -0
  158. package/test/selinux.test.js +71 -0
  159. package/test/sops-secret-store.test.js +612 -0
  160. package/test/underpost-gateway.test.js +510 -0
  161. package/test/underpost-ingress.test.js +305 -0
  162. package/test/wireguard-edge.test.js +1177 -0
package/bin/index.js CHANGED
@@ -17,7 +17,7 @@ import { shellExec } from '../src/server/process.js';
17
17
  import { loggerFactory } from '../src/server/logger.js';
18
18
  import { generateBesuManifests, deployBesu, removeBesu } from '../src/projects/cyberia/besu-genesis-generator.js';
19
19
  import { DataBaseProviderService } from '../src/db/DataBaseProvider.js';
20
- import { loadConfServerJson } from '../src/server/conf.js';
20
+ import { etcHostFactory, loadConfServerJson, normalizeInstanceTopology } from '../src/server/conf.js';
21
21
  import {
22
22
  ObjectLayerEngine,
23
23
  resolveCanonicalCid,
@@ -51,6 +51,7 @@ import {
51
51
  } from '../src/api/cyberia-server-defaults/cyberia-server-defaults.js';
52
52
 
53
53
  import {
54
+ DEFAULT_INSTANCE_CODE,
54
55
  ITEM_TYPES as itemTypes,
55
56
  DefaultCyberiaItems,
56
57
  } from '../src/client/components/cyberia/SharedDefaultsCyberia.js';
@@ -95,6 +96,22 @@ async function connectDbForChain({ envPath, mongoHost }) {
95
96
  /** @type {Function} */
96
97
  const logger = loggerFactory(import.meta);
97
98
 
99
+ const CYBERIA_DOCKER_HOST_ALIASES = ['cyberia-client', 'cyberia-server', 'engine-cyberia'];
100
+
101
+ const installCyberiaDockerHostAliases = () => {
102
+ try {
103
+ const { changed } = etcHostFactory(CYBERIA_DOCKER_HOST_ALIASES, {
104
+ append: true,
105
+ blockId: 'dd-cyberia-docker-compose',
106
+ });
107
+ return { aliases: CYBERIA_DOCKER_HOST_ALIASES, changed };
108
+ } catch (error) {
109
+ if (error?.code === 'EACCES' || error?.code === 'EPERM')
110
+ throw new Error('Cannot update /etc/hosts for Cyberia Docker aliases. Re-run the workflow as root.');
111
+ throw error;
112
+ }
113
+ };
114
+
98
115
  try {
99
116
  const program = new Command();
100
117
 
@@ -219,7 +236,6 @@ try {
219
236
  deployId,
220
237
  host,
221
238
  path,
222
- db,
223
239
  });
224
240
 
225
241
  await DataBaseProviderService.load({
@@ -240,6 +256,17 @@ try {
240
256
  /** @type {import('mongoose').Model} */
241
257
  const Ipfs = DataBaseProviderService.getModel('ipfs', { host, path });
242
258
 
259
+ // Idempotent repair, run only before flows that write: collapses legacy
260
+ // duplicates and upgrades the data.item.id index to unique so every
261
+ // later write has exactly one document to land on. Read-only
262
+ // subcommands stay side-effect free — findByItemId already resolves the
263
+ // same canonical document whether or not duplicates are still present.
264
+ if (options.import || options.importTypes || options.drop || options.generate) {
265
+ const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
266
+ if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
267
+ if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
268
+ }
269
+
243
270
  if (options.drop) {
244
271
  // Parse comma-separated item IDs for targeted drop; if none provided, drop everything
245
272
  const dropItemIds = itemId
@@ -423,7 +450,7 @@ try {
423
450
  });
424
451
 
425
452
  // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
426
- const existingOL = await ObjectLayer.findOne({ 'data.item.id': currentItemId });
453
+ const existingOL = await ObjectLayer.findByItemId(currentItemId);
427
454
  let objectLayer;
428
455
 
429
456
  if (existingOL) {
@@ -441,7 +468,6 @@ try {
441
468
  let stagingFileDoc = null;
442
469
  let stagingAtlasDoc = null;
443
470
  let stagingCid = '';
444
- let stagingSha256 = '';
445
471
  try {
446
472
  const itemKey = currentItemId;
447
473
 
@@ -530,8 +556,7 @@ try {
530
556
  stagingData.render.cid = importItemCid;
531
557
  stagingData.render.metadataCid = importItemMetadataCid;
532
558
 
533
- // Pin data JSON to IPFS (compute final SHA-256 in memory)
534
- stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
559
+ // Pin data JSON to IPFS
535
560
  try {
536
561
  const ipfsDataResult = await IpfsClient.addJsonToIpfs(
537
562
  stagingData,
@@ -569,10 +594,9 @@ try {
569
594
  // Create the new RenderFrames doc (only now touches DB)
570
595
  const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
571
596
 
572
- // Single atomic update of the live document
573
- await ObjectLayer.findByIdAndUpdate(existingOL._id, {
597
+ // Single atomic upsert on the natural key; empty staged CIDs keep their stored value
598
+ objectLayer = await ObjectLayer.upsertByItemId({
574
599
  data: stagingData,
575
- sha256: stagingSha256,
576
600
  cid: stagingCid,
577
601
  objectLayerRenderFramesId: newRenderFrames._id,
578
602
  atlasSpriteSheetId: stagingAtlasDoc._id,
@@ -583,14 +607,13 @@ try {
583
607
  await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
584
608
  }
585
609
 
586
- logger.info(`[cut-over] Live document ${existingOL._id} updated atomically`);
610
+ logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
587
611
  } else {
588
612
  // Rollback: only File/AtlasSpriteSheet were written, clean those up
589
613
  if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
590
614
  logger.warn(`[cut-over] Staging rolled back for ${currentItemId}, live document preserved`);
615
+ objectLayer = await ObjectLayer.findByItemId(currentItemId);
591
616
  }
592
-
593
- objectLayer = await ObjectLayer.findById(existingOL._id);
594
617
  } else {
595
618
  // ── New item: stage everything before creating (same cut-over pattern) ──
596
619
  logger.info(`ObjectLayer '${currentItemId}' is new, staging creation...`);
@@ -605,7 +628,6 @@ try {
605
628
  let stagingFileDoc = null;
606
629
  let stagingAtlasDoc = null;
607
630
  let stagingCid = '';
608
- let stagingSha256 = '';
609
631
  try {
610
632
  const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
611
633
  objectLayerRenderFramesData,
@@ -689,7 +711,6 @@ try {
689
711
  stagingData.render.cid = importItemCid;
690
712
  stagingData.render.metadataCid = importItemMetadataCid;
691
713
 
692
- stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
693
714
  try {
694
715
  const ipfsDataResult = await IpfsClient.addJsonToIpfs(
695
716
  stagingData,
@@ -722,9 +743,8 @@ try {
722
743
 
723
744
  if (cutoverReady) {
724
745
  const newRenderFrames = await ObjectLayerRenderFrames.create(objectLayerRenderFramesData);
725
- objectLayer = await ObjectLayer.create({
746
+ objectLayer = await ObjectLayer.upsertByItemId({
726
747
  data: stagingData,
727
- sha256: stagingSha256,
728
748
  cid: stagingCid,
729
749
  objectLayerRenderFramesId: newRenderFrames._id,
730
750
  atlasSpriteSheetId: stagingAtlasDoc._id,
@@ -826,7 +846,7 @@ try {
826
846
 
827
847
  if (shouldGenerateAtlas) {
828
848
  // Check if an ObjectLayer with the same item.id already exists (upsert by item ID)
829
- const existingOL = await ObjectLayer.findOne({ 'data.item.id': objectLayerId });
849
+ const existingOL = await ObjectLayer.findByItemId(objectLayerId);
830
850
  let objectLayer;
831
851
 
832
852
  if (existingOL) {
@@ -844,7 +864,6 @@ try {
844
864
  let stagingFileDoc = null;
845
865
  let stagingAtlasDoc = null;
846
866
  let stagingCid = '';
847
- let stagingSha256 = '';
848
867
  try {
849
868
  const itemKey = objectLayerId;
850
869
 
@@ -932,8 +951,7 @@ try {
932
951
  stagingData.render.cid = importAtlasCid;
933
952
  stagingData.render.metadataCid = importAtlasMetadataCid;
934
953
 
935
- // Pin data JSON to IPFS (compute final SHA-256 in memory)
936
- stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
954
+ // Pin data JSON to IPFS
937
955
  try {
938
956
  const ipfsDataResult = await IpfsClient.addJsonToIpfs(
939
957
  stagingData,
@@ -969,9 +987,8 @@ try {
969
987
  const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
970
988
  const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
971
989
 
972
- await ObjectLayer.findByIdAndUpdate(existingOL._id, {
990
+ objectLayer = await ObjectLayer.upsertByItemId({
973
991
  data: stagingData,
974
- sha256: stagingSha256,
975
992
  cid: stagingCid,
976
993
  objectLayerRenderFramesId: newRenderFrames._id,
977
994
  atlasSpriteSheetId: stagingAtlasDoc._id,
@@ -980,13 +997,12 @@ try {
980
997
  if (oldRenderFramesId) {
981
998
  await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
982
999
  }
983
- logger.info(`[cut-over] Live document ${existingOL._id} updated atomically`);
1000
+ logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically`);
984
1001
  } else {
985
1002
  if (stagingFileDoc) await File.findByIdAndDelete(stagingFileDoc._id);
986
1003
  logger.warn(`[cut-over] Staging rolled back for ${objectLayerId}, live document preserved`);
1004
+ objectLayer = await ObjectLayer.findByItemId(objectLayerId);
987
1005
  }
988
-
989
- objectLayer = await ObjectLayer.findById(existingOL._id);
990
1006
  } else {
991
1007
  // ── New item: stage everything before creating (same cut-over pattern) ──
992
1008
  logger.info(`ObjectLayer '${objectLayerId}' is new, staging creation...`);
@@ -1001,7 +1017,6 @@ try {
1001
1017
  let stagingFileDoc = null;
1002
1018
  let stagingAtlasDoc = null;
1003
1019
  let stagingCid = '';
1004
- let stagingSha256 = '';
1005
1020
  try {
1006
1021
  const { buffer, metadata } = await AtlasSpriteSheetGenerator.generateAtlas(
1007
1022
  entry.objectLayerRenderFramesData,
@@ -1085,7 +1100,6 @@ try {
1085
1100
  stagingData.render.cid = importAtlasCid;
1086
1101
  stagingData.render.metadataCid = importAtlasMetadataCid;
1087
1102
 
1088
- stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1089
1103
  try {
1090
1104
  const ipfsDataResult = await IpfsClient.addJsonToIpfs(
1091
1105
  stagingData,
@@ -1118,9 +1132,8 @@ try {
1118
1132
 
1119
1133
  if (cutoverReady) {
1120
1134
  const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1121
- objectLayer = await ObjectLayer.create({
1135
+ objectLayer = await ObjectLayer.upsertByItemId({
1122
1136
  data: stagingData,
1123
- sha256: stagingSha256,
1124
1137
  cid: stagingCid,
1125
1138
  objectLayerRenderFramesId: newRenderFrames._id,
1126
1139
  atlasSpriteSheetId: stagingAtlasDoc._id,
@@ -1143,7 +1156,7 @@ try {
1143
1156
  if (existingItemIds.has(objectLayerId)) continue;
1144
1157
 
1145
1158
  // --import all: create documents without atlas generation
1146
- const existingOL = await ObjectLayer.findOne({ 'data.item.id': objectLayerId });
1159
+ const existingOL = await ObjectLayer.findByItemId(objectLayerId);
1147
1160
  let objectLayer;
1148
1161
 
1149
1162
  if (existingOL) {
@@ -1156,15 +1169,13 @@ try {
1156
1169
  if (!stagingData.render) stagingData.render = {};
1157
1170
  stagingData.render.cid = '';
1158
1171
  stagingData.render.metadataCid = '';
1159
- const stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1160
1172
 
1161
1173
  // Atomic cut-over: create new RenderFrames, swap live doc, delete old
1162
1174
  const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1163
1175
  const oldRenderFramesId = existingOL.objectLayerRenderFramesId;
1164
1176
 
1165
- await ObjectLayer.findByIdAndUpdate(existingOL._id, {
1177
+ objectLayer = await ObjectLayer.upsertByItemId({
1166
1178
  data: stagingData,
1167
- sha256: stagingSha256,
1168
1179
  objectLayerRenderFramesId: newRenderFrames._id,
1169
1180
  });
1170
1181
 
@@ -1172,20 +1183,17 @@ try {
1172
1183
  await ObjectLayerRenderFrames.findByIdAndDelete(oldRenderFramesId);
1173
1184
  }
1174
1185
 
1175
- objectLayer = await ObjectLayer.findById(existingOL._id);
1176
- logger.info(`[cut-over] Live document ${existingOL._id} updated atomically (atlas skipped)`);
1186
+ logger.info(`[cut-over] Live document ${objectLayer._id} updated atomically (atlas skipped)`);
1177
1187
  } else {
1178
- // New item: create with sha256 populated (no atlas for bulk import)
1188
+ // New item (no atlas for bulk import)
1179
1189
  const stagingData = JSON.parse(JSON.stringify(entry.data));
1180
1190
  if (!stagingData.render) stagingData.render = {};
1181
1191
  stagingData.render.cid = '';
1182
1192
  stagingData.render.metadataCid = '';
1183
- const stagingSha256 = ObjectLayerEngine.computeSha256(stagingData);
1184
1193
 
1185
1194
  const newRenderFrames = await ObjectLayerRenderFrames.create(entry.objectLayerRenderFramesData);
1186
- objectLayer = await ObjectLayer.create({
1195
+ objectLayer = await ObjectLayer.upsertByItemId({
1187
1196
  data: stagingData,
1188
- sha256: stagingSha256,
1189
1197
  objectLayerRenderFramesId: newRenderFrames._id,
1190
1198
  });
1191
1199
  }
@@ -1215,9 +1223,7 @@ try {
1215
1223
  logger.info(`Showing frame for item: ${itemId}, direction: ${direction}, frame: ${frameIndexNum}`);
1216
1224
 
1217
1225
  // Find ObjectLayer by item-id
1218
- const objectLayer = await ObjectLayer.findOne({ 'data.item.id': itemId }).populate(
1219
- 'objectLayerRenderFramesId',
1220
- );
1226
+ const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1221
1227
 
1222
1228
  if (!objectLayer) {
1223
1229
  logger.error(`ObjectLayer not found for item-id: ${itemId}`);
@@ -1301,9 +1307,7 @@ try {
1301
1307
  }
1302
1308
 
1303
1309
  // Find ObjectLayer by item-id
1304
- const objectLayer = await ObjectLayer.findOne({ 'data.item.id': itemId }).populate(
1305
- 'objectLayerRenderFramesId',
1306
- );
1310
+ const objectLayer = await ObjectLayer.findByItemId(itemId).populate('objectLayerRenderFramesId');
1307
1311
 
1308
1312
  if (!objectLayer) {
1309
1313
  logger.error(`ObjectLayer not found for item-id: ${itemId}`);
@@ -1437,7 +1441,7 @@ try {
1437
1441
  logger.info(`Looking up atlas sprite sheet for item: ${itemId}`);
1438
1442
 
1439
1443
  // Find ObjectLayer by item-id
1440
- const objectLayer = await ObjectLayer.findOne({ 'data.item.id': itemId });
1444
+ const objectLayer = await ObjectLayer.findByItemId(itemId);
1441
1445
 
1442
1446
  if (!objectLayer) {
1443
1447
  logger.error(`ObjectLayer not found for item-id: ${itemId}`);
@@ -1954,7 +1958,7 @@ try {
1954
1958
  ? db.host
1955
1959
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
1956
1960
 
1957
- logger.info('instance env', { env: options.envPath, deployId, host, path, db });
1961
+ logger.info('instance env', { env: options.envPath, deployId, host, path });
1958
1962
 
1959
1963
  await DataBaseProviderService.load({
1960
1964
  apis: [
@@ -2679,6 +2683,12 @@ try {
2679
2683
 
2680
2684
  logger.info('Importing instance', { code: instanceCode, backupDir });
2681
2685
 
2686
+ // Idempotent: a backup restore writes object layers, so collapse any
2687
+ // legacy duplicates and make the data.item.id index unique first.
2688
+ const { removedIds, indexUpgraded } = await ObjectLayer.ensureUniqueItemIdIndex();
2689
+ if (removedIds.length > 0) logger.warn(`Removed ${removedIds.length} duplicate ObjectLayer document(s)`);
2690
+ if (indexUpgraded) logger.info('Upgraded data.item.id index to unique');
2691
+
2682
2692
  // Item ids belonging to this instance (collected from imported object
2683
2693
  // layers + the instance doc) — used to backfill missing skills from the
2684
2694
  // canonical DefaultSkillConfig when the backup predates the skill model.
@@ -3013,12 +3023,21 @@ try {
3013
3023
  let olCount = 0;
3014
3024
  for (const file of olFiles) {
3015
3025
  const olData = fs.readJsonSync(`${olDir}/${file}`);
3016
- await ObjectLayer.deleteOne({ _id: olData._id });
3017
- if (olData.sha256) {
3018
- await ObjectLayer.deleteOne({ sha256: olData.sha256 });
3026
+ const olItemId = olData.data?.item?.id;
3027
+ if (!olItemId) {
3028
+ logger.warn(`Skipping object-layer file '${file}' missing data.item.id`);
3029
+ continue;
3019
3030
  }
3020
- await ObjectLayer.create(olData);
3021
- if (olData.data?.item?.id) importedItemIds.add(olData.data.item.id);
3031
+ // preserveUUID only applies to items this database does not have yet.
3032
+ // An item that already exists keeps its own _id and simply absorbs the
3033
+ // backup's attribute values, so a restore can never fork data.item.id
3034
+ // into two documents. sha256 needs no separate cleanup: it is derived
3035
+ // from data, so an identical hash implies the same item id.
3036
+ const existingOL = await ObjectLayer.findByItemId(olItemId);
3037
+ if (!existingOL && olData._id) await ObjectLayer.deleteOne({ _id: olData._id });
3038
+
3039
+ await ObjectLayer.upsertByItemId(olData);
3040
+ importedItemIds.add(olItemId);
3022
3041
  olCount++;
3023
3042
  }
3024
3043
  logger.info(`Imported ${olCount} ObjectLayer document(s)`);
@@ -3892,7 +3911,7 @@ try {
3892
3911
  ? db.host
3893
3912
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
3894
3913
 
3895
- logger.info('generate-saga', { deployId, host, path, db });
3914
+ logger.info('generate-saga', { deployId, host, path });
3896
3915
 
3897
3916
  await DataBaseProviderService.load({
3898
3917
  apis: [
@@ -4835,6 +4854,7 @@ try {
4835
4854
  '--clean',
4836
4855
  'Restore repositories to canonical state (git checkout Dockerfile, etc.) before updating compose.env',
4837
4856
  )
4857
+ .option('--test', 'Test DNS connectivity accross cyberia deployments')
4838
4858
  .option('--reset', 'Reset the development environment before updating compose.env')
4839
4859
  .action((options) => {
4840
4860
  if (options.reset) {
@@ -4848,6 +4868,25 @@ try {
4848
4868
  shellExec(`node bin run clean ./cyberia-client`);
4849
4869
  return;
4850
4870
  }
4871
+ if (options.test) {
4872
+ const testHosts = [
4873
+ 'localhost',
4874
+ 'localhost:4005',
4875
+ 'localhost:8081',
4876
+ 'localhost:8082',
4877
+ 'engine-cyberia',
4878
+ 'cyberia-server',
4879
+ 'cyberia-client',
4880
+ ];
4881
+ const testPaths = ['/', '/TEST', '/FOREST'];
4882
+ for (const host of testHosts) {
4883
+ for (const path of testPaths) {
4884
+ shellExec(`curl -L -v -i -s http://${host}${path} | head -n 10`, {
4885
+ silentOnError: true,
4886
+ });
4887
+ }
4888
+ }
4889
+ }
4851
4890
  const envPath = `./engine-private/conf/dd-cyberia/docker-compose/cyberia/compose.env`;
4852
4891
  const canonicalDevDockerfile = './src/runtime/engine-cyberia/Dockerfile.dev';
4853
4892
  fs.writeFileSync(
@@ -4930,7 +4969,7 @@ try {
4930
4969
  ? db.host
4931
4970
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
4932
4971
 
4933
- logger.info('drop-db', { deployId, host, path, db });
4972
+ logger.info('drop-db', { deployId, host, path });
4934
4973
 
4935
4974
  const cyberiaCollections = [
4936
4975
  'cyberia-entity',
@@ -4990,6 +5029,19 @@ try {
4990
5029
  shellExec(`gh workflow run ${id}.cd.yml -R underpostnet/${id} -f job=deploy`);
4991
5030
  });
4992
5031
 
5032
+ runner
5033
+ .command('test')
5034
+ .option('--n-con <connections>', 'Number of concurrent WebSocket connections')
5035
+ .option('--duration <ms>', 'Load test duration in milliseconds')
5036
+ .option('--tap-freq <seconds>', 'Frequency of tap events in seconds')
5037
+ .action((options) => {
5038
+ shellExec(`CYBERIA_LOAD_WS_URL=ws://localhost:8081/ws \
5039
+ CYBERIA_LOAD_CONNECTIONS=${options.nCon ?? 40} \
5040
+ CYBERIA_LOAD_TAP_FREQUENCY=${options.tapFreq ?? 5} \
5041
+ CYBERIA_LOAD_DURATION_MS=${options.duration ?? 1000 * 60 * 30} \
5042
+ c8 mocha test/cyberia-load.test.js`);
5043
+ });
5044
+
4993
5045
  runner
4994
5046
  .command('docker-image [id]')
4995
5047
  .option('--load-tar', 'Load a pre-built image tar archive into the enabled target(s) without building.')
@@ -4997,7 +5049,7 @@ try {
4997
5049
  // no funca
4998
5050
  if (options.loadTar) {
4999
5051
  for (const imageId of dockerImageIds)
5000
- if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.2.80.tar`);
5052
+ if (imageId === id || id === '.') shellExec(`docker load -i ./${imageId}-dev_v3.3.0.tar`);
5001
5053
  return;
5002
5054
  }
5003
5055
  switch (id) {
@@ -5008,7 +5060,7 @@ node bin/build dd-cyberia --update-private
5008
5060
  node bin image --path src/runtime/engine-cyberia \
5009
5061
  --docker-compose --pull-base --build \
5010
5062
  --dockerfile-name Dockerfile.dev \
5011
- --image-name engine-cyberia-dev:v3.2.80 \
5063
+ --image-name engine-cyberia-dev:v3.3.0 \
5012
5064
  --image-out-path .
5013
5065
  `);
5014
5066
  break;
@@ -5019,7 +5071,7 @@ cp -f src/runtime/cyberia-server/Dockerfile.dev cyberia-server/Dockerfile.dev
5019
5071
  node bin image --path cyberia-server \
5020
5072
  --docker-compose --pull-base --build \
5021
5073
  --dockerfile-name Dockerfile.dev \
5022
- --image-name cyberia-server-dev:v3.2.80 \
5074
+ --image-name cyberia-server-dev:v3.3.0 \
5023
5075
  --image-out-path .
5024
5076
  `);
5025
5077
  break;
@@ -5029,7 +5081,7 @@ cp -f src/runtime/cyberia-client/Dockerfile.dev cyberia-client/Dockerfile.dev
5029
5081
  node bin image --path cyberia-client \
5030
5082
  --docker-compose --pull-base --build \
5031
5083
  --dockerfile-name Dockerfile.dev \
5032
- --image-name cyberia-client-dev:v3.2.80 \
5084
+ --image-name cyberia-client-dev:v3.3.0 \
5033
5085
  --image-out-path .
5034
5086
  `);
5035
5087
  break;
@@ -5038,6 +5090,10 @@ node bin image --path cyberia-client \
5038
5090
 
5039
5091
  for (const [cmd, action] of Object.entries(DOCKER_SCRIPTS))
5040
5092
  runner.command(cmd).action(() => {
5093
+ if (cmd === 'docker:up' || cmd === 'docker:up:build' || cmd === 'docker:restart') {
5094
+ const { aliases, changed } = installCyberiaDockerHostAliases();
5095
+ logger.info(`Docker host aliases ${changed ? 'installed' : 'already configured'}`, { aliases });
5096
+ }
5041
5097
  shellExec(action);
5042
5098
  });
5043
5099
 
@@ -5074,7 +5130,7 @@ node bin image --path cyberia-client \
5074
5130
  ? db.host
5075
5131
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
5076
5132
 
5077
- logger.info('seed-dialogues', { deployId, host, path, db });
5133
+ logger.info('seed-dialogues', { deployId, host, path });
5078
5134
 
5079
5135
  await DataBaseProviderService.load({ apis: ['cyberia-dialogue'], host, path, db });
5080
5136
 
@@ -5129,7 +5185,7 @@ node bin image --path cyberia-client \
5129
5185
  ? db.host
5130
5186
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
5131
5187
 
5132
- logger.info('seed-actions-quests', { deployId, host, path, db });
5188
+ logger.info('seed-actions-quests', { deployId, host, path });
5133
5189
 
5134
5190
  await DataBaseProviderService.load({ apis: ['cyberia-action', 'cyberia-quest'], host, path, db });
5135
5191
 
@@ -5185,7 +5241,7 @@ node bin image --path cyberia-client \
5185
5241
  ? db.host
5186
5242
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
5187
5243
 
5188
- logger.info('seed-skills', { deployId, host, path, db });
5244
+ logger.info('seed-skills', { deployId, host, path });
5189
5245
 
5190
5246
  await DataBaseProviderService.load({ apis: ['cyberia-skill'], host, path, db });
5191
5247
 
@@ -5245,7 +5301,7 @@ node bin image --path cyberia-client \
5245
5301
  ? db.host
5246
5302
  : db.host.replace('127.0.0.1', 'mongodb-0.mongodb-service');
5247
5303
 
5248
- logger.info('seed-entities', { deployId, host, path, db });
5304
+ logger.info('seed-entities', { deployId, host, path });
5249
5305
 
5250
5306
  await DataBaseProviderService.load({ apis: ['cyberia-entity-type-default'], host, path, db });
5251
5307
 
@@ -5353,6 +5409,84 @@ node bin image --path cyberia-client \
5353
5409
  logger.info('All semantic examples generated.');
5354
5410
  });
5355
5411
 
5412
+ // Instance id → project root. Single source of truth for the workloads this
5413
+ // workflow builds: both the k8s manifests and the status page artifacts each
5414
+ // project ships are resolved from this list plus conf.instances.json.
5415
+ const CYBERIA_INSTANCE_PROJECTS = [
5416
+ { id: 'mmo-client', rootPath: './cyberia-client' },
5417
+ { id: 'mmo-server', rootPath: './cyberia-server' },
5418
+ ];
5419
+ const CYBERIA_CONF_INSTANCES_PATH = './engine-private/conf/dd-cyberia/conf.instances.json';
5420
+ const CYBERIA_CONF_SSR_PATH = './engine-private/conf/dd-cyberia/conf.ssr.json';
5421
+ // Copy shared by the server and the client for a given status code. A status
5422
+ // without an entry falls back to its conf.ssr.json view title.
5423
+ const CYBERIA_STATUS_PAGE_META = {
5424
+ 404: {
5425
+ title: 'Cyberia — Sector Not Found',
5426
+ description: 'Cyberia Online 404 — the requested sector is not on the grid.',
5427
+ },
5428
+ };
5429
+
5430
+ /**
5431
+ * Reads the raw (unexpanded) conf.instances.json entries.
5432
+ * @returns {Array<object>} Instance entries, or an empty list when unavailable.
5433
+ */
5434
+ const readCyberiaConfInstances = () => {
5435
+ try {
5436
+ return JSON.parse(fs.readFileSync(CYBERIA_CONF_INSTANCES_PATH, 'utf8'));
5437
+ } catch (err) {
5438
+ logger.warn(`Could not read ${CYBERIA_CONF_INSTANCES_PATH}: ${err.message}`);
5439
+ return [];
5440
+ }
5441
+ };
5442
+
5443
+ /**
5444
+ * Resolves the SSR view that renders a status page. The view is declared in
5445
+ * conf.ssr.json as a route whose path is the bare status code (`/404`), the
5446
+ * same declaration the PWA build turns into `/404/index.html`.
5447
+ * @param {string} status - HTTP status code.
5448
+ * @returns {{ client: string, title: string }|null} View descriptor, or null when undeclared.
5449
+ */
5450
+ const resolveStatusPageView = (status) => {
5451
+ if (!fs.existsSync(CYBERIA_CONF_SSR_PATH)) return null;
5452
+ const confSSR = JSON.parse(fs.readFileSync(CYBERIA_CONF_SSR_PATH, 'utf8'));
5453
+ for (const clientConf of Object.values(confSSR))
5454
+ for (const view of clientConf?.views || []) if (view.path === `/${status}`) return view;
5455
+ return null;
5456
+ };
5457
+
5458
+ /**
5459
+ * Renders one custom status page to a static HTML artifact. The workloads no
5460
+ * longer serve error pages themselves — the document is carried into the
5461
+ * gateway config by `run instance-build-manifest`, so this only has to place
5462
+ * it at the `hostPath` the instance declares.
5463
+ * @param {string} status - HTTP status code.
5464
+ * @param {string} outputPath - Destination HTML path.
5465
+ * @param {boolean} [dev] - Render the development variant.
5466
+ * @returns {boolean} True when the artifact was rendered.
5467
+ */
5468
+ const buildCyberiaStatusPage = ({ status, outputPath, dev = false }) => {
5469
+ const view = resolveStatusPageView(status);
5470
+ const pagePath = `./src/client/ssr/views/${view?.client || `Cyberia${status}`}.js`;
5471
+ if (!fs.existsSync(pagePath)) {
5472
+ logger.warn(`[build-status-page] No SSR view for status ${status}; skipping`, { pagePath, outputPath });
5473
+ return false;
5474
+ }
5475
+ const meta = CYBERIA_STATUS_PAGE_META[status] || {};
5476
+ const title = meta.title || view?.title || `Cyberia — ${status}`;
5477
+ const description = meta.description || `Cyberia Online ${status}.`;
5478
+ shellExec(
5479
+ `node bin static --page ${pagePath}` +
5480
+ ` --output-path ${outputPath}` +
5481
+ ` --title '${title}'` +
5482
+ ` --favicon /favicon.ico` +
5483
+ ` --description '${description}'` +
5484
+ ` --lang en` +
5485
+ ` --env ${dev ? 'development' : 'production'}`,
5486
+ );
5487
+ return true;
5488
+ };
5489
+
5356
5490
  runner
5357
5491
  .command('build-manifest')
5358
5492
  .option(
@@ -5385,24 +5519,24 @@ node bin image --path cyberia-client \
5385
5519
  // A variant declared in conf.instances.json without the instance
5386
5520
  // directory is silently skipped so the Dockerfile never tries to
5387
5521
  // copy a non-existent directory and the container build does not fail.
5388
- const confInstancesPath = './engine-private/conf/dd-cyberia/conf.instances.json';
5389
5522
  const cyberiaInstancesDir = '/home/dd/cyberia-instances';
5390
5523
  let instanceCodes = 'amethyst-strata-expansion,FOREST'; // fallback
5524
+ const confInstancesEntries = readCyberiaConfInstances();
5391
5525
  try {
5392
- const confInstances = JSON.parse(fs.readFileSync(confInstancesPath, 'utf8'));
5393
- const serverInstances = confInstances.filter((inst) => inst.runtime === 'cyberia-server');
5526
+ const serverInstances = confInstancesEntries.filter((inst) => inst.runtime === 'cyberia-server');
5394
5527
  const codes = new Set();
5395
5528
  for (const inst of serverInstances) {
5396
5529
  if (inst.multiInstance?.variants) {
5397
- for (const v of inst.multiInstance.variants) {
5398
- if (!v.code) continue;
5530
+ const topology = normalizeInstanceTopology(inst.multiInstance, `dd-cyberia/${inst.id}`);
5531
+ for (const v of topology.variants) {
5532
+ const code = v.path === '/' ? DEFAULT_INSTANCE_CODE : v.code;
5399
5533
  // Skip codes that have no on-disk instance backup dir
5400
- const instanceDir = `${cyberiaInstancesDir}/instances/${v.code}`;
5534
+ const instanceDir = `${cyberiaInstancesDir}/instances/${code}`;
5401
5535
  if (!fs.existsSync(instanceDir)) {
5402
- logger.info(`[build-manifest] Skipping code "${v.code}": no instance dir at ${instanceDir}`);
5536
+ logger.info(`[build-manifest] Skipping code "${code}": no instance dir at ${instanceDir}`);
5403
5537
  continue;
5404
5538
  }
5405
- codes.add(v.code);
5539
+ codes.add(code);
5406
5540
  }
5407
5541
  }
5408
5542
  }
@@ -5413,7 +5547,7 @@ node bin image --path cyberia-client \
5413
5547
  logger.warn(`[build-manifest] No valid instance codes found; keeping fallback: ${instanceCodes}`);
5414
5548
  }
5415
5549
  } catch (err) {
5416
- logger.warn(`[build-manifest] Could not read ${confInstancesPath}: ${err.message}; using fallback`);
5550
+ logger.warn(`[build-manifest] Could not read ${CYBERIA_CONF_INSTANCES_PATH}: ${err.message}; using fallback`);
5417
5551
  }
5418
5552
 
5419
5553
  // ── Update Dockerfile.dev + Dockerfile INSTANCE_CODES build arg ──────
@@ -5474,33 +5608,46 @@ node bin image --path cyberia-client \
5474
5608
  logger.warn(`[build-manifest] Could not update ${catalogPath}: ${err.message}`);
5475
5609
  }
5476
5610
 
5611
+ // ── Build SSR views ──────────────────────────────────────────────────
5612
+ // Status pages are rendered BEFORE the manifests: the gateway manifests
5613
+ // embed each document declared under an instance's `customStatusPages`,
5614
+ // so the artifact has to exist at its `hostPath` by manifest time.
5615
+ const statusPagesBuilt = [];
5616
+ for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS) {
5617
+ const instance = confInstancesEntries.find((entry) => entry.id === id);
5618
+ for (const page of instance?.customStatusPages || []) {
5619
+ if (!page?.status || !page?.hostPath) continue;
5620
+ const outputPath = nodePath.normalize(`${rootPath}/${page.hostPath}`);
5621
+ if (buildCyberiaStatusPage({ status: page.status, outputPath, dev: isDev }))
5622
+ statusPagesBuilt.push({ instance: id, status: page.status, outputPath });
5623
+ }
5624
+ }
5625
+ logger.info('[build-manifest] Custom status pages built', statusPagesBuilt);
5626
+ shellExec(
5627
+ `node bin/cyberia run-workflow build-server-dashboard --output-path ./cyberia-server/public/index.html`,
5628
+ );
5629
+
5477
5630
  // ── Build dev manifests (always --kind --dev) ────────────────────────
5478
5631
  {
5479
5632
  const flags = `--kind --dev${nodeFlag}`;
5480
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,mmo-client,./cyberia-client' ${flags}`);
5481
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,mmo-server,./cyberia-server' ${flags}`);
5633
+ for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5634
+ shellExec(`node bin run instance-build-manifest 'dd-cyberia,${id},${rootPath}' ${flags}`);
5482
5635
  }
5483
5636
  // ── Build prod manifests (--kubeadm, no --dev) ───────────────────────
5484
5637
  if (!isDev) {
5485
5638
  const flags = `--kubeadm${nodeFlag}`;
5486
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,mmo-client,./cyberia-client' ${flags}`);
5487
- shellExec(`node bin run instance-build-manifest 'dd-cyberia,mmo-server,./cyberia-server' ${flags}`);
5639
+ for (const { id, rootPath } of CYBERIA_INSTANCE_PROJECTS)
5640
+ shellExec(`node bin run instance-build-manifest 'dd-cyberia,${id},${rootPath}' ${flags}`);
5488
5641
  }
5489
5642
 
5490
- // ── Build SSR views ──────────────────────────────────────────────────
5491
- const env404 = isDev ? ' --dev' : '';
5492
- shellExec(
5493
- `node bin/cyberia run-workflow build-cyberia-404 --output-path ./cyberia-server/public/404.html${env404}`,
5494
- );
5495
- shellExec(`node bin/cyberia run-workflow build-cyberia-404 --output-path ./cyberia-client/bin/404.html${env404}`);
5496
- shellExec(
5497
- `node bin/cyberia run-workflow build-server-dashboard --output-path ./cyberia-server/public/index.html`,
5498
- );
5499
-
5500
5643
  // Copy canonical doc sources into the generated project READMEs.
5501
5644
  // Edit the canonical sources; never hand-edit these generated outputs.
5645
+ fs.copySync('./deploy/lib', './cyberia-server/deploy/lib', { overwrite: true });
5646
+ fs.copySync('./deploy/lib', './cyberia-client/deploy/lib', { overwrite: true });
5502
5647
  fs.copyFileSync('./src/client/public/cyberia-docs/CYBERIA-CLIENT.md', './cyberia-client/README.md');
5648
+ fs.copySync('./deploy/cyberia-client', './cyberia-client/deploy');
5503
5649
  fs.copyFileSync('./src/client/public/cyberia-docs/CYBERIA-SERVER.md', './cyberia-server/README.md');
5650
+ fs.copySync('./deploy/cyberia-server', './cyberia-server/deploy');
5504
5651
  fs.copyFileSync(
5505
5652
  './.github/workflows/cyberia-client.cd.yml',
5506
5653
  './cyberia-client/.github/workflows/cyberia-client.cd.yml',
@@ -5565,27 +5712,54 @@ node bin image --path cyberia-client \
5565
5712
  );
5566
5713
  });
5567
5714
 
5715
+ runner
5716
+ .command('build-status-page')
5717
+ .requiredOption(
5718
+ '--status <status>',
5719
+ 'HTTP status code to render (must be declared as an SSR view in conf.ssr.json).',
5720
+ )
5721
+ .option('--dev', 'Build a development variant of the status page.')
5722
+ .option(
5723
+ '--output-path <path>',
5724
+ 'Output path for the rendered HTML. Defaults to the `hostPath` the mmo-server instance declares for the status.',
5725
+ )
5726
+ .description(
5727
+ 'Build one custom status page artifact. The SSR view is resolved from the conf.ssr.json route whose path is ' +
5728
+ 'the bare status code; the rendered document is served by the gateway, not by the workload.',
5729
+ )
5730
+ .action((options) => {
5731
+ const status = `${options.status}`;
5732
+ const statusPage = (
5733
+ readCyberiaConfInstances().find((entry) => entry.id === 'mmo-server')?.customStatusPages || []
5734
+ ).find((page) => `${page.status}` === status);
5735
+ const outputPath =
5736
+ options.outputPath || (statusPage ? nodePath.normalize(`./cyberia-server/${statusPage.hostPath}`) : null);
5737
+ if (!outputPath) {
5738
+ logger.error(`[build-status-page] No --output-path and no customStatusPages entry for status ${status}`);
5739
+ return;
5740
+ }
5741
+ buildCyberiaStatusPage({ status, outputPath, dev: !!options.dev });
5742
+ });
5743
+
5568
5744
  runner
5569
5745
  .command('build-cyberia-404')
5570
5746
  .option('--dev', 'Build a development variant of the 404 page.')
5571
5747
  .option(
5572
5748
  '--output-path <path>',
5573
5749
  'Output path for the rendered 404.html (default: ./cyberia-server/public/404.html). ' +
5574
- 'The same page is served, sub-path aware, by every instance variant of both the ' +
5575
- 'cyberia-server (Go static server) and cyberia-client (docker-driver).',
5750
+ 'The same page is served, sub-path aware, by the gateway for every instance variant of both the ' +
5751
+ 'cyberia-server and cyberia-client workloads.',
5752
+ )
5753
+ .description(
5754
+ 'Build the cyberpunk pixel-art "sector not found" (404) page shared by the Cyberia server + client. ' +
5755
+ 'Thin alias of `build-status-page --status 404`, kept as the entrypoint the instance CI workflows call.',
5576
5756
  )
5577
- .description('Build the cyberpunk pixel-art "sector not found" (404) page shared by the Cyberia server + client.')
5578
5757
  .action((options) => {
5579
- const outputPath = options.outputPath || './cyberia-server/public/404.html';
5580
- shellExec(
5581
- `node bin static --page ./src/client/ssr/views/Cyberia404.js` +
5582
- ` --output-path ${outputPath}` +
5583
- ` --title 'Cyberia — Sector Not Found'` +
5584
- ` --favicon /favicon.ico` +
5585
- ` --description 'Cyberia Online 404 — the requested sector is not on the grid.'` +
5586
- ` --lang en` +
5587
- ` --env ${options.dev ? 'development' : 'production'}`,
5588
- );
5758
+ buildCyberiaStatusPage({
5759
+ status: '404',
5760
+ outputPath: options.outputPath || './cyberia-server/public/404.html',
5761
+ dev: !!options.dev,
5762
+ });
5589
5763
  });
5590
5764
 
5591
5765
  // Passthrough check: if the user invoked a command that is OWNED by the