Skip to content

Commit fe49aac

Browse files
jszobodyclaudehappy-otter
committed
Add custom stage support with centralized validation
- Created StageAddCommand for adding custom stages at any time - Extracted stage validation logic into ValidatesStages trait - Updated ConfigureCommand to use centralized validation - Added "custom" option in configure command for immediate stage creation - Enforced lowercase alphanumeric pattern for stage names (a-z0-9_-) - Comprehensive test coverage for stage management Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 0de77bd commit fe49aac

File tree

5 files changed

+276
-2
lines changed

5 files changed

+276
-2
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace STS\Keep\Commands\Concerns;
4+
5+
trait ValidatesStages
6+
{
7+
protected function isValidStageName(string $name): bool
8+
{
9+
return preg_match('/^[a-z0-9_-]+$/', $name) === 1;
10+
}
11+
12+
protected function getStageValidationError(string $name): ?string
13+
{
14+
if (empty($name)) {
15+
return 'Stage name is required';
16+
}
17+
18+
if (!$this->isValidStageName($name)) {
19+
return 'Stage name can only contain lowercase letters, numbers, hyphens, and underscores';
20+
}
21+
22+
return null;
23+
}
24+
25+
protected function stageExists(string $name, array $existingStages): bool
26+
{
27+
return in_array($name, $existingStages, true);
28+
}
29+
30+
protected function validateNewStageName(string $name, array $existingStages): ?string
31+
{
32+
$error = $this->getStageValidationError($name);
33+
if ($error) {
34+
return $error;
35+
}
36+
37+
if ($this->stageExists($name, $existingStages)) {
38+
return "Stage '{$name}' already exists";
39+
}
40+
41+
return null;
42+
}
43+
}

src/Commands/ConfigureCommand.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Support\Str;
66
use STS\Keep\Commands\Concerns\ConfiguresVaults;
7+
use STS\Keep\Commands\Concerns\ValidatesStages;
78
use STS\Keep\Data\Settings;
89
use STS\Keep\Facades\Keep;
910

@@ -15,7 +16,7 @@
1516

1617
class ConfigureCommand extends BaseCommand
1718
{
18-
use ConfiguresVaults;
19+
use ConfiguresVaults, ValidatesStages;
1920

2021
protected $signature = 'configure';
2122

@@ -55,11 +56,26 @@ protected function process()
5556
'staging' => 'Staging (pre-production)',
5657
'sandbox' => 'Sandbox (demos / experiments)',
5758
'production' => 'Production (live)',
59+
'custom' => '➕ Add custom stage...',
5860
],
5961
default: $existingSettings['stages'] ?? ['local', 'staging', 'production'],
6062
scroll: 6,
61-
hint: 'You can add more later. Toggle with space bar, confirm with enter.',
63+
hint: 'You can add more later with "keep stage:add". Toggle with space bar, confirm with enter.',
6264
);
65+
66+
// Handle custom stage input
67+
if (in_array('custom', $stages)) {
68+
$stages = array_diff($stages, ['custom']); // Remove 'custom' from list
69+
70+
$customStages = text(
71+
label: 'Enter custom stage names (comma-separated, lowercase only)',
72+
placeholder: 'e.g., dev2, demo, integration',
73+
validate: fn($value) => $this->validateCustomStagesInput($value)
74+
);
75+
76+
$customStagesList = array_map('trim', explode(',', $customStages));
77+
$stages = array_merge($stages, $customStagesList);
78+
}
6379

6480
// Create configuration structure
6581
$this->createKeepDirectory();
@@ -139,4 +155,21 @@ private function createGlobalSettings(string $appName, string $namespace, array
139155
'version' => '1.0',
140156
])->save();
141157
}
158+
159+
private function validateCustomStagesInput(string $value): ?string
160+
{
161+
if (empty($value)) {
162+
return 'Please enter at least one custom stage name';
163+
}
164+
165+
$stages = array_map('trim', explode(',', $value));
166+
foreach ($stages as $stage) {
167+
$error = $this->getStageValidationError($stage);
168+
if ($error) {
169+
return $error;
170+
}
171+
}
172+
173+
return null;
174+
}
142175
}

src/Commands/StageAddCommand.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace STS\Keep\Commands;
4+
5+
use STS\Keep\Commands\Concerns\ValidatesStages;
6+
use STS\Keep\Data\Settings;
7+
use function Laravel\Prompts\text;
8+
use function Laravel\Prompts\info;
9+
use function Laravel\Prompts\error;
10+
use function Laravel\Prompts\confirm;
11+
12+
class StageAddCommand extends BaseCommand
13+
{
14+
use ValidatesStages;
15+
16+
protected $signature = 'stage:add {name? : The name of the stage to add}';
17+
18+
protected $description = 'Add a custom stage/environment';
19+
20+
protected function requiresInitialization(): bool
21+
{
22+
return true;
23+
}
24+
25+
public function process()
26+
{
27+
$settings = Settings::load();
28+
$stageName = $this->getStageName($settings);
29+
30+
if (!$stageName) {
31+
return self::FAILURE;
32+
}
33+
34+
$this->line('Current stages: ' . implode(', ', $settings->stages()));
35+
36+
if (!confirm("Add '{$stageName}' as a new stage?")) {
37+
info('Stage addition cancelled.');
38+
return self::SUCCESS;
39+
}
40+
41+
$this->addStage($settings, $stageName);
42+
43+
info("✅ Stage '{$stageName}' has been added successfully!");
44+
$this->line('You can now use this stage with any Keep command using --stage=' . $stageName);
45+
46+
return self::SUCCESS;
47+
}
48+
49+
private function getStageName(Settings $settings): ?string
50+
{
51+
$stageName = $this->argument('name') ?: $this->promptForStageName($settings);
52+
53+
$error = $this->validateNewStageName($stageName, $settings->stages());
54+
if ($error) {
55+
error($error);
56+
return null;
57+
}
58+
59+
return $stageName;
60+
}
61+
62+
private function promptForStageName(Settings $settings): string
63+
{
64+
return text(
65+
label: 'Enter the name of the new stage',
66+
placeholder: 'e.g., qa, demo, sandbox, dev2',
67+
required: true,
68+
validate: fn($value) => $this->validateNewStageName($value, $settings->stages())
69+
);
70+
}
71+
72+
private function addStage(Settings $settings, string $stageName): void
73+
{
74+
Settings::fromArray([
75+
'app_name' => $settings->appName(),
76+
'namespace' => $settings->namespace(),
77+
'stages' => [...$settings->stages(), $stageName],
78+
'default_vault' => $settings->defaultVault(),
79+
'created_at' => $settings->createdAt(),
80+
])->save();
81+
}
82+
}

src/KeepApplication.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ public function __construct(protected KeepInstall $install)
3737
Commands\VaultEditCommand::class,
3838
Commands\VaultListCommand::class,
3939

40+
Commands\StageAddCommand::class,
41+
4042
Commands\GetCommand::class,
4143
Commands\SetCommand::class,
4244
Commands\CopyCommand::class,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
use STS\Keep\Data\Settings;
4+
5+
describe('StageAddCommand', function () {
6+
7+
describe('adding custom stages', function () {
8+
9+
it('adds a new custom stage via argument', function () {
10+
$initialSettings = Settings::load();
11+
12+
// Use a unique stage name for this test
13+
$stageName = 'test-stage-' . uniqid();
14+
15+
expect($initialSettings->stages())->not->toContain($stageName);
16+
17+
// Add a custom stage (auto-confirmed in non-interactive mode)
18+
$commandTester = runCommand('stage:add', [
19+
'name' => $stageName
20+
]);
21+
22+
expect($commandTester->getStatusCode())->toBe(0);
23+
24+
// Verify stage was added
25+
$updatedSettings = Settings::load();
26+
expect($updatedSettings->stages())->toContain($stageName);
27+
});
28+
29+
it('validates stage name format', function () {
30+
// Try to add an invalid stage name
31+
$commandTester = runCommand('stage:add', [
32+
'name' => 'invalid stage!' // Contains space and special char
33+
]);
34+
35+
expect($commandTester->getStatusCode())->toBe(1);
36+
expect($commandTester->getDisplay())->toContain('can only contain');
37+
38+
// Verify stage was not added
39+
$settings = Settings::load();
40+
expect($settings->stages())->not->toContain('invalid stage!');
41+
});
42+
43+
it('prevents duplicate stage names', function () {
44+
$settings = Settings::load();
45+
$existingStage = $settings->stages()[0]; // Get first existing stage
46+
47+
// Try to add a duplicate
48+
$commandTester = runCommand('stage:add', [
49+
'name' => $existingStage
50+
]);
51+
52+
expect($commandTester->getStatusCode())->toBe(1);
53+
54+
// Count should remain the same
55+
$updatedSettings = Settings::load();
56+
expect(count($updatedSettings->stages()))->toBe(count($settings->stages()));
57+
});
58+
59+
it('allows lowercase alphanumeric names with hyphens and underscores', function () {
60+
$validNames = ['dev-2', 'test_env', 'qa1', 'prod-backup'];
61+
62+
foreach ($validNames as $stageName) {
63+
// Remove stage if it exists (cleanup from previous tests)
64+
$settings = Settings::load();
65+
$stages = array_diff($settings->stages(), [$stageName]);
66+
Settings::fromArray([
67+
'app_name' => $settings->appName(),
68+
'namespace' => $settings->namespace(),
69+
'stages' => array_values($stages),
70+
'default_vault' => $settings->defaultVault(),
71+
'created_at' => $settings->createdAt(),
72+
])->save();
73+
74+
// Add the stage
75+
$commandTester = runCommand('stage:add', [
76+
'name' => $stageName
77+
]);
78+
79+
expect($commandTester->getStatusCode())->toBe(0);
80+
81+
// Verify it was added
82+
$updatedSettings = Settings::load();
83+
expect($updatedSettings->stages())->toContain($stageName);
84+
}
85+
});
86+
});
87+
88+
89+
describe('integration with other commands', function () {
90+
91+
it('makes custom stage available for use', function () {
92+
// Add a unique custom stage
93+
$stageName = 'integration-' . uniqid();
94+
$commandTester = runCommand('stage:add', ['name' => $stageName]);
95+
96+
expect($commandTester->getStatusCode())->toBe(0);
97+
expect($commandTester->getDisplay())->toContain("Stage '{$stageName}' has been added successfully");
98+
99+
// Verify the custom stage is persisted in settings
100+
$settings = Settings::load();
101+
expect($settings->stages())->toContain($stageName);
102+
103+
// Verify multiple custom stages can be added
104+
$secondStage = 'secondary-' . uniqid();
105+
$secondCommand = runCommand('stage:add', ['name' => $secondStage]);
106+
107+
expect($secondCommand->getStatusCode())->toBe(0);
108+
109+
$updatedSettings = Settings::load();
110+
expect($updatedSettings->stages())->toContain($stageName);
111+
expect($updatedSettings->stages())->toContain($secondStage);
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)