alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
289 lines (249 loc) • 9.55 kB
text/typescript
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));
}
}