From fd7a73398cc4b9a4ce11b1d3728c716da2b98d70 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 16 Apr 2026 18:54:07 +0000 Subject: [PATCH] feat(cli): add machine-readable CLI help, recommendation, and configuration flows --- changelog.md | 8 + readme.hints.md | 57 +- readme.md | 150 ++-- ts/00_commitinfo_data.ts | 2 +- ts/gitzone.cli.ts | 88 ++- ts/helpers.climode.ts | 212 +++++ ts/helpers.smartconfig.ts | 192 +++++ ts/mod_commit/index.ts | 423 +++++++--- ts/mod_config/index.ts | 672 +++++++++++----- ts/mod_format/classes.formatcontext.ts | 23 +- .../formatters/smartconfig.formatter.ts | 94 ++- ts/mod_format/index.ts | 365 ++++++--- ts/mod_services/index.ts | 733 +++++++++++++----- ts/mod_standard/index.ts | 249 ++++-- 14 files changed, 2482 insertions(+), 786 deletions(-) create mode 100644 ts/helpers.climode.ts create mode 100644 ts/helpers.smartconfig.ts diff --git a/changelog.md b/changelog.md index 786557d..d2da0e1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-16 - 2.14.0 - feat(cli) +add machine-readable CLI help, recommendation, and configuration flows + +- introduces shared CLI mode handling for human, plain, and JSON output with configurable interactivity and update checks +- adds read-only JSON support for `commit recommend`, `format plan`, and command help output +- expands `config` and `services` commands with non-interactive config inspection and service enablement flows +- updates format and smartconfig handling to respect non-interactive execution and fail clearly when required metadata is missing + ## 2026-04-16 - 2.13.16 - fix(mod_format) stop package.json formatter from modifying buildDocs and dependency entries diff --git a/readme.hints.md b/readme.hints.md index 6027ad1..621bcf0 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -23,10 +23,10 @@ Gitzone CLI (`@git.zone/cli`) is a comprehensive toolbelt for streamlining local ### Configuration Management -- Uses `npmextra.json` for all tool configuration -- Configuration stored under `gitzone` key in npmextra -- No separate `.gitzonerc` file - everything in npmextra.json -- Project type and module metadata also stored in npmextra +- Uses `.smartconfig.json` for tool configuration +- CLI settings live under the `@git.zone/cli` namespace +- Agent and non-interactive defaults now belong under `@git.zone/cli.cli` +- Project type, module metadata, release settings, commit defaults, and format settings live in the same file ### Format Module (`mod_format`) - SIGNIFICANTLY ENHANCED @@ -84,7 +84,7 @@ The format module is responsible for project standardization: 1. **Plan โ†’ Action Workflow**: Shows changes before applying them 2. **Rollback Mechanism**: Full backup and restore on failures -3. **Enhanced Configuration**: Granular control via npmextra.json +3. **Enhanced Configuration**: Granular control via `.smartconfig.json` 4. **Better Error Handling**: Detailed errors with recovery options 5. **Performance Optimizations**: Parallel execution and caching 6. **Reporting**: Diff views, statistics, verbose logging @@ -132,7 +132,7 @@ The commit module now supports `-y/--yes` flag for non-interactive commits: ## Development Tips - Always check readme.plan.md for ongoing improvement plans -- Use npmextra.json for any new configuration options +- Use `.smartconfig.json` for any new configuration options - Keep modules focused and single-purpose - Maintain the existing plugin pattern for dependencies - Test format operations on sample projects before deploying @@ -144,30 +144,18 @@ The commit module now supports `-y/--yes` flag for non-interactive commits: ```json { - "gitzone": { + "@git.zone/cli": { + "cli": { + "interactive": true, + "output": "human", + "checkUpdates": true + }, "format": { "interactive": true, - "parallel": true, "showStats": true, - "cache": { - "enabled": true, - "clean": true - }, - "rollback": { - "enabled": true, - "autoRollbackOnError": true, - "backupRetentionDays": 7 - }, "modules": { "skip": ["prettier"], - "only": [], - "order": [] - }, - "licenses": { - "allowed": ["MIT", "Apache-2.0"], - "exceptions": { - "some-package": "GPL-3.0" - } + "only": [] } } } @@ -182,6 +170,9 @@ The commit module now supports `-y/--yes` flag for non-interactive commits: # Interactive commit (default) gitzone commit +# Read-only recommendation +gitzone commit recommend --json + # Auto-accept AI recommendations (no prompts) gitzone commit -y gitzone commit --yes @@ -201,11 +192,14 @@ gitzone commit --format # Basic format gitzone format +# Read-only JSON plan +gitzone format plan --json + # Dry run to preview changes gitzone format --dry-run -# Non-interactive mode -gitzone format --yes +# Non-interactive apply +gitzone format --write --yes # Plan only (no execution) gitzone format --plan-only @@ -222,11 +216,10 @@ gitzone format --verbose # Detailed diff views gitzone format --detailed -# Rollback operations -gitzone format --rollback -gitzone format --rollback -gitzone format --list-backups -gitzone format --clean-backups +# Inspect config for agents and scripts +gitzone config show --json +gitzone config set cli.output json +gitzone config get release.accessLevel ``` ## Common Issues (Now Resolved) diff --git a/readme.md b/readme.md index 8f4de06..e302278 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,9 @@ Create standardized commits with AI-powered suggestions that automatically handl # Interactive commit with AI recommendations gitzone commit +# Read-only recommendation for agents and scripts +gitzone commit recommend --json + # Auto-accept AI recommendations (skipped for BREAKING CHANGEs) gitzone commit -y @@ -65,14 +68,15 @@ gitzone commit -ypbr **Flags:** -| Flag | Long Form | Description | -|------|-----------|-------------| -| `-y` | `--yes` | Auto-accept AI recommendations | -| `-p` | `--push` | Push to remote after commit | -| `-t` | `--test` | Run tests before committing | -| `-b` | `--build` | Build after commit, verify clean tree | -| `-r` | `--release` | Publish to configured npm registries | -| | `--format` | Run format before committing | +| Flag | Long Form | Description | +| ---- | ----------- | ---------------------------------------- | +| `-y` | `--yes` | Auto-accept AI recommendations | +| `-p` | `--push` | Push to remote after commit | +| `-t` | `--test` | Run tests before committing | +| `-b` | `--build` | Build after commit, verify clean tree | +| `-r` | `--release` | Publish to configured npm registries | +| | `--format` | Run format before committing | +| | `--json` | Emit JSON for `gitzone commit recommend` | **Workflow steps:** @@ -94,6 +98,9 @@ Automatically format and standardize your entire codebase. **Dry-run by default* # Preview what would change (default behavior) gitzone format +# Emit a machine-readable plan +gitzone format plan --json + # Apply changes gitzone format --write @@ -109,22 +116,22 @@ gitzone format --verbose **Flags:** -| Flag | Description | -|------|-------------| -| `--write` / `-w` | Apply changes (default is dry-run) | -| `--yes` | Auto-approve without interactive confirmation | -| `--plan-only` | Only show what would be done | -| `--save-plan ` | Save the format plan to a file | -| `--from-plan ` | Load and execute a saved plan | -| `--detailed` | Show detailed stats and save report | -| `--parallel` / `--no-parallel` | Toggle parallel execution | -| `--verbose` | Enable verbose logging | -| `--diff` | Show file diffs | +| Flag | Description | +| -------------------- | --------------------------------------------- | +| `--write` / `-w` | Apply changes (default is dry-run) | +| `--yes` | Auto-approve without interactive confirmation | +| `--plan-only` | Only show what would be done | +| `--save-plan ` | Save the format plan to a file | +| `--from-plan ` | Load and execute a saved plan | +| `--detailed` | Show detailed stats and save report | +| `--verbose` | Enable verbose logging | +| `--diff` | Show file diffs | +| `--json` | Emit a read-only format plan as JSON | **Formatters (executed in order):** 1. ๐Ÿงน **Cleanup** โ€” removes obsolete files (yarn.lock, package-lock.json, tslint.json, etc.) -2. โš™๏ธ **Npmextra** โ€” formats and standardizes `npmextra.json` +2. โš™๏ธ **Smartconfig** โ€” formats and standardizes `.smartconfig.json` 3. ๐Ÿ“œ **License** โ€” ensures proper licensing and checks dependency licenses 4. ๐Ÿ“ฆ **Package.json** โ€” standardizes package configuration 5. ๐Ÿ“‹ **Templates** โ€” applies project template updates @@ -144,18 +151,21 @@ gitzone services [command] **Commands:** -| Command | Description | -|---------|-------------| -| `start [service]` | Start services (`mongo`\|`s3`\|`elasticsearch`\|`all`) | -| `stop [service]` | Stop services | -| `restart [service]` | Restart services | -| `status` | Show current service status | -| `config` | Display configuration details | -| `compass` | Get MongoDB Compass connection string with network IP | -| `logs [service] [lines]` | View service logs (default: 20 lines) | -| `reconfigure` | Reassign ports and restart all services | -| `remove` | Remove containers (preserves data) | -| `clean` | Remove containers AND data (โš ๏ธ destructive) | +| Command | Description | +| ------------------------ | ------------------------------------------------------ | +| `start [service]` | Start services (`mongo`\|`s3`\|`elasticsearch`\|`all`) | +| `stop [service]` | Stop services | +| `restart [service]` | Restart services | +| `status` | Show current service status | +| `config` | Display configuration details | +| `set ` | Set enabled services without prompts | +| `enable ` | Enable one or more services | +| `disable ` | Disable one or more services | +| `compass` | Get MongoDB Compass connection string with network IP | +| `logs [service] [lines]` | View service logs (default: 20 lines) | +| `reconfigure` | Reassign ports and restart all services | +| `remove` | Remove containers (preserves data) | +| `clean` | Remove containers AND data (โš ๏ธ destructive) | **Service aliases:** @@ -195,6 +205,9 @@ gitzone services cleanup -g # Start all services for your project gitzone services start +# Configure enabled services without prompts +gitzone services set mongodb,minio + # Check what's running gitzone services status @@ -217,18 +230,21 @@ Manage release registries and commit settings: gitzone config [subcommand] ``` -| Command | Description | -|---------|-------------| -| `show` | Display current release config (registries, access level) | -| `add [url]` | Add a registry URL (default: `https://registry.npmjs.org`) | -| `remove [url]` | Remove a registry URL (interactive selection if no URL) | -| `clear` | Clear all registries (with confirmation) | -| `access [public\|private]` | Set npm access level for publishing | -| `commit alwaysTest [true\|false]` | Always run tests before commit | -| `commit alwaysBuild [true\|false]` | Always build after commit | -| `services` | Configure which services are enabled | +| Command | Description | +| ---------------------------------- | ---------------------------------------------------------- | +| `show` | Display current release config (registries, access level) | +| `get ` | Read a single value from `@git.zone/cli` | +| `set ` | Write a single value to `@git.zone/cli` | +| `unset ` | Remove a single value from `@git.zone/cli` | +| `add [url]` | Add a registry URL (default: `https://registry.npmjs.org`) | +| `remove [url]` | Remove a registry URL (interactive selection if no URL) | +| `clear` | Clear all registries (with confirmation) | +| `access [public\|private]` | Set npm access level for publishing | +| `commit alwaysTest [true\|false]` | Always run tests before commit | +| `commit alwaysBuild [true\|false]` | Always build after commit | +| `services` | Configure which services are enabled | -Configuration is stored in `npmextra.json` under the `@git.zone/cli` key. +Configuration is stored in `.smartconfig.json` under the `@git.zone/cli` key. ### ๐Ÿ“ฆ Project Templates @@ -323,44 +339,33 @@ gitzone helpers shortid ## ๐Ÿ“‹ Configuration -### npmextra.json +### .smartconfig.json -Customize gitzone behavior through `npmextra.json`: +Customize gitzone behavior through `.smartconfig.json`: ```json { "@git.zone/cli": { "projectType": "npm", + "cli": { + "interactive": true, + "output": "human", + "checkUpdates": true + }, "release": { - "registries": [ - "https://registry.npmjs.org" - ], + "registries": ["https://registry.npmjs.org"], "accessLevel": "public" }, "commit": { "alwaysTest": false, "alwaysBuild": false - } - }, - "gitzone": { + }, "format": { "interactive": true, - "parallel": true, "showStats": true, - "cache": { - "enabled": true, - "clean": true - }, "modules": { "skip": ["prettier"], - "only": [], - "order": [] - }, - "licenses": { - "allowed": ["MIT", "Apache-2.0"], - "exceptions": { - "some-package": "GPL-3.0" - } + "only": [] } } } @@ -408,6 +413,23 @@ gitzone services stop gitzone commit -ytbpr ``` +### Agent-Friendly Inspection + +```bash +# Top-level machine-readable help +gitzone help config --json + +# Read-only commit recommendation +gitzone commit recommend --json + +# Read-only format plan +gitzone format plan --json + +# Read or change config without prompts +gitzone config get release.accessLevel +gitzone config set cli.interactive false +``` + ### Multi-Repository Management ```bash diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 6efadb6..57c116b 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.13.16', + version: '2.14.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/gitzone.cli.ts b/ts/gitzone.cli.ts index 5364707..1ad469b 100644 --- a/ts/gitzone.cli.ts +++ b/ts/gitzone.cli.ts @@ -1,23 +1,29 @@ -import * as plugins from './plugins.js'; -import * as paths from './paths.js'; -import { GitzoneConfig } from './classes.gitzoneconfig.js'; +import * as plugins from "./plugins.js"; +import * as paths from "./paths.js"; +import { GitzoneConfig } from "./classes.gitzoneconfig.js"; +import { getRawCliMode } from "./helpers.climode.js"; const gitzoneSmartcli = new plugins.smartcli.Smartcli(); export let run = async () => { const done = plugins.smartpromise.defer(); + const rawCliMode = await getRawCliMode(); // get packageInfo const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir); // check for updates - const smartupdateInstance = new plugins.smartupdate.SmartUpdate(); - await smartupdateInstance.check( - 'gitzone', - projectInfo.npm.version, - 'http://gitzone.gitlab.io/gitzone/changelog.html', - ); - console.log('---------------------------------------------'); + if (rawCliMode.checkUpdates) { + const smartupdateInstance = new plugins.smartupdate.SmartUpdate(); + await smartupdateInstance.check( + "gitzone", + projectInfo.npm.version, + "http://gitzone.gitlab.io/gitzone/changelog.html", + ); + } + if (rawCliMode.output === "human") { + console.log("---------------------------------------------"); + } gitzoneSmartcli.addVersion(projectInfo.npm.version); // ======> Standard task <====== @@ -26,8 +32,13 @@ export let run = async () => { * standard task */ gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => { - const modStandard = await import('./mod_standard/index.js'); - await modStandard.run(); + const modStandard = await import("./mod_standard/index.js"); + await modStandard.run(argvArg); + }); + + gitzoneSmartcli.addCommand("help").subscribe(async (argvArg) => { + const modStandard = await import("./mod_standard/index.js"); + await modStandard.run(argvArg); }); // ======> Specific tasks <====== @@ -35,43 +46,44 @@ export let run = async () => { /** * commit something */ - gitzoneSmartcli.addCommand('commit').subscribe(async (argvArg) => { - const modCommit = await import('./mod_commit/index.js'); + gitzoneSmartcli.addCommand("commit").subscribe(async (argvArg) => { + const modCommit = await import("./mod_commit/index.js"); await modCommit.run(argvArg); }); /** * deprecate a package on npm */ - gitzoneSmartcli.addCommand('deprecate').subscribe(async (argvArg) => { - const modDeprecate = await import('./mod_deprecate/index.js'); + gitzoneSmartcli.addCommand("deprecate").subscribe(async (argvArg) => { + const modDeprecate = await import("./mod_deprecate/index.js"); await modDeprecate.run(); }); /** * docker */ - gitzoneSmartcli.addCommand('docker').subscribe(async (argvArg) => { - const modDocker = await import('./mod_docker/index.js'); + gitzoneSmartcli.addCommand("docker").subscribe(async (argvArg) => { + const modDocker = await import("./mod_docker/index.js"); await modDocker.run(argvArg); }); /** * Update all files that comply with the gitzone standard */ - gitzoneSmartcli.addCommand('format').subscribe(async (argvArg) => { + gitzoneSmartcli.addCommand("format").subscribe(async (argvArg) => { const config = GitzoneConfig.fromCwd(); - const modFormat = await import('./mod_format/index.js'); + const modFormat = await import("./mod_format/index.js"); // Handle format with options // Default is dry-mode, use --write/-w to apply changes await modFormat.run({ + ...argvArg, write: argvArg.write || argvArg.w, - dryRun: argvArg['dry-run'], + dryRun: argvArg["dry-run"], yes: argvArg.yes, - planOnly: argvArg['plan-only'], - savePlan: argvArg['save-plan'], - fromPlan: argvArg['from-plan'], + planOnly: argvArg["plan-only"], + savePlan: argvArg["save-plan"], + fromPlan: argvArg["from-plan"], detailed: argvArg.detailed, interactive: argvArg.interactive !== false, verbose: argvArg.verbose, @@ -82,54 +94,54 @@ export let run = async () => { /** * run meta commands */ - gitzoneSmartcli.addCommand('meta').subscribe(async (argvArg) => { + gitzoneSmartcli.addCommand("meta").subscribe(async (argvArg) => { const config = GitzoneConfig.fromCwd(); - const modMeta = await import('./mod_meta/index.js'); + const modMeta = await import("./mod_meta/index.js"); modMeta.run(argvArg); }); /** * open assets */ - gitzoneSmartcli.addCommand('open').subscribe(async (argvArg) => { - const modOpen = await import('./mod_open/index.js'); + gitzoneSmartcli.addCommand("open").subscribe(async (argvArg) => { + const modOpen = await import("./mod_open/index.js"); modOpen.run(argvArg); }); /** * add a readme to a project */ - gitzoneSmartcli.addCommand('template').subscribe(async (argvArg) => { - const modTemplate = await import('./mod_template/index.js'); + gitzoneSmartcli.addCommand("template").subscribe(async (argvArg) => { + const modTemplate = await import("./mod_template/index.js"); modTemplate.run(argvArg); }); /** * start working on a project */ - gitzoneSmartcli.addCommand('start').subscribe(async (argvArg) => { - const modTemplate = await import('./mod_start/index.js'); + gitzoneSmartcli.addCommand("start").subscribe(async (argvArg) => { + const modTemplate = await import("./mod_start/index.js"); modTemplate.run(argvArg); }); - gitzoneSmartcli.addCommand('helpers').subscribe(async (argvArg) => { - const modHelpers = await import('./mod_helpers/index.js'); + gitzoneSmartcli.addCommand("helpers").subscribe(async (argvArg) => { + const modHelpers = await import("./mod_helpers/index.js"); modHelpers.run(argvArg); }); /** * manage release configuration */ - gitzoneSmartcli.addCommand('config').subscribe(async (argvArg) => { - const modConfig = await import('./mod_config/index.js'); + gitzoneSmartcli.addCommand("config").subscribe(async (argvArg) => { + const modConfig = await import("./mod_config/index.js"); await modConfig.run(argvArg); }); /** * manage development services (MongoDB, S3/MinIO) */ - gitzoneSmartcli.addCommand('services').subscribe(async (argvArg) => { - const modServices = await import('./mod_services/index.js'); + gitzoneSmartcli.addCommand("services").subscribe(async (argvArg) => { + const modServices = await import("./mod_services/index.js"); await modServices.run(argvArg); }); diff --git a/ts/helpers.climode.ts b/ts/helpers.climode.ts new file mode 100644 index 0000000..bd60a02 --- /dev/null +++ b/ts/helpers.climode.ts @@ -0,0 +1,212 @@ +import { getCliConfigValue } from "./helpers.smartconfig.js"; + +export type TCliOutputMode = "human" | "plain" | "json"; + +export interface ICliMode { + output: TCliOutputMode; + interactive: boolean; + json: boolean; + plain: boolean; + quiet: boolean; + yes: boolean; + help: boolean; + agent: boolean; + checkUpdates: boolean; + isTty: boolean; + command?: string; +} + +interface ICliConfigSettings { + interactive?: boolean; + output?: TCliOutputMode; + checkUpdates?: boolean; +} + +type TArgSource = Record & { _?: string[] }; + +const camelCase = (value: string): string => { + return value.replace(/-([a-z])/g, (_match, group: string) => + group.toUpperCase(), + ); +}; + +const getArgValue = (argvArg: TArgSource, key: string): any => { + const keyVariants = [key, camelCase(key), key.replace(/-/g, "")]; + for (const keyVariant of keyVariants) { + if (argvArg[keyVariant] !== undefined) { + return argvArg[keyVariant]; + } + } + return undefined; +}; + +const parseRawArgv = (argv: string[]): TArgSource => { + const parsedArgv: TArgSource = { _: [] }; + + for (let i = 0; i < argv.length; i++) { + const currentArg = argv[i]; + + if (currentArg.startsWith("--no-")) { + const key = currentArg.slice(5); + parsedArgv[key] = false; + parsedArgv[camelCase(key)] = false; + continue; + } + + if (currentArg.startsWith("--")) { + const withoutPrefix = currentArg.slice(2); + const [rawKey, inlineValue] = withoutPrefix.split("=", 2); + if (inlineValue !== undefined) { + parsedArgv[rawKey] = inlineValue; + parsedArgv[camelCase(rawKey)] = inlineValue; + continue; + } + + const nextArg = argv[i + 1]; + if (nextArg && !nextArg.startsWith("-")) { + parsedArgv[rawKey] = nextArg; + parsedArgv[camelCase(rawKey)] = nextArg; + i++; + } else { + parsedArgv[rawKey] = true; + parsedArgv[camelCase(rawKey)] = true; + } + continue; + } + + if (currentArg.startsWith("-") && currentArg.length > 1) { + for (const shortFlag of currentArg.slice(1).split("")) { + parsedArgv[shortFlag] = true; + } + continue; + } + + parsedArgv._ = parsedArgv._ || []; + parsedArgv._.push(currentArg); + } + + return parsedArgv; +}; + +const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => { + if (value === "human" || value === "plain" || value === "json") { + return value; + } + return undefined; +}; + +const resolveCliMode = ( + argvArg: TArgSource, + cliConfig: ICliConfigSettings, +): ICliMode => { + const isTty = Boolean(process.stdout?.isTTY && process.stdin?.isTTY); + const agentMode = Boolean(getArgValue(argvArg, "agent")); + const outputOverride = normalizeOutputMode(getArgValue(argvArg, "output")); + + let output: TCliOutputMode = + normalizeOutputMode(cliConfig.output) || (isTty ? "human" : "plain"); + if (agentMode || getArgValue(argvArg, "json")) { + output = "json"; + } else if (getArgValue(argvArg, "plain")) { + output = "plain"; + } else if (outputOverride) { + output = outputOverride; + } + + const interactiveSetting = getArgValue(argvArg, "interactive"); + let interactive = cliConfig.interactive ?? isTty; + if (interactiveSetting === true) { + interactive = true; + } else if (interactiveSetting === false) { + interactive = false; + } + if (!isTty || output !== "human" || agentMode) { + interactive = false; + } + + const checkUpdatesSetting = getArgValue(argvArg, "check-updates"); + let checkUpdates = cliConfig.checkUpdates ?? output === "human"; + if (checkUpdatesSetting === true) { + checkUpdates = true; + } else if (checkUpdatesSetting === false) { + checkUpdates = false; + } + if (output !== "human" || agentMode) { + checkUpdates = false; + } + + return { + output, + interactive, + json: output === "json", + plain: output === "plain", + quiet: Boolean( + getArgValue(argvArg, "quiet") || + getArgValue(argvArg, "q") || + output === "json", + ), + yes: Boolean(getArgValue(argvArg, "yes") || getArgValue(argvArg, "y")), + help: Boolean( + getArgValue(argvArg, "help") || + getArgValue(argvArg, "h") || + argvArg._?.[0] === "help", + ), + agent: agentMode, + checkUpdates, + isTty, + command: argvArg._?.[0], + }; +}; + +const getCliModeConfig = async (): Promise => { + return await getCliConfigValue("cli", {}); +}; + +export const getCliMode = async ( + argvArg: TArgSource = {}, +): Promise => { + const cliConfig = await getCliModeConfig(); + return resolveCliMode(argvArg, cliConfig); +}; + +export const getRawCliMode = async (): Promise => { + const cliConfig = await getCliModeConfig(); + const rawArgv = parseRawArgv(process.argv.slice(2)); + return resolveCliMode(rawArgv, cliConfig); +}; + +export const printJson = (data: unknown): void => { + console.log(JSON.stringify(data, null, 2)); +}; + +export const runWithSuppressedOutput = async ( + fn: () => Promise, +): Promise => { + const originalConsole = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + }; + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const noop = () => undefined; + + console.log = noop; + console.info = noop; + console.warn = noop; + console.error = noop; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + try { + return await fn(); + } finally { + console.log = originalConsole.log; + console.info = originalConsole.info; + console.warn = originalConsole.warn; + console.error = originalConsole.error; + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +}; diff --git a/ts/helpers.smartconfig.ts b/ts/helpers.smartconfig.ts new file mode 100644 index 0000000..8a515ea --- /dev/null +++ b/ts/helpers.smartconfig.ts @@ -0,0 +1,192 @@ +import * as plugins from "./plugins.js"; +import { rename, writeFile } from "fs/promises"; + +export const CLI_NAMESPACE = "@git.zone/cli"; + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +export const getSmartconfigPath = (cwd: string = process.cwd()): string => { + return plugins.path.join(cwd, ".smartconfig.json"); +}; + +export const readSmartconfigFile = async ( + cwd: string = process.cwd(), +): Promise> => { + const smartconfigPath = getSmartconfigPath(cwd); + if (!(await plugins.smartfs.file(smartconfigPath).exists())) { + return {}; + } + + const content = (await plugins.smartfs + .file(smartconfigPath) + .encoding("utf8") + .read()) as string; + if (content.trim() === "") { + return {}; + } + return JSON.parse(content); +}; + +export const writeSmartconfigFile = async ( + data: Record, + cwd: string = process.cwd(), +): Promise => { + const smartconfigPath = getSmartconfigPath(cwd); + const tempPath = `${smartconfigPath}.tmp-${Date.now()}`; + const content = JSON.stringify(data, null, 2); + await writeFile(tempPath, content, "utf8"); + await rename(tempPath, smartconfigPath); +}; + +export const normalizeCliConfigPath = (configPath: string): string => { + const trimmedPath = configPath.trim(); + if (!trimmedPath || trimmedPath === CLI_NAMESPACE) { + return ""; + } + + if (trimmedPath.startsWith(`${CLI_NAMESPACE}.`)) { + return trimmedPath.slice(`${CLI_NAMESPACE}.`.length); + } + + return trimmedPath; +}; + +export const getCliConfigPathSegments = (configPath: string): string[] => { + const normalizedPath = normalizeCliConfigPath(configPath); + if (!normalizedPath) { + return []; + } + + return normalizedPath + .split(".") + .map((segment) => segment.trim()) + .filter(Boolean); +}; + +export const getCliNamespaceConfig = ( + smartconfigData: Record, +): Record => { + const cliConfig = smartconfigData[CLI_NAMESPACE]; + if (isPlainObject(cliConfig)) { + return cliConfig; + } + return {}; +}; + +export const getCliConfigValueFromData = ( + smartconfigData: Record, + configPath: string, +): any => { + const segments = getCliConfigPathSegments(configPath); + let currentValue: any = getCliNamespaceConfig(smartconfigData); + + for (const segment of segments) { + if (!isPlainObject(currentValue) && !Array.isArray(currentValue)) { + return undefined; + } + currentValue = (currentValue as any)?.[segment]; + } + + return currentValue; +}; + +export const getCliConfigValue = async ( + configPath: string, + defaultValue: T, + cwd: string = process.cwd(), +): Promise => { + const smartconfigData = await readSmartconfigFile(cwd); + const configValue = getCliConfigValueFromData(smartconfigData, configPath); + + if (configValue === undefined) { + return defaultValue; + } + + if (isPlainObject(defaultValue) && isPlainObject(configValue)) { + return { + ...defaultValue, + ...configValue, + } as T; + } + + return configValue as T; +}; + +export const setCliConfigValueInData = ( + smartconfigData: Record, + configPath: string, + value: any, +): Record => { + const segments = getCliConfigPathSegments(configPath); + + if (!isPlainObject(smartconfigData[CLI_NAMESPACE])) { + smartconfigData[CLI_NAMESPACE] = {}; + } + + if (segments.length === 0) { + smartconfigData[CLI_NAMESPACE] = value; + return smartconfigData; + } + + let currentValue = smartconfigData[CLI_NAMESPACE]; + for (const segment of segments.slice(0, -1)) { + if (!isPlainObject(currentValue[segment])) { + currentValue[segment] = {}; + } + currentValue = currentValue[segment]; + } + + currentValue[segments[segments.length - 1]] = value; + return smartconfigData; +}; + +export const unsetCliConfigValueInData = ( + smartconfigData: Record, + configPath: string, +): boolean => { + const segments = getCliConfigPathSegments(configPath); + if (segments.length === 0) { + if (smartconfigData[CLI_NAMESPACE] !== undefined) { + delete smartconfigData[CLI_NAMESPACE]; + return true; + } + return false; + } + + const parentSegments = segments.slice(0, -1); + let currentValue: any = getCliNamespaceConfig(smartconfigData); + const objectPath: Array> = [currentValue]; + + for (const segment of parentSegments) { + if (!isPlainObject(currentValue[segment])) { + return false; + } + currentValue = currentValue[segment]; + objectPath.push(currentValue); + } + + const lastSegment = segments[segments.length - 1]; + if (!(lastSegment in currentValue)) { + return false; + } + + delete currentValue[lastSegment]; + + for (let i = objectPath.length - 1; i >= 1; i--) { + if (Object.keys(objectPath[i]).length > 0) { + break; + } + + const parentObject = objectPath[i - 1]; + const parentKey = parentSegments[i - 1]; + delete parentObject[parentKey]; + } + + if (Object.keys(getCliNamespaceConfig(smartconfigData)).length === 0) { + delete smartconfigData[CLI_NAMESPACE]; + } + + return true; +}; diff --git a/ts/mod_commit/index.ts b/ts/mod_commit/index.ts index cd27b40..cb23bac 100644 --- a/ts/mod_commit/index.ts +++ b/ts/mod_commit/index.ts @@ -1,13 +1,41 @@ // this file contains code to create commits in a consistent way -import * as plugins from './mod.plugins.js'; -import * as paths from '../paths.js'; -import { logger } from '../gitzone.logging.js'; -import * as helpers from './mod.helpers.js'; -import * as ui from './mod.ui.js'; -import { ReleaseConfig } from '../mod_config/classes.releaseconfig.js'; +import * as plugins from "./mod.plugins.js"; +import * as paths from "../paths.js"; +import { logger } from "../gitzone.logging.js"; +import * as helpers from "./mod.helpers.js"; +import * as ui from "./mod.ui.js"; +import { ReleaseConfig } from "../mod_config/classes.releaseconfig.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { + getCliMode, + printJson, + runWithSuppressedOutput, +} from "../helpers.climode.js"; export const run = async (argvArg: any) => { + const mode = await getCliMode(argvArg); + const subcommand = argvArg._?.[1]; + + if (mode.help || subcommand === "help") { + showHelp(mode); + return; + } + + if (subcommand === "recommend") { + await handleRecommend(mode); + return; + } + + if (mode.json) { + printJson({ + ok: false, + error: + "JSON output is only supported for the read-only recommendation flow. Use `gitzone commit recommend --json`.", + }); + return; + } + // Read commit config from .smartconfig.json const smartconfigInstance = new plugins.smartconfig.Smartconfig(); const gitzoneConfig = smartconfigInstance.dataFor<{ @@ -15,7 +43,7 @@ export const run = async (argvArg: any) => { alwaysTest?: boolean; alwaysBuild?: boolean; }; - }>('@git.zone/cli', {}); + }>("@git.zone/cli", {}); const commitConfig = gitzoneConfig.commit || {}; // Check flags and merge with config options @@ -27,10 +55,12 @@ export const run = async (argvArg: any) => { if (wantsRelease) { releaseConfig = await ReleaseConfig.fromCwd(); if (!releaseConfig.hasRegistries()) { - logger.log('error', 'No release registries configured.'); - console.log(''); - console.log(' Run `gitzone config add ` to add registries.'); - console.log(''); + logger.log("error", "No release registries configured."); + console.log(""); + console.log( + " Run `gitzone config add ` to add registries.", + ); + console.log(""); process.exit(1); } } @@ -47,26 +77,26 @@ export const run = async (argvArg: any) => { }); if (argvArg.format) { - const formatMod = await import('../mod_format/index.js'); + const formatMod = await import("../mod_format/index.js"); await formatMod.run(); } // Run tests early to fail fast before analysis if (wantsTest) { - ui.printHeader('๐Ÿงช Running tests...'); + ui.printHeader("๐Ÿงช Running tests..."); const smartshellForTest = new plugins.smartshell.Smartshell({ - executor: 'bash', + executor: "bash", sourceFilePaths: [], }); - const testResult = await smartshellForTest.exec('pnpm test'); + const testResult = await smartshellForTest.exec("pnpm test"); if (testResult.exitCode !== 0) { - logger.log('error', 'Tests failed. Aborting commit.'); + logger.log("error", "Tests failed. Aborting commit."); process.exit(1); } - logger.log('success', 'All tests passed.'); + logger.log("success", "All tests passed."); } - ui.printHeader('๐Ÿ” Analyzing repository changes...'); + ui.printHeader("๐Ÿ” Analyzing repository changes..."); const aidoc = new plugins.tsdoc.AiDoc(); await aidoc.start(); @@ -79,58 +109,63 @@ export const run = async (argvArg: any) => { recommendedNextVersion: nextCommitObject.recommendedNextVersion, recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel, recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope, - recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage, + recommendedNextVersionMessage: + nextCommitObject.recommendedNextVersionMessage, }); let answerBucket: plugins.smartinteract.AnswerBucket; // Check if -y/--yes flag is set AND version is not a breaking change // Breaking changes (major version bumps) always require manual confirmation - const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === 'BREAKING CHANGE'; + const isBreakingChange = + nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE"; const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange; if (canAutoAccept) { // Auto-mode: create AnswerBucket programmatically - logger.log('info', 'โœ“ Auto-accepting AI recommendations (--yes flag)'); + logger.log("info", "โœ“ Auto-accepting AI recommendations (--yes flag)"); answerBucket = new plugins.smartinteract.AnswerBucket(); answerBucket.addAnswer({ - name: 'commitType', + name: "commitType", value: nextCommitObject.recommendedNextVersionLevel, }); answerBucket.addAnswer({ - name: 'commitScope', + name: "commitScope", value: nextCommitObject.recommendedNextVersionScope, }); answerBucket.addAnswer({ - name: 'commitDescription', + name: "commitDescription", value: nextCommitObject.recommendedNextVersionMessage, }); answerBucket.addAnswer({ - name: 'pushToOrigin', + name: "pushToOrigin", value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided }); answerBucket.addAnswer({ - name: 'createRelease', + name: "createRelease", value: wantsRelease, }); } else { // Warn if --yes was provided but we're requiring confirmation due to breaking change if (isBreakingChange && (argvArg.y || argvArg.yes)) { - logger.log('warn', 'โš ๏ธ BREAKING CHANGE detected - manual confirmation required'); + logger.log( + "warn", + "โš ๏ธ BREAKING CHANGE detected - manual confirmation required", + ); } // Interactive mode: prompt user for input const commitInteract = new plugins.smartinteract.SmartInteract(); commitInteract.addQuestions([ { - type: 'list', + type: "list", name: `commitType`, message: `Choose TYPE of the commit:`, choices: [`fix`, `feat`, `BREAKING CHANGE`], default: nextCommitObject.recommendedNextVersionLevel, }, { - type: 'input', + type: "input", name: `commitScope`, message: `What is the SCOPE of the commit:`, default: nextCommitObject.recommendedNextVersionScope, @@ -142,13 +177,13 @@ export const run = async (argvArg: any) => { default: nextCommitObject.recommendedNextVersionMessage, }, { - type: 'confirm', + type: "confirm", name: `pushToOrigin`, message: `Do you want to push this version now?`, default: true, }, { - type: 'confirm', + type: "confirm", name: `createRelease`, message: `Do you want to publish to npm registries?`, default: wantsRelease, @@ -157,40 +192,50 @@ export const run = async (argvArg: any) => { answerBucket = await commitInteract.runQueue(); } const commitString = createCommitStringFromAnswerBucket(answerBucket); - const commitVersionType = (() => { - switch (answerBucket.getAnswerFor('commitType')) { - case 'fix': - return 'patch'; - case 'feat': - return 'minor'; - case 'BREAKING CHANGE': - return 'major'; - } - })(); + const commitType = answerBucket.getAnswerFor("commitType"); + let commitVersionType: helpers.VersionType; + switch (commitType) { + case "fix": + commitVersionType = "patch"; + break; + case "feat": + commitVersionType = "minor"; + break; + case "BREAKING CHANGE": + commitVersionType = "major"; + break; + default: + throw new Error(`Unsupported commit type: ${commitType}`); + } - ui.printHeader('โœจ Creating Semantic Commit'); + ui.printHeader("โœจ Creating Semantic Commit"); ui.printCommitMessage(commitString); const smartshellInstance = new plugins.smartshell.Smartshell({ - executor: 'bash', + executor: "bash", sourceFilePaths: [], }); // Load release config if user wants to release (interactively selected) - if (answerBucket.getAnswerFor('createRelease') && !releaseConfig) { + if (answerBucket.getAnswerFor("createRelease") && !releaseConfig) { releaseConfig = await ReleaseConfig.fromCwd(); if (!releaseConfig.hasRegistries()) { - logger.log('error', 'No release registries configured.'); - console.log(''); - console.log(' Run `gitzone config add ` to add registries.'); - console.log(''); + logger.log("error", "No release registries configured."); + console.log(""); + console.log( + " Run `gitzone config add ` to add registries.", + ); + console.log(""); process.exit(1); } } // Determine total steps based on options // Note: test runs early (like format) so not counted in numbered steps - const willPush = answerBucket.getAnswerFor('pushToOrigin') && !(process.env.CI === 'true'); - const willRelease = answerBucket.getAnswerFor('createRelease') && releaseConfig?.hasRegistries(); + const willPush = + answerBucket.getAnswerFor("pushToOrigin") && !(process.env.CI === "true"); + const willRelease = + answerBucket.getAnswerFor("createRelease") && + releaseConfig?.hasRegistries(); let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version if (wantsBuild) totalSteps += 2; // build step + verification step if (willPush) totalSteps++; @@ -199,96 +244,156 @@ export const run = async (argvArg: any) => { // Step 1: Baking commitinfo currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ”ง Baking commit info into code', 'in-progress'); + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ”ง Baking commit info into code", + "in-progress", + ); const commitInfo = new plugins.commitinfo.CommitInfo( paths.cwd, commitVersionType, ); await commitInfo.writeIntoPotentialDirs(); - ui.printStep(currentStep, totalSteps, '๐Ÿ”ง Baking commit info into code', 'done'); + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ”ง Baking commit info into code", + "done", + ); // Step 2: Writing changelog currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ“„ Generating changelog.md', 'in-progress'); - let changelog = nextCommitObject.changelog; + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ“„ Generating changelog.md", + "in-progress", + ); + let changelog = nextCommitObject.changelog || "# Changelog\n"; changelog = changelog.replaceAll( - '{{nextVersion}}', + "{{nextVersion}}", (await commitInfo.getNextPlannedVersion()).versionString, ); changelog = changelog.replaceAll( - '{{nextVersionScope}}', - `${await answerBucket.getAnswerFor('commitType')}(${await answerBucket.getAnswerFor('commitScope')})`, + "{{nextVersionScope}}", + `${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`, ); changelog = changelog.replaceAll( - '{{nextVersionMessage}}', + "{{nextVersionMessage}}", nextCommitObject.recommendedNextVersionMessage, ); if (nextCommitObject.recommendedNextVersionDetails?.length > 0) { changelog = changelog.replaceAll( - '{{nextVersionDetails}}', - '- ' + nextCommitObject.recommendedNextVersionDetails.join('\n- '), + "{{nextVersionDetails}}", + "- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "), ); } else { - changelog = changelog.replaceAll('\n{{nextVersionDetails}}', ''); + changelog = changelog.replaceAll("\n{{nextVersionDetails}}", ""); } await plugins.smartfs .file(plugins.path.join(paths.cwd, `changelog.md`)) - .encoding('utf8') + .encoding("utf8") .write(changelog); - ui.printStep(currentStep, totalSteps, '๐Ÿ“„ Generating changelog.md', 'done'); + ui.printStep(currentStep, totalSteps, "๐Ÿ“„ Generating changelog.md", "done"); // Step 3: Staging files currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ“ฆ Staging files', 'in-progress'); + ui.printStep(currentStep, totalSteps, "๐Ÿ“ฆ Staging files", "in-progress"); await smartshellInstance.exec(`git add -A`); - ui.printStep(currentStep, totalSteps, '๐Ÿ“ฆ Staging files', 'done'); + ui.printStep(currentStep, totalSteps, "๐Ÿ“ฆ Staging files", "done"); // Step 4: Creating commit currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ’พ Creating git commit', 'in-progress'); + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ’พ Creating git commit", + "in-progress", + ); await smartshellInstance.exec(`git commit -m "${commitString}"`); - ui.printStep(currentStep, totalSteps, '๐Ÿ’พ Creating git commit', 'done'); + ui.printStep(currentStep, totalSteps, "๐Ÿ’พ Creating git commit", "done"); // Step 5: Bumping version currentStep++; const projectType = await helpers.detectProjectType(); - const newVersion = await helpers.bumpProjectVersion(projectType, commitVersionType, currentStep, totalSteps); + const newVersion = await helpers.bumpProjectVersion( + projectType, + commitVersionType, + currentStep, + totalSteps, + ); // Step 6: Run build (optional) if (wantsBuild) { currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ”จ Running build', 'in-progress'); - const buildResult = await smartshellInstance.exec('pnpm build'); + ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "in-progress"); + const buildResult = await smartshellInstance.exec("pnpm build"); if (buildResult.exitCode !== 0) { - ui.printStep(currentStep, totalSteps, '๐Ÿ”จ Running build', 'error'); - logger.log('error', 'Build failed. Aborting release.'); + ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "error"); + logger.log("error", "Build failed. Aborting release."); process.exit(1); } - ui.printStep(currentStep, totalSteps, '๐Ÿ”จ Running build', 'done'); + ui.printStep(currentStep, totalSteps, "๐Ÿ”จ Running build", "done"); // Step 7: Verify no uncommitted changes currentStep++; - ui.printStep(currentStep, totalSteps, '๐Ÿ” Verifying clean working tree', 'in-progress'); - const statusResult = await smartshellInstance.exec('git status --porcelain'); - if (statusResult.stdout.trim() !== '') { - ui.printStep(currentStep, totalSteps, '๐Ÿ” Verifying clean working tree', 'error'); - logger.log('error', 'Build produced uncommitted changes. This usually means build output is not gitignored.'); - logger.log('error', 'Uncommitted files:'); + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ” Verifying clean working tree", + "in-progress", + ); + const statusResult = await smartshellInstance.exec( + "git status --porcelain", + ); + if (statusResult.stdout.trim() !== "") { + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ” Verifying clean working tree", + "error", + ); + logger.log( + "error", + "Build produced uncommitted changes. This usually means build output is not gitignored.", + ); + logger.log("error", "Uncommitted files:"); console.log(statusResult.stdout); - logger.log('error', 'Aborting release. Please ensure build artifacts are in .gitignore'); + logger.log( + "error", + "Aborting release. Please ensure build artifacts are in .gitignore", + ); process.exit(1); } - ui.printStep(currentStep, totalSteps, '๐Ÿ” Verifying clean working tree', 'done'); + ui.printStep( + currentStep, + totalSteps, + "๐Ÿ” Verifying clean working tree", + "done", + ); } // Step: Push to remote (optional) const currentBranch = await helpers.detectCurrentBranch(); if (willPush) { currentStep++; - ui.printStep(currentStep, totalSteps, `๐Ÿš€ Pushing to origin/${currentBranch}`, 'in-progress'); - await smartshellInstance.exec(`git push origin ${currentBranch} --follow-tags`); - ui.printStep(currentStep, totalSteps, `๐Ÿš€ Pushing to origin/${currentBranch}`, 'done'); + ui.printStep( + currentStep, + totalSteps, + `๐Ÿš€ Pushing to origin/${currentBranch}`, + "in-progress", + ); + await smartshellInstance.exec( + `git push origin ${currentBranch} --follow-tags`, + ); + ui.printStep( + currentStep, + totalSteps, + `๐Ÿš€ Pushing to origin/${currentBranch}`, + "done", + ); } // Step 7: Publish to npm registries (optional) @@ -296,51 +401,173 @@ export const run = async (argvArg: any) => { if (willRelease && releaseConfig) { currentStep++; const registries = releaseConfig.getRegistries(); - ui.printStep(currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'in-progress'); + ui.printStep( + currentStep, + totalSteps, + `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, + "in-progress", + ); const accessLevel = releaseConfig.getAccessLevel(); for (const registry of registries) { try { - await smartshellInstance.exec(`npm publish --registry=${registry} --access=${accessLevel}`); + await smartshellInstance.exec( + `npm publish --registry=${registry} --access=${accessLevel}`, + ); releasedRegistries.push(registry); } catch (error) { - logger.log('error', `Failed to publish to ${registry}: ${error}`); + logger.log("error", `Failed to publish to ${registry}: ${error}`); } } if (releasedRegistries.length === registries.length) { - ui.printStep(currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'done'); + ui.printStep( + currentStep, + totalSteps, + `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, + "done", + ); } else { - ui.printStep(currentStep, totalSteps, `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'error'); + ui.printStep( + currentStep, + totalSteps, + `๐Ÿ“ฆ Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`, + "error", + ); } } - console.log(''); // Add spacing before summary + console.log(""); // Add spacing before summary // Get commit SHA for summary - const commitShaResult = await smartshellInstance.exec('git rev-parse --short HEAD'); + const commitShaResult = await smartshellInstance.exec( + "git rev-parse --short HEAD", + ); const commitSha = commitShaResult.stdout.trim(); // Print final summary ui.printSummary({ projectType, branch: currentBranch, - commitType: answerBucket.getAnswerFor('commitType'), - commitScope: answerBucket.getAnswerFor('commitScope'), - commitMessage: answerBucket.getAnswerFor('commitDescription'), + commitType: answerBucket.getAnswerFor("commitType"), + commitScope: answerBucket.getAnswerFor("commitScope"), + commitMessage: answerBucket.getAnswerFor("commitDescription"), newVersion: newVersion, commitSha: commitSha, pushed: willPush, released: releasedRegistries.length > 0, - releasedRegistries: releasedRegistries.length > 0 ? releasedRegistries : undefined, + releasedRegistries: + releasedRegistries.length > 0 ? releasedRegistries : undefined, }); }; +async function handleRecommend(mode: ICliMode): Promise { + const recommendationBuilder = async () => { + const aidoc = new plugins.tsdoc.AiDoc(); + await aidoc.start(); + try { + return await aidoc.buildNextCommitObject(paths.cwd); + } finally { + await aidoc.stop(); + } + }; + + const recommendation = mode.json + ? await runWithSuppressedOutput(recommendationBuilder) + : await recommendationBuilder(); + + if (mode.json) { + printJson(recommendation); + return; + } + + ui.printRecommendation({ + recommendedNextVersion: recommendation.recommendedNextVersion, + recommendedNextVersionLevel: recommendation.recommendedNextVersionLevel, + recommendedNextVersionScope: recommendation.recommendedNextVersionScope, + recommendedNextVersionMessage: recommendation.recommendedNextVersionMessage, + }); + + console.log( + `Suggested commit: ${recommendation.recommendedNextVersionLevel}(${recommendation.recommendedNextVersionScope}): ${recommendation.recommendedNextVersionMessage}`, + ); +} + const createCommitStringFromAnswerBucket = ( answerBucket: plugins.smartinteract.AnswerBucket, ) => { - const commitType = answerBucket.getAnswerFor('commitType'); - const commitScope = answerBucket.getAnswerFor('commitScope'); - const commitDescription = answerBucket.getAnswerFor('commitDescription'); + const commitType = answerBucket.getAnswerFor("commitType"); + const commitScope = answerBucket.getAnswerFor("commitScope"); + const commitDescription = answerBucket.getAnswerFor("commitDescription"); return `${commitType}(${commitScope}): ${commitDescription}`; }; + +export function showHelp(mode?: ICliMode): void { + if (mode?.json) { + printJson({ + command: "commit", + usage: "gitzone commit [recommend] [options]", + description: + "Creates semantic commits or emits a read-only recommendation.", + commands: [ + { + name: "recommend", + description: + "Generate a commit recommendation without mutating the repository", + }, + ], + flags: [ + { flag: "-y, --yes", description: "Auto-accept AI recommendations" }, + { flag: "-p, --push", description: "Push to origin after commit" }, + { flag: "-t, --test", description: "Run tests before the commit flow" }, + { + flag: "-b, --build", + description: "Run the build after the commit flow", + }, + { + flag: "-r, --release", + description: "Publish to configured registries after push", + }, + { + flag: "--format", + description: "Run gitzone format before committing", + }, + { + flag: "--json", + description: "Emit JSON for `commit recommend` only", + }, + ], + examples: [ + "gitzone commit recommend --json", + "gitzone commit -y", + "gitzone commit -ypbr", + ], + }); + return; + } + + console.log(""); + console.log("Usage: gitzone commit [recommend] [options]"); + console.log(""); + console.log("Commands:"); + console.log( + " recommend Generate a commit recommendation without mutating the repository", + ); + console.log(""); + console.log("Flags:"); + console.log(" -y, --yes Auto-accept AI recommendations"); + console.log(" -p, --push Push to origin after commit"); + console.log(" -t, --test Run tests before the commit flow"); + console.log(" -b, --build Run the build after the commit flow"); + console.log( + " -r, --release Publish to configured registries after push", + ); + console.log(" --format Run gitzone format before committing"); + console.log(" --json Emit JSON for `commit recommend` only"); + console.log(""); + console.log("Examples:"); + console.log(" gitzone commit recommend --json"); + console.log(" gitzone commit -y"); + console.log(" gitzone commit -ypbr"); + console.log(""); +} diff --git a/ts/mod_config/index.ts b/ts/mod_config/index.ts index 417ed33..433f32b 100644 --- a/ts/mod_config/index.ts +++ b/ts/mod_config/index.ts @@ -1,73 +1,116 @@ // gitzone config - manage release registry configuration -import * as plugins from './mod.plugins.js'; -import { ReleaseConfig } from './classes.releaseconfig.js'; -import { CommitConfig } from './classes.commitconfig.js'; -import { runFormatter, type ICheckResult } from '../mod_format/index.js'; +import * as plugins from "./mod.plugins.js"; +import { ReleaseConfig } from "./classes.releaseconfig.js"; +import { CommitConfig } from "./classes.commitconfig.js"; +import { runFormatter, type ICheckResult } from "../mod_format/index.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { getCliMode, printJson } from "../helpers.climode.js"; +import { + getCliConfigValueFromData, + readSmartconfigFile, + setCliConfigValueInData, + unsetCliConfigValueInData, + writeSmartconfigFile, +} from "../helpers.smartconfig.js"; export { ReleaseConfig, CommitConfig }; +const defaultCliMode: ICliMode = { + output: "human", + interactive: true, + json: false, + plain: false, + quiet: false, + yes: false, + help: false, + agent: false, + checkUpdates: true, + isTty: true, +}; + /** * Format .smartconfig.json with diff preview * Shows diff first, asks for confirmation, then applies */ -async function formatSmartconfigWithDiff(): Promise { +async function formatSmartconfigWithDiff(mode: ICliMode): Promise { + if (!mode.interactive) { + return; + } + // Check for diffs first - const checkResult = await runFormatter('smartconfig', { + const checkResult = (await runFormatter("smartconfig", { checkOnly: true, showDiff: true, - }) as ICheckResult | void; + })) as ICheckResult | void; if (checkResult && checkResult.hasDiff) { - const shouldApply = await plugins.smartinteract.SmartInteract.getCliConfirmation( - 'Apply formatting changes to .smartconfig.json?', - true - ); + const shouldApply = + await plugins.smartinteract.SmartInteract.getCliConfirmation( + "Apply formatting changes to .smartconfig.json?", + true, + ); if (shouldApply) { - await runFormatter('smartconfig', { silent: true }); + await runFormatter("smartconfig", { silent: true }); } } } export const run = async (argvArg: any) => { + const mode = await getCliMode(argvArg); const command = argvArg._?.[1]; const value = argvArg._?.[2]; + if (mode.help || command === "help") { + showHelp(mode); + return; + } + // If no command provided, show interactive menu if (!command) { + if (!mode.interactive) { + showHelp(mode); + return; + } await handleInteractiveMenu(); return; } switch (command) { - case 'show': - await handleShow(); + case "show": + await handleShow(mode); break; - case 'add': - await handleAdd(value); + case "add": + await handleAdd(value, mode); break; - case 'remove': - await handleRemove(value); + case "remove": + await handleRemove(value, mode); break; - case 'clear': - await handleClear(); + case "clear": + await handleClear(mode); break; - case 'access': - case 'accessLevel': - await handleAccessLevel(value); + case "access": + case "accessLevel": + await handleAccessLevel(value, mode); break; - case 'commit': - await handleCommit(argvArg._?.[2], argvArg._?.[3]); + case "commit": + await handleCommit(argvArg._?.[2], argvArg._?.[3], mode); break; - case 'services': - await handleServices(); + case "services": + await handleServices(mode); break; - case 'help': - showHelp(); + case "get": + await handleGet(value, mode); + break; + case "set": + await handleSet(value, argvArg._?.[3], mode); + break; + case "unset": + await handleUnset(value, mode); break; default: - plugins.logger.log('error', `Unknown command: ${command}`); - showHelp(); + plugins.logger.log("error", `Unknown command: ${command}`); + showHelp(mode); } }; @@ -75,55 +118,61 @@ export const run = async (argvArg: any) => { * Interactive menu for config command */ async function handleInteractiveMenu(): Promise { - console.log(''); - console.log('โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ'); - console.log('โ”‚ gitzone config - Project Configuration โ”‚'); - console.log('โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ'); - console.log(''); + console.log(""); + console.log( + "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ", + ); + console.log( + "โ”‚ gitzone config - Project Configuration โ”‚", + ); + console.log( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ", + ); + console.log(""); const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'list', - name: 'action', - message: 'What would you like to do?', - default: 'show', + type: "list", + name: "action", + message: "What would you like to do?", + default: "show", choices: [ - { name: 'Show current configuration', value: 'show' }, - { name: 'Add a registry', value: 'add' }, - { name: 'Remove a registry', value: 'remove' }, - { name: 'Clear all registries', value: 'clear' }, - { name: 'Set access level (public/private)', value: 'access' }, - { name: 'Configure commit options', value: 'commit' }, - { name: 'Configure services', value: 'services' }, - { name: 'Show help', value: 'help' }, + { name: "Show current configuration", value: "show" }, + { name: "Add a registry", value: "add" }, + { name: "Remove a registry", value: "remove" }, + { name: "Clear all registries", value: "clear" }, + { name: "Set access level (public/private)", value: "access" }, + { name: "Configure commit options", value: "commit" }, + { name: "Configure services", value: "services" }, + { name: "Show help", value: "help" }, ], }); const action = (response as any).value; switch (action) { - case 'show': - await handleShow(); + case "show": + await handleShow(defaultCliMode); break; - case 'add': - await handleAdd(); + case "add": + await handleAdd(undefined, defaultCliMode); break; - case 'remove': - await handleRemove(); + case "remove": + await handleRemove(undefined, defaultCliMode); break; - case 'clear': - await handleClear(); + case "clear": + await handleClear(defaultCliMode); break; - case 'access': - await handleAccessLevel(); + case "access": + await handleAccessLevel(undefined, defaultCliMode); break; - case 'commit': - await handleCommit(); + case "commit": + await handleCommit(undefined, undefined, defaultCliMode); break; - case 'services': - await handleServices(); + case "services": + await handleServices(defaultCliMode); break; - case 'help': + case "help": showHelp(); break; } @@ -132,50 +181,69 @@ async function handleInteractiveMenu(): Promise { /** * Show current registry configuration */ -async function handleShow(): Promise { +async function handleShow(mode: ICliMode): Promise { + if (mode.json) { + const smartconfigData = await readSmartconfigFile(); + printJson(getCliConfigValueFromData(smartconfigData, "")); + return; + } + const config = await ReleaseConfig.fromCwd(); const registries = config.getRegistries(); const accessLevel = config.getAccessLevel(); - console.log(''); - console.log('โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ'); - console.log('โ”‚ Release Configuration โ”‚'); - console.log('โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ'); - console.log(''); + console.log(""); + console.log( + "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ", + ); + console.log( + "โ”‚ Release Configuration โ”‚", + ); + console.log( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ", + ); + console.log(""); // Show access level - plugins.logger.log('info', `Access Level: ${accessLevel}`); - console.log(''); + plugins.logger.log("info", `Access Level: ${accessLevel}`); + console.log(""); if (registries.length === 0) { - plugins.logger.log('info', 'No release registries configured.'); - console.log(''); - console.log(' Run `gitzone config add ` to add one.'); - console.log(''); + plugins.logger.log("info", "No release registries configured."); + console.log(""); + console.log(" Run `gitzone config add ` to add one."); + console.log(""); } else { - plugins.logger.log('info', `Configured registries (${registries.length}):`); - console.log(''); + plugins.logger.log("info", `Configured registries (${registries.length}):`); + console.log(""); registries.forEach((url, index) => { console.log(` ${index + 1}. ${url}`); }); - console.log(''); + console.log(""); } } /** * Add a registry URL */ -async function handleAdd(url?: string): Promise { +async function handleAdd( + url: string | undefined, + mode: ICliMode, +): Promise { if (!url) { + if (!mode.interactive) { + throw new Error("Registry URL is required in non-interactive mode"); + } + // Interactive mode const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'input', - name: 'registryUrl', - message: 'Enter registry URL:', - default: 'https://registry.npmjs.org', + type: "input", + name: "registryUrl", + message: "Enter registry URL:", + default: "https://registry.npmjs.org", validate: (input: string) => { - return !!(input && input.trim() !== ''); + return !!(input && input.trim() !== ""); }, }); url = (response as any).value; @@ -186,32 +254,48 @@ async function handleAdd(url?: string): Promise { if (added) { await config.save(); - plugins.logger.log('success', `Added registry: ${url}`); - await formatSmartconfigWithDiff(); + if (mode.json) { + printJson({ + ok: true, + action: "add", + registry: url, + registries: config.getRegistries(), + }); + return; + } + plugins.logger.log("success", `Added registry: ${url}`); + await formatSmartconfigWithDiff(mode); } else { - plugins.logger.log('warn', `Registry already exists: ${url}`); + plugins.logger.log("warn", `Registry already exists: ${url}`); } } /** * Remove a registry URL */ -async function handleRemove(url?: string): Promise { +async function handleRemove( + url: string | undefined, + mode: ICliMode, +): Promise { const config = await ReleaseConfig.fromCwd(); const registries = config.getRegistries(); if (registries.length === 0) { - plugins.logger.log('warn', 'No registries configured to remove.'); + plugins.logger.log("warn", "No registries configured to remove."); return; } if (!url) { + if (!mode.interactive) { + throw new Error("Registry URL is required in non-interactive mode"); + } + // Interactive mode - show list to select from const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'list', - name: 'registryUrl', - message: 'Select registry to remove:', + type: "list", + name: "registryUrl", + message: "Select registry to remove:", choices: registries, default: registries[0], }); @@ -222,99 +306,135 @@ async function handleRemove(url?: string): Promise { if (removed) { await config.save(); - plugins.logger.log('success', `Removed registry: ${url}`); - await formatSmartconfigWithDiff(); + if (mode.json) { + printJson({ + ok: true, + action: "remove", + registry: url, + registries: config.getRegistries(), + }); + return; + } + plugins.logger.log("success", `Removed registry: ${url}`); + await formatSmartconfigWithDiff(mode); } else { - plugins.logger.log('warn', `Registry not found: ${url}`); + plugins.logger.log("warn", `Registry not found: ${url}`); } } /** * Clear all registries */ -async function handleClear(): Promise { +async function handleClear(mode: ICliMode): Promise { const config = await ReleaseConfig.fromCwd(); if (!config.hasRegistries()) { - plugins.logger.log('info', 'No registries to clear.'); + plugins.logger.log("info", "No registries to clear."); return; } // Confirm before clearing - const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( - 'Clear all configured registries?', - false - ); + const confirmed = mode.interactive + ? await plugins.smartinteract.SmartInteract.getCliConfirmation( + "Clear all configured registries?", + false, + ) + : true; if (confirmed) { config.clearRegistries(); await config.save(); - plugins.logger.log('success', 'All registries cleared.'); - await formatSmartconfigWithDiff(); + if (mode.json) { + printJson({ ok: true, action: "clear", registries: [] }); + return; + } + plugins.logger.log("success", "All registries cleared."); + await formatSmartconfigWithDiff(mode); } else { - plugins.logger.log('info', 'Operation cancelled.'); + plugins.logger.log("info", "Operation cancelled."); } } /** * Set or toggle access level */ -async function handleAccessLevel(level?: string): Promise { +async function handleAccessLevel( + level: string | undefined, + mode: ICliMode, +): Promise { const config = await ReleaseConfig.fromCwd(); const currentLevel = config.getAccessLevel(); if (!level) { + if (!mode.interactive) { + throw new Error("Access level is required in non-interactive mode"); + } + // Interactive mode - toggle or ask const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'list', - name: 'accessLevel', - message: 'Select npm access level for publishing:', - choices: ['public', 'private'], + type: "list", + name: "accessLevel", + message: "Select npm access level for publishing:", + choices: ["public", "private"], default: currentLevel, }); level = (response as any).value; } // Validate the level - if (level !== 'public' && level !== 'private') { - plugins.logger.log('error', `Invalid access level: ${level}. Must be 'public' or 'private'.`); + if (level !== "public" && level !== "private") { + plugins.logger.log( + "error", + `Invalid access level: ${level}. Must be 'public' or 'private'.`, + ); return; } if (level === currentLevel) { - plugins.logger.log('info', `Access level is already set to: ${level}`); + plugins.logger.log("info", `Access level is already set to: ${level}`); return; } - config.setAccessLevel(level as 'public' | 'private'); + config.setAccessLevel(level as "public" | "private"); await config.save(); - plugins.logger.log('success', `Access level set to: ${level}`); - await formatSmartconfigWithDiff(); + if (mode.json) { + printJson({ ok: true, action: "access", accessLevel: level }); + return; + } + plugins.logger.log("success", `Access level set to: ${level}`); + await formatSmartconfigWithDiff(mode); } /** * Handle commit configuration */ -async function handleCommit(setting?: string, value?: string): Promise { +async function handleCommit( + setting: string | undefined, + value: string | undefined, + mode: ICliMode, +): Promise { const config = await CommitConfig.fromCwd(); // No setting = interactive mode if (!setting) { + if (!mode.interactive) { + throw new Error("Commit setting is required in non-interactive mode"); + } await handleCommitInteractive(config); return; } // Direct setting switch (setting) { - case 'alwaysTest': - await handleCommitSetting(config, 'alwaysTest', value); + case "alwaysTest": + await handleCommitSetting(config, "alwaysTest", value, mode); break; - case 'alwaysBuild': - await handleCommitSetting(config, 'alwaysBuild', value); + case "alwaysBuild": + await handleCommitSetting(config, "alwaysBuild", value, mode); break; default: - plugins.logger.log('error', `Unknown commit setting: ${setting}`); + plugins.logger.log("error", `Unknown commit setting: ${setting}`); showCommitHelp(); } } @@ -323,109 +443,297 @@ async function handleCommit(setting?: string, value?: string): Promise { * Interactive commit configuration */ async function handleCommitInteractive(config: CommitConfig): Promise { - console.log(''); - console.log('โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ'); - console.log('โ”‚ Commit Configuration โ”‚'); - console.log('โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ'); - console.log(''); + console.log(""); + console.log( + "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ", + ); + console.log( + "โ”‚ Commit Configuration โ”‚", + ); + console.log( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ", + ); + console.log(""); const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'checkbox', - name: 'commitOptions', - message: 'Select commit options to enable:', + type: "checkbox", + name: "commitOptions", + message: "Select commit options to enable:", choices: [ - { name: 'Always run tests before commit (-t)', value: 'alwaysTest' }, - { name: 'Always build after commit (-b)', value: 'alwaysBuild' }, + { name: "Always run tests before commit (-t)", value: "alwaysTest" }, + { name: "Always build after commit (-b)", value: "alwaysBuild" }, ], default: [ - ...(config.getAlwaysTest() ? ['alwaysTest'] : []), - ...(config.getAlwaysBuild() ? ['alwaysBuild'] : []), + ...(config.getAlwaysTest() ? ["alwaysTest"] : []), + ...(config.getAlwaysBuild() ? ["alwaysBuild"] : []), ], }); const selected = (response as any).value || []; - config.setAlwaysTest(selected.includes('alwaysTest')); - config.setAlwaysBuild(selected.includes('alwaysBuild')); + config.setAlwaysTest(selected.includes("alwaysTest")); + config.setAlwaysBuild(selected.includes("alwaysBuild")); await config.save(); - plugins.logger.log('success', 'Commit configuration updated'); - await formatSmartconfigWithDiff(); + plugins.logger.log("success", "Commit configuration updated"); + await formatSmartconfigWithDiff(defaultCliMode); } /** * Set a specific commit setting */ -async function handleCommitSetting(config: CommitConfig, setting: string, value?: string): Promise { +async function handleCommitSetting( + config: CommitConfig, + setting: string, + value: string | undefined, + mode: ICliMode, +): Promise { // Parse boolean value - const boolValue = value === 'true' || value === '1' || value === 'on'; + const boolValue = value === "true" || value === "1" || value === "on"; - if (setting === 'alwaysTest') { + if (setting === "alwaysTest") { config.setAlwaysTest(boolValue); - } else if (setting === 'alwaysBuild') { + } else if (setting === "alwaysBuild") { config.setAlwaysBuild(boolValue); } await config.save(); - plugins.logger.log('success', `Set ${setting} to ${boolValue}`); - await formatSmartconfigWithDiff(); + if (mode.json) { + printJson({ ok: true, action: "commit", setting, value: boolValue }); + return; + } + plugins.logger.log("success", `Set ${setting} to ${boolValue}`); + await formatSmartconfigWithDiff(mode); } /** * Show help for commit subcommand */ function showCommitHelp(): void { - console.log(''); - console.log('Usage: gitzone config commit [setting] [value]'); - console.log(''); - console.log('Settings:'); - console.log(' alwaysTest [true|false] Always run tests before commit'); - console.log(' alwaysBuild [true|false] Always build after commit'); - console.log(''); - console.log('Examples:'); - console.log(' gitzone config commit # Interactive mode'); - console.log(' gitzone config commit alwaysTest true'); - console.log(' gitzone config commit alwaysBuild false'); - console.log(''); + console.log(""); + console.log("Usage: gitzone config commit [setting] [value]"); + console.log(""); + console.log("Settings:"); + console.log(" alwaysTest [true|false] Always run tests before commit"); + console.log(" alwaysBuild [true|false] Always build after commit"); + console.log(""); + console.log("Examples:"); + console.log(" gitzone config commit # Interactive mode"); + console.log(" gitzone config commit alwaysTest true"); + console.log(" gitzone config commit alwaysBuild false"); + console.log(""); } /** * Handle services configuration */ -async function handleServices(): Promise { +async function handleServices(mode: ICliMode): Promise { + if (!mode.interactive) { + throw new Error( + "Use `gitzone services config --json` or `gitzone services set ...` in non-interactive mode", + ); + } + // Import and use ServiceManager's configureServices - const { ServiceManager } = await import('../mod_services/classes.servicemanager.js'); + const { ServiceManager } = + await import("../mod_services/classes.servicemanager.js"); const serviceManager = new ServiceManager(); await serviceManager.init(); await serviceManager.configureServices(); } +async function handleGet( + configPath: string | undefined, + mode: ICliMode, +): Promise { + if (!configPath) { + throw new Error("Configuration path is required"); + } + + const smartconfigData = await readSmartconfigFile(); + const value = getCliConfigValueFromData(smartconfigData, configPath); + + if (mode.json) { + printJson({ path: configPath, value, exists: value !== undefined }); + return; + } + + if (value === undefined) { + plugins.logger.log("warn", `No value set for ${configPath}`); + return; + } + + if (typeof value === "string") { + console.log(value); + return; + } + + printJson(value); +} + +async function handleSet( + configPath: string | undefined, + rawValue: string | undefined, + mode: ICliMode, +): Promise { + if (!configPath) { + throw new Error("Configuration path is required"); + } + if (rawValue === undefined) { + throw new Error("Configuration value is required"); + } + + const smartconfigData = await readSmartconfigFile(); + const parsedValue = parseConfigValue(rawValue); + setCliConfigValueInData(smartconfigData, configPath, parsedValue); + await writeSmartconfigFile(smartconfigData); + + if (mode.json) { + printJson({ + ok: true, + action: "set", + path: configPath, + value: parsedValue, + }); + return; + } + + plugins.logger.log("success", `Set ${configPath}`); +} + +async function handleUnset( + configPath: string | undefined, + mode: ICliMode, +): Promise { + if (!configPath) { + throw new Error("Configuration path is required"); + } + + const smartconfigData = await readSmartconfigFile(); + const removed = unsetCliConfigValueInData(smartconfigData, configPath); + if (!removed) { + if (mode.json) { + printJson({ + ok: false, + action: "unset", + path: configPath, + removed: false, + }); + return; + } + + plugins.logger.log("warn", `No value set for ${configPath}`); + return; + } + + await writeSmartconfigFile(smartconfigData); + + if (mode.json) { + printJson({ ok: true, action: "unset", path: configPath, removed: true }); + return; + } + + plugins.logger.log("success", `Unset ${configPath}`); +} + +function parseConfigValue(rawValue: string): any { + const trimmedValue = rawValue.trim(); + if (trimmedValue === "true") { + return true; + } + if (trimmedValue === "false") { + return false; + } + if (trimmedValue === "null") { + return null; + } + if (/^-?\d+(\.\d+)?$/.test(trimmedValue)) { + return Number(trimmedValue); + } + if ( + (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || + (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) || + (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) + ) { + return JSON.parse(trimmedValue); + } + return rawValue; +} + /** * Show help for config command */ -function showHelp(): void { - console.log(''); - console.log('Usage: gitzone config [options]'); - console.log(''); - console.log('Commands:'); - console.log(' show Display current release configuration'); - console.log(' add [url] Add a registry URL'); - console.log(' remove [url] Remove a registry URL'); - console.log(' clear Clear all registries'); - console.log(' access [public|private] Set npm access level for publishing'); - console.log(' commit [setting] [value] Configure commit options'); - console.log(' services Configure which services are enabled'); - console.log(''); - console.log('Examples:'); - console.log(' gitzone config show'); - console.log(' gitzone config add https://registry.npmjs.org'); - console.log(' gitzone config add https://verdaccio.example.com'); - console.log(' gitzone config remove https://registry.npmjs.org'); - console.log(' gitzone config clear'); - console.log(' gitzone config access public'); - console.log(' gitzone config access private'); - console.log(' gitzone config commit # Interactive'); - console.log(' gitzone config commit alwaysTest true'); - console.log(' gitzone config services # Interactive'); - console.log(''); +export function showHelp(mode?: ICliMode): void { + if (mode?.json) { + printJson({ + command: "config", + usage: "gitzone config [options]", + commands: [ + { + name: "show", + description: "Display current @git.zone/cli configuration", + }, + { name: "get ", description: "Read a single config value" }, + { name: "set ", description: "Write a config value" }, + { name: "unset ", description: "Delete a config value" }, + { name: "add [url]", description: "Add a release registry" }, + { name: "remove [url]", description: "Remove a release registry" }, + { name: "clear", description: "Clear all release registries" }, + { + name: "access [public|private]", + description: "Set npm publish access level", + }, + { + name: "commit ", + description: "Set commit defaults", + }, + ], + examples: [ + "gitzone config show --json", + "gitzone config get release.accessLevel", + "gitzone config set cli.interactive false", + "gitzone config set cli.output json", + ], + }); + return; + } + + console.log(""); + console.log("Usage: gitzone config [options]"); + console.log(""); + console.log("Commands:"); + console.log( + " show Display current @git.zone/cli configuration", + ); + console.log(" get Read a single config value"); + console.log(" set Write a config value"); + console.log(" unset Delete a config value"); + console.log(" add [url] Add a registry URL"); + console.log(" remove [url] Remove a registry URL"); + console.log(" clear Clear all registries"); + console.log( + " access [public|private] Set npm access level for publishing", + ); + console.log(" commit [setting] [value] Configure commit options"); + console.log( + " services Configure which services are enabled", + ); + console.log(""); + console.log("Examples:"); + console.log(" gitzone config show"); + console.log(" gitzone config show --json"); + console.log(" gitzone config get release.accessLevel"); + console.log(" gitzone config set cli.interactive false"); + console.log(" gitzone config set cli.output json"); + console.log(" gitzone config unset cli.output"); + console.log(" gitzone config add https://registry.npmjs.org"); + console.log(" gitzone config add https://verdaccio.example.com"); + console.log(" gitzone config remove https://registry.npmjs.org"); + console.log(" gitzone config clear"); + console.log(" gitzone config access public"); + console.log(" gitzone config access private"); + console.log(" gitzone config commit # Interactive"); + console.log(" gitzone config commit alwaysTest true"); + console.log(" gitzone config services # Interactive"); + console.log(""); } diff --git a/ts/mod_format/classes.formatcontext.ts b/ts/mod_format/classes.formatcontext.ts index 1a511f5..18f5573 100644 --- a/ts/mod_format/classes.formatcontext.ts +++ b/ts/mod_format/classes.formatcontext.ts @@ -1,14 +1,31 @@ -import * as plugins from './mod.plugins.js'; -import { FormatStats } from './classes.formatstats.js'; +import * as plugins from "./mod.plugins.js"; +import { FormatStats } from "./classes.formatstats.js"; + +interface IFormatContextOptions { + interactive?: boolean; + jsonOutput?: boolean; +} export class FormatContext { private formatStats: FormatStats; + private interactive: boolean; + private jsonOutput: boolean; - constructor() { + constructor(options: IFormatContextOptions = {}) { this.formatStats = new FormatStats(); + this.interactive = options.interactive ?? true; + this.jsonOutput = options.jsonOutput ?? false; } getFormatStats(): FormatStats { return this.formatStats; } + + isInteractive(): boolean { + return this.interactive; + } + + isJsonOutput(): boolean { + return this.jsonOutput; + } } diff --git a/ts/mod_format/formatters/smartconfig.formatter.ts b/ts/mod_format/formatters/smartconfig.formatter.ts index 97d1c10..3b33f17 100644 --- a/ts/mod_format/formatters/smartconfig.formatter.ts +++ b/ts/mod_format/formatters/smartconfig.formatter.ts @@ -1,7 +1,7 @@ -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'; +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"; /** * Migrates .smartconfig.json from old namespace keys to new package-scoped keys @@ -9,11 +9,11 @@ import { logger, logVerbose } from '../../gitzone.logging.js'; const migrateNamespaceKeys = (smartconfigJson: 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' }, + { 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 (smartconfigJson[oldKey]) { @@ -36,36 +36,37 @@ const migrateNamespaceKeys = (smartconfigJson: any): boolean => { * Migrates npmAccessLevel from @ship.zone/szci to @git.zone/cli.release.accessLevel */ const migrateAccessLevel = (smartconfigJson: any): boolean => { - const szciConfig = smartconfigJson['@ship.zone/szci']; + const szciConfig = smartconfigJson["@ship.zone/szci"]; if (!szciConfig?.npmAccessLevel) { return false; } - const gitzoneConfig = smartconfigJson['@git.zone/cli'] || {}; + const gitzoneConfig = smartconfigJson["@git.zone/cli"] || {}; if (gitzoneConfig?.release?.accessLevel) { delete szciConfig.npmAccessLevel; return true; } - if (!smartconfigJson['@git.zone/cli']) { - smartconfigJson['@git.zone/cli'] = {}; + if (!smartconfigJson["@git.zone/cli"]) { + smartconfigJson["@git.zone/cli"] = {}; } - if (!smartconfigJson['@git.zone/cli'].release) { - smartconfigJson['@git.zone/cli'].release = {}; + if (!smartconfigJson["@git.zone/cli"].release) { + smartconfigJson["@git.zone/cli"].release = {}; } - smartconfigJson['@git.zone/cli'].release.accessLevel = szciConfig.npmAccessLevel; + smartconfigJson["@git.zone/cli"].release.accessLevel = + szciConfig.npmAccessLevel; delete szciConfig.npmAccessLevel; return true; }; -const CONFIG_FILE = '.smartconfig.json'; +const CONFIG_FILE = ".smartconfig.json"; export class SmartconfigFormatter extends BaseFormatter { get name(): string { - return 'smartconfig'; + return "smartconfig"; } async analyze(): Promise { @@ -76,13 +77,13 @@ export class SmartconfigFormatter extends BaseFormatter { // This formatter only operates on .smartconfig.json. const exists = await plugins.smartfs.file(CONFIG_FILE).exists(); if (!exists) { - logVerbose('.smartconfig.json does not exist, skipping'); + logVerbose(".smartconfig.json does not exist, skipping"); return changes; } const currentContent = (await plugins.smartfs .file(CONFIG_FILE) - .encoding('utf8') + .encoding("utf8") .read()) as string; const smartconfigJson = JSON.parse(currentContent); @@ -92,21 +93,21 @@ export class SmartconfigFormatter extends BaseFormatter { migrateAccessLevel(smartconfigJson); // Ensure namespaces exist - if (!smartconfigJson['@git.zone/cli']) { - smartconfigJson['@git.zone/cli'] = {}; + if (!smartconfigJson["@git.zone/cli"]) { + smartconfigJson["@git.zone/cli"] = {}; } - if (!smartconfigJson['@ship.zone/szci']) { - smartconfigJson['@ship.zone/szci'] = {}; + if (!smartconfigJson["@ship.zone/szci"]) { + smartconfigJson["@ship.zone/szci"] = {}; } const newContent = JSON.stringify(smartconfigJson, null, 2); if (newContent !== currentContent) { changes.push({ - type: 'modify', + type: "modify", path: CONFIG_FILE, module: this.name, - description: 'Migrate and format .smartconfig.json', + description: "Migrate and format .smartconfig.json", content: newContent, }); } @@ -115,26 +116,41 @@ export class SmartconfigFormatter extends BaseFormatter { } async applyChange(change: IPlannedChange): Promise { - if (change.type !== 'modify' || !change.content) return; + if (change.type !== "modify" || !change.content) return; const smartconfigJson = 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', + "projectType", + "module.githost", + "module.gitscope", + "module.gitrepo", + "module.description", + "module.npmPackagename", + "module.license", ]; const interactInstance = new plugins.smartinteract.SmartInteract(); + const missingRepoInformation = expectedRepoInformation.filter( + (expectedRepoInformationItem) => { + return !plugins.smartobject.smartGet( + smartconfigJson["@git.zone/cli"], + expectedRepoInformationItem, + ); + }, + ); + + if (missingRepoInformation.length > 0 && !this.context.isInteractive()) { + throw new Error( + `Missing required .smartconfig.json fields: ${missingRepoInformation.join(", ")}`, + ); + } + for (const expectedRepoInformationItem of expectedRepoInformation) { if ( !plugins.smartobject.smartGet( - smartconfigJson['@git.zone/cli'], + smartconfigJson["@git.zone/cli"], expectedRepoInformationItem, ) ) { @@ -142,8 +158,8 @@ export class SmartconfigFormatter extends BaseFormatter { { message: `What is the value of ${expectedRepoInformationItem}`, name: expectedRepoInformationItem, - type: 'input', - default: 'undefined variable', + type: "input", + default: "undefined variable", }, ]); } @@ -156,7 +172,7 @@ export class SmartconfigFormatter extends BaseFormatter { ); if (cliProvidedValue) { plugins.smartobject.smartAdd( - smartconfigJson['@git.zone/cli'], + smartconfigJson["@git.zone/cli"], expectedRepoInformationItem, cliProvidedValue, ); @@ -165,6 +181,6 @@ export class SmartconfigFormatter extends BaseFormatter { const finalContent = JSON.stringify(smartconfigJson, null, 2); await this.modifyFile(change.path, finalContent); - logger.log('info', 'Updated .smartconfig.json'); + logger.log("info", "Updated .smartconfig.json"); } } diff --git a/ts/mod_format/index.ts b/ts/mod_format/index.ts index 15696eb..5ba48e5 100644 --- a/ts/mod_format/index.ts +++ b/ts/mod_format/index.ts @@ -1,44 +1,60 @@ -import * as plugins from './mod.plugins.js'; -import { Project } from '../classes.project.js'; -import { FormatContext } from './classes.formatcontext.js'; -import { FormatPlanner } from './classes.formatplanner.js'; -import { BaseFormatter } from './classes.baseformatter.js'; -import { logger, setVerboseMode } from '../gitzone.logging.js'; +import * as plugins from "./mod.plugins.js"; +import { Project } from "../classes.project.js"; +import { FormatContext } from "./classes.formatcontext.js"; +import { FormatPlanner } from "./classes.formatplanner.js"; +import { BaseFormatter } from "./classes.baseformatter.js"; +import { logger, setVerboseMode } from "../gitzone.logging.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { + getCliMode, + printJson, + runWithSuppressedOutput, +} from "../helpers.climode.js"; +import { getCliConfigValue } from "../helpers.smartconfig.js"; -import { CleanupFormatter } from './formatters/cleanup.formatter.js'; -import { SmartconfigFormatter } from './formatters/smartconfig.formatter.js'; -import { LicenseFormatter } from './formatters/license.formatter.js'; -import { PackageJsonFormatter } from './formatters/packagejson.formatter.js'; -import { TemplatesFormatter } from './formatters/templates.formatter.js'; -import { GitignoreFormatter } from './formatters/gitignore.formatter.js'; -import { TsconfigFormatter } from './formatters/tsconfig.formatter.js'; -import { PrettierFormatter } from './formatters/prettier.formatter.js'; -import { ReadmeFormatter } from './formatters/readme.formatter.js'; -import { CopyFormatter } from './formatters/copy.formatter.js'; +import { CleanupFormatter } from "./formatters/cleanup.formatter.js"; +import { SmartconfigFormatter } from "./formatters/smartconfig.formatter.js"; +import { LicenseFormatter } from "./formatters/license.formatter.js"; +import { PackageJsonFormatter } from "./formatters/packagejson.formatter.js"; +import { TemplatesFormatter } from "./formatters/templates.formatter.js"; +import { GitignoreFormatter } from "./formatters/gitignore.formatter.js"; +import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js"; +import { PrettierFormatter } from "./formatters/prettier.formatter.js"; +import { ReadmeFormatter } from "./formatters/readme.formatter.js"; +import { CopyFormatter } from "./formatters/copy.formatter.js"; /** * Rename npmextra.json or smartconfig.json to .smartconfig.json * before any formatter tries to read config. */ -async function migrateConfigFile(): Promise { - const target = '.smartconfig.json'; +async function migrateConfigFile(allowWrite: boolean): Promise { + const target = ".smartconfig.json"; const targetExists = await plugins.smartfs.file(target).exists(); if (targetExists) return; - for (const oldName of ['smartconfig.json', 'npmextra.json']) { + for (const oldName of ["smartconfig.json", "npmextra.json"]) { const exists = await plugins.smartfs.file(oldName).exists(); if (exists) { - const content = await plugins.smartfs.file(oldName).encoding('utf8').read() as string; - await plugins.smartfs.file(`./${target}`).encoding('utf8').write(content); + if (!allowWrite) { + return; + } + const content = (await plugins.smartfs + .file(oldName) + .encoding("utf8") + .read()) as string; + await plugins.smartfs.file(`./${target}`).encoding("utf8").write(content); await plugins.smartfs.file(oldName).delete(); - logger.log('info', `Migrated ${oldName} to ${target}`); + logger.log("info", `Migrated ${oldName} to ${target}`); return; } } } // Shared formatter class map used by both run() and runFormatter() -const formatterMap: Record BaseFormatter> = { +const formatterMap: Record< + string, + new (ctx: FormatContext, proj: Project) => BaseFormatter +> = { cleanup: CleanupFormatter, smartconfig: SmartconfigFormatter, license: LicenseFormatter, @@ -52,7 +68,104 @@ const formatterMap: Record Ba }; // Formatters that don't require projectType to be set -const formattersNotRequiringProjectType = ['smartconfig', 'prettier', 'cleanup', 'packagejson']; +const formattersNotRequiringProjectType = [ + "smartconfig", + "prettier", + "cleanup", + "packagejson", +]; + +const getFormatConfig = async () => { + const rawFormatConfig = await getCliConfigValue>( + "format", + {}, + ); + return { + interactive: true, + showDiffs: false, + autoApprove: false, + showStats: true, + modules: { + skip: [], + only: [], + ...(rawFormatConfig.modules || {}), + }, + ...rawFormatConfig, + }; +}; + +const createActiveFormatters = async (options: { + interactive: boolean; + jsonOutput: boolean; +}) => { + const project = await Project.fromCwd({ requireProjectType: false }); + const context = new FormatContext(options); + const planner = new FormatPlanner(); + + const formatConfig = await getFormatConfig(); + const formatters = Object.entries(formatterMap).map( + ([, FormatterClass]) => new FormatterClass(context, project), + ); + + const activeFormatters = formatters.filter((formatter) => { + if (formatConfig.modules.only.length > 0) { + return formatConfig.modules.only.includes(formatter.name); + } + if (formatConfig.modules.skip.includes(formatter.name)) { + return false; + } + return true; + }); + + return { + context, + planner, + formatConfig, + activeFormatters, + }; +}; + +const buildFormatPlan = async (options: { + fromPlan?: string; + interactive: boolean; + jsonOutput: boolean; +}) => { + const { context, planner, formatConfig, activeFormatters } = + await createActiveFormatters({ + interactive: options.interactive, + jsonOutput: options.jsonOutput, + }); + + const plan = options.fromPlan + ? JSON.parse( + (await plugins.smartfs + .file(options.fromPlan) + .encoding("utf8") + .read()) as string, + ) + : await planner.planFormat(activeFormatters); + + return { + context, + planner, + formatConfig, + activeFormatters, + plan, + }; +}; + +const serializePlan = (plan: any) => { + return { + summary: plan.summary, + warnings: plan.warnings, + changes: plan.changes.map((change: any) => ({ + type: change.type, + path: change.path, + module: change.module, + description: change.description, + })), + }; +}; export let run = async ( options: { @@ -66,62 +179,61 @@ export let run = async ( interactive?: boolean; verbose?: boolean; diff?: boolean; + [key: string]: any; } = {}, ): Promise => { + const mode = await getCliMode(options as any); + const subcommand = (options as any)?._?.[1]; + + if (mode.help || subcommand === "help") { + showHelp(mode); + return; + } + if (options.verbose) { setVerboseMode(true); } - const shouldWrite = options.write ?? (options.dryRun === false); + const shouldWrite = options.write ?? options.dryRun === false; + const treatAsPlan = subcommand === "plan"; + + if (mode.json && shouldWrite) { + printJson({ + ok: false, + error: + "JSON output is only supported for read-only format planning. Use `gitzone format plan --json` or omit `--json` when applying changes.", + }); + return; + } // Migrate config file before anything reads it - await migrateConfigFile(); + await migrateConfigFile(shouldWrite); - const project = await Project.fromCwd({ requireProjectType: false }); - const context = new FormatContext(); - const planner = new FormatPlanner(); - - const smartconfigInstance = new plugins.smartconfig.Smartconfig(); - const formatConfig = smartconfigInstance.dataFor('@git.zone/cli.format', { - interactive: true, - showDiffs: false, - autoApprove: false, - modules: { - skip: [], - only: [], - }, - }); - - const interactive = options.interactive ?? formatConfig.interactive; + const formatConfig = await getFormatConfig(); + const interactive = + options.interactive ?? (mode.interactive && formatConfig.interactive); const autoApprove = options.yes ?? formatConfig.autoApprove; try { - // Initialize formatters in execution order - const formatters = Object.entries(formatterMap).map( - ([, FormatterClass]) => new FormatterClass(context, project), - ); + const planBuilder = async () => { + return await buildFormatPlan({ + fromPlan: options.fromPlan, + interactive, + jsonOutput: mode.json, + }); + }; - // Filter formatters based on configuration - const activeFormatters = formatters.filter((formatter) => { - if (formatConfig.modules.only.length > 0) { - return formatConfig.modules.only.includes(formatter.name); - } - if (formatConfig.modules.skip.includes(formatter.name)) { - return false; - } - return true; - }); + if (!mode.json) { + logger.log("info", "Analyzing project for format operations..."); + } + const { context, planner, activeFormatters, plan } = mode.json + ? await runWithSuppressedOutput(planBuilder) + : await planBuilder(); - // Plan phase - logger.log('info', 'Analyzing project for format operations...'); - let plan = options.fromPlan - ? JSON.parse( - (await plugins.smartfs - .file(options.fromPlan) - .encoding('utf8') - .read()) as string, - ) - : await planner.planFormat(activeFormatters); + if (mode.json) { + printJson(serializePlan(plan)); + return; + } // Display plan await planner.displayPlan(plan, options.detailed); @@ -130,34 +242,35 @@ export let run = async ( if (options.savePlan) { await plugins.smartfs .file(options.savePlan) - .encoding('utf8') + .encoding("utf8") .write(JSON.stringify(plan, null, 2)); - logger.log('info', `Plan saved to ${options.savePlan}`); + logger.log("info", `Plan saved to ${options.savePlan}`); } - if (options.planOnly) { + if (options.planOnly || treatAsPlan) { return; } // Show diffs if explicitly requested or before interactive write confirmation - const showDiffs = options.diff || (shouldWrite && interactive && !autoApprove); + const showDiffs = + options.diff || (shouldWrite && interactive && !autoApprove); if (showDiffs) { - logger.log('info', 'Showing file diffs:'); - console.log(''); + logger.log("info", "Showing file diffs:"); + console.log(""); for (const formatter of activeFormatters) { const checkResult = await formatter.check(); if (checkResult.hasDiff) { - logger.log('info', `[${formatter.name}]`); + logger.log("info", `[${formatter.name}]`); formatter.displayAllDiffs(checkResult); - console.log(''); + console.log(""); } } } // Dry-run mode (default behavior) if (!shouldWrite) { - logger.log('info', 'Dry-run mode - use --write (-w) to apply changes'); + logger.log("info", "Dry-run mode - use --write (-w) to apply changes"); return; } @@ -165,25 +278,25 @@ export let run = async ( if (interactive && !autoApprove) { const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'confirm', - name: 'proceed', - message: 'Proceed with formatting?', + type: "confirm", + name: "proceed", + message: "Proceed with formatting?", default: true, }); if (!(response as any).value) { - logger.log('info', 'Format operation cancelled by user'); + logger.log("info", "Format operation cancelled by user"); return; } } // Execute phase - logger.log('info', 'Executing format operations...'); + logger.log("info", "Executing format operations..."); await planner.executePlan(plan, activeFormatters, context); context.getFormatStats().finish(); - const showStats = smartconfigInstance.dataFor('gitzone.format.showStats', true); + const showStats = formatConfig.showStats ?? true; if (showStats) { context.getFormatStats().displayStats(); } @@ -193,14 +306,15 @@ export let run = async ( await context.getFormatStats().saveReport(statsPath); } - logger.log('success', 'Format operations completed successfully!'); + logger.log("success", "Format operations completed successfully!"); } catch (error) { - logger.log('error', `Format operation failed: ${error.message}`); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.log("error", `Format operation failed: ${errorMessage}`); throw error; } }; -import type { ICheckResult } from './interfaces.format.js'; +import type { ICheckResult } from "./interfaces.format.js"; export type { ICheckResult }; /** @@ -212,11 +326,12 @@ export const runFormatter = async ( silent?: boolean; checkOnly?: boolean; showDiff?: boolean; - } = {} + } = {}, ): Promise => { - const requireProjectType = !formattersNotRequiringProjectType.includes(formatterName); + const requireProjectType = + !formattersNotRequiringProjectType.includes(formatterName); const project = await Project.fromCwd({ requireProjectType }); - const context = new FormatContext(); + const context = new FormatContext({ interactive: true, jsonOutput: false }); const FormatterClass = formatterMap[formatterName]; if (!FormatterClass) { @@ -240,6 +355,80 @@ export const runFormatter = async ( } if (!options.silent) { - logger.log('success', `Formatter '${formatterName}' completed`); + logger.log("success", `Formatter '${formatterName}' completed`); } }; + +export function showHelp(mode?: ICliMode): void { + if (mode?.json) { + printJson({ + command: "format", + usage: "gitzone format [plan] [options]", + description: + "Plans formatting changes by default and applies them only with --write.", + flags: [ + { flag: "--write, -w", description: "Apply planned changes" }, + { + flag: "--yes", + description: "Skip the interactive confirmation before writing", + }, + { + flag: "--plan-only", + description: "Show the plan without applying changes", + }, + { + flag: "--save-plan ", + description: "Write the format plan to a file", + }, + { + flag: "--from-plan ", + description: "Load a previously saved plan", + }, + { + flag: "--detailed", + description: "Show detailed diffs and save stats", + }, + { flag: "--verbose", description: "Enable verbose logging" }, + { + flag: "--diff", + description: "Show per-file diffs before applying changes", + }, + { flag: "--json", description: "Emit a read-only format plan as JSON" }, + ], + examples: [ + "gitzone format", + "gitzone format plan --json", + "gitzone format --write --yes", + ], + }); + return; + } + + console.log(""); + console.log("Usage: gitzone format [plan] [options]"); + console.log(""); + console.log( + "Plans formatting changes by default and applies them only with --write.", + ); + console.log(""); + console.log("Flags:"); + console.log(" --write, -w Apply planned changes"); + console.log( + " --yes Skip the interactive confirmation before writing", + ); + console.log(" --plan-only Show the plan without applying changes"); + console.log(" --save-plan Write the format plan to a file"); + console.log(" --from-plan Load a previously saved plan"); + console.log(" --detailed Show detailed diffs and save stats"); + console.log(" --verbose Enable verbose logging"); + console.log( + " --diff Show per-file diffs before applying changes", + ); + console.log(" --json Emit a read-only format plan as JSON"); + console.log(""); + console.log("Examples:"); + console.log(" gitzone format"); + console.log(" gitzone format plan --json"); + console.log(" gitzone format --write --yes"); + console.log(""); +} diff --git a/ts/mod_services/index.ts b/ts/mod_services/index.ts index 00a677c..925443c 100644 --- a/ts/mod_services/index.ts +++ b/ts/mod_services/index.ts @@ -1,12 +1,26 @@ -import * as plugins from './mod.plugins.js'; -import * as helpers from './helpers.js'; -import { ServiceManager } from './classes.servicemanager.js'; -import { GlobalRegistry } from './classes.globalregistry.js'; -import { logger } from '../gitzone.logging.js'; +import * as plugins from "./mod.plugins.js"; +import * as helpers from "./helpers.js"; +import { ServiceManager } from "./classes.servicemanager.js"; +import { GlobalRegistry } from "./classes.globalregistry.js"; +import { logger } from "../gitzone.logging.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { getCliMode, printJson } from "../helpers.climode.js"; +import { + getCliConfigValueFromData, + readSmartconfigFile, + setCliConfigValueInData, + writeSmartconfigFile, +} from "../helpers.smartconfig.js"; export const run = async (argvArg: any) => { + const mode = await getCliMode(argvArg); const isGlobal = argvArg.g || argvArg.global; - const command = argvArg._[1] || 'help'; + const command = argvArg._[1] || "help"; + + if (mode.help || command === "help") { + showHelp(mode); + return; + } // Handle global commands first if (isGlobal) { @@ -14,264 +28,597 @@ export const run = async (argvArg: any) => { return; } - // Local project commands - const serviceManager = new ServiceManager(); - await serviceManager.init(); - - const service = argvArg._[2] || 'all'; + const service = argvArg._[2] || "all"; switch (command) { - case 'start': - await handleStart(serviceManager, service); - break; - - case 'stop': - await handleStop(serviceManager, service); - break; - - case 'restart': - await handleRestart(serviceManager, service); - break; - - case 'status': - await serviceManager.showStatus(); - break; - - case 'config': - if (service === 'services' || argvArg._[2] === 'services') { + case "config": + if (service === "services" || argvArg._[2] === "services") { + const serviceManager = new ServiceManager(); + await serviceManager.init(); await handleConfigureServices(serviceManager); } else { - await serviceManager.showConfig(); + await handleShowConfig(mode); } break; - case 'compass': - await serviceManager.showCompassConnection(); + case "set": + await handleSetServices(argvArg._[2], mode); break; - - case 'logs': - const lines = parseInt(argvArg._[3]) || 20; - await serviceManager.showLogs(service, lines); + + case "enable": + await handleEnableServices(argvArg._.slice(2), mode); break; - - case 'remove': - await handleRemove(serviceManager); + + case "disable": + await handleDisableServices(argvArg._.slice(2), mode); break; - - case 'clean': - await handleClean(serviceManager); + + case "start": + case "stop": + case "restart": + case "status": + case "compass": + case "logs": + case "remove": + case "clean": + case "reconfigure": { + const serviceManager = new ServiceManager(); + await serviceManager.init(); + + switch (command) { + case "start": + await handleStart(serviceManager, service); + break; + + case "stop": + await handleStop(serviceManager, service); + break; + + case "restart": + await handleRestart(serviceManager, service); + break; + + case "status": + await serviceManager.showStatus(); + break; + + case "compass": + await serviceManager.showCompassConnection(); + break; + + case "logs": { + const lines = parseInt(argvArg._[3]) || 20; + await serviceManager.showLogs(service, lines); + break; + } + + case "remove": + await handleRemove(serviceManager); + break; + + case "clean": + await handleClean(serviceManager); + break; + + case "reconfigure": + await serviceManager.reconfigure(); + break; + } break; - - case 'reconfigure': - await serviceManager.reconfigure(); - break; - - case 'help': + } default: - showHelp(); + showHelp(mode); break; } }; +const allowedServices = ["mongodb", "minio", "elasticsearch"]; + +const normalizeServiceName = (service: string): string => { + switch (service) { + case "mongo": + case "mongodb": + return "mongodb"; + case "minio": + case "s3": + return "minio"; + case "elastic": + case "elasticsearch": + case "es": + return "elasticsearch"; + default: + return service; + } +}; + +async function readServicesConfig(): Promise<{ + enabledServices: string[]; + environment: Record | null; +}> { + const smartconfigData = await readSmartconfigFile(); + const enabledServices = getCliConfigValueFromData( + smartconfigData, + "services", + ); + let environment: Record | null = null; + const envPath = plugins.path.join(process.cwd(), ".nogit", "env.json"); + if (await plugins.smartfs.file(envPath).exists()) { + const envContent = (await plugins.smartfs + .file(envPath) + .encoding("utf8") + .read()) as string; + environment = JSON.parse(envContent); + } + + return { + enabledServices: Array.isArray(enabledServices) ? enabledServices : [], + environment, + }; +} + +async function updateEnabledServices(services: string[]): Promise { + const smartconfigData = await readSmartconfigFile(); + setCliConfigValueInData(smartconfigData, "services", services); + await writeSmartconfigFile(smartconfigData); +} + +async function handleShowConfig(mode: ICliMode) { + const configData = await readServicesConfig(); + + if (mode.json) { + printJson(configData); + return; + } + + helpers.printHeader("Current Services Configuration"); + logger.log( + "info", + `Enabled Services: ${configData.enabledServices.length > 0 ? configData.enabledServices.join(", ") : "none configured"}`, + ); + console.log(); + + if (!configData.environment) { + logger.log( + "note", + "No .nogit/env.json found yet. Start a service once to create runtime defaults.", + ); + return; + } + + const env = configData.environment; + logger.log("note", "MongoDB:"); + logger.log("info", ` Host: ${env.MONGODB_HOST}:${env.MONGODB_PORT}`); + logger.log("info", ` Database: ${env.MONGODB_NAME}`); + logger.log("info", ` User: ${env.MONGODB_USER}`); + logger.log("info", ` Container: ${env.PROJECT_NAME}-mongodb`); + logger.log( + "info", + ` Data: ${plugins.path.join(process.cwd(), ".nogit", "mongodata")}`, + ); + logger.log("info", ` Connection: ${env.MONGODB_URL}`); + console.log(); + + logger.log("note", "S3/MinIO:"); + logger.log("info", ` Host: ${env.S3_HOST}`); + logger.log("info", ` API Port: ${env.S3_PORT}`); + logger.log("info", ` Console Port: ${env.S3_CONSOLE_PORT}`); + logger.log("info", ` Bucket: ${env.S3_BUCKET}`); + logger.log("info", ` Container: ${env.PROJECT_NAME}-minio`); + logger.log( + "info", + ` Data: ${plugins.path.join(process.cwd(), ".nogit", "miniodata")}`, + ); + logger.log("info", ` Endpoint: ${env.S3_ENDPOINT}`); + console.log(); + + logger.log("note", "Elasticsearch:"); + logger.log( + "info", + ` Host: ${env.ELASTICSEARCH_HOST}:${env.ELASTICSEARCH_PORT}`, + ); + logger.log("info", ` User: ${env.ELASTICSEARCH_USER}`); + logger.log("info", ` Container: ${env.PROJECT_NAME}-elasticsearch`); + logger.log( + "info", + ` Data: ${plugins.path.join(process.cwd(), ".nogit", "esdata")}`, + ); + logger.log("info", ` Connection: ${env.ELASTICSEARCH_URL}`); +} + +async function handleSetServices(rawValue: string | undefined, mode: ICliMode) { + if (!rawValue) { + throw new Error("Specify a comma-separated list of services"); + } + + const requestedServices = rawValue + .split(",") + .map((service) => normalizeServiceName(service.trim())) + .filter(Boolean); + validateRequestedServices(requestedServices); + await updateEnabledServices(requestedServices); + + if (mode.json) { + printJson({ ok: true, action: "set", enabledServices: requestedServices }); + return; + } + + logger.log("ok", `Enabled services set to: ${requestedServices.join(", ")}`); +} + +async function handleEnableServices( + requestedServices: string[], + mode: ICliMode, +) { + const normalizedServices = requestedServices.map((service) => + normalizeServiceName(service), + ); + validateRequestedServices(normalizedServices); + + const configData = await readServicesConfig(); + const nextServices = Array.from( + new Set([...configData.enabledServices, ...normalizedServices]), + ); + await updateEnabledServices(nextServices); + + if (mode.json) { + printJson({ ok: true, action: "enable", enabledServices: nextServices }); + return; + } + + logger.log("ok", `Enabled services: ${nextServices.join(", ")}`); +} + +async function handleDisableServices( + requestedServices: string[], + mode: ICliMode, +) { + const normalizedServices = requestedServices.map((service) => + normalizeServiceName(service), + ); + validateRequestedServices(normalizedServices); + + const configData = await readServicesConfig(); + const nextServices = configData.enabledServices.filter( + (service) => !normalizedServices.includes(service), + ); + await updateEnabledServices(nextServices); + + if (mode.json) { + printJson({ ok: true, action: "disable", enabledServices: nextServices }); + return; + } + + logger.log("ok", `Enabled services: ${nextServices.join(", ")}`); +} + +function validateRequestedServices(services: string[]): void { + if (services.length === 0) { + throw new Error("Specify at least one service"); + } + + const invalidServices = services.filter( + (service) => !allowedServices.includes(service), + ); + if (invalidServices.length > 0) { + throw new Error(`Unknown service(s): ${invalidServices.join(", ")}`); + } +} + async function handleStart(serviceManager: ServiceManager, service: string) { - helpers.printHeader('Starting Services'); + helpers.printHeader("Starting Services"); switch (service) { - case 'mongo': - case 'mongodb': + case "mongo": + case "mongodb": await serviceManager.startMongoDB(); break; - case 'minio': - case 's3': + case "minio": + case "s3": await serviceManager.startMinIO(); break; - case 'elasticsearch': - case 'es': + case "elasticsearch": + case "es": await serviceManager.startElasticsearch(); break; - case 'all': - case '': + case "all": + case "": await serviceManager.startAll(); break; default: - logger.log('error', `Unknown service: ${service}`); - logger.log('note', 'Use: mongo, s3, elasticsearch, or all'); + logger.log("error", `Unknown service: ${service}`); + logger.log("note", "Use: mongo, s3, elasticsearch, or all"); break; } } async function handleStop(serviceManager: ServiceManager, service: string) { - helpers.printHeader('Stopping Services'); + helpers.printHeader("Stopping Services"); switch (service) { - case 'mongo': - case 'mongodb': + case "mongo": + case "mongodb": await serviceManager.stopMongoDB(); break; - case 'minio': - case 's3': + case "minio": + case "s3": await serviceManager.stopMinIO(); break; - case 'elasticsearch': - case 'es': + case "elasticsearch": + case "es": await serviceManager.stopElasticsearch(); break; - case 'all': - case '': + case "all": + case "": await serviceManager.stopAll(); break; default: - logger.log('error', `Unknown service: ${service}`); - logger.log('note', 'Use: mongo, s3, elasticsearch, or all'); + logger.log("error", `Unknown service: ${service}`); + logger.log("note", "Use: mongo, s3, elasticsearch, or all"); break; } } async function handleRestart(serviceManager: ServiceManager, service: string) { - helpers.printHeader('Restarting Services'); + helpers.printHeader("Restarting Services"); switch (service) { - case 'mongo': - case 'mongodb': + case "mongo": + case "mongodb": await serviceManager.stopMongoDB(); await plugins.smartdelay.delayFor(2000); await serviceManager.startMongoDB(); break; - case 'minio': - case 's3': + case "minio": + case "s3": await serviceManager.stopMinIO(); await plugins.smartdelay.delayFor(2000); await serviceManager.startMinIO(); break; - case 'elasticsearch': - case 'es': + case "elasticsearch": + case "es": await serviceManager.stopElasticsearch(); await plugins.smartdelay.delayFor(2000); await serviceManager.startElasticsearch(); break; - case 'all': - case '': + case "all": + case "": await serviceManager.stopAll(); await plugins.smartdelay.delayFor(2000); await serviceManager.startAll(); break; default: - logger.log('error', `Unknown service: ${service}`); + logger.log("error", `Unknown service: ${service}`); break; } } async function handleRemove(serviceManager: ServiceManager) { - helpers.printHeader('Removing Containers'); - logger.log('note', 'โš ๏ธ This will remove containers but preserve data'); - - const shouldContinue = await plugins.smartinteract.SmartInteract.getCliConfirmation('Continue?', false); - + helpers.printHeader("Removing Containers"); + logger.log("note", "โš ๏ธ This will remove containers but preserve data"); + + const shouldContinue = + await plugins.smartinteract.SmartInteract.getCliConfirmation( + "Continue?", + false, + ); + if (shouldContinue) { await serviceManager.removeContainers(); } else { - logger.log('note', 'Cancelled'); + logger.log("note", "Cancelled"); } } async function handleClean(serviceManager: ServiceManager) { - helpers.printHeader('Clean All'); - logger.log('error', 'โš ๏ธ WARNING: This will remove all containers and data!'); - logger.log('error', 'This action cannot be undone!'); + helpers.printHeader("Clean All"); + logger.log("error", "โš ๏ธ WARNING: This will remove all containers and data!"); + logger.log("error", "This action cannot be undone!"); const smartinteraction = new plugins.smartinteract.SmartInteract(); const confirmAnswer = await smartinteraction.askQuestion({ - name: 'confirm', - type: 'input', + name: "confirm", + type: "input", message: 'Type "yes" to confirm:', - default: 'no' + default: "no", }); - if (confirmAnswer.value === 'yes') { + if (confirmAnswer.value === "yes") { await serviceManager.removeContainers(); console.log(); await serviceManager.cleanData(); - logger.log('ok', 'All cleaned โœ“'); + logger.log("ok", "All cleaned โœ“"); } else { - logger.log('note', 'Cancelled'); + logger.log("note", "Cancelled"); } } async function handleConfigureServices(serviceManager: ServiceManager) { - helpers.printHeader('Configure Services'); + helpers.printHeader("Configure Services"); await serviceManager.configureServices(); } -function showHelp() { - helpers.printHeader('GitZone Services Manager'); +export function showHelp(mode?: ICliMode) { + if (mode?.json) { + printJson({ + command: "services", + usage: "gitzone services [options]", + commands: [ + { + name: "config", + description: + "Show configured services and any existing runtime env.json data", + }, + { + name: "set ", + description: "Set the enabled service list without prompts", + }, + { + name: "enable ", + description: "Enable one or more services without prompts", + }, + { + name: "disable ", + description: "Disable one or more services without prompts", + }, + { name: "start [service]", description: "Start services" }, + { name: "stop [service]", description: "Stop services" }, + { name: "status", description: "Show service status" }, + ], + examples: [ + "gitzone services config --json", + "gitzone services set mongodb,minio", + "gitzone services enable elasticsearch", + ], + }); + return; + } - logger.log('ok', 'Usage: gitzone services [command] [options]'); + helpers.printHeader("GitZone Services Manager"); + + logger.log("ok", "Usage: gitzone services [command] [options]"); console.log(); - logger.log('note', 'Commands:'); - logger.log('info', ' start [service] Start services (mongo|s3|elasticsearch|all)'); - logger.log('info', ' stop [service] Stop services (mongo|s3|elasticsearch|all)'); - logger.log('info', ' restart [service] Restart services (mongo|s3|elasticsearch|all)'); - logger.log('info', ' status Show service status'); - logger.log('info', ' config Show current configuration'); - logger.log('info', ' config services Configure which services are enabled'); - logger.log('info', ' compass Show MongoDB Compass connection string'); - logger.log('info', ' logs [service] Show logs (mongo|s3|elasticsearch|all) [lines]'); - logger.log('info', ' reconfigure Reassign ports and restart services'); - logger.log('info', ' remove Remove all containers'); - logger.log('info', ' clean Remove all containers and data โš ๏ธ'); - logger.log('info', ' help Show this help message'); + logger.log("note", "Commands:"); + logger.log( + "info", + " start [service] Start services (mongo|s3|elasticsearch|all)", + ); + logger.log( + "info", + " stop [service] Stop services (mongo|s3|elasticsearch|all)", + ); + logger.log( + "info", + " restart [service] Restart services (mongo|s3|elasticsearch|all)", + ); + logger.log("info", " status Show service status"); + logger.log("info", " config Show current configuration"); + logger.log( + "info", + " config services Configure which services are enabled", + ); + logger.log( + "info", + " set Set enabled services without prompts", + ); + logger.log("info", " enable Enable one or more services"); + logger.log("info", " disable Disable one or more services"); + logger.log( + "info", + " compass Show MongoDB Compass connection string", + ); + logger.log( + "info", + " logs [service] Show logs (mongo|s3|elasticsearch|all) [lines]", + ); + logger.log("info", " reconfigure Reassign ports and restart services"); + logger.log("info", " remove Remove all containers"); + logger.log("info", " clean Remove all containers and data โš ๏ธ"); + logger.log("info", " help Show this help message"); console.log(); - logger.log('note', 'Available Services:'); - logger.log('info', ' โ€ข MongoDB (mongo) - Document database'); - logger.log('info', ' โ€ข MinIO (s3) - S3-compatible object storage'); - logger.log('info', ' โ€ข Elasticsearch (elasticsearch) - Search and analytics engine'); + logger.log("note", "Available Services:"); + logger.log("info", " โ€ข MongoDB (mongo) - Document database"); + logger.log("info", " โ€ข MinIO (s3) - S3-compatible object storage"); + logger.log( + "info", + " โ€ข Elasticsearch (elasticsearch) - Search and analytics engine", + ); console.log(); - logger.log('note', 'Features:'); - logger.log('info', ' โ€ข Auto-creates .nogit/env.json with smart defaults'); - logger.log('info', ' โ€ข Random ports (20000-30000) for MongoDB/MinIO to avoid conflicts'); - logger.log('info', ' โ€ข Elasticsearch uses standard port 9200'); - logger.log('info', ' โ€ข Project-specific containers for multi-project support'); - logger.log('info', ' โ€ข Preserves custom configuration values'); - logger.log('info', ' โ€ข MongoDB Compass connection support'); + logger.log("note", "Features:"); + logger.log("info", " โ€ข Auto-creates .nogit/env.json with smart defaults"); + logger.log( + "info", + " โ€ข Random ports (20000-30000) for MongoDB/MinIO to avoid conflicts", + ); + logger.log("info", " โ€ข Elasticsearch uses standard port 9200"); + logger.log( + "info", + " โ€ข Project-specific containers for multi-project support", + ); + logger.log("info", " โ€ข Preserves custom configuration values"); + logger.log("info", " โ€ข MongoDB Compass connection support"); console.log(); - logger.log('note', 'Examples:'); - logger.log('info', ' gitzone services start # Start all services'); - logger.log('info', ' gitzone services start mongo # Start only MongoDB'); - logger.log('info', ' gitzone services start elasticsearch # Start only Elasticsearch'); - logger.log('info', ' gitzone services stop # Stop all services'); - logger.log('info', ' gitzone services status # Check service status'); - logger.log('info', ' gitzone services config # Show configuration'); - logger.log('info', ' gitzone services compass # Get MongoDB Compass connection'); - logger.log('info', ' gitzone services logs elasticsearch # Show Elasticsearch logs'); + logger.log("note", "Examples:"); + logger.log( + "info", + " gitzone services start # Start all services", + ); + logger.log( + "info", + " gitzone services start mongo # Start only MongoDB", + ); + logger.log( + "info", + " gitzone services start elasticsearch # Start only Elasticsearch", + ); + logger.log( + "info", + " gitzone services stop # Stop all services", + ); + logger.log( + "info", + " gitzone services status # Check service status", + ); + logger.log( + "info", + " gitzone services config # Show configuration", + ); + logger.log( + "info", + " gitzone services config --json # Show configuration as JSON", + ); + logger.log( + "info", + " gitzone services set mongodb,minio # Configure services without prompts", + ); + logger.log( + "info", + " gitzone services compass # Get MongoDB Compass connection", + ); + logger.log( + "info", + " gitzone services logs elasticsearch # Show Elasticsearch logs", + ); console.log(); - logger.log('note', 'Global Commands (-g/--global):'); - logger.log('info', ' list -g List all registered projects'); - logger.log('info', ' status -g Show status across all projects'); - logger.log('info', ' stop -g Stop all containers across all projects'); - logger.log('info', ' cleanup -g Remove stale registry entries'); + logger.log("note", "Global Commands (-g/--global):"); + logger.log("info", " list -g List all registered projects"); + logger.log("info", " status -g Show status across all projects"); + logger.log( + "info", + " stop -g Stop all containers across all projects", + ); + logger.log("info", " cleanup -g Remove stale registry entries"); console.log(); - logger.log('note', 'Global Examples:'); - logger.log('info', ' gitzone services list -g # List all registered projects'); - logger.log('info', ' gitzone services status -g # Show global container status'); - logger.log('info', ' gitzone services stop -g # Stop all (prompts for confirmation)'); + logger.log("note", "Global Examples:"); + logger.log( + "info", + " gitzone services list -g # List all registered projects", + ); + logger.log( + "info", + " gitzone services status -g # Show global container status", + ); + logger.log( + "info", + " gitzone services stop -g # Stop all (prompts for confirmation)", + ); } // ==================== Global Command Handlers ==================== @@ -280,23 +627,23 @@ async function handleGlobalCommand(command: string) { const globalRegistry = GlobalRegistry.getInstance(); switch (command) { - case 'list': + case "list": await handleGlobalList(globalRegistry); break; - case 'status': + case "status": await handleGlobalStatus(globalRegistry); break; - case 'stop': + case "stop": await handleGlobalStop(globalRegistry); break; - case 'cleanup': + case "cleanup": await handleGlobalCleanup(globalRegistry); break; - case 'help': + case "help": default: showHelp(); break; @@ -304,13 +651,13 @@ async function handleGlobalCommand(command: string) { } async function handleGlobalList(globalRegistry: GlobalRegistry) { - helpers.printHeader('Registered Projects (Global)'); + helpers.printHeader("Registered Projects (Global)"); const projects = await globalRegistry.getAllProjects(); const projectPaths = Object.keys(projects); if (projectPaths.length === 0) { - logger.log('note', 'No projects registered'); + logger.log("note", "No projects registered"); return; } @@ -319,20 +666,20 @@ async function handleGlobalList(globalRegistry: GlobalRegistry) { const lastActive = new Date(project.lastActive).toLocaleString(); console.log(); - logger.log('ok', `๐Ÿ“ ${project.projectName}`); - logger.log('info', ` Path: ${project.projectPath}`); - logger.log('info', ` Services: ${project.enabledServices.join(', ')}`); - logger.log('info', ` Last Active: ${lastActive}`); + logger.log("ok", `๐Ÿ“ ${project.projectName}`); + logger.log("info", ` Path: ${project.projectPath}`); + logger.log("info", ` Services: ${project.enabledServices.join(", ")}`); + logger.log("info", ` Last Active: ${lastActive}`); } } async function handleGlobalStatus(globalRegistry: GlobalRegistry) { - helpers.printHeader('Global Service Status'); + helpers.printHeader("Global Service Status"); const statuses = await globalRegistry.getGlobalStatus(); if (statuses.length === 0) { - logger.log('note', 'No projects registered'); + logger.log("note", "No projects registered"); return; } @@ -341,28 +688,39 @@ async function handleGlobalStatus(globalRegistry: GlobalRegistry) { for (const project of statuses) { console.log(); - logger.log('ok', `๐Ÿ“ ${project.projectName}`); - logger.log('info', ` Path: ${project.projectPath}`); + logger.log("ok", `๐Ÿ“ ${project.projectName}`); + logger.log("info", ` Path: ${project.projectPath}`); if (project.containers.length === 0) { - logger.log('note', ' No containers configured'); + logger.log("note", " No containers configured"); continue; } for (const container of project.containers) { totalContainers++; - const statusIcon = container.status === 'running' ? '๐ŸŸข' : container.status === 'exited' ? '๐ŸŸก' : 'โšช'; - if (container.status === 'running') runningCount++; - logger.log('info', ` ${statusIcon} ${container.name}: ${container.status}`); + const statusIcon = + container.status === "running" + ? "๐ŸŸข" + : container.status === "exited" + ? "๐ŸŸก" + : "โšช"; + if (container.status === "running") runningCount++; + logger.log( + "info", + ` ${statusIcon} ${container.name}: ${container.status}`, + ); } } console.log(); - logger.log('note', `Summary: ${runningCount}/${totalContainers} containers running across ${statuses.length} project(s)`); + logger.log( + "note", + `Summary: ${runningCount}/${totalContainers} containers running across ${statuses.length} project(s)`, + ); } async function handleGlobalStop(globalRegistry: GlobalRegistry) { - helpers.printHeader('Stop All Containers (Global)'); + helpers.printHeader("Stop All Containers (Global)"); const statuses = await globalRegistry.getGlobalStatus(); @@ -370,64 +728,73 @@ async function handleGlobalStop(globalRegistry: GlobalRegistry) { let runningCount = 0; for (const project of statuses) { for (const container of project.containers) { - if (container.status === 'running') runningCount++; + if (container.status === "running") runningCount++; } } if (runningCount === 0) { - logger.log('note', 'No running containers found'); + logger.log("note", "No running containers found"); return; } - logger.log('note', `Found ${runningCount} running container(s) across ${statuses.length} project(s)`); + logger.log( + "note", + `Found ${runningCount} running container(s) across ${statuses.length} project(s)`, + ); console.log(); // Show what will be stopped for (const project of statuses) { - const runningContainers = project.containers.filter(c => c.status === 'running'); + const runningContainers = project.containers.filter( + (c) => c.status === "running", + ); if (runningContainers.length > 0) { - logger.log('info', `${project.projectName}:`); + logger.log("info", `${project.projectName}:`); for (const container of runningContainers) { - logger.log('info', ` โ€ข ${container.name}`); + logger.log("info", ` โ€ข ${container.name}`); } } } console.log(); - const shouldContinue = await plugins.smartinteract.SmartInteract.getCliConfirmation( - 'Stop all containers?', - false - ); + const shouldContinue = + await plugins.smartinteract.SmartInteract.getCliConfirmation( + "Stop all containers?", + false, + ); if (!shouldContinue) { - logger.log('note', 'Cancelled'); + logger.log("note", "Cancelled"); return; } - logger.log('note', 'Stopping all containers...'); + logger.log("note", "Stopping all containers..."); const result = await globalRegistry.stopAll(); if (result.stopped.length > 0) { - logger.log('ok', `Stopped: ${result.stopped.join(', ')}`); + logger.log("ok", `Stopped: ${result.stopped.join(", ")}`); } if (result.failed.length > 0) { - logger.log('error', `Failed to stop: ${result.failed.join(', ')}`); + logger.log("error", `Failed to stop: ${result.failed.join(", ")}`); } } async function handleGlobalCleanup(globalRegistry: GlobalRegistry) { - helpers.printHeader('Cleanup Registry (Global)'); + helpers.printHeader("Cleanup Registry (Global)"); - logger.log('note', 'Checking for stale registry entries...'); + logger.log("note", "Checking for stale registry entries..."); const removed = await globalRegistry.cleanup(); if (removed.length === 0) { - logger.log('ok', 'No stale entries found'); + logger.log("ok", "No stale entries found"); return; } - logger.log('ok', `Removed ${removed.length} stale entr${removed.length === 1 ? 'y' : 'ies'}:`); + logger.log( + "ok", + `Removed ${removed.length} stale entr${removed.length === 1 ? "y" : "ies"}:`, + ); for (const path of removed) { - logger.log('info', ` โ€ข ${path}`); + logger.log("info", ` โ€ข ${path}`); } -} \ No newline at end of file +} diff --git a/ts/mod_standard/index.ts b/ts/mod_standard/index.ts index 05703c1..d27a07f 100644 --- a/ts/mod_standard/index.ts +++ b/ts/mod_standard/index.ts @@ -1,91 +1,224 @@ /* ----------------------------------------------- * executes as standard task * ----------------------------------------------- */ -import * as plugins from './mod.plugins.js'; -import * as paths from '../paths.js'; +import * as plugins from "./mod.plugins.js"; +import * as paths from "../paths.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { getCliMode, printJson } from "../helpers.climode.js"; -import { logger } from '../gitzone.logging.js'; +import { logger } from "../gitzone.logging.js"; -export let run = async () => { - console.log(''); - console.log('โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ'); - console.log('โ”‚ gitzone - Development Workflow CLI โ”‚'); - console.log('โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ'); - console.log(''); +type ICommandHelpSummary = { + name: string; + description: string; +}; + +const commandSummaries: ICommandHelpSummary[] = [ + { + name: "commit", + description: + "Create semantic commits or generate read-only commit recommendations", + }, + { name: "format", description: "Plan or apply project formatting changes" }, + { name: "config", description: "Read and change .smartconfig.json settings" }, + { name: "services", description: "Manage or configure development services" }, + { name: "template", description: "Create a project from a template" }, + { name: "open", description: "Open project assets and CI pages" }, + { name: "docker", description: "Run Docker-related maintenance tasks" }, + { + name: "deprecate", + description: "Deprecate npm packages across registries", + }, + { name: "meta", description: "Run meta-repository commands" }, + { name: "start", description: "Prepare a project for local work" }, + { name: "helpers", description: "Run helper utilities" }, +]; + +export let run = async (argvArg: any = {}) => { + const mode = await getCliMode(argvArg); + const requestedCommandHelp = + argvArg._?.[0] === "help" ? argvArg._?.[1] : undefined; + + if (mode.help || requestedCommandHelp) { + await showHelp(mode, requestedCommandHelp); + return; + } + + if (!mode.interactive) { + await showHelp(mode); + return; + } + + console.log(""); + console.log( + "โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ", + ); + console.log( + "โ”‚ gitzone - Development Workflow CLI โ”‚", + ); + console.log( + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ", + ); + console.log(""); const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ - type: 'list', - name: 'action', - message: 'What would you like to do?', - default: 'commit', + type: "list", + name: "action", + message: "What would you like to do?", + default: "commit", choices: [ - { name: 'Commit changes (semantic versioning)', value: 'commit' }, - { name: 'Format project files', value: 'format' }, - { name: 'Configure release settings', value: 'config' }, - { name: 'Create from template', value: 'template' }, - { name: 'Manage dev services (MongoDB, S3)', value: 'services' }, - { name: 'Open project assets', value: 'open' }, - { name: 'Show help', value: 'help' }, + { name: "Commit changes (semantic versioning)", value: "commit" }, + { name: "Format project files", value: "format" }, + { name: "Configure release settings", value: "config" }, + { name: "Create from template", value: "template" }, + { name: "Manage dev services (MongoDB, S3)", value: "services" }, + { name: "Open project assets", value: "open" }, + { name: "Show help", value: "help" }, ], }); const action = (response as any).value; switch (action) { - case 'commit': { - const modCommit = await import('../mod_commit/index.js'); - await modCommit.run({ _: ['commit'] }); + case "commit": { + const modCommit = await import("../mod_commit/index.js"); + await modCommit.run({ _: ["commit"] }); break; } - case 'format': { - const modFormat = await import('../mod_format/index.js'); + case "format": { + const modFormat = await import("../mod_format/index.js"); await modFormat.run({ interactive: true }); break; } - case 'config': { - const modConfig = await import('../mod_config/index.js'); - await modConfig.run({ _: ['config'] }); + case "config": { + const modConfig = await import("../mod_config/index.js"); + await modConfig.run({ _: ["config"] }); break; } - case 'template': { - const modTemplate = await import('../mod_template/index.js'); - await modTemplate.run({ _: ['template'] }); + case "template": { + const modTemplate = await import("../mod_template/index.js"); + await modTemplate.run({ _: ["template"] }); break; } - case 'services': { - const modServices = await import('../mod_services/index.js'); - await modServices.run({ _: ['services'] }); + case "services": { + const modServices = await import("../mod_services/index.js"); + await modServices.run({ _: ["services"] }); break; } - case 'open': { - const modOpen = await import('../mod_open/index.js'); - await modOpen.run({ _: ['open'] }); + case "open": { + const modOpen = await import("../mod_open/index.js"); + await modOpen.run({ _: ["open"] }); break; } - case 'help': - showHelp(); + case "help": + await showHelp(mode); break; } }; -function showHelp(): void { - console.log(''); - console.log('Usage: gitzone [options]'); - console.log(''); - console.log('Commands:'); - console.log(' commit Create a semantic commit with versioning'); - console.log(' format Format and standardize project files'); - console.log(' config Manage release registry configuration'); - console.log(' template Create a new project from template'); - console.log(' services Manage dev services (MongoDB, S3/MinIO)'); - console.log(' open Open project assets (GitLab, npm, etc.)'); - console.log(' docker Docker-related operations'); - console.log(' deprecate Deprecate a package on npm'); - console.log(' meta Run meta commands'); - console.log(' start Start working on a project'); - console.log(' helpers Run helper utilities'); - console.log(''); - console.log('Run gitzone --help for more information on a command.'); - console.log(''); +export async function showHelp( + mode: ICliMode, + commandName?: string, +): Promise { + if (commandName) { + const handled = await showCommandHelp(commandName, mode); + if (handled) { + return; + } + } + + if (mode.json) { + printJson({ + name: "gitzone", + usage: "gitzone [options]", + commands: commandSummaries, + globalFlags: [ + { flag: "--help, -h", description: "Show help output" }, + { + flag: "--json", + description: "Emit machine-readable JSON when supported", + }, + { + flag: "--plain", + description: "Use plain text output when supported", + }, + { + flag: "--agent", + description: "Prefer non-interactive machine-friendly output", + }, + { + flag: "--no-interactive", + description: "Disable prompts and interactive menus", + }, + { + flag: "--no-check-updates", + description: "Skip the update check banner", + }, + ], + }); + return; + } + + console.log(""); + console.log("Usage: gitzone [options]"); + console.log(""); + console.log("Commands:"); + for (const commandSummary of commandSummaries) { + console.log( + ` ${commandSummary.name.padEnd(11)} ${commandSummary.description}`, + ); + } + console.log(""); + console.log("Global flags:"); + console.log(" --help, -h Show help output"); + console.log( + " --json Emit machine-readable JSON when supported", + ); + console.log(" --plain Use plain text output when supported"); + console.log( + " --agent Prefer non-interactive machine-friendly output", + ); + console.log(" --no-interactive Disable prompts and interactive menus"); + console.log(" --no-check-updates Skip the update check banner"); + console.log(""); + console.log("Examples:"); + console.log(" gitzone help commit"); + console.log(" gitzone config show --json"); + console.log(" gitzone commit recommend --json"); + console.log(" gitzone format plan --json"); + console.log(" gitzone services set mongodb,minio"); + console.log(""); + console.log("Run gitzone --help for command-specific usage."); + console.log(""); +} + +async function showCommandHelp( + commandName: string, + mode: ICliMode, +): Promise { + switch (commandName) { + case "commit": { + const modCommit = await import("../mod_commit/index.js"); + modCommit.showHelp(mode); + return true; + } + case "config": { + const modConfig = await import("../mod_config/index.js"); + modConfig.showHelp(mode); + return true; + } + case "format": { + const modFormat = await import("../mod_format/index.js"); + modFormat.showHelp(mode); + return true; + } + case "services": { + const modServices = await import("../mod_services/index.js"); + modServices.showHelp(mode); + return true; + } + default: + return false; + } }