diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 22f085aa..a5f6a53f 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -95,8 +95,8 @@ export const CATEGORIES: readonly AdapterCategory[] = [ { id: 'secrets', pkgPrefix: '@profullstack/sh1pt-secrets', - description: 'Secrets CLIs — Doppler, dotenvx, GitHub Secrets, 1Password, Railway', - adapters: ['doppler', 'dotenvx', 'github', 'onepassword', 'railway'], + description: 'Secrets CLIs — Doppler, dotenvx, Environment Updater, GitHub Secrets, 1Password, Railway', + adapters: ['doppler', 'dotenvx', 'env-updater', 'github', 'onepassword', 'railway'], }, { id: 'security', diff --git a/packages/cli/src/commands/secrets.ts b/packages/cli/src/commands/secrets.ts index 56321e9a..4b260052 100644 --- a/packages/cli/src/commands/secrets.ts +++ b/packages/cli/src/commands/secrets.ts @@ -161,3 +161,264 @@ secretsCmd } } }); + +// --------------------------------------------------------------------------- +// Environment updater — sync secrets across providers +// --------------------------------------------------------------------------- + +// Provider slug → adapter package mapping for lazy install. +const PROVIDER_PACKAGES: Record = { + dotenvx: '@profullstack/sh1pt-secrets-dotenvx', + doppler: '@profullstack/sh1pt-secrets-doppler', + github: '@profullstack/sh1pt-secrets-github', + railway: '@profullstack/sh1pt-secrets-railway', + onepassword: '@profullstack/sh1pt-secrets-onepassword', + 'env-updater': '@profullstack/sh1pt-secrets-env-updater', +}; + +function providerId(slug: string): string { + return `secrets-${slug}`; +} + +interface EnvUpdateOpts { + from?: string; + to?: string[]; + envFile?: string; + exclude?: string[]; + include?: string[]; +} + +interface EnvDiffOpts { + source?: string; + target?: string; + envFile?: string; + exclude?: string[]; + include?: string[]; +} + +secretsCmd + .command('env-update') + .description('Sync environment variables across providers (.env, Doppler, Railway, GitHub Secrets)') + .option('--from ', 'source provider slug (default: dotenvx)', 'dotenvx') + .option('--to ', 'target provider slugs (comma-separated or repeated)') + .option('--env-file ', 'path to .env file (for dotenvx provider)', '.env') + .option('--exclude ', 'key patterns to exclude') + .option('--include ', 'key patterns to include') + .action(async (opts: EnvUpdateOpts) => { + const { ensureInstalled, loadInstalledPackage } = await import('../installer.js'); + const { registerSecretProvider, type SecretProvider } = await import('@profullstack/sh1pt-core'); + + // Collect all provider slugs we need + const fromSlug = opts.from ?? 'dotenvx'; + const toSlugs = (opts.to ?? []).flatMap((t) => t.split(',').map((s) => s.trim()).filter(Boolean)); + + const allSlugs = [fromSlug, ...toSlugs]; + const pkgs = allSlugs + .map((s) => PROVIDER_PACKAGES[s]) + .filter((p): p is string => p !== undefined); + + if (pkgs.length > 0) { + try { + await ensureInstalled([...new Set(pkgs)]); + } catch (err) { + console.error(kleur.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } + + // Load and register each provider + for (const slug of [...new Set(allSlugs)]) { + const pkg = PROVIDER_PACKAGES[slug]; + if (!pkg) { + console.error(kleur.yellow(`Unknown provider: ${slug}. Supported: ${Object.keys(PROVIDER_PACKAGES).join(', ')}`)); + process.exit(1); + } + try { + const provider = await loadInstalledPackage>(pkg); + if (provider) registerSecretProvider(provider); + } catch { + // Already registered or not loadable — skip + } + } + + // Load the env-updater orchestrator + try { + await ensureInstalled([PROVIDER_PACKAGES['env-updater']!]); + } catch (err) { + console.error(kleur.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + + let mod: typeof import('@profullstack/sh1pt-secrets-env-updater'); + try { + mod = await import('@profullstack/sh1pt-secrets-env-updater'); + } catch (err) { + console.error(kleur.red(`Failed to load env-updater: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + + const ctx = { + secret: (k: string) => undefined as string | undefined, + log: (m: string) => console.log(kleur.dim(m)), + }; + + // Resolve vault secrets lazily + try { + ctx.secret = (k: string) => { + // Sync lookup not possible — return undefined; providers use direct values + return undefined; + }; + } catch { + // vault not available + } + + const sourceConfig: Record = {}; + if (fromSlug === 'dotenvx') sourceConfig.envFile = opts.envFile; + + const targetConfigs = toSlugs.map((slug) => { + const config: Record = {}; + if (slug === 'dotenvx') config.envFile = opts.envFile; + return { id: providerId(slug), config }; + }); + + const syncConfig = { + excludeKeys: opts.exclude, + includeKeys: opts.include, + }; + + try { + console.log(kleur.cyan(`Pulling from ${fromSlug}…`)); + const secrets = await mod.pullFrom(ctx, { id: providerId(fromSlug), config: sourceConfig }, syncConfig); + console.log(kleur.green(` ${secrets.length} keys pulled`)); + + if (targetConfigs.length === 0) { + console.log(kleur.yellow('No --to targets specified. Use --to to push secrets.')); + return; + } + + console.log(kleur.cyan(`Pushing to ${toSlugs.join(', ')}…`)); + const results = await mod.syncEnv( + ctx, + { id: providerId(fromSlug), config: sourceConfig }, + targetConfigs, + syncConfig, + ); + + for (const result of results) { + const icon = result.status === 'ok' ? kleur.green('✓') + : result.status === 'skipped' ? kleur.dim('–') + : kleur.red('✗'); + console.log(` ${icon} ${result.provider}: ${result.status === 'ok' ? `${result.count} keys` : result.status}${result.error ? ` (${result.error})` : ''}`); + } + } catch (err) { + console.error(kleur.red(`env-update failed: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); + +secretsCmd + .command('env-diff') + .description('Compare environment variables between two providers') + .option('--source ', 'source provider slug (default: dotenvx)', 'dotenvx') + .option('--target ', 'target provider slug (required)') + .option('--env-file ', 'path to .env file (for dotenvx provider)', '.env') + .option('--exclude ', 'key patterns to exclude') + .option('--include ', 'key patterns to include') + .action(async (opts: EnvDiffOpts) => { + if (!opts.target) { + console.error(kleur.yellow('--target is required. Use --target (e.g. doppler, railway, github).')); + process.exit(1); + } + + const { ensureInstalled, loadInstalledPackage } = await import('../installer.js'); + const { registerSecretProvider, type SecretProvider } = await import('@profullstack/sh1pt-core'); + + const sourceSlug = opts.source ?? 'dotenvx'; + const targetSlug = opts.target; + const slugs = [sourceSlug, targetSlug]; + const pkgs = slugs + .map((s) => PROVIDER_PACKAGES[s]) + .filter((p): p is string => p !== undefined); + + if (pkgs.length > 0) { + try { + await ensureInstalled([...new Set(pkgs)]); + } catch (err) { + console.error(kleur.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } + + for (const slug of [...new Set(slugs)]) { + const pkg = PROVIDER_PACKAGES[slug]; + if (!pkg) { + console.error(kleur.yellow(`Unknown provider: ${slug}. Supported: ${Object.keys(PROVIDER_PACKAGES).join(', ')}`)); + process.exit(1); + } + try { + const provider = await loadInstalledPackage>(pkg); + if (provider) registerSecretProvider(provider); + } catch { + // Already registered + } + } + + try { + await ensureInstalled([PROVIDER_PACKAGES['env-updater']!]); + } catch (err) { + console.error(kleur.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + + let mod: typeof import('@profullstack/sh1pt-secrets-env-updater'); + try { + mod = await import('@profullstack/sh1pt-secrets-env-updater'); + } catch (err) { + console.error(kleur.red(`Failed to load env-updater: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + + const ctx = { + secret: () => undefined as string | undefined, + log: (m: string) => console.log(kleur.dim(m)), + }; + + const sourceConfig: Record = {}; + if (sourceSlug === 'dotenvx') sourceConfig.envFile = opts.envFile; + + const targetConfig: Record = {}; + if (targetSlug === 'dotenvx') targetConfig.envFile = opts.envFile; + + try { + const entries = await mod.diffEnv( + ctx, + { id: providerId(sourceSlug), config: sourceConfig }, + { id: providerId(targetSlug), config: targetConfig }, + { excludeKeys: opts.exclude, includeKeys: opts.include }, + ); + + const added = entries.filter((e) => e.status === 'added'); + const removed = entries.filter((e) => e.status === 'removed'); + const changed = entries.filter((e) => e.status === 'changed'); + const unchanged = entries.filter((e) => e.status === 'unchanged'); + + console.log(kleur.bold(`Diff: ${sourceSlug} → ${targetSlug}`)); + console.log(` ${kleur.green(`+${added.length} added`)} ${kleur.red(`-${removed.length} removed`)} ${kleur.yellow(`~${changed.length} changed`)} ${kleur.dim(`${unchanged.length} unchanged`)}`); + + if (added.length) { + console.log(kleur.green('\n Added (in source, not in target):')); + for (const e of added) console.log(` ${kleur.green('+')} ${e.key}`); + } + if (removed.length) { + console.log(kleur.red('\n Removed (in target, not in source):')); + for (const e of removed) console.log(` ${kleur.red('-')} ${e.key}`); + } + if (changed.length) { + console.log(kleur.yellow('\n Changed:')); + for (const e of changed) console.log(` ${kleur.yellow('~')} ${e.key}`); + } + } catch (err) { + console.error(kleur.red(`env-diff failed: ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + }); diff --git a/packages/secrets/env-updater/README.md b/packages/secrets/env-updater/README.md new file mode 100644 index 00000000..fff30aab --- /dev/null +++ b/packages/secrets/env-updater/README.md @@ -0,0 +1,70 @@ +# Environment Updater + +Orchestrates secret synchronization across multiple providers (.env files, Doppler, Railway, GitHub Secrets). + +## What it does + +- Pulls secrets from a source provider and pushes them to one or more target providers. +- Supports .env file management, Doppler, Railway, and GitHub Secrets out of the box. +- Provides diff functionality to compare secrets across providers. +- Filters keys via include/exclude glob patterns. +- Exports standalone helpers (`pullFrom`, `pushTo`, `syncEnv`, `diffEnv`, `readEnvFile`, `writeEnvFile`) for custom workflows. + +## Package + +- Name: `@profullstack/sh1pt-secrets-env-updater` +- Path: `packages/secrets/env-updater` +- Adapter ID: `secrets-env-updater` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-secrets-env-updater +``` + +### Sync .env to Doppler and GitHub Secrets + +```ts +import { syncEnv } from '@profullstack/sh1pt-secrets-env-updater'; + +const results = await syncEnv( + { secret: (k) => vault[k], log: console.log }, + { id: 'secrets-dotenvx', config: { envFile: '.env' } }, + [ + { id: 'secrets-doppler', config: { project: 'my-app', config: 'production' } }, + { id: 'secrets-github', config: { repo: 'owner/repo' } }, + ], +); +``` + +### Diff secrets between providers + +```ts +import { diffEnv } from '@profullstack/sh1pt-secrets-env-updater'; + +const entries = await diffEnv( + { secret: (k) => vault[k], log: console.log }, + { id: 'secrets-dotenvx', config: { envFile: '.env' } }, + { id: 'secrets-doppler', config: { project: 'my-app', config: 'production' } }, +); +// entries: [{ key, sourceValue, targetValue, status: 'added' | 'removed' | 'changed' | 'unchanged' }] +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-secrets-env-updater typecheck +``` + +Run tests from the repository root: + +```bash +pnpm vitest run packages/secrets/env-updater/src/index.test.ts +``` diff --git a/packages/secrets/env-updater/package.json b/packages/secrets/env-updater/package.json new file mode 100644 index 00000000..549c810b --- /dev/null +++ b/packages/secrets/env-updater/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-secrets-env-updater", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/secrets/env-updater" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} 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..f4a6613c --- /dev/null +++ b/packages/secrets/env-updater/src/index.test.ts @@ -0,0 +1,371 @@ +import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import adapter, { + diffEnv, + pullFrom, + pushTo, + readEnvFile, + syncEnv, + writeEnvFile, +} from './index.js'; + +smokeTest(adapter, { idPrefix: 'secrets' }); + +const tempDirs: string[] = []; + +async function tempEnvFile(contents = ''): Promise { + const dir = await mkdtemp(join(tmpdir(), 'sh1pt-env-updater-')); + tempDirs.push(dir); + const envFile = join(dir, '.env'); + await writeFile(envFile, contents, 'utf8'); + return envFile; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +// --------------------------------------------------------------------------- +// Mock provider registry +// --------------------------------------------------------------------------- + +const mockProviders = new Map; + push: ReturnType; +}>(); + +vi.mock('@profullstack/sh1pt-core', async () => { + const actual = await vi.importActual('@profullstack/sh1pt-core'); + return { + ...actual, + getSecretProvider: (id: string) => { + const mock = mockProviders.get(id); + if (!mock) return undefined; + return { + id, + label: id, + cli: 'mock', + connect: vi.fn(), + pull: mock.pull, + push: mock.push, + }; + }, + listSecretProviders: () => [...mockProviders.keys()].map((id) => ({ id })), + }; +}); + +function registerMockProvider(id: string, overrides: { + pull?: ReturnType; + push?: ReturnType; +} = {}) { + const mock = { + pull: overrides.pull ?? vi.fn().mockResolvedValue([]), + push: overrides.push ?? vi.fn().mockResolvedValue({ count: 0 }), + }; + mockProviders.set(id, mock); + return mock; +} + +beforeEach(() => { + mockProviders.clear(); + vi.clearAllMocks(); +}); + +const ctx = { secret: () => undefined, log: () => {} }; + +// --------------------------------------------------------------------------- +// readEnvFile / writeEnvFile +// --------------------------------------------------------------------------- + +describe('readEnvFile', () => { + it('parses env entries with comments, quotes, and export prefixes', async () => { + const file = await tempEnvFile([ + '# comment line', + 'API_KEY=abc123', + 'export DB_URL="postgres://localhost/db"', + "SECRET='single quoted'", + 'MULTI="line\\nnext"', + 'EMPTY=', + '', + ].join('\n')); + + const result = await readEnvFile(file); + expect(result).toEqual([ + { key: 'API_KEY', value: 'abc123', path: file }, + { key: 'DB_URL', value: 'postgres://localhost/db', path: file }, + { key: 'SECRET', value: 'single quoted', path: file }, + { key: 'MULTI', value: 'line\nnext', path: file }, + { key: 'EMPTY', value: '', path: file }, + ]); + }); + + it('returns empty array for non-existent files', async () => { + const result = await readEnvFile('/tmp/sh1pt-nonexistent-env-updater-test'); + expect(result).toEqual([]); + }); +}); + +describe('writeEnvFile', () => { + it('upserts values while preserving comments and unrelated lines', async () => { + const file = await tempEnvFile([ + '# header', + 'EXISTING=old', + 'KEEP=1', + '', + ].join('\n')); + + const count = await writeEnvFile(file, [ + { key: 'EXISTING', value: 'new value' }, + { key: 'ADDED', value: 'fresh' }, + ]); + expect(count).toBe(2); + + const text = await readFile(file, 'utf8'); + expect(text).toBe([ + '# header', + 'EXISTING="new value"', + 'KEEP=1', + '', + 'ADDED=fresh', + '', + ].join('\n')); + }); + + it('creates new file if it does not exist', async () => { + const dir = await mkdtemp(join(tmpdir(), 'sh1pt-env-updater-new-')); + tempDirs.push(dir); + const file = join(dir, '.env.new'); + + await writeEnvFile(file, [{ key: 'FOO', value: 'bar' }]); + const text = await readFile(file, 'utf8'); + expect(text).toBe('FOO=bar\n'); + }); +}); + +// --------------------------------------------------------------------------- +// pullFrom / pushTo +// --------------------------------------------------------------------------- + +describe('pullFrom', () => { + it('delegates to the registered provider and applies key filters', async () => { + const mock = registerMockProvider('secrets-dotenvx', { + pull: vi.fn().mockResolvedValue([ + { key: 'API_KEY', value: 'abc' }, + { key: 'DB_URL', value: 'postgres://localhost' }, + { key: 'DEBUG', value: 'true' }, + ]), + }); + + const result = await pullFrom(ctx, { id: 'secrets-dotenvx' }, { + excludeKeys: ['DEBUG'], + }); + + expect(mock.pull).toHaveBeenCalledOnce(); + expect(result).toEqual([ + { key: 'API_KEY', value: 'abc' }, + { key: 'DB_URL', value: 'postgres://localhost' }, + ]); + }); + + it('applies includeKeys filter', async () => { + registerMockProvider('secrets-doppler', { + pull: vi.fn().mockResolvedValue([ + { key: 'API_KEY', value: 'abc' }, + { key: 'DB_URL', value: 'postgres://localhost' }, + ]), + }); + + const result = await pullFrom(ctx, { id: 'secrets-doppler' }, { + includeKeys: ['API_*'], + }); + + expect(result).toEqual([{ key: 'API_KEY', value: 'abc' }]); + }); + + it('throws when provider is not registered', async () => { + await expect(pullFrom(ctx, { id: 'secrets-nonexistent' })).rejects.toThrow( + 'Secret provider not registered: secrets-nonexistent', + ); + }); +}); + +describe('pushTo', () => { + it('delegates to the registered provider', async () => { + const mock = registerMockProvider('secrets-github', { + push: vi.fn().mockResolvedValue({ count: 2 }), + }); + + const result = await pushTo(ctx, [ + { key: 'TOKEN', value: 'abc' }, + { key: 'KEY', value: 'def' }, + ], { id: 'secrets-github' }); + + expect(mock.push).toHaveBeenCalledOnce(); + expect(result).toEqual({ provider: 'secrets-github', status: 'ok', count: 2 }); + }); + + it('returns error status when provider throws', async () => { + registerMockProvider('secrets-railway', { + push: vi.fn().mockRejectedValue(new Error('auth failed')), + }); + + const result = await pushTo(ctx, [ + { key: 'TOKEN', value: 'abc' }, + ], { id: 'secrets-railway' }); + + expect(result).toEqual({ + provider: 'secrets-railway', + status: 'error', + count: 0, + error: 'auth failed', + }); + }); +}); + +// --------------------------------------------------------------------------- +// syncEnv +// --------------------------------------------------------------------------- + +describe('syncEnv', () => { + it('pulls from source and pushes to all targets', async () => { + const secrets = [ + { key: 'API_KEY', value: 'abc' }, + { key: 'DB_URL', value: 'postgres://localhost' }, + ]; + + registerMockProvider('secrets-dotenvx', { + pull: vi.fn().mockResolvedValue(secrets), + }); + const dopplerMock = registerMockProvider('secrets-doppler', { + push: vi.fn().mockResolvedValue({ count: 2 }), + }); + const githubMock = registerMockProvider('secrets-github', { + push: vi.fn().mockResolvedValue({ count: 2 }), + }); + + const results = await syncEnv( + ctx, + { id: 'secrets-dotenvx' }, + [{ id: 'secrets-doppler' }, { id: 'secrets-github' }], + ); + + expect(results).toEqual([ + { provider: 'secrets-doppler', status: 'ok', count: 2 }, + { provider: 'secrets-github', status: 'ok', count: 2 }, + ]); + expect(dopplerMock.push).toHaveBeenCalledWith(ctx, secrets, expect.any(Object)); + expect(githubMock.push).toHaveBeenCalledWith(ctx, secrets, expect.any(Object)); + }); + + it('skips pushing back to the source provider', async () => { + registerMockProvider('secrets-dotenvx', { + pull: vi.fn().mockResolvedValue([{ key: 'A', value: '1' }]), + push: vi.fn(), + }); + + const results = await syncEnv( + ctx, + { id: 'secrets-dotenvx' }, + [{ id: 'secrets-dotenvx' }], + ); + + expect(results).toEqual([{ provider: 'secrets-dotenvx', status: 'skipped', count: 0 }]); + expect(mockProviders.get('secrets-dotenvx')!.push).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// diffEnv +// --------------------------------------------------------------------------- + +describe('diffEnv', () => { + it('identifies added, removed, changed, and unchanged keys', async () => { + registerMockProvider('secrets-dotenvx', { + pull: vi.fn().mockResolvedValue([ + { key: 'UNCHANGED', value: 'same' }, + { key: 'CHANGED', value: 'new-value' }, + { key: 'ADDED', value: 'fresh' }, + ]), + }); + registerMockProvider('secrets-doppler', { + pull: vi.fn().mockResolvedValue([ + { key: 'UNCHANGED', value: 'same' }, + { key: 'CHANGED', value: 'old-value' }, + { key: 'REMOVED', value: 'gone' }, + ]), + }); + + const entries = await diffEnv( + ctx, + { id: 'secrets-dotenvx' }, + { id: 'secrets-doppler' }, + ); + + expect(entries).toEqual([ + { key: 'ADDED', sourceValue: 'fresh', targetValue: undefined, status: 'added' }, + { key: 'CHANGED', sourceValue: 'new-value', targetValue: 'old-value', status: 'changed' }, + { key: 'REMOVED', sourceValue: undefined, targetValue: 'gone', status: 'removed' }, + { key: 'UNCHANGED', sourceValue: 'same', targetValue: 'same', status: 'unchanged' }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Adapter smoke conformance +// --------------------------------------------------------------------------- + +describe('env-updater adapter', () => { + it('connects when all providers are registered', async () => { + registerMockProvider('secrets-dotenvx'); + registerMockProvider('secrets-doppler'); + + await expect(adapter.connect(ctx, { + source: { id: 'secrets-dotenvx' }, + targets: [{ id: 'secrets-doppler' }], + })).resolves.toEqual({ accountId: 'secrets-dotenvx→1 targets' }); + }); + + it('throws when source provider is not registered', async () => { + await expect(adapter.connect(ctx, { + source: { id: 'secrets-nonexistent' }, + })).rejects.toThrow('Source provider not registered: secrets-nonexistent'); + }); + + it('throws when a target provider is not registered', async () => { + registerMockProvider('secrets-dotenvx'); + + await expect(adapter.connect(ctx, { + source: { id: 'secrets-dotenvx' }, + targets: [{ id: 'secrets-missing' }], + })).rejects.toThrow('Target provider not registered: secrets-missing'); + }); + + it('pushes to .env file when no targets are configured', async () => { + const file = await tempEnvFile(''); + + await expect(adapter.push(ctx, [ + { key: 'FOO', value: 'bar' }, + ], { envFile: file })).resolves.toEqual({ count: 1 }); + + const text = await readFile(file, 'utf8'); + expect(text).toContain('FOO=bar'); + }); + + it('fans out push to configured targets', async () => { + registerMockProvider('secrets-doppler', { + push: vi.fn().mockResolvedValue({ count: 1 }), + }); + registerMockProvider('secrets-github', { + push: vi.fn().mockResolvedValue({ count: 1 }), + }); + + await expect(adapter.push(ctx, [ + { key: 'TOKEN', value: 'secret' }, + ], { + targets: [{ id: 'secrets-doppler' }, { id: 'secrets-github' }], + })).resolves.toEqual({ count: 2 }); + }); +}); diff --git a/packages/secrets/env-updater/src/index.ts b/packages/secrets/env-updater/src/index.ts new file mode 100644 index 00000000..b34bfe52 --- /dev/null +++ b/packages/secrets/env-updater/src/index.ts @@ -0,0 +1,352 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { + defineSecretProvider, + getSecretProvider, + manualSetup, + type SecretProvider, + type SecretRef, +} from '@profullstack/sh1pt-core'; + +// Environment updater plugin — orchestrates secret synchronization across +// multiple providers (.env files, Doppler, Railway, GitHub Secrets). +// +// This module provides two things: +// 1. A SecretProvider adapter (`secrets-env-updater`) that wraps a +// configurable set of upstream providers and exposes pull/push as +// fan-out operations. +// 2. Standalone helpers (pullFrom, pushTo, syncEnv, diffEnv) that +// consumers can import directly to build custom sync workflows. +// +// The orchestrator never re-implements provider-specific logic — it +// delegates to the registered SecretProvider instances via the core +// registry. Adding a new secret backend is a matter of registering +// its provider; the env-updater picks it up automatically. + +export interface ProviderTarget { + /** Registered provider id, e.g. 'secrets-dotenvx', 'secrets-doppler'. */ + id: string; + /** Provider-specific config passed through to connect/pull/push. */ + config?: Record; +} + +export interface EnvUpdateConfig { + /** Default source provider for pull operations. */ + source?: ProviderTarget; + /** Default target providers for push operations. */ + targets?: ProviderTarget[]; + /** .env file path when using the built-in dotenvx provider. */ + envFile?: string; + /** Keys to exclude from synchronization (glob patterns). */ + excludeKeys?: string[]; + /** Only sync keys matching these patterns. */ + includeKeys?: string[]; +} + +export interface SyncResult { + provider: string; + status: 'ok' | 'error' | 'skipped'; + count: number; + error?: string; +} + +export interface DiffEntry { + key: string; + sourceValue?: string; + targetValue?: string; + status: 'added' | 'removed' | 'changed' | 'unchanged'; +} + +interface Ctx { + secret(k: string): string | undefined; + log(m: string): void; +} + +// --------------------------------------------------------------------------- +// Key filtering +// --------------------------------------------------------------------------- + +function matchesGlob(value: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`); + return regex.test(value); +} + +function filterKeys(secrets: SecretRef[], config: EnvUpdateConfig): SecretRef[] { + let filtered = secrets; + if (config.includeKeys?.length) { + filtered = filtered.filter((s) => config.includeKeys!.some((p) => matchesGlob(s.key, p))); + } + if (config.excludeKeys?.length) { + filtered = filtered.filter((s) => !config.excludeKeys!.some((p) => matchesGlob(s.key, p))); + } + return filtered; +} + +// --------------------------------------------------------------------------- +// Provider resolution +// --------------------------------------------------------------------------- + +function resolveProvider(target: ProviderTarget): SecretProvider { + const provider = getSecretProvider(target.id); + if (!provider) { + throw new Error( + `Secret provider not registered: ${target.id}. ` + + `Make sure the adapter package is installed and imported.`, + ); + } + return provider; +} + +// --------------------------------------------------------------------------- +// Public helpers +// --------------------------------------------------------------------------- + +/** Pull secrets from a single provider. */ +export async function pullFrom( + ctx: Ctx, + target: ProviderTarget, + config: EnvUpdateConfig = {}, +): Promise { + const provider = resolveProvider(target); + const merged = { ...config, ...target.config }; + ctx.log(`env-updater: pulling from ${target.id}`); + const secrets = await provider.pull(ctx, merged); + return filterKeys(secrets, config); +} + +/** Push secrets to a single provider. */ +export async function pushTo( + ctx: Ctx, + secrets: SecretRef[], + target: ProviderTarget, + config: EnvUpdateConfig = {}, +): Promise { + const provider = resolveProvider(target); + const merged = { ...config, ...target.config }; + const filtered = filterKeys(secrets, config); + + try { + ctx.log(`env-updater: pushing ${filtered.length} keys to ${target.id}`); + const result = await provider.push(ctx, filtered, merged); + return { provider: target.id, status: 'ok', count: result.count }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.log(`env-updater: error pushing to ${target.id}: ${message}`); + return { provider: target.id, status: 'error', count: 0, error: message }; + } +} + +/** Sync secrets from a source provider to one or more target providers. */ +export async function syncEnv( + ctx: Ctx, + source: ProviderTarget, + targets: ProviderTarget[], + config: EnvUpdateConfig = {}, +): Promise { + const secrets = await pullFrom(ctx, source, config); + ctx.log(`env-updater: pulled ${secrets.length} keys from ${source.id}`); + + const results: SyncResult[] = []; + for (const target of targets) { + if (target.id === source.id) { + results.push({ provider: target.id, status: 'skipped', count: 0 }); + continue; + } + results.push(await pushTo(ctx, secrets, target, config)); + } + return results; +} + +/** Diff secrets between two providers, returning per-key change status. */ +export async function diffEnv( + ctx: Ctx, + source: ProviderTarget, + target: ProviderTarget, + config: EnvUpdateConfig = {}, +): Promise { + const [sourceSecrets, targetSecrets] = await Promise.all([ + pullFrom(ctx, source, config), + pullFrom(ctx, target, config), + ]); + + const sourceMap = new Map(sourceSecrets.map((s) => [s.key, s.value ?? ''])); + const targetMap = new Map(targetSecrets.map((s) => [s.key, s.value ?? ''])); + const allKeys = new Set([...sourceMap.keys(), ...targetMap.keys()]); + + const entries: DiffEntry[] = []; + for (const key of [...allKeys].sort()) { + const sourceValue = sourceMap.get(key); + const targetValue = targetMap.get(key); + + let status: DiffEntry['status']; + if (sourceValue === undefined) status = 'removed'; + else if (targetValue === undefined) status = 'added'; + else if (sourceValue !== targetValue) status = 'changed'; + else status = 'unchanged'; + + entries.push({ key, sourceValue, targetValue, status }); + } + return entries; +} + +// --------------------------------------------------------------------------- +// .env file helpers (lightweight, no dotenvx dependency) +// --------------------------------------------------------------------------- + +const ENV_ENTRY = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/; + +function unquoteValue(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/\\([nrt"\\])/g, (_match, escaped: string) => { + if (escaped === 'n') return '\n'; + if (escaped === 'r') return '\r'; + if (escaped === 't') return '\t'; + return escaped; + }); + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed.slice(1, -1); + return trimmed; +} + +function formatValue(value: string): string { + if (value === '') return ''; + if (/^[A-Za-z0-9_./:@%+-]+$/.test(value)) return value; + return `"${value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/"/g, '\\"')}"`; +} + +/** Read a .env file and return parsed key-value pairs. */ +export async function readEnvFile(path: string): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []; + throw error; + } + + return text + .split(/\r?\n/) + .flatMap((line) => { + const match = ENV_ENTRY.exec(line); + if (!match) return []; + const [, , key, , value] = match; + if (key === undefined || value === undefined) return []; + return [{ key, value: unquoteValue(value), path }]; + }); +} + +/** Write secrets to a .env file, preserving existing entries and comments. */ +export async function writeEnvFile(path: string, secrets: SecretRef[]): Promise { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + text = ''; + } else { + throw error; + } + } + + const pending = new Map(secrets.map((s) => [s.key, s.value ?? ''])); + const lines = text === '' ? [''] : text.split(/\r?\n/); + + const nextLines = lines.map((line) => { + const match = ENV_ENTRY.exec(line); + if (!match) return line; + const [, prefix, key, spacing, value] = match; + if (prefix === undefined || key === undefined || spacing === undefined || value === undefined) return line; + if (!pending.has(key)) return line; + const newValue = pending.get(key)!; + pending.delete(key); + return `${prefix}${key}${spacing}${formatValue(newValue)}`; + }); + + const additions = [...pending].map(([key, value]) => `${key}=${formatValue(value)}`); + if (additions.length) { + if (nextLines.length === 1 && nextLines[0] === '') { + nextLines.splice(0, 1, ...additions, ''); + } else if (nextLines[nextLines.length - 1] === '') { + nextLines.push(...additions, ''); + } else { + nextLines.push(...additions); + } + } + + await writeFile(path, nextLines.join('\n'), 'utf8'); + return secrets.length; +} + +// --------------------------------------------------------------------------- +// SecretProvider adapter +// --------------------------------------------------------------------------- + +const envUpdaterAdapter = defineSecretProvider({ + id: 'secrets-env-updater', + label: 'Environment Updater', + cli: 'sh1pt', + + async connect(ctx, config) { + const sourceId = config.source?.id ?? 'secrets-dotenvx'; + const targetCount = config.targets?.length ?? 0; + ctx.log(`env-updater: source=${sourceId} · targets=${targetCount}`); + + // Verify source provider is registered + if (!getSecretProvider(sourceId)) { + throw new Error(`Source provider not registered: ${sourceId}`); + } + + // Verify target providers are registered + for (const target of config.targets ?? []) { + if (!getSecretProvider(target.id)) { + throw new Error(`Target provider not registered: ${target.id}`); + } + } + + return { accountId: `${sourceId}→${targetCount} targets` }; + }, + + async pull(ctx, config): Promise { + const source = config.source ?? { id: 'secrets-dotenvx', config: { envFile: config.envFile } }; + return pullFrom(ctx, source, config); + }, + + async push(ctx, secrets, config) { + const targets = config.targets ?? []; + if (targets.length === 0) { + ctx.log('env-updater: no targets configured, writing to default .env'); + const envFile = config.envFile ?? '.env'; + const count = await writeEnvFile(envFile, filterKeys(secrets, config)); + return { count }; + } + + let totalCount = 0; + for (const target of targets) { + const result = await pushTo(ctx, secrets, target, config); + if (result.status === 'ok') totalCount += result.count; + if (result.status === 'error') { + ctx.log(`env-updater: failed to push to ${target.id}: ${result.error}`); + } + } + return { count: totalCount }; + }, + + setup: manualSetup({ + label: 'Environment Updater', + vendorDocUrl: 'https://github.com/profullstack/sh1pt', + steps: [ + 'Configure source and target providers in sh1pt.config.ts or pass via CLI flags', + 'Supported providers: dotenvx (.env files), doppler, railway, github', + 'Install the adapter packages for each provider you want to sync with', + 'Run: sh1pt secrets env-updater setup to verify connections', + ], + }), +}); + +export default envUpdaterAdapter; diff --git a/packages/secrets/env-updater/tsconfig.json b/packages/secrets/env-updater/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/secrets/env-updater/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e26e0df4..92c5038d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1409,6 +1409,12 @@ importers: specifier: workspace:* version: link:../../core + packages/secrets/env-updater: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/secrets/github: dependencies: '@profullstack/sh1pt-core':