underpost 3.2.9 → 3.2.11

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 (104) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/extensions.json +9 -9
  5. package/.vscode/settings.json +20 -4
  6. package/CHANGELOG.md +195 -1
  7. package/CLI-HELP.md +92 -23
  8. package/README.md +38 -9
  9. package/bin/build.js +27 -7
  10. package/bin/build.template.js +187 -0
  11. package/bin/deploy.js +12 -2
  12. package/bin/index.js +2 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -7
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  21. package/manifests/mongodb/pv-pvc.yaml +44 -8
  22. package/manifests/mongodb/statefulset.yaml +55 -68
  23. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  24. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  25. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  26. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  27. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  28. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  29. package/manifests/valkey/statefulset.yaml +1 -1
  30. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  31. package/package.json +27 -12
  32. package/scripts/ipxe-setup.sh +52 -49
  33. package/scripts/k3s-node-setup.sh +81 -46
  34. package/scripts/lxd-vm-setup.sh +193 -8
  35. package/scripts/maas-nat-firewalld.sh +145 -0
  36. package/src/api/core/core.router.js +19 -14
  37. package/src/api/core/core.service.js +5 -5
  38. package/src/api/default/default.router.js +22 -18
  39. package/src/api/default/default.service.js +5 -5
  40. package/src/api/document/document.router.js +28 -23
  41. package/src/api/document/document.service.js +100 -23
  42. package/src/api/file/file.router.js +19 -13
  43. package/src/api/file/file.service.js +9 -7
  44. package/src/api/test/test.router.js +17 -12
  45. package/src/api/types.js +24 -0
  46. package/src/api/user/guest.service.js +5 -4
  47. package/src/api/user/user.router.js +297 -288
  48. package/src/api/user/user.service.js +100 -35
  49. package/src/cli/baremetal.js +132 -101
  50. package/src/cli/cluster.js +700 -232
  51. package/src/cli/db.js +59 -60
  52. package/src/cli/deploy.js +216 -137
  53. package/src/cli/fs.js +13 -3
  54. package/src/cli/index.js +80 -15
  55. package/src/cli/ipfs.js +4 -6
  56. package/src/cli/kubectl.js +4 -1
  57. package/src/cli/lxd.js +1099 -223
  58. package/src/cli/monitor.js +9 -3
  59. package/src/cli/release.js +334 -140
  60. package/src/cli/repository.js +68 -23
  61. package/src/cli/run.js +191 -47
  62. package/src/cli/secrets.js +11 -2
  63. package/src/cli/test.js +9 -3
  64. package/src/client/Default.index.js +9 -3
  65. package/src/client/components/core/Auth.js +5 -0
  66. package/src/client/components/core/ClientEvents.js +76 -0
  67. package/src/client/components/core/EventBus.js +4 -0
  68. package/src/client/components/core/Modal.js +82 -41
  69. package/src/client/components/core/PanelForm.js +56 -52
  70. package/src/client/components/core/Worker.js +162 -363
  71. package/src/client/sw/core.sw.js +174 -112
  72. package/src/db/DataBaseProvider.js +115 -15
  73. package/src/db/mariadb/MariaDB.js +2 -1
  74. package/src/db/mongo/MongoBootstrap.js +657 -0
  75. package/src/db/mongo/MongooseDB.js +129 -21
  76. package/src/index.js +1 -1
  77. package/src/runtime/express/Express.js +2 -2
  78. package/src/runtime/wp/Wp.js +8 -5
  79. package/src/server/auth.js +2 -2
  80. package/src/server/client-build-docs.js +1 -1
  81. package/src/server/client-build.js +94 -129
  82. package/src/server/conf.js +81 -79
  83. package/src/server/process.js +180 -19
  84. package/src/server/proxy.js +9 -2
  85. package/src/server/runtime.js +1 -1
  86. package/src/server/start.js +16 -4
  87. package/src/server/valkey.js +2 -0
  88. package/src/ws/IoInterface.js +16 -16
  89. package/src/ws/core/channels/core.ws.chat.js +11 -11
  90. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  91. package/src/ws/core/channels/core.ws.stream.js +19 -19
  92. package/src/ws/core/core.ws.connection.js +8 -8
  93. package/src/ws/core/core.ws.server.js +6 -5
  94. package/src/ws/default/channels/default.ws.main.js +10 -10
  95. package/src/ws/default/default.ws.connection.js +4 -4
  96. package/src/ws/default/default.ws.server.js +4 -3
  97. package/bin/file.js +0 -202
  98. package/bin/vs.js +0 -74
  99. package/bin/zed.js +0 -84
  100. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  101. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  102. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  103. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  104. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -41,6 +41,20 @@ const logger = loggerFactory(import.meta);
41
41
  */
42
42
  const ENV_REF_PREFIX = 'env:';
43
43
 
44
+ /**
45
+ * Resolves a standardized context key from host/path descriptors.
46
+ * The key is used across DB, WS, mailer, and cache registries.
47
+ *
48
+ * @method resolveHostKeyContext
49
+ * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or prebuilt key.
50
+ * @returns {string} Host key context string.
51
+ * @memberof ServerConfBuilder
52
+ */
53
+ const resolveHostKeyContext = (context = { host: '', path: '' }) => {
54
+ if (typeof context === 'string') return context;
55
+ return `${context.host || ''}${context.path || ''}`;
56
+ };
57
+
44
58
  /**
45
59
  * Recursively walks a configuration object and replaces every string value that
46
60
  * starts with {@link ENV_REF_PREFIX} (`"env:"`) with the corresponding
@@ -320,25 +334,29 @@ const Config = {
320
334
 
321
335
  if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
322
336
 
323
- const envTemplate = fs.existsSync('./.env.example')
337
+ const sharedEnvTemplate = fs.existsSync('./.env.example')
324
338
  ? fs.readFileSync('./.env.example', 'utf8')
325
339
  : fs.existsSync('./.env.production')
326
340
  ? fs.readFileSync('./.env.production', 'utf8')
327
341
  : '';
328
342
 
329
- if (envTemplate) {
330
- const prodEnv = envTemplate.replaceAll('dd-default', deployId);
331
- fs.writeFileSync(`${folder}/.env.production`, prodEnv, 'utf8');
332
- fs.writeFileSync(
333
- `${folder}/.env.development`,
334
- prodEnv.replace('NODE_ENV=production', 'NODE_ENV=development').replace('PORT=3000', 'PORT=4000'),
335
- 'utf8',
336
- );
337
- fs.writeFileSync(
338
- `${folder}/.env.test`,
339
- prodEnv.replace('NODE_ENV=production', 'NODE_ENV=test').replace('PORT=3000', 'PORT=5000'),
340
- 'utf8',
341
- );
343
+ const envTemplates = {
344
+ production: fs.existsSync('./.env.production') ? fs.readFileSync('./.env.production', 'utf8') : sharedEnvTemplate,
345
+ development: fs.existsSync('./.env.development')
346
+ ? fs.readFileSync('./.env.development', 'utf8')
347
+ : sharedEnvTemplate
348
+ ? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=development').replace('PORT=3000', 'PORT=4000')
349
+ : '',
350
+ test: fs.existsSync('./.env.test')
351
+ ? fs.readFileSync('./.env.test', 'utf8')
352
+ : sharedEnvTemplate
353
+ ? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=test').replace('PORT=3000', 'PORT=5000')
354
+ : '',
355
+ };
356
+
357
+ for (const [envName, envTemplate] of Object.entries(envTemplates)) {
358
+ if (!envTemplate) continue;
359
+ fs.writeFileSync(`${folder}/.env.${envName}`, envTemplate.replaceAll('dd-default', deployId), 'utf8');
342
360
  }
343
361
 
344
362
  fs.writeFileSync(
@@ -1208,7 +1226,11 @@ const validateTemplatePath = (absolutePath = '') => {
1208
1226
  const confSsr = DefaultConf.ssr[ssr];
1209
1227
  const clients = DefaultConf.client.default.services;
1210
1228
 
1211
- if (absolutePath.match('src/api') && !confServer.apis.find((p) => absolutePath.match(`src/api/${p}/`))) {
1229
+ if (
1230
+ absolutePath.match('src/api') &&
1231
+ !absolutePath.match('src/api/types.js') &&
1232
+ !confServer.apis.find((p) => absolutePath.match(`src/api/${p}/`))
1233
+ ) {
1212
1234
  return false;
1213
1235
  }
1214
1236
  if (absolutePath.match('conf.dd-') && absolutePath.match('.js')) return false;
@@ -1250,14 +1272,8 @@ const validateTemplatePath = (absolutePath = '') => {
1250
1272
  return false;
1251
1273
  }
1252
1274
  if (
1253
- absolutePath.match('src/client/ssr/offline') &&
1254
- !confSsr.offline.find((p) => absolutePath.match(`src/client/ssr/offline/${p.client}.js`))
1255
- ) {
1256
- return false;
1257
- }
1258
- if (
1259
- absolutePath.match('src/client/ssr/pages') &&
1260
- !confSsr.pages.find((p) => absolutePath.match(`src/client/ssr/pages/${p.client}.js`))
1275
+ absolutePath.match('src/client/ssr/views') &&
1276
+ !(confSsr.views || []).find((p) => absolutePath.match(`src/client/ssr/views/${p.client}.js`))
1261
1277
  ) {
1262
1278
  return false;
1263
1279
  }
@@ -1287,6 +1303,7 @@ const validateTemplatePath = (absolutePath = '') => {
1287
1303
  const awaitDeployMonitor = async (init = false, deltaMs = 1000) => {
1288
1304
  if (init) Underpost.env.set('await-deploy', new Date().toISOString());
1289
1305
  await timer(deltaMs);
1306
+ if (Underpost.env.get('container-status') === 'error') throw new Error('Container status error');
1290
1307
  if (Underpost.env.get('await-deploy')) return await awaitDeployMonitor();
1291
1308
  };
1292
1309
 
@@ -1312,59 +1329,6 @@ const mergeFile = async (parts = [], outputFilePath) => {
1312
1329
  });
1313
1330
  };
1314
1331
 
1315
- /**
1316
- * @method rebuildConfFactory
1317
- * @description Rebuilds the conf factory.
1318
- * @param {object} options - The options.
1319
- * @param {string} options.deployId - The deploy ID.
1320
- * @param {string} options.valkey - The valkey.
1321
- * @param {boolean} [options.mongo=false] - The mongo.
1322
- * @returns {object} - The rebuild conf factory.
1323
- * @memberof ServerConfBuilder
1324
- */
1325
- const rebuildConfFactory = ({ deployId, valkey, mongo }) => {
1326
- const confServer = loadReplicas(deployId, loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`));
1327
- const hosts = {};
1328
- for (const host of Object.keys(confServer)) {
1329
- hosts[host] = {};
1330
- for (const path of Object.keys(confServer[host])) {
1331
- if (!confServer[host][path].db) continue;
1332
- const { singleReplica, replicas, db } = confServer[host][path];
1333
- const { provider } = db;
1334
- if (singleReplica) {
1335
- for (const replica of replicas) {
1336
- const deployIdReplica = buildReplicaId({ replica, deployId });
1337
- const confServerReplica = loadConfServerJson(`./engine-private/replica/${deployIdReplica}/conf.server.json`);
1338
- for (const _host of Object.keys(confServerReplica)) {
1339
- for (const _path of Object.keys(confServerReplica[_host])) {
1340
- hosts[host][_path] = { replica: { host, path } };
1341
- confServerReplica[_host][_path].valkey = valkey;
1342
- switch (provider) {
1343
- case 'mongoose':
1344
- confServerReplica[_host][_path].db.host = mongo.host;
1345
- break;
1346
- }
1347
- }
1348
- }
1349
- fs.writeFileSync(
1350
- `./engine-private/replica/${deployIdReplica}/conf.server.json`,
1351
- JSON.stringify(confServerReplica, null, 4),
1352
- 'utf8',
1353
- );
1354
- }
1355
- } else hosts[host][path] = {};
1356
- confServer[host][path].valkey = valkey;
1357
- switch (provider) {
1358
- case 'mongoose':
1359
- confServer[host][path].db.host = mongo.host;
1360
- break;
1361
- }
1362
- }
1363
- }
1364
- fs.writeFileSync(`./engine-private/conf/${deployId}/conf.server.json`, JSON.stringify(confServer, null, 4), 'utf8');
1365
- return { hosts };
1366
- };
1367
-
1368
1332
  /**
1369
1333
  * @method getPathsSSR
1370
1334
  * @description Gets the paths SSR.
@@ -1377,8 +1341,7 @@ const getPathsSSR = (conf) => {
1377
1341
  for (const o of conf.head) paths.push(`src/client/ssr/head/${o}.js`);
1378
1342
  for (const o of conf.body) paths.push(`src/client/ssr/body/${o}.js`);
1379
1343
  for (const o of Object.keys(conf.mailer)) paths.push(`src/client/ssr/mailer/${conf.mailer[o]}.js`);
1380
- for (const o of conf.offline) paths.push(`src/client/ssr/mailer/${o.client}.js`);
1381
- for (const o of conf.pages) paths.push(`src/client/ssr/pages/${o.client}.js`);
1344
+ for (const o of conf.views || []) paths.push(`src/client/ssr/views/${o.client}.js`);
1382
1345
  return paths;
1383
1346
  };
1384
1347
 
@@ -1746,6 +1709,44 @@ const loadConfServerJson = (jsonPath, options) => {
1746
1709
  return options && options.resolve === true ? resolveConfSecrets(raw) : raw;
1747
1710
  };
1748
1711
 
1712
+ /**
1713
+ * Creates and writes the /etc/hosts file for a deployment.
1714
+ * @method etcHostFactory
1715
+ * @param {Array<string>} hosts - List of hosts to be added to the hosts file.
1716
+ * @param {object} options - Options for the hosts file creation.
1717
+ * @param {boolean} options.append - Whether to append to the existing hosts file.
1718
+ * @returns {object} - Object containing the rendered hosts file.
1719
+ * @memberof ServerConfBuilder
1720
+ */
1721
+ const etcHostFactory = (hosts = [], options = { append: false }) => {
1722
+ hosts = hosts.map((host) => {
1723
+ try {
1724
+ if (!host.startsWith('http')) host = `http://${host}`;
1725
+ const hostname = new URL(host).hostname;
1726
+ logger.info('Hostname extract valid', { host, hostname });
1727
+ return hostname;
1728
+ } catch (e) {
1729
+ logger.warn('No hostname extract valid', host);
1730
+ return host;
1731
+ }
1732
+ });
1733
+ const renderHosts = `127.0.0.1 ${hosts.join(
1734
+ ' ',
1735
+ )} localhost localhost.localdomain localhost4 localhost4.localdomain4
1736
+ ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6`;
1737
+
1738
+ if (options && options.append && fs.existsSync(`/etc/hosts`)) {
1739
+ fs.writeFileSync(
1740
+ `/etc/hosts`,
1741
+ fs.readFileSync(`/etc/hosts`, 'utf8') +
1742
+ `
1743
+ ${renderHosts}`,
1744
+ 'utf8',
1745
+ );
1746
+ } else fs.writeFileSync(`/etc/hosts`, renderHosts, 'utf8');
1747
+ return { renderHosts };
1748
+ };
1749
+
1749
1750
  export {
1750
1751
  Config,
1751
1752
  loadConf,
@@ -1774,7 +1775,6 @@ export {
1774
1775
  pathPortAssignmentFactory,
1775
1776
  deployRangePortFactory,
1776
1777
  awaitDeployMonitor,
1777
- rebuildConfFactory,
1778
1778
  buildCliDoc,
1779
1779
  getInstanceContext,
1780
1780
  buildApiConf,
@@ -1784,6 +1784,7 @@ export {
1784
1784
  devProxyHostFactory,
1785
1785
  isTlsDevProxy,
1786
1786
  getTlsHosts,
1787
+ resolveHostKeyContext,
1787
1788
  resolveConfSecrets,
1788
1789
  loadConfServerJson,
1789
1790
  getConfFolder,
@@ -1792,4 +1793,5 @@ export {
1792
1793
  DEFAULT_DEPLOY_ID,
1793
1794
  loadCronDeployEnv,
1794
1795
  cronDeployIdResolve,
1796
+ etcHostFactory,
1795
1797
  };
@@ -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
  );
@@ -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
 
@@ -198,20 +198,32 @@ class UnderpostStartUp {
198
198
  */
199
199
  async run(deployId = 'dd-default', env = 'development', options = {}) {
200
200
  const runCmd = env === 'production' ? 'run prod:container' : 'run dev:container';
201
+ const makeDeployCallback = (cmd) => (code, out, msg) => {
202
+ if (code !== 0) {
203
+ logger.error(`Deployment process exited with code ${code}`, { cmd, msg });
204
+ Underpost.env.set('container-status', 'error');
205
+ }
206
+ };
201
207
  if (fs.existsSync(`./engine-private/replica`)) {
202
208
  const replicas = await fs.readdir(`./engine-private/replica`);
203
209
  for (const replica of replicas) {
204
210
  if (!replica.match(deployId)) continue;
205
211
  shellExec(`node bin env ${replica} ${env}`);
206
- shellExec(`npm ${runCmd} ${replica}`, { async: true });
212
+ const replicaCmd = `npm ${runCmd} ${replica}`;
213
+ shellExec(replicaCmd, { async: true, callback: makeDeployCallback(replicaCmd) });
207
214
  await awaitDeployMonitor(true);
208
215
  }
209
216
  }
210
217
  shellExec(`node bin env ${deployId} ${env}`);
211
- shellExec(`npm ${runCmd} ${deployId}`, { async: true });
218
+ const deployCmd = `npm ${runCmd} ${deployId}`;
219
+ shellExec(deployCmd, { async: true, callback: makeDeployCallback(deployCmd) });
212
220
  await awaitDeployMonitor(true);
213
- if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
214
- Underpost.env.set('container-status', `${deployId}-${env}-running-deployment`);
221
+ if (Underpost.env.get('container-status') !== 'error') {
222
+ if (env === 'production' && Underpost.env.isInsideContainer()) Underpost.secret.globalSecretClean();
223
+ Underpost.env.set('container-status', `${deployId}-${env}-running-deployment`);
224
+ }
225
+ Underpost.env.set('container-status', 'error');
226
+ throw new Error('Deployment process exited unexpectedly');
215
227
  },
216
228
  };
217
229
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import Valkey from 'iovalkey';
16
16
  import { loggerFactory } from './logger.js';
17
+ import Underpost from '../index.js';
17
18
 
18
19
  const logger = loggerFactory(import.meta);
19
20
 
@@ -70,6 +71,7 @@ const createValkeyConnection = async (instance = {}, connectionOptions = {}) =>
70
71
  client.on('error', (err) => {
71
72
  ValkeyStatus[key] = 'error';
72
73
  logger.error('Valkey error', { err: err?.message, instance });
74
+ if (Underpost.env.isInsideContainer()) Underpost.env.set('container-status', 'error');
73
75
  });
74
76
  client.on('reconnecting', () => {
75
77
  ValkeyStatus[key] = 'reconnecting';