Skip to content

Commit bafdaec

Browse files
committed
Add alias and forget commands for user-defined package aliases
1 parent e8670fc commit bafdaec

13 files changed

Lines changed: 574 additions & 4 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ Behind the scenes, cpx will install the package into a separate directory and ru
4343

4444
For example, `cpx php-cs-fixer` is an alias for `cpx friendsofphp/php-cs-fixer`, and `cpx laravel` is an alias for `cpx laravel/installer`.
4545

46+
### cpx alias
47+
48+
`cpx alias` lets you create your own shortcut for a package, so you don't have to remember or type its full vendor/package name every time.
49+
50+
```
51+
cpx alias laravel/pint pint
52+
```
53+
54+
Both arguments are optional — if you leave either one out, cpx will prompt you for it. Leaving out the name defaults it to the package's short name, so `cpx alias laravel/pint` alone is enough to create the `pint` alias above.
55+
56+
Your aliases are saved under `~/.cpx/` and shown alongside the built-in ones under `cpx aliases`. They take priority over the built-in aliases, so you can also use `cpx alias` to point an existing alias like `pint` at a different package.
57+
58+
Use `cpx forget <name>` to remove one of your aliases, e.g. `cpx forget pint`. Leave off the name and cpx will prompt you for it.
59+
4660
### cpx list
4761

4862
`cpx list` shows all the packages you have run via cpx and have installed.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"require-dev": {
4141
"laravel/pao": "^1.1",
4242
"laravel/pint": "^1.29",
43+
"laravel/prompts": "^0.3.21",
4344
"pestphp/pest": "^4.7",
4445
"pestphp/pest-plugin-type-coverage": "^4.0",
4546
"phpstan/phpstan": "^2.2"

src/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace Cpx;
66

7+
use Cpx\Commands\AliasCommand;
78
use Cpx\Commands\AliasesCommand;
89
use Cpx\Commands\CleanCommand;
910
use Cpx\Commands\ExecCommand;
11+
use Cpx\Commands\ForgetCommand;
1012
use Cpx\Commands\ListCommand;
1113
use Cpx\Commands\RunPackageCommand;
1214
use Cpx\Commands\TinkerCommand;
@@ -52,7 +54,9 @@ private function registerCommands(PackageCommandRunner $packageCommandRunner): v
5254
{
5355
$this->addCommands([
5456
new ListCommand,
57+
new AliasCommand,
5558
new AliasesCommand,
59+
new ForgetCommand,
5660
new CleanCommand,
5761
new UpdateCommand,
5862
new UpgradeCommand,

src/Commands/AliasCommand.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cpx\Commands;
6+
7+
use Cpx\Packages\Package;
8+
use Cpx\Packages\UserAliases;
9+
use InvalidArgumentException;
10+
use Laravel\Prompts\Exceptions\NonInteractiveValidationException;
11+
use Laravel\Prompts\Prompt;
12+
use Symfony\Component\Console\Attribute\AsCommand;
13+
use Symfony\Component\Console\Command\Command;
14+
use Symfony\Component\Console\Input\InputArgument;
15+
use Symfony\Component\Console\Input\InputInterface;
16+
use Symfony\Component\Console\Output\OutputInterface;
17+
18+
use function Laravel\Prompts\error;
19+
use function Laravel\Prompts\info;
20+
use function Laravel\Prompts\text;
21+
22+
#[AsCommand(
23+
name: 'alias',
24+
description: 'Create a shortcut command for a Composer package',
25+
)]
26+
class AliasCommand extends Command
27+
{
28+
protected function configure(): void
29+
{
30+
$this->addArgument('package', InputArgument::OPTIONAL, 'The package to alias, e.g. <vendor>/<package>[:version]');
31+
$this->addArgument('name', InputArgument::OPTIONAL, 'The alias name to run the package as, e.g. "cpx <name>"');
32+
}
33+
34+
protected function execute(InputInterface $input, OutputInterface $output): int
35+
{
36+
Prompt::setOutput($output);
37+
38+
try {
39+
$package = $this->resolvePackage($input);
40+
$name = $this->resolveName($input, $package);
41+
} catch (InvalidArgumentException|NonInteractiveValidationException $e) {
42+
error($e->getMessage());
43+
44+
return self::FAILURE;
45+
}
46+
47+
UserAliases::open()->put($name, $package)->save();
48+
49+
info("Alias created: cpx {$name} now runs {$package}.");
50+
51+
return self::SUCCESS;
52+
}
53+
54+
private function resolvePackage(InputInterface $input): Package
55+
{
56+
if ($package = $input->getArgument('package')) {
57+
return Package::parse($package);
58+
}
59+
60+
return Package::parse(text(
61+
label: 'Which package would you like to alias?',
62+
placeholder: '<vendor>/<package>[:version]',
63+
required: 'A package name must be provided.',
64+
validate: $this->validatePackage(...),
65+
));
66+
}
67+
68+
private function validatePackage(string $value): ?string
69+
{
70+
try {
71+
Package::parse($value);
72+
73+
return null;
74+
} catch (InvalidArgumentException $e) {
75+
return $e->getMessage();
76+
}
77+
}
78+
79+
private function resolveName(InputInterface $input, Package $package): string
80+
{
81+
if ($name = $input->getArgument('name')) {
82+
if ($error = $this->validateName($name)) {
83+
throw new InvalidArgumentException($error);
84+
}
85+
86+
return $name;
87+
}
88+
89+
return text(
90+
label: 'What should the alias be called?',
91+
default: $package->name,
92+
validate: $this->validateName(...),
93+
);
94+
}
95+
96+
private function validateName(string $name): ?string
97+
{
98+
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
99+
return 'An alias name may only contain letters, numbers, dots, dashes and underscores.';
100+
}
101+
102+
if ($this->getApplication()?->has($name) === true) {
103+
return "\"{$name}\" is already a cpx command and cannot be used as an alias name.";
104+
}
105+
106+
return null;
107+
}
108+
}

src/Commands/AliasesCommand.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Cpx\Packages\PackageAlias;
88
use Cpx\Packages\PackageAliases;
9+
use Cpx\Packages\UserAliases;
910
use Symfony\Component\Console\Attribute\AsCommand;
1011
use Symfony\Component\Console\Command\Command;
1112
use Symfony\Component\Console\Input\InputInterface;
@@ -28,6 +29,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
2829
$output->writeln(' <info>cpx '.$paddedCommand.'</info> '.$package->description);
2930
}
3031

32+
$userAliases = UserAliases::open()->all();
33+
34+
if ($userAliases !== []) {
35+
ksort($userAliases);
36+
37+
$output->writeln(PHP_EOL.'Your aliases:'.PHP_EOL);
38+
39+
foreach ($userAliases as $name => $package) {
40+
$paddedCommand = str_pad($name, 15);
41+
$output->writeln(' <info>cpx '.$paddedCommand.'</info> '.$package->fullPackageString());
42+
}
43+
}
44+
3145
return self::SUCCESS;
3246
}
3347
}

src/Commands/ForgetCommand.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cpx\Commands;
6+
7+
use Cpx\Packages\UserAliases;
8+
use Laravel\Prompts\Exceptions\NonInteractiveValidationException;
9+
use Laravel\Prompts\Prompt;
10+
use Symfony\Component\Console\Attribute\AsCommand;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Input\InputArgument;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Output\OutputInterface;
15+
16+
use function Laravel\Prompts\error;
17+
use function Laravel\Prompts\info;
18+
use function Laravel\Prompts\select;
19+
20+
#[AsCommand(
21+
name: 'forget',
22+
description: 'Remove a user-defined alias',
23+
)]
24+
class ForgetCommand extends Command
25+
{
26+
protected function configure(): void
27+
{
28+
$this->addArgument('name', InputArgument::OPTIONAL, 'The alias name to remove');
29+
}
30+
31+
protected function execute(InputInterface $input, OutputInterface $output): int
32+
{
33+
Prompt::setOutput($output);
34+
35+
$aliases = UserAliases::open();
36+
37+
if ($aliases->all() === []) {
38+
info('You have no aliases to forget.');
39+
40+
return self::SUCCESS;
41+
}
42+
43+
try {
44+
$name = $this->resolveName($input, $aliases);
45+
} catch (NonInteractiveValidationException $e) {
46+
error($e->getMessage());
47+
48+
return self::FAILURE;
49+
}
50+
51+
if (! $aliases->has($name)) {
52+
error("No alias named \"{$name}\" was found.");
53+
54+
return self::FAILURE;
55+
}
56+
57+
$aliases->forget($name)->save();
58+
59+
info("Alias \"{$name}\" removed.");
60+
61+
return self::SUCCESS;
62+
}
63+
64+
private function resolveName(InputInterface $input, UserAliases $aliases): string
65+
{
66+
return $input->getArgument('name') ?? select(
67+
label: 'Which alias would you like to forget?',
68+
options: array_keys($aliases->all()),
69+
required: 'An alias name must be provided.',
70+
);
71+
}
72+
}

src/Packages/PackageCommandRunner.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public function run(PackageInvocation $invocation, OutputInterface $output): int
2323
return (new ExecCommand)->run($this->fileInput($invocation), $output);
2424
}
2525

26+
$userAlias = UserAliases::open()->find($invocation->target);
27+
28+
if ($userAlias !== null) {
29+
return $userAlias->runCommand($invocation, $output);
30+
}
31+
2632
$aliases = PackageAliases::all();
2733

2834
if (array_key_exists($invocation->target, $aliases)) {

src/Packages/UserAliases.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Cpx\Packages;
6+
7+
class UserAliases
8+
{
9+
private const FILE = 'aliases.json';
10+
11+
/**
12+
* @param array<string, Package> $aliases
13+
*/
14+
protected function __construct(
15+
protected array $aliases = [],
16+
) {}
17+
18+
public static function open(): self
19+
{
20+
$file = cpx_path(self::FILE);
21+
22+
if (! file_exists($file)) {
23+
return new self;
24+
}
25+
26+
$json = json_decode((string) file_get_contents($file), true);
27+
28+
return new self(array_map(
29+
fn (string $value): Package => Package::parse($value),
30+
$json,
31+
));
32+
}
33+
34+
/** @return array<string, Package> */
35+
public function all(): array
36+
{
37+
return $this->aliases;
38+
}
39+
40+
public function has(string $name): bool
41+
{
42+
return array_key_exists($name, $this->aliases);
43+
}
44+
45+
public function find(string $name): ?Package
46+
{
47+
return $this->aliases[$name] ?? null;
48+
}
49+
50+
public function put(string $name, Package $package): self
51+
{
52+
$this->aliases[$name] = $package;
53+
54+
return $this;
55+
}
56+
57+
public function forget(string $name): self
58+
{
59+
unset($this->aliases[$name]);
60+
61+
return $this;
62+
}
63+
64+
public function save(): void
65+
{
66+
$aliasesFile = cpx_path(self::FILE);
67+
68+
if (! is_dir(dirname($aliasesFile))) {
69+
mkdir(dirname($aliasesFile), 0755, true);
70+
}
71+
72+
file_put_contents($aliasesFile, json_encode($this->toArray(), JSON_PRETTY_PRINT));
73+
}
74+
75+
/** @return array<string, string> */
76+
public function toArray(): array
77+
{
78+
return array_map(
79+
fn (Package $package): string => $package->fullPackageString(),
80+
$this->aliases,
81+
);
82+
}
83+
}

0 commit comments

Comments
 (0)