@underpostnet/underpost 2.99.5 → 2.99.7

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 (37) hide show
  1. package/.github/workflows/ghpkg.ci.yml +10 -25
  2. package/.github/workflows/npmpkg.ci.yml +13 -2
  3. package/CHANGELOG.md +496 -0
  4. package/README.md +4 -4
  5. package/baremetal/commission-workflows.json +43 -6
  6. package/bin/deploy.js +13 -0
  7. package/cli.md +84 -42
  8. package/examples/static-page/README.md +80 -13
  9. package/jsdoc.json +26 -5
  10. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +47 -0
  11. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +47 -0
  12. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  13. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  14. package/package.json +2 -4
  15. package/scripts/maas-setup.sh +13 -9
  16. package/scripts/rocky-kickstart.sh +294 -0
  17. package/src/cli/baremetal.js +237 -555
  18. package/src/cli/cloud-init.js +27 -45
  19. package/src/cli/index.js +52 -6
  20. package/src/cli/kickstart.js +149 -0
  21. package/src/cli/repository.js +166 -13
  22. package/src/cli/run.js +26 -19
  23. package/src/cli/ssh.js +1 -1
  24. package/src/cli/static.js +27 -1
  25. package/src/cli/system.js +332 -0
  26. package/src/client/components/core/Docs.js +22 -3
  27. package/src/db/DataBaseProvider.js +3 -3
  28. package/src/db/mariadb/MariaDB.js +3 -3
  29. package/src/db/mongo/MongooseDB.js +3 -3
  30. package/src/index.js +28 -5
  31. package/src/mailer/EmailRender.js +3 -3
  32. package/src/mailer/MailerProvider.js +4 -4
  33. package/src/server/backup.js +23 -5
  34. package/src/server/client-build-docs.js +29 -3
  35. package/src/server/conf.js +6 -27
  36. package/src/server/cron.js +354 -135
  37. package/src/server/dns.js +2 -0
@@ -64,7 +64,7 @@ class UnderpostCloudInit {
64
64
  fs.writeFileSync(
65
65
  `${nfsHostToolsPath}/date.sh`,
66
66
  Underpost.baremetal.stepsRender(
67
- Underpost.baremetal.systemProvisioningFactory[systemProvisioning].timezone({
67
+ Underpost.system.factory[systemProvisioning].timezone({
68
68
  timezone,
69
69
  chronyConfPath,
70
70
  }),
@@ -78,7 +78,7 @@ class UnderpostCloudInit {
78
78
  fs.writeFileSync(
79
79
  `${nfsHostToolsPath}/keyboard.sh`,
80
80
  Underpost.baremetal.stepsRender(
81
- Underpost.baremetal.systemProvisioningFactory[systemProvisioning].keyboard(keyboard.layout),
81
+ Underpost.system.factory[systemProvisioning].keyboard(keyboard.layout),
82
82
  false,
83
83
  ),
84
84
  'utf8',
@@ -122,8 +122,6 @@ ${Underpost.baremetal.stepsRender(
122
122
  `cloud-init modules --mode=config`,
123
123
  `sleep 3`,
124
124
  `cloud-init modules --mode=final`,
125
- `sleep 3`,
126
- `/underpost/enlistment.sh`,
127
125
  ],
128
126
  false,
129
127
  )}`,
@@ -190,45 +188,6 @@ cat /etc/default/keyboard`,
190
188
  logger.info('Build', `${nfsHostToolsPath}/device_scan.sh`);
191
189
  fs.copySync(`${underpostRoot}/scripts/device-scan.sh`, `${nfsHostToolsPath}/device_scan.sh`);
192
190
 
193
- // Build and write the MAAS enlistment script.
194
- logger.info('Build', `${nfsHostToolsPath}/enlistment.sh`);
195
- fs.writeFileSync(
196
- `${nfsHostToolsPath}/enlistment.sh`,
197
- `#!/bin/bash
198
- set -x
199
-
200
- # ------------------------------------------------------------
201
- # Step: Commission a machine in MAAS using OAuth1 authentication
202
- # ------------------------------------------------------------
203
-
204
- MACHINE_ID=$(cat /underpost/system-id)
205
- CONSUMER_KEY=$(cat /underpost/consumer-key)
206
- TOKEN_KEY=$(cat /underpost/token-key)
207
- TOKEN_SECRET=$(cat /underpost/token-secret)
208
-
209
- echo ">>> Starting MAAS machine commissioning for system_id: $MACHINE_ID …"
210
-
211
- curl -X POST \\
212
- --fail --location --verbose --include --raw --trace-ascii /dev/stdout\\
213
- --header "Authorization:\\
214
- OAuth oauth_version=1.0,\\
215
- oauth_signature_method=PLAINTEXT,\\
216
- oauth_consumer_key=$CONSUMER_KEY,\\
217
- oauth_token=$TOKEN_KEY,\\
218
- oauth_signature=&$TOKEN_SECRET,\\
219
- oauth_nonce=$(uuidgen),\\
220
- oauth_timestamp=$(date +%s)"\\
221
- -F "commissioning_scripts=20-maas-01-install-lldpd"\\
222
- -F "enable_ssh=1"\\
223
- -F "skip_bmc_config=1"\\
224
- -F "skip_networking=1"\\
225
- -F "skip_storage=1"\\
226
- -F "testing_scripts=none"\\
227
- http://${callbackMetaData.runnerHost.ip}:5240/MAAS/api/2.0/machines/$MACHINE_ID/op-commission \\
228
- 2>&1 | tee /underpost/enlistment.log || echo "ERROR: MAAS commissioning returned code $?"`,
229
- 'utf8',
230
- );
231
-
232
191
  // Import SSH keys for root user.
233
192
  logger.info('Import ssh keys');
234
193
  shellExec(`sudo rm -rf ${nfsHostPath}/root/.ssh`);
@@ -659,7 +618,30 @@ curl -X POST \\
659
618
  }
660
619
 
661
620
  return yaml.join('\n');
662
- } /**
621
+ },
622
+
623
+ /**
624
+ * @method httpServerStaticFactory
625
+ * @description Writes cloud-init files (user-data, meta-data, vendor-data) to the bootstrap HTTP server path.
626
+ * @param {object} params
627
+ * @param {string} params.bootstrapHttpServerPath
628
+ * @param {string} params.hostname
629
+ * @param {string} params.cloudConfigSrc
630
+ * @param {string} [params.vendorData='']
631
+ * @memberof UnderpostCloudInit
632
+ * @returns {void}
633
+ */
634
+ httpServerStaticFactory({ bootstrapHttpServerPath, hostname, cloudConfigSrc, vendorData = '' }) {
635
+ if (!cloudConfigSrc) return;
636
+ const dir = `${bootstrapHttpServerPath}/${hostname}/cloud-init`;
637
+ shellExec(`mkdir -p ${dir}`);
638
+ fs.writeFileSync(`${dir}/user-data`, cloudConfigSrc, 'utf8');
639
+ fs.writeFileSync(`${dir}/meta-data`, `instance-id: ${hostname}\nlocal-hostname: ${hostname}`, 'utf8');
640
+ fs.writeFileSync(`${dir}/vendor-data`, vendorData, 'utf8');
641
+ logger.info(`Cloud-init files written to ${dir}`);
642
+ },
643
+
644
+ /**
663
645
  * @method kernelParamsFactory
664
646
  * @description Generates the kernel parameters for the target machine's bootloader configuration,
665
647
  * including the necessary parameters to enable cloud-init with a specific configuration URL and logging settings.
@@ -670,7 +652,7 @@ curl -X POST \\
670
652
  * @param {string} [options.machine.system_id] - The unique identifier of the machine, used to fetch the correct cloud-init preseed configuration from MAAS.
671
653
  * @return {array} The modified array of kernel parameters with cloud-init parameters included.
672
654
  * @memberof UnderpostCloudInit
673
- */,
655
+ */
674
656
  kernelParamsFactory(
675
657
  macAddress,
676
658
  cmd = [],
package/src/cli/index.js CHANGED
@@ -76,11 +76,20 @@ program
76
76
  .option('--info', 'Displays information about available commit types.')
77
77
  .option('--diff', 'Shows the current git diff changes.')
78
78
  .option('--edit', 'Edit last commit.')
79
- .option('--msg <msg>', 'Sets a custom commit message.')
80
79
  .option('--deploy-id <deploy-id>', 'Sets the deployment configuration ID for the commit context.')
81
80
  .option('--cached', 'Commit staged changes only or context.')
82
81
  .option('--hashes <hashes>', 'Comma-separated list of specific file hashes of commits.')
83
82
  .option('--extension <extension>', 'specific file extensions of commits.')
83
+ .option(
84
+ '--changelog [latest-n]',
85
+ 'Print plain the changelog of the specified number of latest n commits, if no number is provided it will get the changelog to latest ci integration',
86
+ )
87
+ .option('--changelog-build', 'Builds a CHANGELOG.md file based on the commit history')
88
+ .option('--changelog-min-version <version>', 'Sets the minimum version limit for --changelog-build (default: 2.85.0)')
89
+ .option(
90
+ '--changelog-no-hash',
91
+ 'Excludes commit hashes from the generated changelog entries (used with --changelog-build).',
92
+ )
84
93
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
85
94
  .action(Underpost.repo.commit);
86
95
 
@@ -150,6 +159,11 @@ program
150
159
  .option('--dir <dir>', 'HTML dir attribute (default: ltr).')
151
160
  .option('--dev', 'Sets the development cli context')
152
161
 
162
+ .option(
163
+ '--run-sv [port]',
164
+ 'Start a standalone Express static server to preview the static build (default port: 5000).',
165
+ )
166
+
153
167
  .description(`Manages static build of page, bundles, and documentation with comprehensive customization options.`)
154
168
  .action(Underpost.static.callback);
155
169
 
@@ -402,10 +416,27 @@ program
402
416
  '[job-list]',
403
417
  `A comma-separated list of job IDs. Options: ${Underpost.cron.getJobsIDs()}. Defaults to all available jobs.`,
404
418
  )
405
- .option('--init-pm2-cronjobs', 'Initializes PM2 cron jobs from configuration for the specified deployment IDs.')
406
- .option('--git', 'Uploads cron job configurations to GitHub.')
407
- .option('--update-package-scripts', 'Updates package.json start scripts for each deploy-id configuration.')
408
- .description('Manages cron jobs, including initialization, execution, and configuration updates.')
419
+ .option('--generate-k8s-cronjobs', 'Generates Kubernetes CronJob YAML manifests from cron configuration.')
420
+ .option('--apply', 'Applies generated K8s CronJob manifests to the cluster via kubectl.')
421
+ .option(
422
+ '--setup-start [deploy-id]',
423
+ 'Updates deploy-id package.json start script and generates+applies its K8s CronJob manifests.',
424
+ )
425
+ .option('--namespace <namespace>', 'Kubernetes namespace for the CronJob resources (default: "default").')
426
+ .option('--image <image>', 'Custom container image for the CronJob pods.')
427
+ .option('--git', 'Pass --git flag to cron job execution.')
428
+ .option('--cmd <cmd>', 'Optional pre-script commands to run before cron execution.')
429
+ .option('--dev', 'Use local ./ base path instead of global underpost installation.')
430
+ .option('--k3s', 'Use k3s cluster context (apply directly on host).')
431
+ .option('--kind', 'Use kind cluster context (apply via kind-worker container).')
432
+ .option('--kubeadm', 'Use kubeadm cluster context (apply directly on host).')
433
+ .option('--dry-run', 'Preview cron jobs without executing them.')
434
+ .option(
435
+ '--create-job-now',
436
+ 'After applying manifests, immediately create a Job from each CronJob (requires --apply).',
437
+ )
438
+ .option('--ssh', 'Execute backup commands via SSH on the remote node instead of locally.')
439
+ .description('Manages cron jobs: execute jobs directly or generate and apply K8s CronJob manifests.')
409
440
  .action(Underpost.cron.callback);
410
441
 
411
442
  program
@@ -481,6 +512,7 @@ program
481
512
  .option('--status', 'Checks the status of the SSH service.')
482
513
  .option('--connect-uri', 'Displays the connection URI.')
483
514
  .option('--copy', 'Copies the connection URI to clipboard.')
515
+ .description('Manages SSH credentials and sessions for remote access to cluster nodes or services.')
484
516
  .action(Underpost.ssh.callback);
485
517
 
486
518
  program
@@ -534,7 +566,11 @@ program
534
566
  .option('--expose', 'Enables service exposure for the runner execution.')
535
567
  .option('--conf-server-path <conf-server-path>', 'Sets a custom configuration server path.')
536
568
  .option('--underpost-root <underpost-root>', 'Sets a custom Underpost root path.')
537
- .option('--cron-jobs <jobs>', 'Comma-separated list of cron jobs to run before executing the script.')
569
+ .option('--cmd-cron-jobs <cmd-cron-jobs>', 'Pre-script commands to run before cron job execution.')
570
+ .option(
571
+ '--deploy-id-cron-jobs <deploy-id-cron-jobs>',
572
+ 'Specifies deployment IDs to synchronize cron jobs with during execution.',
573
+ )
538
574
  .option('--timezone <timezone>', 'Sets the timezone for the runner execution.')
539
575
  .option('--kubeadm', 'Sets the kubeadm cluster context for the runner execution.')
540
576
  .option('--k3s', 'Sets the k3s cluster context for the runner execution.')
@@ -567,6 +603,11 @@ program
567
603
  '--monitor-status-max-attempts <attempts>',
568
604
  'Sets the maximum number of status check attempts (default: 600).',
569
605
  )
606
+ .option('--dry-run', 'Preview operations without executing them.')
607
+ .option(
608
+ '--create-job-now',
609
+ 'After applying cron manifests, immediately create a Job from each CronJob (forwarded to cron runner).',
610
+ )
570
611
  .description('Runs specified scripts using various runners.')
571
612
  .action(Underpost.run.callback);
572
613
 
@@ -650,6 +691,10 @@ program
650
691
  .option('--remove-machines <system-ids>', 'Removes baremetal machines by comma-separated system IDs, or use "all"')
651
692
  .option('--clear-discovered', 'Clears all discovered baremetal machines from the database.')
652
693
  .option('--commission', 'Init workflow for commissioning a physical machine.')
694
+ .option(
695
+ '--bootstrap-http-server-run',
696
+ 'Runs a temporary bootstrap HTTP server for generic purposes such as serving iPXE scripts or ISO images during commissioning.',
697
+ )
653
698
  .option(
654
699
  '--bootstrap-http-server-path <path>',
655
700
  'Sets a custom bootstrap HTTP server path for baremetal commissioning.',
@@ -661,6 +706,7 @@ program
661
706
  .option('--iso-url <url>', 'Uses a custom ISO URL for baremetal machine commissioning.')
662
707
  .option('--nfs-build', 'Builds an NFS root filesystem for a workflow id config architecture using QEMU emulation.')
663
708
  .option('--nfs-mount', 'Mounts the NFS root filesystem for a workflow id config architecture.')
709
+ .option('--nfs-reset', 'Resets the NFS server completely, closing all connections before reloading exports.')
664
710
  .option('--nfs-unmount', 'Unmounts the NFS root filesystem for a workflow id config architecture.')
665
711
  .option('--nfs-build-server', 'Builds the NFS server for a workflow id config architecture.')
666
712
  .option('--nfs-sh', 'Copies QEMU emulation root entrypoint shell command to the clipboard.')
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Kickstart configuration generator for Underpost Engine
3
+ * @module src/cli/kickstart.js
4
+ * @namespace UnderpostKickStart
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { loggerFactory } from '../server/logger.js';
11
+
12
+ const logger = loggerFactory(import.meta);
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ class UnderpostKickStart {
17
+ static API = {
18
+ /**
19
+ * @method kickstartHeader
20
+ * @description Generates the kickstart header section with template variables.
21
+ * @param {object} options
22
+ * @param {string} [options.lang='en_US.UTF-8']
23
+ * @param {string} [options.keyboard='us']
24
+ * @param {string} [options.timezone='America/New_York']
25
+ * @param {string} [options.rootPassword]
26
+ * @memberof UnderpostKickStart
27
+ * @returns {string}
28
+ */
29
+ kickstartHeader: ({ lang = 'en_US.UTF-8', keyboard = 'us', timezone = 'America/New_York', rootPassword = '' }) => {
30
+ return [
31
+ '# Rocky Linux 9 Kickstart - Ephemeral Anaconda Live Environment with SSHD',
32
+ 'cmdline',
33
+ 'eula --agreed',
34
+ `keyboard --vckeymap=${keyboard} --xlayouts='${keyboard}'`,
35
+ `lang ${lang}`,
36
+ 'network --bootproto=dhcp --device=link --activate --onboot=yes',
37
+ `timezone ${timezone} --utc`,
38
+ rootPassword ? `rootpw --plaintext ${rootPassword}` : 'rootpw --lock',
39
+ 'firstboot --disable',
40
+ 'skipx',
41
+ ].join('\n');
42
+ },
43
+
44
+ /**
45
+ * @method kickstartPreVariables
46
+ * @description Generates the variable assignments block for the %pre script.
47
+ * @param {object} options
48
+ * @param {string} [options.rootPassword]
49
+ * @param {string} [options.authorizedKeys]
50
+ * @param {string} [options.adminUsername]
51
+ * @memberof UnderpostKickStart
52
+ * @returns {string}
53
+ */
54
+ kickstartPreVariables: ({ rootPassword = '', authorizedKeys = '', adminUsername = '' }) => {
55
+ const sanitizedKeys = (authorizedKeys || '').trim();
56
+ return [
57
+ `ROOT_PASS='${rootPassword || ''}'`,
58
+ `AUTHORIZED_KEYS='${sanitizedKeys}'`,
59
+ `ADMIN_USER='${adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas'}'`,
60
+ ].join('\n');
61
+ },
62
+
63
+ /**
64
+ * @method kickstartFactory
65
+ * @description Generates a complete kickstart configuration by combining the header,
66
+ * variable assignments, and the rocky-kickstart.sh script body.
67
+ * @param {object} options
68
+ * @param {string} [options.lang='en_US.UTF-8']
69
+ * @param {string} [options.keyboard='us']
70
+ * @param {string} [options.timezone='America/New_York']
71
+ * @param {string} [options.rootPassword]
72
+ * @param {string} [options.authorizedKeys]
73
+ * @memberof UnderpostKickStart
74
+ * @returns {string}
75
+ */
76
+ kickstartFactory: ({
77
+ lang = 'en_US.UTF-8',
78
+ keyboard = 'us',
79
+ timezone = 'America/New_York',
80
+ rootPassword = process.env.MAAS_ADMIN_PASS,
81
+ authorizedKeys = '',
82
+ }) => {
83
+ const adminUsername = process.env.MAAS_ADMIN_USERNAME || 'maas';
84
+ const header = UnderpostKickStart.API.kickstartHeader({ lang, keyboard, timezone, rootPassword });
85
+ const variables = UnderpostKickStart.API.kickstartPreVariables({ rootPassword, authorizedKeys, adminUsername });
86
+
87
+ const scriptPath = path.resolve(__dirname, '../../scripts/rocky-kickstart.sh');
88
+ const scriptBody = fs.readFileSync(scriptPath, 'utf8');
89
+
90
+ return [
91
+ header,
92
+ '',
93
+ '%pre --interpreter=/bin/bash --log=/tmp/ks-pre.log --erroronfail',
94
+ '#!/bin/bash',
95
+ variables,
96
+ '',
97
+ scriptBody,
98
+ '%end',
99
+ ].join('\n');
100
+ },
101
+
102
+ /**
103
+ * @method kernelParamsFactory
104
+ * @description Appends kickstart-specific kernel parameters (inst.ks, inst.repo, inst.text, inst.sshd).
105
+ * @param {string} macAddress - The MAC address of the target machine.
106
+ * @param {array} cmd - The existing array of kernel parameters.
107
+ * @param {object} options - Options for generating kernel parameters.
108
+ * @param {string} options.ipDhcpServer - The IP address of the DHCP server.
109
+ * @param {number} [options.bootstrapHttpServerPort=8888] - Port for the bootstrap HTTP server.
110
+ * @param {string} options.hostname - The hostname of the target machine.
111
+ * @param {string} [options.architecture='amd64'] - The target architecture.
112
+ * @memberof UnderpostKickStart
113
+ * @returns {array}
114
+ */
115
+ kernelParamsFactory(
116
+ macAddress,
117
+ cmd = [],
118
+ options = { ipDhcpServer: '', bootstrapHttpServerPort: 8888, hostname: '', architecture: 'amd64' },
119
+ ) {
120
+ const { ipDhcpServer, bootstrapHttpServerPort, hostname, architecture } = options;
121
+ const repoArch = architecture && architecture.match('arm64') ? 'aarch64' : 'x86_64';
122
+ return cmd.concat([
123
+ `inst.ks=http://${ipDhcpServer}:${bootstrapHttpServerPort}/${hostname}/ks.cfg`,
124
+ `inst.repo=http://dl.rockylinux.org/pub/rocky/9/BaseOS/${repoArch}/os/`,
125
+ `inst.text`,
126
+ `inst.sshd`,
127
+ ]);
128
+ },
129
+
130
+ /**
131
+ * @method httpServerStaticFactory
132
+ * @description Writes kickstart ks.cfg file to the bootstrap HTTP server path.
133
+ * @param {object} params
134
+ * @param {string} params.bootstrapHttpServerPath
135
+ * @param {string} params.hostname
136
+ * @param {string} params.kickstartSrc
137
+ * @memberof UnderpostKickStart
138
+ * @returns {void}
139
+ */
140
+ httpServerStaticFactory({ bootstrapHttpServerPath, hostname, kickstartSrc }) {
141
+ if (!kickstartSrc) return;
142
+ const dest = `${bootstrapHttpServerPath}/${hostname}/ks.cfg`;
143
+ fs.writeFileSync(dest, kickstartSrc, 'utf8');
144
+ logger.info(`Kickstart file written to ${dest}`);
145
+ },
146
+ };
147
+ }
148
+
149
+ export default UnderpostKickStart;
@@ -88,10 +88,13 @@ class UnderpostRepository {
88
88
  * @param {boolean} [options.cached=false] - If true, commits only staged changes.
89
89
  * @param {number} [options.log=0] - If greater than 0, shows the last N commits with diffs.
90
90
  * @param {boolean} [options.lastMsg=0] - If greater than 0, copies or show the last last single n commit message to clipboard.
91
- * @param {string} [options.msg=''] - If provided, outputs this message instead of committing.
92
91
  * @param {string} [options.deployId=''] - An optional deploy ID to include in the commit message.
93
92
  * @param {string} [options.hashes=''] - If provided with diff option, shows the diff between two hashes.
94
93
  * @param {string} [options.extension=''] - If provided with diff option, filters the diff by this file extension.
94
+ * @param {boolean|string} [options.changelog=undefined] - If true, prints the changelog since the last CI integration commit (starting with 'ci(package-pwa-microservices-'). If a number string, prints the changelog of the last N commits split by version sections. Only considers commits starting with '[<tag>]'.
95
+ * @param {boolean} [options.changelogBuild=false] - If true, scrapes all git history and builds a full CHANGELOG.md. Commits containing 'New release v:' are used as version section titles. Only commits starting with '[<tag>]' are included as entries.
96
+ * @param {string} [options.changelogMinVersion=''] - If set, overrides the default minimum version limit (2.85.0) for --changelog-build.
97
+ * @param {boolean} [options.changelogNoHash=false] - If true, omits commit hashes from the changelog entries.
95
98
  * @memberof UnderpostRepository
96
99
  */
97
100
  commit(
@@ -108,13 +111,169 @@ class UnderpostRepository {
108
111
  cached: false,
109
112
  lastMsg: 0,
110
113
  log: 0,
111
- msg: '',
112
114
  deployId: '',
113
115
  hashes: '',
114
116
  extension: '',
117
+ changelog: undefined,
118
+ changelogBuild: false,
119
+ changelogMinVersion: '',
120
+ changelogNoHash: false,
115
121
  },
116
122
  ) {
117
123
  if (!repoPath) repoPath = '.';
124
+
125
+ if (options.changelog !== undefined || options.changelogBuild) {
126
+ const ciIntegrationPrefix = 'ci(package-pwa-microservices-';
127
+ const releaseMatch = 'New release v:';
128
+
129
+ // Helper: parse [<tag>] commits into grouped sections
130
+ const buildSectionChangelog = (commits) => {
131
+ const groups = {};
132
+ const tagOrder = [];
133
+ for (const commit of commits) {
134
+ if (!commit.message.startsWith('[')) continue;
135
+ const match = commit.message.match(/^\[([^\]]+)\]\s*(.*)/);
136
+ if (match) {
137
+ const tag = match[1].trim();
138
+ const context = match[2].trim().replaceAll('"', '');
139
+ if (!groups[tag]) {
140
+ groups[tag] = [];
141
+ tagOrder.push(tag);
142
+ }
143
+ groups[tag].push({ ...commit, context });
144
+ }
145
+ }
146
+ let out = '';
147
+ for (const tag of tagOrder) {
148
+ out += `### ${tag}\n\n`;
149
+ for (const entry of groups[tag]) {
150
+ out += `- ${entry.context}${options.changelogNoHash ? '' : ` (${commitUrl(entry.hash, entry.fullHash)})`}\n`;
151
+ }
152
+ out += '\n';
153
+ }
154
+ return out;
155
+ };
156
+
157
+ // Helper: fetch git log as structured array
158
+ const fetchHistory = (limit) => {
159
+ const limitArg = limit ? ` -n ${limit}` : '';
160
+ const rawLog = shellExec(`git log --pretty=format:"%h||%H||%s||%ci"${limitArg}`, {
161
+ stdout: true,
162
+ silent: true,
163
+ disableLog: true,
164
+ });
165
+ return rawLog
166
+ .split('\n')
167
+ .map((line) => {
168
+ const parts = line.split('||');
169
+ return {
170
+ hash: (parts[0] || '').trim(),
171
+ fullHash: (parts[1] || '').trim(),
172
+ message: parts[2] || '',
173
+ date: parts[3] || '',
174
+ };
175
+ })
176
+ .filter((c) => c.hash);
177
+ };
178
+
179
+ const githubUser = process.env.GITHUB_USERNAME || 'underpostnet';
180
+ const commitUrl = (shortHash, fullHash) =>
181
+ `[${shortHash}](https://github.com/${githubUser}/engine/commit/${fullHash})`;
182
+
183
+ // Helper: extract version from commit message containing 'New release v:'
184
+ const extractVersion = (message) => {
185
+ const idx = message.indexOf(releaseMatch);
186
+ if (idx === -1) return null;
187
+ return message.substring(idx + releaseMatch.length).trim();
188
+ };
189
+
190
+ // Helper: split commits array into version sections by 'New release v:' boundary
191
+ const buildVersionSections = (commits) => {
192
+ const sections = [];
193
+ let currentSection = { title: null, date: new Date().toISOString().split('T')[0], commits: [] };
194
+
195
+ for (const commit of commits) {
196
+ const version = extractVersion(commit.message);
197
+ if (version) {
198
+ // Push accumulated commits as a section
199
+ sections.push(currentSection);
200
+ // Start new version section; commits below this one belong to it
201
+ const commitDate = commit.date ? commit.date.split(' ')[0] : '';
202
+ currentSection = { title: `${releaseMatch}${version}`, date: commitDate, hash: commit.hash, commits: [] };
203
+ } else {
204
+ currentSection.commits.push(commit);
205
+ }
206
+ }
207
+ // Push the last (oldest) section
208
+ if (currentSection.commits.length > 0) sections.push(currentSection);
209
+ return sections;
210
+ };
211
+
212
+ // Helper: render sections array into changelog markdown string
213
+ const renderSections = (sections) => {
214
+ let changelog = '';
215
+ for (const section of sections) {
216
+ const sectionBody = buildSectionChangelog(section.commits);
217
+ if (!sectionBody) continue;
218
+ if (section.title) {
219
+ changelog += `## ${section.title}${options.changelogNoHash ? '' : ` (${section.date})`}\n\n`;
220
+ } else {
221
+ changelog += `## ${section.date}\n\n`;
222
+ }
223
+ changelog += sectionBody;
224
+ }
225
+ return changelog;
226
+ };
227
+
228
+ const changelogMinVersion = options.changelogMinVersion || '2.97.1';
229
+
230
+ if (options.changelogBuild) {
231
+ // --changelog-build: scrape ALL history, split by 'New release v:' commits as version sections
232
+ const allCommits = fetchHistory();
233
+ const sections = buildVersionSections(allCommits);
234
+
235
+ // Filter sections: stop at changelogMinVersion boundary
236
+ const limitedSections = [];
237
+ for (const section of sections) {
238
+ limitedSections.push(section);
239
+ if (section.title) {
240
+ const versionStr = section.title.replace(releaseMatch, '').trim();
241
+ if (versionStr === changelogMinVersion) break;
242
+ }
243
+ }
244
+
245
+ let changelog = renderSections(limitedSections);
246
+
247
+ if (!changelog) {
248
+ changelog = `No changelog entries found.\n`;
249
+ }
250
+
251
+ const changelogPath = `${repoPath === '.' ? '.' : repoPath}/CHANGELOG.md`;
252
+ fs.writeFileSync(changelogPath, `# Changelog\n\n${changelog}`);
253
+ logger.info('CHANGELOG.md built at', changelogPath);
254
+ } else {
255
+ // --changelog [latest-n]: print changelog of last N commits or since last release
256
+ const hasExplicitCount =
257
+ options.changelog !== undefined && options.changelog !== true && !isNaN(parseInt(options.changelog));
258
+ const scanLimit = hasExplicitCount ? parseInt(options.changelog) : 500;
259
+ const allCommits = fetchHistory(scanLimit);
260
+
261
+ let commits;
262
+ if (!hasExplicitCount) {
263
+ // No explicit count: find commits up to the last CI integration boundary
264
+ const ciIndex = allCommits.findIndex((c) => c.message.startsWith(ciIntegrationPrefix));
265
+ commits = ciIndex >= 0 ? allCommits.slice(0, ciIndex) : allCommits;
266
+ } else {
267
+ commits = allCommits;
268
+ }
269
+
270
+ const sections = buildVersionSections(commits);
271
+ let changelog = renderSections(sections);
272
+ console.log(changelog || `No changelog entries found.\n`);
273
+ }
274
+
275
+ return;
276
+ }
118
277
  if (options.diff && options.hashes) {
119
278
  const hashes = options.hashes.split(',');
120
279
  const cmd = `git --no-pager diff ${hashes[0]} ${hashes[1] ? hashes[1] : 'HEAD'}${options.extension ? ` -- '*.${options.extension}'` : ''}`;
@@ -123,16 +282,6 @@ class UnderpostRepository {
123
282
  } else console.log(cmd);
124
283
  return;
125
284
  }
126
- if (options.msg) {
127
- options.msg = options.msg.replaceAll('"', '').replaceAll(`'`, '').replaceAll('`', '');
128
- let key = Object.keys(commitData).find((k) => k && options.msg.toLocaleLowerCase().slice(0, 16).match(k));
129
- if (!key) key = Object.keys(commitData).find((k) => k && options.msg.toLocaleLowerCase().match(k));
130
- if (!key || key === undefined) key = 'chore';
131
- shellExec(
132
- `underpost cmt ${repoPath} ${key} ${options.deployId ? options.deployId : `''`} '${options.msg.replaceAll(`${key}(${key}`, '')}'`,
133
- );
134
- return;
135
- }
136
285
  if (options.lastMsg) {
137
286
  if (options.copy) {
138
287
  pbcopy(Underpost.repo.getLastCommitMsg(options.lastMsg - 1));
@@ -197,7 +346,11 @@ class UnderpostRepository {
197
346
  * @memberof UnderpostRepository
198
347
  */
199
348
  getLastCommitMsg(skip = 0) {
200
- return shellExec(`git --no-pager log -1 --skip=${skip} --pretty=%B`, { stdout: true });
349
+ return shellExec(`git --no-pager log -1 --skip=${skip} --pretty=%B`, {
350
+ stdout: true,
351
+ silent: true,
352
+ disableLog: true,
353
+ });
201
354
  },
202
355
 
203
356
  /**