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 b92e1bc..87dd2b5 100644
--- a/src/Commands/ConfigSwitch.php
+++ b/src/Commands/ConfigSwitch.php
@@ -106,32 +106,76 @@ 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) {
+ $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) {
+ $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/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/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..11f8dcc 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('');
- $output->writeln(' Install the AWS CLI and configure credentials:');
- $output->writeln(' aws configure>');
+
+ 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(' Running aws configure> to set up credentials...');
+ $output->writeln('');
+
+ Shell::passthru('aws configure');
$output->writeln('');
- return Command::FAILURE;
+
+ // 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('');
@@ -86,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);
-
- if ($testReturn === 0) {
- $output->writeln(" ✓ Secret exists: {$secretName}>");
- } else {
- $testData = json_decode($testResult, true);
- $errorCode = $testData['Error']['Code'] ?? '';
-
- if (strpos($testResult, 'ResourceNotFoundException') !== false) {
- $output->writeln(" ✓ Secret does not exist yet — it will be created on first aws:push>");
+ $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 — check IAM permissions for secretsmanager:DescribeSecret');
- $output->writeln(" {$testResult}>");
- return Command::FAILURE;
+ $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('');
+
+ $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('');
+
+ // 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
}
}
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;
+ }
}