diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index 52d6133a..00000000
--- a/TODO.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# v3 TODO List
-
-Tracking items identified during the v2 → v3 migration audit.
-
----
-
-## Commands
-
-- [ ] Implement `craft` command (drives full build from `craft.yml`; should be easier with v3 vendor/registry mode)
-- [x] Migrate `micro:combine` command (combine `micro.sfx` with PHP code + INI injection)
-- [ ] Implement `dump-extensions` command (extract required extensions from `composer.json` / `composer.lock`)
-- [ ] Design and implement v3 dev toolchain commands (WIP — needs design decision):
- - [ ] `dev:extensions` / equivalent listing command
- - [ ] `dev:php-version`, `dev:ext-version`, `dev:lib-version`
- - [ ] Doc generation commands (`dev:gen-ext-docs`, `dev:gen-ext-dep-docs`, `dev:gen-lib-dep-docs`) — pending v3 doc design
-
----
-
-## Source Patches (SourcePatcher → Artifact migration)
-
-The following v2 `SourcePatcher` hooks are not yet migrated to v3 `src/Package/Artifact/` classes:
-
-- [ ] Migrate `patchSQLSRVWin32` — removes `/sdl` compile flag to prevent Zend build failure on Windows
-- [ ] Migrate `patchSQLSRVPhp85` — fixes `pdo_sqlsrv` directory layout for PHP 8.5
-- [ ] Migrate `patchYamlWin32` — patches `config.w32` `_a.lib` detection logic for the `yaml` extension
-- [ ] Migrate `patchImagickWith84` — applies PHP 8.4 compatibility patch for `imagick` based on version detection
-
----
-
-## Extension Package Classes (Unix)
-
-Extensions that had non-trivial v2 build logic and are missing a v3 `src/Package/Extension/` class:
-
-- [x] `gettext` — macOS: fix `config.m4` bracket syntax for cross-version compatibility + append frameworks to linker flags (critical for macOS linking; this is a Unix-side gap, not Windows-only)
-
----
-
-## Windows Extensions (Early Stage)
-
-Windows extension support is still in early stage. The following extensions had Windows-specific configure args or patches in v2 and are pending v3 Windows implementation:
-
-- [ ] `amqp` — Windows configure args
-- [ ] `com_dotnet` — Windows-only extension
-- [ ] `dom` — remove `dllmain.c` from `config.w32`
-- [ ] `ev` — fix `PHP_EV_SHARED` in `config.w32`
-- [ ] `gmssl` — add `CHECK_LIB("gmssl.lib")` to `config.w32`
-- [ ] `intl` — fix `PHP_INTL_SHARED` in `config.w32`
-- [ ] `lz4` — Windows configure args
-- [ ] `mbregex` — Windows configure args
-- [ ] `sqlsrv` / `pdo_sqlsrv` — complex conditional build logic (independent `sqlsrv` without `pdo_sqlsrv`)
-- [ ] `xml` — remove `dllmain.c` from `config.w32`; handles `soap`, `xmlreader`, `xmlwriter`, `simplexml`
-
----
-
-## Documentation
-
-- [ ] Write v3 user documentation (currently zero v3 docs)
diff --git a/config/pkg/ext/ext-deepclone.yml b/config/pkg/ext/ext-deepclone.yml
new file mode 100644
index 00000000..ae54dbb5
--- /dev/null
+++ b/config/pkg/ext/ext-deepclone.yml
@@ -0,0 +1,10 @@
+ext-deepclone:
+ type: php-extension
+ artifact:
+ source:
+ type: ghtagtar
+ repo: symfony/php-ext-deepclone
+ extract: php-src/ext/deepclone
+ metadata:
+ license-files: [LICENSE]
+ license: PHP-3.01
diff --git a/src/StaticPHP/Command/CraftCommand.php b/src/StaticPHP/Command/CraftCommand.php
new file mode 100644
index 00000000..9b1126ac
--- /dev/null
+++ b/src/StaticPHP/Command/CraftCommand.php
@@ -0,0 +1,224 @@
+addArgument('craft', null, 'Path to craft.yml file', WORKING_DIR . '/craft.yml');
+ }
+
+ public function handle(): int
+ {
+ $craft_file = $this->getArgument('craft');
+ if (!file_exists($craft_file)) {
+ $this->output->writeln('craft.yml not found, please create one!');
+ return static::USER_ERROR;
+ }
+
+ $craft = $this->validateAndParseCraftFile($craft_file);
+
+ // set verbosity
+ $this->output->setVerbosity($craft['verbosity']);
+
+ // apply env
+ array_walk($craft['extra-env'], fn ($v, $k) => f_putenv("{$k}={$v}"));
+
+ // run doctor
+ if ($craft['craft-options']['doctor']) {
+ $doctor = new Doctor($this->output, FIX_POLICY_AUTOFIX);
+ if ($doctor->checkAll()) {
+ Doctor::markPassed();
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln('Doctor check failed, please fix the issues and try again.');
+ return static::ENVIRONMENT_ERROR;
+ }
+ }
+
+ // parse download-options to installer's dl options
+ $build_options = $craft['build-options'];
+ if (!$craft['craft-options']['download']) {
+ $build_options['no-download'] = true;
+ }
+ foreach ($craft['download-options'] as $k => $v) {
+ $build_options["dl-{$k}"] = $v;
+ }
+
+ // parse SAPI
+ foreach ($craft['sapi'] as $name) {
+ $build_options["build-{$name}"] = true;
+ }
+
+ // clean build
+ if ($craft['clean-build']) {
+ FileSystem::resetDir(BUILD_ROOT_PATH);
+ FileSystem::resetDir(SOURCE_PATH);
+ }
+
+ $starttime = microtime(true);
+ // run installer
+ $installer = new PackageInstaller($build_options);
+ $installer->addBuildPackage('php');
+ $installer->run(true);
+
+ $usedtime = round(microtime(true) - $starttime, 1);
+ $this->output->writeln("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
+ $this->output->writeln("✔ BUILD SUCCESSFUL ({$usedtime} s)");
+ $this->output->writeln("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
+
+ $installer->printBuildPackageOutputs();
+
+ return static::SUCCESS;
+ }
+
+ /**
+ * Validate and parse craft.yml file to array.
+ *
+ * @param string $craft_file craft.yml path
+ * @return array{
+ * php-version: string,
+ * extensions: array,
+ * shared-extensions: array,
+ * packages: array,
+ * sapi: array,
+ * verbosity: int,
+ * debug: bool,
+ * clean-build: bool,
+ * build-options: array,
+ * download-options: array,
+ * extra-env: array,
+ * craft-options: array{
+ * doctor: bool,
+ * download: bool,
+ * build: bool
+ * }
+ * } Parsed craft content
+ */
+ private function validateAndParseCraftFile(string $craft_file): array
+ {
+ $build_options = $this->getApplication()->find('build:php')->getDefinition()->getOptions();
+ $download_options = $this->getApplication()->find('download')->getDefinition()->getOptions();
+ try {
+ $craft = Yaml::parseFile($craft_file);
+ } catch (ParseException $e) {
+ throw new ValidationException("Craft file '{$craft_file}' is broken: {$e->getMessage()}");
+ }
+ if (!is_assoc_array($craft)) {
+ throw new ValidationException("Craft file '{$craft_file}' must be an associative array.");
+ }
+
+ // check php-version
+ if (isset($craft['php-version']) && !preg_match('/^(\d+)(\.\d+)?(\.\d+)?$/', strval($craft['php-version']))) {
+ throw new ValidationException("Craft file '{$craft_file}' has invalid 'php-version' field, it should be in format of '8.0.0'.");
+ }
+
+ // check php extensions field
+ if (!isset($craft['extensions'])) {
+ throw new ValidationException("Craft file '{$craft_file}' must have 'extensions' field.");
+ }
+ // parse extension if not list
+ if (is_string($craft['extensions'])) {
+ $craft['extensions'] = parse_extension_list($craft['extensions']);
+ }
+
+ // check shared-extensions field
+ if (!isset($craft['shared-extensions'])) {
+ $craft['shared-extensions'] = [];
+ } elseif (is_string($craft['shared-extensions'])) {
+ $craft['shared-extensions'] = parse_extension_list($craft['shared-extensions']);
+ }
+
+ // check libs and additional packages
+ $v2_libs = parse_comma_list($craft['libs'] ?? []);
+ $v3_packages = parse_comma_list($craft['packages'] ?? []);
+ $craft['packages'] = array_merge($v2_libs, $v3_packages);
+
+ // check PHP SAPI
+ if (!isset($craft['sapi'])) {
+ throw new ValidationException('Craft file "sapi" is required.');
+ }
+ if (is_string($craft['sapi'])) {
+ $craft['sapi'] = parse_comma_list($craft['sapi']);
+ }
+
+ // verbosity
+ $verbosity_level = $craft['verbosity'] ?? OutputInterface::VERBOSITY_NORMAL;
+ $debug = $craft['debug'] ?? false;
+ if ($debug) {
+ $verbosity_level = OutputInterface::VERBOSITY_DEBUG;
+ }
+ $craft['verbosity'] = $verbosity_level;
+
+ // clean-build (if true, reset before all builds)
+ $craft['clean-build'] ??= false;
+
+ // build-options
+ if (isset($craft['build-options'])) {
+ if (!is_assoc_array($craft['build-options'])) {
+ throw new ValidationException('Craft file "build" options must be an associative array.');
+ }
+ foreach ($craft['build-options'] as $key => $value) {
+ if (!isset($build_options[$key])) {
+ throw new ValidationException('Craft file "build" option "' . $key . '" is invalid.');
+ }
+ if ($build_options[$key]->isArray() && !is_array($value)) {
+ throw new ValidationException('Craft file "build" option "' . $key . '" must be an array.');
+ }
+ }
+ } else {
+ $craft['build-options'] = [];
+ }
+
+ // download-options
+ if (isset($craft['download-options'])) {
+ if (!is_assoc_array($craft['download-options'])) {
+ throw new ValidationException('Craft file "download" options must be an associative array.');
+ }
+ foreach ($craft['download-options'] as $key => $value) {
+ if (!isset($download_options[$key])) {
+ throw new ValidationException('Craft file "download" option "' . $key . '" is invalid.');
+ }
+ if ($download_options[$key]->isArray() && !is_array($value)) {
+ throw new ValidationException('Craft file "download" option "' . $key . '" must be an array.');
+ }
+ }
+ } else {
+ $craft['download-options'] = [];
+ }
+
+ // post-parse: parse php-version field to download options
+ if (isset($craft['php-version'])) {
+ $craft['download-options']['with-php'] = strval($craft['php-version']);
+ $craft['download-options']['ignore-cache'] = (($craft['download-options']['ignore-cache'] ?? false) === true ? true : 'php-src');
+ }
+
+ // extra-env
+ if (isset($craft['extra-env'])) {
+ if (!is_assoc_array($craft['extra-env'])) {
+ throw new ValidationException('Craft file "extra-env" must be an associative array.');
+ }
+ } else {
+ $craft['extra-env'] = [];
+ }
+
+ // craft-options
+ $craft['craft-options']['doctor'] ??= true;
+ $craft['craft-options']['download'] ??= true;
+
+ return $craft;
+ }
+}
diff --git a/src/StaticPHP/Command/ResetCommand.php b/src/StaticPHP/Command/ResetCommand.php
index 207f6484..85928c2f 100644
--- a/src/StaticPHP/Command/ResetCommand.php
+++ b/src/StaticPHP/Command/ResetCommand.php
@@ -62,8 +62,10 @@ class ResetCommand extends BaseCommand
InteractiveTerm::indicateProgress("Removing: {$path}");
if (PHP_OS_FAMILY === 'Windows') {
+ Shell::passthruCallback(fn () => InteractiveTerm::advance());
// Force delete on Windows to handle git directories
$this->removeDirectoryWindows($path);
+ Shell::passthruCallback(null);
} else {
// Use FileSystem::removeDir for Unix systems
FileSystem::removeDir($path);
@@ -88,7 +90,6 @@ class ResetCommand extends BaseCommand
// Try using PowerShell for force deletion
$escaped_path = escapeshellarg($path);
- Shell::passthruCallback(fn () => InteractiveTerm::advance());
// Use PowerShell Remove-Item with -Force and -Recurse
$result = cmd()->execWithResult("powershell -Command \"Remove-Item -Path {$escaped_path} -Recurse -Force -ErrorAction SilentlyContinue\"", false);
@@ -106,6 +107,5 @@ class ResetCommand extends BaseCommand
if (is_dir($path)) {
FileSystem::removeDir($path);
}
- Shell::passthruCallback(null);
}
}
diff --git a/src/StaticPHP/ConsoleApplication.php b/src/StaticPHP/ConsoleApplication.php
index 0c8fec83..21e3ee8b 100644
--- a/src/StaticPHP/ConsoleApplication.php
+++ b/src/StaticPHP/ConsoleApplication.php
@@ -7,6 +7,7 @@ namespace StaticPHP;
use StaticPHP\Command\BuildLibsCommand;
use StaticPHP\Command\BuildTargetCommand;
use StaticPHP\Command\CheckUpdateCommand;
+use StaticPHP\Command\CraftCommand;
use StaticPHP\Command\Dev\DumpCapabilitiesCommand;
use StaticPHP\Command\Dev\DumpStagesCommand;
use StaticPHP\Command\Dev\EnvCommand;
@@ -67,6 +68,7 @@ class ConsoleApplication extends Application
new ResetCommand(),
new CheckUpdateCommand(),
new MicroCombineCommand(),
+ new CraftCommand(),
// dev commands
new ShellCommand(),
diff --git a/src/StaticPHP/Package/PackageInstaller.php b/src/StaticPHP/Package/PackageInstaller.php
index ac4aa2e0..b4c041df 100644
--- a/src/StaticPHP/Package/PackageInstaller.php
+++ b/src/StaticPHP/Package/PackageInstaller.php
@@ -154,10 +154,10 @@ class PackageInstaller
$this->resolvePackages();
}
- if ($this->interactive && !$disable_delay_msg) {
- // show install or build options in terminal with beautiful output
- $this->printInstallerInfo();
+ // show install or build options in terminal with beautiful output
+ $this->printInstallerInfo();
+ if ($this->interactive && !$disable_delay_msg) {
InteractiveTerm::notice('Build process will start after 2s ...' . PHP_EOL);
sleep(2);
}
diff --git a/src/StaticPHP/Package/TargetPackage.php b/src/StaticPHP/Package/TargetPackage.php
index d29cf923..ca80d83a 100644
--- a/src/StaticPHP/Package/TargetPackage.php
+++ b/src/StaticPHP/Package/TargetPackage.php
@@ -80,6 +80,14 @@ class TargetPackage extends LibraryPackage
if ($input !== null && $input->hasOption($key)) {
return $input->getOption($key);
}
+
+ // try builder options
+ $builder = ApplicationContext::has(PackageBuilder::class)
+ ? ApplicationContext::get(PackageBuilder::class)
+ : null;
+ if ($builder !== null && ($option = $builder->getOption($key)) !== null) {
+ return $option;
+ }
return $default;
}
diff --git a/src/StaticPHP/Util/FileSystem.php b/src/StaticPHP/Util/FileSystem.php
index cbb46603..5e86a1d9 100644
--- a/src/StaticPHP/Util/FileSystem.php
+++ b/src/StaticPHP/Util/FileSystem.php
@@ -287,10 +287,11 @@ class FileSystem
/**
* Remove directory recursively
*
- * @param string $dir Directory to remove
- * @return bool Success status
+ * @param string $dir Directory to remove
+ * @param null|callable $callback Callback for every single scan items
+ * @return bool Success status
*/
- public static function removeDir(string $dir): bool
+ public static function removeDir(string $dir, ?callable $callback = null): bool
{
$dir = self::convertPath($dir);
logger()->debug('Removing path recursively: "' . $dir . '"');
@@ -311,7 +312,9 @@ class FileSystem
}
// 遍历目录
foreach ($scan_list as $v) {
- InteractiveTerm::advance();
+ if ($callback) {
+ $callback($v);
+ }
// Unix 系统排除这俩目录
if ($v == '.' || $v == '..') {
continue;