From 2f3bcd8744e4d2cd8dc47552879b61b8649d79a4 Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:37:24 +0900 Subject: [PATCH 1/6] feat: add pull and set commands with improved env generation - add pull command to fetch secrets and generate .env files - add set command to push .env content to AWS Secrets Manager - add generateEnvHeader function to add metadata comments to generated files - modify jsonToEnv to quote all values for consistency - refactor fetchSecretList into shared lib function - add comprehensive tests for new functionality - update schema.json with new config options (output, name) --- schema.json | 8 + src/get.ts | 34 ++--- src/index.test.ts | 381 +++++++++++++++++++++++++++------------------- src/index.ts | 279 ++------------------------------- src/lib.test.ts | 56 +++++++ src/lib.ts | 52 +++++++ src/pull.test.ts | 54 +++++++ src/pull.ts | 214 ++++++++++++++++++++++++++ src/set.test.ts | 78 ++++++++++ src/set.ts | 270 ++++++++++++++++++++++++++++++++ 10 files changed, 985 insertions(+), 441 deletions(-) create mode 100644 src/pull.test.ts create mode 100644 src/pull.ts create mode 100644 src/set.test.ts create mode 100644 src/set.ts diff --git a/schema.json b/schema.json index 9e313c4..aba0a2c 100644 --- a/schema.json +++ b/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/src/get.ts b/src/get.ts index 6d510be..41effb0 100644 --- a/src/get.ts +++ b/src/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 { + 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/index.test.ts b/src/index.test.ts index 3f18173..bb9eff7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -20,27 +20,15 @@ const runCli = async (args: string[]) => { }; describe("CLI", () => { - describe("--help", () => { - test("shows help message", async () => { - const { stdout, exitCode } = await runCli(["--help"]); + describe("no subcommand", () => { + test("shows error message when no subcommand provided", async () => { + const { stderr, exitCode } = await runCli([]); - 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"); - }); - - test("shows help with -h flag", async () => { - const { stdout, exitCode } = await runCli(["-h"]); - - expect(exitCode).toBe(0); - expect(stdout).toContain("USAGE:"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Please specify a subcommand"); + expect(stderr).toContain("e2sm set"); + expect(stderr).toContain("e2sm get"); + expect(stderr).toContain("e2sm pull"); }); }); @@ -60,180 +48,222 @@ 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"); + }); - test("exits with error for unknown short flag", async () => { - const { stderr, exitCode } = await runCli(["-x"]); + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["set", "-h"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown option: --x"); + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); }); - }); - describe("--dry-run", () => { - const testEnvPath = "test-fixtures/test.env"; + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["set", "--unknown-flag"]); - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, - "FOO=bar\nBAZ=qux\n", - ); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --unknown-flag"); + }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); - await unlink(`${import.meta.dir.replace("/src", "")}/test-fixtures`).catch(() => {}); + 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"); + }); }); - test("previews JSON output without uploading", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", testEnvPath]); + describe("--dry-run", () => { + const testEnvPath = "test-fixtures/test.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", "")}/${testEnvPath}`, + "FOO=bar\nBAZ=qux\n", + ); + }); - test("works with -d flag", async () => { - const { stdout, exitCode } = await runCli(["-d", "-i", testEnvPath]); + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); + await unlink(`${import.meta.dir.replace("/src", "")}/test-fixtures`).catch(() => {}); + }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); - }); - }); + test("previews JSON output without uploading", async () => { + const { stdout, exitCode } = await runCli(["set", "--dry-run", "--input", testEnvPath]); - describe("file not found", () => { - test("exits with error when file does not exist", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", "nonexistent.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"); + }); - expect(exitCode).toBe(1); - expect(stdout).toContain("File not found"); + 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"); + }); }); - }); - describe("empty env file", () => { - const emptyEnvPath = "test-fixtures/empty.env"; + 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", + ]); - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`, - "# only comments\n", - ); + expect(exitCode).toBe(1); + expect(stdout).toContain("File not found"); + }); }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`).catch(() => {}); - }); + describe("empty env file", () => { + const emptyEnvPath = "test-fixtures/empty.env"; - test("exits with error when no valid variables found", async () => { - const { stdout, exitCode } = await runCli(["--dry-run", "--input", emptyEnvPath]); + beforeAll(async () => { + await Bun.write( + `${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`, + "# only comments\n", + ); + }); - expect(exitCode).toBe(1); - expect(stdout).toContain("No valid environment variables found"); - }); - }); + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${emptyEnvPath}`).catch(() => {}); + }); - describe("flag conflicts", () => { - test("--name with --template exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "--template"]); + 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(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--template"); + expect(exitCode).toBe(1); + expect(stdout).toContain("No valid environment variables found"); + }); }); - test("--name with --application exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-a", "app"]); + describe("flag conflicts", () => { + test("--name with --template exits with error", async () => { + const { stderr, exitCode } = await runCli(["set", "--name", "x", "--template"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Cannot use --name"); - expect(stderr).toContain("--application"); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--template"); + }); - test("--name with --stage exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-s", "prod"]); + 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("--stage"); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--application"); + }); - test("--name with multiple template flags exits with error", async () => { - const { stderr, exitCode } = await runCli(["--name", "x", "-t", "-a", "app", "-s", "prod"]); + 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("--template"); - expect(stderr).toContain("--application"); - expect(stderr).toContain("--stage"); - }); - }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--stage"); + }); - describe("template mode", () => { - const testEnvPath = "test-fixtures/test.env"; + test("--name with multiple template flags exits with error", async () => { + const { stderr, exitCode } = await runCli([ + "set", + "--name", + "x", + "-t", + "-a", + "app", + "-s", + "prod", + ]); - beforeAll(async () => { - await Bun.write( - `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, - "FOO=bar\nBAZ=qux\n", - ); + expect(exitCode).toBe(1); + expect(stderr).toContain("Cannot use --name"); + expect(stderr).toContain("--template"); + expect(stderr).toContain("--application"); + expect(stderr).toContain("--stage"); + }); }); - afterAll(async () => { - await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); - }); + describe("template mode", () => { + const testEnvPath = "test-fixtures/test.env"; - test("-a and -s without -t works (implicit template mode)", async () => { - const { stdout, exitCode } = await runCli([ - "-d", - "-i", - testEnvPath, - "-a", - "my-app", - "-s", - "prod", - ]); + beforeAll(async () => { + await Bun.write( + `${import.meta.dir.replace("/src", "")}/${testEnvPath}`, + "FOO=bar\nBAZ=qux\n", + ); + }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Dry-run mode"); - }); + afterAll(async () => { + await unlink(`${import.meta.dir.replace("/src", "")}/${testEnvPath}`).catch(() => {}); + }); - test("-t -a -s generates correct secret name", async () => { - const { stdout, exitCode } = await runCli([ - "-d", - "-i", - testEnvPath, - "-t", - "-a", - "my-app", - "-s", - "prod", - ]); + 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"); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + }); - test("-a only (implicit template mode)", async () => { - const { exitCode } = await runCli(["-d", "-i", testEnvPath, "-a", "my-app"]); + test("-t -a -s generates correct secret name", async () => { + const { stdout, exitCode } = await runCli([ + "set", + "-d", + "-i", + testEnvPath, + "-t", + "-a", + "my-app", + "-s", + "prod", + ]); - // Will wait for stage input interactively, but dry-run exits before secret name is needed - expect(exitCode).toBe(0); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Dry-run mode"); + }); - test("-s only (implicit template mode)", async () => { - const { exitCode } = await runCli(["-d", "-i", testEnvPath, "-s", "prod"]); + test("-a only (implicit template mode)", async () => { + const { exitCode } = await runCli(["set", "-d", "-i", testEnvPath, "-a", "my-app"]); - // Will wait for application input interactively, but dry-run exits before secret name is needed - expect(exitCode).toBe(0); + // 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); + }); }); }); @@ -274,6 +304,45 @@ describe("CLI", () => { }); }); + describe("pull subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["pull", "--help"]); + + 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("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["pull", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); + }); + + 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("Unknown option: --unknown-flag"); + }); + + test("exits with error for unknown short flag", async () => { + const { stderr, exitCode } = await runCli(["pull", "-x"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); + }); + }); + describe("config file", () => { const testEnvPath = "test-fixtures/test.env"; const configPath = ".e2smrc.json"; @@ -290,7 +359,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 +368,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 +384,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 +392,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..680f918 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,279 +1,30 @@ -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 { pullCommand } from "./pull"; +import { setCommand } from "./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 (set, get, or pull)"); + console.error(""); + console.error("Usage:"); + 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(""); + console.error("Run 'e2sm --help' for more information on a command."); + process.exit(1); }, }); await cli(process.argv.slice(2), command, { version: pkg.version, - fallbackToEntry: true, subCommands: { + set: setCommand, get: getCommand, + pull: pullCommand, }, }); diff --git a/src/lib.test.ts b/src/lib.test.ts index 0281aee..d489922 100644 --- a/src/lib.test.ts +++ b/src/lib.test.ts @@ -2,7 +2,9 @@ import type { ArgSchema, ArgToken } from "gunshi"; import { describe, expect, test } from "bun:test"; import { exec, + generateEnvHeader, isTemplateMode, + jsonToEnv, mergeWithConfig, parseEnvContent, toKebabCase, @@ -303,3 +305,57 @@ describe("exec", () => { expect(result.stderr).toContain("No such file"); }); }); + +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.ts b/src/lib.ts index eabf791..877ceee 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -128,6 +128,8 @@ export interface E2smConfig { profile?: string; region?: string; input?: string; + output?: string; + name?: string; } /** @@ -227,3 +229,53 @@ export function formatJson(obj: unknown, indent = 0): string { ); return `${dim("{")}\n${items.join(`${dim(",")}\n`)}\n${spaces}${dim("}")}`; } + +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 }; +} + +/** + * 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}`; +} diff --git a/src/pull.test.ts b/src/pull.test.ts new file mode 100644 index 0000000..ce3acf8 --- /dev/null +++ b/src/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/pull.ts b/src/pull.ts new file mode 100644 index 0000000..217a958 --- /dev/null +++ b/src/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/set.test.ts b/src/set.test.ts new file mode 100644 index 0000000..e4c2abe --- /dev/null +++ b/src/set.test.ts @@ -0,0 +1,78 @@ +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)", + }); + }); +}); diff --git a/src/set.ts b/src/set.ts new file mode 100644 index 0000000..026904b --- /dev/null +++ b/src/set.ts @@ -0,0 +1,270 @@ +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)", + }, + }, + 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 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 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`); + }, +}); From bafaee47458a7e1bb525c5f0282fb96820971b9c Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:04:45 +0900 Subject: [PATCH 2/6] refactor: reorganize codebase structure and improve UI messages - restructure src/lib.ts into separate modules in src/lib/ directory - aws.ts: AWS-related functions and types - config.ts: configuration loading and merging - env.ts: environment file parsing and JSON formatting - validation.ts: input validation and utility functions - index.ts: central exports for all lib modules - move command files to src/commands/ directory for better organization - update import paths throughout codebase for new structure - enhance set command UI messages to display file paths - dry-run mode shows input file path being previewed - upload mode shows both file path and secret name - add test coverage for file path display in dry-run mode - add CLI help message tests with comprehensive coverage --- src/{ => commands}/get.test.ts | 0 src/{ => commands}/get.ts | 4 +- src/{ => commands}/pull.test.ts | 0 src/{ => commands}/pull.ts | 4 +- src/{ => commands}/set.test.ts | 0 src/{ => commands}/set.ts | 8 +- src/index.test.ts | 23 ++ src/index.ts | 7 +- src/lib.test.ts | 361 -------------------------------- src/lib.ts | 281 ------------------------- src/lib/aws.test.ts | 17 ++ src/lib/aws.ts | 58 +++++ src/lib/config.test.ts | 42 ++++ src/lib/config.ts | 43 ++++ src/lib/env.test.ts | 132 ++++++++++++ src/lib/env.ts | 102 +++++++++ src/lib/index.ts | 14 ++ src/lib/validation.test.ts | 171 +++++++++++++++ src/lib/validation.ts | 77 +++++++ 19 files changed, 691 insertions(+), 653 deletions(-) rename src/{ => commands}/get.test.ts (100%) rename src/{ => commands}/get.ts (98%) rename src/{ => commands}/pull.test.ts (100%) rename src/{ => commands}/pull.ts (99%) rename src/{ => commands}/set.test.ts (100%) rename src/{ => commands}/set.ts (97%) delete mode 100644 src/lib.test.ts delete mode 100644 src/lib.ts create mode 100644 src/lib/aws.test.ts create mode 100644 src/lib/aws.ts create mode 100644 src/lib/config.test.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/env.test.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/validation.test.ts create mode 100644 src/lib/validation.ts 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 98% rename from src/get.ts rename to src/commands/get.ts index 41effb0..37be317 100644 --- a/src/get.ts +++ b/src/commands/get.ts @@ -1,6 +1,6 @@ import * as p from "@clack/prompts"; import { define } from "gunshi"; -import pkg from "../package.json"; +import pkg from "../../package.json"; import { exec, fetchSecretList, @@ -8,7 +8,7 @@ import { loadConfig, mergeWithConfig, validateUnknownFlags, -} from "./lib"; +} from "../lib"; function isCancel(value: unknown): value is symbol { return p.isCancel(value); diff --git a/src/pull.test.ts b/src/commands/pull.test.ts similarity index 100% rename from src/pull.test.ts rename to src/commands/pull.test.ts diff --git a/src/pull.ts b/src/commands/pull.ts similarity index 99% rename from src/pull.ts rename to src/commands/pull.ts index 217a958..ecde3b2 100644 --- a/src/pull.ts +++ b/src/commands/pull.ts @@ -1,7 +1,7 @@ import * as p from "@clack/prompts"; import { define } from "gunshi"; import { access, writeFile } from "node:fs/promises"; -import pkg from "../package.json"; +import pkg from "../../package.json"; import { exec, fetchSecretList, @@ -10,7 +10,7 @@ import { loadConfig, mergeWithConfig, validateUnknownFlags, -} from "./lib"; +} from "../lib"; function isCancel(value: unknown): value is symbol { return p.isCancel(value); diff --git a/src/set.test.ts b/src/commands/set.test.ts similarity index 100% rename from src/set.test.ts rename to src/commands/set.test.ts diff --git a/src/set.ts b/src/commands/set.ts similarity index 97% rename from src/set.ts rename to src/commands/set.ts index 026904b..f0738e2 100644 --- a/src/set.ts +++ b/src/commands/set.ts @@ -1,7 +1,7 @@ import * as p from "@clack/prompts"; import { define } from "gunshi"; import { access, readFile } from "node:fs/promises"; -import pkg from "../package.json"; +import pkg from "../../package.json"; import { exec, formatJson, @@ -11,7 +11,7 @@ import { parseEnvContent, validateNameTemplateConflict, validateUnknownFlags, -} from "./lib"; +} from "../lib"; function isCancel(value: unknown): value is symbol { return p.isCancel(value); @@ -134,7 +134,7 @@ export const setCommand = define({ // 3. Dry-run mode: preview JSON if (isDryRun) { - p.log.info("Dry-run mode: Previewing JSON output"); + p.log.info(`Dry-run mode: Previewing '${envFilePath}'`); console.log(formatJson(envData)); p.outro("Dry-run complete"); return; @@ -208,7 +208,7 @@ export const setCommand = define({ // 5. Upload to AWS Secrets Manager const spinner = p.spinner(); - spinner.start("Uploading to AWS Secrets Manager..."); + spinner.start(`Uploading '${envFilePath}' to '${secretName}' in AWS Secrets Manager...`); const profileArgs = profile ? ["--profile", profile] : []; const regionArgs = region ? ["--region", region] : []; diff --git a/src/index.test.ts b/src/index.test.ts index bb9eff7..bee2dda 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -20,6 +20,28 @@ const runCli = async (args: string[]) => { }; describe("CLI", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("e2sm"); + expect(stdout).toContain("get"); + expect(stdout).toContain("pull"); + expect(stdout).toContain("set"); + expect(stdout).not.toContain("undefined"); + }); + + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["-h"]); + + 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([]); @@ -109,6 +131,7 @@ describe("CLI", () => { 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"); diff --git a/src/index.ts b/src/index.ts index 680f918..7a1109b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import { cli, define } from "gunshi"; import pkg from "../package.json"; -import { getCommand } from "./get"; -import { pullCommand } from "./pull"; -import { setCommand } from "./set"; +import { getCommand } from "./commands/get"; +import { pullCommand } from "./commands/pull"; +import { setCommand } from "./commands/set"; const command = define({ name: "e2sm", @@ -21,6 +21,7 @@ const command = define({ }); await cli(process.argv.slice(2), command, { + name: "e2sm", version: pkg.version, subCommands: { set: setCommand, diff --git a/src/lib.test.ts b/src/lib.test.ts deleted file mode 100644 index d489922..0000000 --- a/src/lib.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import type { ArgSchema, ArgToken } from "gunshi"; -import { describe, expect, test } from "bun:test"; -import { - exec, - generateEnvHeader, - isTemplateMode, - jsonToEnv, - mergeWithConfig, - parseEnvContent, - toKebabCase, - validateNameTemplateConflict, - validateUnknownFlags, -} from "./lib"; - -describe("toKebabCase", () => { - test("converts camelCase to kebab-case", () => { - expect(toKebabCase("dryRun")).toBe("dry-run"); - }); - - test("converts multiple uppercase letters", () => { - expect(toKebabCase("thisIsATest")).toBe("this-is-atest"); - }); - - test("leaves already lowercase string unchanged", () => { - expect(toKebabCase("lowercase")).toBe("lowercase"); - }); - - test("leaves already kebab-case string unchanged", () => { - expect(toKebabCase("already-kebab")).toBe("already-kebab"); - }); - - test("handles single word", () => { - expect(toKebabCase("word")).toBe("word"); - }); - - test("handles empty string", () => { - expect(toKebabCase("")).toBe(""); - }); -}); - -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", - name, - rawName: `--${name}`, - value: undefined, - index: 0, - }); - - const baseArgs: Record = { - dryRun: { - type: "boolean", - short: "d", - }, - profile: { - type: "string", - short: "p", - }, - }; - - test("returns null for known options", () => { - const tokens = [createToken("dry-run")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); - }); - - test("returns null for known short options converted to long", () => { - const tokens = [createToken("profile")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); - }); - - test("returns null for built-in help option", () => { - const tokens = [createToken("help")]; - expect(validateUnknownFlags(tokens, {})).toBeNull(); - }); - - test("returns null for built-in version option", () => { - const tokens = [createToken("version")]; - expect(validateUnknownFlags(tokens, {})).toBeNull(); - }); - - test("returns error for unknown option", () => { - const tokens = [createToken("unknown")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --unknown"); - }); - - test("returns null for --no- prefixed negatable options", () => { - const tokens = [createToken("no-dry-run")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); - }); - - test("returns error for --no- prefixed unknown options", () => { - const tokens = [createToken("no-unknown")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --no-unknown"); - }); - - test("returns null for empty tokens", () => { - expect(validateUnknownFlags([], baseArgs)).toBeNull(); - }); - - test("ignores non-option tokens", () => { - const tokens: ArgToken[] = [{ kind: "positional", value: "some-value", index: 0 }]; - expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); - }); - - test("returns first unknown option when multiple unknown options exist", () => { - const tokens = [createToken("unknown1"), createToken("unknown2")]; - expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --unknown1"); - }); -}); - -describe("isTemplateMode", () => { - test("returns false when no template flags", () => { - expect(isTemplateMode({})).toBe(false); - }); - - test("returns true when --template", () => { - expect(isTemplateMode({ template: true })).toBe(true); - }); - - test("returns false when --template is false", () => { - expect(isTemplateMode({ template: false })).toBe(false); - }); - - test("returns true when --application", () => { - expect(isTemplateMode({ application: "app" })).toBe(true); - }); - - test("returns true when --stage", () => { - expect(isTemplateMode({ stage: "prod" })).toBe(true); - }); - - test("returns true when all flags are set", () => { - expect(isTemplateMode({ template: true, application: "app", stage: "prod" })).toBe(true); - }); -}); - -describe("validateNameTemplateConflict", () => { - test("returns null when no conflict", () => { - expect(validateNameTemplateConflict({ name: "secret" })).toBeNull(); - expect(validateNameTemplateConflict({ template: true })).toBeNull(); - }); - - test("returns null when only template mode flags", () => { - expect(validateNameTemplateConflict({ application: "app", stage: "prod" })).toBeNull(); - }); - - test("returns error when --name with --template", () => { - const result = validateNameTemplateConflict({ name: "s", template: true }); - expect(result).toContain("--template"); - expect(result).toContain("Cannot use --name"); - }); - - test("returns error when --name with --application", () => { - const result = validateNameTemplateConflict({ name: "s", application: "a" }); - expect(result).toContain("--application"); - expect(result).toContain("Cannot use --name"); - }); - - test("returns error when --name with --stage", () => { - const result = validateNameTemplateConflict({ name: "s", stage: "p" }); - expect(result).toContain("--stage"); - expect(result).toContain("Cannot use --name"); - }); - - test("returns error with all conflicting flags listed", () => { - const result = validateNameTemplateConflict({ - name: "s", - template: true, - application: "a", - stage: "p", - }); - expect(result).toContain("--template"); - expect(result).toContain("--application"); - 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"); - }); -}); - -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.ts b/src/lib.ts deleted file mode 100644 index 877ceee..0000000 --- a/src/lib.ts +++ /dev/null @@ -1,281 +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; - output?: string; - name?: 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("}")}`; -} - -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 }; -} - -/** - * 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}`; -} 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..663df19 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { mergeWithConfig } from "./config"; + +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..cbd5419 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,43 @@ +export interface E2smConfig { + template?: boolean; + application?: string; + stage?: string; + profile?: string; + region?: string; + input?: string; + output?: string; + name?: 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)), + }; +} 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/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 0000000..143303c --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,171 @@ +import type { ArgSchema, ArgToken } from "gunshi"; +import { describe, expect, test } from "bun:test"; +import { + isTemplateMode, + toKebabCase, + validateNameTemplateConflict, + validateUnknownFlags, +} from "./validation"; + +describe("toKebabCase", () => { + test("converts camelCase to kebab-case", () => { + expect(toKebabCase("dryRun")).toBe("dry-run"); + }); + + test("converts multiple uppercase letters", () => { + expect(toKebabCase("thisIsATest")).toBe("this-is-atest"); + }); + + test("leaves already lowercase string unchanged", () => { + expect(toKebabCase("lowercase")).toBe("lowercase"); + }); + + test("leaves already kebab-case string unchanged", () => { + expect(toKebabCase("already-kebab")).toBe("already-kebab"); + }); + + test("handles single word", () => { + expect(toKebabCase("word")).toBe("word"); + }); + + test("handles empty string", () => { + expect(toKebabCase("")).toBe(""); + }); +}); + +describe("validateUnknownFlags", () => { + const createToken = (name: string): ArgToken => ({ + kind: "option", + name, + rawName: `--${name}`, + value: undefined, + index: 0, + }); + + const baseArgs: Record = { + dryRun: { + type: "boolean", + short: "d", + }, + profile: { + type: "string", + short: "p", + }, + }; + + test("returns null for known options", () => { + const tokens = [createToken("dry-run")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); + }); + + test("returns null for known short options converted to long", () => { + const tokens = [createToken("profile")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); + }); + + test("returns null for built-in help option", () => { + const tokens = [createToken("help")]; + expect(validateUnknownFlags(tokens, {})).toBeNull(); + }); + + test("returns null for built-in version option", () => { + const tokens = [createToken("version")]; + expect(validateUnknownFlags(tokens, {})).toBeNull(); + }); + + test("returns error for unknown option", () => { + const tokens = [createToken("unknown")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --unknown"); + }); + + test("returns null for --no- prefixed negatable options", () => { + const tokens = [createToken("no-dry-run")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); + }); + + test("returns error for --no- prefixed unknown options", () => { + const tokens = [createToken("no-unknown")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --no-unknown"); + }); + + test("returns null for empty tokens", () => { + expect(validateUnknownFlags([], baseArgs)).toBeNull(); + }); + + test("ignores non-option tokens", () => { + const tokens: ArgToken[] = [{ kind: "positional", value: "some-value", index: 0 }]; + expect(validateUnknownFlags(tokens, baseArgs)).toBeNull(); + }); + + test("returns first unknown option when multiple unknown options exist", () => { + const tokens = [createToken("unknown1"), createToken("unknown2")]; + expect(validateUnknownFlags(tokens, baseArgs)).toBe("Unknown option: --unknown1"); + }); +}); + +describe("isTemplateMode", () => { + test("returns false when no template flags", () => { + expect(isTemplateMode({})).toBe(false); + }); + + test("returns true when --template", () => { + expect(isTemplateMode({ template: true })).toBe(true); + }); + + test("returns false when --template is false", () => { + expect(isTemplateMode({ template: false })).toBe(false); + }); + + test("returns true when --application", () => { + expect(isTemplateMode({ application: "app" })).toBe(true); + }); + + test("returns true when --stage", () => { + expect(isTemplateMode({ stage: "prod" })).toBe(true); + }); + + test("returns true when all flags are set", () => { + expect(isTemplateMode({ template: true, application: "app", stage: "prod" })).toBe(true); + }); +}); + +describe("validateNameTemplateConflict", () => { + test("returns null when no conflict", () => { + expect(validateNameTemplateConflict({ name: "secret" })).toBeNull(); + expect(validateNameTemplateConflict({ template: true })).toBeNull(); + }); + + test("returns null when only template mode flags", () => { + expect(validateNameTemplateConflict({ application: "app", stage: "prod" })).toBeNull(); + }); + + test("returns error when --name with --template", () => { + const result = validateNameTemplateConflict({ name: "s", template: true }); + expect(result).toContain("--template"); + expect(result).toContain("Cannot use --name"); + }); + + test("returns error when --name with --application", () => { + const result = validateNameTemplateConflict({ name: "s", application: "a" }); + expect(result).toContain("--application"); + expect(result).toContain("Cannot use --name"); + }); + + test("returns error when --name with --stage", () => { + const result = validateNameTemplateConflict({ name: "s", stage: "p" }); + expect(result).toContain("--stage"); + expect(result).toContain("Cannot use --name"); + }); + + test("returns error with all conflicting flags listed", () => { + const result = validateNameTemplateConflict({ + name: "s", + template: true, + application: "a", + stage: "p", + }); + expect(result).toContain("--template"); + expect(result).toContain("--application"); + expect(result).toContain("--stage"); + }); +}); 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; +} From b3b08d89d053e6c8ab59ac0748dfbb7e15616f9d Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:30:15 +0900 Subject: [PATCH 3/6] feat: add delete subcommand and overwrite confirmation for set - implement delete command with recovery window support (7-30 days) - add interactive secret selection or --name flag for delete - include --recovery-days and --force flags for delete command - add confirmation prompt for secret deletion with recovery info - enhance set command with --force flag to skip overwrite confirmation - add overwrite confirmation when updating existing secrets in set - include comprehensive test coverage for delete command - update CLI help and error messages to include delete subcommand --- src/commands/delete.test.ts | 53 +++++++++++ src/commands/delete.ts | 178 ++++++++++++++++++++++++++++++++++++ src/commands/set.test.ts | 8 ++ src/commands/set.ts | 31 ++++++- src/index.test.ts | 88 ++++++++++++++++++ src/index.ts | 11 ++- 6 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 src/commands/delete.test.ts create mode 100644 src/commands/delete.ts 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/commands/set.test.ts b/src/commands/set.test.ts index e4c2abe..9aa11e7 100644 --- a/src/commands/set.test.ts +++ b/src/commands/set.test.ts @@ -75,4 +75,12 @@ describe("setCommand", () => { 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 index f0738e2..1434c8b 100644 --- a/src/commands/set.ts +++ b/src/commands/set.ts @@ -62,6 +62,11 @@ export const setCommand = define({ 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 @@ -89,6 +94,7 @@ export const setCommand = define({ 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`); @@ -207,13 +213,10 @@ export const setCommand = define({ } // 5. Upload to AWS Secrets Manager - const spinner = p.spinner(); - spinner.start(`Uploading '${envFilePath}' to '${secretName}' in AWS Secrets Manager...`); - const profileArgs = profile ? ["--profile", profile] : []; const regionArgs = region ? ["--region", region] : []; - // First, try to check if the secret already exists + // First, check if the secret already exists const describeResult = await exec("aws", [ "secretsmanager", "describe-secret", @@ -223,7 +226,25 @@ export const setCommand = define({ ...regionArgs, ]); - if (describeResult.exitCode === 0) { + 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", diff --git a/src/index.test.ts b/src/index.test.ts index bee2dda..ed20cff 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -30,6 +30,7 @@ describe("CLI", () => { expect(stdout).toContain("get"); expect(stdout).toContain("pull"); expect(stdout).toContain("set"); + expect(stdout).toContain("delete"); expect(stdout).not.toContain("undefined"); }); @@ -51,6 +52,7 @@ describe("CLI", () => { expect(stderr).toContain("e2sm set"); expect(stderr).toContain("e2sm get"); expect(stderr).toContain("e2sm pull"); + expect(stderr).toContain("e2sm delete"); }); }); @@ -85,6 +87,7 @@ describe("CLI", () => { expect(stdout).toContain("--template"); expect(stdout).toContain("--application"); expect(stdout).toContain("--stage"); + expect(stdout).toContain("--force"); }); test("shows help with -h flag", async () => { @@ -366,6 +369,91 @@ describe("CLI", () => { }); }); + describe("delete subcommand", () => { + describe("--help", () => { + test("shows help message", async () => { + const { stdout, exitCode } = await runCli(["delete", "--help"]); + + 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"); + }); + + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["delete", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); + }); + + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + const { stderr, exitCode } = await runCli(["delete", "--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(["delete", "-x"]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown option: --x"); + }); + }); + + 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", + ]); + + 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 too high", async () => { + const { stdout, exitCode } = await runCli([ + "delete", + "--name", + "test-secret", + "--recovery-days", + "31", + "--force", + ]); + + 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("config file", () => { const testEnvPath = "test-fixtures/test.env"; const configPath = ".e2smrc.json"; diff --git a/src/index.ts b/src/index.ts index 7a1109b..d6d2e27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { cli, define } from "gunshi"; import pkg from "../package.json"; +import { deleteCommand } from "./commands/delete"; import { getCommand } from "./commands/get"; import { pullCommand } from "./commands/pull"; import { setCommand } from "./commands/set"; @@ -8,12 +9,13 @@ const command = define({ name: "e2sm", description: "Manage environment variables with AWS Secrets Manager", run: () => { - console.error("Error: Please specify a subcommand (set, get, or pull)"); + console.error("Error: Please specify a subcommand (set, get, pull, or delete)"); console.error(""); console.error("Usage:"); - 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 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); @@ -27,5 +29,6 @@ await cli(process.argv.slice(2), command, { set: setCommand, get: getCommand, pull: pullCommand, + delete: deleteCommand, }, }); From bc255ac7c7cb58e9bc82806c4f771719378d9cff Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:35:25 +0900 Subject: [PATCH 4/6] docs: update README.md --- README.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4a0b6c6..a95e8ed 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,86 @@ ## Usage ```bash -npx e2sm -npx e2sm --dry-run -npx e2sm --help +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. +### 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.json` file to set default options. ```json { @@ -32,7 +94,8 @@ You can create a `.e2smrc.json` file to set default options. "stage": "dev", "profile": "my-profile", "region": "ap-northeast-1", - "input": ".env.local" + "input": ".env.local", + "output": ".env" } ``` @@ -49,8 +112,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 ``` From 745a7fc80e07e7a5dc449d7271812e149b797394 Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:28:07 +0900 Subject: [PATCH 5/6] feat: add init subcommand with JSONC config support - add init subcommand to create .e2smrc.jsonc configuration file - implement interactive confirmation for file overwrite with --force flag - migrate config format from .json to .jsonc with comment support - maintain backward compatibility with existing .json config files - add jsonc-parser dependency for parsing JSONC with error handling - move schema.json to assets/schema.json for better organization - create template.jsonc with commented examples for all config options - enhance config loading with fallback chain and parse error warnings - add comprehensive tests for init command and config parsing - update README with init command documentation and JSONC examples --- README.md | 41 ++++++++--- schema.json => assets/schema.json | 0 assets/template.jsonc | 15 ++++ bun.lock | 3 + package.json | 5 +- src/commands/init.test.ts | 42 ++++++++++++ src/commands/init.ts | 61 +++++++++++++++++ src/index.test.ts | 84 ++++++++++++++++++++++- src/index.ts | 5 +- src/lib/config.test.ts | 110 +++++++++++++++++++++++++++++- src/lib/config.ts | 31 +++++++-- 11 files changed, 377 insertions(+), 20 deletions(-) rename schema.json => assets/schema.json (100%) create mode 100644 assets/template.jsonc create mode 100644 src/commands/init.test.ts create mode 100644 src/commands/init.ts diff --git a/README.md b/README.md index a95e8ed..1154917 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## Usage ```bash +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 @@ -14,6 +15,19 @@ npx e2sm --help # Show help ## Commands +### 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 @@ -84,25 +98,34 @@ npx e2sm delete -n my-app/prod -d 7 --force ## Configuration -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", + // File paths "input": ".env.local", - "output": ".env" + "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). diff --git a/schema.json b/assets/schema.json similarity index 100% rename from schema.json rename to assets/schema.json 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/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/index.test.ts b/src/index.test.ts index ed20cff..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"; @@ -27,6 +28,7 @@ describe("CLI", () => { expect(exitCode).toBe(0); expect(stdout).toContain("USAGE:"); expect(stdout).toContain("e2sm"); + expect(stdout).toContain("init"); expect(stdout).toContain("get"); expect(stdout).toContain("pull"); expect(stdout).toContain("set"); @@ -49,6 +51,7 @@ describe("CLI", () => { 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"); @@ -454,9 +457,88 @@ describe("CLI", () => { }); }); + 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(["init", "--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--force"); + }); + + test("shows help with -h flag", async () => { + const { stdout, exitCode } = await runCli(["init", "-h"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("USAGE:"); + }); + }); + + describe("unknown flags", () => { + test("exits with error for unknown flag", async () => { + 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(["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 () => { diff --git a/src/index.ts b/src/index.ts index d6d2e27..208f3ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { cli, define } from "gunshi"; import pkg from "../package.json"; 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"; @@ -9,9 +10,10 @@ const command = define({ name: "e2sm", description: "Manage environment variables with AWS Secrets Manager", run: () => { - console.error("Error: Please specify a subcommand (set, get, pull, or delete)"); + 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"); @@ -26,6 +28,7 @@ await cli(process.argv.slice(2), command, { name: "e2sm", version: pkg.version, subCommands: { + init: initCommand, set: setCommand, get: getCommand, pull: pullCommand, diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 663df19..02fbf57 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,5 +1,111 @@ -import { describe, expect, test } from "bun:test"; -import { mergeWithConfig } from "./config"; +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", () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index cbd5419..06e6cf5 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,3 +1,5 @@ +import { type ParseError, parse, printParseErrorCode } from "jsonc-parser"; + export interface E2smConfig { template?: boolean; application?: string; @@ -10,7 +12,8 @@ export interface E2smConfig { } /** - * Loads config from .e2smrc.json (project or global). + * 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 { @@ -18,15 +21,33 @@ export async function loadConfig(): Promise { const { join } = await import("node:path"); const { readFile } = await import("node:fs/promises"); - const candidates = [join(process.cwd(), ".e2smrc.json"), join(homedir(), ".e2smrc.json")]; + 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 { - const content = await readFile(filePath, "utf-8"); - return JSON.parse(content); + content = await readFile(filePath, "utf-8"); } catch { - // ignore errors (file not found, parse errors), continue to next + // 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 {}; From d602e5b64e634467f6f4ac710f7e8afe0d56b6d9 Mon Sep 17 00:00:00 2001 From: mfyuu <83203852+ve1997@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:38:35 +0900 Subject: [PATCH 6/6] chore: add changeset --- .changeset/vast-dancers-teach.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/vast-dancers-teach.md 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