underpost 3.2.8 → 3.2.9

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.
@@ -172,9 +172,19 @@ class UnderpostCluster {
172
172
  const podNetworkCidr = options.podNetworkCidr || '192.168.0.0/16';
173
173
  const controlPlaneEndpoint = options.controlPlaneEndpoint || `${os.hostname()}:6443`;
174
174
 
175
- // Initialize kubeadm control plane
175
+ // Initialize kubeadm control plane.
176
+ // Use CRI-O socket when available, otherwise fall back to containerd.
177
+ const crioSocket = 'unix:///var/run/crio/crio.sock';
178
+ const containerdSocket = 'unix:///run/containerd/containerd.sock';
179
+ const criSocket =
180
+ shellExec(`test -S /var/run/crio/crio.sock && echo crio || echo containerd`, {
181
+ stdout: true,
182
+ silent: true,
183
+ }).trim() === 'crio'
184
+ ? crioSocket
185
+ : containerdSocket;
176
186
  shellExec(
177
- `sudo kubeadm init --pod-network-cidr=${podNetworkCidr} --control-plane-endpoint="${controlPlaneEndpoint}"`,
187
+ `sudo kubeadm init --pod-network-cidr=${podNetworkCidr} --control-plane-endpoint="${controlPlaneEndpoint}" --cri-socket=${criSocket}`,
178
188
  );
179
189
  // Configure kubectl for the current user
180
190
  Underpost.cluster.chown('kubeadm'); // Pass 'kubeadm' to chown
@@ -389,8 +399,17 @@ EOF
389
399
  );
390
400
  shellExec(`rm -f ${tarPath}`);
391
401
  } else if (options.kubeadm || options.k3s) {
392
- // Kubeadm / K3s: use crictl to pull directly into containerd
393
- shellExec(`sudo crictl pull ${image}`);
402
+ // Kubeadm / K3s: use crictl to pull directly into the active CRI runtime.
403
+ // crictl is not in sudo's secure_path; pass full PATH through env.
404
+ // Point crictl at CRI-O when the socket exists, otherwise fall back to containerd.
405
+ const criSock =
406
+ shellExec(`test -S /var/run/crio/crio.sock && echo crio || echo containerd`, {
407
+ stdout: true,
408
+ silent: true,
409
+ }).trim() === 'crio'
410
+ ? 'unix:///var/run/crio/crio.sock'
411
+ : 'unix:///run/containerd/containerd.sock';
412
+ shellExec(`sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl --runtime-endpoint ${criSock} pull ${image}`);
394
413
  }
395
414
  },
396
415
 
@@ -453,12 +472,11 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
453
472
  shellExec(`sudo sysctl -w fs.inotify.max_queued_events=2099999999`);
454
473
 
455
474
  // shellExec(`sudo sysctl --system`); // Apply sysctl changes immediately
456
- // Apply NAT iptables rules.
475
+ // Apply NAT iptables rules and configure firewalld for Kubernetes.
476
+ // nat-iptables.sh enables firewalld and opens all required ports; do NOT stop it
477
+ // afterwards — keeping firewalld running with these rules is required for
478
+ // multi-machine kubeadm inter-node communication.
457
479
  shellExec(`${underpostRoot}/scripts/nat-iptables.sh`, { silent: true });
458
-
459
- // Disable firewalld (common cause of network issues in Kubernetes)
460
- shellExec(`sudo systemctl stop firewalld`); // Stop if running
461
- shellExec(`sudo systemctl disable firewalld`); // Disable from starting on boot
462
480
  },
463
481
 
464
482
  /**
@@ -575,8 +593,10 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
575
593
  shellExec('sudo systemctl stop kubelet');
576
594
  shellExec('sudo systemctl stop docker');
577
595
  shellExec('sudo systemctl stop podman');
578
- // Safely unmount pod filesystems to avoid errors.
579
- shellExec('sudo umount -f /var/lib/kubelet/pods/*/*');
596
+ // Lazy-unmount all kubelet pod mounts to avoid 'Device or resource busy' on rm.
597
+ shellExec(
598
+ `sudo sh -c 'findmnt --raw --noheadings -o TARGET | grep /var/lib/kubelet | sort -r | xargs -r umount -l' 2>/dev/null || true`,
599
+ );
580
600
 
581
601
  // Phase 3: Execute official uninstallation commands (type-specific)
582
602
  const clusterType = options.clusterType || 'kind';
@@ -584,6 +604,14 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
584
604
  `Phase 3/7: Executing official reset/uninstallation commands for cluster type: '${clusterType}'...`,
585
605
  );
586
606
  if (clusterType === 'kubeadm') {
607
+ // Kill control plane processes that hold ports (6443, 10257, 10259, 2379, 2380)
608
+ // so the next `kubeadm init` does not fail with [ERROR Port-xxxx].
609
+ logger.info(' -> Stopping and killing control plane containers and processes...');
610
+ shellExec('sudo crictl rm -a -f 2>/dev/null || true');
611
+ shellExec('sudo crictl rmp -a -f 2>/dev/null || true');
612
+ shellExec('sudo systemctl stop etcd 2>/dev/null || true');
613
+ for (const port of [6443, 10259, 10257, 2379, 2380])
614
+ shellExec(`sudo fuser -k ${port}/tcp 2>/dev/null || true`);
587
615
  logger.info(' -> Executing kubeadm reset...');
588
616
  shellExec('sudo kubeadm reset --force');
589
617
  } else if (clusterType === 'k3s') {
@@ -600,7 +628,12 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
600
628
  // Remove any leftover configurations and data.
601
629
  shellExec('sudo rm -rf /etc/kubernetes/*');
602
630
  shellExec('sudo rm -rf /etc/cni/net.d/*');
631
+ // Second-pass lazy umount before rm to clear any remaining busy mounts.
632
+ shellExec(
633
+ `sudo sh -c 'findmnt --raw --noheadings -o TARGET | grep /var/lib/kubelet | sort -r | xargs -r umount -l' 2>/dev/null || true`,
634
+ );
603
635
  shellExec('sudo rm -rf /var/lib/kubelet/*');
636
+ shellExec('sudo rm -rf /var/lib/etcd');
604
637
  shellExec('sudo rm -rf /var/lib/cni/*');
605
638
  shellExec('sudo rm -rf /var/lib/docker/*');
606
639
  shellExec('sudo rm -rf /var/lib/containerd/*');
@@ -613,11 +646,14 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
613
646
  // Remove iptables rules and CNI network interfaces.
614
647
  shellExec('sudo iptables -F');
615
648
  shellExec('sudo iptables -t nat -F');
616
- shellExec('sudo ip link del cni0');
617
- shellExec('sudo ip link del flannel.1');
649
+ shellExec('sudo ip link del cni0 2>/dev/null || true');
650
+ shellExec('sudo ip link del flannel.1 2>/dev/null || true');
651
+ shellExec('sudo ip link del vxlan.calico 2>/dev/null || true');
652
+ shellExec('sudo ip link del tunl0 2>/dev/null || true');
618
653
 
619
654
  logger.info('Phase 6/7: Clean up images');
620
- shellExec(`podman rmi $(podman images -qa) --force`);
655
+ shellExec('sudo podman rmi --all --force 2>/dev/null || true');
656
+ shellExec('sudo crictl rmi --prune 2>/dev/null || true');
621
657
 
622
658
  // Phase 6: Reload daemon and finalize
623
659
  logger.info('Phase 7/7: Reloading the system daemon and finalizing...');
@@ -687,6 +723,9 @@ net.ipv4.ip_forward = 1' | sudo tee ${iptableConfPath}`,
687
723
  // Install Podman
688
724
  shellExec(`sudo dnf -y install podman`);
689
725
 
726
+ // Install CRI-O (required for kubeadm with CRI-O socket)
727
+ shellExec(`node bin run install-crio`);
728
+
690
729
  // Install Kind (Kubernetes in Docker)
691
730
  shellExec(`[ $(uname -m) = ${archData.name} ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.29.0/kind-linux-${archData.alias}
692
731
  chmod +x ./kind
@@ -744,6 +783,14 @@ EOF`);
744
783
  console.log('Removing Podman...');
745
784
  shellExec(`sudo dnf -y remove podman`);
746
785
 
786
+ // Remove CRI-O
787
+ console.log('Removing CRI-O...');
788
+ shellExec(`sudo systemctl stop crio 2>/dev/null || true`);
789
+ shellExec(`sudo systemctl disable crio 2>/dev/null || true`);
790
+ shellExec(`sudo dnf -y remove cri-o`);
791
+ shellExec(`sudo rm -f /etc/yum.repos.d/cri-o.repo`);
792
+ shellExec(`sudo rm -f /etc/crictl.yaml`);
793
+
747
794
  // Remove Kubeadm, Kubelet, and Kubectl
748
795
  console.log('Removing Kubernetes tools...');
749
796
  shellExec(`sudo yum remove -y kubelet kubeadm kubectl`);
package/src/cli/db.js CHANGED
@@ -839,6 +839,8 @@ class UnderpostDB {
839
839
  pods: podsToProcess.map((p) => p.NAME),
840
840
  });
841
841
 
842
+ let exportSucceeded = false;
843
+
842
844
  // Process each pod
843
845
  for (const pod of podsToProcess) {
844
846
  logger.info('Processing pod', { podName: pod.NAME, node: pod.NODE, status: pod.STATUS });
@@ -871,7 +873,7 @@ class UnderpostDB {
871
873
 
872
874
  if (options.export === true) {
873
875
  const outputPath = options.outPath || toNewSqlPath;
874
- await Underpost.db._exportMariaDB({
876
+ const success = await Underpost.db._exportMariaDB({
875
877
  pod,
876
878
  namespace,
877
879
  dbName,
@@ -879,6 +881,7 @@ class UnderpostDB {
879
881
  password,
880
882
  outputPath,
881
883
  });
884
+ exportSucceeded = exportSucceeded || success;
882
885
  }
883
886
  break;
884
887
  }
@@ -909,13 +912,14 @@ class UnderpostDB {
909
912
 
910
913
  if (options.export === true) {
911
914
  const outputPath = options.outPath || toNewBsonPath;
912
- Underpost.db._exportMongoDB({
915
+ const success = Underpost.db._exportMongoDB({
913
916
  pod,
914
917
  namespace,
915
918
  dbName,
916
919
  outputPath,
917
920
  collections: options.collections,
918
921
  });
922
+ exportSucceeded = exportSucceeded || success;
919
923
  }
920
924
  break;
921
925
  }
@@ -926,6 +930,10 @@ class UnderpostDB {
926
930
  }
927
931
  }
928
932
 
933
+ if (options.export === true && exportSucceeded === true) {
934
+ Underpost.db._enforceBackupRetention(`../${repoName}/${hostFolder}`);
935
+ }
936
+
929
937
  // Mark this host+path combination as processed
930
938
  processedHostPaths.add(hostPathKey);
931
939
  }
@@ -948,6 +956,43 @@ class UnderpostDB {
948
956
  throw error;
949
957
  }
950
958
  },
959
+ /**
960
+ * Helper: Removes old timestamp backup folders and keeps only the newest ones.
961
+ * @method _enforceBackupRetention
962
+ * @memberof UnderpostDB
963
+ * @param {string} backupDir - Path to host-folder backup directory.
964
+ * @param {number} [maxRetention=MAX_BACKUP_RETENTION] - Maximum folders to keep.
965
+ * @return {number} Number of removed backup folders.
966
+ */
967
+ _enforceBackupRetention(backupDir, maxRetention = MAX_BACKUP_RETENTION) {
968
+ try {
969
+ if (!fs.existsSync(backupDir)) return 0;
970
+
971
+ const timestamps = fs
972
+ .readdirSync(backupDir)
973
+ .filter((entry) => /^\d+$/.test(entry))
974
+ .sort((a, b) => parseInt(b, 10) - parseInt(a, 10));
975
+
976
+ if (timestamps.length <= maxRetention) return 0;
977
+
978
+ const staleTimestamps = timestamps.slice(maxRetention);
979
+ staleTimestamps.forEach((timestamp) => {
980
+ fs.removeSync(`${backupDir}/${timestamp}`);
981
+ });
982
+
983
+ logger.info('Pruned old backup timestamp folders', {
984
+ backupDir,
985
+ kept: maxRetention,
986
+ removed: staleTimestamps.length,
987
+ removedTimestamps: staleTimestamps,
988
+ });
989
+
990
+ return staleTimestamps.length;
991
+ } catch (error) {
992
+ logger.error('Failed to enforce backup retention', { backupDir, maxRetention, error: error.message });
993
+ return 0;
994
+ }
995
+ },
951
996
 
952
997
  /**
953
998
  * Creates cluster metadata for the specified deployment.
package/src/cli/deploy.js CHANGED
@@ -125,17 +125,39 @@ class UnderpostDeploy {
125
125
  * @param {string} namespace - Kubernetes namespace for the deployment.
126
126
  * @param {Array<object>} volumes - Volume configurations for the deployment.
127
127
  * @param {Array<string>} cmd - Command to run in the deployment container.
128
+ * @param {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment.
129
+ * @param {boolean} pullBundle - Whether to pull the pre-built client bundle from Cloudinary before starting. Use together with skipFullBuild to skip the local build entirely.
128
130
  * @returns {string} - YAML deployment configuration for the specified deployment.
129
131
  * @memberof UnderpostDeploy
130
132
  */
131
- deploymentYamlPartsFactory({ deployId, env, suffix, resources, replicas, image, namespace, volumes, cmd }) {
133
+ deploymentYamlPartsFactory({
134
+ deployId,
135
+ env,
136
+ suffix,
137
+ resources,
138
+ replicas,
139
+ image,
140
+ namespace,
141
+ volumes,
142
+ cmd,
143
+ skipFullBuild,
144
+ pullBundle,
145
+ }) {
132
146
  if (!cmd)
133
- cmd = [
134
- // `npm install -g npm@11.2.0`,
135
- // `npm install -g underpost`,
136
- `underpost secret underpost --create-from-env`,
137
- `underpost start --build --run ${deployId} ${env}`,
138
- ];
147
+ cmd =
148
+ pullBundle || skipFullBuild
149
+ ? [
150
+ // When pullBundle (or skipFullBuild) is set the container pulls the pre-built client
151
+ // bundle from Cloudinary (push-bundle must have been run on the dev machine beforehand).
152
+ `underpost secret underpost --create-from-env`,
153
+ `underpost start --build --run --pull-bundle --skip-full-build ${deployId} ${env}`,
154
+ ]
155
+ : [
156
+ // `npm install -g npm@11.2.0`,
157
+ // `npm install -g underpost`,
158
+ `underpost secret underpost --create-from-env`,
159
+ `underpost start --build --run ${deployId} ${env}`,
160
+ ];
139
161
  const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
140
162
  if (!volumes) volumes = [];
141
163
  const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
@@ -224,6 +246,8 @@ spec:
224
246
  * @param {string} [options.retryPerTryTimeout] - Retry per-try timeout setting for the deployment.
225
247
  * @param {boolean} [options.disableDeploymentProxy] - Whether to disable deployment proxy.
226
248
  * @param {string} [options.traffic] - Traffic status for the deployment.
249
+ * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; forwarded to deploymentYamlPartsFactory to generate a pull-bundle startup command.
250
+ * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; forwarded to deploymentYamlPartsFactory. Use together with skipFullBuild.
227
251
  * @returns {Promise<void>} - Promise that resolves when the manifest is built.
228
252
  * @memberof UnderpostDeploy
229
253
  */
@@ -260,6 +284,8 @@ ${Underpost.deploy
260
284
  image,
261
285
  namespace: options.namespace,
262
286
  cmd: options.cmd ? options.cmd.split(',').map((c) => c.trim()) : undefined,
287
+ skipFullBuild: options.skipFullBuild,
288
+ pullBundle: options.pullBundle,
263
289
  })
264
290
  .replace('{{ports}}', buildKindPorts(fromPort, toPort))}
265
291
  `;
@@ -553,10 +579,13 @@ spec:
553
579
  * @param {string} [options.kindType] - Type of Kubernetes resource to retrieve information for.
554
580
  * @param {number} [options.port] - Port number for exposing the deployment.
555
581
  * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
582
+ * @param {number} [options.exposePort] - Local:remote port override when --expose is active (overrides auto-detected service port).
556
583
  * @param {boolean} [options.k3s] - Whether to use k3s cluster context.
557
584
  * @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
558
585
  * @param {boolean} [options.kind] - Whether to use kind cluster context.
559
586
  * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
587
+ * @param {boolean} [options.skipFullBuild] - Whether to skip the full client bundle build; passed through to buildManifest/deploymentYamlPartsFactory.
588
+ * @param {boolean} [options.pullBundle] - Whether to pull the pre-built client bundle from Cloudinary; passed through to buildManifest/deploymentYamlPartsFactory. Use together with skipFullBuild.
560
589
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
561
590
  * @memberof UnderpostDeploy
562
591
  */
package/src/cli/fs.js CHANGED
@@ -150,8 +150,12 @@ class UnderpostFileStorage {
150
150
  } else pullSkipCount++;
151
151
  }
152
152
  if (pullSkipCount > 0) logger.warn(`Pull skipped ${pullSkipCount} files that already exist`);
153
- Underpost.repo.initLocalRepo({ path });
154
- shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`);
153
+ // Only run git init/commit when the caller explicitly requests git tracking (--git flag).
154
+ // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
155
+ if (options.git === true) {
156
+ Underpost.repo.initLocalRepo({ path });
157
+ shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`);
158
+ }
155
159
  } else {
156
160
  const files =
157
161
  options.git === true ? Underpost.repo.getChangedFiles(path) : await fs.readdir(path, { recursive: true });
@@ -203,11 +207,13 @@ class UnderpostFileStorage {
203
207
  * @description Uploads a file to Cloudinary.
204
208
  * @param {string} path - The path to the file to upload.
205
209
  * @param {object} [options] - An object containing options for the upload.
206
- * @param {boolean} [options.force=false] - Flag to force file operations.
210
+ * @param {string} [options.deployId=''] - The identifier for the deployment (used to locate the storage config file).
211
+ * @param {boolean} [options.force=false] - Flag to force file operations (overwrites existing remote asset).
207
212
  * @param {string} [options.storageFilePath=''] - The path to the storage configuration file.
208
213
  * @returns {Promise<object>} A promise that resolves to the upload result.
209
214
  * @memberof UnderpostFileStorage
210
215
  */
216
+
211
217
  async upload(
212
218
  path,
213
219
  options = { rm: false, recursive: false, deployId: '', force: false, pull: false, storageFilePath: '' },
@@ -235,6 +241,7 @@ class UnderpostFileStorage {
235
241
  * @param {string} path - The path to the file to pull.
236
242
  * @param {object} [options] - Pull options.
237
243
  * @param {boolean} [options.omitUnzip=false] - If true, do not extract zip and keep downloaded zip file.
244
+ * @param {boolean} [options.force=false] - If true, re-download even if the local zip already exists.
238
245
  * @returns {Promise<void>} A promise that resolves when the file is pulled.
239
246
  * @memberof UnderpostFileStorage
240
247
  */
@@ -264,6 +271,13 @@ class UnderpostFileStorage {
264
271
  path = Underpost.fs.zip2File(zipPath);
265
272
  fs.removeSync(`${path}.zip`);
266
273
  },
274
+ /**
275
+ * @method delete
276
+ * @description Deletes a file from Cloudinary by its public ID.
277
+ * @param {string} path - The path (public ID) of the file to delete.
278
+ * @returns {Promise<object>} A promise that resolves to the Cloudinary delete result.
279
+ * @memberof UnderpostFileStorage
280
+ */
267
281
  async delete(path) {
268
282
  Underpost.fs.cloudinaryConfig();
269
283
  const deleteResult = await cloudinary.api
package/src/cli/index.js CHANGED
@@ -66,6 +66,10 @@ program
66
66
  .option('--underpost-quickly-install', 'Uses Underpost Quickly Install for dependency installation.')
67
67
  .option('--skip-pull-base', 'Skips cloning repositories, uses current workspace code directly.')
68
68
  .option('--skip-full-build', 'Skips the full client bundle build during deployment.')
69
+ .option(
70
+ '--pull-bundle',
71
+ 'Downloads the pre-built client bundle from Cloudinary via pull-bundle before starting. Use together with --skip-full-build to skip the local build entirely.',
72
+ )
69
73
  .action(Underpost.start.callback)
70
74
  .description('Initiates application servers, build pipelines, or other defined services based on the deployment ID.');
71
75
 
@@ -230,6 +234,7 @@ program
230
234
  .option('--ban-egress-clear', 'Clears all banned egress IP addresses.')
231
235
  .option('--ban-both-add', 'Adds IP addresses to both banned ingress and egress lists.')
232
236
  .option('--ban-both-remove', 'Removes IP addresses from both banned ingress and egress lists.')
237
+ .option('--mac', 'Prints the MAC address of the main network interface.')
233
238
  .description('Displays the current public machine IP addresses.')
234
239
  .action(Underpost.dns.ipDispatcher);
235
240
 
@@ -333,6 +338,14 @@ program
333
338
  'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
334
339
  )
335
340
  .option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
341
+ .option(
342
+ '--skip-full-build',
343
+ 'Skip client bundle rebuild; container will pull pre-built bundle via pull-bundle instead.',
344
+ )
345
+ .option(
346
+ '--pull-bundle',
347
+ 'Explicitly pull the pre-built client bundle from Cloudinary inside the container. Use together with --skip-full-build.',
348
+ )
336
349
  .description('Manages application deployments, defaulting to deploying development pods.')
337
350
  .action(Underpost.deploy.callback);
338
351
 
@@ -661,6 +674,14 @@ program
661
674
  '(e.g., "127.0.0.1=foo.local,bar.local;10.1.2.3=foo.remote,bar.remote").',
662
675
  )
663
676
  .option('--copy', 'Copies the runner output to the clipboard (supported by: generate-pass, template-deploy-local).')
677
+ .option(
678
+ '--skip-full-build',
679
+ 'Skip client bundle rebuild; triggers pull-bundle in container startup (supported by: sync, template-deploy).',
680
+ )
681
+ .option(
682
+ '--pull-bundle',
683
+ 'Explicitly download the pre-built client bundle from Cloudinary inside the container (supported by: sync, template-deploy). Use together with --skip-full-build.',
684
+ )
664
685
  .description('Runs specified scripts using various runners.')
665
686
  .action(Underpost.run.callback);
666
687
 
@@ -401,6 +401,7 @@ class UnderpostRepository {
401
401
 
402
402
  /**
403
403
  * Retrieves the message of the last Git commit.
404
+ * @param {number} [skip=0] - Number of commits to skip from HEAD (0 = most recent).
404
405
  * @returns {string} The last commit message.
405
406
  * @memberof UnderpostRepository
406
407
  */
@@ -949,6 +950,7 @@ Prevent build private config repo.`,
949
950
  /**
950
951
  * Retrieves the Git commit history.
951
952
  * @param {number} [sinceCommit=1] - The number of recent commits to retrieve.
953
+ * @param {string} [repoPath='.'] - The path to the repository.
952
954
  * @returns {Array<{hash: string, message: string, files: string}>} An array of commit objects with hash, message, and files.
953
955
  * @memberof UnderpostRepository
954
956
  */
@@ -1270,19 +1272,10 @@ Prevent build private config repo.`,
1270
1272
  },
1271
1273
 
1272
1274
  /**
1273
- * Returns metadata about unpushed commits in a git repository.
1274
- * Fetches from origin, then counts commits ahead of the remote branch.
1275
- * @param {string} [repoPath='.'] - Path to the git repository.
1276
- * @param {number} [fallback=1] - Value to return as `count` when no unpushed commits are detected.
1277
- * @returns {{ count: number, branch: string, hasUnpushed: boolean }} Unpush metadata.
1278
- * @memberof UnderpostRepository
1279
- */
1280
- /**
1281
- * Checks whether a remote Git repository URL is reachable.
1282
- * Uses `git ls-remote` with `|| true` so the process always exits 0.
1283
- * Injects `GITHUB_TOKEN` into GitHub HTTPS URLs when available.
1284
- * @param {string} url - Full HTTPS clone URL to test (e.g. "https://github.com/org/repo.git").
1285
- * @returns {boolean} `true` when the remote responded with at least one ref hash.
1275
+ * Resolves a Git remote URL, normalizing short-form owner/repo references to full
1276
+ * GitHub HTTPS URLs and injecting GITHUB_TOKEN when available.
1277
+ * @param {string} url - The repository URL or short-form (e.g. "owner/repo" or full HTTPS URL).
1278
+ * @returns {string} The resolved (and optionally authenticated) HTTPS URL.
1286
1279
  * @memberof UnderpostRepository
1287
1280
  */
1288
1281
  resolveAuthUrl(url) {
@@ -1300,7 +1293,14 @@ Prevent build private config repo.`,
1300
1293
  }
1301
1294
  return normalized;
1302
1295
  },
1303
-
1296
+ /**
1297
+ * Checks whether a remote Git repository URL is reachable.
1298
+ * Uses `git ls-remote` with `|| true` so the process always exits 0.
1299
+ * Injects `GITHUB_TOKEN` into GitHub HTTPS URLs when available.
1300
+ * @param {string} url - Full HTTPS clone URL to test (e.g. "https://github.com/org/repo.git").
1301
+ * @returns {boolean} `true` when the remote responded with at least one ref hash.
1302
+ * @memberof UnderpostRepository
1303
+ */
1304
1304
  isRemoteRepo(url) {
1305
1305
  if (!url) return false;
1306
1306
  const authUrl = Underpost.repo.resolveAuthUrl(url);
@@ -1314,6 +1314,14 @@ Prevent build private config repo.`,
1314
1314
  return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
1315
1315
  },
1316
1316
 
1317
+ /**
1318
+ * Returns metadata about unpushed commits in a git repository.
1319
+ * Fetches from origin, then counts commits ahead of the remote branch.
1320
+ * @param {string} [repoPath='.'] - Path to the git repository.
1321
+ * @param {number} [fallback=1] - Value to return as `count` when no unpushed commits are detected.
1322
+ * @returns {{ count: number, branch: string, hasUnpushed: boolean }} Unpush metadata.
1323
+ * @memberof UnderpostRepository
1324
+ */
1317
1325
  getUnpushedCount(repoPath = '.', fallback = 1) {
1318
1326
  const branch = shellExec(`cd ${repoPath} && git branch --show-current`, {
1319
1327
  stdout: true,
@@ -1353,19 +1361,6 @@ Prevent build private config repo.`,
1353
1361
  .trim()
1354
1362
  .replaceAll('] - ', '] ');
1355
1363
  },
1356
-
1357
- /**
1358
- * Manages a cron-backup Git repository: clone, pull, commit, or push.
1359
- * Resolves the repository path as `../<repoName>` relative to the CWD.
1360
- * Requires the `GITHUB_USERNAME` environment variable to be set.
1361
- * @param {object} params
1362
- * @param {string} params.repoName - Repository name (e.g. `engine-cyberia-cron-backups`).
1363
- * @param {'clone'|'pull'|'commit'|'push'} params.operation - Git operation to perform.
1364
- * @param {string} [params.message=''] - Commit message (used by the `commit` operation).
1365
- * @param {boolean} [params.forceClone=false] - Remove existing clone before re-cloning.
1366
- * @returns {boolean} `true` on success, `false` if GITHUB_USERNAME is unset or on error.
1367
- * @memberof UnderpostRepository
1368
- */
1369
1364
  /**
1370
1365
  * Initializes a git repository at the given path and configures user identity
1371
1366
  * from environment variables (`GITHUB_USERNAME` / `GITHUB_EMAIL`).
@@ -1398,7 +1393,18 @@ Prevent build private config repo.`,
1398
1393
  }
1399
1394
  }
1400
1395
  },
1401
-
1396
+ /**
1397
+ * Manages a cron-backup Git repository: clone, pull, commit, or push.
1398
+ * Resolves the repository path as `../<repoName>` relative to the CWD.
1399
+ * Requires the `GITHUB_USERNAME` environment variable to be set.
1400
+ * @param {object} params
1401
+ * @param {string} params.repoName - Repository name (e.g. `engine-cyberia-cron-backups`).
1402
+ * @param {'clone'|'pull'|'commit'|'push'} params.operation - Git operation to perform.
1403
+ * @param {string} [params.message=''] - Commit message (used by the `commit` operation).
1404
+ * @param {boolean} [params.forceClone=false] - Remove existing clone before re-cloning.
1405
+ * @returns {boolean} `true` on success, `false` if GITHUB_USERNAME is unset or on error.
1406
+ * @memberof UnderpostRepository
1407
+ */
1402
1408
  manageBackupRepo({ repoName, operation, message = '', forceClone = false }) {
1403
1409
  try {
1404
1410
  const username = process.env.GITHUB_USERNAME;