Skip to content

Commit ac2dd3a

Browse files
committed
Add DevTools self-update command
1 parent 2c16097 commit ac2dd3a

28 files changed

Lines changed: 1703 additions & 19 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add a standalone DevTools `self-update` command plus global `--working-dir` and `--auto-update` binary options for local or global installations (#272)
13+
1014
## [1.23.0] - 2026-04-26
1115

1216
### Added

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ composer dev-tools
5555

5656
# Automatically fix code standards issues where applicable
5757
composer dev-tools:fix
58+
59+
# Run the standalone binary from another project directory
60+
vendor/bin/dev-tools --working-dir=/path/to/project tests
61+
62+
# Update the installed DevTools package
63+
vendor/bin/dev-tools self-update
5864
```
5965

6066
You can also run individual commands for specific development tasks:
@@ -288,6 +294,7 @@ skills they depend on.
288294
| `composer codeowners` | Generates managed `.github/CODEOWNERS` content from local repository metadata. |
289295
| `composer gitattributes` | Manages export-ignore rules in `.gitattributes`. |
290296
| `composer dev-tools:sync` | Updates scripts, CODEOWNERS, funding metadata, workflow stubs, `.editorconfig`, `.gitignore`, `.gitattributes`, wiki setup, packaged skills, and packaged agents. |
297+
| `vendor/bin/dev-tools self-update` / `composer dev-tools:self-update` | Updates the local DevTools package, or the Composer global installation with `--global`. |
291298

292299
## 🔌 Integration
293300

docs/commands/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Detailed documentation for each dev-tools command.
2020
agents
2121
skills
2222
sync
23+
self-update
2324
funding
2425
codeowners
2526
gitignore

docs/commands/self-update.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
self-update
2+
===========
3+
4+
``self-update`` updates the installed ``fast-forward/dev-tools`` package
5+
through Composer.
6+
7+
Usage
8+
-----
9+
10+
.. code-block:: bash
11+
12+
vendor/bin/dev-tools self-update
13+
vendor/bin/dev-tools self-update --global
14+
composer dev-tools:self-update
15+
16+
Options
17+
-------
18+
19+
.. list-table::
20+
:header-rows: 1
21+
:widths: 24 76
22+
23+
* - Option
24+
- Description
25+
* - ``--global``
26+
- Run ``composer global update fast-forward/dev-tools`` instead of
27+
updating the current project installation.
28+
29+
Global runtime options
30+
----------------------
31+
32+
The standalone DevTools binary also accepts Composer-like global runtime
33+
options before the command name:
34+
35+
.. code-block:: bash
36+
37+
vendor/bin/dev-tools --working-dir=/path/to/project tests
38+
vendor/bin/dev-tools --auto-update tests
39+
40+
``--working-dir`` (or ``-d``) switches the process directory before resolving
41+
paths, managed files, or command defaults. This lets a globally installed
42+
binary operate on another project without first changing shell directories.
43+
Composer executions can use Composer's own ``--working-dir``/``-d`` option.
44+
45+
``--auto-update`` runs the project self-update flow before the requested
46+
command. The same behavior MAY be enabled with ``FAST_FORWARD_AUTO_UPDATE``;
47+
set it to ``global`` when the update should target Composer's global
48+
installation. Auto-update failures are reported as warnings and do not block
49+
the requested command.
50+
51+
Version freshness check
52+
-----------------------
53+
54+
When DevTools runs from an installed package, the binary checks Composer
55+
metadata for the latest stable ``fast-forward/dev-tools`` release. If a newer
56+
stable version is available, DevTools prints a warning recommending
57+
``dev-tools self-update``. This check is best-effort: network, Composer, or
58+
metadata failures are ignored so the requested command can continue normally.

src/Composer/Capability/DevToolsCommandProvider.php

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,32 @@
2121

2222
use Composer\Plugin\Capability\CommandProvider;
2323
use FastForward\DevTools\Composer\Command\ProxyCommand;
24+
use FastForward\DevTools\Composer\DevToolsPluginInterface;
2425
use FastForward\DevTools\Console\DevTools;
26+
use Symfony\Component\Console\Command\Command;
2527

2628
/**
2729
* Provides a registry of custom dev-tools commands mapped for Composer integration.
2830
* This capability struct MUST implement the defined `CommandProvider`.
2931
*/
30-
final class DevToolsCommandProvider implements CommandProvider
32+
final readonly class DevToolsCommandProvider implements CommandProvider
3133
{
3234
/**
3335
* @var string the namespace prefix for dev-tools console commands to be registered as Composer commands
3436
*/
3537
private const string COMMAND_NAMESPACE = 'FastForward\DevTools\Console\Command';
3638

39+
private ?DevToolsPluginInterface $plugin;
40+
41+
/**
42+
* @param array<string, mixed> $constructorArguments the Composer capability constructor arguments
43+
*/
44+
public function __construct(array $constructorArguments = [])
45+
{
46+
$plugin = $constructorArguments['plugin'] ?? null;
47+
$this->plugin = $plugin instanceof DevToolsPluginInterface ? $plugin : null;
48+
}
49+
3750
/**
3851
* {@inheritDoc}
3952
*/
@@ -55,9 +68,38 @@ public function getCommands()
5568
continue;
5669
}
5770

58-
$commands[] = new ProxyCommand($command);
71+
if ($this->isRegisteredCommand($command->getName())) {
72+
continue;
73+
}
74+
75+
$commands[] = new ProxyCommand($command, $this->getComposerAliases($command));
5976
}
6077

6178
return $commands;
6279
}
80+
81+
/**
82+
* Returns command aliases that may be safely exposed to Composer.
83+
*
84+
* @param Command $command the Symfony command being proxied
85+
*
86+
* @return list<string>
87+
*/
88+
private function getComposerAliases(Command $command): array
89+
{
90+
return array_values(array_filter(
91+
$command->getAliases(),
92+
fn(string $alias): bool => ! $this->isRegisteredCommand($alias),
93+
));
94+
}
95+
96+
/**
97+
* Detects names already owned by Composer's active command surface.
98+
*
99+
* @param string|null $name the command name or alias being evaluated
100+
*/
101+
private function isRegisteredCommand(?string $name): bool
102+
{
103+
return $this->plugin?->isRegisteredCommand($name) ?? false;
104+
}
63105
}

src/Composer/Command/ProxyCommand.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ final class ProxyCommand extends BaseCommand
3131
{
3232
/**
3333
* @param Command $command the Symfony command adapted for Composer plugin execution
34+
* @param list<string>|null $aliases the optional alias list exposed to Composer
3435
*/
3536
public function __construct(
3637
private readonly Command $command,
38+
?array $aliases = null,
3739
) {
3840
parent::__construct($this->command->getName());
3941

4042
$this
41-
->setAliases($this->command->getAliases())
43+
->setAliases($aliases ?? $this->command->getAliases())
4244
->setDescription($this->command->getDescription())
4345
->setHelp($this->command->getHelp())
4446
->setDefinition(clone $this->command->getDefinition())
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Fast Forward Development Tools for PHP projects.
7+
*
8+
* This file is part of fast-forward/dev-tools project.
9+
*
10+
* @author Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
11+
* @license https://opensource.org/licenses/MIT MIT License
12+
*
13+
* @see https://github.com/php-fast-forward/
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward/dev-tools/issues
16+
* @see https://php-fast-forward.github.io/dev-tools/
17+
* @see https://datatracker.ietf.org/doc/html/rfc2119
18+
*/
19+
20+
namespace FastForward\DevTools\Composer;
21+
22+
use Composer\Plugin\PluginInterface;
23+
24+
/**
25+
* Defines DevTools-specific Composer plugin conventions.
26+
*/
27+
interface DevToolsPluginInterface extends PluginInterface
28+
{
29+
public const array COMPOSER_COMMAND_NAMES = [
30+
'_complete',
31+
'about',
32+
'archive',
33+
'audit',
34+
'browse',
35+
'bump',
36+
'cc',
37+
'check-platform-reqs',
38+
'clear-cache',
39+
'clearcache',
40+
'completion',
41+
'config',
42+
'create-project',
43+
'depends',
44+
'diagnose',
45+
'dump-autoload',
46+
'dumpautoload',
47+
'exec',
48+
'fund',
49+
'global',
50+
'help',
51+
'home',
52+
'i',
53+
'info',
54+
'init',
55+
'install',
56+
'licenses',
57+
'list',
58+
'outdated',
59+
'prohibits',
60+
'r',
61+
'reinstall',
62+
'remove',
63+
'repo',
64+
'repository',
65+
'require',
66+
'rm',
67+
'run',
68+
'run-script',
69+
'search',
70+
'self-update',
71+
'selfupdate',
72+
'show',
73+
'status',
74+
'suggests',
75+
'u',
76+
'uninstall',
77+
'update',
78+
'upgrade',
79+
'validate',
80+
'why',
81+
'why-not',
82+
];
83+
84+
/**
85+
* Detects whether a command name or alias is already registered in Composer's command surface.
86+
*
87+
* @param string|null $name the command name or alias being evaluated
88+
*/
89+
public function isRegisteredCommand(?string $name): bool;
90+
}

src/Composer/Plugin.php

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,21 @@
2121

2222
use Composer\Composer;
2323
use Composer\EventDispatcher\EventSubscriberInterface;
24-
use Composer\Script\Event;
2524
use Composer\IO\IOInterface;
2625
use Composer\Plugin\Capability\CommandProvider;
2726
use Composer\Plugin\Capable;
28-
use Composer\Plugin\PluginInterface;
27+
use Composer\Script\Event;
2928
use Composer\Script\ScriptEvents;
3029
use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider;
3130

3231
/**
3332
* Implements the lifecycle of the Composer dev-tools extension framework.
3433
* This plugin class MUST initialize and coordinate custom script registrations securely.
3534
*/
36-
final class Plugin implements Capable, EventSubscriberInterface, PluginInterface
35+
final class Plugin implements Capable, DevToolsPluginInterface, EventSubscriberInterface
3736
{
37+
private ?Composer $composer = null;
38+
3839
/**
3940
* Resolves the implemented Composer capabilities structure.
4041
*
@@ -84,6 +85,29 @@ public function runSyncCommand(Event $event): void
8485
->execute('vendor/bin/dev-tools dev-tools:sync');
8586
}
8687

88+
/**
89+
* Detects whether a command name or alias is already registered in Composer's command surface.
90+
*
91+
* @param string|null $name the command name or alias being evaluated
92+
*/
93+
public function isRegisteredCommand(?string $name): bool
94+
{
95+
return null !== $name && \in_array($name, $this->getReservedCommandNames(), true);
96+
}
97+
98+
/**
99+
* Returns command names and aliases that DevTools plugin commands MUST NOT override.
100+
*
101+
* @return list<string>
102+
*/
103+
private function getReservedCommandNames(): array
104+
{
105+
return array_values(array_unique([
106+
...self::COMPOSER_COMMAND_NAMES,
107+
...$this->getRootScriptCommandNames(),
108+
]));
109+
}
110+
87111
/**
88112
* Handles activation lifecycle events for the Composer session.
89113
*
@@ -96,7 +120,7 @@ public function runSyncCommand(Event $event): void
96120
*/
97121
public function activate(Composer $composer, IOInterface $io): void
98122
{
99-
// No activation logic needed for this plugin
123+
$this->composer = $composer;
100124
}
101125

102126
/**
@@ -111,7 +135,7 @@ public function activate(Composer $composer, IOInterface $io): void
111135
*/
112136
public function deactivate(Composer $composer, IOInterface $io): void
113137
{
114-
// No deactivation logic needed for this plugin
138+
$this->composer = null;
115139
}
116140

117141
/**
@@ -126,6 +150,30 @@ public function deactivate(Composer $composer, IOInterface $io): void
126150
*/
127151
public function uninstall(Composer $composer, IOInterface $io): void
128152
{
129-
// No uninstall logic needed for this plugin
153+
$this->composer = null;
154+
}
155+
156+
/**
157+
* Returns custom Composer script command names from the active root package.
158+
*
159+
* @return list<string>
160+
*/
161+
private function getRootScriptCommandNames(): array
162+
{
163+
if (! $this->composer instanceof Composer) {
164+
return [];
165+
}
166+
167+
$names = [];
168+
169+
foreach (array_keys($this->composer->getPackage()->getScripts()) as $script) {
170+
if (\defined(ScriptEvents::class . '::' . str_replace('-', '_', strtoupper($script)))) {
171+
continue;
172+
}
173+
174+
$names[] = $script;
175+
}
176+
177+
return $names;
130178
}
131179
}

0 commit comments

Comments
 (0)