From 836fa8a8545fc9546dd88812ebf74167e3c8a05f Mon Sep 17 00:00:00 2001 From: wheakerd Date: Wed, 11 Nov 2026 21:47:23 -0800 Subject: [PATCH] Read project settings only from the configuration file A setting that concerns the behavior of the project now comes only from skernel.json or skernel.json.dist, and a setting the file leaves out takes its default. The command-line options for those settings are gone: --php on every command, --watch on run, and --phar, --dev, --output, --composer, and --runtime on package. Options describe only the invocation: the global --working-dir, the directory and --composer of new, and the arguments after run --. The file gains a top-level composer, used by build, run, and package in place of package.composer, and a run section: watch, false by default, and watch-files, the name patterns of the watched files, by default *.php, .env, .env.*, composer.json, and composer.lock. A composer value with a / resolves against the project root; one without is looked up in PATH. package leaves the configuration files out of the staged copy. The release workflow and the binary check write the package path into a skernel.json. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check-binary.sh | 3 +- .github/workflows/release.yml | 5 +- README.md | 50 ++++--- README.zh-CN.md | 50 ++++--- skernel.schema.json | 50 +++++-- src/Command/BuildCommand.php | 15 +- src/Command/PackageCommand.php | 62 ++------- src/Command/ProjectRuntime.php | 25 ++-- src/Command/RunCommand.php | 18 +-- src/Configuration/ConfigurationLoader.php | 26 +++- src/Configuration/PackageConfiguration.php | 13 +- src/Configuration/ProjectConfiguration.php | 10 +- src/Configuration/RunConfiguration.php | 28 ++++ src/Configuration/Setting.php | 22 +++ src/Run/ProjectFiles.php | 22 +-- src/Run/ProjectRunner.php | 28 ++-- src/Toolchain/PhpReleases.php | 2 +- tests/BootstrapTest.php | 33 ++--- tests/Integration/PackageCommandTest.php | 84 +++++++++--- tests/Integration/ProjectCommandsTest.php | 129 ++++++++++++++++-- .../Configuration/ConfigurationLoaderTest.php | 107 +++++++++++++-- tests/Unit/Run/ProjectFilesTest.php | 47 +++++-- tests/Unit/Toolchain/PhpReleasesTest.php | 2 +- 23 files changed, 588 insertions(+), 243 deletions(-) create mode 100644 src/Configuration/RunConfiguration.php diff --git a/.github/scripts/check-binary.sh b/.github/scripts/check-binary.sh index b225698..574e179 100755 --- a/.github/scripts/check-binary.sh +++ b/.github/scripts/check-binary.sh @@ -35,7 +35,8 @@ fi env -i "$binary" list --raw "$binary" new "$work/app" -"$binary" package -d "$work/app" --output="$work/app-binary" +printf '{"package": {"output": "%s"}}\n' "$work/app-binary" >"$work/app/skernel.json" +"$binary" package -d "$work/app" mkdir "$work/elsewhere" mv "$work/app-binary" "$work/elsewhere/app" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0f6895..7ddbc7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,8 +85,11 @@ jobs: - name: Install the dependencies run: composer install --no-dev --no-interaction --no-progress + # The package path is a setting of the project, so it goes into SKERNEL's own configuration file. - name: Package SKERNEL - run: php bin/bootstrap.php package --output="$BINARY" + run: | + printf '{"package": {"output": "%s"}}\n' "$BINARY" > skernel.json + php bin/bootstrap.php package - name: Check the binary run: .github/scripts/check-binary.sh "$PWD/$BINARY" diff --git a/README.md b/README.md index 9def267..c2586a4 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,42 @@ Commands work on the SuperKernel application in the current directory, or in the ## Configuration file -Options left off the command line are read from `skernel.json` in the project's root directory, or from `skernel.json.dist` when `skernel.json` does not exist; the two files are never merged. Commit `skernel.json.dist` with the project's shared settings and keep machine-specific overrides in an ignored `skernel.json`. +A project's settings for SKERNEL's commands are read from `skernel.json` in the project's root directory, or from `skernel.json.dist` when `skernel.json` does not exist; the two files are never merged. They have no command-line options: the command line says only where to work, with the global `-d`/`--working-dir` option, and passes the application's arguments after `run --`. Commit `skernel.json.dist` with the project's shared settings and keep machine-specific settings in an ignored `skernel.json`. ```json { "$schema": "https://raw.githubusercontent.com/super-kernel/skernel/main/skernel.schema.json", "php": "8.4", + "composer": "composer", + "run": { + "watch": true, + "watch-files": ["*.php", ".env", ".env.*", "composer.json", "composer.lock"] + }, "package": { "runtime": "build/micro.sfx", "output": "target/my-app", - "composer": "composer", "dev": false, "phar": false } } ``` -The keys mirror the command options: `php` applies to every command, and the `package` section to `package`. Command-line options take precedence over the file, and the file over the built-in defaults. Relative paths in the file resolve against the project's root directory, while relative paths on the command line resolve against the current directory. An unknown key or a value of the wrong type stops the command with a message that names the file and the key. [`skernel.schema.json`](skernel.schema.json) describes the file for editors that support JSON Schema. +| Key | Used by | Default | +| --- | --- | --- | +| `php` | `build`, `run`, `package` | the latest PHP release that the root package's `require.php` allows, see [Managed PHP](#managed-php) | +| `composer` | `build`, `run`, `package` | the first working Composer 2 SKERNEL finds, or else its own, as for [`new`](#new) | +| `run.watch` | `run` | `false` | +| `run.watch-files` | `run` | `["*.php", ".env", ".env.*", "composer.json", "composer.lock"]` | +| `package.runtime` | `package` | the managed micro runtime | +| `package.output` | `package` | `target/`, or `target/.phar` for a PHAR | +| `package.dev` | `package` | `false` | +| `package.phar` | `package` | `false` | + +A setting that the file leaves out, and every setting of a project without the file, takes its default. Relative paths resolve against the project's root directory, and a `composer` value without a `/` is a name looked up in `PATH`. An unknown key or a value of the wrong type stops the command with a message that names the file and the key. [`skernel.schema.json`](skernel.schema.json) describes the file for editors that support JSON Schema. ## Managed PHP -SKERNEL builds, runs, and packages a project with a static PHP build of its own rather than with the PHP on your system. A PHP version is given as `major.minor`, such as `8.4`, which selects that branch's latest release on php.net, or as `major.minor.patch`, such as `8.4.27`. Without a version, SKERNEL selects the latest supported PHP release that satisfies the root package's `require.php`. +SKERNEL builds, runs, and packages a project with a static PHP build of its own rather than with the PHP on your system. The `php` setting gives the version as `major.minor`, such as `8.4`, which selects that branch's latest release on php.net, or as `major.minor.patch`, such as `8.4.27`. Without a version, SKERNEL selects the latest supported PHP release that satisfies the root package's `require.php`. The build contains the PHP extensions that the project's installation requires through `ext-*` requirements in the root manifest, in its development requirements when they are installed, and in every installed package, together with `phar`, which packaging needs, and `tokenizer`, which `build` needs. SKERNEL builds it once per platform, version, and extension set with a pinned release of [static-php-cli](https://github.com/crazywhalecc/static-php-cli), producing a static PHP CLI and a micro runtime for standalone binaries, and reuses it afterwards. The micro runtime reports `cli` as `PHP_SAPI`, so a standalone binary starts what requires the CLI, such as a Swoole server. On Linux, both are linked statically against musl and compiled for the architecture's baseline CPU, so they do not depend on the Linux distribution; SKERNEL builds them with a pinned release of [Zig](https://ziglang.org), which it verifies and installs into its home on first use. As the Zig Software Foundation asks of tools, Zig is downloaded from the [community mirrors](https://ziglang.org/download/community-mirrors/) in random order, moving on from a mirror that is too slow, and from ziglang.org only when no mirror serves it. @@ -100,12 +115,12 @@ A directory that exists and is not empty, a Composer that is not found or fails, ### run ```text -run [-d|--working-dir=] [--php=] [--watch] [--] [...] +run [-d|--working-dir=] [--] [...] ``` -`run` builds the project as `build` does and then runs it from source with its managed PHP, in a separate process in the project directory. `` go to the application, so `run swoole:start` starts its Swoole server; put options for the application after `--`. Without `--watch`, SKERNEL exits with the application's exit status. +`run` builds the project as `build` does and then runs it from source with its managed PHP, in a separate process in the project directory. `` go to the application, so `run swoole:start` starts its Swoole server; put options for the application after `--`. Unless the project watches, SKERNEL exits with the application's exit status. -With `--watch`, SKERNEL restarts the application whenever a project file changes. It checks once a second for changes to PHP files, `.env` and `.env.*` files, and `composer.lock`, leaving out the vendor directory, `target/`, and directories whose name starts with a dot, so the logs and caches a running application writes do not restart it. It sends the application SIGTERM and then SIGKILL after 10 seconds, and builds the project again, including its Composer autoloader, before it restarts it. An application that exits on its own, or whose build fails, is started again by the next change. +When the configuration file sets `run.watch` to `true`, SKERNEL restarts the application whenever a watched file changes. It checks once a second for changes to the files whose name matches a pattern of `run.watch-files`, with `*`, `?`, and `[...]`, by default PHP files, `.env` and `.env.*` files, `composer.json`, and `composer.lock`. It always leaves out the vendor directory, `target/`, and directories whose name starts with a dot, so the logs and caches a running application writes do not restart it. It sends the application SIGTERM and then SIGKILL after 10 seconds, and builds the project again, including its Composer autoloader, before it restarts it. An application that exits on its own, or whose build fails, is started again by the next change. SIGINT and SIGTERM stop the application before SKERNEL exits. The failures of `build`, and an application that cannot start, print a short message on STDERR and exit with status 1. @@ -115,7 +130,7 @@ SIGINT and SIGTERM stop the application before SKERNEL exits. The failures of `b clean [-d|--working-dir=] ``` -`clean` removes the project's `target/` directory and its `/.cache` build artifacts, and says what it removed. A package written elsewhere with `--output` or the `package.output` setting stays. The project's dependencies do not have to be installed. +`clean` removes the project's `target/` directory and its `/.cache` build artifacts, and says what it removed. A package written elsewhere through the `package.output` setting stays. The project's dependencies do not have to be installed. ### self-update @@ -130,12 +145,12 @@ Running from source, a latest release that cannot be read or has no verified bin ### build ```text -build [-d|--working-dir=] [--php=] +build [-d|--working-dir=] ``` `build` generates the build artifacts that the application loads at start and writes them to the project's `/.cache` directory, honouring its `config.vendor-dir`: the package records with their class maps, the attributes collected from those classes, a build stamp, and whatever the project's components write when the build publishes `SuperKernel\Event\ApplicationBuild`, such as the prepared proxies of `super-kernel/aop`. A SuperKernel application started without them, or with artifacts from an older `composer.lock`, stops with a message that names `skernel build`. The project must have its dependencies installed, including `super-kernel/metadata`, `super-kernel/context`, and `super-kernel/di`, and the build covers the packages of that installation. -Before it generates anything, `build` regenerates the project's Composer autoloader with Composer's `dump-autoload` command, so the build and the application find a class added since `composer install` without a `composer dump-autoload` of your own. It uses the Composer that [`new`](#new) uses without `--composer`: the first working Composer 2 it finds, or else its own. Composer applies the project's own autoloader settings, such as `config.optimize-autoloader` and `config.classmap-authoritative` in `composer.json`, and the development autoload rules follow the installation: a project installed with `--no-dev` stays without them. Composer runs in its own process, and so do the project's `pre-autoload-dump` and `post-autoload-dump` scripts that it runs. When Composer cannot be provided or fails, the build stops before it generates anything and keeps the previous artifacts. +Before it generates anything, `build` regenerates the project's Composer autoloader with Composer's `dump-autoload` command, so the build and the application find a class added since `composer install` without a `composer dump-autoload` of your own. It uses the Composer that the `composer` setting names, or else the first working Composer 2 it finds, or else its own, as [`new`](#new) does. Composer applies the project's own autoloader settings, such as `config.optimize-autoloader` and `config.classmap-authoritative` in `composer.json`, and the development autoload rules follow the installation: a project installed with `--no-dev` stays without them. Composer runs in its own process, and so do the project's `pre-autoload-dump` and `post-autoload-dump` scripts that it runs. When Composer cannot be provided or fails, the build stops before it generates anything and keeps the previous artifacts. The build runs in two separate processes of the managed PHP CLI, so SKERNEL never loads the project's code itself. The first loads the project's own autoloader, checks that the installed `super-kernel/metadata` uses the build protocol this SKERNEL generates, maps the classes of every installed package, collects the attributes of the classes that declare any, and publishes the records through the project's own `super-kernel/metadata` and `super-kernel/annotator`. A class that cannot be loaded or whose attribute cannot be created is skipped, even when loading it ends the process with a fatal error. The second process bootstraps the container from the records and then publishes `ApplicationBuild` through the project's PSR-14 event dispatcher, when the project has both the event and a dispatcher, without resolving or running the application. @@ -153,24 +168,23 @@ On success STDOUT carries one line: Build artifacts written to /path/to/my-app/vendor/.cache. ``` -When the project's `super-kernel/metadata` uses another build protocol, the build stops before it writes anything and says which side to upgrade: SKERNEL, for example with `skernel self-update`, or the project's SuperKernel packages. A project without `super-kernel/metadata` predates SKERNEL-generated artifacts and needs SuperKernel 1.0.0-alpha.2 or later. That, a missing project or dependencies, an invalid configuration file or `--php` value, a managed PHP or Composer that cannot be provided, a Composer that fails to regenerate the autoloader, or a failed build prints a short message on STDERR and exits with status 1. +When the project's `super-kernel/metadata` uses another build protocol, the build stops before it writes anything and says which side to upgrade: SKERNEL, for example with `skernel self-update`, or the project's SuperKernel packages. A project without `super-kernel/metadata` predates SKERNEL-generated artifacts and needs SuperKernel 1.0.0-alpha.2 or later. That, a missing project or dependencies, an invalid configuration file, a managed PHP or Composer that cannot be provided, a Composer that fails to regenerate the autoloader, or a failed build prints a short message on STDERR and exits with status 1. ### package ```text -package [-d|--working-dir=] [--php=] [--phar] [--dev] [--output=] - [--composer=] [--runtime=] +package [-d|--working-dir=] ``` -`package` turns the project into a standalone executable binary, or into a PHAR with `--phar`. It works on a temporary copy of the project, so the project itself is not changed: +`package` turns the project into a standalone executable binary, or into a PHAR when `package.phar` is `true`. It works on a temporary copy of the project, so the project itself is not changed: -1. It copies the project without its vendor directory, VCS metadata such as `.git`, its `target/` directory, and the `.env` and `.env.*` files in its root directory, which usually hold machine-local secrets; a packaged application reads its environment from the process environment. -2. It installs the copy's dependencies from the project's `composer.lock` with the Composer that [`new`](#new) would use: the binary given with `--composer`, a path or a name looked up in `PATH`, or else the first working Composer 2 it finds, or else its own. Development dependencies are left out unless `--dev` is given. The project must have a `composer.lock`, but its own dependencies need not be installed. Composer does not check platform requirements against the PHP that runs it, because the managed PHP is the package's platform. +1. It copies the project without its vendor directory, VCS metadata such as `.git`, its `target/` directory, its `skernel.json` and `skernel.json.dist`, and the `.env` and `.env.*` files in its root directory, which usually hold machine-local secrets; a packaged application reads its environment from the process environment. +2. It installs the copy's dependencies from the project's `composer.lock` with the Composer that `build` uses: the one the `composer` setting names, or else the first working Composer 2 it finds, or else its own. Development dependencies are left out unless `package.dev` is `true`. The project must have a `composer.lock`, but its own dependencies need not be installed. Composer does not check platform requirements against the PHP that runs it, because the managed PHP is the package's platform. 3. It runs `build` on the copy with the managed PHP of that installation and the same Composer. 4. It archives the copy into a PHAR whose stub loads the copy's autoloader, obtains the application container, and runs the application, so no entry script is needed. -5. It writes the managed micro runtime, or the file given with `--runtime`, followed by the PHAR to the output path, or only the PHAR with `--phar`, and makes the result executable. A micro runtime given with `--runtime` must report `cli` as `PHP_SAPI` for the result to start a Swoole server; with static-php-cli, build it with `--with-micro-fake-cli`. +5. It writes the managed micro runtime, or the file that `package.runtime` names, followed by the PHAR to the output path, or only the PHAR when `package.phar` is `true`, and makes the result executable. A micro runtime named by `package.runtime` must report `cli` as `PHP_SAPI` for the result to start a Swoole server; with static-php-cli, build it with `--with-micro-fake-cli`. -The output path is given with `--output`, or defaults to `target/`, or `target/.phar` with `--phar`, where `` is the last segment of the root package's name, or the project directory's name when the package has none. Everything the copy's Composer, build, and archive processes write goes to STDERR, and on success STDOUT carries one line: +The output path is `package.output`, by default `target/`, or `target/.phar` for a PHAR, where `` is the last segment of the root package's name, or the project directory's name when the package has none. Everything the copy's Composer, build, and archive processes write goes to STDERR, and on success STDOUT carries one line: ```text Package written to /path/to/my-app/target/my-app. diff --git a/README.zh-CN.md b/README.zh-CN.md index b8e376b..013eb7c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,27 +31,42 @@ SKERNEL 把 WARNING 及以上级别的日志(包括框架的异常报告)写 ## 配置文件 -命令行上没有给出的选项,从项目根目录的 `skernel.json` 读取;`skernel.json` 不存在时改读 `skernel.json.dist`。两个文件从不合并。把项目共享的设置写进 `skernel.json.dist` 并提交,本机特有的覆盖写进被忽略的 `skernel.json`。 +项目供 SKERNEL 各命令使用的设置,从项目根目录的 `skernel.json` 读取;`skernel.json` 不存在时改读 `skernel.json.dist`。两个文件从不合并。这些设置没有命令行选项:命令行只用全局选项 `-d`/`--working-dir` 说明在哪里工作,并在 `run --` 之后传递应用的参数。把项目共享的设置写进 `skernel.json.dist` 并提交,本机特有的设置写进被忽略的 `skernel.json`。 ```json { "$schema": "https://raw.githubusercontent.com/super-kernel/skernel/main/skernel.schema.json", "php": "8.4", + "composer": "composer", + "run": { + "watch": true, + "watch-files": ["*.php", ".env", ".env.*", "composer.json", "composer.lock"] + }, "package": { "runtime": "build/micro.sfx", "output": "target/my-app", - "composer": "composer", "dev": false, "phar": false } } ``` -键名与命令选项一一对应:`php` 适用于所有命令,`package` 一节适用于 `package`。命令行选项优先于配置文件,配置文件优先于内置默认值。配置文件里的相对路径相对于项目根目录解析,命令行上的相对路径则相对于当前目录解析。出现未知的键或类型不对的值时,命令会停止,并给出包含文件名和键名的提示。[`skernel.schema.json`](skernel.schema.json) 为支持 JSON Schema 的编辑器描述了这个文件。 +| 键 | 使用者 | 默认值 | +| --- | --- | --- | +| `php` | `build`、`run`、`package` | 根包 `require.php` 允许的最新 PHP 版本,见[受管 PHP](#受管-php) | +| `composer` | `build`、`run`、`package` | SKERNEL 找到的第一个可用的 Composer 2,否则是它自己的,与 [`new`](#new) 相同 | +| `run.watch` | `run` | `false` | +| `run.watch-files` | `run` | `["*.php", ".env", ".env.*", "composer.json", "composer.lock"]` | +| `package.runtime` | `package` | 受管 micro 运行时 | +| `package.output` | `package` | `target/`,PHAR 为 `target/.phar` | +| `package.dev` | `package` | `false` | +| `package.phar` | `package` | `false` | + +配置文件没有写出的设置,以及没有配置文件的项目的所有设置,都取默认值。相对路径相对于项目根目录解析;不含 `/` 的 `composer` 值是在 `PATH` 中查找的名称。出现未知的键或类型不对的值时,命令会停止,并给出包含文件名和键名的提示。[`skernel.schema.json`](skernel.schema.json) 为支持 JSON Schema 的编辑器描述了这个文件。 ## 受管 PHP -SKERNEL 用自己构建的静态 PHP 来构建、运行和打包项目,而不是系统上的 PHP。PHP 版本写成 `major.minor`(例如 `8.4`)时,选用 php.net 上该分支的最新发布;写成 `major.minor.patch`(例如 `8.4.27`)时精确使用该版本。不指定版本时,SKERNEL 选用满足根包 `require.php` 的最新受支持 PHP 版本。 +SKERNEL 用自己构建的静态 PHP 来构建、运行和打包项目,而不是系统上的 PHP。`php` 设置把版本写成 `major.minor`(例如 `8.4`)时,选用 php.net 上该分支的最新发布;写成 `major.minor.patch`(例如 `8.4.27`)时精确使用该版本。不指定版本时,SKERNEL 选用满足根包 `require.php` 的最新受支持 PHP 版本。 构建出的 PHP 包含项目安装所需的扩展,也就是根清单中、已安装时的开发依赖中,以及每个已安装包中的 `ext-*` 要求,另外再加上打包所需的 `phar` 和 `build` 所需的 `tokenizer`。SKERNEL 使用固定版本的 [static-php-cli](https://github.com/crazywhalecc/static-php-cli),按平台、版本和扩展组合各构建一次,产出静态 PHP CLI 和用于独立二进制的 micro 运行时,之后重复使用。micro 运行时把 `PHP_SAPI` 报告为 `cli`,所以独立二进制可以启动要求 CLI 的功能,例如 Swoole 服务。在 Linux 上,两者都静态链接 musl,并按该架构的基线 CPU 编译,不依赖具体的 Linux 发行版;SKERNEL 用固定版本的 [Zig](https://ziglang.org) 构建它们,首次使用时校验并安装到自己的主目录中。按照 Zig 软件基金会对下载工具的要求,Zig 以随机顺序从[社区镜像](https://ziglang.org/download/community-mirrors/)下载,某个镜像太慢就换下一个,没有镜像能提供时才从 ziglang.org 下载。 @@ -100,12 +115,12 @@ Project created in /path/to/my-app. ### run ```text -run [-d|--working-dir=] [--php=] [--watch] [--] [...] +run [-d|--working-dir=] [--] [...] ``` -`run` 先像 `build` 一样构建项目,再用受管 PHP 从源码运行项目,在项目目录中以单独的进程执行。`` 传给应用,所以 `run swoole:start` 会启动它的 Swoole 服务;要传给应用的选项放在 `--` 之后。不加 `--watch` 时,SKERNEL 以应用的退出码退出。 +`run` 先像 `build` 一样构建项目,再用受管 PHP 从源码运行项目,在项目目录中以单独的进程执行。`` 传给应用,所以 `run swoole:start` 会启动它的 Swoole 服务;要传给应用的选项放在 `--` 之后。项目不监视文件时,SKERNEL 以应用的退出码退出。 -加上 `--watch` 后,项目文件变化时 SKERNEL 会重启应用。它每秒检查一次 PHP 文件、`.env` 和 `.env.*` 文件以及 `composer.lock` 的变化,并跳过 vendor 目录、`target/` 和名称以点开头的目录,所以运行中的应用写入日志和缓存不会触发重启。重启时先向应用发送 SIGTERM,10 秒后发送 SIGKILL,并在重启前重新构建项目,包括它的 Composer autoloader。应用自行退出或构建失败后,下一次变化会再次启动它。 +配置文件把 `run.watch` 设为 `true` 时,被监视的文件变化后 SKERNEL 会重启应用。它每秒检查一次名称匹配 `run.watch-files` 中某个模式(可用 `*`、`?` 和 `[...]`)的文件,默认是 PHP 文件、`.env` 和 `.env.*` 文件、`composer.json` 以及 `composer.lock`。它总是跳过 vendor 目录、`target/` 和名称以点开头的目录,所以运行中的应用写入日志和缓存不会触发重启。重启时先向应用发送 SIGTERM,10 秒后发送 SIGKILL,并在重启前重新构建项目,包括它的 Composer autoloader。应用自行退出或构建失败后,下一次变化会再次启动它。 SIGINT 和 SIGTERM 会先停止应用,SKERNEL 再退出。出现 `build` 的任一种失败或应用无法启动时,会在 STDERR 上打印一条简短的提示,退出码为 1。 @@ -115,7 +130,7 @@ SIGINT 和 SIGTERM 会先停止应用,SKERNEL 再退出。出现 `build` 的 clean [-d|--working-dir=] ``` -`clean` 删除项目的 `target/` 目录和 `/.cache` 构建产物,并说明删除了什么。用 `--output` 或 `package.output` 设置写到其他位置的包不会被删除。项目的依赖不必已安装。 +`clean` 删除项目的 `target/` 目录和 `/.cache` 构建产物,并说明删除了什么。通过 `package.output` 设置写到其他位置的包不会被删除。项目的依赖不必已安装。 ### self-update @@ -130,12 +145,12 @@ self-update ### build ```text -build [-d|--working-dir=] [--php=] +build [-d|--working-dir=] ``` `build` 生成应用启动时加载的构建产物,写到项目的 `/.cache` 目录,vendor 目录遵循项目的 `config.vendor-dir`:包括带类映射的包记录、从这些类收集到的注解、构建戳,以及构建发布 `SuperKernel\Event\ApplicationBuild` 时项目各组件写出的产物,例如 `super-kernel/aop` 预先准备好的代理。SuperKernel 应用在缺少这些产物,或产物来自较旧的 `composer.lock` 时启动,会停止并给出提到 `skernel build` 的提示。项目必须已经安装好依赖,其中包括 `super-kernel/metadata`、`super-kernel/context` 和 `super-kernel/di`;构建覆盖的包以这次安装为准。 -`build` 在生成任何内容之前,先用 Composer 的 `dump-autoload` 命令重新生成项目的 Composer autoloader,所以 `composer install` 之后新增的类能被构建和应用找到,不需要自己运行 `composer dump-autoload`。它使用的 Composer 与不指定 `--composer` 时的 [`new`](#new) 相同:找到的第一个可用的 Composer 2,否则是 SKERNEL 自己的。Composer 采用项目自己的 autoloader 设置,例如 `composer.json` 中的 `config.optimize-autoloader` 和 `config.classmap-authoritative`;开发环境的自动加载规则随安装方式而定:用 `--no-dev` 安装的项目不会重新获得这些规则。Composer 在自己的进程中运行,它执行的项目 `pre-autoload-dump` 和 `post-autoload-dump` 脚本也是如此。无法提供 Composer 或 Composer 执行失败时,构建会在生成任何内容之前停止,并保留之前的产物。 +`build` 在生成任何内容之前,先用 Composer 的 `dump-autoload` 命令重新生成项目的 Composer autoloader,所以 `composer install` 之后新增的类能被构建和应用找到,不需要自己运行 `composer dump-autoload`。它使用 `composer` 设置指定的 Composer,否则是找到的第一个可用的 Composer 2,再否则是 SKERNEL 自己的,与 [`new`](#new) 相同。Composer 采用项目自己的 autoloader 设置,例如 `composer.json` 中的 `config.optimize-autoloader` 和 `config.classmap-authoritative`;开发环境的自动加载规则随安装方式而定:用 `--no-dev` 安装的项目不会重新获得这些规则。Composer 在自己的进程中运行,它执行的项目 `pre-autoload-dump` 和 `post-autoload-dump` 脚本也是如此。无法提供 Composer 或 Composer 执行失败时,构建会在生成任何内容之前停止,并保留之前的产物。 构建在受管 PHP CLI 的两个单独进程中运行,所以 SKERNEL 自己从不加载项目的代码。第一个进程加载项目自己的 autoloader,确认已安装的 `super-kernel/metadata` 使用本 SKERNEL 生成的构建协议,为每个已安装的包建立类映射,收集声明了注解的类的注解,再通过项目自己的 `super-kernel/metadata` 和 `super-kernel/annotator` 发布这些记录。无法加载的类,或注解无法创建的类,会被跳过,即使加载它时进程因致命错误而结束也是如此。第二个进程从这些记录引导出容器;项目同时具备该事件和事件分发器时,再通过项目的 PSR-14 事件分发器发布 `ApplicationBuild`。整个过程不解析也不运行应用。 @@ -153,24 +168,23 @@ SKERNEL skipped 2 classes whose attributes could not be collected: Build artifacts written to /path/to/my-app/vendor/.cache. ``` -项目的 `super-kernel/metadata` 使用其他构建协议时,构建会在写入任何内容之前停止,并说明该升级哪一方:SKERNEL(例如用 `skernel self-update`),或项目的 SuperKernel 包。没有 `super-kernel/metadata` 的项目早于由 SKERNEL 生成产物的做法,需要 SuperKernel 1.0.0-alpha.2 或更高版本。出现这种情况,或项目或依赖不存在、配置文件或 `--php` 的值无效、无法提供受管 PHP 或 Composer、Composer 重新生成 autoloader 失败、构建失败时,会在 STDERR 上打印一条简短的提示,退出码为 1。 +项目的 `super-kernel/metadata` 使用其他构建协议时,构建会在写入任何内容之前停止,并说明该升级哪一方:SKERNEL(例如用 `skernel self-update`),或项目的 SuperKernel 包。没有 `super-kernel/metadata` 的项目早于由 SKERNEL 生成产物的做法,需要 SuperKernel 1.0.0-alpha.2 或更高版本。出现这种情况,或项目或依赖不存在、配置文件无效、无法提供受管 PHP 或 Composer、Composer 重新生成 autoloader 失败、构建失败时,会在 STDERR 上打印一条简短的提示,退出码为 1。 ### package ```text -package [-d|--working-dir=] [--php=] [--phar] [--dev] [--output=] - [--composer=] [--runtime=] +package [-d|--working-dir=] ``` -`package` 把项目做成可直接执行的独立二进制,指定 `--phar` 时则做成 PHAR。它在项目的临时副本上工作,不会改动项目本身: +`package` 把项目做成可直接执行的独立二进制,`package.phar` 为 `true` 时则做成 PHAR。它在项目的临时副本上工作,不会改动项目本身: -1. 复制项目,但不包括 vendor 目录、`.git` 等 VCS 元数据、`target/` 目录,以及根目录下的 `.env` 和 `.env.*` 文件(这些文件通常保存本机密钥);打包后的应用从进程环境变量读取环境配置。 -2. 按项目的 `composer.lock` 为副本安装依赖,使用的 Composer 与 [`new`](#new) 相同:`--composer` 指定的程序(路径或在 `PATH` 中查找的名称),否则是找到的第一个可用的 Composer 2,再否则是 SKERNEL 自己的。除非指定 `--dev`,否则不安装开发依赖。项目必须有 `composer.lock`,但项目自身不必安装依赖。Composer 不会按运行它的那个 PHP 检查平台要求,因为包的运行平台是受管 PHP。 +1. 复制项目,但不包括 vendor 目录、`.git` 等 VCS 元数据、`target/` 目录、`skernel.json` 和 `skernel.json.dist`,以及根目录下的 `.env` 和 `.env.*` 文件(这些文件通常保存本机密钥);打包后的应用从进程环境变量读取环境配置。 +2. 按项目的 `composer.lock` 为副本安装依赖,使用的 Composer 与 `build` 相同:`composer` 设置指定的那个,否则是找到的第一个可用的 Composer 2,再否则是 SKERNEL 自己的。除非 `package.dev` 为 `true`,否则不安装开发依赖。项目必须有 `composer.lock`,但项目自身不必安装依赖。Composer 不会按运行它的那个 PHP 检查平台要求,因为包的运行平台是受管 PHP。 3. 用该次安装对应的受管 PHP 和同一个 Composer 对副本运行 `build`。 4. 把副本归档为 PHAR,它的 stub 加载副本的 autoloader、获取应用容器并运行应用,因此不需要入口脚本。 -5. 把受管 micro 运行时(或 `--runtime` 指定的文件)和 PHAR 依次写入输出路径;指定 `--phar` 时只写入 PHAR。结果文件会被设为可执行。用 `--runtime` 指定的 micro 运行时必须把 `PHP_SAPI` 报告为 `cli`,结果文件才能启动 Swoole 服务;使用 static-php-cli 时,构建时加上 `--with-micro-fake-cli`。 +5. 把受管 micro 运行时(或 `package.runtime` 指定的文件)和 PHAR 依次写入输出路径;`package.phar` 为 `true` 时只写入 PHAR。结果文件会被设为可执行。由 `package.runtime` 指定的 micro 运行时必须把 `PHP_SAPI` 报告为 `cli`,结果文件才能启动 Swoole 服务;使用 static-php-cli 时,构建时加上 `--with-micro-fake-cli`。 -输出路径由 `--output` 指定,默认是 `target/`,指定 `--phar` 时是 `target/.phar`;其中 `` 是根包名称的最后一段,根包没有名称时取项目目录名。副本的 Composer、构建和归档进程写出的所有内容都转到 STDERR;成功时 STDOUT 只有一行: +输出路径是 `package.output`,默认是 `target/`,PHAR 为 `target/.phar`;其中 `` 是根包名称的最后一段,根包没有名称时取项目目录名。副本的 Composer、构建和归档进程写出的所有内容都转到 STDERR;成功时 STDOUT 只有一行: ```text Package written to /path/to/my-app/target/my-app. diff --git a/skernel.schema.json b/skernel.schema.json index 692769b..c0b422a 100644 --- a/skernel.schema.json +++ b/skernel.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/super-kernel/skernel/main/skernel.schema.json", "title": "SKERNEL configuration", - "description": "Settings for the options left off SKERNEL's command line, read from skernel.json or skernel.json.dist in the project root.", + "description": "The settings of a project for SKERNEL's commands, read from skernel.json or skernel.json.dist in the project root. A setting left out keeps its default.", "type": "object", "additionalProperties": false, "properties": { @@ -12,7 +12,7 @@ "minLength": 1 }, "php": { - "description": "The managed PHP version: major.minor for that branch's latest release, or an exact major.minor.patch.", + "description": "The managed PHP version: major.minor for that branch's latest release, or an exact major.minor.patch; by default the latest release that the root package's require.php allows.", "type": "string", "pattern": "^[1-9][0-9]*\\.[0-9]+(\\.[0-9]+)?$", "examples": [ @@ -20,6 +20,39 @@ "8.4.27" ] }, + "composer": { + "description": "The Composer that build, run, and package use: a path, relative to the project root when it contains a /, or a name looked up in PATH; by default the first working Composer 2 or else SKERNEL's own.", + "type": "string", + "minLength": 1 + }, + "run": { + "description": "Settings of the run command.", + "type": "object", + "additionalProperties": false, + "properties": { + "watch": { + "description": "Whether run rebuilds and restarts the application when a watched file changes.", + "type": "boolean", + "default": false + }, + "watch-files": { + "description": "The name patterns of the watched files, with *, ?, and [...]; the vendor directory, target/, and directories whose name starts with a dot are never watched.", + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "default": [ + "*.php", + ".env", + ".env.*", + "composer.json", + "composer.lock" + ] + } + } + }, "package": { "description": "Settings of the package command.", "type": "object", @@ -31,22 +64,19 @@ "minLength": 1 }, "output": { - "description": "The package path, relative to the project root; by default target/.", - "type": "string", - "minLength": 1 - }, - "composer": { - "description": "The Composer binary that installs the packaged copy, a path or a name looked up in PATH, by default a working Composer 2 or else SKERNEL's own.", + "description": "The package path, relative to the project root; by default target/, or target/.phar for a PHAR.", "type": "string", "minLength": 1 }, "dev": { "description": "Whether the development dependencies are installed into the package.", - "type": "boolean" + "type": "boolean", + "default": false }, "phar": { "description": "Whether packaging stops at the PHAR instead of a standalone binary.", - "type": "boolean" + "type": "boolean", + "default": false } } } diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php index 1279fe4..3e895b1 100644 --- a/src/Command/BuildCommand.php +++ b/src/Command/BuildCommand.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -18,6 +17,8 @@ /** * Regenerates the project's Composer autoloader and generates its build artifacts with its managed PHP; the * application loads them at start. + * + * The PHP version and the Composer come from the project's configuration file. */ #[AttributeCommand(name: 'build', description: 'Generate the build artifacts that the project loads at start')] final class BuildCommand extends Command @@ -30,16 +31,6 @@ public function __construct( parent::__construct(); } - protected function configure(): void - { - $this->addOption( - 'php', - null, - InputOption::VALUE_REQUIRED, - 'The PHP version that runs the project, such as 8.4 or 8.4.27', - ); - } - /** * @throws RuntimeException When the project or its configuration is invalid, its managed PHP or Composer cannot be * provided, the project's build protocol differs, or the build cannot start or fails @@ -47,7 +38,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - [$project, $runtime, $composer] = $this->projectRuntime->resolve($input, $errorOutput); + [$project, , $runtime, $composer] = $this->projectRuntime->resolve($input, $errorOutput); $this->projectBuilder->build( $project, $runtime->php, diff --git a/src/Command/PackageCommand.php b/src/Command/PackageCommand.php index 24b673c..3dc5fe3 100644 --- a/src/Command/PackageCommand.php +++ b/src/Command/PackageCommand.php @@ -12,20 +12,16 @@ use Src\Package\PackageFile; use Src\Package\PharBuilder; use Src\Package\ProjectCopier; -use Src\Project\AbsolutePath; use Src\Project\ProjectLocator; use Src\Toolchain\ManagedPhp; -use Src\Toolchain\PhpVersion; use SuperKernel\Command\Attribute\Command as AttributeCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use function basename; -use function getcwd; use function is_file; use function sprintf; use function strrchr; @@ -37,7 +33,7 @@ /** * Builds a staged copy of the project with its managed PHP and packages it as a standalone binary or a PHAR. * - * Options left off the command line come from the project's SKERNEL configuration file, then from the defaults. + * The PHP version, the Composer, and what is packaged where come from the project's configuration file. */ #[AttributeCommand(name: 'package', description: 'Package the project as a standalone binary or a PHAR')] final class PackageCommand extends Command @@ -55,31 +51,6 @@ public function __construct( parent::__construct(); } - protected function configure(): void - { - $this->addOption('phar', null, InputOption::VALUE_NONE, 'Stop at the PHAR instead of a standalone binary'); - $this->addOption('dev', null, InputOption::VALUE_NONE, 'Install the development dependencies into the package'); - $this->addOption('output', null, InputOption::VALUE_REQUIRED, 'The package path, by default target/'); - $this->addOption( - 'php', - null, - InputOption::VALUE_REQUIRED, - 'The PHP version that runs the package, such as 8.4 or 8.4.27', - ); - $this->addOption( - 'composer', - null, - InputOption::VALUE_REQUIRED, - "The Composer binary that installs the packaged copy, by default a working Composer 2 or else SKERNEL's own", - ); - $this->addOption( - 'runtime', - null, - InputOption::VALUE_REQUIRED, - 'A micro.sfx that replaces the managed micro runtime of the standalone binary', - ); - } - /** * @throws RuntimeException When the project or its configuration is invalid, or staging, installing, building, * or writing the package fails @@ -89,12 +60,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $project = $this->projectLocator->find($input->getOption('working-dir')); $settings = $this->configurationLoader->load($project->directory); $configuration = $settings->package; - $workingDirectory = (string) getcwd(); - $php = $input->getOption('php'); - - if (null !== $php && !PhpVersion::isValid($php)) { - throw new RuntimeException('The --php option must be a PHP version such as 8.4 or 8.4.27.'); - } if (!is_file($project->directory . '/composer.lock')) { throw new RuntimeException(sprintf( @@ -103,9 +68,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int )); } - $phar = $input->getOption('phar') || true === $configuration->phar; - $runtime = $input->getOption('runtime'); - $runtime = null === $runtime ? $configuration->runtime : AbsolutePath::resolve($runtime, $workingDirectory); + $phar = $configuration->phar; + $runtime = $configuration->runtime; if (!$phar && null !== $runtime && !is_file($runtime)) { throw new RuntimeException(sprintf('The micro runtime %s was not found.', $runtime)); @@ -114,9 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $name = null === $project->name ? basename($project->directory) : substr((string) strrchr('/' . $project->name, '/'), 1); - $target = $input->getOption('output'); - $target = null === $target ? $configuration->output : AbsolutePath::resolve($target, $workingDirectory); - $target ??= $project->directory . '/target/' . $name . ($phar ? '.phar' : ''); + $target = $configuration->output ?? $project->directory . '/target/' . $name . ($phar ? '.phar' : ''); $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $staging = tempnam(sys_get_temp_dir(), 'skernel-package-'); @@ -125,19 +87,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { + // The configuration file holds SKERNEL's settings for this machine's builds, which the package never reads. ProjectCopier::copy($project->directory, $staging . '/project', [ $project->vendorDirectory, $project->directory . '/target', + $project->directory . '/skernel.json', + $project->directory . '/skernel.json.dist', $target, ]); - $composer = $this->composerLocator->command( - $input->getOption('composer') ?? $configuration->composer, - $errorOutput, - ); + $composer = $this->composerLocator->command($settings->composer, $errorOutput); $status = $this->dependencyInstaller->install( $staging . '/project', $composer, - $input->getOption('dev') || true === $configuration->dev, + $configuration->dev, $errorOutput, ); @@ -149,11 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $staged = $this->projectLocator->locate($staging . '/project'); - $managed = $this->managedPhp->runtime( - $staged, - $php ?? $settings->php, - $errorOutput, - ); + $managed = $this->managedPhp->runtime($staged, $settings->php, $errorOutput); $this->projectBuilder->build( $staged, $managed->php, diff --git a/src/Command/ProjectRuntime.php b/src/Command/ProjectRuntime.php index efba577..1c9c7c7 100644 --- a/src/Command/ProjectRuntime.php +++ b/src/Command/ProjectRuntime.php @@ -6,18 +6,18 @@ use Src\Composer\ComposerLocator; use Src\Configuration\ConfigurationLoader; +use Src\Configuration\ProjectConfiguration; use Src\Project\Project; use Src\Project\ProjectLocator; use Src\Toolchain\ManagedPhp; -use Src\Toolchain\PhpVersion; use Src\Toolchain\Runtime; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** - * Provides what a command that builds an installed project needs: the project from `--working-dir`, its managed PHP - * from `--php` or the project's configuration file, and the Composer that regenerates its autoloader. + * Provides what a command that builds an installed project needs: the project from `--working-dir`, its configuration + * file, its managed PHP, and the Composer that regenerates its autoloader. */ final readonly class ProjectRuntime { @@ -31,7 +31,9 @@ public function __construct( } /** - * @return array{Project, Runtime, non-empty-list} The project, its managed PHP, and the Composer command + * Returns the project, its settings, its managed PHP, and the command that runs its Composer. + * + * @return array{Project, ProjectConfiguration, Runtime, non-empty-list} * * @throws RuntimeException When the project or its configuration is invalid, or its managed PHP or Composer cannot * be provided @@ -39,20 +41,13 @@ public function __construct( public function resolve(InputInterface $input, OutputInterface $errorOutput): array { $project = $this->projectLocator->locate($input->getOption('working-dir')); - $php = $input->getOption('php'); - - if (null !== $php && !PhpVersion::isValid($php)) { - throw new RuntimeException('The --php option must be a PHP version such as 8.4 or 8.4.27.'); - } + $configuration = $this->configurationLoader->load($project->directory); return [ $project, - $this->managedPhp->runtime( - $project, - $php ?? $this->configurationLoader->load($project->directory)->php, - $errorOutput, - ), - $this->composerLocator->command(null, $errorOutput), + $configuration, + $this->managedPhp->runtime($project, $configuration->php, $errorOutput), + $this->composerLocator->command($configuration->composer, $errorOutput), ]; } } diff --git a/src/Command/RunCommand.php b/src/Command/RunCommand.php index eacce96..04c53db 100644 --- a/src/Command/RunCommand.php +++ b/src/Command/RunCommand.php @@ -10,15 +10,14 @@ use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; /** - * Builds and runs the project from source with its managed PHP, and with --watch rebuilds and restarts it whenever its - * files change. + * Builds and runs the project from source with its managed PHP, and when the project's configuration file enables + * watching, rebuilds and restarts it whenever a watched file changes. */ -#[AttributeCommand(name: 'run', description: 'Build and run the project, and restart it on changes with --watch')] +#[AttributeCommand(name: 'run', description: 'Build and run the project, and restart it on changes when it watches')] final class RunCommand extends Command { public function __construct( @@ -36,13 +35,6 @@ protected function configure(): void InputArgument::IS_ARRAY, 'The arguments of the application, such as swoole:start; put options after --', ); - $this->addOption('watch', null, InputOption::VALUE_NONE, 'Restart the application when project files change'); - $this->addOption( - 'php', - null, - InputOption::VALUE_REQUIRED, - 'The PHP version that runs the project, such as 8.4 or 8.4.27', - ); } /** @@ -54,14 +46,14 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - [$project, $runtime, $composer] = $this->projectRuntime->resolve($input, $errorOutput); + [$project, $configuration, $runtime, $composer] = $this->projectRuntime->resolve($input, $errorOutput); return $this->projectRunner->run( $project, $runtime->php, $composer, $input->getArgument('arguments'), - $input->getOption('watch'), + $configuration->run, (string) $this->getApplication()?->getVersion(), $output, $errorOutput, diff --git a/src/Configuration/ConfigurationLoader.php b/src/Configuration/ConfigurationLoader.php index d845172..3c0711b 100644 --- a/src/Configuration/ConfigurationLoader.php +++ b/src/Configuration/ConfigurationLoader.php @@ -10,11 +10,14 @@ use Symfony\Component\Console\Exception\RuntimeException; use function is_file; +use function str_contains; /** - * Reads a project's `skernel.json`, or `skernel.json.dist` when it is absent, into validated settings. + * Reads a project's `skernel.json`, or `skernel.json.dist` when it is absent, into validated settings; a setting that + * the file leaves out keeps its default. * - * The two files are never merged. Relative paths in the file resolve against the project directory. + * The two files are never merged. Relative paths in the file resolve against the project directory; a Composer + * without a `/` is a name looked up in PATH. */ final readonly class ConfigurationLoader { @@ -36,13 +39,21 @@ public function load(string $projectDirectory): ProjectConfiguration $settings = Setting::object(JsonFile::decode($projectDirectory . '/' . $file, false), $file, '', [ '$schema', 'php', + 'composer', + 'run', 'package', ]); Setting::string($settings['$schema'] ?? null, $file, '$schema'); + $composer = Setting::string($settings['composer'] ?? null, $file, 'composer'); + $run = Setting::object($settings['run'] ?? new stdClass(), $file, 'run', [ + 'watch', + 'watch-files', + ]); + $watch = Setting::boolean($run['watch'] ?? null, $file, 'run.watch') ?? false; + $watchFiles = Setting::list($run['watch-files'] ?? null, $file, 'run.watch-files'); $package = Setting::object($settings['package'] ?? new stdClass(), $file, 'package', [ 'runtime', 'output', - 'composer', 'dev', 'phar', ]); @@ -51,12 +62,15 @@ public function load(string $projectDirectory): ProjectConfiguration return new ProjectConfiguration( Setting::version($settings['php'] ?? null, $file, 'php'), + null !== $composer && str_contains($composer, '/') + ? AbsolutePath::resolve($composer, $projectDirectory) + : $composer, + null === $watchFiles ? new RunConfiguration($watch) : new RunConfiguration($watch, $watchFiles), new PackageConfiguration( null === $runtime ? null : AbsolutePath::resolve($runtime, $projectDirectory), null === $output ? null : AbsolutePath::resolve($output, $projectDirectory), - Setting::string($package['composer'] ?? null, $file, 'package.composer'), - Setting::boolean($package['dev'] ?? null, $file, 'package.dev'), - Setting::boolean($package['phar'] ?? null, $file, 'package.phar'), + Setting::boolean($package['dev'] ?? null, $file, 'package.dev') ?? false, + Setting::boolean($package['phar'] ?? null, $file, 'package.phar') ?? false, ), ); } diff --git a/src/Configuration/PackageConfiguration.php b/src/Configuration/PackageConfiguration.php index b1e874c..f16fda7 100644 --- a/src/Configuration/PackageConfiguration.php +++ b/src/Configuration/PackageConfiguration.php @@ -5,16 +5,21 @@ namespace Src\Configuration; /** - * The `package` settings of a project's SKERNEL configuration file; null leaves a setting to its option or default. + * The `package` settings of a project's SKERNEL configuration file, with their defaults. */ final readonly class PackageConfiguration { + /** + * @param string|null $runtime An absolute micro.sfx path, or null for the managed micro runtime + * @param string|null $output An absolute package path, or null for `target/` or `target/.phar` + * @param bool $dev Whether the development dependencies are installed into the package + * @param bool $phar Whether packaging stops at the PHAR instead of a standalone binary + */ public function __construct( public ?string $runtime = null, public ?string $output = null, - public ?string $composer = null, - public ?bool $dev = null, - public ?bool $phar = null, + public bool $dev = false, + public bool $phar = false, ) { } diff --git a/src/Configuration/ProjectConfiguration.php b/src/Configuration/ProjectConfiguration.php index e8496fa..bbbf86d 100644 --- a/src/Configuration/ProjectConfiguration.php +++ b/src/Configuration/ProjectConfiguration.php @@ -5,12 +5,20 @@ namespace Src\Configuration; /** - * The settings of a project's SKERNEL configuration file; null leaves a setting to its option or default. + * The settings of a project's SKERNEL configuration file, with their defaults. */ final readonly class ProjectConfiguration { + /** + * @param string|null $php A PHP version, or null for the latest one that the root package's `require.php` + * allows + * @param string|null $composer An absolute Composer path or a name looked up in PATH, or null for the first + * working Composer 2 or else SKERNEL's own + */ public function __construct( public ?string $php = null, + public ?string $composer = null, + public RunConfiguration $run = new RunConfiguration(), public PackageConfiguration $package = new PackageConfiguration(), ) { diff --git a/src/Configuration/RunConfiguration.php b/src/Configuration/RunConfiguration.php new file mode 100644 index 0000000..096a4c9 --- /dev/null +++ b/src/Configuration/RunConfiguration.php @@ -0,0 +1,28 @@ + $watchFiles The name patterns of the watched files, with `*`, `?`, and `[...]` + */ + public function __construct( + public bool $watch = false, + public array $watchFiles = [ + '*.php', + '.env', + '.env.*', + 'composer.json', + 'composer.lock', + ], + ) + { + } +} diff --git a/src/Configuration/Setting.php b/src/Configuration/Setting.php index cc50435..5c8ce17 100644 --- a/src/Configuration/Setting.php +++ b/src/Configuration/Setting.php @@ -9,9 +9,12 @@ use Symfony\Component\Console\Exception\RuntimeException; use function array_diff; +use function array_filter; +use function array_is_list; use function array_keys; use function get_object_vars; use function implode; +use function is_array; use function is_bool; use function is_string; use function sprintf; @@ -60,6 +63,25 @@ public static function string(mixed $value, string $file, string $key): ?string return $value; } + /** + * @return non-empty-list|null + * + * @throws RuntimeException When a present value is not a non-empty list of non-empty strings + */ + public static function list(mixed $value, string $file, string $key): ?array + { + if (null !== $value && ( + !is_array($value) + || [] === $value + || !array_is_list($value) + || [] !== array_filter($value, static fn(mixed $item): bool => !is_string($item) || '' === $item) + )) { + throw new RuntimeException(sprintf('%s: "%s" must be a non-empty list of non-empty strings.', $file, $key)); + } + + return $value; + } + /** @throws RuntimeException When a present value is not a boolean */ public static function boolean(mixed $value, string $file, string $key): ?bool { diff --git a/src/Run/ProjectFiles.php b/src/Run/ProjectFiles.php index 03d0dbf..45ae75b 100644 --- a/src/Run/ProjectFiles.php +++ b/src/Run/ProjectFiles.php @@ -11,25 +11,30 @@ use SplFileInfo; use Src\Project\Project; +use function array_filter; use function clearstatcache; use function filemtime; use function filesize; +use function fnmatch; use function in_array; use function ksort; -use function str_ends_with; use function str_starts_with; /** - * Takes a snapshot of the project files whose changes restart a watched application: its PHP files, its `.env` and - * `.env.*` files, and its `composer.lock`. + * Takes a snapshot of the project files whose changes restart a watched application: the files whose name matches one + * of the watched patterns, with `*`, `?`, and `[...]`. * * The vendor directory, `target/`, and directories whose name starts with a dot are left out, as are other files, so * that the logs and caches a running application writes do not restart it. */ final class ProjectFiles { - /** @return array The modification time and size of each file by its path */ - public static function snapshot(Project $project): array + /** + * @param non-empty-list $patterns The name patterns of the watched files + * + * @return array The modification time and size of each file by its path + */ + public static function snapshot(Project $project, array $patterns): array { clearstatcache(); $excluded = [ @@ -51,12 +56,7 @@ public static function snapshot(Project $project): array ) as $file) { $name = $file->getFilename(); - if ( - !str_ends_with($name, '.php') - && 'composer.lock' !== $name - && '.env' !== $name - && !str_starts_with($name, '.env.') - ) { + if ([] === array_filter($patterns, static fn(string $pattern): bool => fnmatch($pattern, $name))) { continue; } diff --git a/src/Run/ProjectRunner.php b/src/Run/ProjectRunner.php index f6130fa..c7f0810 100644 --- a/src/Run/ProjectRunner.php +++ b/src/Run/ProjectRunner.php @@ -5,6 +5,7 @@ namespace Src\Run; use Src\Build\ProjectBuilder; +use Src\Configuration\RunConfiguration; use Src\Process\Executable; use Src\Project\Project; use Symfony\Component\Console\Exception\RuntimeException; @@ -27,7 +28,7 @@ use const SIGTERM; /** - * Runs a project from source in a separate PHP process, and with watching restarts it whenever its files change. + * Runs a project from source in a separate PHP process, and when watching restarts it whenever a watched file changes. * * Every start builds the project first, so the application loads an autoloader and artifacts that match its sources. * The process runs the standard application entry sequence in the project directory. A watched application that exits @@ -66,6 +67,7 @@ public function __construct(private ProjectBuilder $projectBuilder) * @param string $phpBinary A path or a name looked up in PATH * @param non-empty-list $composer The command that runs Composer * @param list $arguments The arguments of the application + * @param RunConfiguration $run Whether and which files the run watches * @param string $skernelVersion The running SKERNEL's version, `UNKNOWN` or empty when it has none * * @return int The exit status of the application @@ -74,14 +76,14 @@ public function __construct(private ProjectBuilder $projectBuilder) * temporary entry script cannot be written */ public function run( - Project $project, - string $phpBinary, - array $composer, - array $arguments, - bool $watch, - string $skernelVersion, - OutputInterface $output, - OutputInterface $errorOutput, + Project $project, + string $phpBinary, + array $composer, + array $arguments, + RunConfiguration $run, + string $skernelVersion, + OutputInterface $output, + OutputInterface $errorOutput, ): int { $command = [ @@ -158,7 +160,7 @@ public function run( } $process = $start(); - $snapshot = $watch ? ProjectFiles::snapshot($project) : []; + $snapshot = $run->watch ? ProjectFiles::snapshot($project, $run->watchFiles) : []; $checked = hrtime(true); $reported = false; @@ -171,7 +173,7 @@ public function run( return $process?->getExitCode() ?? 1; } - if (!$running && !$watch) { + if (!$running && !$run->watch) { return $process?->getExitCode() ?? 1; } @@ -183,9 +185,9 @@ public function run( $reported = true; } - if ($watch && self::WATCH_INTERVAL <= hrtime(true) - $checked) { + if ($run->watch && self::WATCH_INTERVAL <= hrtime(true) - $checked) { $checked = hrtime(true); - $current = ProjectFiles::snapshot($project); + $current = ProjectFiles::snapshot($project, $run->watchFiles); if ($current !== $snapshot) { $snapshot = $current; diff --git a/src/Toolchain/PhpReleases.php b/src/Toolchain/PhpReleases.php index cfc0d89..35194c9 100644 --- a/src/Toolchain/PhpReleases.php +++ b/src/Toolchain/PhpReleases.php @@ -61,7 +61,7 @@ public function resolve(?string $requested, ?string $constraint): string if ([] === $versions) { throw new RuntimeException(sprintf( - 'No supported PHP release satisfies the requirement %s; set "php" in skernel.json or pass --php.', + 'No supported PHP release satisfies the requirement %s; set "php" in skernel.json.', $constraint, )); } diff --git a/tests/BootstrapTest.php b/tests/BootstrapTest.php index abef84a..8b5bfa5 100644 --- a/tests/BootstrapTest.php +++ b/tests/BootstrapTest.php @@ -27,12 +27,14 @@ use function getenv; use function implode; use function json_decode; +use function json_encode; use function mkdir; use function preg_replace; use function putenv; use function symlink; use function sys_get_temp_dir; use function tempnam; +use function time; use function touch; use function unlink; @@ -152,9 +154,23 @@ public function testBuildWritesTheBuildArtifactsOfTheProjectWithItsManagedPhp(): '', ]))); self::assertTrue(chmod($home . '/path/composer', 0o755)); + // SKERNEL has no configuration file, so php.net's answer, cached here, selects the running PHP's version. + self::assertTrue(mkdir($home . '/cache', 0o700)); + self::assertNotFalse(file_put_contents($home . '/cache/php-releases.json', (string) json_encode([ + 'active' => [ + 'checked' => time(), + 'data' => [ + (string) PHP_MAJOR_VERSION => [ + PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION => [ + 'version' => $version, + ], + ], + ], + ], + ]))); $output = $this->bootstrap( - 'build --php=' . $version . ' -d ' . escapeshellarg($root), + 'build -d ' . escapeshellarg($root), $status, '2>/dev/null', 'SKERNEL_HOME=' . escapeshellarg($home) . ' PATH=' . escapeshellarg($home . '/path:' . getenv('PATH')), @@ -175,21 +191,6 @@ public function testBuildWritesTheBuildArtifactsOfTheProjectWithItsManagedPhp(): } } - public function testBuildRejectsAPhpOptionThatIsNotAVersion(): void - { - $output = $this->bootstrap( - 'build --php=php8.4 -d ' . escapeshellarg(dirname(__DIR__)), - $status, - '2>&1 >/dev/null', - ); - - self::assertSame(1, $status); - self::assertStringContainsString( - 'The --php option must be a PHP version such as 8.4 or 8.4.27.', - (string) preg_replace('/\s+/', ' ', implode(' ', $output)), - ); - } - public function testBuildRejectsADirectoryWithoutAProject(): void { $missing = dirname(__DIR__) . '/missing'; diff --git a/tests/Integration/PackageCommandTest.php b/tests/Integration/PackageCommandTest.php index c2632d9..e6507af 100644 --- a/tests/Integration/PackageCommandTest.php +++ b/tests/Integration/PackageCommandTest.php @@ -166,9 +166,12 @@ protected function tearDown(): void Directory::remove($this->directory); } - public function testPackagesAPharWithoutMetadataSecretsOrOldOutput(): void + public function testPackagesAPharWithoutMetadataSecretsConfigurationOrOldOutput(): void { - $output = $this->package('--phar --php=' . $this->version . ' --composer=' . $this->composer(), $status); + $this->settings([ + 'phar' => true, + ]); + $output = $this->package($status); self::assertSame(0, $status, implode("\n", $output)); self::assertSame([ @@ -204,7 +207,10 @@ public function testPackagesAPharWithoutMetadataSecretsOrOldOutput(): void public function testPackagesAStandaloneBinaryWithTheManagedMicroRuntime(): void { - $output = $this->package('--dev --php=' . $this->version . ' --composer=' . $this->composer(), $status); + $this->settings([ + 'dev' => true, + ]); + $output = $this->package($status); self::assertSame(0, $status, implode("\n", $output)); self::assertSame([ @@ -219,31 +225,50 @@ public function testPackagesAStandaloneBinaryWithTheManagedMicroRuntime(): void self::assertStringStartsWith('MICROwrite($this->project . '/skernel.json.dist', (string) json_encode([ - 'php' => $this->version, - 'package' => [ - 'composer' => $this->directory . '/bin/composer', - 'output' => 'dist/demo.phar', - 'phar' => true, + 'php' => $this->version, + 'composer' => $this->directory . '/bin/composer', + 'package' => [ + 'output' => 'dist/demo.phar', + 'phar' => true, ], ])); - $output = $this->package('', $status); + $output = $this->package($status); self::assertSame(0, $status, implode("\n", $output)); self::assertSame([ 'Package written to ' . $this->project . '/dist/demo.phar.', ], $output); self::assertFileExists($this->project . '/dist/demo.phar'); + self::assertNotContains('skernel.json.dist', $this->entries($this->project . '/dist/demo.phar')); + } + + public function testRejectsAMicroRuntimeThatIsNotFound(): void + { + $this->settings([ + 'runtime' => 'build/micro.sfx', + ]); + + $output = $this->package($status, '2>&1 >/dev/null'); + + self::assertSame(1, $status); + self::assertStringContainsString( + 'Themicroruntime' . $this->project . '/build/micro.sfxwasnotfound.', + (string) preg_replace('/\s+/', '', implode('', $output)), + ); } public function testPackagesAProjectWhoseDependenciesAreNotInstalled(): void { Directory::remove($this->project . '/vendor'); + $this->settings([ + 'phar' => true, + ]); - $output = $this->package('--phar --php=' . $this->version . ' --composer=' . $this->composer(), $status); + $output = $this->package($status); self::assertSame(0, $status, implode("\n", $output)); self::assertSame([ @@ -252,14 +277,21 @@ public function testPackagesAProjectWhoseDependenciesAreNotInstalled(): void self::assertDirectoryDoesNotExist($this->project . '/vendor'); } - public function testFindsAWorkingComposerWhenNoneIsGiven(): void + public function testFindsAWorkingComposerWhenNoneIsConfigured(): void { + $this->write($this->project . '/skernel.json', (string) json_encode([ + 'php' => $this->version, + 'package' => [ + 'phar' => true, + ], + ])); + exec( 'PATH=' . escapeshellarg($this->directory . '/bin:/usr/bin:/bin') . ' HOME=' . escapeshellarg($this->directory) . ' SKERNEL_HOME=' . escapeshellarg($this->directory . '/home') . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__, 2) . '/bin/bootstrap.php') - . ' package --phar --php=' . $this->version . ' -d ' . escapeshellarg($this->project) . ' 2>/dev/null', + . ' package -d ' . escapeshellarg($this->project) . ' 2>/dev/null', $output, $status, ); @@ -275,7 +307,7 @@ public function testRejectsAProjectWithoutALockFile(): void { unlink($this->project . '/composer.lock'); - $output = $this->package('--phar', $status, '2>&1 >/dev/null'); + $output = $this->package($status, '2>&1 >/dev/null'); self::assertSame(1, $status); self::assertStringContainsString( @@ -284,12 +316,27 @@ public function testRejectsAProjectWithoutALockFile(): void ); } + /** + * Writes the project's `skernel.json` with the running PHP version, the stand-in Composer, and the given + * `package` settings. + * + * @param array $package + */ + private function settings(array $package): void + { + $this->write($this->project . '/skernel.json', (string) json_encode([ + 'php' => $this->version, + 'composer' => $this->directory . '/bin/composer', + 'package' => $package, + ])); + } + /** @return list */ - private function package(string $options, ?int &$status, string $redirection = '2>/dev/null'): array + private function package(?int &$status, string $redirection = '2>/dev/null'): array { $command = 'SKERNEL_HOME=' . escapeshellarg($this->directory . '/home') . ' ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(dirname(__DIR__, 2) . '/bin/bootstrap.php') - . ' package ' . $options . ' -d ' . escapeshellarg($this->project) . ' ' . $redirection; + . ' package -d ' . escapeshellarg($this->project) . ' ' . $redirection; exec($command, $output, $status); @@ -306,11 +353,6 @@ private function assertDumpedInTheStagedCopy(string $mode): void self::assertSame(1, substr_count($line, "\n")); } - private function composer(): string - { - return escapeshellarg($this->directory . '/bin/composer'); - } - private function vendor(string $directory): void { $this->write($directory . '/vendor/autoload.php', self::AUTOLOAD); diff --git a/tests/Integration/ProjectCommandsTest.php b/tests/Integration/ProjectCommandsTest.php index 7050352..0d6b194 100644 --- a/tests/Integration/ProjectCommandsTest.php +++ b/tests/Integration/ProjectCommandsTest.php @@ -4,6 +4,7 @@ namespace Src\Tests\Integration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystemFamily; use PHPUnit\Framework\TestCase; use Src\Filesystem\Directory; @@ -121,6 +122,7 @@ protected function setUp(): void 'dev' => false, ])); $this->write($this->project . '/src/App.php', 'settings([]); $this->composer(''); putenv('SKERNEL_HOME=' . $this->directory . '/home'); @@ -181,7 +183,7 @@ public function testCleanWorksOnAProjectWhoseDependenciesAreNotInstalled(): void public function testRunForwardsTheArgumentsOutputAndExitStatusOfTheApplication(): void { - $arguments = 'run --php=' . $this->version . ' -d ' . escapeshellarg($this->project); + $arguments = 'run -d ' . escapeshellarg($this->project); self::assertSame([ 'started in project with serve --port=1', @@ -202,12 +204,15 @@ public function testRunForwardsTheArgumentsOutputAndExitStatusOfTheApplication() public function testRunWithWatchRestartsOnAChangeAndStopsOnASignal(): void { + $this->settings([ + 'run' => [ + 'watch' => true, + ], + ]); $process = new Process([ PHP_BINARY, dirname(__DIR__, 2) . '/bin/bootstrap.php', 'run', - '--watch', - '--php=' . $this->version, '-d', $this->project, 'sleep', @@ -232,6 +237,16 @@ public function testRunWithWatchRestartsOnAChangeAndStopsOnASignal(): void $this->project . " dump-autoload --no-interaction --no-dev\n", )); + $this->write($this->project . '/composer.json', (string) json_encode([ + 'name' => 'acme/demo', + 'autoload' => [ + 'classmap' => [ + 'lib/', + ], + ], + ])); + $this->waitFor(static fn(): bool => 3 === substr_count($process->getOutput(), 'started in project')); + $process->signal(SIGTERM); $process->wait(); self::assertFalse($process->isRunning()); @@ -242,12 +257,15 @@ public function testRunWithWatchRestartsOnAChangeAndStopsOnASignal(): void public function testRunWithWatchWaitsForAChangeAfterTheApplicationExits(): void { + $this->settings([ + 'run' => [ + 'watch' => true, + ], + ]); $process = new Process([ PHP_BINARY, dirname(__DIR__, 2) . '/bin/bootstrap.php', 'run', - '--watch', - '--php=' . $this->version, '-d', $this->project, 'fail', @@ -275,7 +293,7 @@ public function testBuildRegeneratesTheAutoloaderWithTheComposerItFinds(): void { self::assertSame([ 'Build artifacts written to ' . $this->project . '/vendor/.cache.', - ], $this->skernel('build --php=' . $this->version . ' -d ' . escapeshellarg($this->project), $status)); + ], $this->skernel('build -d ' . escapeshellarg($this->project), $status)); self::assertSame(0, $status); self::assertStringEqualsFile( $this->directory . '/composer-arguments', @@ -292,7 +310,7 @@ public function testBuildAndRunStopWhenComposerFails(): void 'run', ] as $command) { $output = $this->skernel( - $command . ' --php=' . $this->version . ' -d ' . escapeshellarg($this->project), + $command . ' -d ' . escapeshellarg($this->project), $status, '2>&1', ); @@ -310,12 +328,15 @@ public function testBuildAndRunStopWhenComposerFails(): void public function testRunWithWatchWaitsForAChangeWhenComposerFailsOnARebuild(): void { + $this->settings([ + 'run' => [ + 'watch' => true, + ], + ]); $process = new Process([ PHP_BINARY, dirname(__DIR__, 2) . '/bin/bootstrap.php', 'run', - '--watch', - '--php=' . $this->version, '-d', $this->project, 'sleep', @@ -344,6 +365,83 @@ public function testRunWithWatchWaitsForAChangeWhenComposerFailsOnARebuild(): vo } } + public function testRunWithoutWatchingIgnoresChanges(): void + { + $process = new Process([ + PHP_BINARY, + dirname(__DIR__, 2) . '/bin/bootstrap.php', + 'run', + '-d', + $this->project, + 'sleep', + ], null, [ + 'SKERNEL_HOME' => $this->directory . '/home', + 'PATH' => $this->path(), + ], null, 60); + $process->start(); + + try { + $this->waitFor(static fn(): bool => str_contains($process->getOutput(), 'started in project with sleep')); + + $this->write($this->project . '/src/App.php', 'getOutput(), 'started in project')); + self::assertStringNotContainsString('restarting', $process->getErrorOutput()); + } finally { + $process->stop(1); + } + } + + public function testBuildUsesTheComposerOfTheConfigurationFile(): void + { + $this->write($this->project . '/tools/composer', implode("\n", [ + '#!/bin/sh', + 'echo "configured $*" >> ' . escapeshellarg($this->directory . '/composer-arguments'), + '', + ])); + self::assertTrue(chmod($this->project . '/tools/composer', 0o755)); + $this->settings([ + 'composer' => 'tools/composer', + ]); + + $this->skernel('build -d ' . escapeshellarg($this->project), $status); + + self::assertSame(0, $status); + self::assertStringEqualsFile( + $this->directory . '/composer-arguments', + "configured dump-autoload --no-interaction --no-dev\n", + ); + } + + /** @return array */ + public static function projectSettingOptions(): array + { + return [ + 'build --php' => [ + 'build --php=8.4', + ], + 'run --php' => [ + 'run --php=8.4', + ], + 'run --watch' => [ + 'run --watch', + ], + 'package --output' => [ + 'package --output=app', + ], + ]; + } + + #[DataProvider('projectSettingOptions')] + public function testRejectsAProjectSettingOnTheCommandLine(string $arguments): void + { + $output = $this->skernel($arguments . ' -d ' . escapeshellarg($this->project), $status, '2>&1'); + + self::assertSame(1, $status); + self::assertMatchesRegularExpression('/The "--\w+" option does not exist\./', implode(' ', $output)); + self::assertFileDoesNotExist($this->directory . '/composer-arguments'); + } + public function testNewCreatesAProjectFromTheSkeletonWithComposer(): void { $this->write( @@ -441,6 +539,19 @@ private function composer(string $code): void self::assertTrue(chmod($this->directory . '/path/composer', 0o755)); } + /** + * Writes the project's `skernel.json` with the running PHP version and the given settings. + * + * @param array $settings + */ + private function settings(array $settings): void + { + $this->write($this->project . '/skernel.json', (string) json_encode([ + 'php' => $this->version, + ...$settings, + ])); + } + /** The PATH in which commands find the stand-in Composer first */ private function path(): string { diff --git a/tests/Unit/Configuration/ConfigurationLoaderTest.php b/tests/Unit/Configuration/ConfigurationLoaderTest.php index 4ff8dcd..0ef0235 100644 --- a/tests/Unit/Configuration/ConfigurationLoaderTest.php +++ b/tests/Unit/Configuration/ConfigurationLoaderTest.php @@ -9,6 +9,7 @@ use Src\Configuration\ConfigurationLoader; use Src\Configuration\PackageConfiguration; use Src\Configuration\ProjectConfiguration; +use Src\Configuration\RunConfiguration; use Symfony\Component\Console\Exception\RuntimeException; use function array_keys; @@ -46,8 +47,28 @@ protected function tearDown(): void rmdir($this->directory); } - public function testAProjectWithoutAConfigurationFileLeavesEverySettingOpen(): void + public function testAProjectWithoutAConfigurationFileGetsTheDefaults(): void { + $configuration = new ConfigurationLoader()->load($this->directory); + + self::assertEquals(new ProjectConfiguration(), $configuration); + self::assertNull($configuration->php); + self::assertNull($configuration->composer); + self::assertFalse($configuration->run->watch); + self::assertSame([ + '*.php', + '.env', + '.env.*', + 'composer.json', + 'composer.lock', + ], $configuration->run->watchFiles); + self::assertEquals(new PackageConfiguration(null, null, false, false), $configuration->package); + } + + public function testAnEmptyFileKeepsEveryDefault(): void + { + $this->write('skernel.json', '{"run": {}, "package": {}}'); + self::assertEquals(new ProjectConfiguration(), new ConfigurationLoader()->load($this->directory)); } @@ -57,23 +78,39 @@ public function testReadsEverySettingAndResolvesFilePathsAgainstTheProject(): vo { "$schema": "https://example.com/skernel.schema.json", "php": "8.4", + "composer": "tools/composer", + "run": { + "watch": true, + "watch-files": ["*.php", "*.yaml"] + }, "package": { "runtime": "build/micro.sfx", "output": "/opt/my-app", - "composer": "composer2", "dev": true, - "phar": false + "phar": true } } JSON); - self::assertEquals(new ProjectConfiguration('8.4', new PackageConfiguration( - $this->directory . '/build/micro.sfx', - '/opt/my-app', - 'composer2', - true, - false, - )), new ConfigurationLoader()->load($this->directory)); + self::assertEquals(new ProjectConfiguration( + '8.4', + $this->directory . '/tools/composer', + new RunConfiguration(true, [ + '*.php', + '*.yaml', + ]), + new PackageConfiguration($this->directory . '/build/micro.sfx', '/opt/my-app', true, true), + ), new ConfigurationLoader()->load($this->directory)); + } + + public function testAComposerWithoutASlashIsANameLookedUpInPath(): void + { + $this->write('skernel.json', '{"composer": "composer2", "run": {"watch": true}}'); + + self::assertEquals( + new ProjectConfiguration(composer: 'composer2', run: new RunConfiguration(true)), + new ConfigurationLoader()->load($this->directory), + ); } public function testTheDistributedFileIsReadOnlyWhenTheLocalOneIsAbsent(): void @@ -81,7 +118,7 @@ public function testTheDistributedFileIsReadOnlyWhenTheLocalOneIsAbsent(): void $this->write('skernel.json.dist', '{"php": "8.3", "package": {"dev": true}}'); self::assertEquals( - new ProjectConfiguration('8.3', new PackageConfiguration(dev: true)), + new ProjectConfiguration('8.3', package: new PackageConfiguration(dev: true)), new ConfigurationLoader()->load($this->directory), ); @@ -106,6 +143,42 @@ public static function invalidFiles(): array '{"package": {"target": "dist"}}', 'skernel.json: unknown setting "package.target".', ], + 'composer in package' => [ + '{"package": {"composer": "composer"}}', + 'skernel.json: unknown setting "package.composer".', + ], + 'unknown run' => [ + '{"run": {"poll": 1}}', + 'skernel.json: unknown setting "run.poll".', + ], + 'run list' => [ + '{"run": [true]}', + 'skernel.json: "run" must be an object.', + ], + 'string watch' => [ + '{"run": {"watch": "yes"}}', + 'skernel.json: "run.watch" must be true or false.', + ], + 'watched string' => [ + '{"run": {"watch-files": "*.php"}}', + 'skernel.json: "run.watch-files" must be a non-empty list of non-empty strings.', + ], + 'no watched files' => [ + '{"run": {"watch-files": []}}', + 'skernel.json: "run.watch-files" must be a non-empty list of non-empty strings.', + ], + 'empty watched file' => [ + '{"run": {"watch-files": ["*.php", ""]}}', + 'skernel.json: "run.watch-files" must be a non-empty list of non-empty strings.', + ], + 'watched object' => [ + '{"run": {"watch-files": {"php": "*.php"}}}', + 'skernel.json: "run.watch-files" must be a non-empty list of non-empty strings.', + ], + 'empty composer' => [ + '{"composer": ""}', + 'skernel.json: "composer" must be a non-empty string.', + ], 'package list' => [ '{"package": []}', 'skernel.json: "package" must be an object.', @@ -162,17 +235,27 @@ public function testTheSchemaDescribesExactlyTheAcceptedSettings(): void self::assertSame([ '$schema', 'php', + 'composer', + 'run', 'package', ], array_keys($schema['properties'])); + self::assertSame([ + 'watch', + 'watch-files', + ], array_keys($schema['properties']['run']['properties'])); self::assertSame([ 'runtime', 'output', - 'composer', 'dev', 'phar', ], array_keys($schema['properties']['package']['properties'])); self::assertFalse($schema['additionalProperties']); + self::assertFalse($schema['properties']['run']['additionalProperties']); self::assertFalse($schema['properties']['package']['additionalProperties']); + self::assertSame( + new RunConfiguration()->watchFiles, + $schema['properties']['run']['properties']['watch-files']['default'], + ); } private function write(string $file, string $contents): void diff --git a/tests/Unit/Run/ProjectFilesTest.php b/tests/Unit/Run/ProjectFilesTest.php index 509b854..4c850e0 100644 --- a/tests/Unit/Run/ProjectFilesTest.php +++ b/tests/Unit/Run/ProjectFilesTest.php @@ -4,6 +4,7 @@ namespace Src\Tests\Unit\Run; +use Src\Configuration\RunConfiguration; use Src\Project\Project; use Src\Run\ProjectFiles; use Src\Tests\Unit\Toolchain\ToolchainTestCase; @@ -22,6 +23,7 @@ public function testWatchesSourcesAndConfigurationButNotWhatTheApplicationWrites foreach ([ '.env', '.env.local', + 'composer.json', 'composer.lock', 'config/app.php', 'src/App.php', @@ -32,7 +34,6 @@ public function testWatchesSourcesAndConfigurationButNotWhatTheApplicationWrites foreach ([ '.git/hook.php', '.idea/workspace.php', - 'composer.json', 'runtime/app.log', 'target/demo.php', 'vendor/autoload.php', @@ -44,29 +45,53 @@ public function testWatchesSourcesAndConfigurationButNotWhatTheApplicationWrites self::assertSame([ $this->home . '/.env', $this->home . '/.env.local', + $this->home . '/composer.json', $this->home . '/composer.lock', $this->home . '/config/app.php', $this->home . '/src/App.php', - ], array_keys(ProjectFiles::snapshot($this->project()))); + ], array_keys(ProjectFiles::snapshot($this->project(), $this->defaults()))); + } + + public function testWatchesOnlyTheFilesWhoseNameMatchesAPattern(): void + { + foreach ([ + 'composer.json', + 'config/app.yaml', + 'config/app.yml', + 'src/App.php', + 'vendor/package.yaml', + ] as $file) { + $this->write($file); + } + + self::assertSame([ + $this->home . '/config/app.yaml', + $this->home . '/config/app.yml', + $this->home . '/src/App.php', + ], array_keys(ProjectFiles::snapshot($this->project(), [ + '*.php', + '*.y[a]ml', + 'app.y?l', + ]))); } public function testAChangedSizeTimeOrFileChangesTheSnapshot(): void { $this->write('src/App.php'); $project = $this->project(); - $snapshot = ProjectFiles::snapshot($project); - self::assertSame($snapshot, ProjectFiles::snapshot($project)); + $snapshot = ProjectFiles::snapshot($project, $this->defaults()); + self::assertSame($snapshot, ProjectFiles::snapshot($project, $this->defaults())); self::assertNotFalse(file_put_contents($this->home . '/src/App.php', 'changed')); - $changed = ProjectFiles::snapshot($project); + $changed = ProjectFiles::snapshot($project, $this->defaults()); self::assertNotSame($snapshot, $changed); self::assertTrue(touch($this->home . '/src/App.php', 1)); - $touched = ProjectFiles::snapshot($project); + $touched = ProjectFiles::snapshot($project, $this->defaults()); self::assertNotSame($changed, $touched); $this->write('src/Other.php'); - self::assertNotSame($touched, ProjectFiles::snapshot($project)); + self::assertNotSame($touched, ProjectFiles::snapshot($project, $this->defaults())); } public function testLeavesOutAFileThatCannotBeRead(): void @@ -76,7 +101,13 @@ public function testLeavesOutAFileThatCannotBeRead(): void self::assertSame([ $this->home . '/src/App.php', - ], array_keys(ProjectFiles::snapshot($this->project()))); + ], array_keys(ProjectFiles::snapshot($this->project(), $this->defaults()))); + } + + /** @return non-empty-list */ + private function defaults(): array + { + return new RunConfiguration()->watchFiles; } private function project(): Project diff --git a/tests/Unit/Toolchain/PhpReleasesTest.php b/tests/Unit/Toolchain/PhpReleasesTest.php index 738eaee..6467c11 100644 --- a/tests/Unit/Toolchain/PhpReleasesTest.php +++ b/tests/Unit/Toolchain/PhpReleasesTest.php @@ -70,7 +70,7 @@ public function testRejectsARequirementThatNoSupportedReleaseSatisfies(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessageIs( - 'No supported PHP release satisfies the requirement ^7.4; set "php" in skernel.json or pass --php.', + 'No supported PHP release satisfies the requirement ^7.4; set "php" in skernel.json.', ); $this->releases()->resolve(null, '^7.4');