underpost 3.2.9 → 3.2.10

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 (81) 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/settings.json +10 -5
  5. package/CHANGELOG.md +122 -1
  6. package/CLI-HELP.md +22 -7
  7. package/README.md +37 -8
  8. package/bin/build.js +26 -9
  9. package/bin/deploy.js +20 -21
  10. package/bin/file.js +31 -13
  11. package/bin/index.js +2 -1
  12. package/bin/vs.js +1 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -4
  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/mongodb/pv-pvc.yaml +44 -8
  21. package/manifests/mongodb/statefulset.yaml +55 -68
  22. package/package.json +27 -12
  23. package/scripts/k3s-node-setup.sh +28 -9
  24. package/src/api/core/core.router.js +19 -14
  25. package/src/api/core/core.service.js +5 -5
  26. package/src/api/default/default.router.js +22 -18
  27. package/src/api/default/default.service.js +5 -5
  28. package/src/api/document/document.router.js +28 -23
  29. package/src/api/document/document.service.js +100 -23
  30. package/src/api/file/file.router.js +19 -13
  31. package/src/api/file/file.service.js +9 -7
  32. package/src/api/test/test.router.js +17 -12
  33. package/src/api/types.js +24 -0
  34. package/src/api/user/guest.service.js +5 -4
  35. package/src/api/user/user.router.js +297 -288
  36. package/src/api/user/user.service.js +100 -35
  37. package/src/cli/baremetal.js +20 -11
  38. package/src/cli/cluster.js +196 -55
  39. package/src/cli/db.js +59 -60
  40. package/src/cli/deploy.js +273 -159
  41. package/src/cli/fs.js +3 -1
  42. package/src/cli/index.js +16 -9
  43. package/src/cli/ipfs.js +4 -6
  44. package/src/cli/kubectl.js +4 -1
  45. package/src/cli/lxd.js +217 -135
  46. package/src/cli/release.js +289 -131
  47. package/src/cli/repository.js +58 -7
  48. package/src/cli/run.js +152 -25
  49. package/src/cli/test.js +9 -3
  50. package/src/client/Default.index.js +9 -3
  51. package/src/client/components/core/Auth.js +4 -0
  52. package/src/client/components/core/PanelForm.js +56 -52
  53. package/src/client/components/core/Worker.js +162 -363
  54. package/src/client/sw/core.sw.js +174 -112
  55. package/src/db/DataBaseProvider.js +120 -20
  56. package/src/db/mongo/MongoBootstrap.js +587 -0
  57. package/src/db/mongo/MongooseDB.js +126 -22
  58. package/src/index.js +1 -1
  59. package/src/runtime/express/Express.js +2 -2
  60. package/src/runtime/wp/Wp.js +8 -5
  61. package/src/server/auth.js +2 -2
  62. package/src/server/client-build-docs.js +1 -1
  63. package/src/server/client-build.js +94 -129
  64. package/src/server/conf.js +20 -65
  65. package/src/server/process.js +180 -19
  66. package/src/server/runtime.js +1 -1
  67. package/src/server/start.js +12 -4
  68. package/src/ws/IoInterface.js +16 -16
  69. package/src/ws/core/channels/core.ws.chat.js +11 -11
  70. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  71. package/src/ws/core/channels/core.ws.stream.js +19 -19
  72. package/src/ws/core/core.ws.connection.js +8 -8
  73. package/src/ws/core/core.ws.server.js +6 -5
  74. package/src/ws/default/channels/default.ws.main.js +10 -10
  75. package/src/ws/default/default.ws.connection.js +4 -4
  76. package/src/ws/default/default.ws.server.js +4 -3
  77. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  78. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  79. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  80. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  81. /package/src/client/ssr/{pages → views}/Test.js +0 -0
package/src/cli/run.js CHANGED
@@ -24,6 +24,7 @@ import { range, setPad, timer } from '../client/components/core/CommonJs.js';
24
24
  import os from 'os';
25
25
  import Underpost from '../index.js';
26
26
  import dotenv from 'dotenv';
27
+ import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
27
28
 
28
29
  const waitForPort = (port, host = '127.0.0.1', { maxAttempts = 30, interval = 2000 } = {}) =>
29
30
  new Promise((resolve, reject) => {
@@ -97,6 +98,7 @@ const logger = loggerFactory(import.meta);
97
98
  * @property {string} deployId - The deployment ID.
98
99
  * @property {string} instanceId - The instance ID.
99
100
  * @property {string} user - The user to run as.
101
+ * @property {string} group - The group to use.
100
102
  * @property {string} pid - The process ID.
101
103
  * @property {boolean} disablePrivateConfUpdate - Whether to disable private configuration updates.
102
104
  * @property {string} monitorStatus - The monitor status option.
@@ -165,6 +167,7 @@ const DEFAULT_OPTION = {
165
167
  deployId: '',
166
168
  instanceId: '',
167
169
  user: '',
170
+ group: '',
168
171
  pid: '',
169
172
  disablePrivateConfUpdate: false,
170
173
  monitorStatus: '',
@@ -225,7 +228,7 @@ class UnderpostRun {
225
228
  // Detect MongoDB primary pod using method
226
229
  let primaryMongoHost = 'mongodb-0.mongodb-service';
227
230
  try {
228
- const primaryPodName = Underpost.db.getMongoPrimaryPodName({
231
+ const primaryPodName = MongoBootstrap.getPrimaryPodName({
229
232
  namespace: options.namespace,
230
233
  podName: 'mongodb-0',
231
234
  });
@@ -505,14 +508,16 @@ class UnderpostRun {
505
508
  },
506
509
  /**
507
510
  * @method docker-image
508
- * @description Dispatches the Docker image CI workflow (`docker-image.ci.yml`) for the `engine` repository via `workflow_dispatch`.
509
- * @param {string} path - The input value, identifier, or path for the operation.
511
+ * @description Dispatches the Docker image CI workflow (`docker-image[.<runtime>].ci.yml`) via `workflow_dispatch`.
512
+ * Repository resolution is delegated to `Underpost.repo.resolveInstanceRepo(path)`.
513
+ * @param {string} path - Optional runtime / workflow suffix (e.g. `cyberia-server`, `cyberia-client`).
510
514
  * @param {Object} options - The default underpost runner options for customizing workflow
511
515
  * @memberof UnderpostRun
512
516
  */
513
517
  'docker-image': (path, options = DEFAULT_OPTION) => {
518
+ const repo = Underpost.repo.resolveInstanceRepo(path);
514
519
  Underpost.repo.dispatchWorkflow({
515
- repo: `${process.env.GITHUB_USERNAME}/engine`,
520
+ repo,
516
521
  workflowFile: `docker-image${path ? `.${path}` : ''}.ci.yml`,
517
522
  ref: 'master',
518
523
  inputs: {},
@@ -536,16 +541,14 @@ class UnderpostRun {
536
541
  * @memberof UnderpostRun
537
542
  */
538
543
  pull: (path, options = DEFAULT_OPTION) => {
544
+ // shellExec is fail-fast by default — any non-zero exit throws and
545
+ // propagates up to the workflow step. No per-call flag required.
539
546
  if (!fs.existsSync(`/home/dd`) || !fs.existsSync(`/home/dd/engine`)) {
540
547
  fs.mkdirSync(`/home/dd`, { recursive: true });
541
- shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, {
542
- silent: true,
543
- });
548
+ shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
544
549
  } else {
545
550
  shellExec(`underpost run clean`);
546
- shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, {
547
- silent: true,
548
- });
551
+ shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
549
552
  }
550
553
  if (!fs.existsSync(`/home/dd/engine/engine-private`))
551
554
  shellExec(`cd /home/dd/engine && underpost clone ${process.env.GITHUB_USERNAME}/engine-private`, {
@@ -554,9 +557,7 @@ class UnderpostRun {
554
557
  else
555
558
  shellExec(
556
559
  `cd /home/dd/engine/engine-private && underpost pull . ${process.env.GITHUB_USERNAME}/engine-private`,
557
- {
558
- silent: true,
559
- },
560
+ { silent: true },
560
561
  );
561
562
  },
562
563
  /**
@@ -643,6 +644,11 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
643
644
  /**
644
645
  * @method sync
645
646
  * @description Cleans up, and then runs a deployment synchronization command (`underpost deploy --kubeadm --build-manifest --sync...`) using parameters parsed from `path` (deployId, replicas, versions, image, node).
647
+ *
648
+ * Forwards `--image-pull-policy <policy>` to the underlying `deploy --build-manifest` invocation when `options.imagePullPolicy` is set,
649
+ * which then plumbs through `buildManifest` and `deploymentYamlPartsFactory` to override the container `imagePullPolicy` in the generated
650
+ * `deployment.yaml`. Useful when you want to force `Always` so the kubelet re-pulls a mutable tag on every rollout. Example:
651
+ * `node bin run sync dd-core --kubeadm --image-pull-policy Always`
646
652
  * @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string containing deploy parameters).
647
653
  * @param {Object} options - The default underpost runner options for customizing workflow
648
654
  * @memberof UnderpostRun
@@ -700,13 +706,14 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
700
706
 
701
707
  const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
702
708
  const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
709
+ const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
703
710
 
704
711
  shellExec(
705
712
  `${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
706
713
  image ? ` --image ${image}` : ''
707
714
  }${versions ? ` --versions ${versions}` : ''}${
708
715
  options.namespace ? ` --namespace ${options.namespace}` : ''
709
- }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag} ${deployId} ${env}`,
716
+ }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag} ${deployId} ${env}`,
710
717
  );
711
718
 
712
719
  if (isDeployRunnerContext(path, options)) {
@@ -717,7 +724,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
717
724
  shellExec(
718
725
  `${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
719
726
  options.namespace ? ` --namespace ${options.namespace}` : ''
720
- }${timeoutFlags}${gitCleanFlag}`,
727
+ }${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}`,
721
728
  );
722
729
  if (!targetTraffic)
723
730
  targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
@@ -1023,6 +1030,9 @@ EOF
1023
1030
  cmd: _cmd,
1024
1031
  volumes: _volumes,
1025
1032
  metadata: _metadata,
1033
+ lifecycle: _lifecycle,
1034
+ readinessProbe: _readinessProbe,
1035
+ livenessProbe: _livenessProbe,
1026
1036
  } = instance;
1027
1037
  if (id !== _id) continue;
1028
1038
  const _deployId = `${deployId}-${_id}`;
@@ -1087,6 +1097,20 @@ EOF
1087
1097
  ),
1088
1098
  );
1089
1099
 
1100
+ // Resolve env-scoped lifecycle/probe blocks: each can be either
1101
+ // { ...envObj } // shared shape
1102
+ // { development: {...}, production: {...} } // env-specific
1103
+ const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
1104
+
1105
+ // Convention: an instance config may place `imagePullPolicy` inside
1106
+ // the env-scoped lifecycle block (alongside postStart/preStop).
1107
+ // Extract it onto the container spec (where K8S expects it) and
1108
+ // strip it from the lifecycle hash so the rendered YAML stays valid.
1109
+ // CLI override (`--image-pull-policy`) wins over the conf value.
1110
+ const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
1111
+ Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
1112
+ const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
1113
+
1090
1114
  let deploymentYaml = `---
1091
1115
  ${Underpost.deploy
1092
1116
  .deploymentYamlPartsFactory({
@@ -1099,6 +1123,11 @@ ${Underpost.deploy
1099
1123
  namespace: options.namespace,
1100
1124
  volumes: _volumes,
1101
1125
  cmd: resolvedCmd,
1126
+ lifecycle: lifecycleForManifest,
1127
+ readinessProbe: pickEnv(_readinessProbe),
1128
+ livenessProbe: pickEnv(_livenessProbe),
1129
+ containerPort: _toPort,
1130
+ imagePullPolicy: instanceImagePullPolicy,
1102
1131
  })
1103
1132
  .replace('{{ports}}', buildKindPorts(_fromPort, _toPort))}
1104
1133
  `;
@@ -1187,14 +1216,34 @@ EOF
1187
1216
  volumes: _volumes,
1188
1217
  metadata: _metadata,
1189
1218
  runtime: _runtime,
1219
+ lifecycle: _lifecycle,
1220
+ readinessProbe: _readinessProbe,
1221
+ livenessProbe: _livenessProbe,
1190
1222
  } = instance;
1191
1223
 
1192
- // Resolve Dockerfile source: use runtime-specific path when instance defines a runtime.
1193
- const dockerfileSourcePath = _runtime ? `src/runtime/${_runtime}/Dockerfile` : `${rootPath}/Dockerfile`;
1194
- if (fs.existsSync(dockerfileSourcePath)) {
1224
+ // Resolve Dockerfile source. Dev/prod variant rules:
1225
+ // - When the instance defines a `runtime`, look under
1226
+ // `src/runtime/<runtime>/`. In `--dev` mode prefer `Dockerfile.dev`
1227
+ // when it exists, falling back to `Dockerfile`.
1228
+ // - When `runtime` is not set, look in the project root with the
1229
+ // same `.dev` → no-suffix precedence.
1230
+ // Dockerfile.dev is a full Dockerfile (not an overlay) — each runtime
1231
+ // owns the contract between its dev image and its prod image (debug
1232
+ // build flags, extra tooling, default ports, etc.).
1233
+ const dockerfileBase = _runtime ? `src/runtime/${_runtime}` : rootPath;
1234
+ const dockerfileCandidates = options.dev
1235
+ ? [`${dockerfileBase}/Dockerfile.dev`, `${dockerfileBase}/Dockerfile`]
1236
+ : [`${dockerfileBase}/Dockerfile`];
1237
+ const dockerfileSourcePath = dockerfileCandidates.find((p) => fs.existsSync(p));
1238
+ if (dockerfileSourcePath) {
1239
+ if (options.dev && !dockerfileSourcePath.endsWith('.dev')) {
1240
+ logger.warn(
1241
+ `[instance-build-manifest] --dev requested but no Dockerfile.dev present; falling back to ${dockerfileSourcePath}`,
1242
+ );
1243
+ }
1195
1244
  fs.copyFileSync(dockerfileSourcePath, dockerfileManifestPath);
1196
1245
  } else {
1197
- logger.warn(`[instance-build-manifest] Dockerfile not found at ${dockerfileSourcePath}`);
1246
+ logger.warn(`[instance-build-manifest] Dockerfile not found; tried: ${dockerfileCandidates.join(', ')}`);
1198
1247
  }
1199
1248
 
1200
1249
  const _deployId = `${deployId}-${_id}`;
@@ -1239,6 +1288,17 @@ EOF
1239
1288
  ),
1240
1289
  );
1241
1290
 
1291
+ // Env-aware lifecycle / probe selection. Each block may either be
1292
+ // a single object (shared across envs) or `{ development, production }`.
1293
+ const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
1294
+
1295
+ // Convention: an instance config may place `imagePullPolicy` inside
1296
+ // the env-scoped lifecycle block (alongside postStart/preStop).
1297
+ // Extract it onto the container spec and strip it from the lifecycle hash.
1298
+ const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
1299
+ Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
1300
+ const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
1301
+
1242
1302
  const deploymentYaml =
1243
1303
  `---\n` +
1244
1304
  Underpost.deploy
@@ -1252,6 +1312,11 @@ EOF
1252
1312
  namespace: options.namespace,
1253
1313
  volumes: _volumes,
1254
1314
  cmd: resolvedCmd,
1315
+ lifecycle: lifecycleForManifest,
1316
+ readinessProbe: pickEnv(_readinessProbe),
1317
+ livenessProbe: pickEnv(_livenessProbe),
1318
+ containerPort: _toPort,
1319
+ imagePullPolicy: instanceImagePullPolicy,
1255
1320
  })
1256
1321
  .replace('{{ports}}', buildKindPorts(_fromPort, _toPort));
1257
1322
 
@@ -1330,9 +1395,9 @@ EOF`);
1330
1395
  // crictl is in the kubernetes repo but excluded by default — install it explicitly
1331
1396
  shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
1332
1397
  // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1333
- shellExec(
1334
- `sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf 2>/dev/null || true`,
1335
- );
1398
+ shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
1399
+ silentOnError: true,
1400
+ });
1336
1401
  shellExec(`sudo systemctl enable --now crio`);
1337
1402
  logger.info('CRI-O installed and started.');
1338
1403
  // Write crictl config so all crictl calls default to the CRI-O socket
@@ -2180,15 +2245,23 @@ EOF`);
2180
2245
  * @memberof UnderpostRun
2181
2246
  */
2182
2247
  kill: (path = '', options = DEFAULT_OPTION) => {
2183
- if (options.pid) return shellExec(`sudo kill -9 ${options.pid}`);
2248
+ if (options.pid)
2249
+ return shellExec(`sudo kill -9 ${options.pid}`, {
2250
+ silentOnError: true,
2251
+ });
2184
2252
  for (const _path of path.split(',')) {
2185
2253
  if (_path.split('+')[1]) {
2186
2254
  let [port, sumPortOffSet] = _path.split('+');
2187
2255
  port = parseInt(port);
2188
2256
  sumPortOffSet = parseInt(sumPortOffSet);
2189
2257
  for (const sumPort of range(0, sumPortOffSet))
2190
- shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})`);
2191
- } else shellExec(`sudo kill -9 $(lsof -t -i:${_path})`);
2258
+ shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})`, {
2259
+ silentOnError: true,
2260
+ });
2261
+ } else
2262
+ shellExec(`sudo kill -9 $(lsof -t -i:${_path})`, {
2263
+ silentOnError: true,
2264
+ });
2192
2265
  }
2193
2266
  },
2194
2267
  /**
@@ -2545,6 +2618,60 @@ EOF`;
2545
2618
  }
2546
2619
  }
2547
2620
  },
2621
+
2622
+ /**
2623
+ * @method setup-shared-dir
2624
+ * @description Run once for initial shared-directory setup. Creates the group, adds the user,
2625
+ * creates the directory, sets ownership, applies the SGID bit, and configures default ACLs so
2626
+ * all future files inside the directory automatically inherit group write permissions.
2627
+ * Use `reload-shared-dir` for subsequent permission repairs without recreating the group.
2628
+ * @param {string} path - Target directory to set up (defaults to `/home/dd/engine`).
2629
+ * Customise via the `path` argument or leave empty to use the default.
2630
+ * @param {Object} options - The default underpost runner options for customizing workflow.
2631
+ * Key fields: `options.user` (default `'admin'`), `options.group` (default `'engine-dev'`).
2632
+ * @memberof UnderpostRun
2633
+ */
2634
+ 'setup-shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
2635
+ const dir = path || '/home/dd/engine';
2636
+ const user = options.user || 'admin';
2637
+ const group = options.group || 'engine-dev';
2638
+
2639
+ logger.info(`[setup-shared-dir] dir=${dir} user=${user} group=${group}`);
2640
+
2641
+ shellExec(`sudo groupadd ${group} 2>/dev/null || true`);
2642
+ shellExec(`sudo usermod -aG ${group} ${user}`);
2643
+ shellExec(`sudo mkdir -p ${dir}`);
2644
+ shellExec(`sudo chown -R ${user}:${group} ${dir}`);
2645
+ shellExec(`sudo chmod -R 2775 ${dir}`);
2646
+ shellExec(`sudo setfacl -d -m g:${group}:rwx ${dir}`);
2647
+ shellExec(`sudo setfacl -m g:${group}:rwx ${dir}`);
2648
+
2649
+ logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2650
+ },
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
+ },
2548
2675
  };
2549
2676
 
2550
2677
  static API = {
package/src/cli/test.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * @namespace UnderpostTest
5
5
  */
6
6
 
7
+ import fs from 'fs-extra';
7
8
  import { timer } from '../client/components/core/CommonJs.js';
8
9
  import { MariaDB } from '../db/mariadb/MariaDB.js';
9
10
  import { getNpmRootPath } from '../server/conf.js';
@@ -42,7 +43,13 @@ class UnderpostTest {
42
43
  */
43
44
  run() {
44
45
  actionInitLog();
45
- shellExec(`cd ${getNpmRootPath()}/underpost && npm run test`);
46
+ const underpostTestPath = `${getNpmRootPath()}/underpost`;
47
+ if (fs.existsSync(underpostTestPath)) {
48
+ shellExec(`cd ${underpostTestPath} && npm run test`);
49
+ } else {
50
+ logger.warn(`Global underpost not found at ${underpostTestPath}, running local npm test instead`);
51
+ shellExec('npm test');
52
+ }
46
53
  },
47
54
  /**
48
55
  * @method callback
@@ -148,8 +155,7 @@ class UnderpostTest {
148
155
  const pods = Underpost.kubectl.get(podName, kindType);
149
156
  let result = pods.find((p) => p.STATUS === status || (status === 'Running' && p.STATUS === 'Completed'));
150
157
  logger.info(
151
- `Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${
152
- index + 1
158
+ `Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${index + 1
153
159
  }/${maxAttempts}`,
154
160
  pods[0] ? pods[0].STATUS : 'Not found kind object',
155
161
  );
@@ -19,6 +19,12 @@ const DefaultTemplate = async () => {
19
19
  });
20
20
  });
21
21
  return html`
22
+ <style>
23
+ .feature-icon {
24
+ font-size: 2.5rem;
25
+ margin-bottom: 1rem;
26
+ }
27
+ </style>
22
28
  <div class="landing-container">
23
29
  <div class="content-wrapper">
24
30
  <h1 class="animated-text">
@@ -27,17 +33,17 @@ const DefaultTemplate = async () => {
27
33
  </h1>
28
34
  <div class="features">
29
35
  <div class="feature-card">
30
- <i class="icon">🚀</i>
36
+ <i class="fas fa-rocket feature-icon"></i>
31
37
  <h3>Fast &amp; Reliable</h3>
32
38
  <p>Lightning-fast performance with 99.9% uptime</p>
33
39
  </div>
34
40
  <div class="feature-card">
35
- <i class="icon">🎨</i>
41
+ <i class="fas fa-palette feature-icon"></i>
36
42
  <h3>Beautiful UI</h3>
37
43
  <p>Modern and intuitive user interface</p>
38
44
  </div>
39
45
  <div class="feature-card">
40
- <i class="icon">⚡</i>
46
+ <i class="fas fa-bolt feature-icon"></i>
41
47
  <h3>Powerful Features</h3>
42
48
  <p>Everything you need in one place</p>
43
49
  </div>
@@ -319,6 +319,10 @@ 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'))
325
+ s(`.main-body-btn-ui-bar-custom-open`).click();
322
326
  });
323
327
  }
324
328
 
@@ -73,7 +73,7 @@ class PanelForm {
73
73
  parentIdModal: undefined,
74
74
  route: 'home',
75
75
  htmlFormHeader: async () => '',
76
- firsUpdateEvent: async () => {},
76
+ firsUpdateEvent: async () => { },
77
77
  share: {
78
78
  copyLink: false,
79
79
  copySourceMd: false,
@@ -196,12 +196,12 @@ class PanelForm {
196
196
  <img
197
197
  class="abs center"
198
198
  style="${renderCssAttr({
199
- style: {
200
- width: '100px',
201
- height: '100px',
202
- opacity: 0.2,
203
- },
204
- })}"
199
+ style: {
200
+ width: '100px',
201
+ height: '100px',
202
+ opacity: 0.2,
203
+ },
204
+ })}"
205
205
  src="${defaultUrlImage}"
206
206
  />
207
207
  `,
@@ -332,16 +332,10 @@ class PanelForm {
332
332
  }, 50);
333
333
  },
334
334
  initEdit: async function ({ data }) {
335
- // Clear file input when entering edit mode
336
- const fileFormData = formData.find((f) => f.inputType === 'file');
337
- if (fileFormData && s(`.${fileFormData.id}`)) {
338
- s(`.${fileFormData.id}`).value = '';
339
- s(`.${fileFormData.id}`).inputFiles = null;
340
- htmls(
341
- `.file-name-render-${fileFormData.id}`,
342
- `<div class="abs center"><i style="font-size: 25px" class="fa-solid fa-cloud"></i></div>`,
343
- );
344
- }
335
+ // Do NOT clear the file input here - the file should remain as-is when entering edit mode.
336
+ // If user wants to remove the file, they use the "clean file" button.
337
+ // If user wants to replace the file, they select a new file.
338
+ // Unconditionally clearing the file here would cause the server to receive fileId: null on save.
345
339
  setTimeout(() => {
346
340
  s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
347
341
  }, 50);
@@ -388,15 +382,15 @@ class PanelForm {
388
382
  // It will be filtered from the tags array to keep visibility control separate from content tags
389
383
  const tags = data.tags
390
384
  ? uniqueArray(
391
- data.tags
392
- .replaceAll('/', ',')
393
- .replaceAll('-', ',')
394
- .replaceAll(' ', ',')
395
- .split(',')
396
- .map((t) => t.trim())
397
- .filter((t) => t)
398
- .concat(prefixTags),
399
- )
385
+ data.tags
386
+ .replaceAll('/', ',')
387
+ .replaceAll('-', ',')
388
+ .replaceAll(' ', ',')
389
+ .split(',')
390
+ .map((t) => t.trim())
391
+ .filter((t) => t)
392
+ .concat(prefixTags),
393
+ )
400
394
  : prefixTags;
401
395
  let originObj, originFileObj, indexOriginObj;
402
396
  if (editId) {
@@ -434,7 +428,17 @@ class PanelForm {
434
428
  for (const file of inputFiles) {
435
429
  indexFormDoc++;
436
430
  let fileId = undefined; // Reset for each iteration - only set if user uploaded a file
431
+ // Track whether the file input was explicitly cleared (null) vs never had a file (undefined)
432
+ // In edit mode, null means user cleared the file - we need to tell server to remove it
433
+ const isFileCleared = data.fileId === null && editId;
437
434
  await (async () => {
435
+ // When file is null and not the first iteration or not in edit mode, skip upload
436
+ if (!file && !isFileCleared) return;
437
+ // When user cleared file in edit mode, set fileId=null so server removes the reference
438
+ if (isFileCleared) {
439
+ fileId = null;
440
+ return;
441
+ }
438
442
  const body = new FormData();
439
443
  // Only append md file if it was created (has content)
440
444
  if (md) body.append('md', md);
@@ -485,8 +489,8 @@ class PanelForm {
485
489
  message: documentMessage,
486
490
  data: documentData,
487
491
  } = originObj && indexFormDoc === 0
488
- ? await DocumentService.put({ id: originObj._id, body })
489
- : await DocumentService.post({
492
+ ? await DocumentService.put({ id: originObj._id, body })
493
+ : await DocumentService.post({
490
494
  body,
491
495
  });
492
496
  const newDoc = {
@@ -514,12 +518,12 @@ class PanelForm {
514
518
  fileId: {
515
519
  fileBlob: file
516
520
  ? {
517
- data: {
518
- data: await getDataFromInputFile(file),
519
- },
520
- mimetype: file.type,
521
- name: file.name,
522
- }
521
+ data: {
522
+ data: await getDataFromInputFile(file),
523
+ },
524
+ mimetype: file.type,
525
+ name: file.name,
526
+ }
523
527
  : undefined,
524
528
  filePlain: undefined,
525
529
  },
@@ -738,36 +742,36 @@ class PanelForm {
738
742
  <div
739
743
  class="in fll ssr-shimmer-search-box"
740
744
  style="${renderCssAttr({
741
- style: {
742
- width: '80%',
743
- height: '30px',
744
- top: '-13px',
745
- left: '10px',
746
- },
747
- })}"
745
+ style: {
746
+ width: '80%',
747
+ height: '30px',
748
+ top: '-13px',
749
+ left: '10px',
750
+ },
751
+ })}"
748
752
  ></div>
749
753
  </div>`,
750
754
  createdAt: html`<div class="fl">
751
755
  <div
752
756
  class="in fll ssr-shimmer-search-box"
753
757
  style="${renderCssAttr({
754
- style: {
755
- width: '50%',
756
- height: '30px',
757
- left: '-5px',
758
- },
759
- })}"
758
+ style: {
759
+ width: '50%',
760
+ height: '30px',
761
+ left: '-5px',
762
+ },
763
+ })}"
760
764
  ></div>
761
765
  </div>`,
762
766
  mdFileId: html`<div class="fl section-mp">
763
767
  <div
764
768
  class="in fll ssr-shimmer-search-box"
765
769
  style="${renderCssAttr({
766
- style: {
767
- width: '80%',
768
- height: '30px',
769
- },
770
- })}"
770
+ style: {
771
+ width: '80%',
772
+ height: '30px',
773
+ },
774
+ })}"
771
775
  ></div>
772
776
  </div>`.repeat(random(2, 4)),
773
777
  ssr: true,