From b506bf8785f09d11b531dd6d2ceeeeef53f64e3e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 15 Dec 2025 17:07:30 +0000 Subject: [PATCH] feat(mod_format): Refactor formatting modules to new BaseFormatter and implement concrete analyze/apply logic --- changelog.md | 15 ++ ts/00_commitinfo_data.ts | 2 +- ts/mod_format/formatters/copy.formatter.ts | 119 +++++++++- .../formatters/gitignore.formatter.ts | 113 +++++++++- ts/mod_format/formatters/legacy.formatter.ts | 43 ---- ts/mod_format/formatters/license.formatter.ts | 64 +++++- .../formatters/npmextra.formatter.ts | 167 +++++++++++++- .../formatters/packagejson.formatter.ts | 206 +++++++++++++++++- .../formatters/prettier.formatter.ts | 51 ++++- ts/mod_format/formatters/readme.formatter.ts | 47 +++- .../formatters/templates.formatter.ts | 157 ++++++++++++- .../formatters/tsconfig.formatter.ts | 75 ++++++- 12 files changed, 971 insertions(+), 88 deletions(-) delete mode 100644 ts/mod_format/formatters/legacy.formatter.ts diff --git a/changelog.md b/changelog.md index c7ec1f7..92676a5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,20 @@ # Changelog +## 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) 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. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index c871cd7..12c02a5 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@git.zone/cli', - version: '2.9.0', + version: '2.10.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.' } diff --git a/ts/mod_format/formatters/copy.formatter.ts b/ts/mod_format/formatters/copy.formatter.ts index 8ae3eb3..a31260e 100644 --- a/ts/mod_format/formatters/copy.formatter.ts +++ b/ts/mod_format/formatters/copy.formatter.ts @@ -1,8 +1,117 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatCopy from '../format.copy.js'; +import { BaseFormatter } from '../classes.baseformatter.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 { - constructor(context: any, project: any) { - super(context, project, 'copy', formatCopy); +interface ICopyPattern { + from: string; + to: string; + preservePath?: boolean; +} + +export class CopyFormatter extends BaseFormatter { + get name(): string { + return 'copy'; + } + + async analyze(): Promise { + 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 { + 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}`); } } diff --git a/ts/mod_format/formatters/gitignore.formatter.ts b/ts/mod_format/formatters/gitignore.formatter.ts index a4d695f..4ea86c4 100644 --- a/ts/mod_format/formatters/gitignore.formatter.ts +++ b/ts/mod_format/formatters/gitignore.formatter.ts @@ -1,8 +1,111 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatGitignore from '../format.gitignore.js'; +import { BaseFormatter } from '../classes.baseformatter.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 { - constructor(context: any, project: any) { - super(context, project, 'gitignore', formatGitignore); +// Standard gitignore template content (without front-matter) +const GITIGNORE_TEMPLATE = `.nogit/ + +# 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 { + 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 { + 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)'); + } } } diff --git a/ts/mod_format/formatters/legacy.formatter.ts b/ts/mod_format/formatters/legacy.formatter.ts deleted file mode 100644 index 5738601..0000000 --- a/ts/mod_format/formatters/legacy.formatter.ts +++ /dev/null @@ -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 { - // 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: '', - module: this.name, - description: `Run ${this.name} formatter`, - }, - ]; - } - - async applyChange(change: IPlannedChange): Promise { - // Run the legacy format module - await this.formatModule.run(this.project); - } -} diff --git a/ts/mod_format/formatters/license.formatter.ts b/ts/mod_format/formatters/license.formatter.ts index a1e6552..8d33b82 100644 --- a/ts/mod_format/formatters/license.formatter.ts +++ b/ts/mod_format/formatters/license.formatter.ts @@ -1,8 +1,62 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatLicense from '../format.license.js'; +import { BaseFormatter } from '../classes.baseformatter.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 { - constructor(context: any, project: any) { - super(context, project, 'license', formatLicense); +const INCOMPATIBLE_LICENSES: string[] = ['AGPL', 'GPL', 'SSPL']; + +export class LicenseFormatter extends BaseFormatter { + get name(): string { + return 'license'; + } + + async analyze(): Promise { + // 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 { + 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 { + // No file changes for license formatter } } diff --git a/ts/mod_format/formatters/npmextra.formatter.ts b/ts/mod_format/formatters/npmextra.formatter.ts index 2edf0ef..c818f7b 100644 --- a/ts/mod_format/formatters/npmextra.formatter.ts +++ b/ts/mod_format/formatters/npmextra.formatter.ts @@ -1,8 +1,165 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatNpmextra from '../format.npmextra.js'; +import { BaseFormatter } from '../classes.baseformatter.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) { - super(context, project, 'npmextra', formatNpmextra); +/** + * Migrates npmextra.json from old namespace keys to new package-scoped keys + */ +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 { + 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 { + 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'); } } diff --git a/ts/mod_format/formatters/packagejson.formatter.ts b/ts/mod_format/formatters/packagejson.formatter.ts index 194a975..708a21d 100644 --- a/ts/mod_format/formatters/packagejson.formatter.ts +++ b/ts/mod_format/formatters/packagejson.formatter.ts @@ -1,8 +1,204 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatPackageJson from '../format.packagejson.js'; +import { BaseFormatter } from '../classes.baseformatter.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) { - super(context, project, 'packagejson', formatPackageJson); +/** + * Ensures a certain dependency exists or is excluded + */ +const ensureDependency = async ( + packageJsonObject: any, + position: 'dep' | 'devDep' | 'everywhere', + constraint: 'exclude' | 'include' | 'latest', + dependencyArg: string, +): Promise => { + 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 { + 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 { + if (change.type !== 'modify' || !change.content) return; + + await this.modifyFile(change.path, change.content); + logger.log('info', 'Updated package.json'); } } diff --git a/ts/mod_format/formatters/prettier.formatter.ts b/ts/mod_format/formatters/prettier.formatter.ts index 934ae48..38a864d 100644 --- a/ts/mod_format/formatters/prettier.formatter.ts +++ b/ts/mod_format/formatters/prettier.formatter.ts @@ -1,5 +1,5 @@ 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 { logger, logVerbose } from '../../gitzone.logging.js'; @@ -243,4 +243,53 @@ export class PrettierFormatter extends BaseFormatter { arrowParens: 'always', }); } + + /** + * Override check() to compute diffs on-the-fly by running prettier + */ + async check(): Promise { + 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, + }; + } } diff --git a/ts/mod_format/formatters/readme.formatter.ts b/ts/mod_format/formatters/readme.formatter.ts index 948a375..5bc0c5b 100644 --- a/ts/mod_format/formatters/readme.formatter.ts +++ b/ts/mod_format/formatters/readme.formatter.ts @@ -1,6 +1,15 @@ import { BaseFormatter } from '../classes.baseformatter.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 { get name(): string { @@ -8,17 +17,39 @@ export class ReadmeFormatter extends BaseFormatter { } async analyze(): Promise { - return [ - { - type: 'modify', + const changes: IPlannedChange[] = []; + + // Check readme.md + const readmeExists = await plugins.smartfs.file('readme.md').exists(); + if (!readmeExists) { + changes.push({ + type: 'create', path: 'readme.md', 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 { - await formatReadme.run(); + if (change.type !== 'create' || !change.content) return; + + await this.createFile(change.path, change.content); + logger.log('info', `Created ${change.path}`); } } diff --git a/ts/mod_format/formatters/templates.formatter.ts b/ts/mod_format/formatters/templates.formatter.ts index 69b3c66..088bbd8 100644 --- a/ts/mod_format/formatters/templates.formatter.ts +++ b/ts/mod_format/formatters/templates.formatter.ts @@ -1,8 +1,155 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatTemplates from '../format.templates.js'; +import { BaseFormatter } from '../classes.baseformatter.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 { - constructor(context: any, project: any) { - super(context, project, 'templates', formatTemplates); +export class TemplatesFormatter extends BaseFormatter { + get name(): string { + return 'templates'; + } + + async analyze(): Promise { + 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 { + 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 { + 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}`); } } diff --git a/ts/mod_format/formatters/tsconfig.formatter.ts b/ts/mod_format/formatters/tsconfig.formatter.ts index 1860721..43c76ca 100644 --- a/ts/mod_format/formatters/tsconfig.formatter.ts +++ b/ts/mod_format/formatters/tsconfig.formatter.ts @@ -1,8 +1,73 @@ -import { LegacyFormatter } from './legacy.formatter.js'; -import * as formatTsconfig from '../format.tsconfig.js'; +import { BaseFormatter } from '../classes.baseformatter.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 { - constructor(context: any, project: any) { - super(context, project, 'tsconfig', formatTsconfig); +export class TsconfigFormatter extends BaseFormatter { + get name(): string { + return 'tsconfig'; + } + + async analyze(): Promise { + 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 { + if (change.type !== 'modify' || !change.content) return; + + await this.modifyFile(change.path, change.content); + logger.log('info', 'Updated tsconfig.json'); } }