Compare commits

...

4 Commits

Author SHA1 Message Date
4d7eaa238f v2.11.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 17:24:17 +00:00
601e0d1063 feat(mod_format): feat(mod_format): use unified diff formatter with filenames and context in BaseFormatter.displayDiff 2025-12-15 17:24:17 +00:00
4bb1a2f8c7 v2.10.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 17:07:30 +00:00
b506bf8785 feat(mod_format): Refactor formatting modules to new BaseFormatter and implement concrete analyze/apply logic 2025-12-15 17:07:30 +00:00
14 changed files with 984 additions and 90 deletions

View File

@@ -1,5 +1,27 @@
# Changelog # Changelog
## 2025-12-15 - 2.11.0 - feat(mod_format)
feat(mod_format): use unified diff formatter with filenames and context in BaseFormatter.displayDiff
- Replaced plugins.smartdiff.formatLineDiffForConsole(...) with plugins.smartdiff.formatUnifiedDiffForConsole(...) when both before and after are present.
- Passes originalFileName and revisedFileName as diff.path and sets context to 3 to show a unified diff with surrounding lines.
- Improves console output for multi-line diffs by using unified diff format and including file names.
## 2025-12-15 - 2.10.0 - feat(mod_format)
Refactor formatting modules to new BaseFormatter and implement concrete analyze/apply logic
- Replace generic LegacyFormatter with explicit BaseFormatter implementations for formatters: copy, gitignore, license, npmextra, packagejson, prettier, readme, templates, tsconfig (legacy.formatter.ts removed).
- Copy formatter: implemented pattern-based copying, template-preserve path handling, content equality check and planned change generation/apply.
- Gitignore formatter: canonical template with preservation of custom section when updating/creating .gitignore.
- License formatter: added runtime license check against node_modules for incompatible licenses and reporting (no file changes).
- Npmextra formatter: automatic migrations for old namespace keys to package-scoped keys and migration of npmAccessLevel -> @git.zone/cli.release.accessLevel; reformatting and interactive prompting to fill missing repo metadata.
- Package.json formatter: enforces repository/metadata, sets module type/private/license/scripts/files, ensures/updates dependencies (including fetching latest via registry), and applies pnpm overrides from assets.
- Prettier formatter: added check() to compute diffs by running Prettier and returning per-file before/after diffs.
- Readme formatter: create readme.md and readme.hints.md when missing with default content.
- Templates formatter: apply templates from templatesDir based on project type (vscode, CI, docker, website/service/wcc), compare template vs destination and create/modify files as needed; ensures dest directories exist.
- Tsconfig formatter: sets compilerOptions.baseUrl and computes path mappings from @git.zone/tspublish modules.
- General: extensive use of plugins (smartfs, path, smartnpm, smartinteract, smartobject, smartlegal), improved logging and verbose messages.
## 2025-12-15 - 2.9.0 - feat(format) ## 2025-12-15 - 2.9.0 - feat(format)
Add --diff option to format command to display file diffs; pass flag through CLI and show formatter diffs. Bump @git.zone/tsdoc to ^1.11.0. Add --diff option to format command to display file diffs; pass flag through CLI and show formatter diffs. Bump @git.zone/tsdoc to ^1.11.0.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@git.zone/cli", "name": "@git.zone/cli",
"private": false, "private": false,
"version": "2.9.2", "version": "2.11.0",
"description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.", "description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.",
"main": "dist_ts/index.ts", "main": "dist_ts/index.ts",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/cli', name: '@git.zone/cli',
version: '2.9.0', version: '2.11.0',
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.' description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
} }

View File

@@ -143,7 +143,11 @@ export abstract class BaseFormatter {
displayDiff(diff: ICheckResult['diffs'][0]): void { displayDiff(diff: ICheckResult['diffs'][0]): void {
console.log(`\n--- ${diff.path}`); console.log(`\n--- ${diff.path}`);
if (diff.before && diff.after) { if (diff.before && diff.after) {
console.log(plugins.smartdiff.formatLineDiffForConsole(diff.before, diff.after)); console.log(plugins.smartdiff.formatUnifiedDiffForConsole(diff.before, diff.after, {
originalFileName: diff.path,
revisedFileName: diff.path,
context: 3,
}));
} else if (diff.after && !diff.before) { } else if (diff.after && !diff.before) {
console.log(' (new file)'); console.log(' (new file)');
// Show first few lines of new content // Show first few lines of new content

View File

@@ -1,8 +1,117 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatCopy from '../format.copy.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import { logger, logVerbose } from '../../gitzone.logging.js';
export class CopyFormatter extends LegacyFormatter { interface ICopyPattern {
constructor(context: any, project: any) { from: string;
super(context, project, 'copy', formatCopy); to: string;
preservePath?: boolean;
}
export class CopyFormatter extends BaseFormatter {
get name(): string {
return 'copy';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
// Get copy configuration from npmextra.json
const npmextraConfig = new plugins.npmextra.Npmextra();
const copyConfig = npmextraConfig.dataFor<{ patterns: ICopyPattern[] }>(
'gitzone.format.copy',
{ patterns: [] },
);
if (!copyConfig.patterns || copyConfig.patterns.length === 0) {
logVerbose('No copy patterns configured in npmextra.json');
return changes;
}
for (const pattern of copyConfig.patterns) {
if (!pattern.from || !pattern.to) {
logVerbose('Invalid copy pattern - missing "from" or "to" field');
continue;
}
try {
// Handle glob patterns
const entries = await plugins.smartfs
.directory('.')
.recursive()
.filter(pattern.from)
.list();
const files = entries.map((entry) => entry.path);
for (const file of files) {
const sourcePath = file;
let destPath = pattern.to;
// If destination is a directory, preserve filename
if (pattern.to.endsWith('/')) {
const filename = plugins.path.basename(file);
destPath = plugins.path.join(pattern.to, filename);
}
// Handle template variables in destination path
if (pattern.preservePath) {
const relativePath = plugins.path.relative(
plugins.path.dirname(pattern.from.replace(/\*/g, '')),
file,
);
destPath = plugins.path.join(pattern.to, relativePath);
}
// Read source content
const content = (await plugins.smartfs
.file(sourcePath)
.encoding('utf8')
.read()) as string;
// Check if destination exists and has same content
let needsCopy = true;
const destExists = await plugins.smartfs.file(destPath).exists();
if (destExists) {
const existingContent = (await plugins.smartfs
.file(destPath)
.encoding('utf8')
.read()) as string;
if (existingContent === content) {
needsCopy = false;
}
}
if (needsCopy) {
changes.push({
type: destExists ? 'modify' : 'create',
path: destPath,
module: this.name,
description: `Copy from ${sourcePath}`,
content: content,
});
}
}
} catch (error) {
logVerbose(`Failed to process pattern ${pattern.from}: ${error.message}`);
}
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (!change.content) return;
// Ensure destination directory exists
const destDir = plugins.path.dirname(change.path);
await plugins.smartfs.directory(destDir).recursive().create();
if (change.type === 'create') {
await this.createFile(change.path, change.content);
} else {
await this.modifyFile(change.path, change.content);
}
logger.log('info', `Copied to ${change.path}`);
} }
} }

View File

@@ -1,8 +1,111 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatGitignore from '../format.gitignore.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import { logger } from '../../gitzone.logging.js';
export class GitignoreFormatter extends LegacyFormatter { // Standard gitignore template content (without front-matter)
constructor(context: any, project: any) { const GITIGNORE_TEMPLATE = `.nogit/
super(context, project, 'gitignore', formatGitignore);
# artifacts
coverage/
public/
# installs
node_modules/
# caches
.yarn/
.cache/
.rpt2_cache
# builds
dist/
dist_*/
# AI
.claude/
.serena/
#------# custom`;
export class GitignoreFormatter extends BaseFormatter {
get name(): string {
return 'gitignore';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const gitignorePath = '.gitignore';
// Check if file exists and extract custom content
let customContent = '';
const exists = await plugins.smartfs.file(gitignorePath).exists();
if (exists) {
const existingContent = (await plugins.smartfs
.file(gitignorePath)
.encoding('utf8')
.read()) as string;
// Extract custom section content
const customMarkers = ['#------# custom', '# custom'];
for (const marker of customMarkers) {
const splitResult = existingContent.split(marker);
if (splitResult.length > 1) {
customContent = splitResult[1].trim();
break;
}
}
}
// Compute new content
let newContent = GITIGNORE_TEMPLATE;
if (customContent) {
newContent = GITIGNORE_TEMPLATE + '\n' + customContent + '\n';
} else {
newContent = GITIGNORE_TEMPLATE + '\n';
}
// Read current content to compare
let currentContent = '';
if (exists) {
currentContent = (await plugins.smartfs
.file(gitignorePath)
.encoding('utf8')
.read()) as string;
}
// Determine change type
if (!exists) {
changes.push({
type: 'create',
path: gitignorePath,
module: this.name,
description: 'Create .gitignore',
content: newContent,
});
} else if (newContent !== currentContent) {
changes.push({
type: 'modify',
path: gitignorePath,
module: this.name,
description: 'Update .gitignore (preserving custom section)',
content: newContent,
});
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (!change.content) return;
if (change.type === 'create') {
await this.createFile(change.path, change.content);
logger.log('info', 'Created .gitignore');
} else if (change.type === 'modify') {
await this.modifyFile(change.path, change.content);
logger.log('info', 'Updated .gitignore (preserved custom section)');
}
} }
} }

View File

@@ -1,43 +0,0 @@
import { BaseFormatter } from '../classes.baseformatter.js';
import type { IPlannedChange } from '../interfaces.format.js';
import { Project } from '../../classes.project.js';
import * as plugins from '../mod.plugins.js';
// This is a wrapper for existing format modules
export class LegacyFormatter extends BaseFormatter {
private moduleName: string;
private formatModule: any;
constructor(
context: any,
project: Project,
moduleName: string,
formatModule: any,
) {
super(context, project);
this.moduleName = moduleName;
this.formatModule = formatModule;
}
get name(): string {
return this.moduleName;
}
async analyze(): Promise<IPlannedChange[]> {
// For legacy modules, we can't easily predict changes
// So we'll return a generic change that indicates the module will run
return [
{
type: 'modify',
path: '<various files>',
module: this.name,
description: `Run ${this.name} formatter`,
},
];
}
async applyChange(change: IPlannedChange): Promise<void> {
// Run the legacy format module
await this.formatModule.run(this.project);
}
}

View File

@@ -1,8 +1,62 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatLicense from '../format.license.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger } from '../../gitzone.logging.js';
export class LicenseFormatter extends LegacyFormatter { const INCOMPATIBLE_LICENSES: string[] = ['AGPL', 'GPL', 'SSPL'];
constructor(context: any, project: any) {
super(context, project, 'license', formatLicense); export class LicenseFormatter extends BaseFormatter {
get name(): string {
return 'license';
}
async analyze(): Promise<IPlannedChange[]> {
// License formatter only checks for incompatible licenses
// It does not modify any files, so return empty array
// The actual check happens in execute() for reporting purposes
return [];
}
async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name);
try {
// Check if node_modules exists
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules');
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
logger.log('warn', 'No node_modules found. Skipping license check');
return;
}
// Run license check
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
const licenseCheckResult = await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
if (licenseCheckResult.failingModules.length === 0) {
logger.log('info', 'License check passed - no incompatible licenses found');
} else {
logger.log('error', 'License check failed - incompatible licenses found:');
for (const failedModule of licenseCheckResult.failingModules) {
console.log(
` ${failedModule.name} has license ${failedModule.license}`,
);
}
}
} finally {
this.stats.endModule(this.name, startTime);
}
}
async applyChange(change: IPlannedChange): Promise<void> {
// No file changes for license formatter
} }
} }

View File

@@ -1,8 +1,165 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatNpmextra from '../format.npmextra.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import { logger, logVerbose } from '../../gitzone.logging.js';
export class NpmextraFormatter extends LegacyFormatter { /**
constructor(context: any, project: any) { * Migrates npmextra.json from old namespace keys to new package-scoped keys
super(context, project, 'npmextra', formatNpmextra); */
const migrateNamespaceKeys = (npmextraJson: any): boolean => {
let migrated = false;
const migrations = [
{ oldKey: 'gitzone', newKey: '@git.zone/cli' },
{ oldKey: 'tsdoc', newKey: '@git.zone/tsdoc' },
{ oldKey: 'npmdocker', newKey: '@git.zone/tsdocker' },
{ oldKey: 'npmci', newKey: '@ship.zone/szci' },
{ oldKey: 'szci', newKey: '@ship.zone/szci' },
];
for (const { oldKey, newKey } of migrations) {
if (npmextraJson[oldKey] && !npmextraJson[newKey]) {
npmextraJson[newKey] = npmextraJson[oldKey];
delete npmextraJson[oldKey];
migrated = true;
}
}
return migrated;
};
/**
* Migrates npmAccessLevel from @ship.zone/szci to @git.zone/cli.release.accessLevel
*/
const migrateAccessLevel = (npmextraJson: any): boolean => {
const szciConfig = npmextraJson['@ship.zone/szci'];
if (!szciConfig?.npmAccessLevel) {
return false;
}
const gitzoneConfig = npmextraJson['@git.zone/cli'] || {};
if (gitzoneConfig?.release?.accessLevel) {
delete szciConfig.npmAccessLevel;
return true;
}
if (!npmextraJson['@git.zone/cli']) {
npmextraJson['@git.zone/cli'] = {};
}
if (!npmextraJson['@git.zone/cli'].release) {
npmextraJson['@git.zone/cli'].release = {};
}
npmextraJson['@git.zone/cli'].release.accessLevel = szciConfig.npmAccessLevel;
delete szciConfig.npmAccessLevel;
return true;
};
export class NpmextraFormatter extends BaseFormatter {
get name(): string {
return 'npmextra';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const npmextraPath = 'npmextra.json';
// Check if file exists
const exists = await plugins.smartfs.file(npmextraPath).exists();
if (!exists) {
logVerbose('npmextra.json does not exist, skipping');
return changes;
}
// Read current content
const currentContent = (await plugins.smartfs
.file(npmextraPath)
.encoding('utf8')
.read()) as string;
// Parse and compute new content
const npmextraJson = JSON.parse(currentContent);
// Apply migrations (these are automatic, non-interactive)
migrateNamespaceKeys(npmextraJson);
migrateAccessLevel(npmextraJson);
// Ensure namespaces exist
if (!npmextraJson['@git.zone/cli']) {
npmextraJson['@git.zone/cli'] = {};
}
if (!npmextraJson['@ship.zone/szci']) {
npmextraJson['@ship.zone/szci'] = {};
}
const newContent = JSON.stringify(npmextraJson, null, 2);
// Only add change if content differs
if (newContent !== currentContent) {
changes.push({
type: 'modify',
path: npmextraPath,
module: this.name,
description: 'Migrate and format npmextra.json',
content: newContent,
});
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (change.type !== 'modify' || !change.content) return;
// Parse the content to check for missing required fields
const npmextraJson = JSON.parse(change.content);
// Check for missing required module information
const expectedRepoInformation: string[] = [
'projectType',
'module.githost',
'module.gitscope',
'module.gitrepo',
'module.description',
'module.npmPackagename',
'module.license',
];
const interactInstance = new plugins.smartinteract.SmartInteract();
for (const expectedRepoInformationItem of expectedRepoInformation) {
if (
!plugins.smartobject.smartGet(
npmextraJson['@git.zone/cli'],
expectedRepoInformationItem,
)
) {
interactInstance.addQuestions([
{
message: `What is the value of ${expectedRepoInformationItem}`,
name: expectedRepoInformationItem,
type: 'input',
default: 'undefined variable',
},
]);
}
}
const answerbucket = await interactInstance.runQueue();
for (const expectedRepoInformationItem of expectedRepoInformation) {
const cliProvidedValue = answerbucket.getAnswerFor(
expectedRepoInformationItem,
);
if (cliProvidedValue) {
plugins.smartobject.smartAdd(
npmextraJson['@git.zone/cli'],
expectedRepoInformationItem,
cliProvidedValue,
);
}
}
// Write the final content
const finalContent = JSON.stringify(npmextraJson, null, 2);
await this.modifyFile(change.path, finalContent);
logger.log('info', 'Updated npmextra.json');
} }
} }

View File

@@ -1,8 +1,204 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatPackageJson from '../format.packagejson.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger, logVerbose } from '../../gitzone.logging.js';
export class PackageJsonFormatter extends LegacyFormatter { /**
constructor(context: any, project: any) { * Ensures a certain dependency exists or is excluded
super(context, project, 'packagejson', formatPackageJson); */
const ensureDependency = async (
packageJsonObject: any,
position: 'dep' | 'devDep' | 'everywhere',
constraint: 'exclude' | 'include' | 'latest',
dependencyArg: string,
): Promise<void> => {
const [packageName, version] = dependencyArg.includes('@')
? dependencyArg.split('@').filter(Boolean)
: [dependencyArg, 'latest'];
const targetSections: string[] = [];
switch (position) {
case 'dep':
targetSections.push('dependencies');
break;
case 'devDep':
targetSections.push('devDependencies');
break;
case 'everywhere':
targetSections.push('dependencies', 'devDependencies');
break;
}
for (const section of targetSections) {
if (!packageJsonObject[section]) {
packageJsonObject[section] = {};
}
switch (constraint) {
case 'exclude':
delete packageJsonObject[section][packageName];
break;
case 'include':
if (!packageJsonObject[section][packageName]) {
packageJsonObject[section][packageName] =
version === 'latest' ? '^1.0.0' : version;
}
break;
case 'latest':
try {
const registry = new plugins.smartnpm.NpmRegistry();
const packageInfo = await registry.getPackageInfo(packageName);
const latestVersion = packageInfo['dist-tags'].latest;
packageJsonObject[section][packageName] = `^${latestVersion}`;
} catch (error) {
logVerbose(
`Could not fetch latest version for ${packageName}, using existing or default`,
);
if (!packageJsonObject[section][packageName]) {
packageJsonObject[section][packageName] =
version === 'latest' ? '^1.0.0' : version;
}
}
break;
}
}
};
export class PackageJsonFormatter extends BaseFormatter {
get name(): string {
return 'packagejson';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const packageJsonPath = 'package.json';
// Check if file exists
const exists = await plugins.smartfs.file(packageJsonPath).exists();
if (!exists) {
logVerbose('package.json does not exist, skipping');
return changes;
}
// Read current content
const currentContent = (await plugins.smartfs
.file(packageJsonPath)
.encoding('utf8')
.read()) as string;
// Parse and compute new content
const packageJson = JSON.parse(currentContent);
// Get gitzone config from npmextra
const npmextraConfig = new plugins.npmextra.Npmextra(paths.cwd);
const gitzoneData: any = npmextraConfig.dataFor('@git.zone/cli', {});
// Set metadata from gitzone config
if (gitzoneData.module) {
packageJson.repository = {
type: 'git',
url: `https://${gitzoneData.module.githost}/${gitzoneData.module.gitscope}/${gitzoneData.module.gitrepo}.git`,
};
packageJson.bugs = {
url: `https://${gitzoneData.module.githost}/${gitzoneData.module.gitscope}/${gitzoneData.module.gitrepo}/issues`,
};
packageJson.homepage = `https://${gitzoneData.module.githost}/${gitzoneData.module.gitscope}/${gitzoneData.module.gitrepo}#readme`;
}
// Ensure module type
if (!packageJson.type) {
packageJson.type = 'module';
}
// Ensure private field exists
if (packageJson.private === undefined) {
packageJson.private = true;
}
// Ensure license field exists
if (!packageJson.license) {
packageJson.license = 'UNLICENSED';
}
// Ensure scripts object exists
if (!packageJson.scripts) {
packageJson.scripts = {};
}
// Ensure build script exists
if (!packageJson.scripts.build) {
packageJson.scripts.build = `echo "Not needed for now"`;
}
// Ensure buildDocs script exists
if (!packageJson.scripts.buildDocs) {
packageJson.scripts.buildDocs = `tsdoc`;
}
// Set files array
packageJson.files = [
'ts/**/*',
'ts_web/**/*',
'dist/**/*',
'dist_*/**/*',
'dist_ts/**/*',
'dist_ts_web/**/*',
'assets/**/*',
'cli.js',
'npmextra.json',
'readme.md',
];
// Handle dependencies
await ensureDependency(
packageJson,
'devDep',
'exclude',
'@push.rocks/tapbundle',
);
await ensureDependency(packageJson, 'devDep', 'latest', '@git.zone/tstest');
await ensureDependency(
packageJson,
'devDep',
'latest',
'@git.zone/tsbuild',
);
// Set pnpm overrides from assets
try {
const overridesContent = (await plugins.smartfs
.file(plugins.path.join(paths.assetsDir, 'overrides.json'))
.encoding('utf8')
.read()) as string;
const overrides = JSON.parse(overridesContent);
packageJson.pnpm = packageJson.pnpm || {};
packageJson.pnpm.overrides = overrides;
} catch (error) {
logVerbose(`Could not read overrides.json: ${error.message}`);
}
const newContent = JSON.stringify(packageJson, null, 2);
// Only add change if content differs
if (newContent !== currentContent) {
changes.push({
type: 'modify',
path: packageJsonPath,
module: this.name,
description: 'Format package.json',
content: newContent,
});
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (change.type !== 'modify' || !change.content) return;
await this.modifyFile(change.path, change.content);
logger.log('info', 'Updated package.json');
} }
} }

View File

@@ -1,5 +1,5 @@
import { BaseFormatter } from '../classes.baseformatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import type { IPlannedChange } from '../interfaces.format.js'; import type { IPlannedChange, ICheckResult } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js'; import * as plugins from '../mod.plugins.js';
import { logger, logVerbose } from '../../gitzone.logging.js'; import { logger, logVerbose } from '../../gitzone.logging.js';
@@ -243,4 +243,53 @@ export class PrettierFormatter extends BaseFormatter {
arrowParens: 'always', arrowParens: 'always',
}); });
} }
/**
* Override check() to compute diffs on-the-fly by running prettier
*/
async check(): Promise<ICheckResult> {
const changes = await this.analyze();
const diffs: ICheckResult['diffs'] = [];
for (const change of changes) {
if (change.type !== 'modify') continue;
try {
// Read current content
const currentContent = (await plugins.smartfs
.file(change.path)
.encoding('utf8')
.read()) as string;
// Skip files without extension (prettier can't infer parser)
const fileExt = plugins.path.extname(change.path).toLowerCase();
if (!fileExt) continue;
// Format with prettier to get what it would produce
const prettier = await import('prettier');
const formatted = await prettier.format(currentContent, {
filepath: change.path,
...(await this.getPrettierConfig()),
});
// Only add to diffs if content differs
if (formatted !== currentContent) {
diffs.push({
path: change.path,
type: 'modify',
before: currentContent,
after: formatted,
});
}
} catch (error) {
// Skip files that can't be processed
logVerbose(`Skipping diff for ${change.path}: ${error.message}`);
}
}
return {
hasDiff: diffs.length > 0,
diffs,
};
}
} }

View File

@@ -1,6 +1,15 @@
import { BaseFormatter } from '../classes.baseformatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import type { IPlannedChange } from '../interfaces.format.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as formatReadme from '../format.readme.js'; import * as plugins from '../mod.plugins.js';
import { logger } from '../../gitzone.logging.js';
const DEFAULT_README_CONTENT = `# Project Readme
This is the initial readme file.`;
const DEFAULT_README_HINTS_CONTENT = `# Project Readme Hints
This is the initial readme hints file.`;
export class ReadmeFormatter extends BaseFormatter { export class ReadmeFormatter extends BaseFormatter {
get name(): string { get name(): string {
@@ -8,17 +17,39 @@ export class ReadmeFormatter extends BaseFormatter {
} }
async analyze(): Promise<IPlannedChange[]> { async analyze(): Promise<IPlannedChange[]> {
return [ const changes: IPlannedChange[] = [];
{
type: 'modify', // Check readme.md
const readmeExists = await plugins.smartfs.file('readme.md').exists();
if (!readmeExists) {
changes.push({
type: 'create',
path: 'readme.md', path: 'readme.md',
module: this.name, module: this.name,
description: 'Ensure readme files exist', description: 'Create readme.md',
}, content: DEFAULT_README_CONTENT,
]; });
}
// Check readme.hints.md
const hintsExists = await plugins.smartfs.file('readme.hints.md').exists();
if (!hintsExists) {
changes.push({
type: 'create',
path: 'readme.hints.md',
module: this.name,
description: 'Create readme.hints.md',
content: DEFAULT_README_HINTS_CONTENT,
});
}
return changes;
} }
async applyChange(change: IPlannedChange): Promise<void> { async applyChange(change: IPlannedChange): Promise<void> {
await formatReadme.run(); if (change.type !== 'create' || !change.content) return;
await this.createFile(change.path, change.content);
logger.log('info', `Created ${change.path}`);
} }
} }

View File

@@ -1,8 +1,155 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatTemplates from '../format.templates.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger, logVerbose } from '../../gitzone.logging.js';
export class TemplatesFormatter extends LegacyFormatter { export class TemplatesFormatter extends BaseFormatter {
constructor(context: any, project: any) { get name(): string {
super(context, project, 'templates', formatTemplates); return 'templates';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const project = this.project;
const projectType = project.gitzoneConfig?.data?.projectType;
// VSCode template - for all projects
const vscodeChanges = await this.analyzeTemplate('vscode', [
{ templatePath: '.vscode/settings.json', destPath: '.vscode/settings.json' },
{ templatePath: '.vscode/launch.json', destPath: '.vscode/launch.json' },
]);
changes.push(...vscodeChanges);
// CI and other templates based on projectType
switch (projectType) {
case 'npm':
case 'wcc':
const accessLevel = project.gitzoneConfig?.data?.npmciOptions?.npmAccessLevel;
const ciTemplate = accessLevel === 'public' ? 'ci_default' : 'ci_default_private';
const ciChanges = await this.analyzeTemplate(ciTemplate, [
{ templatePath: '.gitea/workflows/default_nottags.yaml', destPath: '.gitea/workflows/default_nottags.yaml' },
{ templatePath: '.gitea/workflows/default_tags.yaml', destPath: '.gitea/workflows/default_tags.yaml' },
]);
changes.push(...ciChanges);
break;
case 'service':
case 'website':
const dockerCiChanges = await this.analyzeTemplate('ci_docker', [
{ templatePath: '.gitea/workflows/docker_nottags.yaml', destPath: '.gitea/workflows/docker_nottags.yaml' },
{ templatePath: '.gitea/workflows/docker_tags.yaml', destPath: '.gitea/workflows/docker_tags.yaml' },
]);
changes.push(...dockerCiChanges);
const dockerfileChanges = await this.analyzeTemplate('dockerfile_service', [
{ templatePath: 'Dockerfile', destPath: 'Dockerfile' },
{ templatePath: 'dockerignore', destPath: '.dockerignore' },
]);
changes.push(...dockerfileChanges);
const cliChanges = await this.analyzeTemplate('cli', [
{ templatePath: 'cli.js', destPath: 'cli.js' },
{ templatePath: 'cli.ts.js', destPath: 'cli.ts.js' },
]);
changes.push(...cliChanges);
break;
}
// Update templates based on projectType
if (projectType === 'website') {
const websiteChanges = await this.analyzeTemplate('website_update', [
{ templatePath: 'html/index.html', destPath: 'html/index.html' },
]);
changes.push(...websiteChanges);
} else if (projectType === 'service') {
const serviceChanges = await this.analyzeTemplate('service_update', []);
changes.push(...serviceChanges);
} else if (projectType === 'wcc') {
const wccChanges = await this.analyzeTemplate('wcc_update', [
{ templatePath: 'html/index.html', destPath: 'html/index.html' },
{ templatePath: 'html/index.ts', destPath: 'html/index.ts' },
]);
changes.push(...wccChanges);
}
return changes;
}
private async analyzeTemplate(
templateName: string,
files: Array<{ templatePath: string; destPath: string }>,
): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const templateDir = plugins.path.join(paths.templatesDir, templateName);
// Check if template exists
const templateExists = await plugins.smartfs.directory(templateDir).exists();
if (!templateExists) {
logVerbose(`Template ${templateName} not found`);
return changes;
}
for (const file of files) {
const templateFilePath = plugins.path.join(templateDir, file.templatePath);
const destFilePath = file.destPath;
// Check if template file exists
const fileExists = await plugins.smartfs.file(templateFilePath).exists();
if (!fileExists) {
logVerbose(`Template file ${templateFilePath} not found`);
continue;
}
try {
// Read template content
const templateContent = (await plugins.smartfs
.file(templateFilePath)
.encoding('utf8')
.read()) as string;
// Check if destination file exists
const destExists = await plugins.smartfs.file(destFilePath).exists();
let currentContent = '';
if (destExists) {
currentContent = (await plugins.smartfs
.file(destFilePath)
.encoding('utf8')
.read()) as string;
}
// Only add change if content differs
if (templateContent !== currentContent) {
changes.push({
type: destExists ? 'modify' : 'create',
path: destFilePath,
module: this.name,
description: `Apply template ${templateName}/${file.templatePath}`,
content: templateContent,
});
}
} catch (error) {
logVerbose(`Failed to read template ${templateFilePath}: ${error.message}`);
}
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (!change.content) return;
// Ensure destination directory exists
const destDir = plugins.path.dirname(change.path);
if (destDir && destDir !== '.') {
await plugins.smartfs.directory(destDir).recursive().create();
}
if (change.type === 'create') {
await this.createFile(change.path, change.content);
} else {
await this.modifyFile(change.path, change.content);
}
logger.log('info', `Applied template to ${change.path}`);
} }
} }

View File

@@ -1,8 +1,73 @@
import { LegacyFormatter } from './legacy.formatter.js'; import { BaseFormatter } from '../classes.baseformatter.js';
import * as formatTsconfig from '../format.tsconfig.js'; import type { IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger, logVerbose } from '../../gitzone.logging.js';
export class TsconfigFormatter extends LegacyFormatter { export class TsconfigFormatter extends BaseFormatter {
constructor(context: any, project: any) { get name(): string {
super(context, project, 'tsconfig', formatTsconfig); return 'tsconfig';
}
async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = [];
const tsconfigPath = 'tsconfig.json';
// Check if file exists
const exists = await plugins.smartfs.file(tsconfigPath).exists();
if (!exists) {
logVerbose('tsconfig.json does not exist, skipping');
return changes;
}
// Read current content
const currentContent = (await plugins.smartfs
.file(tsconfigPath)
.encoding('utf8')
.read()) as string;
// Parse and compute new content
const tsconfigObject = JSON.parse(currentContent);
tsconfigObject.compilerOptions = tsconfigObject.compilerOptions || {};
tsconfigObject.compilerOptions.baseUrl = '.';
tsconfigObject.compilerOptions.paths = {};
// Get module paths from tspublish
try {
const tsPublishMod = await import('@git.zone/tspublish');
const tsPublishInstance = new tsPublishMod.TsPublish();
const publishModules = await tsPublishInstance.getModuleSubDirs(paths.cwd);
for (const publishModule of Object.keys(publishModules)) {
const publishConfig = publishModules[publishModule];
tsconfigObject.compilerOptions.paths[`${publishConfig.name}`] = [
`./${publishModule}/index.js`,
];
}
} catch (error) {
logVerbose(`Could not get tspublish modules: ${error.message}`);
}
const newContent = JSON.stringify(tsconfigObject, null, 2);
// Only add change if content differs
if (newContent !== currentContent) {
changes.push({
type: 'modify',
path: tsconfigPath,
module: this.name,
description: 'Format tsconfig.json with path mappings',
content: newContent,
});
}
return changes;
}
async applyChange(change: IPlannedChange): Promise<void> {
if (change.type !== 'modify' || !change.content) return;
await this.modifyFile(change.path, change.content);
logger.log('info', 'Updated tsconfig.json');
} }
} }