cyberia 3.2.22 → 3.2.70

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 (185) hide show
  1. package/.env.example +127 -68
  2. package/.github/workflows/cyberia-client.cd.yml +40 -0
  3. package/.github/workflows/cyberia-server.cd.yml +40 -0
  4. package/.github/workflows/docker-image.cyberia-client.ci.yml +49 -0
  5. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +48 -0
  6. package/.github/workflows/docker-image.cyberia-server.ci.yml +69 -0
  7. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +69 -0
  8. package/.github/workflows/docker-image.engine-cyberia.ci.yml +52 -0
  9. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +52 -0
  10. package/.github/workflows/engine-cyberia.cd.yml +33 -24
  11. package/.github/workflows/engine-cyberia.ci.yml +13 -3
  12. package/.github/workflows/ghpkg.ci.yml +88 -1
  13. package/.github/workflows/gitlab.ci.yml +1 -1
  14. package/.github/workflows/hardhat.ci.yml +1 -1
  15. package/.github/workflows/npmpkg.ci.yml +10 -7
  16. package/.github/workflows/publish.ci.yml +2 -2
  17. package/.github/workflows/publish.cyberia.ci.yml +5 -16
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -8
  19. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  20. package/CHANGELOG.md +301 -1
  21. package/CLI-HELP.md +71 -6
  22. package/Dockerfile +141 -43
  23. package/Dockerfile.dev +143 -0
  24. package/Dockerfile.test +165 -0
  25. package/README.md +1 -1
  26. package/baremetal/commission-workflows.json +1 -0
  27. package/bin/build.js +31 -0
  28. package/bin/cyberia.js +1080 -184
  29. package/bin/deploy.js +2 -2
  30. package/bin/index.js +1080 -184
  31. package/bump.config.js +1 -0
  32. package/compose.env +131 -0
  33. package/conf.js +18 -1
  34. package/deployment.yaml +2 -2
  35. package/docker-compose.yml +316 -0
  36. package/hardhat/hardhat.config.js +2 -2
  37. package/hardhat/package-lock.json +620 -2041
  38. package/hardhat/package.json +7 -5
  39. package/hardhat/scripts/deployObjectLayerToken.js +18 -18
  40. package/hardhat/test/ObjectLayerToken.js +378 -274
  41. package/ipfs/configure-ipfs.sh +13 -0
  42. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  43. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  44. package/manifests/deployment/dd-cyberia-development/deployment.yaml +2 -2
  45. package/manifests/deployment/dd-cyberia-development/grpc-service.yaml +17 -0
  46. package/manifests/deployment/dd-cyberia-development/pv-pvc.yaml +32 -0
  47. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  48. package/mongodb/entrypoint.sh +76 -0
  49. package/nginx.conf +87 -0
  50. package/package.json +31 -19
  51. package/pv-pvc.yaml +32 -0
  52. package/scripts/disk-clean.sh +85 -60
  53. package/scripts/kubeadm-node-setup.sh +317 -0
  54. package/scripts/rocky-kickstart.sh +877 -185
  55. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  56. package/scripts/test-monitor.sh +3 -5
  57. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +43 -7
  58. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +17 -25
  59. package/src/api/cyberia-action/cyberia-action.controller.js +19 -0
  60. package/src/api/cyberia-action/cyberia-action.model.js +21 -29
  61. package/src/api/cyberia-action/cyberia-action.router.js +42 -7
  62. package/src/api/cyberia-action/cyberia-action.service.js +20 -4
  63. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +58 -57
  64. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +37 -6
  65. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +8 -2
  66. package/src/api/cyberia-entity/cyberia-entity.router.js +39 -7
  67. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +74 -0
  68. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +67 -0
  69. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +63 -0
  70. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +46 -0
  71. package/src/api/cyberia-instance/cyberia-fallback-world.js +62 -10
  72. package/src/api/cyberia-instance/cyberia-instance.model.js +32 -2
  73. package/src/api/cyberia-instance/cyberia-instance.router.js +6 -6
  74. package/src/api/cyberia-instance/cyberia-world-generator.js +116 -3
  75. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +3 -0
  76. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +39 -7
  77. package/src/api/cyberia-map/cyberia-map.router.js +21 -6
  78. package/src/api/cyberia-quest/cyberia-quest.controller.js +38 -0
  79. package/src/api/cyberia-quest/cyberia-quest.router.js +47 -7
  80. package/src/api/cyberia-quest/cyberia-quest.service.js +59 -4
  81. package/src/api/cyberia-saga/cyberia-saga.controller.js +74 -0
  82. package/src/api/cyberia-saga/cyberia-saga.model.js +59 -0
  83. package/src/api/cyberia-saga/cyberia-saga.router.js +63 -0
  84. package/src/api/cyberia-saga/cyberia-saga.service.js +42 -0
  85. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +551 -129
  86. package/src/api/cyberia-skill/cyberia-skill.controller.js +74 -0
  87. package/src/api/cyberia-skill/cyberia-skill.model.js +50 -0
  88. package/src/api/cyberia-skill/cyberia-skill.router.js +63 -0
  89. package/src/api/cyberia-skill/cyberia-skill.service.js +42 -0
  90. package/src/api/ipfs/ipfs.service.js +28 -15
  91. package/src/api/object-layer/object-layer.router.js +25 -15
  92. package/src/api/object-layer/object-layer.service.js +15 -20
  93. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +39 -7
  94. package/src/cli/baremetal.js +717 -16
  95. package/src/cli/cluster.js +80 -5
  96. package/src/cli/deploy.js +127 -11
  97. package/src/cli/docker-compose.js +648 -0
  98. package/src/cli/env.js +11 -2
  99. package/src/cli/fs.js +75 -30
  100. package/src/cli/image.js +129 -51
  101. package/src/cli/index.js +110 -6
  102. package/src/cli/kickstart.js +142 -20
  103. package/src/cli/release.js +46 -4
  104. package/src/cli/repository.js +238 -33
  105. package/src/cli/run.js +520 -114
  106. package/src/cli/secrets.js +82 -48
  107. package/src/cli/ssh.js +234 -0
  108. package/src/cli/static.js +2 -2
  109. package/src/client/components/cyberia/ActionEngineCyberia.js +1867 -0
  110. package/src/client/components/cyberia/EntityEngineCyberia.js +585 -0
  111. package/src/client/components/cyberia/InstanceEngineCyberia.js +219 -13
  112. package/src/client/components/cyberia/MapEngineCyberia.js +154 -71
  113. package/src/client/components/cyberia/ObjectLayerEngineModal.js +40 -63
  114. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +34 -20
  115. package/src/client/components/cyberia/SharedDefaultsCyberia.js +195 -4
  116. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +66 -0
  117. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +8 -0
  118. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +8 -0
  119. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +45 -27
  120. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +3 -3
  121. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +39 -22
  122. package/src/client/public/cyberia-docs/CYBERIA-LORE.md +88 -0
  123. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +394 -0
  124. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +8 -1
  125. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  126. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +5 -5
  127. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  128. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  129. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +99 -0
  130. package/src/client/services/cyberia-instance/cyberia-instance.management.js +2 -2
  131. package/src/client/services/cyberia-map/cyberia-map.management.js +2 -2
  132. package/src/client/services/cyberia-saga/cyberia-saga.service.js +99 -0
  133. package/src/client/services/cyberia-skill/cyberia-skill.service.js +99 -0
  134. package/src/client/services/object-layer/object-layer.management.js +6 -6
  135. package/src/{server → client-builder}/client-build-docs.js +15 -5
  136. package/src/{server → client-builder}/client-build-live.js +3 -3
  137. package/src/{server → client-builder}/client-build.js +25 -22
  138. package/src/{server → client-builder}/client-dev-server.js +3 -3
  139. package/src/{server → client-builder}/client-icons.js +2 -2
  140. package/src/{server → client-builder}/ssr.js +5 -5
  141. package/src/client.build.js +1 -3
  142. package/src/client.dev.js +1 -1
  143. package/src/db/mongo/MongoBootstrap.js +12 -12
  144. package/src/grpc/cyberia/grpc-server.js +255 -70
  145. package/src/index.js +12 -1
  146. package/src/mailer/EmailRender.js +1 -1
  147. package/src/{server → projects/cyberia}/atlas-sprite-sheet-generator.js +2 -2
  148. package/src/{server → projects/cyberia}/besu-genesis-generator.js +3 -3
  149. package/src/projects/cyberia/catalog-cyberia.js +81 -0
  150. package/src/projects/cyberia/gemini-client.js +175 -0
  151. package/src/projects/cyberia/generate-saga.js +2107 -0
  152. package/src/{server → projects/cyberia}/ipfs-client.js +2 -2
  153. package/src/{server → projects/cyberia}/object-layer.js +12 -108
  154. package/src/{server → projects/cyberia}/semantic-layer-generator-floor.js +1 -1
  155. package/src/{server → projects/cyberia}/semantic-layer-generator-resource.js +1 -1
  156. package/src/{server → projects/cyberia}/semantic-layer-generator-skin.js +1 -1
  157. package/src/{server → projects/cyberia}/semantic-layer-generator.js +2 -2
  158. package/src/{server → projects/cyberia}/shape-generator.js +2 -2
  159. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  160. package/src/runtime/cyberia-client/Dockerfile +27 -61
  161. package/src/runtime/cyberia-client/Dockerfile.dev +20 -12
  162. package/src/runtime/cyberia-server/Dockerfile +21 -20
  163. package/src/runtime/cyberia-server/Dockerfile.dev +17 -8
  164. package/src/runtime/engine-cyberia/Dockerfile +143 -0
  165. package/src/runtime/engine-cyberia/Dockerfile.dev +143 -0
  166. package/src/runtime/engine-cyberia/Dockerfile.test +165 -0
  167. package/src/runtime/engine-cyberia/compose.env +131 -0
  168. package/src/runtime/engine-cyberia/docker-compose.yml +316 -0
  169. package/src/runtime/engine-cyberia/ipfs/configure-ipfs.sh +13 -0
  170. package/src/runtime/engine-cyberia/mongodb/entrypoint.sh +76 -0
  171. package/src/runtime/engine-cyberia/nginx.conf +87 -0
  172. package/src/runtime/express/Express.js +2 -2
  173. package/src/runtime/nginx/Nginx.js +250 -0
  174. package/src/server/catalog.js +9 -14
  175. package/src/server/conf.js +3 -6
  176. package/src/server/runtime-status.js +18 -1
  177. package/src/server/start.js +12 -2
  178. package/src/server.js +6 -2
  179. package/test/cyberia-instance-conf-defaults.test.js +140 -0
  180. package/test/deploy-monitor.test.js +26 -10
  181. package/test/shape-generator.test.js +7 -1
  182. package/typedoc.dd-cyberia.json +3 -1
  183. package/typedoc.json +3 -1
  184. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  185. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -13,6 +13,58 @@ import { loggerFactory } from '../server/logger.js';
13
13
 
14
14
  const logger = loggerFactory(import.meta);
15
15
 
16
+ // Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
17
+ // application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
18
+ // overrides the container image's own and breaks coreutils/sudo resolution inside the pod
19
+ // ("rm: command not found"). Single source of truth for both container-env capture and the
20
+ // `underpost-config` secret built from an env file.
21
+ const RESERVED_ENV_KEYS = new Set([
22
+ 'HOME',
23
+ 'HOSTNAME',
24
+ 'PATH',
25
+ 'TERM',
26
+ 'SHLVL',
27
+ 'PWD',
28
+ '_',
29
+ 'LANG',
30
+ 'LANGUAGE',
31
+ 'LC_ALL',
32
+ 'container',
33
+ 'SHELL',
34
+ 'USER',
35
+ 'LOGNAME',
36
+ 'MAIL',
37
+ 'OLDPWD',
38
+ 'LESSOPEN',
39
+ 'LESSCLOSE',
40
+ 'LS_COLORS',
41
+ 'DISPLAY',
42
+ 'COLORTERM',
43
+ 'EDITOR',
44
+ 'VISUAL',
45
+ 'TERM_PROGRAM',
46
+ 'TERM_PROGRAM_VERSION',
47
+ 'SSH_AUTH_SOCK',
48
+ 'SSH_CLIENT',
49
+ 'SSH_CONNECTION',
50
+ 'SSH_TTY',
51
+ 'XDG_SESSION_ID',
52
+ 'XDG_RUNTIME_DIR',
53
+ 'XDG_DATA_DIRS',
54
+ 'XDG_CONFIG_DIRS',
55
+ 'DBUS_SESSION_BUS_ADDRESS',
56
+ 'GPG_AGENT_INFO',
57
+ 'WINDOWID',
58
+ 'DESKTOP_SESSION',
59
+ 'SESSION_MANAGER',
60
+ 'XAUTHORITY',
61
+ 'WAYLAND_DISPLAY',
62
+ 'which_declare',
63
+ ]);
64
+ const RESERVED_ENV_KEY_PREFIXES = ['KUBERNETES_', 'npm_', 'NODE_'];
65
+ const isReservedEnvKey = (key) =>
66
+ RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
67
+
16
68
  /**
17
69
  * @class UnderpostSecret
18
70
  * @description Manages the secrets of the application.
@@ -49,67 +101,49 @@ class UnderpostSecret {
49
101
  */
50
102
  createFromContainerEnv() {
51
103
  Underpost.env.clean();
52
- const systemKeys = new Set([
53
- 'HOME',
54
- 'HOSTNAME',
55
- 'PATH',
56
- 'TERM',
57
- 'SHLVL',
58
- 'PWD',
59
- '_',
60
- 'LANG',
61
- 'LANGUAGE',
62
- 'LC_ALL',
63
- 'container',
64
- 'SHELL',
65
- 'USER',
66
- 'LOGNAME',
67
- 'MAIL',
68
- 'OLDPWD',
69
- 'LESSOPEN',
70
- 'LESSCLOSE',
71
- 'LS_COLORS',
72
- 'DISPLAY',
73
- 'COLORTERM',
74
- 'EDITOR',
75
- 'VISUAL',
76
- 'TERM_PROGRAM',
77
- 'TERM_PROGRAM_VERSION',
78
- 'SSH_AUTH_SOCK',
79
- 'SSH_CLIENT',
80
- 'SSH_CONNECTION',
81
- 'SSH_TTY',
82
- 'XDG_SESSION_ID',
83
- 'XDG_RUNTIME_DIR',
84
- 'XDG_DATA_DIRS',
85
- 'XDG_CONFIG_DIRS',
86
- 'DBUS_SESSION_BUS_ADDRESS',
87
- 'GPG_AGENT_INFO',
88
- 'WINDOWID',
89
- 'DESKTOP_SESSION',
90
- 'SESSION_MANAGER',
91
- 'XAUTHORITY',
92
- 'WAYLAND_DISPLAY',
93
- 'which_declare',
94
- ]);
95
- const systemKeyPrefixes = ['KUBERNETES_', 'npm_', 'NODE_'];
96
104
  for (const [key, value] of Object.entries(process.env)) {
97
- if (systemKeys.has(key)) continue;
98
- if (systemKeyPrefixes.some((prefix) => key.startsWith(prefix))) continue;
105
+ if (isReservedEnvKey(key)) continue;
99
106
  Underpost.env.set(key, value);
100
107
  }
101
108
  },
102
109
  },
103
110
 
111
+ /**
112
+ * @method sanitizeSecretEnvFile
113
+ * @description Strips shell/runtime-critical and Kubernetes-injected keys (PATH, HOME, …) from
114
+ * raw `.env` file content so the resulting `underpost-config` secret can be safely injected via
115
+ * `envFrom` without clobbering the container image's own PATH. Blank lines and comments are
116
+ * preserved. Uses the same {@link RESERVED_ENV_KEYS} blocklist as container-env capture.
117
+ * @param {string} envFileContent - Raw contents of a `.env.<env>` file.
118
+ * @returns {string} Filtered env-file content.
119
+ * @memberof UnderpostSecret
120
+ */
121
+ sanitizeSecretEnvFile(envFileContent) {
122
+ return envFileContent
123
+ .split('\n')
124
+ .filter((line) => {
125
+ const trimmed = line.trimStart();
126
+ if (!trimmed || trimmed.startsWith('#')) return true;
127
+ const key = line.slice(0, line.indexOf('=')).trim();
128
+ return !key || !isReservedEnvKey(key);
129
+ })
130
+ .join('\n');
131
+ },
132
+
104
133
  /**
105
134
  * Removes all filesystem traces of secrets after deployment startup.
106
135
  * Centralizes the defense-in-depth cleanup performed
136
+ * @param {object} options - Options for cleaning the environment.
137
+ * @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
107
138
  * @memberof UnderpostSecret
108
139
  */
109
- globalSecretClean() {
140
+ globalSecretClean(options = { keepKeys: [] }) {
141
+ const { keepKeys } = options;
110
142
  loadConf('clean');
111
143
  Underpost.repo.cleanupPrivateEngineRepo();
112
- Underpost.env.clean();
144
+ Underpost.env.clean({
145
+ keepKeys: keepKeys.length > 0 ? keepKeys : ['container-status', 'start-container-status'],
146
+ });
113
147
  },
114
148
  };
115
149
  }
package/src/cli/ssh.js CHANGED
@@ -496,6 +496,50 @@ EOF`);
496
496
  if (options.status) shellExec('service sshd status');
497
497
  },
498
498
 
499
+ /**
500
+ * Synchronously copies a local directory to a remote host over key-only SSH,
501
+ * streaming it as a tar archive (no intermediate file) and fixing ownership.
502
+ * Mirrors the kind-node `tar | docker cp` provisioning pattern but targets a
503
+ * real node via SSH, so node-local hostPath volumes can be materialized on the
504
+ * node where the pod will actually run.
505
+ *
506
+ * Idempotent and re-runnable: `mkdir -p` + `tar -x` overwrite in place. Throws
507
+ * on any SSH/tar failure so an empty-volume deploy is never produced silently.
508
+ *
509
+ * @function copyDirToNode
510
+ * @memberof UnderpostSSH
511
+ * @param {object} params
512
+ * @param {string} params.host - Target host/IP (key-only SSH reachable).
513
+ * @param {string} params.localDir - Local source directory.
514
+ * @param {string} params.remoteDir - Destination directory on the node.
515
+ * @param {number} [params.port=22] - SSH port.
516
+ * @param {string} [params.user='root'] - SSH user (key-only).
517
+ * @param {string} [params.keyPath='./engine-private/deploy/id_rsa'] - Private key path.
518
+ * @param {string} [params.owner='1000:1000'] - chown target on the node (empty to skip).
519
+ * @param {string} [params.mode='755'] - chmod mode on the node (empty to skip).
520
+ * @returns {void}
521
+ */
522
+ copyDirToNode: ({
523
+ host,
524
+ localDir,
525
+ remoteDir,
526
+ port = 22,
527
+ user = 'root',
528
+ keyPath = './engine-private/deploy/id_rsa',
529
+ owner = '1000:1000',
530
+ mode = '755',
531
+ }) => {
532
+ if (!host) throw new Error('copyDirToNode requires a host');
533
+ if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
534
+ if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
535
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
536
+ const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
537
+ shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`);
538
+ shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`);
539
+ const fixups = `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
540
+ if (fixups) shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`);
541
+ },
542
+
499
543
  /**
500
544
  * Generic SSH remote command runner that SSH execution logic.
501
545
  * Executes arbitrary shell commands on a remote server via SSH with proper credential handling.
@@ -545,6 +589,196 @@ EOF
545
589
  return shellExec(sshScript, { stdout: true });
546
590
  },
547
591
 
592
+ /**
593
+ * Waits until a TCP SSH port becomes reachable on a host.
594
+ * @async
595
+ * @function waitForSshPort
596
+ * @memberof UnderpostSSH
597
+ * @param {object} params
598
+ * @param {string} params.host - Target host/IP.
599
+ * @param {number} [params.port=22] - SSH port.
600
+ * @param {number} [params.timeoutMs=600000] - Maximum wait window.
601
+ * @param {number} [params.intervalMs=3000] - Poll interval.
602
+ * @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
603
+ */
604
+ waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
605
+ const deadline = Date.now() + timeoutMs;
606
+ while (Date.now() < deadline) {
607
+ const probe = shellExec(
608
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
609
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
610
+ );
611
+ if (`${probe}`.trim() === 'open') return true;
612
+ await new Promise((r) => setTimeout(r, intervalMs));
613
+ }
614
+ logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
615
+ return false;
616
+ },
617
+
618
+ /**
619
+ * Waits until a host's SSH port stops accepting connections (e.g. while it
620
+ * reboots). Used to detect a reboot edge before waiting for the port to come
621
+ * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
622
+ * @async
623
+ * @function waitForSshPortClosed
624
+ * @memberof UnderpostSSH
625
+ * @param {object} params
626
+ * @param {string} params.host - Target host/IP.
627
+ * @param {number} [params.port=22] - SSH port.
628
+ * @param {number} [params.timeoutMs=180000] - Maximum wait window.
629
+ * @param {number} [params.intervalMs=3000] - Poll interval.
630
+ * @returns {Promise<boolean>} True once the port is closed, false on timeout.
631
+ */
632
+ waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
633
+ const deadline = Date.now() + timeoutMs;
634
+ while (Date.now() < deadline) {
635
+ const probe = shellExec(
636
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
637
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
638
+ );
639
+ if (`${probe}`.trim() === 'closed') return true;
640
+ await new Promise((r) => setTimeout(r, intervalMs));
641
+ }
642
+ return false;
643
+ },
644
+
645
+ /**
646
+ * Orchestrates a non-interactive, key-only SSH session against a freshly
647
+ * provisioned host: waits for the port, attempts key-based auth, runs a
648
+ * remote command batch, and returns a structured result. Used by the
649
+ * commissioning flow once the ephemeral runtime reports SSH readiness.
650
+ * @async
651
+ * @function sshExecBatch
652
+ * @memberof UnderpostSSH
653
+ * @param {object} params
654
+ * @param {string} params.host - Target host/IP.
655
+ * @param {string} params.command - Remote command batch to execute.
656
+ * @param {number} [params.port=22] - SSH port.
657
+ * @param {string} [params.user='root'] - SSH user (key-only).
658
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
659
+ * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
660
+ * @param {number} [params.retries=3] - Auth/exec retry attempts.
661
+ * @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
662
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
663
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
664
+ */
665
+ sshExecBatch: async ({
666
+ host,
667
+ command,
668
+ port = 22,
669
+ user = 'root',
670
+ keyPath = './engine-private/deploy/id_rsa',
671
+ connectTimeoutSec = 15,
672
+ retries = 3,
673
+ retryDelayMs = 5000,
674
+ waitForPortMs = 0,
675
+ }) => {
676
+ if (!host) throw new Error('sshExecBatch requires a host');
677
+ if (!command) throw new Error('sshExecBatch requires a command');
678
+
679
+ if (waitForPortMs > 0) {
680
+ const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs });
681
+ if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
682
+ }
683
+
684
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
685
+
686
+ const sshOpts = [
687
+ `-i ${keyPath}`,
688
+ `-o BatchMode=yes`,
689
+ `-o PreferredAuthentications=publickey`,
690
+ `-o PubkeyAuthentication=yes`,
691
+ `-o PasswordAuthentication=no`,
692
+ `-o StrictHostKeyChecking=no`,
693
+ `-o UserKnownHostsFile=/dev/null`,
694
+ `-o ConnectTimeout=${connectTimeoutSec}`,
695
+ // Tolerate a freshly-booted node whose network briefly flaps (e.g. while
696
+ // NetworkManager applies a static profile): retry the TCP connect and
697
+ // keep the session alive across short stalls.
698
+ `-o ConnectionAttempts=3`,
699
+ `-o ServerAliveInterval=10`,
700
+ `-o ServerAliveCountMax=6`,
701
+ `-p ${port}`,
702
+ ].join(' ');
703
+
704
+ let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
705
+ for (let attempt = 1; attempt <= retries; attempt++) {
706
+ const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
707
+ stdout: false,
708
+ silentOnError: true,
709
+ disableLog: true,
710
+ });
711
+ last = {
712
+ ok: result.code === 0,
713
+ code: result.code,
714
+ stdout: result.stdout || '',
715
+ stderr: result.stderr || '',
716
+ attempts: attempt,
717
+ };
718
+ if (last.ok) {
719
+ logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`);
720
+ return last;
721
+ }
722
+ logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, {
723
+ code: last.code,
724
+ stderr: last.stderr.slice(-400),
725
+ });
726
+ if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
727
+ }
728
+ return last;
729
+ },
730
+
731
+ /**
732
+ * Transfers a local script to a remote host and runs it over key-only SSH.
733
+ * The script is base64-encoded so no shell-quoting/escaping is needed, then
734
+ * decoded, made executable, and executed with the given arguments. Reuses
735
+ * sshExecBatch for the actual transport, retries, and structured result.
736
+ * @async
737
+ * @function sshRunScript
738
+ * @memberof UnderpostSSH
739
+ * @param {object} params
740
+ * @param {string} params.host - Target host/IP.
741
+ * @param {string} params.scriptPath - Local path to the script to run.
742
+ * @param {string} [params.args=''] - Arguments appended to the remote invocation.
743
+ * @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed.
744
+ * @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script.
745
+ * @param {number} [params.port=22] - SSH port.
746
+ * @param {string} [params.user='root'] - SSH user (key-only).
747
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
748
+ * @param {number} [params.retries=3] - Retry attempts.
749
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
750
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
751
+ */
752
+ sshRunScript: async ({
753
+ host,
754
+ scriptPath,
755
+ args = '',
756
+ env = {},
757
+ remotePath = '/tmp/underpost-remote-script.sh',
758
+ port = 22,
759
+ user = 'root',
760
+ keyPath = './engine-private/deploy/id_rsa',
761
+ retries = 3,
762
+ waitForPortMs = 0,
763
+ }) => {
764
+ if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`);
765
+ const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64');
766
+ // Inline env assignments (single-quote escaped) so secrets are exported for
767
+ // the remote run without appearing as logged CLI args.
768
+ const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
769
+ const envPrefix = Object.entries(env)
770
+ .filter(([, v]) => v !== undefined && v !== null && `${v}` !== '')
771
+ .map(([k, v]) => `${k}=${sq(v)}`)
772
+ .join(' ');
773
+ const command = [
774
+ 'set -e',
775
+ `echo '${b64}' | base64 -d > ${remotePath}`,
776
+ `chmod +x ${remotePath}`,
777
+ `${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`,
778
+ ].join('\n');
779
+ return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command });
780
+ },
781
+
548
782
  /**
549
783
  * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
550
784
  * @async
package/src/cli/static.js CHANGED
@@ -6,10 +6,10 @@
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
8
  import express from 'express';
9
- import { ssrFactory } from '../server/ssr.js';
9
+ import { ssrFactory } from '../client-builder/ssr.js';
10
10
  import { shellExec } from '../server/process.js';
11
11
  import Underpost from '../index.js';
12
- import { JSONweb } from '../server/client-formatted.js';
12
+ import { JSONweb } from '../client-builder/client-formatted.js';
13
13
  import { loggerFactory, loggerMiddleware } from '../server/logger.js';
14
14
  const logger = loggerFactory(import.meta);
15
15
  /**