Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions bin/build-phar.php
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
<?php

declare(strict_types=1);

/**
* Manual PHAR builder for kcode — bypasses Box chdir() bug on PHP 8.4
* Manual PHAR builder for kcode — bypasses Box chdir() bug on PHP 8.4.
*
* The PHAR is intentionally lean: it bundles only the KaririCode\Devkit
* source classes and a minimal PSR-4 autoloader. Dev tools (phpunit,
* phpstan, etc.) are NOT bundled — `kcode init` installs them dynamically
* into .kcode/vendor/ of the target project via composer.
*/

$root = dirname(__DIR__);
$output = $root . '/build/kcode.phar';
$root = dirname(__DIR__);
$output = $root . '/build/kcode.phar';

if (file_exists($output)) {
unlink($output);
Expand Down
5 changes: 4 additions & 1 deletion bin/kcode
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ declare(strict_types=1);
$devkit = new \KaririCode\Devkit\Core\Devkit($detector);

// Register config generators
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\KcodeComposerGenerator());
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\PhpUnitConfigGenerator());
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\PhpStanConfigGenerator());
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\CsFixerConfigGenerator());
Expand Down Expand Up @@ -75,7 +76,9 @@ declare(strict_types=1);

$app = new \KaririCode\Devkit\Command\Application($devkit);

$app->register(new \KaririCode\Devkit\Command\InitCommand());
$app->register(new \KaririCode\Devkit\Command\InitCommand(
new \KaririCode\Devkit\Core\MigrationDetector(),
));
$app->register(new \KaririCode\Devkit\Command\MigrateCommand(
new \KaririCode\Devkit\Core\MigrationDetector(),
));
Expand Down
39 changes: 33 additions & 6 deletions src/Command/InitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,24 @@
use KaririCode\Devkit\Core\MigrationDetector;

/**
* Generates all config files inside `.kcode/`.
* Generates all config files inside `.kcode/` and installs dev tools.
*
* With `--config`, scaffolds a `devkit.php` override file in the project root.
* On `kcode init`, writes all tool configs (phpunit.xml.dist, phpstan.neon, etc.)
* to `.kcode/` via the registered generators, then runs `composer install
* --working-dir=.kcode/` to install the tool binaries into `.kcode/vendor/bin/`.
*
* Flags:
* --config Scaffold a `devkit.php` override file in the project root
* --skip-install Generate configs only (skip composer install step)
*
* @since 1.0.0
*/
final class InitCommand extends AbstractCommand
{
public function __construct(
private readonly MigrationDetector $detector,
) {
}
#[\Override]
public function name(): string
{
Expand All @@ -38,20 +48,37 @@ public function execute(Devkit $devkit, array $arguments): int
$this->info("Namespace: {$context->namespace}");
$this->info("PHP: {$context->phpVersion}");

// ── Phase 1: Generate config files into .kcode/ ─────────────────
$count = $devkit->init();

$this->line();
$this->info("Generated {$count} config file(s) in .kcode/");
$this->info(".kcode/ added to .gitignore (regenerate with kcode init)");

// Scaffold devkit.php if requested
// ── Phase 2: Install dev tools into .kcode/vendor/ ──────────────
if (! $this->hasFlag($arguments, '--skip-install')) {
$this->line();
$this->info("Installing dev tools into .kcode/vendor/ ...");

$exitCode = $devkit->installTools($context->projectRoot);

if (0 !== $exitCode) {
$this->warning("composer install failed (exit {$exitCode}). Run manually:");
$this->line(" composer install --working-dir={$context->devkitDir} --no-interaction");

return $exitCode;
}

$this->info("Dev tools installed in .kcode/vendor/bin/");
}

// ── Phase 3: Scaffold devkit.php if requested ────────────────────
if ($this->hasFlag($arguments, '--config')) {
$this->scaffoldDevkitConfig($context->projectRoot);
}

// Hint: detect redundant root-level configs and dev dependencies
$detector = new MigrationDetector();
$migration = $detector->detect($context->projectRoot);
// ── Phase 4: Hint about redundant legacy configs ──────────────────
$migration = $this->detector->detect($context->projectRoot);

if ($migration->hasRedundancies) {
$this->line();
Expand Down
99 changes: 99 additions & 0 deletions src/Configuration/KcodeComposerGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace KaririCode\Devkit\Configuration;

use KaririCode\Devkit\Contract\ConfigGenerator;
use KaririCode\Devkit\Core\ProjectContext;

/**
* Generates `.kcode/composer.json` — the self-contained dev-toolchain manifest.
*
* When `kcode init` runs, this file is written to `.kcode/` and then
* `composer install --working-dir=.kcode/ --no-interaction` is executed
* by the InitCommand. Tools are installed into `.kcode/vendor/bin/`,
* keeping the target project's own composer.json free of dev-tool deps.
*
* Version constraints come from `devkit.php` → `tools` key (optional).
* Falls back to KaririCode-certified defaults when not specified.
*
* @since 1.0.0
*/
final class KcodeComposerGenerator implements ConfigGenerator
{
private const array DEFAULT_TOOL_VERSIONS = [
'phpunit/phpunit' => '^12.5',
'phpstan/phpstan' => '^2.0',
'friendsofphp/php-cs-fixer' => '^3.64',
'rector/rector' => '^2.0',
'vimeo/psalm' => '^6.0',
];

/** @var array<string, string> Maps devkit.php tool short-names → Composer package names */
private const array TOOL_SHORT_NAME_MAP = [
'phpunit' => 'phpunit/phpunit',
'phpstan' => 'phpstan/phpstan',
'php-cs-fixer' => 'friendsofphp/php-cs-fixer',
'rector' => 'rector/rector',
'psalm' => 'vimeo/psalm',
];

#[\Override]
public function toolName(): string
{
return 'kcode-composer';
}

#[\Override]
public function outputPath(): string
{
return 'composer.json';
}

#[\Override]
public function generate(ProjectContext $context): string
{
$require = $this->resolveVersions($context->toolVersions);

$manifest = [
'name' => 'kariricode/devkit-tools',
'description' => 'Dev toolchain managed by kcode — do not edit manually.',
'require' => $require,
'config' => [
'bin-compat' => 'full',
'optimize-autoloader' => true,
'sort-packages' => true,
'preferred-install' => 'dist',
'allow-plugins' => [
'infection/extension-installer' => false,
],
],
'minimum-stability' => 'stable',
'prefer-stable' => true,
];

return json_encode($manifest, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . \PHP_EOL;
}

/**
* Merge user-supplied version constraints with defaults.
* User constraints win on conflict; short-names are resolved to package names.
*
* @param array<string, string> $userVersions From devkit.php → tools
* @return array<string, string>
*/
private function resolveVersions(array $userVersions): array
{
$resolved = self::DEFAULT_TOOL_VERSIONS;

foreach ($userVersions as $shortName => $constraint) {
$package = self::TOOL_SHORT_NAME_MAP[$shortName] ?? $shortName;
$resolved[$package] = $constraint;
}

ksort($resolved);

return $resolved;
}
}
72 changes: 72 additions & 0 deletions src/Core/ComposerResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace KaririCode\Devkit\Core;

/**
* Resolves the Composer binary path from the environment.
*
* Searches in order:
* 1. `COMPOSER_BINARY` environment variable (explicit override)
* 2. Global PATH via `command -v composer`
* 3. Common installation paths (`/usr/local/bin/composer`, `/usr/bin/composer`)
* 4. Composer PHAR in `HOME/.composer/composer`
* 5. Fallback literal `composer` (assumes it is on the PATH at runtime)
*
* Extracted from `Devkit` as a standalone service to honour SRP.
*
* @since 1.0.0
*/
final readonly class ComposerResolver
{
private const array GLOBAL_PATHS = [
'/usr/local/bin/composer',
'/usr/bin/composer',
];

/**
* Return the absolute path to a usable Composer binary.
*
* Returns a single path string (never a shell fragment) so callers
* can safely pass it as the first element of a `proc_open` command array.
*/
public function resolve(): string
{
// 1. Explicit environment override
$environmentPath = getenv('COMPOSER_BINARY');

if (\is_string($environmentPath) && '' !== $environmentPath && is_executable($environmentPath)) {
return $environmentPath;
}

// 2. PATH lookup
/** @psalm-suppress ForbiddenCode — shell_exec is intentional here; no user-controlled input */
$pathBinary = trim((string) shell_exec('command -v composer 2>/dev/null'));

if ('' !== $pathBinary && is_executable($pathBinary)) {
return $pathBinary;
}

// 3. Known global install locations
foreach (self::GLOBAL_PATHS as $candidate) {
if (is_executable($candidate)) {
return $candidate;
}
}

// 4. User-level Composer PHAR
$home = getenv('HOME');

if (\is_string($home) && '' !== $home) {
$userPhar = $home . '/.composer/composer';

if (is_executable($userPhar)) {
return $userPhar;
}
}

// 5. Assume PATH fallback — let the OS fail with a clear error
return 'composer';
}
}
51 changes: 51 additions & 0 deletions src/Core/Devkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class Devkit

public function __construct(
private readonly ProjectDetector $detector,
private readonly ComposerResolver $composerResolver = new ComposerResolver(),
) {
}

Expand Down Expand Up @@ -89,6 +90,55 @@ public function init(string $workingDirectory = '.'): int
return $count;
}

/**
* Install dev tools into `.kcode/vendor/` by running Composer.
*
* Reads the `.kcode/composer.json` manifest generated by `KcodeComposerGenerator`
* and runs `composer install --working-dir=.kcode/` so tools are available
* at `.kcode/vendor/bin/` for Tier-1 binary resolution.
*
* Output streams live to the user's terminal via STDIN/STDOUT/STDERR passthrough.
*
* @return int Composer exit code (0 = success)
*
* @since 1.0.0
*/
public function installTools(string $workingDirectory = '.'): int
{
$context = $this->context($workingDirectory);
$devkitDirectory = $context->devkitDir;
$composerManifestPath = $devkitDirectory . \DIRECTORY_SEPARATOR . 'composer.json';

if (! is_file($composerManifestPath)) {
// KcodeComposerGenerator not registered — nothing to install
return 0;
}

$composerBinary = $this->composerResolver->resolve();
$command = [
$composerBinary,
'install',
'--working-dir=' . $devkitDirectory,
'--no-interaction',
'--prefer-dist',
'--optimize-autoloader',
'--no-scripts',
];

$process = proc_open(
$command,
[0 => \STDIN, 1 => \STDOUT, 2 => \STDERR],
$pipes,
$workingDirectory,
);

if (! \is_resource($process)) {
return 1;
}

return proc_close($process);
}

// ── Run ───────────────────────────────────────────────────────

/** @param list<string> $arguments */
Expand Down Expand Up @@ -235,4 +285,5 @@ private function removeRecursive(string $dir): void

rmdir($dir);
}

}
17 changes: 9 additions & 8 deletions src/Core/ProcessExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,22 +83,22 @@ public function execute(string $toolName, array $command): ToolResult
*/
public function resolveBinary(string $vendorBin): ?string
{
// Tier 1: PHAR-internal binary
if ('' !== \Phar::running(false)) {
$pharBin = \Phar::running(true) . '/' . $vendorBin;
if (file_exists($pharBin)) {
return $pharBin;
}
$basename = basename($vendorBin);

// Tier 1: .kcode/vendor/bin/ — tools installed by `kcode init`
// This is the primary resolution path for the kcode toolchain.
$kcodeBin = $this->workingDirectory . '/.kcode/vendor/bin/' . $basename;
if (is_file($kcodeBin) && is_executable($kcodeBin)) {
return $kcodeBin;
}

// Tier 2: Project-local vendor binary
// Tier 2: Project-local vendor binary (e.g. package has devkit as require-dev)
$localBin = $this->workingDirectory . '/' . $vendorBin;
if (is_file($localBin) && is_executable($localBin)) {
return $localBin;
}

// Tier 3: Global PATH
$basename = basename($vendorBin);
/** @psalm-suppress ForbiddenCode — shell_exec is intentional for binary resolution; input is escaped */
$globalBin = trim((string) shell_exec('command -v ' . escapeshellarg($basename) . ' 2>/dev/null'));
if ('' !== $globalBin && is_executable($globalBin)) {
Expand All @@ -107,4 +107,5 @@ public function resolveBinary(string $vendorBin): ?string

return null;
}

}
Loading
Loading