fix(cli): improve changelog release handling and TypeScript compatibility

This commit is contained in:
2026-06-03 09:53:36 +00:00
parent 5cba50b56e
commit 0b7cd9c635
20 changed files with 1068 additions and 3206 deletions
+8
View File
@@ -3,6 +3,14 @@
## Pending ## Pending
### Fixes
- improve changelog release handling and TypeScript compatibility (cli)
- Avoid mutating changelog files when only reading pending entries
- Remove the consumed Pending section when moving entries to a versioned release
- Use async project metadata creation and safer unknown error handling for updated dependencies
- Add changelog helper tests for missing, consumed, and recreated Pending sections
## 2026-05-24 - 2.19.4 ## 2026-05-24 - 2.19.4
### Fixes ### Fixes
+7 -7
View File
@@ -57,17 +57,17 @@
}, },
"homepage": "https://gitlab.com/gitzone/private/gitzone#readme", "homepage": "https://gitlab.com/gitzone/private/gitzone#readme",
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.3.0", "@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.3.2", "@git.zone/tstest": "^3.6.6",
"@types/node": "^25.4.0" "@types/node": "^25.9.1"
}, },
"dependencies": { "dependencies": {
"@git.zone/tsdoc": "^2.0.6", "@git.zone/tsdoc": "^2.0.6",
"@git.zone/tspublish": "^1.11.2", "@git.zone/tspublish": "^1.11.2",
"@push.rocks/commitinfo": "^1.0.12", "@push.rocks/commitinfo": "^1.0.13",
"@push.rocks/early": "^4.0.4", "@push.rocks/early": "^4.0.4",
"@push.rocks/projectinfo": "^5.0.2", "@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/smartcli": "^4.0.20", "@push.rocks/smartcli": "^4.0.20",
"@push.rocks/smartconfig": "^6.0.1", "@push.rocks/smartconfig": "^6.0.1",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
@@ -86,10 +86,10 @@
"@push.rocks/smartopen": "^2.0.0", "@push.rocks/smartopen": "^2.0.0",
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartscaf": "^4.0.21", "@push.rocks/smartscaf": "^4.0.22",
"@push.rocks/smartshell": "^3.5.0", "@push.rocks/smartshell": "^3.5.0",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartupdate": "^2.0.6", "@push.rocks/smartupdate": "^2.0.7",
"prettier": "^3.8.1" "prettier": "^3.8.1"
}, },
"files": [ "files": [
+912 -3166
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import { rm, writeFile, readFile, mkdtemp } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
appendPendingChangelogEntry,
movePendingToVersion,
readPendingChangelog,
} from './ts/helpers.changelog.js';
const withChangelogFile = async (
initialContent: string,
testFunction: (filePath: string) => Promise<void>,
) => {
const testDir = await mkdtemp(join(tmpdir(), 'gitzone-changelog-'));
const changelogPath = join(testDir, 'changelog.md');
try {
await writeFile(changelogPath, initialContent);
await testFunction(changelogPath);
} finally {
await rm(testDir, { recursive: true, force: true });
}
};
tap.test('readPendingChangelog should not create a missing Pending section', async () => {
await withChangelogFile('# Changelog\n\n## 2026-01-01 - 1.0.0\n\n### Fixes\n\n- existing\n', async (filePath) => {
const pending = await readPendingChangelog(filePath, 'Pending');
const changelogContent = await readFile(filePath, 'utf8');
expect(pending.isEmpty).toBeTrue();
expect(pending.block).toEqual('');
expect(changelogContent).not.toInclude('## Pending');
});
});
tap.test('movePendingToVersion should fail when Pending is missing', async () => {
await withChangelogFile('# Changelog\n\n## 2026-01-01 - 1.0.0\n\n### Fixes\n\n- existing\n', async (filePath) => {
let errorMessage = '';
try {
await movePendingToVersion(filePath, 'Pending', '## {{date}} - {{version}}', '1.0.1', '2026-01-02');
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toEqual('No pending changelog entries. Nothing to release.');
const changelogContent = await readFile(filePath, 'utf8');
expect(changelogContent).not.toInclude('## Pending');
});
});
tap.test('movePendingToVersion should remove the consumed Pending section', async () => {
await withChangelogFile('# Changelog\n\n## Pending\n\n### Fixes\n\n- pending fix\n\n## 2026-01-01 - 1.0.0\n\n### Fixes\n\n- existing\n', async (filePath) => {
await movePendingToVersion(filePath, 'Pending', '## {{date}} - {{version}}', '1.0.1', '2026-01-02');
const changelogContent = await readFile(filePath, 'utf8');
expect(changelogContent).not.toInclude('## Pending');
expect(changelogContent).toInclude('## 2026-01-02 - 1.0.1\n\n### Fixes\n\n- pending fix');
expect(changelogContent).toInclude('## 2026-01-01 - 1.0.0\n\n### Fixes\n\n- existing');
});
});
tap.test('appendPendingChangelogEntry should recreate Pending when needed', async () => {
await withChangelogFile('# Changelog\n\n## 2026-01-01 - 1.0.0\n\n### Fixes\n\n- existing\n', async (filePath) => {
await appendPendingChangelogEntry(filePath, 'Pending', {
type: 'fix',
scope: 'changelog',
message: 'record pending changes',
});
const changelogContent = await readFile(filePath, 'utf8');
expect(changelogContent).toInclude('## Pending\n\n### Fixes\n\n- record pending changes (changelog)');
expect(changelogContent).toInclude('## 2026-01-01 - 1.0.0');
});
});
export default tap.start();
+1 -1
View File
@@ -35,7 +35,7 @@ export class GitzoneConfig {
return gitzoneConfig; return gitzoneConfig;
} }
public data: IGitzoneConfigData; public data!: IGitzoneConfigData;
public async readConfigFromCwd() { public async readConfigFromCwd() {
const smartconfigInstance = new plugins.smartconfig.Smartconfig(paths.cwd); const smartconfigInstance = new plugins.smartconfig.Smartconfig(paths.cwd);
+1 -1
View File
@@ -105,7 +105,7 @@ export let run = async () => {
const rawCliMode = await getRawCliMode(); const rawCliMode = await getRawCliMode();
// get packageInfo // get packageInfo
const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir); const projectInfo = await plugins.projectinfo.ProjectInfo.create(paths.packageDir);
const projectInfoVersion = (projectInfo.npm as any)?.version; const projectInfoVersion = (projectInfo.npm as any)?.version;
const packageVersion = const packageVersion =
typeof projectInfoVersion === "string" && projectInfoVersion.length > 0 typeof projectInfoVersion === "string" && projectInfoVersion.length > 0
+22 -8
View File
@@ -19,6 +19,12 @@ export interface IPendingChangelog {
isEmpty: boolean; isEmpty: boolean;
} }
interface IChangelogSection {
start: number;
bodyStart: number;
end: number;
}
const bucketForCommitType = (commitType: string): TChangelogBucket => { const bucketForCommitType = (commitType: string): TChangelogBucket => {
switch (commitType) { switch (commitType) {
case "BREAKING CHANGE": case "BREAKING CHANGE":
@@ -48,7 +54,7 @@ const writeChangelog = async (filePath: string, content: string): Promise<void>
const findPendingSection = ( const findPendingSection = (
content: string, content: string,
sectionName: string, sectionName: string,
): { start: number; bodyStart: number; end: number } | null => { ): IChangelogSection | null => {
const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
const match = headingRegex.exec(content); const match = headingRegex.exec(content);
if (!match || match.index === undefined) { if (!match || match.index === undefined) {
@@ -123,9 +129,11 @@ export const readPendingChangelog = async (
filePath: string, filePath: string,
sectionName = "Pending", sectionName = "Pending",
): Promise<IPendingChangelog> => { ): Promise<IPendingChangelog> => {
const content = await ensurePendingSection(filePath, sectionName); const content = await readChangelog(filePath);
const pendingSection = findPendingSection(content, sectionName)!; const pendingSection = findPendingSection(content, sectionName);
const block = content.slice(pendingSection.bodyStart, pendingSection.end).trim(); const block = pendingSection
? content.slice(pendingSection.bodyStart, pendingSection.end).trim()
: "";
return { return {
block, block,
isEmpty: block.length === 0, isEmpty: block.length === 0,
@@ -149,8 +157,11 @@ export const movePendingToVersion = async (
version: string, version: string,
dateString: string, dateString: string,
): Promise<void> => { ): Promise<void> => {
let content = await ensurePendingSection(filePath, sectionName); let content = await readChangelog(filePath);
const pendingSection = findPendingSection(content, sectionName)!; const pendingSection = findPendingSection(content, sectionName);
if (!pendingSection) {
throw new Error("No pending changelog entries. Nothing to release.");
}
const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim(); const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
if (!pendingBlock) { if (!pendingBlock) {
throw new Error("No pending changelog entries. Nothing to release."); throw new Error("No pending changelog entries. Nothing to release.");
@@ -159,7 +170,10 @@ export const movePendingToVersion = async (
const renderedHeading = versionHeading const renderedHeading = versionHeading
.replaceAll("{{version}}", version) .replaceAll("{{version}}", version)
.replaceAll("{{date}}", dateString); .replaceAll("{{date}}", dateString);
const nextContent = content.slice(pendingSection.end).replace(/^\n+/, ""); const beforePending = content.slice(0, pendingSection.start).trimEnd();
content = `${content.slice(0, pendingSection.bodyStart)}\n\n${renderedHeading}\n\n${pendingBlock}\n\n${nextContent}`; const afterPending = content.slice(pendingSection.end).replace(/^\n+/, "").trimEnd();
content = [beforePending, renderedHeading, pendingBlock, afterPending]
.filter((block) => block.length > 0)
.join("\n\n");
await writeChangelog(filePath, content); await writeChangelog(filePath, content);
}; };
+4 -2
View File
@@ -27,7 +27,8 @@ export async function detectCurrentBranch(): Promise<string> {
logger.log('info', `Detected current branch: ${branchName}`); logger.log('info', `Detected current branch: ${branchName}`);
return branchName; return branchName;
} catch (error) { } catch (error) {
logger.log('warn', `Failed to detect branch: ${error.message}, falling back to "master"`); const errorMessage = error instanceof Error ? error.message : String(error);
logger.log('warn', `Failed to detect branch: ${errorMessage}, falling back to "master"`);
return 'master'; return 'master';
} }
} }
@@ -225,6 +226,7 @@ export async function bumpProjectVersion(
return newVersion; return newVersion;
} catch (error) { } catch (error) {
throw new Error(`Failed to bump project version: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to bump project version: ${errorMessage}`);
} }
} }
+2 -1
View File
@@ -42,9 +42,10 @@ export class DiffReporter {
change.content, change.content,
); );
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log( logger.log(
'error', 'error',
`Failed to generate diff for ${change.path}: ${error.message}`, `Failed to generate diff for ${change.path}: ${errorMessage}`,
); );
return null; return null;
} }
+2 -1
View File
@@ -93,7 +93,8 @@ export class CopyFormatter extends BaseFormatter {
} }
} }
} catch (error) { } catch (error) {
logVerbose(`Failed to process pattern ${pattern.from}: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Failed to process pattern ${pattern.from}: ${errorMessage}`);
} }
} }
@@ -94,7 +94,8 @@ export class PackageJsonFormatter extends BaseFormatter {
packageJson.pnpm = packageJson.pnpm || {}; packageJson.pnpm = packageJson.pnpm || {};
packageJson.pnpm.overrides = overrides; packageJson.pnpm.overrides = overrides;
} catch (error) { } catch (error) {
logVerbose(`Could not read overrides.json: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Could not read overrides.json: ${errorMessage}`);
} }
const newContent = JSON.stringify(packageJson, null, 2); const newContent = JSON.stringify(packageJson, null, 2);
@@ -117,7 +117,8 @@ export class TemplatesFormatter extends BaseFormatter {
try { try {
renderedFiles = await this.renderTemplate(templateName); renderedFiles = await this.renderTemplate(templateName);
} catch (error) { } catch (error) {
logVerbose(`Failed to render template ${templateName}: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Failed to render template ${templateName}: ${errorMessage}`);
return changes; return changes;
} }
@@ -46,7 +46,8 @@ export class TsconfigFormatter extends BaseFormatter {
]; ];
} }
} catch (error) { } catch (error) {
logVerbose(`Could not get tspublish modules: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Could not get tspublish modules: ${errorMessage}`);
} }
tsconfigObject.compilerOptions.paths = { ...existingPaths, ...tspublishPaths }; tsconfigObject.compilerOptions.paths = { ...existingPaths, ...tspublishPaths };
+1 -1
View File
@@ -26,7 +26,7 @@ export class Meta {
/** /**
* the meta repo data * the meta repo data
*/ */
public metaRepoData: interfaces.IMetaRepoData; public metaRepoData!: interfaces.IMetaRepoData;
public smartshellInstance = new plugins.smartshell.Smartshell({ public smartshellInstance = new plugins.smartshell.Smartshell({
executor: 'bash', executor: 'bash',
}); });
+2 -2
View File
@@ -1,8 +1,8 @@
import * as plugins from './mod.plugins.js'; import * as plugins from './mod.plugins.js';
import * as paths from '../paths.js'; import * as paths from '../paths.js';
export let run = (argvArg) => { export let run = async (argvArg) => {
let projectInfo = new plugins.projectinfo.ProjectInfo(paths.cwd); let projectInfo = await plugins.projectinfo.ProjectInfo.create(paths.cwd);
if (argvArg._[1] === 'ci') { if (argvArg._[1] === 'ci') {
plugins.smartopen.openUrl( plugins.smartopen.openUrl(
`https://gitlab.com/${projectInfo.git.gituser}/${projectInfo.git.gitrepo}/settings/ci_cd`, `https://gitlab.com/${projectInfo.git.gituser}/${projectInfo.git.gitrepo}/settings/ci_cd`,
+5 -3
View File
@@ -148,7 +148,8 @@ export class DockerContainer {
const result = await this.smartshell.exec(command); const result = await this.smartshell.exec(command);
return result.exitCode === 0; return result.exitCode === 0;
} catch (error) { } catch (error) {
logger.log('error', `Failed to run container: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logger.log('error', `Failed to run container: ${errorMessage}`);
return false; return false;
} }
} }
@@ -177,7 +178,8 @@ export class DockerContainer {
const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`); const result = await this.smartshell.exec(`docker logs ${tailFlag} ${containerName}`);
return result.stdout; return result.stdout;
} catch (error) { } catch (error) {
return `Error getting logs: ${error.message}`; const errorMessage = error instanceof Error ? error.message : String(error);
return `Error getting logs: ${errorMessage}`;
} }
} }
@@ -258,4 +260,4 @@ export class DockerContainer {
return null; return null;
} }
} }
} }
@@ -28,7 +28,7 @@ export interface IServiceConfig {
export class ServiceConfiguration { export class ServiceConfiguration {
private configPath: string; private configPath: string;
private config: IServiceConfig; private config!: IServiceConfig;
private docker: DockerContainer; private docker: DockerContainer;
constructor() { constructor() {
@@ -515,4 +515,4 @@ export class ServiceConfiguration {
logger.log('info', ` 📍 S3 Console: ${s3ConsolePort}`); logger.log('info', ` 📍 S3 Console: ${s3ConsolePort}`);
logger.log('info', ` 📍 Elasticsearch: ${esPort}`); logger.log('info', ` 📍 Elasticsearch: ${esPort}`);
} }
} }
+9 -6
View File
@@ -61,13 +61,15 @@ export class ServiceManager {
default: ['mongodb', 'minio', 'elasticsearch'] default: ['mongodb', 'minio', 'elasticsearch']
}); });
this.enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch']; const enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch'];
this.enabledServices = enabledServices;
// Save to .smartconfig.json // Save to .smartconfig.json
await this.saveServiceConfiguration(this.enabledServices); await this.saveServiceConfiguration(enabledServices);
} else { } else {
this.enabledServices = gitzoneConfig.services; const enabledServices = gitzoneConfig.services as string[];
logger.log('info', `🔧 Enabled services: ${this.enabledServices.join(', ')}`); this.enabledServices = enabledServices;
logger.log('info', `🔧 Enabled services: ${enabledServices.join(', ')}`);
} }
} }
@@ -902,10 +904,11 @@ export class ServiceManager {
default: currentServices default: currentServices
}); });
this.enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch']; const enabledServices = response.value || ['mongodb', 'minio', 'elasticsearch'];
this.enabledServices = enabledServices;
// Save to .smartconfig.json // Save to .smartconfig.json
await this.saveServiceConfiguration(this.enabledServices); await this.saveServiceConfiguration(enabledServices);
logger.log('ok', '✅ Service configuration updated'); logger.log('ok', '✅ Service configuration updated');
} }
+5 -1
View File
@@ -15,7 +15,7 @@ export const isTemplate = async (templateNameArg: string) => {
}; };
export const getTemplate = async (templateNameArg: string) => { export const getTemplate = async (templateNameArg: string) => {
if (isTemplate(templateNameArg)) { if (await isTemplate(templateNameArg)) {
const localScafTemplate = new plugins.smartscaf.ScafTemplate( const localScafTemplate = new plugins.smartscaf.ScafTemplate(
getTemplatePath(templateNameArg), getTemplatePath(templateNameArg),
); );
@@ -50,6 +50,10 @@ export const run = async (argvArg: any) => {
} }
const localScafTemplate = await getTemplate(chosenTemplate); const localScafTemplate = await getTemplate(chosenTemplate);
if (!localScafTemplate) {
logger.log('error', `Template ${chosenTemplate} not available`);
return;
}
await localScafTemplate.askCliForMissingVariables(); await localScafTemplate.askCliForMissingVariables();
await localScafTemplate.writeToDisk(paths.cwd); await localScafTemplate.writeToDisk(paths.cwd);
}; };
-1
View File
@@ -5,7 +5,6 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {} "paths": {}
}, },
"exclude": ["dist_*/**/*.d.ts"] "exclude": ["dist_*/**/*.d.ts"]