cyberia 3.2.9 → 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 (184) hide show
  1. package/.github/workflows/engine-cyberia.cd.yml +7 -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 +10 -5
  5. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  6. package/.github/workflows/release.cd.yml +1 -0
  7. package/.vscode/extensions.json +9 -9
  8. package/.vscode/settings.json +20 -4
  9. package/CHANGELOG.md +363 -1
  10. package/CLI-HELP.md +975 -1061
  11. package/README.md +190 -348
  12. package/bin/build.js +102 -125
  13. package/bin/build.template.js +33 -0
  14. package/bin/cyberia.js +238 -56
  15. package/bin/deploy.js +16 -3
  16. package/bin/index.js +238 -56
  17. package/bump.config.js +26 -0
  18. package/conf.js +131 -24
  19. package/deployment.yaml +76 -2
  20. package/hardhat/package-lock.json +113 -144
  21. package/hardhat/package.json +4 -3
  22. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
  23. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  24. package/manifests/deployment/dd-cyberia-development/deployment.yaml +76 -2
  25. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  26. package/manifests/kind-config-dev.yaml +8 -0
  27. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  28. package/manifests/mongodb/pv-pvc.yaml +44 -8
  29. package/manifests/mongodb/statefulset.yaml +55 -68
  30. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  31. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  32. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  33. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  34. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  35. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  36. package/manifests/valkey/statefulset.yaml +1 -1
  37. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  38. package/package.json +31 -19
  39. package/scripts/ipxe-setup.sh +52 -49
  40. package/scripts/k3s-node-setup.sh +81 -46
  41. package/scripts/link-local-underpost-cli.sh +6 -0
  42. package/scripts/lxd-vm-setup.sh +193 -8
  43. package/scripts/maas-nat-firewalld.sh +145 -0
  44. package/scripts/test-monitor.sh +250 -0
  45. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +38 -33
  46. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +16 -16
  47. package/src/api/core/core.router.js +19 -14
  48. package/src/api/core/core.service.js +5 -5
  49. package/src/api/crypto/crypto.router.js +18 -12
  50. package/src/api/crypto/crypto.service.js +3 -3
  51. package/src/api/cyberia-action/cyberia-action.model.js +1 -1
  52. package/src/api/cyberia-action/cyberia-action.router.js +22 -18
  53. package/src/api/cyberia-action/cyberia-action.service.js +5 -5
  54. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +74 -0
  55. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +99 -0
  56. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +98 -0
  57. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +152 -0
  58. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +25 -20
  59. package/src/api/cyberia-dialogue/cyberia-dialogue.service.js +6 -6
  60. package/src/api/cyberia-entity/cyberia-entity.router.js +22 -18
  61. package/src/api/cyberia-entity/cyberia-entity.service.js +5 -5
  62. package/src/api/cyberia-instance/cyberia-fallback-world.js +79 -4
  63. package/src/api/cyberia-instance/cyberia-instance.router.js +57 -52
  64. package/src/api/cyberia-instance/cyberia-instance.service.js +10 -10
  65. package/src/api/cyberia-instance/cyberia-world-generator.js +3 -3
  66. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +14 -48
  67. package/src/api/cyberia-instance-conf/cyberia-instance-conf.router.js +22 -18
  68. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +5 -5
  69. package/src/api/cyberia-map/cyberia-map.router.js +35 -30
  70. package/src/api/cyberia-map/cyberia-map.service.js +7 -7
  71. package/src/api/cyberia-quest/cyberia-quest.model.js +1 -1
  72. package/src/api/cyberia-quest/cyberia-quest.router.js +22 -18
  73. package/src/api/cyberia-quest/cyberia-quest.service.js +5 -5
  74. package/src/api/cyberia-quest-progress/cyberia-quest-progress.router.js +22 -18
  75. package/src/api/cyberia-quest-progress/cyberia-quest-progress.service.js +5 -5
  76. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +458 -0
  77. package/src/api/default/default.router.js +22 -18
  78. package/src/api/default/default.service.js +5 -5
  79. package/src/api/document/document.router.js +28 -23
  80. package/src/api/document/document.service.js +100 -23
  81. package/src/api/file/file.router.js +19 -13
  82. package/src/api/file/file.service.js +9 -7
  83. package/src/api/instance/instance.router.js +29 -24
  84. package/src/api/instance/instance.service.js +6 -6
  85. package/src/api/ipfs/ipfs.router.js +21 -16
  86. package/src/api/ipfs/ipfs.service.js +8 -8
  87. package/src/api/object-layer/object-layer.router.js +512 -507
  88. package/src/api/object-layer/object-layer.service.js +17 -14
  89. package/src/api/object-layer-render-frames/object-layer-render-frames.router.js +22 -18
  90. package/src/api/object-layer-render-frames/object-layer-render-frames.service.js +5 -5
  91. package/src/api/test/test.router.js +17 -12
  92. package/src/api/types.js +24 -0
  93. package/src/api/user/guest.service.js +5 -4
  94. package/src/api/user/user.router.js +297 -288
  95. package/src/api/user/user.service.js +100 -35
  96. package/src/cli/baremetal.js +132 -101
  97. package/src/cli/cluster.js +700 -232
  98. package/src/cli/db.js +59 -60
  99. package/src/cli/deploy.js +291 -294
  100. package/src/cli/env.js +1 -4
  101. package/src/cli/fs.js +13 -3
  102. package/src/cli/image.js +58 -4
  103. package/src/cli/index.js +127 -15
  104. package/src/cli/ipfs.js +4 -6
  105. package/src/cli/kubectl.js +4 -1
  106. package/src/cli/lxd.js +1099 -223
  107. package/src/cli/monitor.js +396 -9
  108. package/src/cli/release.js +355 -146
  109. package/src/cli/repository.js +169 -30
  110. package/src/cli/run.js +347 -117
  111. package/src/cli/secrets.js +11 -2
  112. package/src/cli/test.js +9 -3
  113. package/src/client/Default.index.js +9 -3
  114. package/src/client/components/core/Auth.js +5 -0
  115. package/src/client/components/core/ClientEvents.js +76 -0
  116. package/src/client/components/core/EventBus.js +4 -0
  117. package/src/client/components/core/Modal.js +82 -41
  118. package/src/client/components/core/PanelForm.js +14 -10
  119. package/src/client/components/core/Worker.js +162 -363
  120. package/src/client/components/cyberia/MapEngineCyberia.js +1 -1
  121. package/src/client/components/cyberia/SharedDefaultsCyberia.js +330 -0
  122. package/src/client/public/cyberia-docs/ACTION-SYSTEM.md +55 -1
  123. package/src/client/public/cyberia-docs/ARCHITECTURE.md +223 -361
  124. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +114 -327
  125. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +200 -222
  126. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +212 -185
  127. package/src/client/public/cyberia-docs/CYBERIA.md +259 -0
  128. package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +2 -2
  129. package/src/client/public/cyberia-docs/QUEST-SYSTEM.md +23 -1
  130. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  131. package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +106 -0
  132. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  133. package/src/client/services/cyberia-client-hints/cyberia-client-hints.service.js +99 -0
  134. package/src/client/ssr/views/CyberiaServerMetrics.js +982 -0
  135. package/src/client/sw/core.sw.js +174 -112
  136. package/src/db/DataBaseProvider.js +115 -15
  137. package/src/db/mariadb/MariaDB.js +2 -1
  138. package/src/db/mongo/MongoBootstrap.js +657 -0
  139. package/src/db/mongo/MongooseDB.js +130 -21
  140. package/src/grpc/cyberia/grpc-server.js +25 -57
  141. package/src/index.js +1 -1
  142. package/src/runtime/cyberia-client/Dockerfile +10 -7
  143. package/src/runtime/cyberia-client/Dockerfile.dev +67 -0
  144. package/src/runtime/cyberia-server/Dockerfile +11 -6
  145. package/src/runtime/cyberia-server/Dockerfile.dev +47 -0
  146. package/src/runtime/express/Express.js +2 -2
  147. package/src/runtime/wp/Dockerfile +3 -3
  148. package/src/runtime/wp/Wp.js +8 -5
  149. package/src/server/auth.js +2 -2
  150. package/src/server/catalog-underpost.js +61 -0
  151. package/src/server/catalog.js +77 -0
  152. package/src/server/client-build-docs.js +1 -1
  153. package/src/server/client-build.js +94 -129
  154. package/src/server/conf.js +496 -135
  155. package/src/server/ipfs-client.js +5 -3
  156. package/src/server/process.js +180 -19
  157. package/src/server/proxy.js +9 -2
  158. package/src/server/runtime-status.js +235 -0
  159. package/src/server/runtime.js +1 -1
  160. package/src/server/start.js +44 -11
  161. package/src/server/valkey.js +2 -0
  162. package/src/ws/IoInterface.js +16 -16
  163. package/src/ws/core/channels/core.ws.chat.js +11 -11
  164. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  165. package/src/ws/core/channels/core.ws.stream.js +19 -19
  166. package/src/ws/core/core.ws.connection.js +8 -8
  167. package/src/ws/core/core.ws.server.js +6 -5
  168. package/src/ws/default/channels/default.ws.main.js +10 -10
  169. package/src/ws/default/default.ws.connection.js +4 -4
  170. package/src/ws/default/default.ws.server.js +4 -3
  171. package/test/deploy-monitor.test.js +251 -0
  172. package/bin/file.js +0 -202
  173. package/bin/vs.js +0 -74
  174. package/bin/zed.js +0 -84
  175. package/manifests/deployment/dd-test-development/deployment.yaml +0 -254
  176. package/manifests/deployment/dd-test-development/proxy.yaml +0 -102
  177. package/src/api/cyberia-instance-conf/cyberia-instance-conf.defaults.js +0 -574
  178. package/src/client/components/cyberia-portal/CommonCyberiaPortal.js +0 -467
  179. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  180. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  181. package/src/client/ssr/pages/CyberiaServerMetrics.js +0 -461
  182. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  183. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  184. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import stringify from 'fast-json-stable-stringify';
14
14
  import { loggerFactory } from './logger.js';
15
+ import Underpost from '../index.js';
15
16
  const logger = loggerFactory(import.meta);
16
17
  const DEFAULT_IPFS_HTTP_TIMEOUT_MS = Number(process.env.IPFS_HTTP_TIMEOUT_MS || 10000);
17
18
  const getRequestTimeoutMs = (kind = 'kubo') => {
@@ -46,21 +47,22 @@ const fetchWithTimeout = async (url, options = {}, { kind = 'kubo', label = url
46
47
  * @returns {string}
47
48
  */
48
49
  const getIpfsApiUrl = () =>
49
- process.env.IPFS_API_URL || `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:5001`;
50
+ process.env.IPFS_API_URL ||
51
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:5001`;
50
52
  /**
51
53
  * Base URL of the IPFS Cluster REST API (port 9094).
52
54
  * @returns {string}
53
55
  */
54
56
  const getClusterApiUrl = () =>
55
57
  process.env.IPFS_CLUSTER_API_URL ||
56
- `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:9094`;
58
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:9094`;
57
59
  /**
58
60
  * Base URL of the IPFS HTTP Gateway (port 8080).
59
61
  * @returns {string}
60
62
  */
61
63
  const getGatewayUrl = () =>
62
64
  process.env.IPFS_GATEWAY_URL ||
63
- `http://${process.env.NODE_ENV === 'development' ? 'localhost' : 'ipfs-cluster'}:8080`;
65
+ `http://${process.env.NODE_ENV === 'development' && !Underpost.env.isInsideContainer() ? 'localhost' : 'ipfs-cluster'}:8080`;
64
66
  // ─────────────────────────────────────────────────────────
65
67
  // Core: add content
66
68
  // ─────────────────────────────────────────────────────────
@@ -1,6 +1,22 @@
1
1
  /**
2
2
  * Module for process and shell command management.
3
- * Provides utilities for executing shell commands, managing signals, and handling environment details.
3
+ * Provides utilities for executing shell commands, managing signals, and
4
+ * handling environment details.
5
+ *
6
+ * Execution semantics:
7
+ * - `shellExec(cmd)` throws `ShellExecError` on non-zero exit (fail-fast
8
+ * is the default). CI/CD chains observe the failure end-to-end.
9
+ * - `shellExec(cmd, { silentOnError: true })` opts out — returns the
10
+ * `ShellString` result with `.code/.stdout/.stderr` so callers can
11
+ * branch on the exit code themselves. Use for existence checks
12
+ * (`test -x …`, `command -v …`, `kubectl get` when "missing" is a
13
+ * normal answer).
14
+ * - `shellExec(cmd, { cwd: "..." })` runs hermetically in `cwd` without
15
+ * touching shelljs's global state.
16
+ * - All children spawned by `shellExec` register in
17
+ * `ProcessController.children` so SIGINT/SIGTERM forwarding can reach
18
+ * them before the parent exits.
19
+ *
4
20
  * @module src/server/process.js
5
21
  * @namespace Process
6
22
  */
@@ -19,6 +35,12 @@ const logger = loggerFactory(import.meta);
19
35
  const getRootDirectory = () => process.cwd().replace(/\\/g, '/');
20
36
  /**
21
37
  * Controls and manages process-level events and signals.
38
+ *
39
+ * Subprocess registry: any child process tracked here will receive
40
+ * SIGTERM (followed by SIGKILL after a short grace period) when the
41
+ * parent receives SIGINT or SIGTERM. This prevents orphaned children
42
+ * during Ctrl+C in dev and during pod-termination in K8S.
43
+ *
22
44
  * @namespace ProcessController
23
45
  */
24
46
  class ProcessController {
@@ -41,9 +63,41 @@ class ProcessController {
41
63
  'SIGSEGV',
42
64
  'SIGILL',
43
65
  ];
66
+
67
+ /**
68
+ * Registry of currently running tracked child processes.
69
+ * Populated when callers spawn via the streaming Node-native path
70
+ * (future expansion). The sets are exposed so signal handlers and
71
+ * test harnesses can introspect / clean up the registry.
72
+ */
73
+ static children = new Set();
74
+
75
+ /** Internal: forward terminating signals to all tracked children. */
76
+ static _forwardToChildren(sig) {
77
+ if (ProcessController.children.size === 0) return;
78
+ for (const child of [...ProcessController.children]) {
79
+ try {
80
+ if (!child.killed) child.kill(sig);
81
+ } catch (_) {
82
+ // child may already have exited; ignore.
83
+ }
84
+ }
85
+ // Hard SIGKILL after 5s grace if any child is still alive.
86
+ setTimeout(() => {
87
+ for (const child of [...ProcessController.children]) {
88
+ try {
89
+ if (!child.killed) child.kill('SIGKILL');
90
+ } catch (_) {
91
+ /* noop */
92
+ }
93
+ }
94
+ }, 5000).unref();
95
+ }
96
+
44
97
  /**
45
98
  * Sets up listeners for various process signals defined in {@link ProcessController.SIG}.
46
- * Handles graceful exit on 'SIGINT' (Ctrl+C).
99
+ * Handles graceful exit on 'SIGINT' (Ctrl+C) — but first forwards the
100
+ * signal to every tracked child so they get a chance to clean up.
47
101
  * @memberof ProcessController
48
102
  * @returns {Array<process.Process>} An array of process listener handles.
49
103
  */
@@ -53,7 +107,14 @@ class ProcessController {
53
107
  ProcessController.logger.info(`process on ${sig}`, args);
54
108
  switch (sig) {
55
109
  case 'SIGINT':
56
- return process.exit();
110
+ case 'SIGTERM':
111
+ case 'SIGHUP':
112
+ ProcessController._forwardToChildren('SIGTERM');
113
+ // Give children a moment to exit cleanly before our own exit.
114
+ if (sig === 'SIGINT') {
115
+ setTimeout(() => process.exit(130), 200).unref();
116
+ }
117
+ break;
57
118
  default:
58
119
  break;
59
120
  }
@@ -71,33 +132,111 @@ class ProcessController {
71
132
  ProcessController.logger = logger;
72
133
  process.on('exit', (...args) => {
73
134
  ProcessController.logger.info(`process on exit`, args);
135
+ // Last-chance reap: any tracked child still alive at exit time
136
+ // gets a hard kill so the parent does not leak orphans into the
137
+ // pod / shell session.
138
+ ProcessController._forwardToChildren('SIGKILL');
74
139
  });
75
140
  ProcessController.onSigListen();
76
- Underpost.env.delete('await-deploy');
141
+ }
142
+ }
143
+ /**
144
+ * `ShellExecError` — thrown by `shellExec` when the underlying command
145
+ * exits with a non-zero code (the default fail-fast behaviour). Carries
146
+ * the exit code, stdout, and stderr for inspection by callers / CI
147
+ * pipelines that need structured failure data.
148
+ */
149
+ class ShellExecError extends Error {
150
+ constructor(cmd, code, stdout, stderr) {
151
+ super(`shellExec failed (exit=${code}): ${cmd}`);
152
+ this.name = 'ShellExecError';
153
+ this.cmd = cmd;
154
+ this.code = code;
155
+ this.stdout = stdout;
156
+ this.stderr = stderr;
77
157
  }
78
158
  }
79
159
  /**
80
160
  * Executes a shell command using shelljs.
161
+ *
162
+ * **Default behaviour is fail-fast**: a non-zero exit code throws
163
+ * `ShellExecError`. Callers that need to branch on the exit code
164
+ * (existence checks, optional commands) must pass `silentOnError: true`
165
+ * to opt out of throwing.
166
+ *
167
+ * The async-callback path is exempt from the throw — shelljs delivers
168
+ * `(code, stdout, stderr)` to the callback, which owns its own error
169
+ * handling.
170
+ *
81
171
  * @memberof Process
82
172
  * @param {string} cmd - The command string to execute.
83
173
  * @param {Object} [options] - Options for execution.
84
- * @param {boolean} [options.silent=false] - Suppress output from shell commands.
85
- * @param {boolean} [options.async=false] - Run command asynchronously.
86
- * @param {boolean} [options.stdout=false] - Return stdout content (string) instead of shelljs result object.
87
- * @param {boolean} [options.disableLog=false] - Prevent logging of the command.
88
- * @param {Function} [options.callback=null] - Callback function for asynchronous execution.
89
- * @returns {string|shelljs.ShellString} The result of the shell command (string if `stdout: true`, otherwise a ShellString object).
174
+ * @param {boolean} [options.silent=false] - Suppress child stdout/stderr to the parent terminal.
175
+ * @param {boolean} [options.async=false] - Run the command asynchronously (use with `callback`).
176
+ * @param {boolean} [options.stdout=false] - Return stdout string instead of the `ShellString` result object.
177
+ * @param {boolean} [options.disableLog=false] - Skip the `[process] cmd …` info log line.
178
+ * @param {Function} [options.callback=null] - Async callback `(code, stdout, stderr) => void` when `async: true`.
179
+ * @param {boolean} [options.silentOnError=false] - When `true`, swallow non-zero exits and return the `ShellString` instead of throwing. Inverse of the previous `throwOnError` flag.
180
+ * @param {string} [options.cwd] - Hermetic working directory (snapshotted + restored — does NOT leak).
181
+ * @returns {string|shelljs.ShellString} `ShellString` by default; the stdout string when `stdout: true`.
182
+ * @throws {ShellExecError} On non-zero exit when `silentOnError` is not set.
90
183
  */
91
- const shellExec = (
92
- cmd,
93
- options = { silent: false, async: false, stdout: false, disableLog: false, callback: null },
94
- ) => {
184
+ const shellExec = (cmd, options = {}) => {
95
185
  if (!options.disableLog) logger.info(`cmd`, cmd);
96
- if (options.callback) return shell.exec(cmd, options, options.callback);
97
- return options.stdout ? shell.exec(cmd, options).stdout : shell.exec(cmd, options);
186
+
187
+ // Whitelist exactly the keys `shelljs.exec` understands. Passing our own
188
+ // bookkeeping keys through (or a literal `cwd: undefined`) makes shelljs
189
+ // call `path.resolve(undefined)` and crash with ERR_INVALID_ARG_TYPE.
190
+ const shellOpts = {};
191
+ if (options.silent !== undefined) shellOpts.silent = options.silent;
192
+ if (options.async !== undefined) shellOpts.async = options.async;
193
+
194
+ // Hermetic cwd. shelljs.cd mutates a process-wide global; instead we
195
+ // snapshot the current cwd here, switch for the duration of this call,
196
+ // and restore in `finally`. We deliberately do NOT forward `cwd` to
197
+ // shelljs — leaving its `cwd` unset means it inherits our just-changed
198
+ // `process.cwd()`, and we keep full control of restore semantics.
199
+ const previousCwd = options.cwd ? process.cwd() : null;
200
+ if (options.cwd) {
201
+ try {
202
+ process.chdir(options.cwd);
203
+ } catch (err) {
204
+ if (Underpost.env.isInsideContainer()) Underpost.env.set('container-status', 'error')
205
+ throw new ShellExecError(cmd, -1, '', `chdir(${options.cwd}) failed: ${err.message}`);
206
+ }
207
+ }
208
+ try {
209
+ if (options.callback) {
210
+ // Async path. shelljs invokes the callback with (code, stdout, stderr).
211
+ // The callback owns its own error handling; the throw default does
212
+ // not apply here.
213
+ return shell.exec(cmd, shellOpts, options.callback);
214
+ }
215
+ const result = shell.exec(cmd, shellOpts);
216
+
217
+ if (!options.silentOnError && result && typeof result.code === 'number' && result.code !== 0) {
218
+ if (Underpost.env.isInsideContainer()) Underpost.env.set('container-status', 'error')
219
+ throw new ShellExecError(cmd, result.code, result.stdout || '', result.stderr || '');
220
+ }
221
+
222
+ return options.stdout ? result.stdout : result;
223
+ } finally {
224
+ if (previousCwd) {
225
+ try {
226
+ process.chdir(previousCwd);
227
+ } catch (_) {
228
+ /* best-effort restore */
229
+ }
230
+ }
231
+ }
98
232
  };
99
233
  /**
100
234
  * Changes the current working directory using shelljs.
235
+ *
236
+ * Note: `shellCd` mutates global state. Prefer `shellExec(cmd, { cwd })`
237
+ * for one-shot directory-scoped commands; use `shellCd` only for the
238
+ * outermost shell where the cwd should persist across many calls.
239
+ *
101
240
  * @memberof Process
102
241
  * @param {string} cd - The path to change the directory to.
103
242
  * @param {Object} [options] - Options for the CD operation.
@@ -110,6 +249,11 @@ const shellCd = (cd, options = { disableLog: false }) => {
110
249
  };
111
250
  /**
112
251
  * Wraps a command to run it as a daemon process in a shell (keeping the process alive/terminal open).
252
+ *
253
+ * NB: callers must ensure `cmd` does not contain unescaped single quotes —
254
+ * the wrapper uses `bash -c '<cmd>; …'`. For arbitrary user input prefer
255
+ * a heredoc or a temporary script file.
256
+ *
113
257
  * @memberof Process
114
258
  * @param {string} cmd - The command to daemonize.
115
259
  * @returns {string} The shell command string for the daemon process.
@@ -119,11 +263,19 @@ const daemonProcess = (cmd) => `exec bash -c '${cmd}; exec tail -f /dev/null'`;
119
263
  * Retrieves the process ID (PID) of the most recently created gnome-terminal instance.
120
264
  * Note: This function is environment-specific (GNOME/Linux) and uses `pgrep -n`.
121
265
  * @memberof Process
122
- * @returns {number} The PID of the last gnome-terminal process.
266
+ * @returns {number|null} The PID of the last gnome-terminal process, or null if none running.
123
267
  */
124
268
  // list all terminals: pgrep gnome-terminal
125
269
  // list last terminal: pgrep -n gnome-terminal
126
- const getTerminalPid = () => JSON.parse(shellExec(`pgrep -n gnome-terminal`, { stdout: true, silent: true }));
270
+ const getTerminalPid = () => {
271
+ const raw = shellExec(`pgrep -n gnome-terminal`, { stdout: true, silent: true, silentOnError: true });
272
+ if (!raw || !raw.trim()) return null;
273
+ try {
274
+ return JSON.parse(raw);
275
+ } catch {
276
+ return null;
277
+ }
278
+ };
127
279
  /**
128
280
  * Copies text content to the system clipboard using clipboardy.
129
281
  * Logs the copied content for confirmation.
@@ -135,4 +287,13 @@ function pbcopy(data) {
135
287
  clipboard.writeSync(data || '🦄');
136
288
  logger.info(`copied to clipboard`, clipboard.readSync());
137
289
  }
138
- export { ProcessController, getRootDirectory, shellExec, shellCd, pbcopy, getTerminalPid, daemonProcess };
290
+ export {
291
+ ProcessController,
292
+ ShellExecError,
293
+ getRootDirectory,
294
+ shellExec,
295
+ shellCd,
296
+ pbcopy,
297
+ getTerminalPid,
298
+ daemonProcess,
299
+ };
@@ -9,7 +9,14 @@
9
9
  import express from 'express';
10
10
  import { createProxyMiddleware } from 'http-proxy-middleware';
11
11
  import { loggerFactory, loggerMiddleware } from './logger.js';
12
- import { buildPortProxyRouter, buildProxyRouter, getTlsHosts, isDevProxyContext, isTlsDevProxy } from './conf.js';
12
+ import {
13
+ buildPortProxyRouter,
14
+ buildProxyRouter,
15
+ etcHostFactory,
16
+ getTlsHosts,
17
+ isDevProxyContext,
18
+ isTlsDevProxy,
19
+ } from './conf.js';
13
20
 
14
21
  import { shellExec } from './process.js';
15
22
  import fs from 'fs-extra';
@@ -114,7 +121,7 @@ class ProxyService {
114
121
  logger.info('Proxy running', { port, router: options.router });
115
122
  if (process.env.NODE_ENV === 'development')
116
123
  logger.info(
117
- Underpost.deploy.etcHostFactory(Object.keys(options.router), {
124
+ etcHostFactory(Object.keys(options.router), {
118
125
  append: true,
119
126
  }).renderHosts,
120
127
  );
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Runtime status contract and the in-pod internal status endpoint.
3
+ *
4
+ * Single source of truth for the Underpost runtime readiness signal (Phase 2 of
5
+ * the two-phase deployment monitor). The runtime publishes its lifecycle here;
6
+ * the CD-side monitor (`src/cli/monitor.js`) reads it over HTTP via
7
+ * `kubectl port-forward`. Kubernetes pod readiness (Phase 1) is owned by kubelet
8
+ * and is intentionally not modeled in this module.
9
+ *
10
+ * Cross-process contract:
11
+ * - In-pod, the canonical value lives in the underpost root env key
12
+ * `container-status`, written by `start.js`. For non-error phases it carries
13
+ * the namespaced form `<deployId>-<env>-<phase>`; a fatal fault collapses to
14
+ * the bare value `error`.
15
+ * - The internal HTTP server exposes that value (normalized to the bare
16
+ * contract phase) and never exposes secrets, env dumps, or configuration.
17
+ *
18
+ * @module src/server/runtime-status.js
19
+ * @namespace RuntimeStatus
20
+ */
21
+
22
+ import http from 'node:http';
23
+ import fs from 'fs-extra';
24
+ import dotenv from 'dotenv';
25
+ import Underpost from '../index.js';
26
+ import { loggerFactory } from './logger.js';
27
+
28
+ const logger = loggerFactory(import.meta);
29
+
30
+ /**
31
+ * Allowed runtime status contract values. These are the only Phase-2 signals
32
+ * the monitor reasons about.
33
+ * @memberof RuntimeStatus
34
+ */
35
+ const RUNTIME_STATUS = {
36
+ BUILD: 'build-deployment',
37
+ INIT: 'initializing-deployment',
38
+ RUNNING: 'running-deployment',
39
+ ERROR: 'error',
40
+ };
41
+
42
+ const CONTAINER_STATUS_KEY = 'container-status';
43
+ const INTERNAL_STATUS_PATH = '/_internal/status';
44
+ const INTERNAL_READY_PATH = '/_internal/ready';
45
+ const INTERNAL_HEALTH_PATH = '/_internal/health';
46
+
47
+ /**
48
+ * Resolves the internal status port. Defaults to the deployment base `PORT`
49
+ * (app instances bind `PORT + 1` upward, so the base port is free inside the
50
+ * pod). An explicit `UNDERPOST_INTERNAL_PORT` override wins.
51
+ * @memberof RuntimeStatus
52
+ * @returns {number|undefined}
53
+ */
54
+ const resolveInternalStatusPort = () => {
55
+ const raw = process.env.UNDERPOST_INTERNAL_PORT || process.env.PORT;
56
+ const port = parseInt(raw);
57
+ return Number.isNaN(port) ? undefined : port;
58
+ };
59
+
60
+ /**
61
+ * Single source of truth for the internal status port of a specific deployment,
62
+ * used identically by the in-pod server bind (`start.js`) and the CD-side
63
+ * monitor target (`monitor.js`) so the two can never disagree.
64
+ *
65
+ * Resolution order: `UNDERPOST_INTERNAL_PORT` override → the deployment's
66
+ * `.env.<env>` `PORT` → the ambient `PORT`.
67
+ *
68
+ * @memberof RuntimeStatus
69
+ * @param {string} deployId
70
+ * @param {string} env
71
+ * @returns {number|undefined}
72
+ */
73
+ const deployStatusPort = (deployId, env) => {
74
+ const override = parseInt(process.env.UNDERPOST_INTERNAL_PORT);
75
+ if (!Number.isNaN(override)) return override;
76
+ try {
77
+ const envPath = `./engine-private/conf/${deployId}/.env.${env}`;
78
+ if (fs.existsSync(envPath)) {
79
+ const port = parseInt(dotenv.parse(fs.readFileSync(envPath, 'utf8')).PORT);
80
+ if (!Number.isNaN(port)) return port;
81
+ }
82
+ } catch (_) {
83
+ /* fall through to ambient resolution */
84
+ }
85
+ return resolveInternalStatusPort();
86
+ };
87
+
88
+ /**
89
+ * Builds the `container-status` env value for a lifecycle phase.
90
+ * @memberof RuntimeStatus
91
+ */
92
+ const containerStatusValue = (deployId, env, phase) =>
93
+ phase === RUNTIME_STATUS.ERROR ? RUNTIME_STATUS.ERROR : `${deployId}-${env}-${phase}`;
94
+
95
+ /**
96
+ * Normalizes a raw `container-status` value to a bare contract phase.
97
+ * Strips the `<deployId>-<env>-` prefix; `error` and unknown/empty values are
98
+ * passed through (empty → undefined).
99
+ * @memberof RuntimeStatus
100
+ * @param {string} raw
101
+ * @returns {string|undefined}
102
+ */
103
+ const normalizeContainerStatus = (raw) => {
104
+ if (!raw || typeof raw !== 'string') return undefined;
105
+ const value = raw.trim();
106
+ if (!value || value === 'undefined' || value.toLowerCase().includes('empty')) return undefined;
107
+ if (value === RUNTIME_STATUS.ERROR) return RUNTIME_STATUS.ERROR;
108
+ for (const phase of [RUNTIME_STATUS.BUILD, RUNTIME_STATUS.INIT, RUNTIME_STATUS.RUNNING])
109
+ if (value.endsWith(`-${phase}`)) return phase;
110
+ return value;
111
+ };
112
+
113
+ /**
114
+ * Reads the current normalized runtime status from the env file.
115
+ * @memberof RuntimeStatus
116
+ * @returns {string|undefined}
117
+ */
118
+ const getRuntimeStatus = () =>
119
+ normalizeContainerStatus(Underpost.env.get(CONTAINER_STATUS_KEY, undefined, { disableLog: true }));
120
+
121
+ /**
122
+ * Minimal, secret-free payload served by the internal status endpoint and used
123
+ * by the monitor for failure classification and observability.
124
+ * @memberof RuntimeStatus
125
+ * @returns {{status: (string|null), deployId: (string|null), env: (string|null)}}
126
+ */
127
+ const runtimeStatusPayload = () => ({
128
+ status: getRuntimeStatus() ?? null,
129
+ deployId: process.env.DEPLOY_ID ?? null,
130
+ env: process.env.NODE_ENV ?? null,
131
+ });
132
+
133
+ /**
134
+ * Emits a structured, secret-free deployment transition event.
135
+ * @memberof RuntimeStatus
136
+ */
137
+ const emitRuntimeEvent = ({ deployId, env, phase }) => {
138
+ logger.info('runtime-status', {
139
+ deployId,
140
+ env,
141
+ phase: 'runtime',
142
+ status: phase,
143
+ timestamp: new Date().toISOString(),
144
+ });
145
+ };
146
+
147
+ /**
148
+ * Publishes a runtime lifecycle phase to the cross-process contract.
149
+ * @memberof RuntimeStatus
150
+ * @param {string} deployId
151
+ * @param {string} env
152
+ * @param {string} phase - One of {@link RUNTIME_STATUS}.
153
+ */
154
+ const setRuntimeStatus = (deployId, env, phase) => {
155
+ Underpost.env.set(CONTAINER_STATUS_KEY, containerStatusValue(deployId, env, phase));
156
+ emitRuntimeEvent({ deployId, env, phase });
157
+ };
158
+
159
+ let internalServer;
160
+
161
+ /**
162
+ * Starts the in-pod internal status server. Idempotent: repeated calls return
163
+ * the already-listening server. Exposes only the three internal endpoints and
164
+ * never serves secrets or configuration.
165
+ *
166
+ * GET /_internal/status → 200, `{status, deployId, env}` (monitor transport)
167
+ * GET /_internal/ready → 200 iff running-deployment, else 503 (readinessProbe)
168
+ * GET /_internal/health → 200 while the process is alive (livenessProbe)
169
+ *
170
+ * @memberof RuntimeStatus
171
+ * @param {number} [port]
172
+ * @returns {import('node:http').Server|undefined}
173
+ */
174
+ const startInternalStatusServer = (port = resolveInternalStatusPort()) => {
175
+ if (internalServer) return internalServer;
176
+ if (!port) {
177
+ logger.warn('Internal status server not started: no resolvable port');
178
+ return undefined;
179
+ }
180
+ const server = http.createServer((req, res) => {
181
+ const url = (req.url || '').split('?')[0];
182
+ const sendJson = (code, body) => {
183
+ res.writeHead(code, { 'Content-Type': 'application/json' });
184
+ res.end(JSON.stringify(body));
185
+ };
186
+ if (req.method !== 'GET') return sendJson(405, { error: 'method_not_allowed' });
187
+ switch (url) {
188
+ case INTERNAL_HEALTH_PATH:
189
+ return sendJson(200, { status: 'ok' });
190
+ case INTERNAL_READY_PATH:
191
+ return getRuntimeStatus() === RUNTIME_STATUS.RUNNING
192
+ ? sendJson(200, { status: RUNTIME_STATUS.RUNNING })
193
+ : sendJson(503, { status: getRuntimeStatus() ?? null });
194
+ case INTERNAL_STATUS_PATH:
195
+ return sendJson(200, runtimeStatusPayload());
196
+ default:
197
+ return sendJson(404, { error: 'not_found' });
198
+ }
199
+ });
200
+ server.on('error', (error) => logger.error('internal status server error', error?.message ?? error));
201
+ server.listen(port, () => logger.info(`Internal status endpoint listening on :${port}${INTERNAL_STATUS_PATH}`));
202
+ internalServer = server;
203
+ return internalServer;
204
+ };
205
+
206
+ /**
207
+ * Stops the internal status server if running. Returns a promise that resolves
208
+ * once the listener is closed. Primarily a test/teardown hook.
209
+ * @memberof RuntimeStatus
210
+ * @returns {Promise<void>}
211
+ */
212
+ const stopInternalStatusServer = () =>
213
+ new Promise((resolve) => {
214
+ if (!internalServer) return resolve();
215
+ const server = internalServer;
216
+ internalServer = undefined;
217
+ server.close(() => resolve());
218
+ });
219
+
220
+ export {
221
+ RUNTIME_STATUS,
222
+ CONTAINER_STATUS_KEY,
223
+ INTERNAL_STATUS_PATH,
224
+ INTERNAL_READY_PATH,
225
+ INTERNAL_HEALTH_PATH,
226
+ resolveInternalStatusPort,
227
+ deployStatusPort,
228
+ containerStatusValue,
229
+ normalizeContainerStatus,
230
+ getRuntimeStatus,
231
+ runtimeStatusPayload,
232
+ setRuntimeStatus,
233
+ startInternalStatusServer,
234
+ stopInternalStatusServer,
235
+ };
@@ -176,7 +176,7 @@ const buildRuntime = async () => {
176
176
  }
177
177
 
178
178
  if (Lampp.enabled() && Lampp.router) Lampp.initService();
179
-
179
+ Underpost.env.delete('await-deploy');
180
180
  Underpost.start.logRuntimeRouter();
181
181
  };
182
182
 
@@ -8,6 +8,7 @@ import fs from 'fs-extra';
8
8
  import { awaitDeployMonitor } from './conf.js';
9
9
  import { actionInitLog, loggerFactory } from './logger.js';
10
10
  import { shellCd, shellExec } from './process.js';
11
+ import { RUNTIME_STATUS, setRuntimeStatus, startInternalStatusServer, deployStatusPort } from './runtime-status.js';
11
12
  import Underpost from '../index.js';
12
13
  const logger = loggerFactory(import.meta);
13
14
 
@@ -147,10 +148,20 @@ class UnderpostStartUp {
147
148
  pullBundle: false,
148
149
  },
149
150
  ) {
150
- Underpost.env.set('container-status', `${deployId}-${env}-build-deployment`);
151
- if (options.build === true) await Underpost.start.build(deployId, env, options);
152
- Underpost.env.set('container-status', `${deployId}-${env}-initializing-deployment`);
153
- if (options.run === true) await Underpost.start.run(deployId, env, options);
151
+ // Bring the internal status endpoint up first so Phase-2 readiness is
152
+ // observable through every lifecycle phase, including build and init. Bind
153
+ // the deployment-resolved port so it always matches the monitor's target.
154
+ startInternalStatusServer(deployStatusPort(deployId, env));
155
+ try {
156
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.BUILD);
157
+ if (options.build === true) await Underpost.start.build(deployId, env, options);
158
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.INIT);
159
+ if (options.run === true) await Underpost.start.run(deployId, env, options);
160
+ } catch (error) {
161
+ logger.error('Deployment build/init failed', { deployId, env, message: error?.message });
162
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
163
+ if (!Underpost.env.isInsideContainer()) throw error;
164
+ }
154
165
  },
155
166
  /**
156
167
  * Run itc-scripts and builds client bundle.
@@ -162,6 +173,8 @@ class UnderpostStartUp {
162
173
  * @param {boolean} options.skipFullBuild - Whether to skip building the full client bundle.
163
174
  * @param {boolean} options.pullBundle - When true, download pre-built client bundle from Cloudinary via pull-bundle (must be pushed first with push-bundle).
164
175
  * This flag is independent of skipFullBuild: it can be combined with skipFullBuild or used alone.
176
+ * @param {boolean} options.privateTestRepo - When true, clone `engine-test-<id>` (the private test source repo
177
+ * published by `node bin/build <deployId> --update-private`) instead of the production `engine-<id>` repo.
165
178
  * @memberof UnderpostStartUp
166
179
  */
167
180
  async build(
@@ -170,7 +183,11 @@ class UnderpostStartUp {
170
183
  options = { underpostQuicklyInstall: false, skipPullBase: false, skipFullBuild: false, pullBundle: false },
171
184
  ) {
172
185
  const buildBasePath = `/home/dd`;
173
- const repoName = `engine-${deployId.split('-')[1]}`;
186
+ // `--private-test-repo` clones the isolated test source repo published by
187
+ // `node bin/build <deployId> --update-private`, instead of the production one.
188
+ const repoName = options?.privateTestRepo
189
+ ? `engine-test-${deployId.split('-')[1]}`
190
+ : `engine-${deployId.split('-')[1]}`;
174
191
  if (!options.skipPullBase) {
175
192
  shellExec(`cd ${buildBasePath} && underpost clone ${process.env.GITHUB_USERNAME}/${repoName}`);
176
193
  shellExec(`mkdir -p ${buildBasePath}/engine`);
@@ -198,20 +215,36 @@ class UnderpostStartUp {
198
215
  */
199
216
  async run(deployId = 'dd-default', env = 'development', options = {}) {
200
217
  const runCmd = env === 'production' ? 'run prod:container' : 'run dev:container';
218
+ const makeDeployCallback = (cmd) => (code, out, msg) => {
219
+ if (code !== 0) {
220
+ logger.error(`Deployment process exited with code ${code}`, { cmd, msg });
221
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
222
+ }
223
+ };
201
224
  if (fs.existsSync(`./engine-private/replica`)) {
202
225
  const replicas = await fs.readdir(`./engine-private/replica`);
203
226
  for (const replica of replicas) {
204
227
  if (!replica.match(deployId)) continue;
205
228
  shellExec(`node bin env ${replica} ${env}`);
206
- shellExec(`npm ${runCmd} ${replica}`, { async: true });
207
- await awaitDeployMonitor(true);
229
+ const replicaCmd = `npm ${runCmd} ${replica}`;
230
+ shellExec(replicaCmd, { async: true, callback: makeDeployCallback(replicaCmd) });
231
+ const result = await awaitDeployMonitor();
232
+ if (result !== true) {
233
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
234
+ return;
235
+ }
208
236
  }
209
237
  }
210
238
  shellExec(`node bin env ${deployId} ${env}`);
211
- shellExec(`npm ${runCmd} ${deployId}`, { async: true });
212
- await awaitDeployMonitor(true);
213
- if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
214
- Underpost.env.set('container-status', `${deployId}-${env}-running-deployment`);
239
+ const deployCmd = `npm ${runCmd} ${deployId}`;
240
+ shellExec(deployCmd, { async: true, callback: makeDeployCallback(deployCmd) });
241
+ const result = await awaitDeployMonitor(true);
242
+ if (result === true) {
243
+ if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
244
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.RUNNING);
245
+ } else {
246
+ setRuntimeStatus(deployId, env, RUNTIME_STATUS.ERROR);
247
+ }
215
248
  },
216
249
  };
217
250
  }