From 7da8f61171b2c03f872584ed771f79a578469f68 Mon Sep 17 00:00:00 2001 From: threebeats Date: Sun, 21 Jun 2026 16:16:32 +0000 Subject: [PATCH 1/2] feat: add env-updater plugin for cross-platform env sync Adds a new secrets provider that syncs environment variables across: - Local .env files - Doppler (via Doppler CLI) - Railway (via Railway CLI) - GitHub Secrets (via gh CLI) The provider extends the existing secrets architecture with a unified push/pull interface that updates all configured targets at once. Closes #710 Usage: sh1pt secret push --provider env-updater --- packages/secrets/env-updater/package.json | 11 + .../secrets/env-updater/src/index.test.ts | 17 ++ packages/secrets/env-updater/src/index.ts | 197 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 packages/secrets/env-updater/package.json create mode 100644 packages/secrets/env-updater/src/index.test.ts create mode 100644 packages/secrets/env-updater/src/index.ts diff --git a/packages/secrets/env-updater/package.json b/packages/secrets/env-updater/package.json new file mode 100644 index 00000000..c69d4912 --- /dev/null +++ b/packages/secrets/env-updater/package.json @@ -0,0 +1,11 @@ +{ + "name": "@profullstack/secrets-env-updater", + "version": "0.1.0", + "description": "Sync environment variables across .env, Doppler, Railway, and GitHub Secrets", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + } +} diff --git a/packages/secrets/env-updater/src/index.test.ts b/packages/secrets/env-updater/src/index.test.ts new file mode 100644 index 00000000..e79f2718 --- /dev/null +++ b/packages/secrets/env-updater/src/index.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +describe('secrets-env-updater', () => { + it('should have the provider registered', async () => { + const provider = await import('./index.js'); + expect(provider.default).toBeDefined(); + expect(provider.default.id).toBe('secrets-env-updater'); + expect(provider.default.label).toBe('Env Updater'); + }); + + it('should define push/pull/connect methods', async () => { + const provider = await import('./index.js'); + expect(typeof provider.default.connect).toBe('function'); + expect(typeof provider.default.pull).toBe('function'); + expect(typeof provider.default.push).toBe('function'); + }); +}); diff --git a/packages/secrets/env-updater/src/index.ts b/packages/secrets/env-updater/src/index.ts new file mode 100644 index 00000000..4dc9b2a0 --- /dev/null +++ b/packages/secrets/env-updater/src/index.ts @@ -0,0 +1,197 @@ +/** + * ๐Ÿ” sh1pt env-updater plugin + * Sync environment variables across: + * - Local .env files + * - Doppler + * - Railway + * - GitHub Secrets + * + * One command to update all environments at once. + */ +import { defineSecretProvider, manualSetup, type SecretRef } from '@profullstack/sh1pt-core'; +import { readFile, writeFile } from 'node:fs/promises'; +import { execSync } from 'node:child_process'; + +interface Config { + envFile?: string; + dopplerProject?: string; + dopplerConfig?: string; + railwayService?: string; + githubRepo?: string; + githubOwner?: string; +} + +const DEFAULT_ENV_FILE = '.env'; + +// ===== HELPERS ===== + +async function readEnvFile(file: string): Promise> { + try { + const text = await readFile(file, 'utf8'); + const secrets: Record = {}; + for (const line of text.split(/\r?\n/)) { + const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (match) { + secrets[match[1]] = match[2].replace(/^["']|["']$/g, ''); + } + } + return secrets; + } catch { + return {}; + } +} + +async function writeEnvFile(file: string, secrets: Record): Promise { + const lines = Object.entries(secrets).map(([key, value]) => `${key}=${value}`); + await writeFile(file, lines.join('\n') + '\n', 'utf8'); +} + +// ===== PLATFORM UPDATERS ===== + +async function updateDoppler(secrets: Record, config: Config): Promise { + const project = config.dopplerProject; + const dopplerConfig = config.dopplerConfig || 'prd'; + if (!project) throw new Error('dopplerProject required'); + + try { + execSync(`doppler secrets set --project ${project} --config ${dopplerConfig} ${Object.entries(secrets).map(([k, v]) => `"${k}=${v}"`).join(' ')}`, { + stdio: 'pipe', + timeout: 30000 + }); + } catch (err) { + throw new Error(`Doppler update failed: ${err}`); + } +} + +async function updateRailway(secrets: Record, config: Config): Promise { + const service = config.railwayService; + if (!service) throw new Error('railwayService required'); + + try { + for (const [key, value] of Object.entries(secrets)) { + execSync(`railway variables set ${key}=${value} --service ${service}`, { + stdio: 'pipe', + timeout: 15000 + }); + } + } catch (err) { + throw new Error(`Railway update failed: ${err}`); + } +} + +async function updateGitHubSecrets(secrets: Record, config: Config): Promise { + const repo = config.githubRepo; + const owner = config.githubOwner || 'profullstack'; + if (!repo) throw new Error('githubRepo required'); + + try { + for (const [key, value] of Object.entries(secrets)) { + const tmpFile = `/tmp/gh-secret-${key}`; + await writeFile(tmpFile, value, 'utf8'); + execSync(`gh secret set ${key} --repo ${owner}/${repo} < ${tmpFile}`, { + stdio: 'pipe', + timeout: 15000 + }); + } + } catch (err) { + throw new Error(`GitHub Secrets update failed: ${err}`); + } +} + +// ===== PROVIDER ===== + +export default defineSecretProvider({ + id: 'secrets-env-updater', + label: 'Env Updater', + cli: 'env-updater', + + async connect(ctx, config) { + ctx.log(`env-updater status ยท envFile=${config.envFile ?? DEFAULT_ENV_FILE}`); + const targets: string[] = ['local']; + if (config.dopplerProject) targets.push('doppler'); + if (config.railwayService) targets.push('railway'); + if (config.githubRepo) targets.push('github'); + ctx.log(`targets: ${targets.join(', ')}`); + return { accountId: `env-updater-${targets.join('-')}` }; + }, + + async pull(ctx, config): Promise { + const file = config.envFile ?? DEFAULT_ENV_FILE; + ctx.log(`env-updater pull โ€” reading ${file}`); + const secrets = await readEnvFile(file); + return Object.entries(secrets).map(([key, value]) => ({ + key, + value, + path: file + })); + }, + + async push(ctx, secrets, config) { + const file = config.envFile ?? DEFAULT_ENV_FILE; + ctx.log(`env-updater push <${secrets.length} keys>`); + + // Build secrets map + const secretMap: Record = {}; + for (const secret of secrets) { + if (secret.key && secret.value !== undefined && secret.value !== null) { + secretMap[secret.key] = secret.value; + } + } + + const results: string[] = []; + + // 1. Always update local .env + await writeEnvFile(file, secretMap); + results.push(`local .env (${Object.keys(secretMap).length} keys)`); + ctx.log(` โœ“ local .env updated`); + + // 2. Doppler + if (config.dopplerProject) { + try { + await updateDoppler(secretMap, config); + results.push('doppler'); + ctx.log(` โœ“ doppler (${config.dopplerProject}/${config.dopplerConfig ?? 'prd'})`); + } catch (err) { + results.push(`doppler: FAILED`); + ctx.log(` โœ— doppler: ${err}`); + } + } + + // 3. Railway + if (config.railwayService) { + try { + await updateRailway(secretMap, config); + results.push('railway'); + ctx.log(` โœ“ railway (${config.railwayService})`); + } catch (err) { + results.push(`railway: FAILED`); + ctx.log(` โœ— railway: ${err}`); + } + } + + // 4. GitHub Secrets + if (config.githubRepo) { + try { + await updateGitHubSecrets(secretMap, config); + results.push('github'); + ctx.log(` โœ“ github (${config.githubOwner ?? 'profullstack'}/${config.githubRepo})`); + } catch (err) { + results.push(`github: FAILED`); + ctx.log(` โœ— github: ${err}`); + } + } + + return { count: secrets.length, targets: results }; + }, + + setup: manualSetup({ + label: 'Env Updater', + vendorDocUrl: 'https://github.com/profullstack/sh1pt', + steps: [ + 'Install CLI tools: doppler CLI, railway CLI, gh CLI', + 'Authenticate each platform: doppler login, railway login, gh auth login', + 'Configure targets in sh1pt.config.ts or pass via CLI flags', + 'Run: sh1pt secret push --provider env-updater' + ], + }), +}); From afbbd90a00008b2f1ff0ce2005014e3e60f27ca2 Mon Sep 17 00:00:00 2001 From: threebeats Date: Sun, 21 Jun 2026 18:34:19 +0000 Subject: [PATCH 2/2] fix: address security audit findings - Replace execSync with spawnSync + args arrays (fixes command injection) - Pass GitHub secrets via stdin instead of temp files (fixes secret leak) - Merge into existing .env instead of overwriting (fixes destructive overwrite) --- packages/secrets/env-updater/src/index.ts | 92 ++++++++++++++--------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/packages/secrets/env-updater/src/index.ts b/packages/secrets/env-updater/src/index.ts index 4dc9b2a0..acf72ca4 100644 --- a/packages/secrets/env-updater/src/index.ts +++ b/packages/secrets/env-updater/src/index.ts @@ -1,16 +1,16 @@ /** * ๐Ÿ” sh1pt env-updater plugin * Sync environment variables across: - * - Local .env files - * - Doppler - * - Railway - * - GitHub Secrets + * - Local .env files (merge-safe) + * - Doppler (via spawnSync, no shell injection) + * - Railway (via spawnSync, no shell injection) + * - GitHub Secrets (via stdin, no temp files) * * One command to update all environments at once. */ import { defineSecretProvider, manualSetup, type SecretRef } from '@profullstack/sh1pt-core'; import { readFile, writeFile } from 'node:fs/promises'; -import { execSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; interface Config { envFile?: string; @@ -22,15 +22,17 @@ interface Config { } const DEFAULT_ENV_FILE = '.env'; +const ENV_ENTRY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/; // ===== HELPERS ===== +/** Read an env file into a key-value map, preserving existing entries. */ async function readEnvFile(file: string): Promise> { try { const text = await readFile(file, 'utf8'); const secrets: Record = {}; for (const line of text.split(/\r?\n/)) { - const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + const match = ENV_ENTRY.exec(line); if (match) { secrets[match[1]] = match[2].replace(/^["']|["']$/g, ''); } @@ -41,25 +43,51 @@ async function readEnvFile(file: string): Promise> { } } -async function writeEnvFile(file: string, secrets: Record): Promise { - const lines = Object.entries(secrets).map(([key, value]) => `${key}=${value}`); +/** + * Merge new secrets into an existing .env file. + * Existing keys are updated in-place; new keys are appended. + * Will not delete keys not present in the push payload. + */ +async function writeEnvFile(file: string, newSecrets: Record): Promise { + const existing = await readEnvFile(file); + // Merge: existing values are overridden by new ones + for (const [key, value] of Object.entries(newSecrets)) { + existing[key] = value; + } + const lines = Object.entries(existing).map(([key, value]) => `${key}=${value}`); await writeFile(file, lines.join('\n') + '\n', 'utf8'); } -// ===== PLATFORM UPDATERS ===== +/** Run a command with args array โ€” no shell interpolation. */ +function run(cmd: string, args: string[], input?: string): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(cmd, args, { + input: input ?? undefined, + encoding: 'utf-8', + timeout: 30000, + stdio: input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'], + }); + return { + stdout: result.stdout?.trim() ?? '', + stderr: result.stderr?.trim() ?? '', + status: result.status, + }; +} + +// ===== PLATFORM UPDATERS (spawnSync โ€” no shell injection) ===== async function updateDoppler(secrets: Record, config: Config): Promise { const project = config.dopplerProject; const dopplerConfig = config.dopplerConfig || 'prd'; if (!project) throw new Error('dopplerProject required'); + + const args = ['secrets', 'set', '--project', project, '--config', dopplerConfig]; + for (const [key, value] of Object.entries(secrets)) { + args.push(`${key}=${value}`); + } - try { - execSync(`doppler secrets set --project ${project} --config ${dopplerConfig} ${Object.entries(secrets).map(([k, v]) => `"${k}=${v}"`).join(' ')}`, { - stdio: 'pipe', - timeout: 30000 - }); - } catch (err) { - throw new Error(`Doppler update failed: ${err}`); + const result = run('doppler', args); + if (result.status !== 0) { + throw new Error(`Doppler update failed: ${result.stderr || result.stdout}`); } } @@ -67,15 +95,12 @@ async function updateRailway(secrets: Record, config: Config): P const service = config.railwayService; if (!service) throw new Error('railwayService required'); - try { - for (const [key, value] of Object.entries(secrets)) { - execSync(`railway variables set ${key}=${value} --service ${service}`, { - stdio: 'pipe', - timeout: 15000 - }); + for (const [key, value] of Object.entries(secrets)) { + const args = ['variables', 'set', `${key}=${value}`, '--service', service]; + const result = run('railway', args); + if (result.status !== 0) { + throw new Error(`Railway update failed for ${key}: ${result.stderr || result.stdout}`); } - } catch (err) { - throw new Error(`Railway update failed: ${err}`); } } @@ -84,17 +109,12 @@ async function updateGitHubSecrets(secrets: Record, config: Conf const owner = config.githubOwner || 'profullstack'; if (!repo) throw new Error('githubRepo required'); - try { - for (const [key, value] of Object.entries(secrets)) { - const tmpFile = `/tmp/gh-secret-${key}`; - await writeFile(tmpFile, value, 'utf8'); - execSync(`gh secret set ${key} --repo ${owner}/${repo} < ${tmpFile}`, { - stdio: 'pipe', - timeout: 15000 - }); + for (const [key, value] of Object.entries(secrets)) { + // Pass secret value via stdin โ€” no temp files, no shell + const result = run('gh', ['secret', 'set', key, '--repo', `${owner}/${repo}`], value); + if (result.status !== 0) { + throw new Error(`GitHub Secrets update failed for ${key}: ${result.stderr || result.stdout}`); } - } catch (err) { - throw new Error(`GitHub Secrets update failed: ${err}`); } } @@ -140,7 +160,7 @@ export default defineSecretProvider({ const results: string[] = []; - // 1. Always update local .env + // 1. Always update local .env (merge-safe โ€” won't delete existing keys) await writeEnvFile(file, secretMap); results.push(`local .env (${Object.keys(secretMap).length} keys)`); ctx.log(` โœ“ local .env updated`); @@ -169,7 +189,7 @@ export default defineSecretProvider({ } } - // 4. GitHub Secrets + // 4. GitHub Secrets (via stdin โ€” no temp files) if (config.githubRepo) { try { await updateGitHubSecrets(secretMap, config);