underpost 3.2.10 → 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 (54) hide show
  1. package/.vscode/extensions.json +9 -9
  2. package/.vscode/settings.json +12 -1
  3. package/CHANGELOG.md +74 -1
  4. package/CLI-HELP.md +80 -26
  5. package/README.md +3 -3
  6. package/bin/build.js +9 -6
  7. package/bin/build.template.js +187 -0
  8. package/bin/deploy.js +29 -18
  9. package/conf.js +1 -4
  10. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  11. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  12. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  13. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  14. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  15. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  16. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  17. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  18. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  19. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  20. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  21. package/manifests/valkey/statefulset.yaml +1 -1
  22. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  23. package/package.json +3 -3
  24. package/scripts/ipxe-setup.sh +52 -49
  25. package/scripts/k3s-node-setup.sh +84 -68
  26. package/scripts/lxd-vm-setup.sh +193 -8
  27. package/scripts/maas-nat-firewalld.sh +145 -0
  28. package/src/cli/baremetal.js +115 -93
  29. package/src/cli/cluster.js +548 -221
  30. package/src/cli/deploy.js +131 -166
  31. package/src/cli/fs.js +11 -3
  32. package/src/cli/index.js +75 -17
  33. package/src/cli/lxd.js +1034 -240
  34. package/src/cli/monitor.js +9 -3
  35. package/src/cli/release.js +72 -36
  36. package/src/cli/repository.js +10 -16
  37. package/src/cli/run.js +70 -53
  38. package/src/cli/secrets.js +11 -2
  39. package/src/client/components/core/Auth.js +4 -3
  40. package/src/client/components/core/ClientEvents.js +76 -0
  41. package/src/client/components/core/EventBus.js +4 -0
  42. package/src/client/components/core/Modal.js +82 -41
  43. package/src/db/DataBaseProvider.js +9 -9
  44. package/src/db/mariadb/MariaDB.js +2 -1
  45. package/src/db/mongo/MongoBootstrap.js +592 -522
  46. package/src/db/mongo/MongooseDB.js +19 -15
  47. package/src/index.js +1 -1
  48. package/src/server/conf.js +62 -15
  49. package/src/server/proxy.js +9 -2
  50. package/src/server/start.js +7 -3
  51. package/src/server/valkey.js +2 -0
  52. package/bin/file.js +0 -220
  53. package/bin/vs.js +0 -74
  54. package/bin/zed.js +0 -84
@@ -4,7 +4,13 @@
4
4
  * @namespace UnderpostMonitor
5
5
  */
6
6
 
7
- import { loadReplicas, pathPortAssignmentFactory, loadConfServerJson, loadCronDeployEnv } from '../server/conf.js';
7
+ import {
8
+ loadReplicas,
9
+ pathPortAssignmentFactory,
10
+ loadConfServerJson,
11
+ loadCronDeployEnv,
12
+ etcHostFactory,
13
+ } from '../server/conf.js';
8
14
  import { loggerFactory } from '../server/logger.js';
9
15
  import axios from 'axios';
10
16
  import fs from 'fs-extra';
@@ -144,7 +150,7 @@ class UnderpostMonitor {
144
150
  if (path.match('peer') || path.match('socket')) continue;
145
151
  const urlTest = `http${env === 'development' ? '' : 's'}://${host}${path}`;
146
152
  if (env === 'development') {
147
- const { renderHosts } = Underpost.deploy.etcHostFactory([host]);
153
+ const { renderHosts } = etcHostFactory([host]);
148
154
  logger.info('renderHosts', renderHosts);
149
155
  }
150
156
  await axios.get(urlTest, { timeout: 10000 }).catch((error) => {
@@ -200,7 +206,7 @@ class UnderpostMonitor {
200
206
  let monitorPodName;
201
207
  const monitorCallBack = (resolve, reject) => {
202
208
  if (env === 'development') {
203
- const { renderHosts } = Underpost.deploy.etcHostFactory([]);
209
+ const { renderHosts } = etcHostFactory([]);
204
210
  logger.info('renderHosts', renderHosts);
205
211
  }
206
212
  const envMsTimeout = Underpost.env.get(`${deployId}-${env}-monitor-ms`);
@@ -13,6 +13,7 @@ import fs from 'fs-extra';
13
13
  import path from 'path';
14
14
  import dotenv from 'dotenv';
15
15
  import { pbcopy, shellCd, shellExec } from '../server/process.js';
16
+ import { Dns } from '../server/dns.js';
16
17
  import { loggerFactory } from '../server/logger.js';
17
18
  import { timer } from '../client/components/core/CommonJs.js';
18
19
  import Underpost from '../index.js';
@@ -76,10 +77,7 @@ const buildVersionBumpTargets = () => [
76
77
  {
77
78
  dir: 'src/client/public/cyberia-docs',
78
79
  match: /\.md$/,
79
- patterns: [
80
- /(\*\*(?:Current )?[Vv]ersion:\*\* )\d+\.\d+\.\d+/g,
81
- /(underpost\/[a-z0-9-]+:v)\d+\.\d+\.\d+/g,
82
- ],
80
+ patterns: [/(\*\*(?:Current )?[Vv]ersion:\*\* )\d+\.\d+\.\d+/g, /(underpost\/[a-z0-9-]+:v)\d+\.\d+\.\d+/g],
83
81
  recursive: true,
84
82
  },
85
83
  {
@@ -99,10 +97,7 @@ const buildVersionBumpTargets = () => [
99
97
  {
100
98
  dir: 'manifests/deployment',
101
99
  match: /deployment\.yaml$/,
102
- patterns: [
103
- /(underpost\/[a-z0-9-]+:v)\d+\.\d+\.\d+/g,
104
- /(engine\.version: )\d+\.\d+\.\d+/g,
105
- ],
100
+ patterns: [/(underpost\/[a-z0-9-]+:v)\d+\.\d+\.\d+/g, /(engine\.version: )\d+\.\d+\.\d+/g],
106
101
  recursive: true,
107
102
  },
108
103
  {
@@ -240,13 +235,72 @@ const printBumpReport = (report, { dryRun }) => {
240
235
  * (e.g. overwriting package.json). Skips VSCode internals and the current process.
241
236
  */
242
237
  function killDevServers() {
243
- // shellExec(
244
- // `kill -9 $(pgrep -f 'nodemon|node.*src/server|node.*dev' | grep -v '^${process.pid}$') 2>/dev/null || true`,
245
- // );
246
- shellExec(`node bin run kill 4001`);
247
- shellExec(`node bin run kill 4002`);
248
- shellExec(`node bin run kill 4003`);
249
- shellExec(`node bin run kill 3000`);
238
+ for (const port of [4001, 4002, 4003, 3000]) shellExec(`node bin run kill ${port}`);
239
+ }
240
+
241
+ const TEMPLATE_PATH = '../pwa-microservices-template';
242
+
243
+ /**
244
+ * Runs a command in an ISOLATED environment. `release build` loads the engine's
245
+ * `dd-cron/.env.production` (DEPLOY_ID=dd-cron, DB creds, secrets, …) into its own `process.env`,
246
+ * which `shellExec` children would otherwise inherit — and the template's `dotenv.config()` runs
247
+ * without override, so it could never reclaim those keys. `env -i` starts the child with an empty
248
+ * environment; only PATH/HOME-class essentials are re-added so node/npm/git still resolve. The
249
+ * template then reads only its own `.env`, resolving `dd-default` exactly like a fresh clone.
250
+ */
251
+ const ISOLATED_ENV = 'env -i HOME="$HOME" PATH="$PATH" USER="$USER" LOGNAME="$LOGNAME" TERM="$TERM" LANG="$LANG"';
252
+
253
+ /**
254
+ * Builds the pwa-microservices-template from scratch and smoke-tests its default workflow.
255
+ *
256
+ * 1. Cleans the engine, pulls latest, and rebuilds the template via `bin/build.template`.
257
+ * 2. Derives `.env` + `.env.example` from the template `.env.example` (DHCP host IP + file
258
+ * logs), both written from the same `dd-default` content so the default startup workflow
259
+ * bootstraps `engine-private` from scratch out of them. `.env` is rewritten because it is
260
+ * gitignored (git clean never removes it) and may otherwise hold a stale `dd-cron`.
261
+ * 3. Installs deps, then builds + runs the template `dev` server in an ISOLATED env
262
+ * (see `ISOLATED_ENV`) so it resolves `dd-default` like a fresh clone, and asserts the
263
+ * startup log is error-free.
264
+ *
265
+ * @returns {boolean} true when the template started cleanly, false otherwise.
266
+ */
267
+ async function buildAndTestTemplate() {
268
+ killDevServers();
269
+ Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
270
+ shellExec(`node bin pull . ${process.env.GITHUB_USERNAME}/engine`);
271
+ shellExec(`npm run update:template`);
272
+ shellExec(`node bin run shared-dir ${TEMPLATE_PATH}`);
273
+
274
+ const dhcpHostIp = Dns.getLocalIPv4Address();
275
+ logger.info(`DHCP host IP for template test: ${dhcpHostIp}`);
276
+ let envContent = fs.readFileSync(`${TEMPLATE_PATH}/.env.example`, 'utf8');
277
+ if (dhcpHostIp) envContent = envContent.replace(/127\.0\.0\.1/g, dhcpHostIp);
278
+ envContent = envContent.replace(/^ENABLE_FILE_LOGS=.*/m, 'ENABLE_FILE_LOGS=true');
279
+ // fs.writeFileSync(`${TEMPLATE_PATH}/.env`, envContent, 'utf8');
280
+ fs.writeFileSync(`${TEMPLATE_PATH}/.env.example`, envContent, 'utf8');
281
+ shellExec(`cd ${TEMPLATE_PATH} && npm install`);
282
+ shellExec(`cd ${TEMPLATE_PATH} && node bin env clean`);
283
+ // Build + run in an isolated env so the template resolves dd-default from its own .env and
284
+ // never inherits the engine's dd-cron deploy selection. See ISOLATED_ENV above.
285
+ //
286
+ // ENABLE_FILE_LOGS must be passed inline: src/server.js builds start.js's logger at import
287
+ // time, before Config.build() loads the template .env, so the var has to be present in the
288
+ // process env from the start for logs/start.js/all.log (the success probe below) to be written.
289
+ shellExec(`cd ${TEMPLATE_PATH} && ${ISOLATED_ENV} npm run build`);
290
+ shellExec(`cd ${TEMPLATE_PATH} && ${ISOLATED_ENV} ENABLE_FILE_LOGS=true timeout 5s npm run dev`, { async: true });
291
+ await timer(5500);
292
+
293
+ const templateLogPath = `${TEMPLATE_PATH}/logs/start.js/all.log`;
294
+ const runnerResult = fs.existsSync(templateLogPath) ? fs.readFileSync(templateLogPath, 'utf8') : '';
295
+ logger.info('Test template runner result');
296
+ killDevServers();
297
+ shellCd(`/home/dd/engine`);
298
+ Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
299
+ if (!runnerResult || runnerResult.toLowerCase().match('error')) {
300
+ logger.error('Test template runner result failed');
301
+ return false;
302
+ }
303
+ return true;
250
304
  }
251
305
 
252
306
  /**
@@ -298,26 +352,8 @@ class UnderpostRelease {
298
352
  logger.info(`Release build — bumping ${version} → ${newVersion}${dryRun ? ' (dry-run)' : ''}`);
299
353
 
300
354
  if (!dryRun) {
301
- killDevServers();
302
- Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
303
- shellExec(`node bin pull . ${process.env.GITHUB_USERNAME}/engine`);
304
- shellExec(`npm run update:template`);
305
- shellExec(`cd ../pwa-microservices-template && npm install && npm run build`);
306
- console.log(fs.existsSync(`../pwa-microservices-template/engine-private/conf/dd-default`));
307
- shellExec(`cd ../pwa-microservices-template && ENABLE_FILE_LOGS=true timeout 5s npm run dev`, {
308
- async: true,
309
- });
310
- await timer(5500);
311
- const templateRunnerResult = fs.readFileSync(`../pwa-microservices-template/logs/start.js/all.log`, 'utf8');
312
- logger.info('Test template runner result');
313
- console.log(templateRunnerResult);
314
- if (!templateRunnerResult || templateRunnerResult.toLowerCase().match('error')) {
315
- logger.error('Test template runner result failed');
316
- return;
317
- }
318
- killDevServers();
319
- shellCd(`/home/dd/engine`);
320
- Underpost.repo.clean({ paths: ['/home/dd/engine', '/home/dd/engine/engine-private '] });
355
+ const templateOk = await buildAndTestTemplate();
356
+ if (!templateOk) return;
321
357
  }
322
358
 
323
359
  // ── Canonical version files: delegate to bumpp (package.json, package-lock.json,
@@ -424,7 +460,7 @@ class UnderpostRelease {
424
460
  * Runs the pwa-microservices-template update and push flow locally.
425
461
  *
426
462
  * Always removes and re-clones pwa-microservices-template, then:
427
- * 1. Runs update:template (node bin/file update-template) to sync engine sources.
463
+ * 1. Runs update:template (node bin/build.template) to sync engine sources.
428
464
  * 2. Installs dependencies and builds the template.
429
465
  * 3. Commits and pushes to the pwa-microservices-template remote repository.
430
466
  *
@@ -924,8 +924,6 @@ class UnderpostRepository {
924
924
  shellExec(`cd ${privateRepoPath} && underpost pull . ${process.env.GITHUB_USERNAME}/${privateRepoName}`, {
925
925
  silent: true,
926
926
  });
927
- shellExec(`underpost run secret`);
928
- shellExec(`underpost run underpost-config`);
929
927
  const packageJsonDeploy = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/package.json`, 'utf8'));
930
928
  const packageJsonEngine = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
931
929
  if (packageJsonDeploy.version !== packageJsonEngine.version) {
@@ -1051,9 +1049,9 @@ Prevent build private config repo.`,
1051
1049
  */
1052
1050
  clean(options = { paths: [''] }) {
1053
1051
  for (const path of options.paths) {
1054
- shellExec(`cd ${path} && git reset`, { silent: true });
1055
- shellExec(`cd ${path} && git checkout .`, { silent: true });
1056
- shellExec(`cd ${path} && git clean -f -d`, { silent: true });
1052
+ shellExec(`cd ${path} && git reset`, { silentOnError: true, silent: true, disableLog: true });
1053
+ shellExec(`cd ${path} && git checkout .`, { silentOnError: true, silent: true, disableLog: true });
1054
+ shellExec(`cd ${path} && git clean -f -d`, { silentOnError: true, silent: true, disableLog: true });
1057
1055
  }
1058
1056
  },
1059
1057
 
@@ -1107,7 +1105,7 @@ Prevent build private config repo.`,
1107
1105
 
1108
1106
  try {
1109
1107
  // Fetch directory contents recursively
1110
- const copiedFiles = await this._fetchAndCopyGitHubDirectory({
1108
+ const copiedFiles = await this.fetchAndCopyGitHubDirectory({
1111
1109
  apiUrl,
1112
1110
  targetPath,
1113
1111
  basePath: directoryPath,
@@ -1143,7 +1141,7 @@ Prevent build private config repo.`,
1143
1141
  * @returns {Promise<array>} Array of copied file paths.
1144
1142
  * @memberof UnderpostRepository
1145
1143
  */
1146
- async _fetchAndCopyGitHubDirectory(options) {
1144
+ async fetchAndCopyGitHubDirectory(options) {
1147
1145
  const { apiUrl, targetPath, basePath, branch } = options;
1148
1146
  const copiedFiles = [];
1149
1147
 
@@ -1178,14 +1176,12 @@ Prevent build private config repo.`,
1178
1176
 
1179
1177
  logger.info(`Found ${contents.length} items in directory: ${basePath}`);
1180
1178
 
1181
- // Process each item in the directory
1182
1179
  for (const item of contents) {
1183
1180
  const itemTargetPath = `${targetPath}/${item.name}`;
1184
1181
 
1185
1182
  if (item.type === 'file') {
1186
1183
  logger.info(`Downloading file: ${item.path}`);
1187
1184
 
1188
- // Download file content
1189
1185
  const fileResponse = await fetch(item.download_url);
1190
1186
  if (!fileResponse.ok) {
1191
1187
  logger.error(`Failed to download: ${item.download_url}`);
@@ -1195,16 +1191,14 @@ Prevent build private config repo.`,
1195
1191
  const fileContent = await fileResponse.text();
1196
1192
  fs.writeFileSync(itemTargetPath, fileContent);
1197
1193
 
1198
- logger.info(`✓ Saved: ${itemTargetPath}`);
1194
+ logger.info(`Saved: ${itemTargetPath}`);
1199
1195
  copiedFiles.push(itemTargetPath);
1200
1196
  } else if (item.type === 'dir') {
1201
- logger.info(`📁 Processing directory: ${item.path}`);
1197
+ logger.info(`Processing directory: ${item.path}`);
1202
1198
 
1203
- // Create subdirectory
1204
1199
  fs.mkdirSync(itemTargetPath, { recursive: true });
1205
1200
 
1206
- // Recursively process subdirectory
1207
- const subFiles = await this._fetchAndCopyGitHubDirectory({
1201
+ const subFiles = await this.fetchAndCopyGitHubDirectory({
1208
1202
  apiUrl: item.url,
1209
1203
  targetPath: itemTargetPath,
1210
1204
  basePath: item.path,
@@ -1212,7 +1206,7 @@ Prevent build private config repo.`,
1212
1206
  });
1213
1207
 
1214
1208
  copiedFiles.push(...subFiles);
1215
- logger.info(`✓ Completed directory: ${item.path} (${subFiles.length} files)`);
1209
+ logger.info(`Completed directory: ${item.path} (${subFiles.length} files)`);
1216
1210
  } else {
1217
1211
  logger.warn(`Skipping unknown item type '${item.type}': ${item.path}`);
1218
1212
  }
@@ -1384,7 +1378,7 @@ Prevent build private config repo.`,
1384
1378
  }
1385
1379
  shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
1386
1380
  shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
1387
-
1381
+ shellExec(`cd "${repoPath}" && git config core.filemode false`);
1388
1382
  if (origin) {
1389
1383
  const currentRemote = shellExec(`cd "${repoPath}" && git remote get-url origin`, {
1390
1384
  stdout: true,
package/src/cli/run.js CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  awaitDeployMonitor,
11
11
  buildKindPorts,
12
12
  Config,
13
+ cronDeployIdResolve,
14
+ etcHostFactory,
13
15
  getNpmRootPath,
14
16
  isDeployRunnerContext,
15
17
  loadConfServerJson,
@@ -116,6 +118,7 @@ const logger = loggerFactory(import.meta);
116
118
  * @property {boolean} copy - Whether to copy the command to the clipboard instead of executing it.
117
119
  * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
118
120
  * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
121
+ * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
119
122
  * @memberof UnderpostRun
120
123
  */
121
124
  const DEFAULT_OPTION = {
@@ -183,6 +186,7 @@ const DEFAULT_OPTION = {
183
186
  copy: false,
184
187
  skipFullBuild: false,
185
188
  pullBundle: false,
189
+ remove: false,
186
190
  };
187
191
 
188
192
  /**
@@ -212,28 +216,39 @@ class UnderpostRun {
212
216
  'dev-cluster': (path, options = DEFAULT_OPTION) => {
213
217
  const baseCommand = options.dev ? 'node bin' : 'underpost';
214
218
  const mongoHosts = ['mongodb-0.mongodb-service'];
219
+ let primaryMongoHost = 'mongodb-0.mongodb-service';
215
220
  if (!options.expose) {
216
221
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --reset`);
217
222
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
218
223
 
219
224
  shellExec(
220
- `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --mongo-db-host ${mongoHosts.join(
225
+ `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb4 --service-host ${mongoHosts.join(
221
226
  ',',
222
227
  )} --pull-image`,
223
228
  );
224
229
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --valkey --pull-image`);
225
230
  }
226
-
227
- {
228
- // Detect MongoDB primary pod using method
229
- let primaryMongoHost = 'mongodb-0.mongodb-service';
231
+ if (options.k3s) {
232
+ if (options.remove) {
233
+ shellExec(`${baseCommand} lxd --delete-expose k3s-control:27017`);
234
+ shellExec(`${baseCommand} lxd --delete-expose k3s-control:6379`);
235
+ } else {
236
+ shellExec(`${baseCommand} lxd --expose k3s-control:27017 --node-port 32017`);
237
+ shellExec(`${baseCommand} lxd --expose k3s-control:6379 --node-port 32079`);
238
+ }
239
+ shellExec(`lxc config device show k3s-control`);
240
+ } else {
230
241
  try {
231
- const primaryPodName = MongoBootstrap.getPrimaryPodName({
232
- namespace: options.namespace,
233
- podName: 'mongodb-0',
234
- });
235
- // shellExec(`${baseCommand} deploy --expose --disable-update-underpost-config mongo`, { async: true });
236
- shellExec(`kubectl port-forward -n ${options.namespace} pod/${primaryPodName} 27017:27017`, { async: true });
242
+ const primaryPodName =
243
+ MongoBootstrap.getPrimaryPodName({
244
+ namespace: options.namespace,
245
+ podName: 'mongodb-0',
246
+ disableAuth: options.dev,
247
+ }) || 'mongodb-0';
248
+ shellExec(
249
+ `${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config mongo`,
250
+ { async: true },
251
+ );
237
252
  shellExec(
238
253
  `${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config valkey`,
239
254
  { async: true },
@@ -244,10 +259,9 @@ class UnderpostRun {
244
259
  default: primaryMongoHost,
245
260
  });
246
261
  }
247
-
248
- const hostListenResult = Underpost.deploy.etcHostFactory([primaryMongoHost]);
249
- logger.info(hostListenResult.renderHosts);
250
262
  }
263
+ const hostListenResult = etcHostFactory([primaryMongoHost]);
264
+ logger.info(hostListenResult.renderHosts);
251
265
  },
252
266
 
253
267
  /**
@@ -532,6 +546,7 @@ class UnderpostRun {
532
546
  */
533
547
  clean: (path = '', options = DEFAULT_OPTION) => {
534
548
  Underpost.repo.clean({ paths: path ? path.split(',') : ['/home/dd/engine', '/home/dd/engine/engine-private'] });
549
+ if (options.dev) shellExec(`node bin run shared-dir ${path ? path : '/home/dd/engine'}`);
535
550
  },
536
551
  /**
537
552
  * @method pull
@@ -1159,7 +1174,7 @@ EOF
1159
1174
  );
1160
1175
  }
1161
1176
  if (options.etcHosts) {
1162
- const hostListenResult = Underpost.deploy.etcHostFactory(etcHosts);
1177
+ const hostListenResult = etcHostFactory(etcHosts);
1163
1178
  logger.info(hostListenResult.renderHosts);
1164
1179
  }
1165
1180
  },
@@ -1531,7 +1546,8 @@ EOF`);
1531
1546
  `git config user.name '${username}' && ` +
1532
1547
  `git config user.email '${email}' && ` +
1533
1548
  `git config credential.interactive always &&` +
1534
- `git config pull.rebase false`,
1549
+ `git config pull.rebase false && ` +
1550
+ `git config core.filemode false`,
1535
1551
  {
1536
1552
  disableLog: true,
1537
1553
  silent: true,
@@ -1908,7 +1924,7 @@ EOF`);
1908
1924
  );
1909
1925
  } else logger.error(`Service pod ${podToMonitor} failed to start in time.`);
1910
1926
  if (options.etcHosts === true) {
1911
- const hostListenResult = Underpost.deploy.etcHostFactory([host]);
1927
+ const hostListenResult = etcHostFactory([host]);
1912
1928
  logger.info(hostListenResult.renderHosts);
1913
1929
  }
1914
1930
  },
@@ -1926,7 +1942,7 @@ EOF`);
1926
1942
  const confServer = loadConfServerJson(`./engine-private/conf/${options.deployId}/conf.server.json`);
1927
1943
  hosts.push(...Object.keys(confServer));
1928
1944
  }
1929
- const hostListenResult = Underpost.deploy.etcHostFactory(hosts);
1945
+ const hostListenResult = etcHostFactory(hosts);
1930
1946
  logger.info(hostListenResult.renderHosts);
1931
1947
  },
1932
1948
 
@@ -2255,11 +2271,14 @@ EOF`);
2255
2271
  port = parseInt(port);
2256
2272
  sumPortOffSet = parseInt(sumPortOffSet);
2257
2273
  for (const sumPort of range(0, sumPortOffSet))
2258
- shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})`, {
2259
- silentOnError: true,
2260
- });
2274
+ shellExec(
2275
+ `PIDS=$(lsof -t -i:${parseInt(port) + parseInt(sumPort)}); [ -n "$PIDS" ] && sudo kill -9 $PIDS || true`,
2276
+ {
2277
+ silentOnError: true,
2278
+ },
2279
+ );
2261
2280
  } else
2262
- shellExec(`sudo kill -9 $(lsof -t -i:${_path})`, {
2281
+ shellExec(`PIDS=$(lsof -t -i:${_path}); [ -n "$PIDS" ] && sudo kill -9 $PIDS || true`, {
2263
2282
  silentOnError: true,
2264
2283
  });
2265
2284
  }
@@ -2308,9 +2327,10 @@ EOF`);
2308
2327
  * @memberof UnderpostRun
2309
2328
  */
2310
2329
  secret: (path, options = DEFAULT_OPTION) => {
2311
- const secretPath = path ? path : `/home/dd/engine/engine-private/conf/dd-cron/.env.production`;
2312
- const command = `${options.dev ? 'node bin' : 'underpost'} secret underpost --create-from-file ${secretPath}`;
2313
- shellExec(command);
2330
+ const cronDeployId = cronDeployIdResolve() || 'dd-cron';
2331
+ Underpost.secret.underpost.createFromEnvFile(
2332
+ `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${options.dev ? 'development' : 'production'}`,
2333
+ );
2314
2334
  },
2315
2335
  /**
2316
2336
  * @method underpost-config
@@ -2620,7 +2640,28 @@ EOF`;
2620
2640
  },
2621
2641
 
2622
2642
  /**
2623
- * @method setup-shared-dir
2643
+ * @method monitor-ui
2644
+ * @description Installs and enables the Cockpit KVM Dashboard (cockpit, cockpit-machines, libvirt)
2645
+ * and opens the cockpit firewall service. With `--remove`, closes the firewall service instead.
2646
+ * @param {string} path - Unused.
2647
+ * @param {Object} options - The default underpost runner options for customizing workflow.
2648
+ * `options.remove` — when true, removes the cockpit firewall rule instead of adding it.
2649
+ * @memberof UnderpostRun
2650
+ */
2651
+ 'monitor-ui': (path, options = DEFAULT_OPTION) => {
2652
+ if (options.remove) {
2653
+ shellExec(`sudo firewall-cmd --zone=public --remove-service=cockpit --permanent`);
2654
+ shellExec(`sudo firewall-cmd --reload`);
2655
+ return;
2656
+ }
2657
+ shellExec(`sudo dnf install -y cockpit cockpit-machines libvirt`);
2658
+ shellExec(`sudo systemctl enable --now cockpit.socket libvirtd`);
2659
+ shellExec(`sudo firewall-cmd --permanent --add-service=cockpit`);
2660
+ shellExec(`sudo firewall-cmd --reload`);
2661
+ },
2662
+
2663
+ /**
2664
+ * @method shared-dir
2624
2665
  * @description Run once for initial shared-directory setup. Creates the group, adds the user,
2625
2666
  * creates the directory, sets ownership, applies the SGID bit, and configures default ACLs so
2626
2667
  * all future files inside the directory automatically inherit group write permissions.
@@ -2631,7 +2672,7 @@ EOF`;
2631
2672
  * Key fields: `options.user` (default `'admin'`), `options.group` (default `'engine-dev'`).
2632
2673
  * @memberof UnderpostRun
2633
2674
  */
2634
- 'setup-shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
2675
+ 'shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
2635
2676
  const dir = path || '/home/dd/engine';
2636
2677
  const user = options.user || 'admin';
2637
2678
  const group = options.group || 'engine-dev';
@@ -2648,30 +2689,6 @@ EOF`;
2648
2689
 
2649
2690
  logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2650
2691
  },
2651
-
2652
- /**
2653
- * @method reload-shared-dir
2654
- * @description Re-applies recursive permissions and ACLs to repair permission drift on an
2655
- * already-configured shared directory. Does **not** recreate the group, add users, or modify
2656
- * ownership. Use this after VS Code permission errors or when existing files lose group write
2657
- * access due to tool or process interference.
2658
- * @param {string} path - Target directory to repair (defaults to `/home/dd/engine`).
2659
- * Customise via the `path` argument or leave empty to use the default.
2660
- * @param {Object} options - The default underpost runner options for customizing workflow.
2661
- * Key fields: `options.group` (default `'engine-dev'`).
2662
- * @memberof UnderpostRun
2663
- */
2664
- 'reload-shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
2665
- const dir = path || '/home/dd/engine';
2666
- const group = options.group || 'engine-dev';
2667
-
2668
- logger.info(`[reload-shared-dir] dir=${dir} group=${group}`);
2669
-
2670
- shellExec(`sudo chmod -R 2775 ${dir}`);
2671
- shellExec(`sudo setfacl -R -m g:${group}:rwx ${dir}`);
2672
-
2673
- logger.info(`[reload-shared-dir] Shared directory permissions reloaded: ${dir}`);
2674
- },
2675
2692
  };
2676
2693
 
2677
2694
  static API = {
@@ -2728,14 +2745,14 @@ EOF`;
2728
2745
  if (options.replicas === '' || options.replicas === null || options.replicas === undefined)
2729
2746
  options.replicas = 1;
2730
2747
  options.npmRoot = npmRoot;
2731
- logger.info('callback', { path, options });
2748
+ logger.info(`Executing runner`, { runner, namespace: options.namespace });
2732
2749
  if (!Underpost.run.RUNNERS.includes(runner)) throw new Error(`Runner not found: ${runner}`);
2733
2750
  const result = await Underpost.run.CALL(runner, path, options);
2734
2751
  return result;
2735
2752
  } catch (error) {
2736
2753
  console.log(error);
2737
2754
  logger.error(error);
2738
- return null;
2755
+ process.exit(1);
2739
2756
  }
2740
2757
  },
2741
2758
  };
@@ -26,17 +26,26 @@ class UnderpostSecret {
26
26
  * @memberof UnderpostSecret
27
27
  */
28
28
  underpost: {
29
- createFromEnvFile(envPath) {
29
+ /**
30
+ * @method createFromEnvFile
31
+ * @description Reads application secrets from a .env file and writes them to the underpost .env file. Used for local development and testing.
32
+ * @param {string} envPath - The path to the .env file to read secrets from. Defaults to './.env'.
33
+ * @memberof UnderpostSecret
34
+ */
35
+ createFromEnvFile(envPath = './.env') {
30
36
  Underpost.env.clean();
31
37
  const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
32
38
  for (const key of Object.keys(envObj)) {
33
39
  Underpost.env.set(key, envObj[key]);
34
40
  }
35
41
  },
36
- /** Reads application secrets from process.env (injected via envFrom: secretRef)
42
+ /**
43
+ * @method createFromContainerEnv
44
+ * @description Reads application secrets from process.env (injected via envFrom: secretRef)
37
45
  * and writes them to the underpost .env file, filtering out known system and
38
46
  * Kubernetes-injected environment variables. Replaces the fragile shell-based
39
47
  * `printenv | grep -vE` pattern with a maintainable Node.js blocklist.
48
+ * @memberof UnderpostSecret
40
49
  */
41
50
  createFromContainerEnv() {
42
51
  Underpost.env.clean();
@@ -319,10 +319,11 @@ class Auth {
319
319
  // Close any open login/signup modals
320
320
  if (s(`.modal-log-in`)) s(`.btn-close-modal-log-in`).click();
321
321
  if (s(`.modal-sign-up`)) s(`.btn-close-modal-sign-up`).click();
322
- if (!s(`.main-body-btn-ui-open`).classList.contains('hide'))
323
- s(`.main-body-btn-ui-open`).click();
324
- if (!s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide'))
322
+ if (!s(`.main-body-btn-ui-open`).classList.contains('hide')) s(`.main-body-btn-ui-open`).click();
323
+ if (!s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide')) {
324
+ SearchBox.Data.skipOpen = true;
325
325
  s(`.main-body-btn-ui-bar-custom-open`).click();
326
+ }
326
327
  });
327
328
  }
328
329
 
@@ -45,6 +45,79 @@ const AppointmentEventType = {
45
45
  submitted: 'appointment:submitted',
46
46
  };
47
47
 
48
+ const ModalEventType = {
49
+ close: 'modal:close',
50
+ menu: 'modal:menu',
51
+ collapseMenu: 'modal:collapse-menu',
52
+ extendMenu: 'modal:extend-menu',
53
+ dragEnd: 'modal:drag-end',
54
+ observer: 'modal:observer',
55
+ click: 'modal:click',
56
+ expandUi: 'modal:expand-ui',
57
+ barUiOpen: 'modal:bar-ui-open',
58
+ barUiClose: 'modal:bar-ui-close',
59
+ reload: 'modal:reload',
60
+ home: 'modal:home',
61
+ };
62
+
63
+ const ModalListenerChannels = {
64
+ onCloseListener: ModalEventType.close,
65
+ onMenuListener: ModalEventType.menu,
66
+ onCollapseMenuListener: ModalEventType.collapseMenu,
67
+ onExtendMenuListener: ModalEventType.extendMenu,
68
+ onDragEndListener: ModalEventType.dragEnd,
69
+ onObserverListener: ModalEventType.observer,
70
+ onClickListener: ModalEventType.click,
71
+ onExpandUiListener: ModalEventType.expandUi,
72
+ onBarUiOpen: ModalEventType.barUiOpen,
73
+ onBarUiClose: ModalEventType.barUiClose,
74
+ onReloadModalListener: ModalEventType.reload,
75
+ onHome: ModalEventType.home,
76
+ };
77
+
78
+ const createModalEventChannel = (bus, type) => {
79
+ const busKey = (key) => `${type}::${key}`;
80
+ return new Proxy(
81
+ {},
82
+ {
83
+ get(_target, prop) {
84
+ if (typeof prop === 'symbol') return undefined;
85
+ const key = busKey(prop);
86
+ if (!bus.has(key)) return undefined;
87
+ return (detail) => bus.emitKey(key, detail);
88
+ },
89
+ set(_target, prop, value) {
90
+ if (typeof prop !== 'symbol' && typeof value === 'function') bus.on(type, value, { key: busKey(prop) });
91
+ return true;
92
+ },
93
+ deleteProperty(_target, prop) {
94
+ if (typeof prop !== 'symbol') bus.off(busKey(prop));
95
+ return true;
96
+ },
97
+ has(_target, prop) {
98
+ return typeof prop !== 'symbol' && bus.has(busKey(prop));
99
+ },
100
+ ownKeys() {
101
+ const prefix = `${type}::`;
102
+ return bus.keysOf(type).map((key) => String(key).slice(prefix.length));
103
+ },
104
+ getOwnPropertyDescriptor(_target, prop) {
105
+ if (typeof prop !== 'symbol' && bus.has(busKey(prop)))
106
+ return { enumerable: true, configurable: true, writable: true, value: undefined };
107
+ return undefined;
108
+ },
109
+ },
110
+ );
111
+ };
112
+
113
+ // One EventBus per modal id, surfaced through the legacy channel names.
114
+ const createModalEvents = () => {
115
+ const bus = new EventBus();
116
+ const channels = {};
117
+ for (const [name, type] of Object.entries(ModalListenerChannels)) channels[name] = createModalEventChannel(bus, type);
118
+ return { bus, channels };
119
+ };
120
+
48
121
  const authLoginEvents = new EventBus();
49
122
  const authLogoutEvents = new EventBus();
50
123
  const authSignupEvents = new EventBus();
@@ -70,6 +143,9 @@ export {
70
143
  KeyboardEventType,
71
144
  AccountEventType,
72
145
  AppointmentEventType,
146
+ ModalEventType,
147
+ ModalListenerChannels,
148
+ createModalEvents,
73
149
  authLoginEvents,
74
150
  authLogoutEvents,
75
151
  authSignupEvents,
@@ -69,6 +69,10 @@ class EventBus {
69
69
  return this.listeners.has(key);
70
70
  }
71
71
 
72
+ keysOf(type) {
73
+ return [...(this.typeKeys.get(type) ?? [])];
74
+ }
75
+
72
76
  async emit(type, detail) {
73
77
  if (!(this.typeKeys.get(type)?.size > 0)) return;
74
78