underpost 3.2.22 → 3.2.28

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 (50) hide show
  1. package/.github/workflows/ghpkg.ci.yml +1 -1
  2. package/.github/workflows/gitlab.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +2 -2
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +141 -1
  8. package/CLI-HELP.md +56 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +1 -0
  12. package/docker-compose.yml +224 -0
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +27 -15
  17. package/scripts/kubeadm-node-setup.sh +317 -0
  18. package/scripts/rocky-kickstart.sh +877 -185
  19. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  20. package/src/cli/baremetal.js +717 -16
  21. package/src/cli/cluster.js +3 -1
  22. package/src/cli/deploy.js +6 -2
  23. package/src/cli/docker-compose.js +591 -0
  24. package/src/cli/fs.js +35 -11
  25. package/src/cli/index.js +75 -0
  26. package/src/cli/kickstart.js +142 -20
  27. package/src/cli/repository.js +72 -11
  28. package/src/cli/run.js +253 -2
  29. package/src/cli/ssh.js +190 -0
  30. package/src/cli/static.js +2 -2
  31. package/src/{server → client-builder}/client-build-docs.js +15 -5
  32. package/src/{server → client-builder}/client-build-live.js +3 -3
  33. package/src/{server → client-builder}/client-build.js +25 -22
  34. package/src/{server → client-builder}/client-dev-server.js +3 -3
  35. package/src/{server → client-builder}/client-icons.js +2 -2
  36. package/src/{server → client-builder}/ssr.js +5 -5
  37. package/src/client.build.js +1 -1
  38. package/src/client.dev.js +1 -1
  39. package/src/index.js +12 -1
  40. package/src/mailer/EmailRender.js +1 -1
  41. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  42. package/src/runtime/express/Express.js +2 -2
  43. package/src/runtime/nginx/Nginx.js +250 -0
  44. package/src/server/catalog.js +7 -14
  45. package/src/server/conf.js +3 -3
  46. package/src/server.js +1 -1
  47. package/typedoc.json +3 -1
  48. package/src/server/ipfs-client.js +0 -599
  49. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  50. /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/run.js CHANGED
@@ -373,6 +373,214 @@ class UnderpostRun {
373
373
  );
374
374
  },
375
375
 
376
+ /**
377
+ * @method node-move
378
+ * @description Abstract runner that relocates any schedulable Kubernetes workload
379
+ * (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, ReplicationController)
380
+ * onto a target node by patching its pod-template `nodeSelector` and rolling it out.
381
+ * Resource-kind agnostic: it resolves the kind dynamically and applies the right
382
+ * patch path, so it works for `sts`, `deployment`, etc. without bespoke logic.
383
+ *
384
+ * Selection grammar via `path`:
385
+ * - `<kind>/<name>` -> a single resource (e.g. `deployment/dd-core-production-blue`)
386
+ * - `<kind>` -> every resource of that kind in the namespace (e.g. `statefulset`)
387
+ * - `` -> all movable workloads (deployment, statefulset, daemonset) in the namespace
388
+ *
389
+ * Placement:
390
+ * - default: built-in `kubernetes.io/hostname=<node>` (no node mutation required)
391
+ * - `--labels k=v,...`: label the target node with those pairs and use them as the
392
+ * nodeSelector (matches the "label node + nodeSelector" pattern), enabling reusable
393
+ * workload pools instead of pinning to a single hostname.
394
+ *
395
+ * Flags: `--node-name <node>` (target), `--namespace <ns>`, `--dry-run` (preview only),
396
+ * `--remove` (clear the nodeSelector / unpin placement).
397
+ *
398
+ * Caveats: Services/ConfigMaps and bare Pods are not schedulable controllers and are
399
+ * skipped (move the owning controller). StatefulSets bound to node-local PVs may stay
400
+ * Pending after a move until their volume is available on the target node.
401
+ * @param {string} path - Resource selector (`kind/name`, `kind`, or empty).
402
+ * @param {Object} options - The default underpost runner options for customizing workflow
403
+ * @memberof UnderpostRun
404
+ * @returns {Array<{ref:string,kind:string,status:string,node?:string}>} Per-resource outcome.
405
+ */
406
+ 'node-move': (path = '', options = DEFAULT_OPTION) => {
407
+ const node = options.nodeName;
408
+ const ns = options.namespace || 'default';
409
+ const dryRun = options.dryRun === true;
410
+ const remove = options.remove === true;
411
+
412
+ if (!remove && !node) {
413
+ throw new Error('node-move requires --node-name <target-node> (or --remove to clear placement)');
414
+ }
415
+
416
+ const normalizeKind = (k) =>
417
+ ({
418
+ deploy: 'deployment',
419
+ deployments: 'deployment',
420
+ deployment: 'deployment',
421
+ sts: 'statefulset',
422
+ statefulsets: 'statefulset',
423
+ statefulset: 'statefulset',
424
+ ds: 'daemonset',
425
+ daemonsets: 'daemonset',
426
+ daemonset: 'daemonset',
427
+ rs: 'replicaset',
428
+ replicasets: 'replicaset',
429
+ replicaset: 'replicaset',
430
+ rc: 'replicationcontroller',
431
+ replicationcontroller: 'replicationcontroller',
432
+ job: 'job',
433
+ jobs: 'job',
434
+ cj: 'cronjob',
435
+ cronjob: 'cronjob',
436
+ cronjobs: 'cronjob',
437
+ po: 'pod',
438
+ pod: 'pod',
439
+ pods: 'pod',
440
+ svc: 'service',
441
+ service: 'service',
442
+ services: 'service',
443
+ })[k] || k;
444
+
445
+ // Kinds that own a pod template we can patch; rolloutKinds additionally
446
+ // support `kubectl rollout restart` to reschedule existing pods now.
447
+ const templated = ['deployment', 'statefulset', 'daemonset', 'replicaset', 'job', 'cronjob', 'replicationcontroller'];
448
+ const rolloutKinds = ['deployment', 'statefulset', 'daemonset'];
449
+ const templateSelectorPath = (kind) =>
450
+ kind === 'cronjob'
451
+ ? ['spec', 'jobTemplate', 'spec', 'template', 'spec', 'nodeSelector']
452
+ : ['spec', 'template', 'spec', 'nodeSelector'];
453
+
454
+ // Resolve the desired nodeSelector. Custom --labels enables reusable pools;
455
+ // otherwise pin by the always-present hostname label.
456
+ let selector = { 'kubernetes.io/hostname': node };
457
+ if (!remove && options.labels) {
458
+ selector = {};
459
+ for (const pair of `${options.labels}`.split(',').map((s) => s.trim()).filter(Boolean)) {
460
+ const eq = pair.indexOf('=');
461
+ if (eq < 0) continue;
462
+ selector[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
463
+ }
464
+ }
465
+
466
+ // Verify the target node exists, and apply custom labels to it if provided.
467
+ if (!remove) {
468
+ const found = shellExec(`kubectl get node ${node} -o name`, {
469
+ silent: true,
470
+ stdout: true,
471
+ silentOnError: true,
472
+ }).trim();
473
+ if (!found) throw new Error(`Target node not found: ${node}`);
474
+ if (options.labels) {
475
+ const labelArgs = Object.entries(selector)
476
+ .map(([k, v]) => `${k}=${v}`)
477
+ .join(' ');
478
+ const labelCmd = `kubectl label node ${node} ${labelArgs} --overwrite`;
479
+ if (dryRun) logger.info(`[dry-run] ${labelCmd}`);
480
+ else shellExec(labelCmd);
481
+ }
482
+ }
483
+
484
+ const kubectlNames = (kind) =>
485
+ (
486
+ shellExec(`kubectl get ${kind} -n ${ns} -o name`, { silent: true, stdout: true, silentOnError: true }).trim() ||
487
+ ''
488
+ )
489
+ .split('\n')
490
+ .map((s) => s.trim())
491
+ .filter(Boolean);
492
+
493
+ // Build the list of "kind/name" targets from the selection grammar.
494
+ let targets = [];
495
+ if (!path) {
496
+ for (const kind of ['deployment', 'statefulset', 'daemonset']) targets.push(...kubectlNames(kind));
497
+ } else if (path.includes('/')) {
498
+ targets = [path];
499
+ } else {
500
+ targets = kubectlNames(path);
501
+ }
502
+
503
+ if (targets.length === 0) {
504
+ logger.warn('node-move: no matching resources found', { path, namespace: ns });
505
+ return [];
506
+ }
507
+
508
+ // Merge-patch body that sets (or clears, when --remove) the nodeSelector.
509
+ const buildPatch = (kind) => {
510
+ const keys = templateSelectorPath(kind);
511
+ let obj = remove ? null : selector;
512
+ for (let i = keys.length - 1; i >= 0; i--) obj = { [keys[i]]: obj };
513
+ return JSON.stringify(obj);
514
+ };
515
+
516
+ const results = [];
517
+ for (const ref of targets) {
518
+ const slash = ref.indexOf('/');
519
+ const rawKind = slash >= 0 ? ref.slice(0, slash) : path && !path.includes('/') ? path : '';
520
+ const name = slash >= 0 ? ref.slice(slash + 1) : ref;
521
+ const kind = normalizeKind(`${rawKind}`.split('.')[0].toLowerCase());
522
+
523
+ if (!templated.includes(kind)) {
524
+ logger.warn(`node-move: ${kind}/${name} is not a schedulable controller; skipping`, {
525
+ hint: 'move its owning controller (deployment/statefulset/daemonset) instead',
526
+ });
527
+ results.push({ ref, kind, status: 'skipped' });
528
+ continue;
529
+ }
530
+
531
+ // Idempotency: skip the patch + rollout if the resource is already where
532
+ // we want it. Compares the live pod-template nodeSelector against the
533
+ // desired placement so a repeated run does not trigger an unnecessary
534
+ // rollout restart.
535
+ const basePath = kind === 'cronjob' ? 'spec.jobTemplate.spec.template.spec' : 'spec.template.spec';
536
+ const jsonpath = (expr) =>
537
+ shellExec(`kubectl get ${kind} ${name} -n ${ns} -o jsonpath='${expr}'`, {
538
+ silent: true,
539
+ stdout: true,
540
+ silentOnError: true,
541
+ disableLog: true,
542
+ }).trim();
543
+
544
+ if (remove) {
545
+ const current = jsonpath(`{.${basePath}.nodeSelector}`);
546
+ if (!current || current === 'map[]') {
547
+ logger.info(`node-move: ${kind}/${name} already has no nodeSelector; nothing to clear`);
548
+ results.push({ ref, kind, status: 'already-cleared' });
549
+ continue;
550
+ }
551
+ } else {
552
+ const alreadyOnNode = Object.entries(selector).every(([k, v]) => {
553
+ const esc = k.replace(/\./g, '\\.');
554
+ return jsonpath(`{.${basePath}.nodeSelector.${esc}}`) === v;
555
+ });
556
+ if (alreadyOnNode) {
557
+ logger.info(`node-move: ${kind}/${name} already pinned to ${node}; skipping`, { namespace: ns });
558
+ results.push({ ref, kind, status: 'already-on-node', node });
559
+ continue;
560
+ }
561
+ }
562
+
563
+ const patchCmd = `kubectl patch ${kind} ${name} -n ${ns} --type=merge -p '${buildPatch(kind)}'`;
564
+ const restartCmd = `kubectl rollout restart ${kind} ${name} -n ${ns}`;
565
+ if (dryRun) {
566
+ logger.info(`[dry-run] ${patchCmd}`);
567
+ if (rolloutKinds.includes(kind)) logger.info(`[dry-run] ${restartCmd}`);
568
+ results.push({ ref, kind, status: 'dry-run', node: remove ? undefined : node });
569
+ continue;
570
+ }
571
+
572
+ shellExec(patchCmd);
573
+ if (rolloutKinds.includes(kind)) shellExec(restartCmd);
574
+ logger.info(remove ? `Cleared node placement: ${kind}/${name}` : `Moved ${kind}/${name} -> ${node}`, {
575
+ namespace: ns,
576
+ });
577
+ results.push({ ref, kind, status: remove ? 'cleared' : 'moved', node: remove ? undefined : node });
578
+ }
579
+
580
+ logger.info('node-move complete', { namespace: ns, node: remove ? null : node, count: results.length });
581
+ return results;
582
+ },
583
+
376
584
  /**
377
585
  * @method dev-hosts-expose
378
586
  * @description Deploys a specified service in development mode with `/etc/hosts` modification for local access.
@@ -1443,8 +1651,24 @@ gpgcheck=1
1443
1651
  gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
1444
1652
  EOF`);
1445
1653
  shellExec(`sudo dnf -y install cri-o`);
1446
- // crictl is in the kubernetes repo but excluded by default install it explicitly
1447
- shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
1654
+ // Add the Kubernetes repo so cri-tools (crictl CLI) is available.
1655
+ // The repo has exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni, so we
1656
+ // use --disableexcludes=kubernetes to override and install cri-tools.
1657
+ shellExec(`cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
1658
+ [kubernetes]
1659
+ name=Kubernetes
1660
+ baseurl=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/
1661
+ enabled=1
1662
+ gpgcheck=1
1663
+ gpgkey=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/repodata/repomd.xml.key
1664
+ exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
1665
+ EOF`);
1666
+ shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes 2>/dev/null || {
1667
+ CRICTL_VERSION="v1.31.0"
1668
+ ARCH="amd64"
1669
+ log "Kubernetes repo not available, downloading crictl binary directly..."
1670
+ curl -sL "https://github.com/kubernetes-sigs/cri-tools/releases/download/\${CRICTL_VERSION}/crictl-\${CRICTL_VERSION}-\${ARCH}.tar.gz" | sudo tar -C /usr/local/bin -xz
1671
+ }`);
1448
1672
  // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1449
1673
  shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
1450
1674
  silentOnError: true,
@@ -2775,6 +2999,33 @@ EOF`;
2775
2999
 
2776
3000
  logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2777
3001
  },
3002
+ /**
3003
+ * @method shared-dir-add-user
3004
+ * @description Add a user to an existing shared directory without changing
3005
+ * file owners. Grants recursive group/ACL access so the user can read and
3006
+ * write throughout the shared workspace while preserving existing ownership.
3007
+ *
3008
+ * @param {string} path - Shared directory (defaults to `/home/dd/engine`).
3009
+ * @param {Object} options - Underpost runner options.
3010
+ * Key fields:
3011
+ * - options.user (default: 'admin')
3012
+ * - options.group (default: 'engine-dev')
3013
+ *
3014
+ * @memberof UnderpostRun
3015
+ */ 'shared-dir-add-user': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
3016
+ const dir = path || '/home/dd/engine';
3017
+ const user = options.user || 'admin';
3018
+
3019
+ logger.info(`[shared-dir-add-user] dir=${dir} user=${user}`);
3020
+
3021
+ // Give the user direct access without changing owners or group ownership.
3022
+ shellExec(`sudo setfacl -R -m u:${user}:rwx ${dir}`);
3023
+
3024
+ // Make future files/directories inherit the same user ACL.
3025
+ shellExec(`sudo find ${dir} -type d -exec setfacl -d -m u:${user}:rwx {} \\;`);
3026
+
3027
+ logger.info(`[shared-dir-add-user] User '${user}' added to shared directory: ${dir}`);
3028
+ },
2778
3029
  };
2779
3030
 
2780
3031
  static API = {
package/src/cli/ssh.js CHANGED
@@ -545,6 +545,196 @@ EOF
545
545
  return shellExec(sshScript, { stdout: true });
546
546
  },
547
547
 
548
+ /**
549
+ * Waits until a TCP SSH port becomes reachable on a host.
550
+ * @async
551
+ * @function waitForSshPort
552
+ * @memberof UnderpostSSH
553
+ * @param {object} params
554
+ * @param {string} params.host - Target host/IP.
555
+ * @param {number} [params.port=22] - SSH port.
556
+ * @param {number} [params.timeoutMs=600000] - Maximum wait window.
557
+ * @param {number} [params.intervalMs=3000] - Poll interval.
558
+ * @returns {Promise<boolean>} True once the port accepts connections, false on timeout.
559
+ */
560
+ waitForSshPort: async ({ host, port = 22, timeoutMs = 10 * 60 * 1000, intervalMs = 3000 }) => {
561
+ const deadline = Date.now() + timeoutMs;
562
+ while (Date.now() < deadline) {
563
+ const probe = shellExec(
564
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
565
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
566
+ );
567
+ if (`${probe}`.trim() === 'open') return true;
568
+ await new Promise((r) => setTimeout(r, intervalMs));
569
+ }
570
+ logger.warn(`SSH port ${host}:${port} not reachable within timeout`);
571
+ return false;
572
+ },
573
+
574
+ /**
575
+ * Waits until a host's SSH port stops accepting connections (e.g. while it
576
+ * reboots). Used to detect a reboot edge before waiting for the port to come
577
+ * back up, so callers don't latch onto the pre-reboot (ephemeral) sshd.
578
+ * @async
579
+ * @function waitForSshPortClosed
580
+ * @memberof UnderpostSSH
581
+ * @param {object} params
582
+ * @param {string} params.host - Target host/IP.
583
+ * @param {number} [params.port=22] - SSH port.
584
+ * @param {number} [params.timeoutMs=180000] - Maximum wait window.
585
+ * @param {number} [params.intervalMs=3000] - Poll interval.
586
+ * @returns {Promise<boolean>} True once the port is closed, false on timeout.
587
+ */
588
+ waitForSshPortClosed: async ({ host, port = 22, timeoutMs = 3 * 60 * 1000, intervalMs = 3000 }) => {
589
+ const deadline = Date.now() + timeoutMs;
590
+ while (Date.now() < deadline) {
591
+ const probe = shellExec(
592
+ `timeout 5 bash -c '</dev/tcp/${host}/${port}' >/dev/null 2>&1 && echo open || echo closed`,
593
+ { silent: true, stdout: true, silentOnError: true, disableLog: true },
594
+ );
595
+ if (`${probe}`.trim() === 'closed') return true;
596
+ await new Promise((r) => setTimeout(r, intervalMs));
597
+ }
598
+ return false;
599
+ },
600
+
601
+ /**
602
+ * Orchestrates a non-interactive, key-only SSH session against a freshly
603
+ * provisioned host: waits for the port, attempts key-based auth, runs a
604
+ * remote command batch, and returns a structured result. Used by the
605
+ * commissioning flow once the ephemeral runtime reports SSH readiness.
606
+ * @async
607
+ * @function sshExecBatch
608
+ * @memberof UnderpostSSH
609
+ * @param {object} params
610
+ * @param {string} params.host - Target host/IP.
611
+ * @param {string} params.command - Remote command batch to execute.
612
+ * @param {number} [params.port=22] - SSH port.
613
+ * @param {string} [params.user='root'] - SSH user (key-only).
614
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
615
+ * @param {number} [params.connectTimeoutSec=15] - Per-attempt SSH connect timeout.
616
+ * @param {number} [params.retries=3] - Auth/exec retry attempts.
617
+ * @param {number} [params.retryDelayMs=5000] - Base backoff between retries.
618
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
619
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
620
+ */
621
+ sshExecBatch: async ({
622
+ host,
623
+ command,
624
+ port = 22,
625
+ user = 'root',
626
+ keyPath = './engine-private/deploy/id_rsa',
627
+ connectTimeoutSec = 15,
628
+ retries = 3,
629
+ retryDelayMs = 5000,
630
+ waitForPortMs = 0,
631
+ }) => {
632
+ if (!host) throw new Error('sshExecBatch requires a host');
633
+ if (!command) throw new Error('sshExecBatch requires a command');
634
+
635
+ if (waitForPortMs > 0) {
636
+ const reachable = await Underpost.ssh.waitForSshPort({ host, port, timeoutMs: waitForPortMs });
637
+ if (!reachable) return { ok: false, code: 255, stdout: '', stderr: 'ssh port unreachable', attempts: 0 };
638
+ }
639
+
640
+ shellExec(`chmod 600 ${keyPath}`, { silent: true, silentOnError: true, disableLog: true });
641
+
642
+ const sshOpts = [
643
+ `-i ${keyPath}`,
644
+ `-o BatchMode=yes`,
645
+ `-o PreferredAuthentications=publickey`,
646
+ `-o PubkeyAuthentication=yes`,
647
+ `-o PasswordAuthentication=no`,
648
+ `-o StrictHostKeyChecking=no`,
649
+ `-o UserKnownHostsFile=/dev/null`,
650
+ `-o ConnectTimeout=${connectTimeoutSec}`,
651
+ // Tolerate a freshly-booted node whose network briefly flaps (e.g. while
652
+ // NetworkManager applies a static profile): retry the TCP connect and
653
+ // keep the session alive across short stalls.
654
+ `-o ConnectionAttempts=3`,
655
+ `-o ServerAliveInterval=10`,
656
+ `-o ServerAliveCountMax=6`,
657
+ `-p ${port}`,
658
+ ].join(' ');
659
+
660
+ let last = { ok: false, code: 255, stdout: '', stderr: '', attempts: 0 };
661
+ for (let attempt = 1; attempt <= retries; attempt++) {
662
+ const result = shellExec(`ssh ${sshOpts} ${user}@${host} bash -s <<'UNDERPOST_SSH_BATCH_EOF'\n${command}\nUNDERPOST_SSH_BATCH_EOF`, {
663
+ stdout: false,
664
+ silentOnError: true,
665
+ disableLog: true,
666
+ });
667
+ last = {
668
+ ok: result.code === 0,
669
+ code: result.code,
670
+ stdout: result.stdout || '',
671
+ stderr: result.stderr || '',
672
+ attempts: attempt,
673
+ };
674
+ if (last.ok) {
675
+ logger.info(`sshExecBatch succeeded on ${user}@${host}:${port} (attempt ${attempt})`);
676
+ return last;
677
+ }
678
+ logger.warn(`sshExecBatch attempt ${attempt}/${retries} failed on ${user}@${host}:${port}`, {
679
+ code: last.code,
680
+ stderr: last.stderr.slice(-400),
681
+ });
682
+ if (attempt < retries) await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
683
+ }
684
+ return last;
685
+ },
686
+
687
+ /**
688
+ * Transfers a local script to a remote host and runs it over key-only SSH.
689
+ * The script is base64-encoded so no shell-quoting/escaping is needed, then
690
+ * decoded, made executable, and executed with the given arguments. Reuses
691
+ * sshExecBatch for the actual transport, retries, and structured result.
692
+ * @async
693
+ * @function sshRunScript
694
+ * @memberof UnderpostSSH
695
+ * @param {object} params
696
+ * @param {string} params.host - Target host/IP.
697
+ * @param {string} params.scriptPath - Local path to the script to run.
698
+ * @param {string} [params.args=''] - Arguments appended to the remote invocation.
699
+ * @param {object} [params.env={}] - Environment variables exported for the remote run (e.g. secrets). Passed inline to the command, never echoed.
700
+ * @param {string} [params.remotePath='/tmp/underpost-remote-script.sh'] - Remote path to write the script.
701
+ * @param {number} [params.port=22] - SSH port.
702
+ * @param {string} [params.user='root'] - SSH user (key-only).
703
+ * @param {string} [params.keyPath] - Private key path (defaults to engine deploy key).
704
+ * @param {number} [params.retries=3] - Retry attempts.
705
+ * @param {number} [params.waitForPortMs=0] - When > 0, wait for the port first.
706
+ * @returns {Promise<{ok: boolean, code: number, stdout: string, stderr: string, attempts: number}>}
707
+ */
708
+ sshRunScript: async ({
709
+ host,
710
+ scriptPath,
711
+ args = '',
712
+ env = {},
713
+ remotePath = '/tmp/underpost-remote-script.sh',
714
+ port = 22,
715
+ user = 'root',
716
+ keyPath = './engine-private/deploy/id_rsa',
717
+ retries = 3,
718
+ waitForPortMs = 0,
719
+ }) => {
720
+ if (!fs.existsSync(scriptPath)) throw new Error(`sshRunScript: script not found: ${scriptPath}`);
721
+ const b64 = Buffer.from(fs.readFileSync(scriptPath, 'utf8'), 'utf8').toString('base64');
722
+ // Inline env assignments (single-quote escaped) so secrets are exported for
723
+ // the remote run without appearing as logged CLI args.
724
+ const sq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
725
+ const envPrefix = Object.entries(env)
726
+ .filter(([, v]) => v !== undefined && v !== null && `${v}` !== '')
727
+ .map(([k, v]) => `${k}=${sq(v)}`)
728
+ .join(' ');
729
+ const command = [
730
+ 'set -e',
731
+ `echo '${b64}' | base64 -d > ${remotePath}`,
732
+ `chmod +x ${remotePath}`,
733
+ `${envPrefix ? `${envPrefix} ` : ''}bash ${remotePath} ${args}`,
734
+ ].join('\n');
735
+ return Underpost.ssh.sshExecBatch({ host, port, user, keyPath, retries, waitForPortMs, command });
736
+ },
737
+
548
738
  /**
549
739
  * Loads saved SSH credentials from config and sets them in the UnderpostRootEnv API.
550
740
  * @async
package/src/cli/static.js CHANGED
@@ -6,10 +6,10 @@
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
8
  import express from 'express';
9
- import { ssrFactory } from '../server/ssr.js';
9
+ import { ssrFactory } from '../client-builder/ssr.js';
10
10
  import { shellExec } from '../server/process.js';
11
11
  import Underpost from '../index.js';
12
- import { JSONweb } from '../server/client-formatted.js';
12
+ import { JSONweb } from '../client-builder/client-formatted.js';
13
13
  import { loggerFactory, loggerMiddleware } from '../server/logger.js';
14
14
  const logger = loggerFactory(import.meta);
15
15
  /**
@@ -2,13 +2,13 @@
2
2
 
3
3
  /**
4
4
  * Module for building project documentation (JSDoc, Swagger, Coverage).
5
- * @module src/server/client-build-docs.js
5
+ * @module src/client-builder/client-build-docs.js
6
6
  * @namespace clientBuildDocs
7
7
  */
8
8
 
9
9
  import fs from 'fs-extra';
10
- import { shellExec } from './process.js';
11
- import { loggerFactory } from './logger.js';
10
+ import { shellExec } from '../server/process.js';
11
+ import { loggerFactory } from '../server/logger.js';
12
12
  import { JSONweb } from './client-formatted.js';
13
13
  import { ssrFactory } from './ssr.js';
14
14
 
@@ -396,10 +396,20 @@ const buildCoverage = async ({ docs, docsDestination }) => {
396
396
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
397
397
  if (pkg.scripts && pkg.scripts.coverage) {
398
398
  logger.info('generating coverage report', coveragePath);
399
- shellExec(`cd ${coveragePath} && npm run coverage`, { silent: true });
399
+ try {
400
+ await shellExec(`cd ${coveragePath} && npm run coverage`, { silent: true });
401
+ } catch (err) {
402
+ logger.warn('coverage generation failed (non-fatal), skipping:', err.message);
403
+ return;
404
+ }
400
405
  } else if (pkg.scripts && pkg.scripts.test) {
401
406
  logger.info('generating coverage via test', coveragePath);
402
- shellExec(`cd ${coveragePath} && npm test`, { silent: true, silentOnError: true });
407
+ try {
408
+ await shellExec(`cd ${coveragePath} && npm test`, { silent: true, silentOnError: true });
409
+ } catch (err) {
410
+ logger.warn('coverage generation failed (non-fatal), skipping:', err.message);
411
+ return;
412
+ }
403
413
  }
404
414
  }
405
415
  }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Module for live building client-side code
3
- * @module src/server/client-build-live.js
3
+ * @module src/client-builder/client-build-live.js
4
4
  * @namespace clientLiveBuild
5
5
  */
6
6
 
7
7
  import fs from 'fs-extra';
8
- import { Config, loadConf, readConfJson } from './conf.js';
9
- import { loggerFactory } from './logger.js';
8
+ import { Config, loadConf, readConfJson } from '../server/conf.js';
9
+ import { loggerFactory } from '../server/logger.js';
10
10
  import { buildClient } from './client-build.js';
11
11
 
12
12
  const logger = loggerFactory(import.meta);
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Manages the client-side build process, including full builds and incremental builds.
3
- * @module server/client-build.js
3
+ * @module src/client-builder/client-build.js
4
4
  * @namespace clientBuild
5
5
  */
6
6
 
@@ -8,18 +8,18 @@
8
8
 
9
9
  import fs from 'fs-extra';
10
10
  import { transformClientJs, JSONweb } from './client-formatted.js';
11
- import { loggerFactory } from './logger.js';
11
+ import { loggerFactory } from '../server/logger.js';
12
12
  import {
13
13
  getCapVariableName,
14
14
  newInstance,
15
15
  orderArrayFromAttrInt,
16
16
  uniqueArray,
17
17
  } from '../client/components/core/CommonJs.js';
18
- import { readConfJson } from './conf.js';
18
+ import { readConfJson } from '../server/conf.js';
19
19
  import { minify } from 'html-minifier-terser';
20
20
  import AdmZip from 'adm-zip';
21
21
  import * as dir from 'path';
22
- import { shellExec } from './process.js';
22
+ import { shellExec } from '../server/process.js';
23
23
  import { SitemapStream, streamToPromise } from 'sitemap';
24
24
  import { Readable } from 'stream';
25
25
  import { buildIcons } from './client-icons.js';
@@ -751,8 +751,9 @@ const buildClient = async (
751
751
  )
752
752
  )
753
753
  for (const view of views) {
754
- const buildPath = `${rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
755
- }${view.path === '/' ? view.path : `${view.path}/`}`;
754
+ const buildPath = `${
755
+ rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
756
+ }${view.path === '/' ? view.path : `${view.path}/`}`;
756
757
 
757
758
  if (!fs.existsSync(buildPath)) fs.mkdirSync(buildPath, { recursive: true });
758
759
 
@@ -768,8 +769,9 @@ const buildClient = async (
768
769
  fs.writeFileSync(`${buildPath}${buildId}.js`, jsSrc, 'utf8');
769
770
  const title = metadata.title ? metadata.title : title;
770
771
 
771
- const canonicalURL = `https://${host}${path}${view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/`
772
- }`;
772
+ const canonicalURL = `https://${host}${path}${
773
+ view.path === '/' ? (path === '/' ? '' : '/') : path === '/' ? `${view.path.slice(1)}/` : `${view.path}/`
774
+ }`;
773
775
 
774
776
  let ssrHeadComponents = ``;
775
777
  let ssrBodyComponents = ``;
@@ -903,12 +905,12 @@ const buildClient = async (
903
905
  `${buildPath}index.html`,
904
906
  minifyBuild
905
907
  ? await minify(htmlSrc, {
906
- minifyCSS: true,
907
- minifyJS: true,
908
- collapseBooleanAttributes: true,
909
- collapseInlineTagWhitespace: true,
910
- collapseWhitespace: true,
911
- })
908
+ minifyCSS: true,
909
+ minifyJS: true,
910
+ collapseBooleanAttributes: true,
911
+ collapseInlineTagWhitespace: true,
912
+ collapseWhitespace: true,
913
+ })
912
914
  : htmlSrc,
913
915
  'utf8',
914
916
  );
@@ -997,8 +999,9 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
997
999
  renderApi: { JSONweb },
998
1000
  });
999
1001
 
1000
- const buildPath = `${rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
1001
- }${view.path === '/' ? view.path : `${view.path}/`}`;
1002
+ const buildPath = `${
1003
+ rootClientPath[rootClientPath.length - 1] === '/' ? rootClientPath.slice(0, -1) : rootClientPath
1004
+ }${view.path === '/' ? view.path : `${view.path}/`}`;
1002
1005
 
1003
1006
  const indexUrl = buildIndexUrl(view.path);
1004
1007
  if (view.offlineDefault) {
@@ -1018,12 +1021,12 @@ Sitemap: ${sitemapBaseUrl}/sitemap.xml`,
1018
1021
  buildHtmlPath,
1019
1022
  minifyBuild
1020
1023
  ? await minify(htmlSrc, {
1021
- minifyCSS: true,
1022
- minifyJS: true,
1023
- collapseBooleanAttributes: true,
1024
- collapseInlineTagWhitespace: true,
1025
- collapseWhitespace: true,
1026
- })
1024
+ minifyCSS: true,
1025
+ minifyJS: true,
1026
+ collapseBooleanAttributes: true,
1027
+ collapseInlineTagWhitespace: true,
1028
+ collapseWhitespace: true,
1029
+ })
1027
1030
  : htmlSrc,
1028
1031
  'utf8',
1029
1032
  );
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Module for creating a client-side development server
3
- * @module src/server/client-dev-server.js
3
+ * @module src/client-builder/client-dev-server.js
4
4
  * @namespace clientDevServer
5
5
  */
6
6
  import fs from 'fs-extra';
7
7
  import nodemon from 'nodemon';
8
8
  import dotenv from 'dotenv';
9
- import { shellExec } from './process.js';
10
- import { loggerFactory } from './logger.js';
9
+ import { shellExec } from '../server/process.js';
10
+ import { loggerFactory } from '../server/logger.js';
11
11
  import Underpost from '../index.js';
12
12
 
13
13
  const logger = loggerFactory(import.meta);