diff --git a/.changeset/vast-dancers-teach.md b/.changeset/vast-dancers-teach.md new file mode 100644 index 0000000..cf9537c --- /dev/null +++ b/.changeset/vast-dancers-teach.md @@ -0,0 +1,13 @@ +--- +"e2sm": minor +--- + +add pull, set, delete, and init subcommands + +- add `pull` command to download secrets and generate .env files +- add `set` command to upload .env files to AWS Secrets Manager +- add `delete` command to schedule secret deletion +- add `init` command to create .e2smrc.jsonc config file +- add overwrite confirmation for `set` command +- support JSONC format in config files (comments and trailing commas) +- reorganize codebase into src/commands/ and src/lib/ directories diff --git a/README.md b/README.md index 4a0b6c6..1154917 100644 --- a/README.md +++ b/README.md @@ -5,41 +5,127 @@ ## Usage ```bash -npx e2sm -npx e2sm --dry-run -npx e2sm --help +npx e2sm init # Create .e2smrc.jsonc config file +npx e2sm set # Upload .env to Secrets Manager +npx e2sm get # Display secret value +npx e2sm pull # Download secret to .env file +npx e2sm delete # Delete secret from Secrets Manager +npx e2sm --help # Show help ``` -### Get Secrets +## Commands -Retrieve secrets from AWS Secrets Manager. +### init - Create configuration file + +Creates a `.e2smrc.jsonc` configuration file in the current directory with commented examples. + +```bash +npx e2sm init +npx e2sm init --force # Overwrite existing file +``` + +| Flag | Short | Description | +| --------- | ----- | ----------------------- | +| `--force` | `-f` | Overwrite existing file | + +### set - Upload .env to Secrets Manager + +```bash +npx e2sm set +npx e2sm set -i .env.local -n my-app/prod +npx e2sm set --dry-run # Preview JSON without uploading +npx e2sm set --force # Skip overwrite confirmation +npx e2sm set -t -a my-app -s prod # Template mode: creates "my-app/prod" +``` + +| Flag | Short | Description | +| --------------- | ----- | --------------------------------------- | +| `--input` | `-i` | Path to .env file | +| `--name` | `-n` | Secret name | +| `--dry-run` | `-d` | Preview JSON output | +| `--force` | `-f` | Skip overwrite confirmation | +| `--template` | `-t` | Use template mode ($application/$stage) | +| `--application` | `-a` | Application name (implies --template) | +| `--stage` | `-s` | Stage name (implies --template) | +| `--profile` | `-p` | AWS profile | +| `--region` | `-r` | AWS region | + +### get - Display secret value ```bash npx e2sm get -npx e2sm get -n my-secret-name +npx e2sm get -n my-app/prod npx e2sm get -p my-profile -r ap-northeast-1 ``` +| Flag | Short | Description | +| ----------- | ----- | ---------------------------------------- | +| `--name` | `-n` | Secret name (skip interactive selection) | +| `--profile` | `-p` | AWS profile | +| `--region` | `-r` | AWS region | + +### pull - Download secret to .env file + +```bash +npx e2sm pull +npx e2sm pull -n my-app/prod -o .env.local +npx e2sm pull --force # Overwrite existing file +``` + +| Flag | Short | Description | +| ----------- | ----- | -------------------------------------------- | +| `--name` | `-n` | Secret name (skip interactive selection) | +| `--output` | `-o` | Output file path (default: .env) | +| `--force` | `-f` | Overwrite existing file without confirmation | +| `--profile` | `-p` | AWS profile | +| `--region` | `-r` | AWS region | + +### delete - Delete secret from Secrets Manager + +```bash +npx e2sm delete +npx e2sm delete -n my-app/prod -d 7 +npx e2sm delete -n my-app/prod -d 7 --force +``` + +| Flag | Short | Description | +| ----------------- | ----- | ---------------------------------------- | +| `--name` | `-n` | Secret name (skip interactive selection) | +| `--recovery-days` | `-d` | Recovery window in days (7-30) | +| `--force` | `-f` | Skip confirmation prompt | +| `--profile` | `-p` | AWS profile | +| `--region` | `-r` | AWS region | + ## Configuration -You can create a `.e2smrc.json` file to set default options. +Create a `.e2smrc.jsonc` file to set default options. JSONC format supports comments. -```json +```jsonc { - "$schema": "https://unpkg.com/e2sm/schema.json", - "template": true, - "application": "my-app", - "stage": "dev", + "$schema": "https://unpkg.com/e2sm/assets/schema.json", + // Secret name (cannot use with template mode) + "name": "my-secret-name", + // Or use template mode: generate secret name as $application/$stage + // "template": true, + // "application": "my-app", + // "stage": "dev", + // AWS settings "profile": "my-profile", "region": "ap-northeast-1", - "input": ".env.local" + // File paths + "input": ".env.local", + "output": ".env", } ``` ### Config file locations -1. `./.e2smrc.json` (project) - takes precedence -2. `~/.e2smrc.json` (global) +Searched in order (first found is used): + +1. `./.e2smrc.jsonc` (project) +2. `./.e2smrc.json` (project, backward compatibility) +3. `~/.e2smrc.jsonc` (global) +4. `~/.e2smrc.json` (global, backward compatibility) Only the first found config is used (no merging). @@ -49,8 +135,8 @@ CLI flags always take precedence over config file values. ```bash # Uses profile from config -npx e2sm +npx e2sm set # Overrides config with "prod-profile" -npx e2sm -p prod-profile +npx e2sm set -p prod-profile ``` diff --git a/schema.json b/assets/schema.json similarity index 78% rename from schema.json rename to assets/schema.json index 9e313c4..aba0a2c 100644 --- a/schema.json +++ b/assets/schema.json @@ -29,6 +29,14 @@ "input": { "type": "string", "description": "Path to the .env file" + }, + "output": { + "type": "string", + "description": "Path to the output .env file for pull command" + }, + "name": { + "type": "string", + "description": "Secret name for AWS Secrets Manager" } }, "additionalProperties": false diff --git a/assets/template.jsonc b/assets/template.jsonc new file mode 100644 index 0000000..2a30088 --- /dev/null +++ b/assets/template.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "https://unpkg.com/e2sm/assets/schema.json", + // Secret name (cannot use with template mode) + // "name": "my-secret-name", + // Or use template mode: generate secret name as $application/$stage + // "template": true, + // "application": "my-app", + // "stage": "dev", + // AWS settings + // "profile": "my-profile", + // "region": "ap-northeast-1", + // File paths + // "input": ".env.local", + // "output": ".env" +} diff --git a/bun.lock b/bun.lock index 9724273..268db8f 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@gunshi/docs": "0.27.5", "@types/bun": "latest", "gunshi": "0.27.5", + "jsonc-parser": "^3.3.1", "kleur": "^4.1.5", "lefthook": "^2.0.15", "oxfmt": "^0.26.0", @@ -296,6 +297,8 @@ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], diff --git a/package.json b/package.json index 63024d3..e9a8551 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "e2sm": "dist/index.mjs" }, "files": [ - "dist", - "schema.json" + "assets", + "dist" ], "type": "module", "publishConfig": { @@ -41,6 +41,7 @@ "@gunshi/docs": "0.27.5", "@types/bun": "latest", "gunshi": "0.27.5", + "jsonc-parser": "^3.3.1", "kleur": "^4.1.5", "lefthook": "^2.0.15", "oxfmt": "^0.26.0", diff --git a/src/commands/delete.test.ts b/src/commands/delete.test.ts new file mode 100644 index 0000000..8966617 --- /dev/null +++ b/src/commands/delete.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { deleteCommand } from "./delete"; + +describe("deleteCommand", () => { + test("has correct name", () => { + expect(deleteCommand.name).toBe("delete"); + }); + + test("has description", () => { + expect(deleteCommand.description).toBeDefined(); + }); + + test("defines profile flag", () => { + expect(deleteCommand.args?.profile).toEqual({ + type: "string", + short: "p", + description: "AWS profile to use", + }); + }); + + test("defines region flag", () => { + expect(deleteCommand.args?.region).toEqual({ + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }); + }); + + test("defines name flag", () => { + expect(deleteCommand.args?.name).toEqual({ + type: "string", + short: "n", + description: "Secret name to delete (skip interactive selection)", + }); + }); + + test("defines recoveryDays flag", () => { + expect(deleteCommand.args?.recoveryDays).toEqual({ + type: "string", + short: "d", + toKebab: true, + description: "Recovery window in days (7-30)", + }); + }); + + test("defines force flag", () => { + expect(deleteCommand.args?.force).toEqual({ + type: "boolean", + short: "f", + description: "Skip confirmation prompt", + }); + }); +}); diff --git a/src/commands/delete.ts b/src/commands/delete.ts new file mode 100644 index 0000000..985b827 --- /dev/null +++ b/src/commands/delete.ts @@ -0,0 +1,178 @@ +import * as p from "@clack/prompts"; +import { define } from "gunshi"; +import pkg from "../../package.json"; +import { exec, fetchSecretList, loadConfig, mergeWithConfig, validateUnknownFlags } from "../lib"; + +function isCancel(value: unknown): value is symbol { + return p.isCancel(value); +} + +const MIN_RECOVERY_DAYS = 7; +const MAX_RECOVERY_DAYS = 30; +export const deleteCommand = define({ + name: "delete", + description: "Delete secret from AWS Secrets Manager", + args: { + profile: { + type: "string", + short: "p", + description: "AWS profile to use", + }, + region: { + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }, + name: { + type: "string", + short: "n", + description: "Secret name to delete (skip interactive selection)", + }, + recoveryDays: { + type: "string", + short: "d", + toKebab: true, + description: `Recovery window in days (${MIN_RECOVERY_DAYS}-${MAX_RECOVERY_DAYS})`, + }, + force: { + type: "boolean", + short: "f", + description: "Skip confirmation prompt", + }, + }, + run: async (ctx) => { + const config = await loadConfig(); + + const unknownFlagError = validateUnknownFlags(ctx.tokens, ctx.args); + if (unknownFlagError) { + console.error(unknownFlagError); + process.exit(1); + } + + const merged = mergeWithConfig(ctx.values, config); + const profile = merged.profile; + const region = merged.region; + const nameFlag = ctx.values.name ?? config.name; + const recoveryDaysFlag = ctx.values.recoveryDays; + const forceFlag = ctx.values.force; + + p.intro(`e2sm delete v${pkg.version} - Delete secret from AWS Secrets Manager`); + + const profileArgs = profile ? ["--profile", profile] : []; + const regionArgs = region ? ["--region", region] : []; + + // 1. Get secret name + let secretName: string; + + if (nameFlag) { + secretName = nameFlag; + } else { + const spinner = p.spinner(); + spinner.start("Fetching secret list..."); + + const result = await fetchSecretList({ profile, region }); + + if ("error" in result) { + spinner.stop("Failed to fetch secret list"); + p.cancel(`Error: ${result.error}`); + process.exit(1); + } + + spinner.stop("Secret list fetched"); + + const { secrets } = result; + + if (secrets.length === 0) { + p.cancel("No secrets found"); + process.exit(1); + } + + const selected = await p.select({ + message: "Select a secret to delete:", + options: secrets.map((s) => ({ + value: s.Name, + label: s.Name, + })), + }); + + if (isCancel(selected)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + secretName = selected; + } + + // 2. Get recovery window days + let recoveryDays: number; + + if (recoveryDaysFlag) { + const parsed = Number.parseInt(recoveryDaysFlag, 10); + if (Number.isNaN(parsed) || parsed < MIN_RECOVERY_DAYS || parsed > MAX_RECOVERY_DAYS) { + p.cancel( + `Invalid recovery days: ${recoveryDaysFlag}. Must be between ${MIN_RECOVERY_DAYS} and ${MAX_RECOVERY_DAYS}.`, + ); + process.exit(1); + } + recoveryDays = parsed; + } else { + const result = await p.text({ + message: "Enter recovery window in days:", + placeholder: `${MIN_RECOVERY_DAYS}-${MAX_RECOVERY_DAYS}`, + validate: (value) => { + const num = Number.parseInt(value, 10); + if (Number.isNaN(num) || num < MIN_RECOVERY_DAYS || num > MAX_RECOVERY_DAYS) { + return `Must be a number between ${MIN_RECOVERY_DAYS} and ${MAX_RECOVERY_DAYS}`; + } + }, + }); + + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + recoveryDays = Number.parseInt(result, 10); + } + + // 3. Confirm deletion + if (!forceFlag) { + const confirmed = await p.confirm({ + message: `Are you sure you want to delete '${secretName}'? (recoverable for ${recoveryDays} days)`, + initialValue: false, + }); + + if (isCancel(confirmed) || !confirmed) { + p.cancel("Operation cancelled"); + process.exit(0); + } + } + + // 4. Delete secret + const spinner = p.spinner(); + spinner.start(`Deleting '${secretName}' from AWS Secrets Manager...`); + + const deleteResult = await exec("aws", [ + "secretsmanager", + "delete-secret", + "--secret-id", + secretName, + "--recovery-window-in-days", + String(recoveryDays), + ...profileArgs, + ...regionArgs, + ]); + + if (deleteResult.exitCode !== 0) { + spinner.stop("Failed to delete secret"); + p.cancel(`Error: ${deleteResult.stderr}`); + process.exit(1); + } + + spinner.stop("Secret deleted successfully"); + + p.outro( + `Secret '${secretName}' has been scheduled for deletion (recoverable for ${recoveryDays} days)`, + ); + }, +}); diff --git a/src/get.test.ts b/src/commands/get.test.ts similarity index 100% rename from src/get.test.ts rename to src/commands/get.test.ts diff --git a/src/get.ts b/src/commands/get.ts similarity index 81% rename from src/get.ts rename to src/commands/get.ts index 6d510be..37be317 100644 --- a/src/get.ts +++ b/src/commands/get.ts @@ -1,21 +1,19 @@ import * as p from "@clack/prompts"; import { define } from "gunshi"; -import pkg from "../package.json"; -import { exec, formatJson, loadConfig, mergeWithConfig, validateUnknownFlags } from "./lib"; +import pkg from "../../package.json"; +import { + exec, + fetchSecretList, + formatJson, + loadConfig, + mergeWithConfig, + validateUnknownFlags, +} from "../lib"; function isCancel(value: unknown): value is symbol { return p.isCancel(value); } -interface SecretListEntry { - Name: string; - ARN: string; -} - -interface SecretListResponse { - SecretList: SecretListEntry[]; -} - export const getCommand = define({ name: "get", description: "Get secret value from AWS Secrets Manager", @@ -48,7 +46,7 @@ export const getCommand = define({ const merged = mergeWithConfig(ctx.values, config); const profile = merged.profile; const region = merged.region; - const nameFlag = ctx.values.name; + const nameFlag = ctx.values.name ?? config.name; p.intro(`e2sm get v${pkg.version} - Get secret from AWS Secrets Manager`); @@ -63,23 +61,17 @@ export const getCommand = define({ const spinner = p.spinner(); spinner.start("Fetching secret list..."); - const listResult = await exec("aws", [ - "secretsmanager", - "list-secrets", - ...profileArgs, - ...regionArgs, - ]); + const result = await fetchSecretList({ profile, region }); - if (listResult.exitCode !== 0) { + if ("error" in result) { spinner.stop("Failed to fetch secret list"); - p.cancel(`Error: ${listResult.stderr}`); + p.cancel(`Error: ${result.error}`); process.exit(1); } spinner.stop("Secret list fetched"); - const response: SecretListResponse = JSON.parse(listResult.stdout); - const secrets = response.SecretList; + const { secrets } = result; if (secrets.length === 0) { p.cancel("No secrets found"); diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts new file mode 100644 index 0000000..f4519d8 --- /dev/null +++ b/src/commands/init.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { parse } from "jsonc-parser"; +import template from "../../assets/template.jsonc" with { type: "text" }; +import { initCommand } from "./init"; + +describe("initCommand", () => { + test("has correct name", () => { + expect(initCommand.name).toBe("init"); + }); + + test("has description", () => { + expect(initCommand.description).toBeDefined(); + expect(initCommand.description).toContain(".e2smrc.jsonc"); + }); + + test("defines force flag", () => { + expect(initCommand.args?.force).toEqual({ + type: "boolean", + short: "f", + description: "Overwrite existing configuration file", + }); + }); +}); + +describe("template.jsonc", () => { + test("is valid JSONC", () => { + expect(() => parse(template)).not.toThrow(); + }); + + test("contains $schema field", () => { + const config = parse(template); + expect(config.$schema).toBe("https://unpkg.com/e2sm/assets/schema.json"); + }); + + test("contains commented examples for all config options", () => { + // Verify template has comments for key options + expect(template).toContain("// Secret name"); + expect(template).toContain("// Or use template mode"); + expect(template).toContain("// AWS settings"); + expect(template).toContain("// File paths"); + }); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..a02190f --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,61 @@ +import * as p from "@clack/prompts"; +import { define } from "gunshi"; +import { access } from "node:fs/promises"; +import template from "../../assets/template.jsonc" with { type: "text" }; +import pkg from "../../package.json"; +import { validateUnknownFlags } from "../lib"; + +const CONFIG_FILE = ".e2smrc.jsonc"; + +export const initCommand = define({ + name: "init", + description: "Initialize a new .e2smrc.jsonc configuration file", + args: { + force: { + type: "boolean", + short: "f", + description: "Overwrite existing configuration file", + }, + }, + run: async (ctx) => { + // Validate unknown flags first + const unknownFlagError = validateUnknownFlags(ctx.tokens, ctx.args); + if (unknownFlagError) { + console.error(unknownFlagError); + process.exit(1); + } + + const forceFlag = ctx.values.force; + + p.intro(`e2sm init v${pkg.version}`); + + // Check if config file already exists + const exists = await access(CONFIG_FILE) + .then(() => true) + .catch(() => false); + + if (exists && !forceFlag) { + const confirmed = await p.confirm({ + message: `'${CONFIG_FILE}' already exists. Overwrite?`, + initialValue: false, + }); + + if (p.isCancel(confirmed) || !confirmed) { + p.cancel("Operation cancelled"); + process.exit(0); + } + } + + // Write config file from template + try { + await Bun.write(CONFIG_FILE, template); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + p.cancel(`Failed to write '${CONFIG_FILE}': ${errorMessage}`); + process.exit(1); + } + + const message = exists ? `Overwritten '${CONFIG_FILE}'` : `Created '${CONFIG_FILE}'`; + p.outro(message); + }, +}); diff --git a/src/commands/pull.test.ts b/src/commands/pull.test.ts new file mode 100644 index 0000000..ce3acf8 --- /dev/null +++ b/src/commands/pull.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { pullCommand } from "./pull"; + +describe("pullCommand", () => { + test("has correct name", () => { + expect(pullCommand.name).toBe("pull"); + }); + + test("has description", () => { + expect(pullCommand.description).toBeDefined(); + expect(pullCommand.description).toContain("AWS Secrets Manager"); + expect(pullCommand.description).toContain(".env"); + }); + + test("defines profile flag", () => { + expect(pullCommand.args?.profile).toEqual({ + type: "string", + short: "p", + description: "AWS profile to use", + }); + }); + + test("defines region flag", () => { + expect(pullCommand.args?.region).toEqual({ + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }); + }); + + test("defines name flag", () => { + expect(pullCommand.args?.name).toEqual({ + type: "string", + short: "n", + description: "Secret name to retrieve (skip interactive selection)", + }); + }); + + test("defines output flag", () => { + expect(pullCommand.args?.output).toEqual({ + type: "string", + short: "o", + description: "Output .env file path (skip interactive prompt)", + }); + }); + + test("defines force flag", () => { + expect(pullCommand.args?.force).toEqual({ + type: "boolean", + short: "f", + description: "Overwrite existing file without confirmation", + }); + }); +}); diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 0000000..ecde3b2 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,214 @@ +import * as p from "@clack/prompts"; +import { define } from "gunshi"; +import { access, writeFile } from "node:fs/promises"; +import pkg from "../../package.json"; +import { + exec, + fetchSecretList, + generateEnvHeader, + jsonToEnv, + loadConfig, + mergeWithConfig, + validateUnknownFlags, +} from "../lib"; + +function isCancel(value: unknown): value is symbol { + return p.isCancel(value); +} + +export const pullCommand = define({ + name: "pull", + description: "Pull secret from AWS Secrets Manager and generate .env file", + args: { + profile: { + type: "string", + short: "p", + description: "AWS profile to use", + }, + region: { + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }, + name: { + type: "string", + short: "n", + description: "Secret name to retrieve (skip interactive selection)", + }, + output: { + type: "string", + short: "o", + description: "Output .env file path (skip interactive prompt)", + }, + force: { + type: "boolean", + short: "f", + description: "Overwrite existing file without confirmation", + }, + }, + run: async (ctx) => { + const config = await loadConfig(); + + const unknownFlagError = validateUnknownFlags(ctx.tokens, ctx.args); + if (unknownFlagError) { + console.error(unknownFlagError); + process.exit(1); + } + + const merged = mergeWithConfig(ctx.values, config); + const profile = merged.profile; + const region = merged.region; + const nameFlag = ctx.values.name ?? config.name; + const outputFlag = ctx.values.output ?? config.output; + const forceFlag = ctx.values.force; + + p.intro(`e2sm pull v${pkg.version} - Pull secret to .env file`); + + const profileArgs = profile ? ["--profile", profile] : []; + const regionArgs = region ? ["--region", region] : []; + + // 1. Get secret name + let secretName: string; + + if (nameFlag) { + secretName = nameFlag; + } else { + const spinner = p.spinner(); + spinner.start("Fetching secret list..."); + + const result = await fetchSecretList({ profile, region }); + + if ("error" in result) { + spinner.stop("Failed to fetch secret list"); + p.cancel(`Error: ${result.error}`); + process.exit(1); + } + + spinner.stop("Secret list fetched"); + + const { secrets } = result; + + if (secrets.length === 0) { + p.cancel("No secrets found"); + process.exit(1); + } + + const selected = await p.select({ + message: "Select a secret:", + options: secrets.map((s) => ({ + value: s.Name, + label: s.Name, + })), + }); + + if (isCancel(selected)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + secretName = selected; + } + + // 2. Get output file path + let outputPath: string; + + if (outputFlag) { + outputPath = outputFlag; + } else { + const result = await p.select({ + message: "Select output file:", + options: [ + { value: ".env", label: ".env" }, + { value: ".env.local", label: ".env.local" }, + { value: ".env.development", label: ".env.development" }, + { value: ".env.production", label: ".env.production" }, + { value: "__other__", label: "Other (enter custom path)" }, + ], + }); + + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + if (result === "__other__") { + const customPath = await p.text({ + message: "Enter the output file path:", + placeholder: ".env", + defaultValue: ".env", + }); + + if (isCancel(customPath)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + outputPath = customPath; + } else { + outputPath = result; + } + } + + // 3. Check if file exists and confirm overwrite + const fileExists = await access(outputPath) + .then(() => true) + .catch(() => false); + + if (fileExists && !forceFlag) { + const confirmed = await p.confirm({ + message: `File '${outputPath}' already exists. Overwrite?`, + initialValue: false, + }); + + if (isCancel(confirmed)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + if (!confirmed) { + p.cancel("Operation cancelled"); + process.exit(0); + } + } + + // 4. Fetch secret value + const spinner = p.spinner(); + spinner.start(`Fetching secret value for '${secretName}'...`); + + const getResult = await exec("aws", [ + "secretsmanager", + "get-secret-value", + "--secret-id", + secretName, + ...profileArgs, + ...regionArgs, + ]); + + if (getResult.exitCode !== 0) { + spinner.stop("Failed to fetch secret value"); + p.cancel(`Error: ${getResult.stderr}`); + process.exit(1); + } + + spinner.stop("Secret value fetched"); + + // 5. Parse and convert to .env format + const secretData = JSON.parse(getResult.stdout); + const secretString = secretData.SecretString; + + let envContent: string; + try { + const parsed = JSON.parse(secretString); + envContent = jsonToEnv(parsed); + } catch { + // If not JSON, write as-is + envContent = secretString; + } + + // 6. Write to file with header comment + const header = generateEnvHeader(secretName); + await writeFile(outputPath, header + "\n" + envContent + "\n", "utf-8"); + + p.outro(`Secret '${secretName}' has been written to '${outputPath}'`); + }, +}); diff --git a/src/commands/set.test.ts b/src/commands/set.test.ts new file mode 100644 index 0000000..9aa11e7 --- /dev/null +++ b/src/commands/set.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { setCommand } from "./set"; + +describe("setCommand", () => { + test("has correct name", () => { + expect(setCommand.name).toBe("set"); + }); + + test("has description", () => { + expect(setCommand.description).toBeDefined(); + expect(setCommand.description).toContain("AWS Secrets Manager"); + }); + + test("defines dryRun flag", () => { + expect(setCommand.args?.dryRun).toEqual({ + type: "boolean", + short: "d", + toKebab: true, + description: "Preview JSON output without uploading", + }); + }); + + test("defines profile flag", () => { + expect(setCommand.args?.profile).toEqual({ + type: "string", + short: "p", + description: "AWS profile to use", + }); + }); + + test("defines input flag", () => { + expect(setCommand.args?.input).toEqual({ + type: "string", + short: "i", + description: "Path to the .env file (skip interactive prompt)", + }); + }); + + test("defines name flag", () => { + expect(setCommand.args?.name).toEqual({ + type: "string", + short: "n", + description: "Secret name for AWS Secrets Manager (skip interactive prompt)", + }); + }); + + test("defines region flag", () => { + expect(setCommand.args?.region).toEqual({ + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }); + }); + + test("defines template flag", () => { + expect(setCommand.args?.template).toEqual({ + type: "boolean", + short: "t", + description: "Use template mode: generate secret name as $application/$stage", + }); + }); + + test("defines application flag", () => { + expect(setCommand.args?.application).toEqual({ + type: "string", + short: "a", + description: "Application name for template mode (implies --template)", + }); + }); + + test("defines stage flag", () => { + expect(setCommand.args?.stage).toEqual({ + type: "string", + short: "s", + description: "Stage name for template mode (implies --template)", + }); + }); + + test("defines force flag", () => { + expect(setCommand.args?.force).toEqual({ + type: "boolean", + short: "f", + description: "Skip confirmation prompt when overwriting existing secret", + }); + }); +}); diff --git a/src/commands/set.ts b/src/commands/set.ts new file mode 100644 index 0000000..1434c8b --- /dev/null +++ b/src/commands/set.ts @@ -0,0 +1,291 @@ +import * as p from "@clack/prompts"; +import { define } from "gunshi"; +import { access, readFile } from "node:fs/promises"; +import pkg from "../../package.json"; +import { + exec, + formatJson, + isTemplateMode, + loadConfig, + mergeWithConfig, + parseEnvContent, + validateNameTemplateConflict, + validateUnknownFlags, +} from "../lib"; + +function isCancel(value: unknown): value is symbol { + return p.isCancel(value); +} + +export const setCommand = define({ + name: "set", + description: "Upload .env file to AWS Secrets Manager", + args: { + dryRun: { + type: "boolean", + short: "d", + toKebab: true, + description: "Preview JSON output without uploading", + }, + profile: { + type: "string", + short: "p", + description: "AWS profile to use", + }, + input: { + type: "string", + short: "i", + description: "Path to the .env file (skip interactive prompt)", + }, + name: { + type: "string", + short: "n", + description: "Secret name for AWS Secrets Manager (skip interactive prompt)", + }, + region: { + type: "string", + short: "r", + description: "AWS region to use (e.g., ap-northeast-1)", + }, + template: { + type: "boolean", + short: "t", + description: "Use template mode: generate secret name as $application/$stage", + }, + application: { + type: "string", + short: "a", + description: "Application name for template mode (implies --template)", + }, + stage: { + type: "string", + short: "s", + description: "Stage name for template mode (implies --template)", + }, + force: { + type: "boolean", + short: "f", + description: "Skip confirmation prompt when overwriting existing secret", + }, + }, + run: async (ctx) => { + // Load config file + const config = await loadConfig(); + + // Validate unknown flags first + const unknownFlagError = validateUnknownFlags(ctx.tokens, ctx.args); + if (unknownFlagError) { + console.error(unknownFlagError); + process.exit(1); + } + + // Validate flag conflicts + const conflictError = validateNameTemplateConflict(ctx.values); + if (conflictError) { + console.error(conflictError); + process.exit(1); + } + + // Merge CLI flags with config (CLI takes precedence) + const merged = mergeWithConfig(ctx.values, config); + + const isDryRun = ctx.values.dryRun; // dryRun is CLI-only + const profile = merged.profile; + const inputFlag = merged.input; + const nameFlag = ctx.values.name; // name is CLI-only + const region = merged.region; + const forceFlag = ctx.values.force; + + p.intro(`e2sm set v${pkg.version} - env to AWS Secrets Manager`); + + // 1. Get env file path (from flag or interactively) + let envFilePath: string; + + if (inputFlag) { + envFilePath = inputFlag; + } else { + const result = await p.text({ + message: "Enter the path to your .env file:", + placeholder: ".env.local", + defaultValue: ".env.local", + }); + + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + envFilePath = result; + } + + // 2. Read and parse env file + const exists = await access(envFilePath) + .then(() => true) + .catch(() => false); + + if (!exists) { + p.cancel(`File not found: ${envFilePath}`); + process.exit(1); + } + + const content = await readFile(envFilePath, "utf-8"); + const envData = parseEnvContent(content); + + if (Object.keys(envData).length === 0) { + p.cancel("No valid environment variables found in the file"); + process.exit(1); + } + + const jsonString = JSON.stringify(envData); + + // 3. Dry-run mode: preview JSON + if (isDryRun) { + p.log.info(`Dry-run mode: Previewing '${envFilePath}'`); + console.log(formatJson(envData)); + p.outro("Dry-run complete"); + return; + } + + // 4. Get secret name + let secretName: string; + + const templateFlag = merged.template; + const applicationFlag = merged.application; + const stageFlag = merged.stage; + const useTemplateMode = isTemplateMode({ + template: templateFlag, + application: applicationFlag, + stage: stageFlag, + }); + + if (nameFlag) { + secretName = nameFlag; + } else if (useTemplateMode) { + // Template mode + let application: string; + let stage: string; + + if (applicationFlag) { + application = applicationFlag; + } else { + const result = await p.text({ + message: "Enter the application name:", + placeholder: "my-app", + defaultValue: "my-app", + }); + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + application = result; + } + + if (stageFlag) { + stage = stageFlag; + } else { + const result = await p.text({ + message: "Enter the stage name:", + placeholder: "dev", + defaultValue: "dev", + }); + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + stage = result; + } + + secretName = `${application}/${stage}`; + } else { + // Default mode + const result = await p.text({ + message: "Enter the secret name for AWS Secrets Manager:", + placeholder: "my-app/default", + defaultValue: "my-app/default", + }); + + if (isCancel(result)) { + p.cancel("Operation cancelled"); + process.exit(0); + } + + secretName = result; + } + + // 5. Upload to AWS Secrets Manager + const profileArgs = profile ? ["--profile", profile] : []; + const regionArgs = region ? ["--region", region] : []; + + // First, check if the secret already exists + const describeResult = await exec("aws", [ + "secretsmanager", + "describe-secret", + "--secret-id", + secretName, + ...profileArgs, + ...regionArgs, + ]); + + const secretExists = describeResult.exitCode === 0; + + // Confirm overwrite if secret exists + if (secretExists && !forceFlag) { + const confirmed = await p.confirm({ + message: `Secret '${secretName}' already exists. Overwrite?`, + initialValue: false, + }); + + if (isCancel(confirmed) || !confirmed) { + p.cancel("Operation cancelled"); + process.exit(0); + } + } + + const spinner = p.spinner(); + spinner.start(`Uploading '${envFilePath}' to '${secretName}' in AWS Secrets Manager...`); + + if (secretExists) { + // Secret exists, update it + const updateResult = await exec("aws", [ + "secretsmanager", + "put-secret-value", + "--secret-id", + secretName, + "--secret-string", + jsonString, + ...profileArgs, + ...regionArgs, + ]); + + if (updateResult.exitCode !== 0) { + spinner.stop("Failed to update secret"); + p.cancel(`Error: ${updateResult.stderr}`); + process.exit(1); + } + + spinner.stop("Secret updated successfully"); + } else { + // Secret doesn't exist, create it + const createResult = await exec("aws", [ + "secretsmanager", + "create-secret", + "--name", + secretName, + "--secret-string", + jsonString, + ...profileArgs, + ...regionArgs, + ]); + + if (createResult.exitCode !== 0) { + spinner.stop("Failed to create secret"); + p.cancel(`Error: ${createResult.stderr}`); + process.exit(1); + } + + spinner.stop("Secret created successfully"); + } + + p.outro(`Secret '${secretName}' has been saved to AWS Secrets Manager`); + }, +}); diff --git a/src/index.test.ts b/src/index.test.ts index 3f18173..7346b4e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { parse } from "jsonc-parser"; import { unlink } from "node:fs/promises"; import pkg from "../package.json"; @@ -26,14 +27,13 @@ describe("CLI", () => { expect(exitCode).toBe(0); expect(stdout).toContain("USAGE:"); - expect(stdout).toContain("--dry-run"); - expect(stdout).toContain("--profile"); - expect(stdout).toContain("--input"); - expect(stdout).toContain("--name"); - expect(stdout).toContain("--region"); - expect(stdout).toContain("--template"); - expect(stdout).toContain("--application"); - expect(stdout).toContain("--stage"); + expect(stdout).toContain("e2sm"); + expect(stdout).toContain("init"); + expect(stdout).toContain("get"); + expect(stdout).toContain("pull"); + expect(stdout).toContain("set"); + expect(stdout).toContain("delete"); + expect(stdout).not.toContain("undefined"); }); test("shows help with -h flag", async () => { @@ -41,6 +41,21 @@ describe("CLI", () => { expect(exitCode).toBe(0); expect(stdout).toContain("USAGE:"); + expect(stdout).not.toContain("undefined"); + }); + }); + + describe("no subcommand", () => { + test("shows error message when no subcommand provided", async () => { + const { stderr, exitCode } = await runCli([]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Please specify a subcommand"); + expect(stderr).toContain("e2sm init"); + expect(stderr).toContain("e2sm set"); + expect(stderr).toContain("e2sm get"); + expect(stderr).toContain("e2sm pull"); + expect(stderr).toContain("e2sm delete"); }); }); @@ -60,197 +75,407 @@ describe("CLI", () => { }); }); - describe("unknown flags", () => { - test("exits with error for unknown flag", async () => { - const { stderr, exitCode } = await runCli(["--unknown-flag"]); + describe("set subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["set", "--help"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown option: --unknown-flag"); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--dry-run"); + expect(stdout).toContain("--profile"); + expect(stdout).toContain("--input"); + expect(stdout).toContain("--name"); + expect(stdout).toContain("--region"); + expect(stdout).toContain("--template"); + expect(stdout).toContain("--application"); + expect(stdout).toContain("--stage"); + expect(stdout).toContain("--force"); + }); + + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["set", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); }); - test("exits with error for unknown short flag", async () => { - const { stderr, exitCode } = await runCli(["-x"]); + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["set", "--unknown-flag"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown option: --x"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --unknown-flag"); + }); + + test("exits with error for unknown short flag", async () => { + const { stderr, exitCode } = await runCli(["set", "-x"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); }); - }); - describe("--dry-run", () => { - const testEnvPath = "test-fixtures/test.env"; + describe("--dry-run", () => { + const testEnvPath = "test-fixtures/test.env"; - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, - "FOO=bar\nBAZ=qux\n", - ); + beforeAll(async () => { + await Bun.write( + `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, + "FOO=bar\nBAZ=qux\n", + ); + }); + + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); + await unlink(`${import.meta.dir.replace("/src", "")}/test-fixtures`).catch(() => {}); + }); + + test("previews JSON output without uploading", async () => { + const { stdout, exitCode } = await runCli(["set", "--dry-run", "--input", testEnvPath]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + expect(stdout).toContain(testEnvPath); + expect(stdout).toContain("FOO"); + expect(stdout).toContain("bar"); + expect(stdout).toContain("BAZ"); + expect(stdout).toContain("qux"); + }); + + test("works with -d flag", async () => { + const { stdout, exitCode } = await runCli(["set", "-d", "-i", testEnvPath]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + }); }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); - await unlink(`${import.meta.dir.replace("/src", "")}/test-fixtures`).catch(() => {}); + describe("file not found", () => { + test("exits with error when file does not exist", async () => { + const { stdout, exitCode } = await runCli([ + "set", + "--dry-run", + "--input", + "nonexistent.env", + ]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("File not found"); + }); }); - test("previews JSON output without uploading", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", testEnvPath]); + describe("empty env file", () => { + const emptyEnvPath = "test-fixtures/empty.env"; - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); - expect(stdout).toContain("FOO"); - expect(stdout).toContain("bar"); - expect(stdout).toContain("BAZ"); - expect(stdout).toContain("qux"); + beforeAll(async () => { + await Bun.write( + `${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`, + "# only comments\n", + ); + }); + + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`).catch(() => {}); + }); + + test("exits with error when no valid variables found", async () => { + const { stdout, exitCode } = await runCli(["set", "--dry-run", "--input", emptyEnvPath]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("No valid environment variables found"); + }); }); - test("works with -d flag", async () => { - const { stdout, exitCode } = await runCli(["-d", "-i", testEnvPath]); + describe("flag conflicts", () => { + test("--name with --template exits with error", async () => { + const { stderr, exitCode } = await runCli(["set", "--name", "x", "--template"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--template"); + }); + + test("--name with --application exits with error", async () => { + const { stderr, exitCode } = await runCli(["set", "--name", "x", "-a", "app"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--application"); + }); + + test("--name with --stage exits with error", async () => { + const { stderr, exitCode } = await runCli(["set", "--name", "x", "-s", "prod"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--stage"); + }); + + test("--name with multiple template flags exits with error", async () => { + const { stderr, exitCode } = await runCli([ + "set", + "--name", + "x", + "-t", + "-a", + "app", + "-s", + "prod", + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--template"); + expect(stderr).toContain("--application"); + expect(stderr).toContain("--stage"); + }); }); - }); - describe("file not found", () => { - test("exits with error when file does not exist", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", "nonexistent.env"]); + describe("template mode", () => { + const testEnvPath = "test-fixtures/test.env"; - expect(exitCode).toBe(1); - expect(stdout).toContain("File not found"); + beforeAll(async () => { + await Bun.write( + `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, + "FOO=bar\nBAZ=qux\n", + ); + }); + + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); + }); + + test("-a and -s without -t works (implicit template mode)", async () => { + const { stdout, exitCode } = await runCli([ + "set", + "-d", + "-i", + testEnvPath, + "-a", + "my-app", + "-s", + "prod", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + }); + + test("-t -a -s generates correct secret name", async () => { + const { stdout, exitCode } = await runCli([ + "set", + "-d", + "-i", + testEnvPath, + "-t", + "-a", + "my-app", + "-s", + "prod", + ]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + }); + + test("-a only (implicit template mode)", async () => { + const { exitCode } = await runCli(["set", "-d", "-i", testEnvPath, "-a", "my-app"]); + + // Will wait for stage input interactively, but dry-run exits before secret name is needed + expect(exitCode).toBe(0); + }); + + test("-s only (implicit template mode)", async () => { + const { exitCode } = await runCli(["set", "-d", "-i", testEnvPath, "-s", "prod"]); + + // Will wait for application input interactively, but dry-run exits before secret name is needed + expect(exitCode).toBe(0); + }); }); }); - describe("empty env file", () => { - const emptyEnvPath = "test-fixtures/empty.env"; + describe("get subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["get", "--help"]); - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`, - "# only comments\n", - ); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--profile"); + expect(stdout).toContain("--region"); + expect(stdout).toContain("--name"); + }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`).catch(() => {}); + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["get", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); }); - test("exits with error when no valid variables found", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", emptyEnvPath]); + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["get", "--unknown-flag"]); - expect(exitCode).toBe(1); - expect(stdout).toContain("No valid environment variables found"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --unknown-flag"); + }); + + test("exits with error for unknown short flag", async () => { + const { stderr, exitCode } = await runCli(["get", "-x"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); }); }); - describe("flag conflicts", () => { - test("--name with --template exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "--template"]); + describe("pull subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["pull", "--help"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--template"); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--profile"); + expect(stdout).toContain("--region"); + expect(stdout).toContain("--name"); + expect(stdout).toContain("--output"); + expect(stdout).toContain("--force"); + }); - test("--name with --application exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-a", "app"]); + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["pull", "-h"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--application"); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); }); - test("--name with --stage exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-s", "prod"]); + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["pull", "--unknown-flag"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--stage"); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --unknown-flag"); + }); - test("--name with multiple template flags exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-t", "-a", "app", "-s", "prod"]); + test("exits with error for unknown short flag", async () => { + const { stderr, exitCode } = await runCli(["pull", "-x"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--template"); - expect(stderr).toContain("--application"); - expect(stderr).toContain("--stage"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); }); }); - describe("template mode", () => { - const testEnvPath = "test-fixtures/test.env"; + describe("delete subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["delete", "--help"]); - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, - "FOO=bar\nBAZ=qux\n", - ); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--profile"); + expect(stdout).toContain("--region"); + expect(stdout).toContain("--name"); + expect(stdout).toContain("--recovery-days"); + expect(stdout).toContain("--force"); + }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["delete", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); }); - test("-a and -s without -t works (implicit template mode)", async () => { - const { stdout, exitCode } = await runCli([ - "-d", - "-i", - testEnvPath, - "-a", - "my-app", - "-s", - "prod", - ]); + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["delete", "--unknown-flag"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --unknown-flag"); + }); - test("-t -a -s generates correct secret name", async () => { - const { stdout, exitCode } = await runCli([ - "-d", - "-i", - testEnvPath, - "-t", - "-a", - "my-app", - "-s", - "prod", - ]); + test("exits with error for unknown short flag", async () => { + const { stderr, exitCode } = await runCli(["delete", "-x"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); }); - test("-a only (implicit template mode)", async () => { - const { exitCode } = await runCli(["-d", "-i", testEnvPath, "-a", "my-app"]); + describe("invalid recovery days", () => { + test("exits with error when recovery days is too low", async () => { + const { stdout, exitCode } = await runCli([ + "delete", + "--name", + "test-secret", + "--recovery-days", + "5", + "--force", + ]); - // Will wait for stage input interactively, but dry-run exits before secret name is needed - expect(exitCode).toBe(0); - }); + expect(exitCode).toBe(1); + expect(stdout).toContain("Invalid recovery days"); + expect(stdout).toContain("Must be between 7 and 30"); + }); - test("-s only (implicit template mode)", async () => { - const { exitCode } = await runCli(["-d", "-i", testEnvPath, "-s", "prod"]); + test("exits with error when recovery days is too high", async () => { + const { stdout, exitCode } = await runCli([ + "delete", + "--name", + "test-secret", + "--recovery-days", + "31", + "--force", + ]); - // Will wait for application input interactively, but dry-run exits before secret name is needed - expect(exitCode).toBe(0); + expect(exitCode).toBe(1); + expect(stdout).toContain("Invalid recovery days"); + expect(stdout).toContain("Must be between 7 and 30"); + }); + + test("exits with error when recovery days is not a number", async () => { + const { stdout, exitCode } = await runCli([ + "delete", + "--name", + "test-secret", + "--recovery-days", + "abc", + "--force", + ]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("Invalid recovery days"); + }); }); }); - describe("get subcommand", () => { + describe("init subcommand", () => { + const configPath = ".e2smrc.jsonc"; + const projectRoot = import.meta.dir.replace("/src", ""); + + afterAll(async () => { + await unlink(`${projectRoot}/${configPath}`).catch(() => {}); + }); + describe("--help", () => { test("shows help message", async () => { - const { stdout, exitCode } = await runCli(["get", "--help"]); + const { stdout, exitCode } = await runCli(["init", "--help"]); expect(exitCode).toBe(0); expect(stdout).toContain("USAGE:"); - expect(stdout).toContain("--profile"); - expect(stdout).toContain("--region"); - expect(stdout).toContain("--name"); + expect(stdout).toContain("--force"); }); test("shows help with -h flag", async () => { - const { stdout, exitCode } = await runCli(["get", "-h"]); + const { stdout, exitCode } = await runCli(["init", "-h"]); expect(exitCode).toBe(0); expect(stdout).toContain("USAGE:"); @@ -259,24 +484,61 @@ describe("CLI", () => { describe("unknown flags", () => { test("exits with error for unknown flag", async () => { - const { stderr, exitCode } = await runCli(["get", "--unknown-flag"]); + const { stderr, exitCode } = await runCli(["init", "--unknown-flag"]); expect(exitCode).toBe(1); expect(stderr).toContain("Unknown option: --unknown-flag"); }); test("exits with error for unknown short flag", async () => { - const { stderr, exitCode } = await runCli(["get", "-x"]); + const { stderr, exitCode } = await runCli(["init", "-x"]); expect(exitCode).toBe(1); expect(stderr).toContain("Unknown option: --x"); }); }); + + describe("create config file", () => { + beforeAll(async () => { + await unlink(`${projectRoot}/${configPath}`).catch(() => {}); + }); + + test("creates config file with --force", async () => { + const { stdout, exitCode } = await runCli(["init", "--force"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Created"); + expect(stdout).toContain(configPath); + + const content = await Bun.file(`${projectRoot}/${configPath}`).text(); + const config = parse(content); + expect(config.$schema).toBe("https://unpkg.com/e2sm/assets/schema.json"); + }); + + test("creates config file with -f flag", async () => { + await unlink(`${projectRoot}/${configPath}`).catch(() => {}); + const { stdout, exitCode } = await runCli(["init", "-f"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Created"); + }); + + test("shows overwritten message when file exists", async () => { + // First create the file + await Bun.write(`${projectRoot}/${configPath}`, "{}"); + + const { stdout, exitCode } = await runCli(["init", "--force"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Overwritten"); + expect(stdout).toContain(configPath); + }); + }); }); describe("config file", () => { const testEnvPath = "test-fixtures/test.env"; - const configPath = ".e2smrc.json"; + const configPath = ".e2smrc.jsonc"; const projectRoot = import.meta.dir.replace("/src", ""); beforeAll(async () => { @@ -290,7 +552,7 @@ describe("CLI", () => { test("loads input flag from config", async () => { await Bun.write(`${projectRoot}/${configPath}`, JSON.stringify({ input: testEnvPath })); - const { stdout, exitCode } = await runCli(["-d"]); + const { stdout, exitCode } = await runCli(["set", "-d"]); expect(exitCode).toBe(0); expect(stdout).toContain("Dry-run mode"); @@ -299,7 +561,7 @@ describe("CLI", () => { test("CLI flag overrides config", async () => { await Bun.write(`${projectRoot}/${configPath}`, JSON.stringify({ input: "nonexistent.env" })); - const { stdout, exitCode } = await runCli(["-d", "-i", testEnvPath]); + const { stdout, exitCode } = await runCli(["set", "-d", "-i", testEnvPath]); expect(exitCode).toBe(0); expect(stdout).toContain("Dry-run mode"); @@ -315,7 +577,7 @@ describe("CLI", () => { input: testEnvPath, }), ); - const { stdout, exitCode } = await runCli(["-d"]); + const { stdout, exitCode } = await runCli(["set", "-d"]); expect(exitCode).toBe(0); expect(stdout).toContain("Dry-run mode"); @@ -323,7 +585,7 @@ describe("CLI", () => { test("ignores invalid config file", async () => { await Bun.write(`${projectRoot}/${configPath}`, "invalid json"); - const { stdout, exitCode } = await runCli(["-d", "-i", testEnvPath]); + const { stdout, exitCode } = await runCli(["set", "-d", "-i", testEnvPath]); expect(exitCode).toBe(0); expect(stdout).toContain("Dry-run mode"); diff --git a/src/index.ts b/src/index.ts index 92d33d5..208f3ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,279 +1,37 @@ -import * as p from "@clack/prompts"; import { cli, define } from "gunshi"; -import { access, readFile } from "node:fs/promises"; import pkg from "../package.json"; -import { getCommand } from "./get"; -import { - exec, - formatJson, - isTemplateMode, - loadConfig, - mergeWithConfig, - parseEnvContent, - validateNameTemplateConflict, - validateUnknownFlags, -} from "./lib"; - -function isCancel(value: unknown): value is symbol { - return p.isCancel(value); -} +import { deleteCommand } from "./commands/delete"; +import { getCommand } from "./commands/get"; +import { initCommand } from "./commands/init"; +import { pullCommand } from "./commands/pull"; +import { setCommand } from "./commands/set"; const command = define({ name: "e2sm", - description: "Upload .env file to AWS Secrets Manager", - args: { - dryRun: { - type: "boolean", - short: "d", - toKebab: true, - description: "Preview JSON output without uploading", - }, - profile: { - type: "string", - short: "p", - description: "AWS profile to use", - }, - input: { - type: "string", - short: "i", - description: "Path to the .env file (skip interactive prompt)", - }, - name: { - type: "string", - short: "n", - description: "Secret name for AWS Secrets Manager (skip interactive prompt)", - }, - region: { - type: "string", - short: "r", - description: "AWS region to use (e.g., ap-northeast-1)", - }, - template: { - type: "boolean", - short: "t", - description: "Use template mode: generate secret name as $application/$stage", - }, - application: { - type: "string", - short: "a", - description: "Application name for template mode (implies --template)", - }, - stage: { - type: "string", - short: "s", - description: "Stage name for template mode (implies --template)", - }, - }, - run: async (ctx) => { - // Load config file - const config = await loadConfig(); - - // Validate unknown flags first - const unknownFlagError = validateUnknownFlags(ctx.tokens, ctx.args); - if (unknownFlagError) { - console.error(unknownFlagError); - process.exit(1); - } - - // Validate flag conflicts - const conflictError = validateNameTemplateConflict(ctx.values); - if (conflictError) { - console.error(conflictError); - process.exit(1); - } - - // Merge CLI flags with config (CLI takes precedence) - const merged = mergeWithConfig(ctx.values, config); - - const isDryRun = ctx.values.dryRun; // dryRun is CLI-only - const profile = merged.profile; - const inputFlag = merged.input; - const nameFlag = ctx.values.name; // name is CLI-only - const region = merged.region; - - p.intro(`e2sm v${pkg.version} - env to AWS Secrets Manager`); - - // 1. Get env file path (from flag or interactively) - let envFilePath: string; - - if (inputFlag) { - envFilePath = inputFlag; - } else { - const result = await p.text({ - message: "Enter the path to your .env file:", - placeholder: ".env.local", - defaultValue: ".env.local", - }); - - if (isCancel(result)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - envFilePath = result; - } - - // 2. Read and parse env file - const exists = await access(envFilePath) - .then(() => true) - .catch(() => false); - - if (!exists) { - p.cancel(`File not found: ${envFilePath}`); - process.exit(1); - } - - const content = await readFile(envFilePath, "utf-8"); - const envData = parseEnvContent(content); - - if (Object.keys(envData).length === 0) { - p.cancel("No valid environment variables found in the file"); - process.exit(1); - } - - const jsonString = JSON.stringify(envData); - - // 3. Dry-run mode: preview JSON - if (isDryRun) { - p.log.info("Dry-run mode: Previewing JSON output"); - console.log(formatJson(envData)); - p.outro("Dry-run complete"); - return; - } - - // 4. Get secret name - let secretName: string; - - const templateFlag = merged.template; - const applicationFlag = merged.application; - const stageFlag = merged.stage; - const useTemplateMode = isTemplateMode({ - template: templateFlag, - application: applicationFlag, - stage: stageFlag, - }); - - if (nameFlag) { - secretName = nameFlag; - } else if (useTemplateMode) { - // Template mode - let application: string; - let stage: string; - - if (applicationFlag) { - application = applicationFlag; - } else { - const result = await p.text({ - message: "Enter the application name:", - placeholder: "my-app", - defaultValue: "my-app", - }); - if (isCancel(result)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - application = result; - } - - if (stageFlag) { - stage = stageFlag; - } else { - const result = await p.text({ - message: "Enter the stage name:", - placeholder: "dev", - defaultValue: "dev", - }); - if (isCancel(result)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - stage = result; - } - - secretName = `${application}/${stage}`; - } else { - // Default mode - const result = await p.text({ - message: "Enter the secret name for AWS Secrets Manager:", - placeholder: "my-app/default", - defaultValue: "my-app/default", - }); - - if (isCancel(result)) { - p.cancel("Operation cancelled"); - process.exit(0); - } - - secretName = result; - } - - // 5. Upload to AWS Secrets Manager - const spinner = p.spinner(); - spinner.start("Uploading to AWS Secrets Manager..."); - - const profileArgs = profile ? ["--profile", profile] : []; - const regionArgs = region ? ["--region", region] : []; - - // First, try to check if the secret already exists - const describeResult = await exec("aws", [ - "secretsmanager", - "describe-secret", - "--secret-id", - secretName, - ...profileArgs, - ...regionArgs, - ]); - - if (describeResult.exitCode === 0) { - // Secret exists, update it - const updateResult = await exec("aws", [ - "secretsmanager", - "put-secret-value", - "--secret-id", - secretName, - "--secret-string", - jsonString, - ...profileArgs, - ...regionArgs, - ]); - - if (updateResult.exitCode !== 0) { - spinner.stop("Failed to update secret"); - p.cancel(`Error: ${updateResult.stderr}`); - process.exit(1); - } - - spinner.stop("Secret updated successfully"); - } else { - // Secret doesn't exist, create it - const createResult = await exec("aws", [ - "secretsmanager", - "create-secret", - "--name", - secretName, - "--secret-string", - jsonString, - ...profileArgs, - ...regionArgs, - ]); - - if (createResult.exitCode !== 0) { - spinner.stop("Failed to create secret"); - p.cancel(`Error: ${createResult.stderr}`); - process.exit(1); - } - - spinner.stop("Secret created successfully"); - } - - p.outro(`Secret '${secretName}' has been saved to AWS Secrets Manager`); + description: "Manage environment variables with AWS Secrets Manager", + run: () => { + console.error("Error: Please specify a subcommand (init, set, get, pull, or delete)"); + console.error(""); + console.error("Usage:"); + console.error(" e2sm init - Initialize .e2smrc.json configuration file"); + console.error(" e2sm set - Upload .env file to AWS Secrets Manager"); + console.error(" e2sm get - Display secret from AWS Secrets Manager"); + console.error(" e2sm pull - Pull secret and generate .env file"); + console.error(" e2sm delete - Delete secret from AWS Secrets Manager"); + console.error(""); + console.error("Run 'e2sm --help' for more information on a command."); + process.exit(1); }, }); await cli(process.argv.slice(2), command, { + name: "e2sm", version: pkg.version, - fallbackToEntry: true, subCommands: { + init: initCommand, + set: setCommand, get: getCommand, + pull: pullCommand, + delete: deleteCommand, }, }); diff --git a/src/lib.ts b/src/lib.ts deleted file mode 100644 index eabf791..0000000 --- a/src/lib.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { ArgSchema, ArgToken } from "gunshi"; -import { cyan, dim, gray, green } from "kleur/colors"; -import { spawn } from "node:child_process"; - -export function toKebabCase(str: string): string { - return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); -} - -/** - * Validates that all provided flags are known options. - * @returns Error message if an unknown flag is found, null otherwise - */ -export function validateUnknownFlags( - tokens: ArgToken[], - args: Record, -): string | null { - // Build set of known option names - const knownOptions = new Set(); - - // Built-in options - knownOptions.add("help"); - knownOptions.add("h"); - knownOptions.add("version"); - knownOptions.add("v"); - - // User-defined options - for (const [key, schema] of Object.entries(args)) { - knownOptions.add(key); - knownOptions.add(toKebabCase(key)); - - if (schema.short) { - knownOptions.add(schema.short); - } - } - - // Check for unknown options - for (const token of tokens) { - if (token.kind === "option" && token.name) { - // Handle --no- prefix for negatable options - const name = token.name.startsWith("no-") ? token.name.slice(3) : token.name; - - if (!knownOptions.has(name)) { - return `Unknown option: --${token.name}`; - } - } - } - - return null; -} - -/** - * Determines if template mode is active. - */ -export function isTemplateMode(flags: { - template?: boolean; - application?: string; - stage?: string; -}): boolean { - return Boolean(flags.template || flags.application || flags.stage); -} - -/** - * Validates that --name flag is not used with template mode flags. - */ -export function validateNameTemplateConflict(flags: { - name?: string; - template?: boolean; - application?: string; - stage?: string; -}): string | null { - if (flags.name && isTemplateMode(flags)) { - const conflicting: string[] = []; - if (flags.template) conflicting.push("--template"); - if (flags.application) conflicting.push("--application"); - if (flags.stage) conflicting.push("--stage"); - return `Cannot use --name with ${conflicting.join(", ")}`; - } - return null; -} - -export function parseEnvContent(content: string): Record { - const result: Record = {}; - - for (const line of content.split("\n")) { - const trimmed = line.trim(); - - // Skip empty lines and comments - if (trimmed === "" || trimmed.startsWith("#")) { - continue; - } - - const equalIndex = trimmed.indexOf("="); - if (equalIndex === -1) { - continue; - } - - const key = trimmed.slice(0, equalIndex).trim(); - let value = trimmed.slice(equalIndex + 1).trim(); - - // Check if value is quoted - const isQuoted = - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")); - - // Remove surrounding quotes if present - if (isQuoted) { - value = value.slice(1, -1); - } else { - // Remove inline comment for unquoted values - const commentIndex = value.indexOf("#"); - if (commentIndex !== -1) { - value = value.slice(0, commentIndex).trim(); - } - } - - if (key) { - result[key] = value; - } - } - - return result; -} - -export interface E2smConfig { - template?: boolean; - application?: string; - stage?: string; - profile?: string; - region?: string; - input?: string; -} - -/** - * Loads config from .e2smrc.json (project or global). - * Returns empty object if no config found. - */ -export async function loadConfig(): Promise { - const { homedir } = await import("node:os"); - const { join } = await import("node:path"); - const { readFile } = await import("node:fs/promises"); - - const candidates = [join(process.cwd(), ".e2smrc.json"), join(homedir(), ".e2smrc.json")]; - - for (const filePath of candidates) { - try { - const content = await readFile(filePath, "utf-8"); - return JSON.parse(content); - } catch { - // ignore errors (file not found, parse errors), continue to next - } - } - - return {}; -} - -/** - * Merges CLI flags with config. CLI takes precedence. - */ -export function mergeWithConfig(cliValues: Partial, config: E2smConfig): E2smConfig { - return { - ...config, - ...Object.fromEntries(Object.entries(cliValues).filter(([, v]) => v !== undefined)), - }; -} - -export function exec( - command: string, - args: string[], -): Promise<{ exitCode: number; stdout: string; stderr: string }> { - return new Promise((resolve) => { - const proc = spawn(command, args, { - shell: false, - stdio: ["pipe", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - proc.stderr.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - proc.on("close", (code) => { - resolve({ exitCode: code ?? 1, stdout, stderr }); - }); - }); -} - -export function formatJson(obj: unknown, indent = 0): string { - const spaces = " ".repeat(indent); - - if (obj === null) { - return gray("null"); - } - - if (typeof obj === "string") { - return green(`"${obj}"`); - } - - if (typeof obj === "number" || typeof obj === "boolean" || typeof obj === "bigint") { - return String(obj); - } - - if (typeof obj === "undefined") { - return gray("undefined"); - } - - if (typeof obj === "symbol") { - return gray(obj.toString()); - } - - if (typeof obj === "function") { - return gray("[Function]"); - } - - if (Array.isArray(obj)) { - if (obj.length === 0) return dim("[]"); - const items = obj.map((item) => `${spaces} ${formatJson(item, indent + 1)}`); - return `${dim("[")}\n${items.join(`${dim(",")}\n`)}\n${spaces}${dim("]")}`; - } - - // obj is now narrowed to object (non-null, non-array) - const entries = Object.entries(obj); - if (entries.length === 0) return dim("{}"); - const items = entries.map( - ([key, value]) => `${spaces} ${cyan(`"${key}"`)}${dim(":")} ${formatJson(value, indent + 1)}`, - ); - return `${dim("{")}\n${items.join(`${dim(",")}\n`)}\n${spaces}${dim("}")}`; -} diff --git a/src/lib/aws.test.ts b/src/lib/aws.test.ts new file mode 100644 index 0000000..c8e979a --- /dev/null +++ b/src/lib/aws.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { exec } from "./aws"; + +describe("exec", () => { + test("executes command and returns stdout", async () => { + const result = await exec("echo", ["hello"]); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("hello"); + expect(result.stderr).toBe(""); + }); + + test("returns stderr on error", async () => { + const result = await exec("ls", ["nonexistent-dir-12345"]); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("No such file"); + }); +}); diff --git a/src/lib/aws.ts b/src/lib/aws.ts new file mode 100644 index 0000000..f4ddb54 --- /dev/null +++ b/src/lib/aws.ts @@ -0,0 +1,58 @@ +import { spawn } from "node:child_process"; + +export function exec( + command: string, + args: string[], +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const proc = spawn(command, args, { + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + proc.on("close", (code) => { + resolve({ exitCode: code ?? 1, stdout, stderr }); + }); + }); +} + +export interface SecretListEntry { + Name: string; + ARN: string; +} + +export interface SecretListResponse { + SecretList: SecretListEntry[]; +} + +/** + * Fetches the list of secrets from AWS Secrets Manager. + */ +export async function fetchSecretList(options: { + profile?: string; + region?: string; +}): Promise<{ secrets: SecretListEntry[] } | { error: string }> { + const profileArgs = options.profile ? ["--profile", options.profile] : []; + const regionArgs = options.region ? ["--region", options.region] : []; + + const result = await exec("aws", [ + "secretsmanager", + "list-secrets", + ...profileArgs, + ...regionArgs, + ]); + + if (result.exitCode !== 0) { + return { error: result.stderr }; + } + + const response: SecretListResponse = JSON.parse(result.stdout); + return { secrets: response.SecretList }; +} diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..02fbf57 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,148 @@ +import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"; +import { unlink } from "node:fs/promises"; +import { loadConfig, mergeWithConfig } from "./config"; + +describe("loadConfig", () => { + const jsoncPath = ".e2smrc.jsonc"; + const jsonPath = ".e2smrc.json"; + + afterEach(async () => { + await unlink(jsoncPath).catch(() => {}); + await unlink(jsonPath).catch(() => {}); + }); + + afterAll(async () => { + await unlink(jsoncPath).catch(() => {}); + await unlink(jsonPath).catch(() => {}); + }); + + test("parses standard JSON", async () => { + await Bun.write(jsoncPath, JSON.stringify({ input: ".env.local" })); + const config = await loadConfig(); + expect(config.input).toBe(".env.local"); + }); + + test("parses JSONC with line comments", async () => { + await Bun.write( + jsoncPath, + `{ + // This is a comment + "input": ".env.local" +}`, + ); + const config = await loadConfig(); + expect(config.input).toBe(".env.local"); + }); + + test("parses JSONC with block comments", async () => { + await Bun.write( + jsoncPath, + `{ + /* Block comment */ + "input": ".env.local" +}`, + ); + const config = await loadConfig(); + expect(config.input).toBe(".env.local"); + }); + + test("parses JSONC with trailing commas", async () => { + await Bun.write( + jsoncPath, + `{ + "input": ".env.local", +}`, + ); + const config = await loadConfig(); + expect(config.input).toBe(".env.local"); + }); + + test("prefers .jsonc over .json when both exist", async () => { + await Bun.write(jsoncPath, JSON.stringify({ input: "from-jsonc" })); + await Bun.write(jsonPath, JSON.stringify({ input: "from-json" })); + const config = await loadConfig(); + expect(config.input).toBe("from-jsonc"); + }); + + test("falls back to .json when .jsonc does not exist (backward compatibility)", async () => { + await Bun.write(jsonPath, JSON.stringify({ input: "from-json" })); + const config = await loadConfig(); + expect(config.input).toBe("from-json"); + }); + + test("warns and skips file with parse errors, falls back to next candidate", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + // Create invalid JSONC in .e2smrc.jsonc + await Bun.write(jsoncPath, "{ invalid json }"); + // Create valid JSON in .e2smrc.json as fallback + await Bun.write(jsonPath, JSON.stringify({ input: "from-json-fallback" })); + + const config = await loadConfig(); + + expect(warnSpy).toHaveBeenCalled(); + const firstWarnMessage = warnSpy.mock.calls[0]?.[0] ?? ""; + expect(firstWarnMessage).toContain("Warning: Parse error"); + expect(firstWarnMessage).toContain(jsoncPath); + expect(config.input).toBe("from-json-fallback"); + + warnSpy.mockRestore(); + }); + + test("warns for each file with parse errors", async () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + + await Bun.write(jsoncPath, "{ invalid }"); + await Bun.write(jsonPath, "{ also invalid }"); + + await loadConfig(); + + // Should warn for both invalid files + expect(warnSpy).toHaveBeenCalled(); + const warnCalls = warnSpy.mock.calls.map((call) => call[0]); + expect(warnCalls.some((msg) => msg.includes(jsoncPath))).toBe(true); + expect(warnCalls.some((msg) => msg.includes(jsonPath))).toBe(true); + + warnSpy.mockRestore(); + }); +}); + +describe("mergeWithConfig", () => { + test("CLI values take precedence over config", () => { + const cli = { application: "cli-app" }; + const config = { application: "config-app", stage: "prod" }; + const result = mergeWithConfig(cli, config); + expect(result.application).toBe("cli-app"); + expect(result.stage).toBe("prod"); + }); + + test("undefined CLI values do not override config", () => { + const cli = { application: undefined }; + const config = { application: "config-app" }; + const result = mergeWithConfig(cli, config); + expect(result.application).toBe("config-app"); + }); + + test("returns config values when CLI has no values", () => { + const cli = {}; + const config = { profile: "my-profile", region: "ap-northeast-1" }; + const result = mergeWithConfig(cli, config); + expect(result.profile).toBe("my-profile"); + expect(result.region).toBe("ap-northeast-1"); + }); + + test("returns CLI values when config is empty", () => { + const cli = { template: true, input: ".env.local" }; + const config = {}; + const result = mergeWithConfig(cli, config); + expect(result.template).toBe(true); + expect(result.input).toBe(".env.local"); + }); + + test("handles boolean false values from CLI", () => { + const cli = { template: false }; + const config = { template: true }; + const result = mergeWithConfig(cli, config); + expect(result.template).toBe(false); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..06e6cf5 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,64 @@ +import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; + +export interface E2smConfig { + template?: boolean; + application?: string; + stage?: string; + profile?: string; + region?: string; + input?: string; + output?: string; + name?: string; +} + +/** + * Loads config from .e2smrc.jsonc or .e2smrc.json (project or global). + * Searches in order: .jsonc (preferred) then .json (backward compatibility). + * Returns empty object if no config found. + */ +export async function loadConfig(): Promise { + const { homedir } = await import("node:os"); + const { join } = await import("node:path"); + const { readFile } = await import("node:fs/promises"); + + const candidates = [ + join(process.cwd(), ".e2smrc.jsonc"), + join(process.cwd(), ".e2smrc.json"), + join(homedir(), ".e2smrc.jsonc"), + join(homedir(), ".e2smrc.json"), + ]; + + for (const filePath of candidates) { + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + // File not found, try next candidate + continue; + } + + const errors: ParseError[] = []; + const config = parse(content, errors, { allowTrailingComma: true }); + + if (errors.length > 0) { + const errorMessages = errors.map((e) => printParseErrorCode(e.error)).join(", "); + console.warn(`Warning: Parse error in ${filePath}: ${errorMessages}`); + console.warn("Skipping this config file and trying next candidate..."); + continue; + } + + return config; + } + + return {}; +} + +/** + * Merges CLI flags with config. CLI takes precedence. + */ +export function mergeWithConfig(cliValues: Partial, config: E2smConfig): E2smConfig { + return { + ...config, + ...Object.fromEntries(Object.entries(cliValues).filter(([, v]) => v !== undefined)), + }; +} diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..e9aaba0 --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import { generateEnvHeader, jsonToEnv, parseEnvContent } from "./env"; + +describe("parseEnvContent", () => { + test("parses basic KEY=value", () => { + expect(parseEnvContent("FOO=bar")).toEqual({ FOO: "bar" }); + }); + + test("parses multiple KEY=value pairs", () => { + const content = `FOO=bar +BAZ=qux`; + expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("parses double-quoted values", () => { + expect(parseEnvContent('FOO="bar baz"')).toEqual({ FOO: "bar baz" }); + }); + + test("parses single-quoted values", () => { + expect(parseEnvContent("FOO='bar baz'")).toEqual({ FOO: "bar baz" }); + }); + + test("preserves hash in double-quoted values", () => { + expect(parseEnvContent('FOO="bar#baz"')).toEqual({ FOO: "bar#baz" }); + }); + + test("preserves hash in single-quoted values", () => { + expect(parseEnvContent("FOO='bar#baz'")).toEqual({ FOO: "bar#baz" }); + }); + + test("removes inline comment from unquoted values", () => { + expect(parseEnvContent("FOO=bar # this is a comment")).toEqual({ FOO: "bar" }); + }); + + test("skips empty lines", () => { + const content = `FOO=bar + +BAZ=qux`; + expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("skips comment lines", () => { + const content = `# This is a comment +FOO=bar +# Another comment +BAZ=qux`; + expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("skips lines without equal sign", () => { + const content = `FOO=bar +invalid line +BAZ=qux`; + expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("handles value with equal sign", () => { + expect(parseEnvContent("FOO=bar=baz")).toEqual({ FOO: "bar=baz" }); + }); + + test("trims whitespace around key and value", () => { + expect(parseEnvContent(" FOO = bar ")).toEqual({ FOO: "bar" }); + }); + + test("handles empty value", () => { + expect(parseEnvContent("FOO=")).toEqual({ FOO: "" }); + }); + + test("returns empty object for empty content", () => { + expect(parseEnvContent("")).toEqual({}); + }); + + test("returns empty object for only comments", () => { + const content = `# comment 1 +# comment 2`; + expect(parseEnvContent(content)).toEqual({}); + }); +}); + +describe("jsonToEnv", () => { + test("converts simple key-value pairs", () => { + const result = jsonToEnv({ FOO: "bar", BAZ: "qux" }); + expect(result).toBe('FOO="bar"\nBAZ="qux"'); + }); + + test("quotes values with spaces", () => { + const result = jsonToEnv({ FOO: "bar baz" }); + expect(result).toBe('FOO="bar baz"'); + }); + + test("quotes values with hash", () => { + const result = jsonToEnv({ FOO: "bar#baz" }); + expect(result).toBe('FOO="bar#baz"'); + }); + + test("escapes double quotes in values", () => { + const result = jsonToEnv({ FOO: 'bar"baz' }); + expect(result).toBe('FOO="bar\\"baz"'); + }); + + test("handles empty value", () => { + const result = jsonToEnv({ FOO: "" }); + expect(result).toBe('FOO=""'); + }); + + test("handles empty object", () => { + const result = jsonToEnv({}); + expect(result).toBe(""); + }); + + test("handles values with single quotes", () => { + const result = jsonToEnv({ FOO: "bar'baz" }); + expect(result).toBe('FOO="bar\'baz"'); + }); + + test("handles values with newlines", () => { + const result = jsonToEnv({ FOO: "bar\nbaz" }); + expect(result).toBe('FOO="bar\nbaz"'); + }); +}); + +describe("generateEnvHeader", () => { + test("generates header with secret name", () => { + const result = generateEnvHeader("my-secret"); + expect(result).toBe("# Generated by e2sm\n# Source: my-secret"); + }); + + test("handles secret name with special characters", () => { + const result = generateEnvHeader("prod/api/secrets"); + expect(result).toBe("# Generated by e2sm\n# Source: prod/api/secrets"); + }); +}); diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..dbafbe8 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,102 @@ +import { cyan, dim, gray, green } from "kleur/colors"; + +export function parseEnvContent(content: string): Record { + const result: Record = {}; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + + // Skip empty lines and comments + if (trimmed === "" || trimmed.startsWith("#")) { + continue; + } + + const equalIndex = trimmed.indexOf("="); + if (equalIndex === -1) { + continue; + } + + const key = trimmed.slice(0, equalIndex).trim(); + let value = trimmed.slice(equalIndex + 1).trim(); + + // Check if value is quoted + const isQuoted = + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")); + + // Remove surrounding quotes if present + if (isQuoted) { + value = value.slice(1, -1); + } else { + // Remove inline comment for unquoted values + const commentIndex = value.indexOf("#"); + if (commentIndex !== -1) { + value = value.slice(0, commentIndex).trim(); + } + } + + if (key) { + result[key] = value; + } + } + + return result; +} + +/** + * Converts JSON object to .env format string. + */ +export function jsonToEnv(data: Record): string { + return Object.entries(data) + .map(([key, value]) => { + const escaped = value.replace(/"/g, '\\"'); + return `${key}="${escaped}"`; + }) + .join("\n"); +} + +export function generateEnvHeader(secretName: string): string { + return `# Generated by e2sm\n# Source: ${secretName}`; +} + +export function formatJson(obj: unknown, indent = 0): string { + const spaces = " ".repeat(indent); + + if (obj === null) { + return gray("null"); + } + + if (typeof obj === "string") { + return green(`"${obj}"`); + } + + if (typeof obj === "number" || typeof obj === "boolean" || typeof obj === "bigint") { + return String(obj); + } + + if (typeof obj === "undefined") { + return gray("undefined"); + } + + if (typeof obj === "symbol") { + return gray(obj.toString()); + } + + if (typeof obj === "function") { + return gray("[Function]"); + } + + if (Array.isArray(obj)) { + if (obj.length === 0) return dim("[]"); + const items = obj.map((item) => `${spaces} ${formatJson(item, indent + 1)}`); + return `${dim("[")}\n${items.join(`${dim(",")}\n`)}\n${spaces}${dim("]")}`; + } + + // obj is now narrowed to object (non-null, non-array) + const entries = Object.entries(obj); + if (entries.length === 0) return dim("{}"); + const items = entries.map( + ([key, value]) => `${spaces} ${cyan(`"${key}"`)}${dim(":")} ${formatJson(value, indent + 1)}`, + ); + return `${dim("{")}\n${items.join(`${dim(",")}\n`)}\n${spaces}${dim("}")}`; +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..0ef2e5d --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,14 @@ +export { exec, fetchSecretList } from "./aws"; +export type { SecretListEntry, SecretListResponse } from "./aws"; + +export { formatJson, generateEnvHeader, jsonToEnv, parseEnvContent } from "./env"; + +export { loadConfig, mergeWithConfig } from "./config"; +export type { E2smConfig } from "./config"; + +export { + isTemplateMode, + toKebabCase, + validateNameTemplateConflict, + validateUnknownFlags, +} from "./validation"; diff --git a/src/lib.test.ts b/src/lib/validation.test.ts similarity index 56% rename from src/lib.test.ts rename to src/lib/validation.test.ts index 0281aee..143303c 100644 --- a/src/lib.test.ts +++ b/src/lib/validation.test.ts @@ -1,14 +1,11 @@ import type { ArgSchema, ArgToken } from "gunshi"; import { describe, expect, test } from "bun:test"; import { - exec, isTemplateMode, - mergeWithConfig, - parseEnvContent, toKebabCase, validateNameTemplateConflict, validateUnknownFlags, -} from "./lib"; +} from "./validation"; describe("toKebabCase", () => { test("converts camelCase to kebab-case", () => { @@ -36,82 +33,6 @@ describe("toKebabCase", () => { }); }); -describe("parseEnvContent", () => { - test("parses basic KEY=value", () => { - expect(parseEnvContent("FOO=bar")).toEqual({ FOO: "bar" }); - }); - - test("parses multiple KEY=value pairs", () => { - const content = `FOO=bar -BAZ=qux`; - expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); - }); - - test("parses double-quoted values", () => { - expect(parseEnvContent('FOO="bar baz"')).toEqual({ FOO: "bar baz" }); - }); - - test("parses single-quoted values", () => { - expect(parseEnvContent("FOO='bar baz'")).toEqual({ FOO: "bar baz" }); - }); - - test("preserves hash in double-quoted values", () => { - expect(parseEnvContent('FOO="bar#baz"')).toEqual({ FOO: "bar#baz" }); - }); - - test("preserves hash in single-quoted values", () => { - expect(parseEnvContent("FOO='bar#baz'")).toEqual({ FOO: "bar#baz" }); - }); - - test("removes inline comment from unquoted values", () => { - expect(parseEnvContent("FOO=bar # this is a comment")).toEqual({ FOO: "bar" }); - }); - - test("skips empty lines", () => { - const content = `FOO=bar - -BAZ=qux`; - expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); - }); - - test("skips comment lines", () => { - const content = `# This is a comment -FOO=bar -# Another comment -BAZ=qux`; - expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); - }); - - test("skips lines without equal sign", () => { - const content = `FOO=bar -invalid line -BAZ=qux`; - expect(parseEnvContent(content)).toEqual({ FOO: "bar", BAZ: "qux" }); - }); - - test("handles value with equal sign", () => { - expect(parseEnvContent("FOO=bar=baz")).toEqual({ FOO: "bar=baz" }); - }); - - test("trims whitespace around key and value", () => { - expect(parseEnvContent(" FOO = bar ")).toEqual({ FOO: "bar" }); - }); - - test("handles empty value", () => { - expect(parseEnvContent("FOO=")).toEqual({ FOO: "" }); - }); - - test("returns empty object for empty content", () => { - expect(parseEnvContent("")).toEqual({}); - }); - - test("returns empty object for only comments", () => { - const content = `# comment 1 -# comment 2`; - expect(parseEnvContent(content)).toEqual({}); - }); -}); - describe("validateUnknownFlags", () => { const createToken = (name: string): ArgToken => ({ kind: "option", @@ -248,58 +169,3 @@ describe("validateNameTemplateConflict", () => { expect(result).toContain("--stage"); }); }); - -describe("mergeWithConfig", () => { - test("CLI values take precedence over config", () => { - const cli = { application: "cli-app" }; - const config = { application: "config-app", stage: "prod" }; - const result = mergeWithConfig(cli, config); - expect(result.application).toBe("cli-app"); - expect(result.stage).toBe("prod"); - }); - - test("undefined CLI values do not override config", () => { - const cli = { application: undefined }; - const config = { application: "config-app" }; - const result = mergeWithConfig(cli, config); - expect(result.application).toBe("config-app"); - }); - - test("returns config values when CLI has no values", () => { - const cli = {}; - const config = { profile: "my-profile", region: "ap-northeast-1" }; - const result = mergeWithConfig(cli, config); - expect(result.profile).toBe("my-profile"); - expect(result.region).toBe("ap-northeast-1"); - }); - - test("returns CLI values when config is empty", () => { - const cli = { template: true, input: ".env.local" }; - const config = {}; - const result = mergeWithConfig(cli, config); - expect(result.template).toBe(true); - expect(result.input).toBe(".env.local"); - }); - - test("handles boolean false values from CLI", () => { - const cli = { template: false }; - const config = { template: true }; - const result = mergeWithConfig(cli, config); - expect(result.template).toBe(false); - }); -}); - -describe("exec", () => { - test("executes command and returns stdout", async () => { - const result = await exec("echo", ["hello"]); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe("hello"); - expect(result.stderr).toBe(""); - }); - - test("returns stderr on error", async () => { - const result = await exec("ls", ["nonexistent-dir-12345"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("No such file"); - }); -}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..2632ffc --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,77 @@ +import type { ArgSchema, ArgToken } from "gunshi"; + +export function toKebabCase(str: string): string { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** + * Validates that all provided flags are known options. + * @returns Error message if an unknown flag is found, null otherwise + */ +export function validateUnknownFlags( + tokens: ArgToken[], + args: Record, +): string | null { + // Build set of known option names + const knownOptions = new Set(); + + // Built-in options + knownOptions.add("help"); + knownOptions.add("h"); + knownOptions.add("version"); + knownOptions.add("v"); + + // User-defined options + for (const [key, schema] of Object.entries(args)) { + knownOptions.add(key); + knownOptions.add(toKebabCase(key)); + + if (schema.short) { + knownOptions.add(schema.short); + } + } + + // Check for unknown options + for (const token of tokens) { + if (token.kind === "option" && token.name) { + // Handle --no- prefix for negatable options + const name = token.name.startsWith("no-") ? token.name.slice(3) : token.name; + + if (!knownOptions.has(name)) { + return `Unknown option: --${token.name}`; + } + } + } + + return null; +} + +/** + * Determines if template mode is active. + */ +export function isTemplateMode(flags: { + template?: boolean; + application?: string; + stage?: string; +}): boolean { + return Boolean(flags.template || flags.application || flags.stage); +} + +/** + * Validates that --name flag is not used with template mode flags. + */ +export function validateNameTemplateConflict(flags: { + name?: string; + template?: boolean; + application?: string; + stage?: string; +}): string | null { + if (flags.name && isTemplateMode(flags)) { + const conflicting: string[] = []; + if (flags.template) conflicting.push("--template"); + if (flags.application) conflicting.push("--application"); + if (flags.stage) conflicting.push("--stage"); + return `Cannot use --name with ${conflicting.join(", ")}`; + } + return null; +}