UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

289 lines (249 loc) 9.55 kB
import { $inject, $state, AlephaError, z } from "alepha"; import { GitHubSecretStore, NamingService, PlatformInspector, platformOptions, SecretFilterService, type SecretStoreProvider, } from "alepha/cli/platform-lib"; import { $command, EnvUtils } from "alepha/command"; import { $logger, ConsoleColorProvider } from "alepha/logger"; export class SecretsCommand { protected readonly log = $logger(); protected readonly options = $state(platformOptions); protected readonly inspector = $inject(PlatformInspector); protected readonly naming = $inject(NamingService); protected readonly envUtils = $inject(EnvUtils); protected readonly githubStore = $inject(GitHubSecretStore); protected readonly filter = $inject(SecretFilterService); protected readonly color = $inject(ConsoleColorProvider); protected readonly envFlags = z.object({ env: z .text({ aliases: ["e"], description: "Target environment", }) .optional(), }); // ----------------------------------------------------------------------- // alepha p secrets list // ----------------------------------------------------------------------- protected readonly list = $command({ name: "list", aliases: ["ls"], description: "List secret names stored in the remote store for an environment (values are never returned). Use --format=gha to emit a ready-to-paste GitHub Actions env: block.", flags: z.object({ env: z .text({ aliases: ["e"], description: "Target environment", }) .optional(), format: z .text({ aliases: ["f"], description: "Output format: table (default), gha", }) .optional(), }), handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envName = this.resolveEnvironmentName(config.project, env); const format = flags.format ?? "table"; const store = this.resolveStore(); await store.ensureAvailable(); const remoteSecrets = await store.list(envName); run.end(); if (format === "gha") { process.stdout.write( `# Generated by alepha — environment: ${envName}\n`, ); process.stdout.write("env:\n"); for (const secret of remoteSecrets) { const localName = this.filter.toLocalName(secret.name); process.stdout.write( ` ${localName}: \${{ secrets.${secret.name} }}\n`, ); } return; } // table format const c = this.color; if (remoteSecrets.length === 0) { process.stdout.write( `\nNo secrets in environment ${c.set("CYAN", envName)}.\n\n`, ); return; } process.stdout.write( `\n${c.set("GREY_LIGHT", "Environment:")} ${c.set("CYAN", envName)}\n\n`, ); const maxNameLen = Math.max( ...remoteSecrets.map((s) => s.name.length), 4, ); process.stdout.write( ` ${c.set("GREY_LIGHT", "NAME".padEnd(maxNameLen + 2))}${c.set("GREY_LIGHT", "UPDATED")}\n`, ); for (const secret of remoteSecrets) { const updated = secret.updatedAt ? new Date(secret.updatedAt).toLocaleString() : "-"; process.stdout.write( ` ${c.set("CYAN", secret.name.padEnd(maxNameLen + 2))}${c.set("GREY_DARK", updated)}\n`, ); } process.stdout.write("\n"); }, }); // ----------------------------------------------------------------------- // alepha p secrets diff // ----------------------------------------------------------------------- protected readonly diff = $command({ name: "diff", description: "Compare keys in local .env.<env> against the remote store. Shows + (only local, would be pushed), - (only remote, orphaned), = (in sync). Compares names only — values are not read from remote.", flags: this.envFlags, handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envName = this.resolveEnvironmentName(config.project, env); const envVars = await this.envUtils.parseEnv(root, [`.env.${env}`]); const filtered = this.filter.filter(envVars); const localKeys = new Set( Object.keys(filtered).map((k) => this.filter.toRemoteName(k)), ); const store = this.resolveStore(); await store.ensureAvailable(); const remoteSecrets = await store.list(envName); const remoteKeys = new Set(remoteSecrets.map((s) => s.name)); run.end(); const c = this.color; const allKeys = [...new Set([...localKeys, ...remoteKeys])].sort(); process.stdout.write( `\n${c.set("GREY_LIGHT", "Environment:")} ${c.set("CYAN", envName)}\n\n`, ); let hasChanges = false; for (const key of allKeys) { const inLocal = localKeys.has(key); const inRemote = remoteKeys.has(key); if (inLocal && !inRemote) { process.stdout.write(` ${c.set("GREEN", "+")} ${key}\n`); hasChanges = true; } else if (!inLocal && inRemote) { process.stdout.write(` ${c.set("RED", "-")} ${key}\n`); hasChanges = true; } else { process.stdout.write(` ${c.set("GREY_DARK", "=")} ${key}\n`); } } if (!hasChanges) { process.stdout.write( ` ${c.set("GREEN", "Local and remote are in sync.")}\n`, ); } process.stdout.write("\n"); }, }); // ----------------------------------------------------------------------- // alepha p secrets apply // ----------------------------------------------------------------------- protected readonly apply = $command({ name: "apply", description: "Push secrets from local .env.<env> to the remote store (one upsert per key). Use --dry-run to preview. Existing remote keys not present locally are left untouched — this command never deletes.", flags: z.object({ env: z .text({ aliases: ["e"], description: "Target environment", }) .optional(), "dry-run": z .boolean() .describe("Preview changes without pushing") .optional(), }), handler: async ({ flags, root, run }) => { const config = await this.inspector.resolveConfig(root); const env = flags.env ?? config.defaultEnv; const envName = this.resolveEnvironmentName(config.project, env); const dryRun = flags["dry-run"] ?? false; const envVars = await this.envUtils.parseEnv(root, [`.env.${env}`]); const filtered = this.filter.filter(envVars); const keys = Object.keys(filtered); if (keys.length === 0) { run.end(); process.stdout.write("\nNo secrets to push.\n\n"); return; } if (dryRun) { run.end(); const c = this.color; process.stdout.write( `\n${c.set("GREY_LIGHT", "Dry run — environment:")} ${c.set("CYAN", envName)}\n\n`, ); for (const key of keys) { process.stdout.write(` ${c.set("GREEN", "+")} ${key}\n`); } process.stdout.write( `\n ${keys.length} secret(s) would be pushed.\n\n`, ); return; } const store = this.resolveStore(); await store.ensureAvailable(); await store.ensureEnvironment(envName); for (const key of keys) { const remoteName = this.filter.toRemoteName(key); await run({ name: `push ${remoteName}`, handler: async () => { await store.set(envName, remoteName, filtered[key]); }, }); } run.end(); process.stdout.write( `\n${keys.length} secret(s) pushed to ${envName}.\n`, ); }, }); // ----------------------------------------------------------------------- // Parent command // ----------------------------------------------------------------------- public readonly secrets = $command({ name: "secrets", aliases: ["sec"], description: "Sync secrets from .env.<env> to an external CI store (currently GitHub Actions environments via the `gh` CLI). Resolves the target environment name from `secrets.environmentPattern` (default `{project}-{env}`) and filters keys via SecretFilterService. Runtime secrets for Cloudflare Workers / Vercel are pushed separately during `alepha platform up`.", children: [this.list, this.diff, this.apply], handler: async ({ help }) => { help(); }, }); // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- public resolveStore(): SecretStoreProvider { const storeName = this.options?.secrets?.store ?? "github"; switch (storeName) { case "github": return this.githubStore; default: throw new AlephaError( `Unknown secret store "${storeName}". Available: github`, ); } } protected resolveEnvironmentName(project: string, env: string): string { const pattern = this.options?.secrets?.environmentPattern ?? "{project}-{env}"; return pattern .replace("{project}", this.naming.slugify(project)) .replace("{env}", this.naming.slugify(env)); } }