Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
261 changes: 261 additions & 0 deletions packages/cli/src/commands/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,264 @@ secretsCmd
}
}
});

// ---------------------------------------------------------------------------
// Environment updater — sync secrets across providers
// ---------------------------------------------------------------------------

// Provider slug → adapter package mapping for lazy install.
const PROVIDER_PACKAGES: Record<string, string> = {
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 <provider>', 'source provider slug (default: dotenvx)', 'dotenvx')
.option('--to <providers...>', 'target provider slugs (comma-separated or repeated)')
.option('--env-file <path>', 'path to .env file (for dotenvx provider)', '.env')
.option('--exclude <patterns...>', 'key patterns to exclude')
.option('--include <patterns...>', '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<SecretProvider<any>>(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<string, unknown> = {};
if (fromSlug === 'dotenvx') sourceConfig.envFile = opts.envFile;

const targetConfigs = toSlugs.map((slug) => {
const config: Record<string, unknown> = {};
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 <provider> 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 <provider>', 'source provider slug (default: dotenvx)', 'dotenvx')
.option('--target <provider>', 'target provider slug (required)')
.option('--env-file <path>', 'path to .env file (for dotenvx provider)', '.env')
.option('--exclude <patterns...>', 'key patterns to exclude')
.option('--include <patterns...>', 'key patterns to include')
.action(async (opts: EnvDiffOpts) => {
if (!opts.target) {
console.error(kleur.yellow('--target is required. Use --target <provider> (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<SecretProvider<any>>(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<string, unknown> = {};
if (sourceSlug === 'dotenvx') sourceConfig.envFile = opts.envFile;

const targetConfig: Record<string, unknown> = {};
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);
}
});
70 changes: 70 additions & 0 deletions packages/secrets/env-updater/README.md
Original file line number Diff line number Diff line change
@@ -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
```
37 changes: 37 additions & 0 deletions packages/secrets/env-updater/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Loading
Loading