From e28b526d7e00ac39b84eb1581e07aad502d062a3 Mon Sep 17 00:00:00 2001 From: Jonathon Byrdziak Date: Sat, 21 Mar 2026 14:04:26 -0700 Subject: [PATCH 1/2] Fix config:switch, add environment-aware aws:push, composer auth for GitHub App - config:switch: Always fetch before switching, verify branch exists (local or remote), confirm switch actually happened, report errors instead of silently failing - aws:push: Prompt for environment (config repo branch), read .env directly from branch via git show, decrypt .env.enc in-memory, push to environment-specific secret name (protocol/{project}/{env}) - aws:init: Show current AWS identity with option to reconfigure, support named profiles, run aws configure interactively - GitHubApp: Write token to composer auth.json on refresh so composer install never hits GitHub rate limits - AwsSecretsHelper: Support named AWS profiles, add pushSecretAs() Co-Authored-By: Claude Opus 4.6 --- src/Commands/ConfigSwitch.php | 61 +++-- src/Helpers/GitHubApp.php | 25 ++ src/Plugins/awssecrets/AwsSecretsHelper.php | 45 +++- .../awssecrets/Commands/AwsSecretsInit.php | 97 +++++++- .../awssecrets/Commands/AwsSecretsPush.php | 218 ++++++++++++++++-- 5 files changed, 397 insertions(+), 49 deletions(-) diff --git a/src/Commands/ConfigSwitch.php b/src/Commands/ConfigSwitch.php index b92e1bc..458a724 100644 --- a/src/Commands/ConfigSwitch.php +++ b/src/Commands/ConfigSwitch.php @@ -106,32 +106,63 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $newName = $input->getArgument('environment', false); - if (!$newName) { - // get the correct environment - Git::fetch( $configrepo ); - $branches = Git::branches( $configrepo ); - $branchStr = implode(', ',$branches); - $currentBranch = Git::branch( $configrepo ); + // Always fetch to ensure we have remote branches + Git::fetch( $configrepo ); + + $currentBranch = Git::branch( $configrepo ); + $branches = Git::branches( $configrepo ); + $newName = $input->getArgument('environment'); + if (!$newName) { + $branchStr = implode(', ', $branches); $output->writeln("You have the following environments: $branchStr"); $question = new Question("You are on env ($currentBranch), switch to what environment?: "); $newName = $helper->ask($input, $output, $question); + } - if (!in_array($newName, $branches)) { - $output->writeln("That's not a valid branch, quitting..."); - return Command::SUCCESS; - } + if (!$newName) { + $output->writeln("No environment specified."); + return Command::FAILURE; + } + + // Check if branch exists locally or as a remote + $escapedRepo = escapeshellarg($configrepo); + $localExists = trim(Shell::run("git -C {$escapedRepo} branch --list " . escapeshellarg($newName) . " 2>/dev/null") ?: ''); + $remoteExists = trim(Shell::run("git -C {$escapedRepo} branch -r --list " . escapeshellarg("*/{$newName}") . " 2>/dev/null") ?: ''); + + if (!$localExists && !$remoteExists) { + $output->writeln("Environment \"{$newName}\" does not exist."); + $output->writeln('Available: ' . implode(', ', $branches)); + return Command::FAILURE; } + if ($newName === $currentBranch) { + $output->writeln("Already on {$newName}."); + return Command::SUCCESS; + } + + // Unlink current config $command = $this->getApplication()->find('config:unlink'); - $returnCode = $command->run((new ArrayInput(['--dir' => $repo_dir])), $output); + $command->run((new ArrayInput(['--dir' => $repo_dir])), $output); + + // Switch branch — use checkout which auto-tracks remote branches + $result = Shell::run("git -C {$escapedRepo} checkout " . escapeshellarg($newName) . " 2>&1", $returnVar); - Git::switchBranch( $newName, $configrepo ); - $output->writeln("Switched! Your new environment is $newName."); + // Verify the switch actually happened + $actualBranch = trim(Shell::run("git -C {$escapedRepo} rev-parse --abbrev-ref HEAD 2>/dev/null") ?: ''); + if ($returnVar !== 0 || $actualBranch !== $newName) { + $output->writeln("Failed to switch to {$newName}:"); + $output->writeln("{$result}"); + return Command::FAILURE; + } + + $output->writeln("Config repo switched to: {$newName}"); + + // Re-link (decrypts .env.enc if encryption key is present) $command = $this->getApplication()->find('config:link'); - $returnCode = $command->run((new ArrayInput(['--dir' => $repo_dir])), $output); + $command->run((new ArrayInput(['--dir' => $repo_dir])), $output); + return Command::SUCCESS; } diff --git a/src/Helpers/GitHubApp.php b/src/Helpers/GitHubApp.php index 23acbc4..ce3fc68 100644 --- a/src/Helpers/GitHubApp.php +++ b/src/Helpers/GitHubApp.php @@ -240,6 +240,30 @@ public static function writeGitCredentials(string $token): void Shell::run("git config --global credential.helper 'store --file=" . escapeshellarg($credentialFile) . "'"); } + /** + * Write the GitHub App token to composer's global auth.json + * so composer can access GitHub API without rate-limit prompts. + */ + public static function writeComposerAuth(string $token): void + { + $composerHome = getenv('COMPOSER_HOME') ?: (getenv('HOME') ?: getenv('USERPROFILE')) . '/.config/composer'; + $authFile = $composerHome . '/auth.json'; + + $auth = []; + if (is_file($authFile)) { + $auth = json_decode(file_get_contents($authFile), true) ?: []; + } + + $auth['github-oauth'] = $auth['github-oauth'] ?? []; + $auth['github-oauth']['github.com'] = $token; + + if (!is_dir($composerHome)) { + mkdir($composerHome, 0700, true); + } + file_put_contents($authFile, json_encode($auth, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", LOCK_EX); + chmod($authFile, 0600); + } + /** * Refresh the git credentials with a new installation token. * Call this periodically (tokens expire after 1 hour). @@ -251,6 +275,7 @@ public static function refreshGitCredentials(?string $owner = null): bool return false; } self::writeGitCredentials($token); + self::writeComposerAuth($token); return true; } diff --git a/src/Plugins/awssecrets/AwsSecretsHelper.php b/src/Plugins/awssecrets/AwsSecretsHelper.php index 05b90e0..253d9c0 100644 --- a/src/Plugins/awssecrets/AwsSecretsHelper.php +++ b/src/Plugins/awssecrets/AwsSecretsHelper.php @@ -52,10 +52,17 @@ public static function defaultSecretName($repoDir = false): string */ public static function getClient($repoDir = false): SecretsManagerClient { - return new SecretsManagerClient([ + $config = [ 'region' => self::region($repoDir), 'version' => 'latest', - ]); + ]; + + $profile = self::config('profile', null, $repoDir); + if ($profile) { + $config['profile'] = $profile; + } + + return new SecretsManagerClient($config); } // ─── Logging ──────────────────────────────────────────────── @@ -147,6 +154,40 @@ public static function jsonToEnv(string $json): string * * @return bool True on success */ + public static function pushSecretAs(string $envContents, string $secretName, $repoDir = false): bool + { + $client = self::getClient($repoDir); + $secretString = self::envToJson($envContents); + + self::log("Pushing secret: {$secretName}"); + + try { + $client->putSecretValue([ + 'SecretId' => $secretName, + 'SecretString' => $secretString, + ]); + self::log("Secret updated: {$secretName}"); + return true; + } catch (AwsException $e) { + if ($e->getAwsErrorCode() === 'ResourceNotFoundException') { + try { + $client->createSecret([ + 'Name' => $secretName, + 'SecretString' => $secretString, + 'Description' => 'Protocol managed environment secrets', + ]); + self::log("Secret created: {$secretName}"); + return true; + } catch (AwsException $createEx) { + self::log("ERROR creating secret: " . $createEx->getMessage()); + return false; + } + } + self::log("ERROR pushing secret: " . $e->getMessage()); + return false; + } + } + public static function pushSecret(string $envContents, $repoDir = false): bool { $client = self::getClient($repoDir); diff --git a/src/Plugins/awssecrets/Commands/AwsSecretsInit.php b/src/Plugins/awssecrets/Commands/AwsSecretsInit.php index 0816372..43b9215 100644 --- a/src/Plugins/awssecrets/Commands/AwsSecretsInit.php +++ b/src/Plugins/awssecrets/Commands/AwsSecretsInit.php @@ -6,6 +6,8 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\ChoiceQuestion; use Gitcd\Plugins\awssecrets\AwsSecretsHelper; use Gitcd\Helpers\Git; use Gitcd\Helpers\Shell; @@ -49,19 +51,96 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); $identity = Shell::run('aws sts get-caller-identity 2>&1', $returnVar); - if ($returnVar !== 0) { - $output->writeln(' AWS CLI is not configured or not installed.'); - $output->writeln(" {$identity}"); + + if ($returnVar === 0) { + $identityData = json_decode($identity, true); + $account = $identityData['Account'] ?? 'unknown'; + $arn = $identityData['Arn'] ?? 'unknown'; + $userId = $identityData['UserId'] ?? 'unknown'; + $output->writeln(" Current AWS identity:"); + $output->writeln(" Account: {$account}"); + $output->writeln(" ARN: {$arn}"); + $output->writeln(" User ID: {$userId}"); + $output->writeln(''); + + $question = new ConfirmationQuestion(' Use this identity? [Y/n]: ', true); + $useExisting = $helper->ask($input, $output, $question); + $output->writeln(''); + + if (!$useExisting) { + $output->writeln(' How would you like to configure AWS credentials?'); + $output->writeln(''); + $choice = new ChoiceQuestion(' Select method:', [ + '1' => 'Run "aws configure" (access key + secret key)', + '2' => 'Set a named profile (aws configure --profile )', + '3' => 'Cancel — I\'ll configure credentials manually', + ], '1'); + $method = $helper->ask($input, $output, $choice); + $output->writeln(''); + + if ($method === '3' || str_contains($method, 'Cancel')) { + $output->writeln(' Configure your credentials and re-run protocol aws:init'); + $output->writeln(''); + return Command::FAILURE; + } + + if ($method === '2' || str_contains($method, 'profile')) { + $profileQ = new Question(' Profile name: ', ''); + $profile = $helper->ask($input, $output, $profileQ); + if (!$profile) { + $output->writeln(' Profile name is required.'); + return Command::FAILURE; + } + $output->writeln(''); + $output->writeln(" Running aws configure --profile {$profile}..."); + $output->writeln(''); + Shell::passthru("aws configure --profile " . escapeshellarg($profile)); + // Set the profile for subsequent AWS calls in this session + putenv("AWS_PROFILE={$profile}"); + // Save profile to protocol.json so deploy uses the right one + Json::write('aws.profile', $profile, $repoDir); + } else { + $output->writeln(' Running aws configure...'); + $output->writeln(''); + Shell::passthru('aws configure'); + } + + $output->writeln(''); + + // Re-verify after configuration + $identity = Shell::run('aws sts get-caller-identity 2>&1', $returnVar); + if ($returnVar !== 0) { + $output->writeln(' AWS authentication failed after configuration.'); + $output->writeln(" {$identity}"); + return Command::FAILURE; + } + + $identityData = json_decode($identity, true); + $account = $identityData['Account'] ?? 'unknown'; + $arn = $identityData['Arn'] ?? 'unknown'; + } + } else { + $output->writeln(' No AWS credentials found.'); $output->writeln(''); - $output->writeln(' Install the AWS CLI and configure credentials:'); - $output->writeln(' aws configure'); + $output->writeln(' Running aws configure to set up credentials...'); $output->writeln(''); - return Command::FAILURE; + + Shell::passthru('aws configure'); + $output->writeln(''); + + // Verify after configuration + $identity = Shell::run('aws sts get-caller-identity 2>&1', $returnVar); + if ($returnVar !== 0) { + $output->writeln(' AWS authentication failed. Check your credentials and try again.'); + $output->writeln(" {$identity}"); + return Command::FAILURE; + } + + $identityData = json_decode($identity, true); + $account = $identityData['Account'] ?? 'unknown'; + $arn = $identityData['Arn'] ?? 'unknown'; } - $identityData = json_decode($identity, true); - $account = $identityData['Account'] ?? 'unknown'; - $arn = $identityData['Arn'] ?? 'unknown'; $output->writeln(" Authenticated as: {$arn}"); $output->writeln(" Account: {$account}"); $output->writeln(''); diff --git a/src/Plugins/awssecrets/Commands/AwsSecretsPush.php b/src/Plugins/awssecrets/Commands/AwsSecretsPush.php index 4d117db..62ca608 100644 --- a/src/Plugins/awssecrets/Commands/AwsSecretsPush.php +++ b/src/Plugins/awssecrets/Commands/AwsSecretsPush.php @@ -3,12 +3,17 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\ChoiceQuestion; use Gitcd\Plugins\awssecrets\AwsSecretsHelper; use Gitcd\Helpers\Git; use Gitcd\Helpers\Config; +use Gitcd\Helpers\Shell; +use Gitcd\Helpers\Secrets; +use Gitcd\Utils\Json; class AwsSecretsPush extends Command { @@ -19,16 +24,22 @@ protected function configure(): void { $this ->setHelp(<<addOption('file', 'f', InputOption::VALUE_OPTIONAL, 'Path to .env file to push') + ->addArgument('environment', InputArgument::OPTIONAL, 'Environment to push (e.g., production, staging)') + ->addOption('file', 'f', InputOption::VALUE_OPTIONAL, 'Path to .env file to push (bypasses config repo)') ->addOption('dir', 'd', InputOption::VALUE_OPTIONAL, 'Directory Path', Git::getGitLocalFolder()) ->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt') ; @@ -39,46 +50,146 @@ protected function execute(InputInterface $input, OutputInterface $output): int $repoDir = $input->getOption('dir') ?: WORKING_DIR; $helper = $this->getHelper('question'); - // Resolve .env file path - $envFile = $input->getOption('file'); - if (!$envFile) { - // Try config repo first, then project root - $configRepo = Config::repo($repoDir); - if ($configRepo && is_file($configRepo . '.env')) { - $envFile = $configRepo . '.env'; - } elseif (is_file($repoDir . '.env')) { - $envFile = $repoDir . '.env'; + $output->writeln(''); + $output->writeln(' Push to AWS Secrets Manager'); + $output->writeln(' ────────────────────────────────'); + $output->writeln(''); + + // If --file is specified, skip environment selection + if ($input->getOption('file')) { + return $this->pushFile($input->getOption('file'), null, $repoDir, $input, $output); + } + + // Get the config repo + $configRepo = Config::repo($repoDir); + if (!$configRepo || !is_dir($configRepo)) { + $output->writeln(' No config repo found. Run `protocol config:init` first.'); + $output->writeln(' Or use --file to push a specific .env file.'); + $output->writeln(''); + return Command::FAILURE; + } + + // Get available environments (git branches in config repo) + $branchOutput = Shell::run("git -C " . escapeshellarg($configRepo) . " branch -a 2>/dev/null"); + $environments = $this->parseBranches($branchOutput); + + if (empty($environments)) { + $output->writeln(' No environment branches found in config repo.'); + return Command::FAILURE; + } + + // Determine which environment to push + $environment = $input->getArgument('environment'); + if (!$environment) { + // Show current environment + $currentBranch = trim(Shell::run("git -C " . escapeshellarg($configRepo) . " rev-parse --abbrev-ref HEAD 2>/dev/null") ?: ''); + if ($currentBranch) { + $output->writeln(" Current config branch: {$currentBranch}"); + $output->writeln(''); } + + // Prompt user to choose, defaulting to the current config branch + $defaultIndex = $currentBranch ? array_search($currentBranch, $environments) : 0; + if ($defaultIndex === false) $defaultIndex = 0; + + $question = new ChoiceQuestion( + ' Which environment do you want to push secrets for?', + $environments, + $defaultIndex + ); + $environment = $helper->ask($input, $output, $question); + $output->writeln(''); } - if (!$envFile || !is_file($envFile)) { - $output->writeln('No .env file found. Use --file to specify a path.'); + if (!in_array($environment, $environments)) { + $output->writeln(" Environment \"{$environment}\" not found in config repo."); + $output->writeln(' Available: ' . implode(', ', $environments)); return Command::FAILURE; } - $envContents = file_get_contents($envFile); - $secretName = AwsSecretsHelper::secretName($repoDir); + // Read .env from that branch (without switching branches) + $envContents = $this->readEnvFromBranch($configRepo, $environment, $output); + if ($envContents === null) { + return Command::FAILURE; + } + + // Override secret name to use the selected environment + $projectName = Json::read('name', '', $repoDir); + if (!$projectName && $repoDir) { + $projectName = basename(rtrim($repoDir, '/')); + } + $secretName = "protocol/{$projectName}/{$environment}"; // Parse and show key names (not values) $json = json_decode(AwsSecretsHelper::envToJson($envContents), true); $keyCount = count($json); + $output->writeln(" Environment: {$environment}"); + $output->writeln(" Secret: {$secretName}"); + $output->writeln(" Region: " . AwsSecretsHelper::region($repoDir) . ""); + $output->writeln(" Keys: {$keyCount}"); $output->writeln(''); - $output->writeln(" Push to AWS Secrets Manager"); + + // Show key names + foreach (array_keys($json) as $key) { + $output->writeln(" - {$key}"); + } $output->writeln(''); + + // Confirm + if (!$input->getOption('yes')) { + $question = new ConfirmationQuestion(" Push {$environment} secrets to AWS? [y/N] ", false); + if (!$helper->ask($input, $output, $question)) { + $output->writeln(' Cancelled.'); + return Command::SUCCESS; + } + $output->writeln(''); + } + + // Push with environment-specific secret name + $output->writeln(' Pushing secrets...'); + $success = AwsSecretsHelper::pushSecretAs($envContents, $secretName, $repoDir); + + if ($success) { + $output->writeln(" ✓ Secrets pushed to {$secretName}"); + $output->writeln(''); + } else { + $output->writeln(' Failed to push secrets. Check aws-secrets.log for details.'); + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + /** + * Push a specific file (--file mode, no environment selection). + */ + private function pushFile(string $envFile, ?string $environment, string $repoDir, InputInterface $input, OutputInterface $output): int + { + $helper = $this->getHelper('question'); + + if (!is_file($envFile)) { + $output->writeln(" File not found: {$envFile}"); + return Command::FAILURE; + } + + $envContents = file_get_contents($envFile); + $secretName = AwsSecretsHelper::secretName($repoDir); + + $json = json_decode(AwsSecretsHelper::envToJson($envContents), true); + $keyCount = count($json); + $output->writeln(" Source: {$envFile}"); $output->writeln(" Secret: {$secretName}"); $output->writeln(" Region: " . AwsSecretsHelper::region($repoDir) . ""); $output->writeln(" Keys: {$keyCount}"); $output->writeln(''); - // Show key names foreach (array_keys($json) as $key) { - $output->writeln(" • {$key}"); + $output->writeln(" - {$key}"); } $output->writeln(''); - // Confirm if (!$input->getOption('yes')) { $question = new ConfirmationQuestion(' Push these secrets to AWS? [y/N] ', false); if (!$helper->ask($input, $output, $question)) { @@ -88,7 +199,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(''); } - // Push $output->writeln(' Pushing secrets...'); $success = AwsSecretsHelper::pushSecret($envContents, $repoDir); @@ -102,4 +212,66 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + + /** + * Parse git branch output into clean branch names. + */ + private function parseBranches(string $branchOutput): array + { + $branches = []; + foreach (explode("\n", trim($branchOutput)) as $line) { + $line = trim($line, " *\t"); + // Skip remote tracking branches like remotes/origin/HEAD -> origin/master + if (str_contains($line, '->')) continue; + // Extract branch name from remotes/origin/xxx + if (str_starts_with($line, 'remotes/origin/')) { + $line = substr($line, strlen('remotes/origin/')); + } + if ($line && !in_array($line, $branches)) { + $branches[] = $line; + } + } + return $branches; + } + + /** + * Read .env contents from a specific branch without switching branches. + * Handles both plaintext .env and encrypted .env.enc. + */ + private function readEnvFromBranch(string $configRepo, string $branch, OutputInterface $output): ?string + { + $escapedRepo = escapeshellarg($configRepo); + $escapedBranch = escapeshellarg($branch); + + // Try plaintext .env first + $envContents = Shell::run("git -C {$escapedRepo} show {$escapedBranch}:.env 2>/dev/null", $returnVar); + if ($returnVar === 0 && $envContents) { + $output->writeln(" Read .env from {$branch} branch"); + $output->writeln(''); + return $envContents; + } + + // Try encrypted .env.enc + $encContents = Shell::run("git -C {$escapedRepo} show {$escapedBranch}:.env.enc 2>/dev/null", $returnVar); + if ($returnVar === 0 && $encContents) { + if (!Secrets::hasKey()) { + $output->writeln(" Found .env.enc on {$branch} but no encryption key on this machine."); + $output->writeln(' Copy the key from a node that has it, or generate a new one.'); + return null; + } + + $decrypted = Secrets::decrypt($encContents); + if ($decrypted === null) { + $output->writeln(" Failed to decrypt .env.enc from {$branch}. Wrong key?"); + return null; + } + + $output->writeln(" Decrypted .env.enc from {$branch} branch"); + $output->writeln(''); + return $decrypted; + } + + $output->writeln(" No .env or .env.enc found on branch \"{$branch}\""); + return null; + } } From 5a0b0d0dbda66af577c8e1db1101a74100a07d4e Mon Sep 17 00:00:00 2001 From: Jonathon Byrdziak Date: Sat, 21 Mar 2026 14:31:13 -0700 Subject: [PATCH 2/2] config:switch creates missing environments, aws:init shows required IAM policy - config:switch: When target branch doesn't exist, offer to create it via config:new instead of just failing - config:new: Accept environment name as argument (no longer interactive-only) - aws:init: On AccessDeniedException, display the exact IAM policy JSON needed (pre-filled with account ID and region), loop retry so user can update permissions and re-test without restarting the wizard - SecretsSetup: Minor help text update for environment scope Co-Authored-By: Claude Opus 4.6 --- src/Commands/ConfigNew.php | 10 +-- src/Commands/ConfigSwitch.php | 19 ++++- src/Commands/SecretsSetup.php | 3 +- .../awssecrets/Commands/AwsSecretsInit.php | 72 ++++++++++++++----- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/src/Commands/ConfigNew.php b/src/Commands/ConfigNew.php index 1c288af..5d14691 100644 --- a/src/Commands/ConfigNew.php +++ b/src/Commands/ConfigNew.php @@ -68,9 +68,8 @@ protected function configure(): void HELP) ; $this - // configure an argument + ->addArgument('environment', InputArgument::OPTIONAL, 'Name for the new environment (e.g., production, staging)') ->addOption('dir', 'd', InputOption::VALUE_OPTIONAL, 'Directory Path', Git::getGitLocalFolder()) - // ... ; } @@ -108,8 +107,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // get the correct environment - $question = new Question('New Environment Name: '); - $newName = $helper->ask($input, $output, $question); + $newName = $input->getArgument('environment'); + if (!$newName) { + $question = new Question('New Environment Name: '); + $newName = $helper->ask($input, $output, $question); + } $slug = Str::slugify( $newName ); if ($newName != $slug) { diff --git a/src/Commands/ConfigSwitch.php b/src/Commands/ConfigSwitch.php index 458a724..87dd2b5 100644 --- a/src/Commands/ConfigSwitch.php +++ b/src/Commands/ConfigSwitch.php @@ -131,9 +131,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int $remoteExists = trim(Shell::run("git -C {$escapedRepo} branch -r --list " . escapeshellarg("*/{$newName}") . " 2>/dev/null") ?: ''); if (!$localExists && !$remoteExists) { - $output->writeln("Environment \"{$newName}\" does not exist."); - $output->writeln('Available: ' . implode(', ', $branches)); - return Command::FAILURE; + $question = new ConfirmationQuestion( + "Environment \"{$newName}\" does not exist. Create it? [Y/n] ", + true + ); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Available: ' . implode(', ', $branches)); + return Command::FAILURE; + } + + // Create the new branch from current, then let the rest of the flow handle it + $command = $this->getApplication()->find('config:new'); + $returnCode = $command->run(new ArrayInput([ + 'environment' => $newName, + '--dir' => $repo_dir, + ]), $output); + return $returnCode; } if ($newName === $currentBranch) { diff --git a/src/Commands/SecretsSetup.php b/src/Commands/SecretsSetup.php index bdf2dbc..d5206b4 100644 --- a/src/Commands/SecretsSetup.php +++ b/src/Commands/SecretsSetup.php @@ -59,8 +59,7 @@ protected function configure(): void The storage location is determined by the environment set via config:env. Production environments store the key globally in protocol's .node/key. All other environments (local, dev, staging) - store the key per-project in the current working directory's - .protocol/key. + store the key per-project in the current working directory's .node/key. The key is also read automatically from the PROTOCOL_ENCRYPTION_KEY environment variable, so CI/CD workflows can pass it from GitHub secrets. diff --git a/src/Plugins/awssecrets/Commands/AwsSecretsInit.php b/src/Plugins/awssecrets/Commands/AwsSecretsInit.php index 43b9215..11f8dcc 100644 --- a/src/Plugins/awssecrets/Commands/AwsSecretsInit.php +++ b/src/Plugins/awssecrets/Commands/AwsSecretsInit.php @@ -165,31 +165,65 @@ protected function execute(InputInterface $input, OutputInterface $output): int $secretName = $helper->ask($input, $output, $question); $output->writeln(''); - // ── Step 3: Test Access ────────────────────────────────────── - $output->writeln(' Step 3/3: Test Access'); + // ── Step 3: Test Secrets Manager Access ────────────────────── + $output->writeln(' Step 3/3: Test Secrets Manager Access'); $output->writeln(''); - // Try to describe the secret (may not exist yet, that's ok) - $testCmd = 'aws secretsmanager describe-secret' - . ' --secret-id ' . escapeshellarg($secretName) - . ' --region ' . escapeshellarg($region) - . ' 2>&1'; - $testResult = Shell::run($testCmd, $testReturn); + $accessGranted = false; + while (!$accessGranted) { + $testCmd = 'aws secretsmanager describe-secret' + . ' --secret-id ' . escapeshellarg($secretName) + . ' --region ' . escapeshellarg($region) + . ' 2>&1'; + $testResult = Shell::run($testCmd, $testReturn); + + if ($testReturn === 0) { + $output->writeln(" Secret exists: {$secretName}"); + $accessGranted = true; + } elseif (strpos($testResult, 'ResourceNotFoundException') !== false) { + // Secret doesn't exist yet but we have permission to check — that's fine + $output->writeln(" Access confirmed — secret will be created on first aws:push"); + $accessGranted = true; + } elseif (strpos($testResult, 'AccessDeniedException') !== false) { + $output->writeln(' ✗ Access denied — this identity cannot access Secrets Manager.'); + $output->writeln(''); + $output->writeln(' Add this IAM policy to the role/user shown above:'); + $output->writeln(''); + $output->writeln(' {'); + $output->writeln(' "Version": "2012-10-17",'); + $output->writeln(' "Statement": [{'); + $output->writeln(' "Effect": "Allow",'); + $output->writeln(' "Action": ['); + $output->writeln(' "secretsmanager:CreateSecret",'); + $output->writeln(' "secretsmanager:PutSecretValue",'); + $output->writeln(' "secretsmanager:GetSecretValue",'); + $output->writeln(' "secretsmanager:DescribeSecret",'); + $output->writeln(' "secretsmanager:ListSecrets"'); + $output->writeln(' ],'); + $output->writeln(" \"Resource\": \"arn:aws:secretsmanager:{$region}:{$account}:secret:protocol/*\""); + $output->writeln(' }]'); + $output->writeln(' }'); + $output->writeln(''); - if ($testReturn === 0) { - $output->writeln(" Secret exists: {$secretName}"); - } else { - $testData = json_decode($testResult, true); - $errorCode = $testData['Error']['Code'] ?? ''; + $retryQ = new ConfirmationQuestion(' Retry after updating permissions? [Y/n] ', true); + if (!$helper->ask($input, $output, $retryQ)) { + $output->writeln(''); + $output->writeln(' Configuration saved but access test failed.'); + $output->writeln(' Update IAM permissions and re-run protocol aws:init'); + $output->writeln(''); - if (strpos($testResult, 'ResourceNotFoundException') !== false) { - $output->writeln(" Secret does not exist yet — it will be created on first aws:push"); - } elseif (strpos($testResult, 'AccessDeniedException') !== false) { - $output->writeln(' ✗ Access denied — check IAM permissions for secretsmanager:DescribeSecret'); - $output->writeln(" {$testResult}"); - return Command::FAILURE; + // Still save the config so they don't have to re-enter region/name + Json::write('aws.region', $region, $repoDir); + Json::write('aws.secret_name', $secretName, $repoDir); + Json::save($repoDir); + return Command::FAILURE; + } + $output->writeln(''); + $output->writeln(' Retrying...'); + $output->writeln(''); } else { $output->writeln(" ⚠ Unexpected response: {$testResult}"); + $accessGranted = true; // Don't loop on unknown errors } }