Skip to content

Commit 490bcd6

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

26 files changed

Lines changed: 1500 additions & 12 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: 54 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\Plugin;
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 ?Plugin $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 Plugin ? $plugin : null;
48+
}
49+
3750
/**
3851
* {@inheritDoc}
3952
*/
@@ -55,9 +68,48 @@ public function getCommands()
5568
continue;
5669
}
5770

58-
$commands[] = new ProxyCommand($command);
71+
if ($this->isComposerReservedName($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->isComposerReservedName($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 isComposerReservedName(?string $name): bool
102+
{
103+
return null !== $name && \in_array($name, $this->getComposerReservedNames(), true);
104+
}
105+
106+
/**
107+
* Returns command names reserved by the active Composer project.
108+
*
109+
* @return list<string>
110+
*/
111+
private function getComposerReservedNames(): array
112+
{
113+
return $this->plugin?->getReservedCommandNames() ?? [];
114+
}
63115
}

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())

src/Composer/Plugin.php

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
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;
2827
use Composer\Plugin\PluginInterface;
28+
use Composer\Script\Event;
2929
use Composer\Script\ScriptEvents;
3030
use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider;
3131

@@ -35,6 +35,63 @@
3535
*/
3636
final class Plugin implements Capable, EventSubscriberInterface, PluginInterface
3737
{
38+
private const array COMPOSER_COMMAND_NAMES = [
39+
'_complete',
40+
'about',
41+
'archive',
42+
'audit',
43+
'browse',
44+
'bump',
45+
'cc',
46+
'check-platform-reqs',
47+
'clear-cache',
48+
'clearcache',
49+
'completion',
50+
'config',
51+
'create-project',
52+
'depends',
53+
'diagnose',
54+
'dump-autoload',
55+
'dumpautoload',
56+
'exec',
57+
'fund',
58+
'global',
59+
'help',
60+
'home',
61+
'i',
62+
'info',
63+
'init',
64+
'install',
65+
'licenses',
66+
'list',
67+
'outdated',
68+
'prohibits',
69+
'r',
70+
'reinstall',
71+
'remove',
72+
'repo',
73+
'repository',
74+
'require',
75+
'rm',
76+
'run',
77+
'run-script',
78+
'search',
79+
'self-update',
80+
'selfupdate',
81+
'show',
82+
'status',
83+
'suggests',
84+
'u',
85+
'uninstall',
86+
'update',
87+
'upgrade',
88+
'validate',
89+
'why',
90+
'why-not',
91+
];
92+
93+
private ?Composer $composer = null;
94+
3895
/**
3996
* Resolves the implemented Composer capabilities structure.
4097
*
@@ -84,6 +141,19 @@ public function runSyncCommand(Event $event): void
84141
->execute('vendor/bin/dev-tools dev-tools:sync');
85142
}
86143

144+
/**
145+
* Returns Composer command names that DevTools plugin commands MUST NOT override.
146+
*
147+
* @return list<string>
148+
*/
149+
public function getReservedCommandNames(): array
150+
{
151+
return array_values(array_unique([
152+
...self::COMPOSER_COMMAND_NAMES,
153+
...$this->getRootScriptCommandNames(),
154+
]));
155+
}
156+
87157
/**
88158
* Handles activation lifecycle events for the Composer session.
89159
*
@@ -96,7 +166,7 @@ public function runSyncCommand(Event $event): void
96166
*/
97167
public function activate(Composer $composer, IOInterface $io): void
98168
{
99-
// No activation logic needed for this plugin
169+
$this->composer = $composer;
100170
}
101171

102172
/**
@@ -111,7 +181,7 @@ public function activate(Composer $composer, IOInterface $io): void
111181
*/
112182
public function deactivate(Composer $composer, IOInterface $io): void
113183
{
114-
// No deactivation logic needed for this plugin
184+
$this->composer = null;
115185
}
116186

117187
/**
@@ -126,6 +196,30 @@ public function deactivate(Composer $composer, IOInterface $io): void
126196
*/
127197
public function uninstall(Composer $composer, IOInterface $io): void
128198
{
129-
// No uninstall logic needed for this plugin
199+
$this->composer = null;
200+
}
201+
202+
/**
203+
* Returns custom Composer script command names from the active root package.
204+
*
205+
* @return list<string>
206+
*/
207+
private function getRootScriptCommandNames(): array
208+
{
209+
if (! $this->composer instanceof Composer) {
210+
return [];
211+
}
212+
213+
$names = [];
214+
215+
foreach (array_keys($this->composer->getPackage()->getScripts()) as $script) {
216+
if (\defined(ScriptEvents::class . '::' . str_replace('-', '_', strtoupper($script)))) {
217+
continue;
218+
}
219+
220+
$names[] = $script;
221+
}
222+
223+
return $names;
130224
}
131225
}

0 commit comments

Comments
 (0)