Public Access
feat: interactive repo configuration wizard (#145)
Add `repo:wizard` command — walks through platform selection, generates config files (phpcs, phpstan, eslint, tsconfig, composer/package.json, .editorconfig, README, CHANGELOG, .gitignore, workflows), and optionally creates the repo on Gitea via API. Supports --dry-run, --non-interactive, and --create-remote flags.
This commit is contained in:
@@ -178,6 +178,7 @@ const COMMAND_MAP = [
|
||||
'repo:archive' => 'cli/archive_repo.php',
|
||||
'repo:scaffold-client' => 'cli/scaffold_client.php',
|
||||
'repo:provision' => 'cli/client_provision.php',
|
||||
'repo:wizard' => 'cli/repo_wizard.php',
|
||||
'repo:rename-branch' => 'cli/branch_rename.php',
|
||||
'repo:reset-dev' => 'cli/dev_branch_reset.php',
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: mokocli.CLI
|
||||
* INGROUP: mokocli
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
* PATH: /cli/repo_wizard.php
|
||||
* BRIEF: Interactive configuration wizard for new repositories
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
||||
|
||||
use MokoCli\{CliFramework, Config, PlatformAdapterFactory};
|
||||
|
||||
/**
|
||||
* Interactive repo setup wizard.
|
||||
*
|
||||
* Walks through platform selection, generates config files, workflows,
|
||||
* and optionally creates the repo on Gitea via API.
|
||||
*
|
||||
* @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/145
|
||||
*/
|
||||
class RepoWizard extends CliFramework
|
||||
{
|
||||
private const PLATFORMS = [
|
||||
'joomla' => 'Joomla extension (component, module, plugin, package)',
|
||||
'dolibarr' => 'Dolibarr ERP module',
|
||||
'nodejs' => 'Node.js / TypeScript project',
|
||||
'python' => 'Python project',
|
||||
'mcp-server' => 'MCP server (Model Context Protocol)',
|
||||
'generic' => 'Generic PHP or multi-language project',
|
||||
];
|
||||
|
||||
private const LICENSES = [
|
||||
'GPL-3.0-or-later' => 'GNU General Public License v3',
|
||||
'MIT' => 'MIT License',
|
||||
'Apache-2.0' => 'Apache License 2.0',
|
||||
'proprietary' => 'Proprietary / All rights reserved',
|
||||
];
|
||||
|
||||
/** Collected wizard answers. */
|
||||
private array $answers = [];
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription('Interactive configuration wizard for new repositories');
|
||||
$this->addArgument('--path', 'Directory to generate files in', '.');
|
||||
$this->addArgument('--create-remote', 'Create repo on Gitea via API', false);
|
||||
$this->addArgument('--non-interactive', 'Use defaults (no prompts)', false);
|
||||
}
|
||||
|
||||
protected function run(): int
|
||||
{
|
||||
$targetPath = realpath($this->getArgument('--path', '.')) ?: $this->getArgument('--path', '.');
|
||||
|
||||
$this->section('MokoCli Repository Wizard');
|
||||
|
||||
// ── Gather info ──────────────────────────────────────────────
|
||||
$this->answers['name'] = $this->input('Repository name', basename($targetPath));
|
||||
$this->answers['platform'] = $this->menu('Platform type', self::PLATFORMS, 'generic');
|
||||
$this->answers['org'] = $this->input('Organization', 'MokoConsulting');
|
||||
$this->answers['description'] = $this->input('Description', '');
|
||||
$this->answers['license'] = $this->menu('License', self::LICENSES, 'GPL-3.0-or-later');
|
||||
|
||||
// ── Confirm ──────────────────────────────────────────────────
|
||||
$this->section('Configuration Summary');
|
||||
foreach ($this->answers as $key => $value) {
|
||||
$this->log('INFO', sprintf(' %-12s %s', $key . ':', $value));
|
||||
}
|
||||
|
||||
if (!$this->confirm('Proceed with these settings?', true)) {
|
||||
$this->log('INFO', 'Wizard cancelled');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Generate files ───────────────────────────────────────────
|
||||
$this->section('Generating files');
|
||||
$generated = $this->generateFiles($targetPath);
|
||||
|
||||
foreach ($generated as $file) {
|
||||
$this->status(true, $file);
|
||||
}
|
||||
|
||||
// ── Create remote repo ───────────────────────────────────────
|
||||
if ($this->getArgument('--create-remote', false)) {
|
||||
$this->section('Creating remote repository');
|
||||
$this->createRemoteRepo();
|
||||
}
|
||||
|
||||
$this->log('INFO', '');
|
||||
$this->log('INFO', "Generated {$this->count($generated)} files in {$targetPath}");
|
||||
$this->log('INFO', 'Next: git init && git add -A && git commit -m "chore: initial scaffold"');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── File generation ──────────────────────────────────────────────
|
||||
|
||||
private function generateFiles(string $path): array
|
||||
{
|
||||
$platform = $this->answers['platform'];
|
||||
$name = $this->answers['name'];
|
||||
$generated = [];
|
||||
|
||||
// .editorconfig
|
||||
$generated[] = $this->writeFile($path, '.editorconfig', $this->editorconfig());
|
||||
|
||||
// README.md
|
||||
$generated[] = $this->writeFile($path, 'README.md', $this->readme());
|
||||
|
||||
// CHANGELOG.md
|
||||
$generated[] = $this->writeFile($path, 'CHANGELOG.md', $this->changelog());
|
||||
|
||||
// LICENSE
|
||||
if ($this->answers['license'] !== 'proprietary') {
|
||||
$generated[] = $this->writeFile($path, 'LICENSE', "See SPDX: {$this->answers['license']}");
|
||||
}
|
||||
|
||||
// Platform-specific configs
|
||||
switch ($platform) {
|
||||
case 'joomla':
|
||||
case 'dolibarr':
|
||||
case 'generic':
|
||||
$generated[] = $this->writeFile($path, 'phpcs.xml', $this->phpcsXml());
|
||||
$generated[] = $this->writeFile($path, 'phpstan.neon', $this->phpstanNeon());
|
||||
$generated[] = $this->writeFile($path, 'composer.json', $this->composerJson());
|
||||
break;
|
||||
case 'nodejs':
|
||||
case 'mcp-server':
|
||||
$generated[] = $this->writeFile($path, 'package.json', $this->packageJson());
|
||||
$generated[] = $this->writeFile($path, 'tsconfig.json', $this->tsconfigJson());
|
||||
$generated[] = $this->writeFile($path, '.eslintrc.json', $this->eslintrc());
|
||||
break;
|
||||
case 'python':
|
||||
$generated[] = $this->writeFile($path, 'pyproject.toml', $this->pyprojectToml());
|
||||
$generated[] = $this->writeFile($path, 'requirements.txt', '');
|
||||
break;
|
||||
}
|
||||
|
||||
// .mokogitea/workflows
|
||||
$generated[] = $this->writeFile($path, '.mokogitea/workflows/pr-check.yml',
|
||||
"# PR check workflow — synced from mokocli templates\n# Run: moko sync to update\n");
|
||||
|
||||
// .gitignore
|
||||
$generated[] = $this->writeFile($path, '.gitignore', $this->gitignore($platform));
|
||||
|
||||
// Source directory
|
||||
$srcDir = in_array($platform, ['joomla', 'dolibarr', 'generic']) ? 'source' : 'src';
|
||||
if (!is_dir("{$path}/{$srcDir}")) {
|
||||
@mkdir("{$path}/{$srcDir}", 0755, true);
|
||||
$generated[] = "{$srcDir}/";
|
||||
}
|
||||
|
||||
return array_filter($generated);
|
||||
}
|
||||
|
||||
private function writeFile(string $basePath, string $relativePath, string $content): ?string
|
||||
{
|
||||
$fullPath = $basePath . '/' . $relativePath;
|
||||
$dir = dirname($fullPath);
|
||||
|
||||
if (file_exists($fullPath)) {
|
||||
$this->log('DEBUG', " SKIP {$relativePath} (already exists)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', "[dry-run] Would create {$relativePath}");
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents($fullPath, $content);
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
// ── Remote repo creation ─────────────────────────────────────────
|
||||
|
||||
private function createRemoteRepo(): void
|
||||
{
|
||||
try {
|
||||
$config = Config::load();
|
||||
$adapter = PlatformAdapterFactory::create($config);
|
||||
$org = $this->answers['org'];
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->log('INFO', "[dry-run] Would create {$org}/{$this->answers['name']} on Gitea");
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $adapter->createRepository($org, $this->answers['name'], [
|
||||
'description' => $this->answers['description'],
|
||||
'private' => false,
|
||||
]);
|
||||
|
||||
$url = $result['html_url'] ?? "{$org}/{$this->answers['name']}";
|
||||
$this->log('INFO', "Created: {$url}");
|
||||
} catch (\Exception $e) {
|
||||
$this->log('ERROR', "Failed to create remote repo: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
private function count(array $arr): int { return \count($arr); }
|
||||
|
||||
// ── Template content ─────────────────────────────────────────────
|
||||
|
||||
private function editorconfig(): string
|
||||
{
|
||||
return <<<'CONF'
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
CONF;
|
||||
}
|
||||
|
||||
private function readme(): string
|
||||
{
|
||||
$name = $this->answers['name'];
|
||||
$desc = $this->answers['description'] ?: 'A Moko Consulting project.';
|
||||
$license = $this->answers['license'];
|
||||
|
||||
return <<<MD
|
||||
# {$name}
|
||||
|
||||
{$desc}
|
||||
|
||||
## License
|
||||
|
||||
{$license}
|
||||
MD;
|
||||
}
|
||||
|
||||
private function changelog(): string
|
||||
{
|
||||
return <<<MD
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial project scaffold
|
||||
MD;
|
||||
}
|
||||
|
||||
private function composerJson(): string
|
||||
{
|
||||
$name = strtolower($this->answers['name']);
|
||||
$desc = $this->answers['description'] ?: $this->answers['name'];
|
||||
$license = $this->answers['license'];
|
||||
|
||||
return <<<JSON
|
||||
{
|
||||
"name": "mokoconsulting/{$name}",
|
||||
"description": "{$desc}",
|
||||
"type": "library",
|
||||
"license": "{$license}",
|
||||
"require": {
|
||||
"php": ">=8.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {}
|
||||
}
|
||||
}
|
||||
JSON;
|
||||
}
|
||||
|
||||
private function phpcsXml(): string
|
||||
{
|
||||
return <<<'XML'
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="MokoCli">
|
||||
<rule ref="PSR12"/>
|
||||
<file>source/</file>
|
||||
<exclude-pattern>vendor/*</exclude-pattern>
|
||||
</ruleset>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function phpstanNeon(): string
|
||||
{
|
||||
return <<<'NEON'
|
||||
parameters:
|
||||
level: 6
|
||||
paths:
|
||||
- source/
|
||||
NEON;
|
||||
}
|
||||
|
||||
private function packageJson(): string
|
||||
{
|
||||
$name = strtolower($this->answers['name']);
|
||||
$desc = $this->answers['description'] ?: $this->answers['name'];
|
||||
|
||||
return <<<JSON
|
||||
{
|
||||
"name": "@mokoconsulting/{$name}",
|
||||
"version": "0.1.0",
|
||||
"description": "{$desc}",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0"
|
||||
}
|
||||
}
|
||||
JSON;
|
||||
}
|
||||
|
||||
private function tsconfigJson(): string
|
||||
{
|
||||
return <<<'JSON'
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
JSON;
|
||||
}
|
||||
|
||||
private function eslintrc(): string
|
||||
{
|
||||
return <<<'JSON'
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"]
|
||||
}
|
||||
JSON;
|
||||
}
|
||||
|
||||
private function pyprojectToml(): string
|
||||
{
|
||||
$name = strtolower($this->answers['name']);
|
||||
$desc = $this->answers['description'] ?: $this->answers['name'];
|
||||
|
||||
return <<<TOML
|
||||
[project]
|
||||
name = "{$name}"
|
||||
version = "0.1.0"
|
||||
description = "{$desc}"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
TOML;
|
||||
}
|
||||
|
||||
private function gitignore(string $platform): string
|
||||
{
|
||||
$common = <<<'GI'
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
GI;
|
||||
|
||||
$extra = match ($platform) {
|
||||
'joomla', 'dolibarr', 'generic' => "\n# PHP\nvendor/\n.phpunit.result.cache\n",
|
||||
'nodejs', 'mcp-server' => "\n# Node\nnode_modules/\ndist/\n*.tsbuildinfo\n",
|
||||
'python' => "\n# Python\n__pycache__/\n*.pyc\n.venv/\n*.egg-info/\n",
|
||||
default => '',
|
||||
};
|
||||
|
||||
return $common . $extra;
|
||||
}
|
||||
}
|
||||
|
||||
$app = new RepoWizard('repo_wizard', 'Interactive configuration wizard for new repositories');
|
||||
exit($app->execute());
|
||||
Reference in New Issue
Block a user