underpost 3.2.70 → 3.2.80

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 (38) hide show
  1. package/.github/workflows/publish.ci.yml +3 -3
  2. package/.github/workflows/release.cd.yml +1 -1
  3. package/CHANGELOG.md +1177 -1038
  4. package/CLI-HELP.md +3 -1
  5. package/README.md +3 -3
  6. package/bin/build.js +10 -4
  7. package/docker-compose.yml +1 -1
  8. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  9. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  10. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  11. package/package.json +10 -10
  12. package/scripts/test-monitor.sh +1 -1
  13. package/src/api/core/core.controller.js +4 -65
  14. package/src/api/core/core.router.js +8 -14
  15. package/src/api/default/default.controller.js +2 -70
  16. package/src/api/default/default.router.js +7 -17
  17. package/src/api/document/document.controller.js +5 -77
  18. package/src/api/document/document.router.js +9 -13
  19. package/src/api/file/file.controller.js +9 -53
  20. package/src/api/file/file.router.js +14 -6
  21. package/src/api/test/test.controller.js +8 -53
  22. package/src/api/test/test.router.js +1 -4
  23. package/src/cli/cluster.js +31 -11
  24. package/src/cli/db.js +4 -2
  25. package/src/cli/deploy.js +51 -9
  26. package/src/cli/docker-compose.js +163 -9
  27. package/src/cli/fs.js +0 -1
  28. package/src/cli/image.js +25 -7
  29. package/src/cli/index.js +5 -0
  30. package/src/cli/release.js +4 -0
  31. package/src/cli/repository.js +13 -2
  32. package/src/cli/run.js +151 -78
  33. package/src/cli/ssh.js +30 -11
  34. package/src/client/components/core/Modal.js +38 -4
  35. package/src/index.js +1 -1
  36. package/src/server/conf.js +163 -0
  37. package/src/server/downloader.js +3 -3
  38. package/src/server/middlewares.js +152 -0
package/src/cli/run.js CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  etcHostFactory,
15
15
  getNpmRootPath,
16
16
  isDeployRunnerContext,
17
+ loadConfInstances,
17
18
  loadConfServerJson,
19
+ selectConfInstances,
18
20
  loadReplicas,
19
21
  writeEnv,
20
22
  } from '../server/conf.js';
@@ -49,6 +51,32 @@ const waitForPort = (port, host = '127.0.0.1', { maxAttempts = 30, interval = 20
49
51
 
50
52
  const logger = loggerFactory(import.meta);
51
53
 
54
+ /**
55
+ * @method instanceProxyRoutesFactory
56
+ * @description Renders the HTTPProxy route block for every instance sharing a host.
57
+ * Routes are emitted longest-prefix first so a specific instance path (`/FOREST`)
58
+ * is never shadowed by the default instance's catch-all (`/`).
59
+ * @param {string} deployId - Parent deployment identifier.
60
+ * @param {Array<object>} instances - Expanded instance entries bound to one host.
61
+ * @param {string} env - `development` | `production`.
62
+ * @param {Object<string,string>} trafficById - Instance id → traffic colour.
63
+ * @returns {string} Concatenated route YAML.
64
+ */
65
+ const instanceProxyRoutesFactory = ({ deployId, instances, env, trafficById }) =>
66
+ [...instances]
67
+ .sort((a, b) => (b.path || '/').length - (a.path || '/').length)
68
+ .map((instance) =>
69
+ Underpost.deploy.deploymentYamlServiceFactory({
70
+ path: instance.path,
71
+ port: env === 'development' && instance.fromDebugPort ? instance.fromDebugPort : instance.fromPort,
72
+ deployId: `${deployId}-${instance.id}`,
73
+ env,
74
+ deploymentVersions: [trafficById[instance.id] || 'blue'],
75
+ pathRewritePolicy: instance.pathRewritePolicy,
76
+ }),
77
+ )
78
+ .join('');
79
+
52
80
  /**
53
81
  * @constant DEFAULT_OPTION
54
82
  * @description Default options for the UnderpostRun class.
@@ -123,6 +151,7 @@ const logger = loggerFactory(import.meta);
123
151
  * @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).
124
152
  * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
125
153
  * @property {boolean} test - Whether to enable test/generic-purpose mode (e.g. use self-signed TLS instead of cert-manager).
154
+ * @property {string} branch - The Git branch to use for operations (e.g., for template-deploy, ssh-deploy).
126
155
  * @memberof UnderpostRun
127
156
  */
128
157
  const DEFAULT_OPTION = {
@@ -193,6 +222,7 @@ const DEFAULT_OPTION = {
193
222
  pullBundle: false,
194
223
  remove: false,
195
224
  test: false,
225
+ branch: '',
196
226
  };
197
227
 
198
228
  /**
@@ -714,10 +744,11 @@ class UnderpostRun {
714
744
  if (deployConfId) inputs.deploy_conf_id = deployConfId;
715
745
  if (deployType) inputs.deploy_type = deployType;
716
746
 
747
+ // Omit `ref` so dispatchWorkflow auto-detects the repo's default branch
748
+ // (a fork may default to `main` rather than the monorepo's `master`).
717
749
  Underpost.repo.dispatchWorkflow({
718
750
  repo,
719
751
  workflowFile: 'npmpkg.ci.yml',
720
- ref: 'master',
721
752
  inputs,
722
753
  });
723
754
  },
@@ -761,7 +792,7 @@ class UnderpostRun {
761
792
  * @memberof UnderpostRun
762
793
  */
763
794
  'docker-image': (path, options = DEFAULT_OPTION) => {
764
- const repo = Underpost.repo.resolveInstanceRepo(path, options.dev);
795
+ const repo = Underpost.repo.resolveInstanceRepo(path, !options.test);
765
796
  Underpost.repo.dispatchWorkflow({
766
797
  repo,
767
798
  workflowFile: `docker-image${path ? `.${path}` : ''}${options.dev ? '.dev' : ''}.ci.yml`,
@@ -841,11 +872,13 @@ class UnderpostRun {
841
872
  job = 'init';
842
873
  confId = path.replace(/^init-/, '');
843
874
  }
844
- const repo = Underpost.repo.resolveInstanceRepo(confId, options.dev);
875
+ const repo = Underpost.repo.resolveInstanceRepo(confId, !options.test);
876
+ // Omit `ref` so dispatchWorkflow auto-detects the target repo's default
877
+ // branch (getDefaultBranch): the monorepo is `master` but instance repos
878
+ // like engine-cyberia default to `main` — hardcoding either 422s.
845
879
  Underpost.repo.dispatchWorkflow({
846
880
  repo,
847
881
  workflowFile: `${confId}.cd.yml`,
848
- ref: 'master',
849
882
  inputs: { job },
850
883
  });
851
884
  },
@@ -1182,66 +1215,54 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
1182
1215
 
1183
1216
  'instance-promote': async (path, options = DEFAULT_OPTION) => {
1184
1217
  const env = options.dev ? 'development' : 'production';
1185
- const baseCommand = options.dev ? 'node bin' : 'underpost';
1186
- const baseClusterCommand = options.dev ? ' --dev' : '';
1187
1218
  let [deployId, id] = path.split(',');
1188
- const confInstances = JSON.parse(
1189
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
1190
- );
1219
+ const confInstances = loadConfInstances(deployId);
1220
+ const promoted = selectConfInstances(confInstances, id);
1191
1221
  let promotedTraffic = '';
1192
- for (const instance of confInstances) {
1193
- let {
1194
- id: _id,
1195
- host: _host,
1196
- path: _path,
1197
- image: _image,
1198
- fromPort: _fromPort,
1199
- toPort: _toPort,
1200
- fromDebugPort: _fromDebugPort,
1201
- toDebugPort: _toDebugPort,
1202
- cmd: _cmd,
1203
- volumes: _volumes,
1204
- metadata: _metadata,
1205
- } = instance;
1206
- if (id !== _id) continue;
1207
- const _deployId = `${deployId}-${_id}`;
1208
- // Use debug ports in development when defined, fall back to production ports.
1209
- if (env === 'development' && _fromDebugPort) _fromPort = _fromDebugPort;
1210
- if (env === 'development' && _toDebugPort) _toPort = _toDebugPort;
1211
- const currentTraffic = Underpost.deploy.getCurrentTraffic(_deployId, {
1212
- hostTest: _host,
1222
+
1223
+ // A Contour HTTPProxy is named after its host, so every instance sharing a
1224
+ // host shares one object. Rebuilding it from the promoted instance alone
1225
+ // would drop its siblings' routes, so each host is rendered from the full
1226
+ // set: promoted instances flip colour, the rest keep their live colour.
1227
+ const promotedIds = new Set(promoted.map((instance) => instance.id));
1228
+ const hosts = [...new Set(promoted.map((instance) => instance.host))];
1229
+ const affected = confInstances.filter((instance) => hosts.includes(instance.host));
1230
+ const trafficById = {};
1231
+ for (const instance of affected) {
1232
+ const currentTraffic = Underpost.deploy.getCurrentTraffic(`${deployId}-${instance.id}`, {
1233
+ hostTest: instance.host,
1213
1234
  namespace: options.namespace,
1235
+ env,
1214
1236
  });
1215
- const targetTraffic = currentTraffic ? (currentTraffic === 'blue' ? 'green' : 'blue') : 'blue';
1216
- promotedTraffic = targetTraffic;
1237
+ if (!promotedIds.has(instance.id)) {
1238
+ trafficById[instance.id] = currentTraffic || 'blue';
1239
+ continue;
1240
+ }
1241
+ trafficById[instance.id] = currentTraffic ? (currentTraffic === 'blue' ? 'green' : 'blue') : 'blue';
1242
+ promotedTraffic = trafficById[instance.id];
1243
+ }
1244
+
1245
+ for (const host of hosts) {
1246
+ const hostInstances = affected.filter((instance) => instance.host === host);
1217
1247
  let proxyYaml =
1218
- Underpost.deploy.baseProxyYamlFactory({ host: _host, env: options.tls ? 'production' : env, options }) +
1219
- Underpost.deploy.deploymentYamlServiceFactory({
1220
- path: _path,
1221
- port: _fromPort,
1222
- // serviceId: deployId,
1223
- deployId: _deployId,
1224
- env,
1225
- deploymentVersions: [targetTraffic],
1226
- // pathRewritePolicy,
1227
- });
1248
+ Underpost.deploy.baseProxyYamlFactory({ host, env: options.tls ? 'production' : env, options }) +
1249
+ instanceProxyRoutesFactory({ deployId, instances: hostInstances, env, trafficById });
1228
1250
  if (options.tls) {
1229
1251
  if (options.test) {
1230
- const sslDir = `./engine-private/ssl/${_host}`;
1231
- const nameSafe = _host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1252
+ const sslDir = `./engine-private/ssl/${host}`;
1253
+ const nameSafe = host.replace(/[^a-zA-Z0-9_.-]/g, '_');
1232
1254
  fs.mkdirpSync(sslDir);
1233
- shellExec(`bash ./scripts/ssl.sh "${sslDir}" "${_host}"`);
1234
- shellExec(`kubectl delete secret ${_host} -n ${options.namespace} --ignore-not-found`);
1255
+ shellExec(`bash ./scripts/ssl.sh "${sslDir}" "${host}"`);
1256
+ shellExec(`kubectl delete secret ${host} -n ${options.namespace} --ignore-not-found`);
1235
1257
  shellExec(
1236
- `kubectl create secret tls ${_host} --cert="${sslDir}/${nameSafe}.pem" --key="${sslDir}/${nameSafe}-key.pem" -n ${options.namespace}`,
1258
+ `kubectl create secret tls ${host} --cert="${sslDir}/${nameSafe}.pem" --key="${sslDir}/${nameSafe}-key.pem" -n ${options.namespace}`,
1237
1259
  );
1238
1260
  } else {
1239
- shellExec(`sudo kubectl delete Certificate ${_host} -n ${options.namespace} --ignore-not-found`);
1240
- proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host: _host });
1261
+ shellExec(`sudo kubectl delete Certificate ${host} -n ${options.namespace} --ignore-not-found`);
1262
+ proxyYaml += Underpost.deploy.buildCertManagerCertificate({ ...options, host });
1241
1263
  }
1242
1264
  }
1243
- // console.log(proxyYaml);
1244
- shellExec(`kubectl delete HTTPProxy ${_host} --namespace ${options.namespace} --ignore-not-found`);
1265
+ shellExec(`kubectl delete HTTPProxy ${host} --namespace ${options.namespace} --ignore-not-found`);
1245
1266
  shellExec(
1246
1267
  `kubectl apply -f - -n ${options.namespace} <<EOF
1247
1268
  ${proxyYaml}
@@ -1276,9 +1297,7 @@ EOF
1276
1297
  const baseClusterCommand = options.dev ? ' --dev' : '';
1277
1298
  let [deployId, id, replicas] = path.split(',');
1278
1299
  if (!replicas) replicas = options.replicas;
1279
- const confInstances = JSON.parse(
1280
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
1281
- );
1300
+ const confInstances = selectConfInstances(loadConfInstances(deployId), id);
1282
1301
  const etcHosts = [];
1283
1302
  for (const instance of confInstances) {
1284
1303
  let {
@@ -1297,7 +1316,6 @@ EOF
1297
1316
  readinessProbe: _readinessProbe,
1298
1317
  livenessProbe: _livenessProbe,
1299
1318
  } = instance;
1300
- if (id !== _id) continue;
1301
1319
  const _deployId = `${deployId}-${_id}`;
1302
1320
  // Use debug ports in development when defined, fall back to production ports.
1303
1321
  if (env === 'development' && _fromDebugPort) _fromPort = _fromDebugPort;
@@ -1321,6 +1339,7 @@ EOF
1321
1339
  const currentTraffic = Underpost.deploy.getCurrentTraffic(_deployId, {
1322
1340
  hostTest: _host,
1323
1341
  namespace: options.namespace,
1342
+ env,
1324
1343
  });
1325
1344
 
1326
1345
  const targetTraffic = currentTraffic ? (currentTraffic === 'blue' ? 'green' : 'blue') : 'blue';
@@ -1426,13 +1445,15 @@ EOF
1426
1445
  logger.error(`Deployment ${deployId} did not become ready in time.`);
1427
1446
  return;
1428
1447
  }
1429
- shellExec(
1430
- `${baseCommand} run${baseClusterCommand} --namespace ${options.namespace}` +
1431
- `${options.nodeName ? ` --node-name ${options.nodeName}` : ''}` +
1432
- `${options.tls ? ` --tls ${options.test ? '--test' : ''}` : ''}` +
1433
- ` instance-promote '${path}'`,
1434
- );
1435
1448
  }
1449
+ // Promote (switch the HTTPProxy to the new colour) ONLY after EVERY
1450
+ // instance in the family is deployed and ready — never per-instance inside
1451
+ // the loop. A per-variant switch would flip server.cyberiaonline.com to the
1452
+ // default's new deployment while -forest/-test are still being created,
1453
+ // pointing routes at services that don't exist yet. instance-promote
1454
+ // rebuilds the whole host proxy (all variant routes) in one shot, so a
1455
+ // single call with the family id promotes the family atomically.
1456
+ if (!options.expose) await UnderpostRun.RUNNERS['instance-promote'](`${deployId},${id}`, options);
1436
1457
  if (options.etcHosts) {
1437
1458
  const hostListenResult = etcHostFactory(etcHosts);
1438
1459
  logger.info(hostListenResult.renderHosts);
@@ -1489,26 +1510,42 @@ EOF
1489
1510
  const env = options.dev ? 'development' : 'production';
1490
1511
  let [deployId, id, projectPath] = path.split(',');
1491
1512
  const rootPath = projectPath ? projectPath : '.';
1513
+
1514
+ const confInstances = loadConfInstances(deployId);
1515
+ // Targeting a template id builds every world in the family. The fan-out
1516
+ // re-enters this runner with `instanceOnly` because the default world
1517
+ // keeps the template id verbatim — without it, selecting `mmo-server`
1518
+ // would match the family again and recurse forever. Only that default
1519
+ // world publishes to the project root, so the repo keeps exactly one
1520
+ // canonical Dockerfile/deployment.yaml pair.
1521
+ const selected = options.instanceOnly
1522
+ ? confInstances.filter((instance) => instance.id === id)
1523
+ : selectConfInstances(confInstances, id);
1524
+ if (selected.length === 0) {
1525
+ logger.error(`Instance with id '${id}' not found in conf.instances.json for deployId '${deployId}'`);
1526
+ return;
1527
+ }
1528
+ if (!options.instanceOnly && (selected.length > 1 || selected[0].id !== id)) {
1529
+ for (const instance of selected)
1530
+ UnderpostRun.RUNNERS['instance-build-manifest'](
1531
+ [deployId, instance.id, projectPath].filter((v) => v !== undefined).join(','),
1532
+ { ...options, instanceOnly: true },
1533
+ );
1534
+ return;
1535
+ }
1536
+
1492
1537
  const envManifestPath = `${rootPath}/manifests/deployments/${id}-${env}`;
1493
1538
  const outputPath = `${envManifestPath}/deployment.yaml`;
1494
1539
  const dockerfileManifestPath = `${envManifestPath}/Dockerfile`;
1495
1540
 
1496
1541
  fs.mkdirpSync(envManifestPath);
1497
1542
 
1498
- const confInstances = JSON.parse(
1499
- fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'),
1500
- );
1501
-
1502
- const instance = confInstances.find((i) => i.id === id);
1503
- if (!instance) {
1504
- logger.error(`Instance with id '${id}' not found in conf.instances.json for deployId '${deployId}'`);
1505
- return;
1506
- }
1543
+ const instance = selected[0];
1544
+ const isDefaultInstance = instance.id === instance.templateId || !instance.templateId;
1507
1545
 
1508
1546
  let {
1509
1547
  id: _id,
1510
1548
  host: _host,
1511
- path: _path,
1512
1549
  image: _image,
1513
1550
  fromPort: _fromPort,
1514
1551
  toPort: _toPort,
@@ -1651,15 +1688,19 @@ EOF
1651
1688
  })}\n`;
1652
1689
  }
1653
1690
 
1654
- // proxy.yaml — HTTPProxy for the instance host (mirrors instance-promote).
1691
+ // proxy.yaml — this instance's OWN route only (its sub-path → its own
1692
+ // service), so each variant's build dir carries a distinct, instance-scoped
1693
+ // fragment rather than an identical copy of the whole host proxy. The
1694
+ // complete host HTTPProxy — every variant's route aggregated onto the one
1695
+ // fqdn, each pointing at its live colour — is assembled and applied by
1696
+ // `instance-promote` at deploy time, and only once EVERY variant is ready.
1655
1697
  const proxyYaml =
1656
1698
  Underpost.deploy.baseProxyYamlFactory({ host: _host, env, options }) +
1657
- Underpost.deploy.deploymentYamlServiceFactory({
1658
- path: _path,
1659
- port: _fromPort,
1660
- deployId: _deployId,
1699
+ instanceProxyRoutesFactory({
1700
+ deployId,
1701
+ instances: [instance],
1661
1702
  env,
1662
- deploymentVersions: [targetTraffic],
1703
+ trafficById: { [instance.id]: targetTraffic },
1663
1704
  });
1664
1705
 
1665
1706
  // grpc-service.yaml — the parent deploy's gRPC ClusterIP (shared; the
@@ -1693,7 +1734,39 @@ EOF
1693
1734
  grpcService: !!grpcServiceYaml,
1694
1735
  });
1695
1736
 
1696
- if (env === 'production') {
1737
+ // --- Per-instance env files -----------------------------------------
1738
+ // Each env file is seeded from the template instance's env file for the
1739
+ // same mode so operator-owned keys (API keys, memory limits, service URLs)
1740
+ // are inherited verbatim; only the keys the instance declares under
1741
+ // `multiInstance.env` — plus the derived container id — are (re)written.
1742
+ //
1743
+ // A derived instance's env dir is generated in full: both development.env
1744
+ // and production.env are written on every build, so a deploy in either
1745
+ // environment always finds the env file its `cmd` sources, no matter which
1746
+ // mode this build ran. The default/template instance owns the committed
1747
+ // source files, so only its current-mode file is idempotently refreshed.
1748
+ if (instance.templateId) {
1749
+ const instanceEnvDir = `./engine-private/conf/${deployId}/instances/${_id}/env`;
1750
+ fs.mkdirpSync(instanceEnvDir);
1751
+ const envsToWrite = isDefaultInstance ? [env] : ['development', 'production'];
1752
+ for (const targetEnv of envsToWrite) {
1753
+ const templateEnvPath = `./engine-private/conf/${deployId}/instances/${instance.templateId}/env/${targetEnv}.env`;
1754
+ const baseEnv = fs.existsSync(templateEnvPath) ? dotenv.parse(fs.readFileSync(templateEnvPath, 'utf8')) : {};
1755
+ writeEnv(`${instanceEnvDir}/${targetEnv}.env`, {
1756
+ ...baseEnv,
1757
+ ...(instance.instanceEnv?.[targetEnv] || {}),
1758
+ CONTAINER_DEPLOY_ID: `${_deployId}-${targetEnv}`,
1759
+ });
1760
+ }
1761
+ logger.info('[instance-build-manifest] Instance env written', {
1762
+ dir: instanceEnvDir,
1763
+ instanceCode: instance.instanceCode,
1764
+ envs: envsToWrite,
1765
+ keys: Object.keys(instance.instanceEnv?.[env] || {}),
1766
+ });
1767
+ }
1768
+
1769
+ if (env === 'production' && isDefaultInstance) {
1697
1770
  if (fs.existsSync(dockerfileManifestPath)) {
1698
1771
  fs.copyFileSync(dockerfileManifestPath, `${rootPath}/Dockerfile`);
1699
1772
  }
package/src/cli/ssh.js CHANGED
@@ -532,12 +532,28 @@ EOF`);
532
532
  if (!host) throw new Error('copyDirToNode requires a host');
533
533
  if (!localDir || !fs.existsSync(localDir)) throw new Error(`copyDirToNode: local dir not found: ${localDir}`);
534
534
  if (!remoteDir) throw new Error('copyDirToNode requires a remoteDir');
535
- shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
536
- const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
537
- shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`);
538
- shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`);
539
- const fixups = `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
540
- if (fixups) shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`);
535
+ try {
536
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
537
+ const sshOpts = `-i ${keyPath} -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}`;
538
+ shellExec(`ssh ${sshOpts} ${user}@${host} 'mkdir -p ${remoteDir}'`, {
539
+ silent: true,
540
+ disableLog: true,
541
+ });
542
+ shellExec(`tar -C ${localDir} -c . | ssh ${sshOpts} ${user}@${host} 'tar -C ${remoteDir} -x'`, {
543
+ silent: true,
544
+ disableLog: true,
545
+ });
546
+ const fixups =
547
+ `${owner ? `chown -R ${owner} ${remoteDir}; ` : ''}${mode ? `chmod -R ${mode} ${remoteDir}` : ''}`.trim();
548
+ if (fixups)
549
+ shellExec(`ssh ${sshOpts} ${user}@${host} '${fixups}'`, {
550
+ silent: true,
551
+ disableLog: true,
552
+ });
553
+ } catch (err) {
554
+ logger.error(`copyDirToNode failed`);
555
+ process.exit(1);
556
+ }
541
557
  },
542
558
 
543
559
  /**
@@ -703,11 +719,14 @@ EOF
703
719
 
704
720
  let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
705
721
  for (let attempt = 1; attempt <= retries; attempt++) {
706
- const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
707
- stdout: false,
708
- silentOnError: true,
709
- disableLog: true,
710
- });
722
+ const result = shellExec(
723
+ `ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`,
724
+ {
725
+ stdout: false,
726
+ silentOnError: true,
727
+ disableLog: true,
728
+ },
729
+ );
711
730
  last = {
712
731
  ok: result.code === 0,
713
732
  code: result.code,
@@ -425,8 +425,8 @@ class Modal {
425
425
  s(`.main-body-btn-ui-menu-menu`).classList.add('hide');
426
426
  s(`.main-body-btn-ui-menu-close`).classList.remove('hide');
427
427
  if (s(`.btn-bar-center-icon-menu`)) {
428
- s(`.btn-bar-center-icon-close`).classList.remove('hide');
429
- s(`.btn-bar-center-icon-menu`).classList.add('hide');
428
+ sa(`.btn-bar-center-icon-close`).forEach((el) => el.classList.remove('hide'));
429
+ sa(`.btn-bar-center-icon-menu`).forEach((el) => el.classList.add('hide'));
430
430
  }
431
431
 
432
432
  s(`.main-body-btn-container`).style[
@@ -449,8 +449,8 @@ class Modal {
449
449
  s(`.main-body-btn-ui-menu-close`).classList.add('hide');
450
450
  s(`.main-body-btn-ui-menu-menu`).classList.remove('hide');
451
451
  if (s(`.btn-bar-center-icon-menu`)) {
452
- s(`.btn-bar-center-icon-menu`).classList.remove('hide');
453
- s(`.btn-bar-center-icon-close`).classList.add('hide');
452
+ sa(`.btn-bar-center-icon-menu`).forEach((el) => el.classList.remove('hide'));
453
+ sa(`.btn-bar-center-icon-close`).forEach((el) => el.classList.add('hide'));
454
454
  }
455
455
  s(`.main-body-btn-container`).style[
456
456
  true || (options.mode && options.mode.match('right')) ? 'right' : 'left'
@@ -683,6 +683,34 @@ class Modal {
683
683
  >
684
684
  </div>`
685
685
  : ''}
686
+ ${idModal === 'modal-menu' && options.mode !== 'slide-menu-right'
687
+ ? html`<div
688
+ class="abs main-btn-menu-top-container"
689
+ style="bottom: 0px; left: 0px; z-index: 10; height: ${originHeightTopBar}px; width: ${originHeightTopBar}px"
690
+ >
691
+ ${await BtnIcon.instance({
692
+ style: `height: 100%`,
693
+ class: `in fll main-btn-menu-top action-bar-box action-btn-center-top`,
694
+ label: html`<div class="abs center">
695
+ <i class="far fa-square btn-bar-center-icon-square hide"></i>
696
+ <span class="btn-bar-center-icon-close hide">${barConfig.buttons.close.label}</span>
697
+ <span class="btn-bar-center-icon-menu">${barConfig.buttons.menu.label}</span>
698
+ </div>`,
699
+ })}
700
+ </div>
701
+
702
+ <style>
703
+ .a-link-top-banner {
704
+ padding-left: 35px;
705
+ }
706
+ .main-body-btn-bar-custom {
707
+ top: 50px !important;
708
+ }
709
+ .main-body-btn-menu {
710
+ display: none;
711
+ }
712
+ </style>`
713
+ : ''}
686
714
  </div>`,
687
715
  );
688
716
  EventsUI.onClick(`.action-btn-profile-log-in`, () => {
@@ -697,6 +725,12 @@ class Modal {
697
725
  }
698
726
  s(`.main-btn-sign-up`).click();
699
727
  });
728
+ if (idModal === 'modal-menu' && options.mode !== 'slide-menu-right') {
729
+ EventsUI.onClick(`.action-btn-center-top`, (e) => {
730
+ e.preventDefault();
731
+ Modal.actionBtnCenter();
732
+ });
733
+ }
700
734
  s(`.input-info-${inputSearchBoxId}`).style.textAlign = 'left';
701
735
  htmls(`.input-info-${inputSearchBoxId}`, '');
702
736
  const inputInfoNode = s(`.input-info-${inputSearchBoxId}`).cloneNode(true);
package/src/index.js CHANGED
@@ -45,7 +45,7 @@ class Underpost {
45
45
  * @type {String}
46
46
  * @memberof Underpost
47
47
  */
48
- static version = 'v3.2.70';
48
+ static version = 'v3.2.80';
49
49
 
50
50
  /**
51
51
  * Required Node.js major version