Skip to content

Commit c42ff94

Browse files
authored
Merge pull request #16 from KaririCode-Framework/develop
Develop
2 parents 7b66892 + c0f8bf3 commit c42ff94

9 files changed

Lines changed: 483 additions & 18 deletions

File tree

bin/build-phar.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
<?php
2+
23
declare(strict_types=1);
34

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

8-
$root = dirname(__DIR__);
9-
$output = $root . '/build/kcode.phar';
14+
$root = dirname(__DIR__);
15+
$output = $root . '/build/kcode.phar';
1016

1117
if (file_exists($output)) {
1218
unlink($output);

bin/kcode

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ declare(strict_types=1);
4343
$devkit = new \KaririCode\Devkit\Core\Devkit($detector);
4444

4545
// Register config generators
46+
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\KcodeComposerGenerator());
4647
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\PhpUnitConfigGenerator());
4748
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\PhpStanConfigGenerator());
4849
$devkit->addGenerator(new \KaririCode\Devkit\Configuration\CsFixerConfigGenerator());
@@ -75,7 +76,9 @@ declare(strict_types=1);
7576

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

78-
$app->register(new \KaririCode\Devkit\Command\InitCommand());
79+
$app->register(new \KaririCode\Devkit\Command\InitCommand(
80+
new \KaririCode\Devkit\Core\MigrationDetector(),
81+
));
7982
$app->register(new \KaririCode\Devkit\Command\MigrateCommand(
8083
new \KaririCode\Devkit\Core\MigrationDetector(),
8184
));

src/Command/InitCommand.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@
88
use KaririCode\Devkit\Core\MigrationDetector;
99

1010
/**
11-
* Generates all config files inside `.kcode/`.
11+
* Generates all config files inside `.kcode/` and installs dev tools.
1212
*
13-
* With `--config`, scaffolds a `devkit.php` override file in the project root.
13+
* On `kcode init`, writes all tool configs (phpunit.xml.dist, phpstan.neon, etc.)
14+
* to `.kcode/` via the registered generators, then runs `composer install
15+
* --working-dir=.kcode/` to install the tool binaries into `.kcode/vendor/bin/`.
16+
*
17+
* Flags:
18+
* --config Scaffold a `devkit.php` override file in the project root
19+
* --skip-install Generate configs only (skip composer install step)
1420
*
1521
* @since 1.0.0
1622
*/
1723
final class InitCommand extends AbstractCommand
1824
{
25+
public function __construct(
26+
private readonly MigrationDetector $detector,
27+
) {
28+
}
1929
#[\Override]
2030
public function name(): string
2131
{
@@ -38,20 +48,37 @@ public function execute(Devkit $devkit, array $arguments): int
3848
$this->info("Namespace: {$context->namespace}");
3949
$this->info("PHP: {$context->phpVersion}");
4050

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

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

47-
// Scaffold devkit.php if requested
58+
// ── Phase 2: Install dev tools into .kcode/vendor/ ──────────────
59+
if (! $this->hasFlag($arguments, '--skip-install')) {
60+
$this->line();
61+
$this->info("Installing dev tools into .kcode/vendor/ ...");
62+
63+
$exitCode = $devkit->installTools($context->projectRoot);
64+
65+
if (0 !== $exitCode) {
66+
$this->warning("composer install failed (exit {$exitCode}). Run manually:");
67+
$this->line(" composer install --working-dir={$context->devkitDir} --no-interaction");
68+
69+
return $exitCode;
70+
}
71+
72+
$this->info("Dev tools installed in .kcode/vendor/bin/");
73+
}
74+
75+
// ── Phase 3: Scaffold devkit.php if requested ────────────────────
4876
if ($this->hasFlag($arguments, '--config')) {
4977
$this->scaffoldDevkitConfig($context->projectRoot);
5078
}
5179

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

5683
if ($migration->hasRedundancies) {
5784
$this->line();
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Devkit\Configuration;
6+
7+
use KaririCode\Devkit\Contract\ConfigGenerator;
8+
use KaririCode\Devkit\Core\ProjectContext;
9+
10+
/**
11+
* Generates `.kcode/composer.json` — the self-contained dev-toolchain manifest.
12+
*
13+
* When `kcode init` runs, this file is written to `.kcode/` and then
14+
* `composer install --working-dir=.kcode/ --no-interaction` is executed
15+
* by the InitCommand. Tools are installed into `.kcode/vendor/bin/`,
16+
* keeping the target project's own composer.json free of dev-tool deps.
17+
*
18+
* Version constraints come from `devkit.php` → `tools` key (optional).
19+
* Falls back to KaririCode-certified defaults when not specified.
20+
*
21+
* @since 1.0.0
22+
*/
23+
final class KcodeComposerGenerator implements ConfigGenerator
24+
{
25+
private const array DEFAULT_TOOL_VERSIONS = [
26+
'phpunit/phpunit' => '^12.5',
27+
'phpstan/phpstan' => '^2.0',
28+
'friendsofphp/php-cs-fixer' => '^3.64',
29+
'rector/rector' => '^2.0',
30+
'vimeo/psalm' => '^6.0',
31+
];
32+
33+
/** @var array<string, string> Maps devkit.php tool short-names → Composer package names */
34+
private const array TOOL_SHORT_NAME_MAP = [
35+
'phpunit' => 'phpunit/phpunit',
36+
'phpstan' => 'phpstan/phpstan',
37+
'php-cs-fixer' => 'friendsofphp/php-cs-fixer',
38+
'rector' => 'rector/rector',
39+
'psalm' => 'vimeo/psalm',
40+
];
41+
42+
#[\Override]
43+
public function toolName(): string
44+
{
45+
return 'kcode-composer';
46+
}
47+
48+
#[\Override]
49+
public function outputPath(): string
50+
{
51+
return 'composer.json';
52+
}
53+
54+
#[\Override]
55+
public function generate(ProjectContext $context): string
56+
{
57+
$require = $this->resolveVersions($context->toolVersions);
58+
59+
$manifest = [
60+
'name' => 'kariricode/devkit-tools',
61+
'description' => 'Dev toolchain managed by kcode — do not edit manually.',
62+
'require' => $require,
63+
'config' => [
64+
'bin-compat' => 'full',
65+
'optimize-autoloader' => true,
66+
'sort-packages' => true,
67+
'preferred-install' => 'dist',
68+
'allow-plugins' => [
69+
'infection/extension-installer' => false,
70+
],
71+
],
72+
'minimum-stability' => 'stable',
73+
'prefer-stable' => true,
74+
];
75+
76+
return json_encode($manifest, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . \PHP_EOL;
77+
}
78+
79+
/**
80+
* Merge user-supplied version constraints with defaults.
81+
* User constraints win on conflict; short-names are resolved to package names.
82+
*
83+
* @param array<string, string> $userVersions From devkit.php → tools
84+
* @return array<string, string>
85+
*/
86+
private function resolveVersions(array $userVersions): array
87+
{
88+
$resolved = self::DEFAULT_TOOL_VERSIONS;
89+
90+
foreach ($userVersions as $shortName => $constraint) {
91+
$package = self::TOOL_SHORT_NAME_MAP[$shortName] ?? $shortName;
92+
$resolved[$package] = $constraint;
93+
}
94+
95+
ksort($resolved);
96+
97+
return $resolved;
98+
}
99+
}

src/Core/ComposerResolver.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace KaririCode\Devkit\Core;
6+
7+
/**
8+
* Resolves the Composer binary path from the environment.
9+
*
10+
* Searches in order:
11+
* 1. `COMPOSER_BINARY` environment variable (explicit override)
12+
* 2. Global PATH via `command -v composer`
13+
* 3. Common installation paths (`/usr/local/bin/composer`, `/usr/bin/composer`)
14+
* 4. Composer PHAR in `HOME/.composer/composer`
15+
* 5. Fallback literal `composer` (assumes it is on the PATH at runtime)
16+
*
17+
* Extracted from `Devkit` as a standalone service to honour SRP.
18+
*
19+
* @since 1.0.0
20+
*/
21+
final readonly class ComposerResolver
22+
{
23+
private const array GLOBAL_PATHS = [
24+
'/usr/local/bin/composer',
25+
'/usr/bin/composer',
26+
];
27+
28+
/**
29+
* Return the absolute path to a usable Composer binary.
30+
*
31+
* Returns a single path string (never a shell fragment) so callers
32+
* can safely pass it as the first element of a `proc_open` command array.
33+
*/
34+
public function resolve(): string
35+
{
36+
// 1. Explicit environment override
37+
$environmentPath = getenv('COMPOSER_BINARY');
38+
39+
if (\is_string($environmentPath) && '' !== $environmentPath && is_executable($environmentPath)) {
40+
return $environmentPath;
41+
}
42+
43+
// 2. PATH lookup
44+
/** @psalm-suppress ForbiddenCode — shell_exec is intentional here; no user-controlled input */
45+
$pathBinary = trim((string) shell_exec('command -v composer 2>/dev/null'));
46+
47+
if ('' !== $pathBinary && is_executable($pathBinary)) {
48+
return $pathBinary;
49+
}
50+
51+
// 3. Known global install locations
52+
foreach (self::GLOBAL_PATHS as $candidate) {
53+
if (is_executable($candidate)) {
54+
return $candidate;
55+
}
56+
}
57+
58+
// 4. User-level Composer PHAR
59+
$home = getenv('HOME');
60+
61+
if (\is_string($home) && '' !== $home) {
62+
$userPhar = $home . '/.composer/composer';
63+
64+
if (is_executable($userPhar)) {
65+
return $userPhar;
66+
}
67+
}
68+
69+
// 5. Assume PATH fallback — let the OS fail with a clear error
70+
return 'composer';
71+
}
72+
}

src/Core/Devkit.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ final class Devkit
3333

3434
public function __construct(
3535
private readonly ProjectDetector $detector,
36+
private readonly ComposerResolver $composerResolver = new ComposerResolver(),
3637
) {
3738
}
3839

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

93+
/**
94+
* Install dev tools into `.kcode/vendor/` by running Composer.
95+
*
96+
* Reads the `.kcode/composer.json` manifest generated by `KcodeComposerGenerator`
97+
* and runs `composer install --working-dir=.kcode/` so tools are available
98+
* at `.kcode/vendor/bin/` for Tier-1 binary resolution.
99+
*
100+
* Output streams live to the user's terminal via STDIN/STDOUT/STDERR passthrough.
101+
*
102+
* @return int Composer exit code (0 = success)
103+
*
104+
* @since 1.0.0
105+
*/
106+
public function installTools(string $workingDirectory = '.'): int
107+
{
108+
$context = $this->context($workingDirectory);
109+
$devkitDirectory = $context->devkitDir;
110+
$composerManifestPath = $devkitDirectory . \DIRECTORY_SEPARATOR . 'composer.json';
111+
112+
if (! is_file($composerManifestPath)) {
113+
// KcodeComposerGenerator not registered — nothing to install
114+
return 0;
115+
}
116+
117+
$composerBinary = $this->composerResolver->resolve();
118+
$command = [
119+
$composerBinary,
120+
'install',
121+
'--working-dir=' . $devkitDirectory,
122+
'--no-interaction',
123+
'--prefer-dist',
124+
'--optimize-autoloader',
125+
'--no-scripts',
126+
];
127+
128+
$process = proc_open(
129+
$command,
130+
[0 => \STDIN, 1 => \STDOUT, 2 => \STDERR],
131+
$pipes,
132+
$workingDirectory,
133+
);
134+
135+
if (! \is_resource($process)) {
136+
return 1;
137+
}
138+
139+
return proc_close($process);
140+
}
141+
92142
// ── Run ───────────────────────────────────────────────────────
93143

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

236286
rmdir($dir);
237287
}
288+
238289
}

src/Core/ProcessExecutor.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,22 @@ public function execute(string $toolName, array $command): ToolResult
8383
*/
8484
public function resolveBinary(string $vendorBin): ?string
8585
{
86-
// Tier 1: PHAR-internal binary
87-
if ('' !== \Phar::running(false)) {
88-
$pharBin = \Phar::running(true) . '/' . $vendorBin;
89-
if (file_exists($pharBin)) {
90-
return $pharBin;
91-
}
86+
$basename = basename($vendorBin);
87+
88+
// Tier 1: .kcode/vendor/bin/ — tools installed by `kcode init`
89+
// This is the primary resolution path for the kcode toolchain.
90+
$kcodeBin = $this->workingDirectory . '/.kcode/vendor/bin/' . $basename;
91+
if (is_file($kcodeBin) && is_executable($kcodeBin)) {
92+
return $kcodeBin;
9293
}
9394

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

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

108108
return null;
109109
}
110+
110111
}

0 commit comments

Comments
 (0)