From e397f7e5db5fc92fedda218d8b4ddc7e099a0db2 Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Fri, 29 May 2026 16:22:40 +0200 Subject: [PATCH 1/2] feat: add ek import command --- package.json | 2 +- src/api/client.ts | 248 ++++++++++++++++++++- src/cmd/import.ts | 270 +++++++++++++++++++++++ src/cmd/index.ts | 2 + src/lib/config.ts | 86 +++++++- tests/helpers/fixtures.ts | 4 + tests/helpers/mock-api.ts | 16 +- tests/integration/config.test.ts | 109 +++++++++ tests/integration/import-command.test.ts | 154 +++++++++++++ tests/unit/import.test.ts | 69 ++++++ 10 files changed, 947 insertions(+), 13 deletions(-) create mode 100644 src/cmd/import.ts create mode 100644 tests/integration/import-command.test.ts create mode 100644 tests/unit/import.test.ts diff --git a/package.json b/package.json index 4cbe37d..342e577 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@enkryptify/cli", - "version": "0.4.0", + "version": "0.4.2", "bin": { "ek": "./dist/cli.js" }, diff --git a/src/api/client.ts b/src/api/client.ts index 7ec831d..834709d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -46,6 +46,8 @@ type Project = { }; type ProjectTeam = { + id?: string; + name?: string; projects: Project[]; }; @@ -54,7 +56,24 @@ type Environment = { name: string; }; -type Resource = Workspace | Project | ProjectTeam | Environment | ApiSecret; +type Team = { + id: string; + name: string; +}; + +export type ImportSecret = { + key: string; + value: string; +}; + +export type ImportTarget = { + config: ProjectConfig; + workspaceName: string; + projectName: string; + environmentName: string; +}; + +type Resource = Workspace | Project | ProjectTeam | Environment | Team | ApiSecret; type ApiSecretValue = { environmentId: string; @@ -68,6 +87,9 @@ type ApiSecret = { values: ApiSecretValue[]; }; +const CREATE_PROJECT_LABEL = "Create a new project"; +const CREATE_ENVIRONMENT_LABEL = "Create a new environment"; + class EnkryptifyClient { private auth: Auth; @@ -118,10 +140,6 @@ class EnkryptifyClient { return { status: "kept", scope, config: setup }; } } else if (scope === "git") { - // A path-only setup for this same directory would shadow the Git - // setup. Detect it and confirm replacing it before continuing. - // getConfigure returns null when no path setup exists, so any thrown - // error here is a genuine read failure and should propagate. const pathSetup = await config.getConfigure(options, { scope: "path" }); if (pathSetup) { const replace = await confirm( @@ -348,6 +366,38 @@ class EnkryptifyClient { }); } + async importSecrets(config: ProjectConfig, secrets: ImportSecret[]): Promise { + const { workspace_slug, project_slug, environment_id } = this.checkProjectConfig(config); + + await http.post(`/v1/workspace/${workspace_slug}/project/${project_slug}/secret`, { + environments: [environment_id], + secrets: secrets.map((secret) => ({ + key: secret.key, + value: secret.value, + type: "runtime", + dataType: "text", + })), + }); + } + + async selectImportTarget(projectPath: string): Promise { + const selectedWorkspace = await this.selectWorkspace(); + const selectedProject = await this.selectProject(selectedWorkspace); + const selectedEnvironment = await this.selectEnvironment(selectedWorkspace, selectedProject); + + return { + config: { + path: projectPath, + workspace_slug: selectedWorkspace.slug, + project_slug: selectedProject.slug, + environment_id: selectedEnvironment.id, + }, + workspaceName: selectedWorkspace.name, + projectName: selectedProject.name, + environmentName: selectedEnvironment.name, + }; + } + async updateSecret(config: ProjectConfig, name: string, isPersonalFlag?: boolean): Promise { const { workspace_slug, project_slug, environment_id } = this.checkProjectConfig(config); @@ -514,6 +564,178 @@ class EnkryptifyClient { } } + private async fetchOptionalResource(url: string): Promise { + try { + const response = await http.get(url); + return response.data ?? []; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 404) { + return []; + } + if (error instanceof AxiosError) { + const status = error.response?.status; + if (status === 401) throw CLIError.from("API_UNAUTHORIZED"); + if (status === 403) throw CLIError.from("API_FORBIDDEN"); + if (status && status >= 500) throw CLIError.from("API_SERVER_ERROR"); + if (!status) throw CLIError.from("API_NETWORK_ERROR"); + } + throw error; + } + } + + private async selectWorkspace(): Promise { + const workspaces = await this.fetchResource("/v1/workspace"); + + if (workspaces.length === 1) return first(workspaces); + + const workspaceMap = new Map(); + const workspaceLabels = workspaces.map((ws) => { + const label = `${ws.name} (${ws.slug})`; + workspaceMap.set(label, ws); + return label; + }); + + const selectedWorkspaceLabel = await selectName(workspaceLabels, "Select workspace"); + const selectedWorkspace = workspaceMap.get(selectedWorkspaceLabel); + if (!selectedWorkspace) { + throw new CLIError( + "The selected workspace could not be found.", + undefined, + 'Try running "ek import" again.', + ); + } + + return selectedWorkspace; + } + + private async selectProject(workspace: Workspace): Promise { + const projectsResponse = await this.fetchOptionalResource( + `/v1/workspace/${workspace.slug}/project`, + ); + const projects = projectsResponse.flatMap((team) => team.projects ?? []); + + if (projects.length === 1) return first(projects); + if (projects.length === 0) return this.createProjectInteractively(workspace); + + const projectMap = new Map(); + const projectLabels = projects.map((project) => { + const label = `${project.name} (${project.slug})`; + projectMap.set(label, project); + return label; + }); + + const selectedProjectLabel = await selectName([...projectLabels, CREATE_PROJECT_LABEL], "Select project"); + if (selectedProjectLabel === CREATE_PROJECT_LABEL) { + return this.createProjectInteractively(workspace); + } + + const selectedProject = projectMap.get(selectedProjectLabel); + if (!selectedProject) { + throw new CLIError("The selected project could not be found.", undefined, 'Try running "ek import" again.'); + } + + return selectedProject; + } + + private async selectEnvironment(workspace: Workspace, project: Project): Promise { + const environments = await this.fetchOptionalResource( + `/v1/workspace/${workspace.slug}/project/${project.slug}/environment`, + ); + + if (environments.length === 1) return first(environments); + if (environments.length === 0) return this.createEnvironmentInteractively(workspace, project); + + const environmentName = await selectName( + [...environments.map((environment) => environment.name), CREATE_ENVIRONMENT_LABEL], + "Select environment", + ); + + if (environmentName === CREATE_ENVIRONMENT_LABEL) { + return this.createEnvironmentInteractively(workspace, project); + } + + const selectedEnvironment = environments.find((environment) => environment.name === environmentName); + if (!selectedEnvironment) { + throw new CLIError( + "The selected environment could not be found.", + undefined, + 'Try running "ek import" again.', + ); + } + + return selectedEnvironment; + } + + private async createProjectInteractively(workspace: Workspace): Promise { + const teams = await this.fetchResource(`/v1/workspace/${workspace.slug}/team`); + const selectedTeam = await this.selectTeam(teams); + const name = (await getTextInput("Project name: ")).trim(); + if (!name) { + throw new CLIError("Project name is required.", undefined, "Enter a project name to continue."); + } + + const defaultSlug = slugify(name); + const slugInput = (await getTextInput(`Project slug (press Enter to use "${defaultSlug}"): `)).trim(); + const slug = slugInput || defaultSlug; + if (!slug) { + throw new CLIError("Project slug is required.", undefined, "Enter a project slug to continue."); + } + + const response = await http.post(`/v1/workspace/${workspace.slug}/project`, { + name, + slug, + teamId: selectedTeam.id, + }); + + logger.success(`Project created successfully! Name: ${response.data.name}`); + return response.data; + } + + private async selectTeam(teams: Team[]): Promise { + if (teams.length === 1) return first(teams); + + const teamMap = new Map(); + const teamLabels = teams.map((team) => { + teamMap.set(team.name, team); + return team.name; + }); + + const selectedTeamLabel = await selectName(teamLabels, "Select team for the new project"); + const selectedTeam = teamMap.get(selectedTeamLabel); + if (!selectedTeam) { + throw new CLIError("The selected team could not be found.", undefined, 'Try running "ek import" again.'); + } + + return selectedTeam; + } + + private async createEnvironmentInteractively(workspace: Workspace, project: Project): Promise { + const name = (await getTextInput("Environment name: ")).trim(); + if (!name) { + throw new CLIError("Environment name is required.", undefined, "Enter an environment name to continue."); + } + + await http.post(`/v1/workspace/${workspace.slug}/project/${project.slug}/environment`, { + name, + hasPersonalOverrides: false, + }); + + const environments = await this.fetchResource( + `/v1/workspace/${workspace.slug}/project/${project.slug}/environment`, + ); + const environment = environments.find((candidate) => candidate.name === name); + if (!environment) { + throw new CLIError( + `Environment "${name}" was created but could not be loaded.`, + undefined, + 'Try running "ek import" again.', + ); + } + + logger.success(`Environment created successfully! Name: ${environment.name}`); + return environment; + } + private checkProjectConfig(config: ProjectConfig) { const { workspace_slug, project_slug, environment_id } = config; if (!workspace_slug || !project_slug || !environment_id) { @@ -529,3 +751,19 @@ class EnkryptifyClient { } export const client = new EnkryptifyClient(); + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function first(items: T[]): T { + const item = items[0]; + if (item === undefined) { + throw new CLIError("Expected at least one item."); + } + return item; +} diff --git a/src/cmd/import.ts b/src/cmd/import.ts new file mode 100644 index 0000000..714ae59 --- /dev/null +++ b/src/cmd/import.ts @@ -0,0 +1,270 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { type ImportSecret, client } from "@/api/client"; +import { analytics } from "@/lib/analytics"; +import { config } from "@/lib/config"; +import { CLIError } from "@/lib/errors"; +import { logger } from "@/lib/logger"; +import { secretNameSchema } from "@/validators/secret"; +import { confirm } from "@/ui/Confirm"; +import type { Command } from "commander"; +import { z } from "zod"; + +type ParsedLine = { + key: string; + value: string; + line: number; +}; + +const ASSIGNMENT_PATTERN = /^\s*(?:export\s+)?([A-Za-z0-9_-]+)\s*=\s*(.*)$/; + +export function parseDotenvContent(content: string): ImportSecret[] { + const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/); + const parsed: ParsedLine[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index] ?? ""; + const trimmedLine = rawLine.trim(); + if (!trimmedLine || trimmedLine.startsWith("#")) continue; + + const match = rawLine.match(ASSIGNMENT_PATTERN); + if (!match) { + throw new CLIError( + `Could not parse .env line ${index + 1}.`, + `Expected KEY=value but found: ${trimmedLine}`, + "Check the file for malformed entries and try again.", + ); + } + + const key = match[1] ?? ""; + const valueStart = match[2] ?? ""; + const { value, endLine } = parseValue(valueStart, lines, index); + parsed.push({ key, value, line: index + 1 }); + index = endLine; + } + + validateParsedSecrets(parsed); + + return parsed.map(({ key, value }) => ({ key, value })); +} + +export async function importCommand(file = ".env"): Promise<{ workspace_slug: string; imported: number }> { + const authenticated = await config.isAuthenticated(); + if (!authenticated) { + throw CLIError.from("AUTH_NOT_LOGGED_IN"); + } + + const filePath = path.resolve(process.cwd(), file); + const content = await readEnvFile(filePath); + const secrets = parseDotenvContent(content); + + if (secrets.length === 0) { + throw new CLIError("No secrets found.", `The file "${filePath}" does not contain any KEY=value entries.`); + } + + const target = await client.selectImportTarget(process.cwd()); + await client.importSecrets(target.config, secrets); + + logger.success( + `Imported ${secrets.length} secret${secrets.length === 1 ? "" : "s"} into ${target.workspaceName}/${target.projectName}/${target.environmentName}.`, + ); + + const shouldDelete = await confirm(`Delete ${filePath}?`); + if (shouldDelete) { + await fs.unlink(filePath); + logger.success(`Deleted ${filePath}.`); + } + + return { workspace_slug: target.config.workspace_slug ?? "", imported: secrets.length }; +} + +export function registerImportCommand(program: Command) { + program + .command("import") + .description("Import secrets from a .env file into Enkryptify") + .argument("[file]", 'Path to a dotenv file. Defaults to ".env".') + .action(async (file?: string) => { + const tracker = analytics.trackCommand("command_import"); + + try { + const result = await importCommand(file ?? ".env"); + tracker.success({ + workspace_slug: result.workspace_slug, + imported: String(result.imported), + }); + } catch (error: unknown) { + tracker.error(error); + if (error instanceof CLIError) { + logger.error(error.message, { why: error.why, fix: error.fix, docs: error.docs }); + } else { + logger.error(error instanceof Error ? error.message : String(error)); + } + process.exit(1); + } + }); +} + +async function readEnvFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8"); + } catch (error: unknown) { + const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : undefined; + if (code === "ENOENT") { + throw new CLIError( + "File not found.", + `Could not find "${filePath}".`, + "Pass a file path or create a .env file.", + ); + } + throw error; + } +} + +function parseValue(valueStart: string, lines: string[], startLine: number): { value: string; endLine: number } { + const trimmedStart = valueStart.trimStart(); + if (trimmedStart.startsWith('"')) { + return parseQuotedValue(trimmedStart.slice(1), lines, startLine, '"'); + } + if (trimmedStart.startsWith("'")) { + return parseQuotedValue(trimmedStart.slice(1), lines, startLine, "'"); + } + + return { + value: stripInlineComment(valueStart).trim(), + endLine: startLine, + }; +} + +function parseQuotedValue( + firstFragment: string, + lines: string[], + startLine: number, + quote: '"' | "'", +): { value: string; endLine: number } { + const fragments = [firstFragment]; + let currentLine = startLine; + + while (currentLine < lines.length) { + const current = fragments[fragments.length - 1] ?? ""; + const closingIndex = findClosingQuote(current, quote, fragments.length > 1); + if (closingIndex !== -1) { + const beforeQuote = current.slice(0, closingIndex); + const afterQuote = current.slice(closingIndex + 1).trim(); + if (afterQuote && !afterQuote.startsWith("#")) { + throw new CLIError( + `Could not parse .env line ${currentLine + 1}.`, + "Unexpected characters after a quoted value.", + "Move comments after quoted values behind a # or remove the extra characters.", + ); + } + + fragments[fragments.length - 1] = beforeQuote; + const rawValue = fragments.join("\n"); + return { + value: quote === '"' ? decodeDoubleQuotedValue(rawValue) : rawValue, + endLine: currentLine, + }; + } + + currentLine += 1; + if (currentLine >= lines.length) break; + fragments.push(lines[currentLine] ?? ""); + } + + throw new CLIError( + `Could not parse .env line ${startLine + 1}.`, + "A quoted value was not closed.", + "Add the missing quote and try again.", + ); +} + +function findClosingQuote(value: string, quote: '"' | "'", isMultilineContinuation: boolean): number { + if (quote === '"' && isMultilineContinuation && /^\s*"[^"]+"\s*:/.test(value)) { + return -1; + } + + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== quote) continue; + if (quote === '"' && isEscaped(value, index)) continue; + + const rest = value.slice(index + 1).trim(); + if (!isMultilineContinuation || rest.length === 0 || rest.startsWith("#")) { + return index; + } + } + + return -1; +} + +function isEscaped(value: string, quoteIndex: number): boolean { + let slashCount = 0; + for (let index = quoteIndex - 1; index >= 0 && value[index] === "\\"; index -= 1) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +function stripInlineComment(value: string): string { + let result = ""; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (char === "#" && (index === 0 || /\s/.test(value[index - 1] ?? ""))) break; + result += char; + } + return result; +} + +function decodeDoubleQuotedValue(value: string): string { + return value.replace(/\\([nrt"\\])/g, (_match, escaped: string) => { + switch (escaped) { + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + case '"': + return '"'; + case "\\": + return "\\"; + default: + return escaped; + } + }); +} + +function validateParsedSecrets(parsed: ParsedLine[]): void { + const seen = new Set(); + + for (const secret of parsed) { + try { + secretNameSchema.parse(secret.key); + } catch (error) { + if (error instanceof z.ZodError) { + throw new CLIError( + `Invalid secret name "${secret.key}" on line ${secret.line}.`, + error.issues.map((issue) => issue.message).join(" "), + "Secret names must match Enkryptify's secret naming rules.", + ); + } + throw error; + } + + if (secret.value.length === 0) { + throw new CLIError( + `Secret "${secret.key}" has an empty value on line ${secret.line}.`, + undefined, + "Remove the entry or provide a value before importing.", + ); + } + + if (seen.has(secret.key)) { + throw new CLIError( + `Duplicate secret "${secret.key}" found on line ${secret.line}.`, + undefined, + "Remove duplicate entries before importing.", + ); + } + seen.add(secret.key); + } +} diff --git a/src/cmd/index.ts b/src/cmd/index.ts index 691dc07..df8c72e 100644 --- a/src/cmd/index.ts +++ b/src/cmd/index.ts @@ -1,6 +1,7 @@ import { registerConfigureCommand } from "@/cmd/configure"; import { registerCreateCommand } from "@/cmd/create"; import { registerDeleteCommand } from "@/cmd/delete"; +import { registerImportCommand } from "@/cmd/import"; import { registerListCommand } from "@/cmd/listCommand"; import { registerLoginCommand } from "@/cmd/login"; import { registerLogoutCommand } from "@/cmd/logout"; @@ -18,6 +19,7 @@ export function registerCommands(program: Command) { registerLogoutCommand(program); registerWhoamiCommand(program); registerConfigureCommand(program); + registerImportCommand(program); registerScanCommand(program); registerRunCommand(program); registerRunFileCommand(program); diff --git a/src/lib/config.ts b/src/lib/config.ts index 1407793..b9b965e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -12,6 +12,8 @@ export type ProjectConfig = { [key: string]: string; }; +export const LOCAL_CONFIG_FILENAME = ".enkryptify.json"; + type ConfigureOptions = { scope?: ConfigureScope; }; @@ -217,6 +219,74 @@ async function getSetupKey(projectPath: string, scope: ConfigureScope, gitRepo?: return repo.setupKey; } +async function readLocalConfigAt(dir: string): Promise { + const filePath = path.join(dir, LOCAL_CONFIG_FILENAME); + + let raw: string; + try { + raw = await fs.readFile(filePath, "utf-8"); + } catch (err: unknown) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return null; + } + throw new CLIError( + `Could not read the project file "${filePath}".`, + err instanceof Error ? err.message : String(err), + "Check the file's permissions and try again.", + "/cli/troubleshooting#configuration", + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err: unknown) { + throw new CLIError( + `The project file "${filePath}" contains invalid JSON.`, + err instanceof Error ? err.message : String(err), + "Fix the JSON in your .enkryptify.json file.", + "/cli/troubleshooting#configuration", + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new CLIError( + `The project file "${filePath}" is not valid.`, + "The file must contain a JSON object.", + "See https://docs.enkryptify.com for the .enkryptify.json format.", + "/cli/troubleshooting#configuration", + ); + } + + const obj = parsed as Record; + const workspace = obj.workspace; + const project = obj.project; + const environment = obj.environment; + + if ( + typeof workspace !== "string" || + workspace.length === 0 || + typeof project !== "string" || + project.length === 0 || + typeof environment !== "string" || + environment.length === 0 + ) { + throw new CLIError( + `The project file "${filePath}" is missing required fields.`, + 'It must define non-empty "workspace", "project" and "environment" fields (each may be a slug or an id).', + "See https://docs.enkryptify.com for the .enkryptify.json format.", + "/cli/troubleshooting#configuration", + ); + } + + return { + path: dir, + workspace_slug: workspace, + project_slug: project, + environment_id: environment, + }; +} + async function createConfigureWithOptions( projectPath: string, projectConfig: ProjectConfig, @@ -278,12 +348,16 @@ async function findProjectConfig(startPath: string): Promise { let currentPath = path.resolve(startPath); const root = path.parse(currentPath).root; + let aboveRepoRoot = false; - // Walk up from the start directory. A path-scoped setup at or below the - // repo root is more specific and wins. Once we reach the repo root, prefer - // the git-scoped setup over any ancestor path setup above the repo, so a - // git-configured repo is never shadowed by an unrelated parent directory. while (true) { + if (!aboveRepoRoot) { + const localConfig = await readLocalConfigAt(currentPath); + if (localConfig) { + return localConfig; + } + } + const setup = setups[currentPath]; if (setup) { return { path: currentPath, ...setup }; @@ -296,6 +370,8 @@ async function findProjectConfig(startPath: string): Promise { if (gitSetup) { return { path: gitRepo.setupKey, ...gitSetup }; } + // Parent directories from here up are outside the repository. + aboveRepoRoot = true; } } @@ -303,8 +379,6 @@ async function findProjectConfig(startPath: string): Promise { currentPath = path.dirname(currentPath); } - // Safety net: resolve the git setup even if the repo root wasn't on the - // resolved walk path (e.g. symlinked working directories). if (gitRepo) { const gitSetup = setups[gitRepo.setupKey]; if (gitSetup) { diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts index f294558..a10e344 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -80,9 +80,13 @@ export const FAKE_ENVIRONMENTS = [ export const FAKE_PROJECTS = [ { + id: "team-1", + name: "Test Team", projects: [ { id: "proj-1", name: "Test Project", slug: "test-project" }, { id: "proj-2", name: "Other Project", slug: "other-project" }, ], }, ]; + +export const FAKE_TEAMS = [{ id: "team-1", name: "Test Team", projectCount: 2, createdAt: new Date().toISOString() }]; diff --git a/tests/helpers/mock-api.ts b/tests/helpers/mock-api.ts index 22e5c9b..f486972 100644 --- a/tests/helpers/mock-api.ts +++ b/tests/helpers/mock-api.ts @@ -1,5 +1,5 @@ import { HttpResponse, http } from "msw"; -import { FAKE_API_SECRETS, FAKE_ENVIRONMENTS, FAKE_PROJECTS, FAKE_WORKSPACES } from "./fixtures"; +import { FAKE_API_SECRETS, FAKE_ENVIRONMENTS, FAKE_PROJECTS, FAKE_TEAMS, FAKE_WORKSPACES } from "./fixtures"; const API_BASE = "https://api.enkryptify.com"; @@ -14,11 +14,25 @@ export const handlers = [ return HttpResponse.json(FAKE_PROJECTS); }), + http.post(`${API_BASE}/v1/workspace/:slug/project`, async ({ request }) => { + const body = (await request.json()) as { name: string; slug: string }; + return HttpResponse.json({ id: "proj-created", name: body.name, slug: body.slug }, { status: 201 }); + }), + + // Teams + http.get(`${API_BASE}/v1/workspace/:slug/team`, () => { + return HttpResponse.json(FAKE_TEAMS); + }), + // Environments http.get(`${API_BASE}/v1/workspace/:wsSlug/project/:pSlug/environment`, () => { return HttpResponse.json(FAKE_ENVIRONMENTS); }), + http.post(`${API_BASE}/v1/workspace/:wsSlug/project/:pSlug/environment`, () => { + return HttpResponse.json({ success: true }); + }), + // Secrets (list + resolve) http.get(`${API_BASE}/v1/workspace/:wsSlug/project/:pSlug/secret`, () => { return HttpResponse.json(FAKE_API_SECRETS); diff --git a/tests/integration/config.test.ts b/tests/integration/config.test.ts index d641396..08cc725 100644 --- a/tests/integration/config.test.ts +++ b/tests/integration/config.test.ts @@ -372,4 +372,113 @@ describe("config (integration)", () => { const result = await config.getConfigure("/nonexistent/unknown/path"); expect(result).toBeNull(); }); + + // --- .enkryptify.json (committed, hand-written, per-directory project file) --- + + const writeLocalFile = (dir: string, data: Record) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ".enkryptify.json"), JSON.stringify(data), "utf-8"); + }; + + it("reads a committed .enkryptify.json in the current directory", async () => { + const dir = path.join(tmpDir, "apps", "app"); + writeLocalFile(dir, { workspace: "ws-app", project: "web", environment: "env-dev" }); + + const found = await config.findProjectConfig(dir); + expect(found.workspace_slug).toBe("ws-app"); + expect(found.project_slug).toBe("web"); + expect(found.environment_id).toBe("env-dev"); + expect(found.path).toBe(path.resolve(dir)); + }); + + it("accepts a slug or an id for each field and passes the value through verbatim", async () => { + const dir = path.join(tmpDir, "by-id"); + writeLocalFile(dir, { workspace: "ws_01H", project: "prj_02K", environment: "env_03M" }); + + const found = await config.findProjectConfig(dir); + expect(found.workspace_slug).toBe("ws_01H"); + expect(found.project_slug).toBe("prj_02K"); + expect(found.environment_id).toBe("env_03M"); + }); + + it("walks up to find a committed .enkryptify.json in a parent directory", async () => { + const appDir = path.join(tmpDir, "apps", "app"); + const deep = path.join(appDir, "src", "components"); + fs.mkdirSync(deep, { recursive: true }); + writeLocalFile(appDir, { workspace: "ws", project: "web", environment: "env" }); + + const found = await config.findProjectConfig(deep); + expect(found.workspace_slug).toBe("ws"); + expect(found.path).toBe(path.resolve(appDir)); + }); + + it("resolves separate .enkryptify.json files per monorepo app", async () => { + const appDir = path.join(tmpDir, "mono", "apps", "app"); + const apiDir = path.join(tmpDir, "mono", "apps", "api"); + writeLocalFile(appDir, { workspace: "ws", project: "web", environment: "env" }); + writeLocalFile(apiDir, { workspace: "ws", project: "api", environment: "env" }); + + const app = await config.findProjectConfig(appDir); + const api = await config.findProjectConfig(apiDir); + expect(app.project_slug).toBe("web"); + expect(api.project_slug).toBe("api"); + }); + + it("prefers a committed .enkryptify.json over a global path setup at the same directory", async () => { + const dir = path.join(tmpDir, "proj"); + fs.mkdirSync(dir, { recursive: true }); + await config.createConfigure(dir, { + path: dir, + workspace_slug: "ws-global", + project_slug: "global", + environment_id: "env-global", + }); + writeLocalFile(dir, { workspace: "ws-file", project: "file", environment: "env-file" }); + + const found = await config.findProjectConfig(dir); + expect(found.project_slug).toBe("file"); + }); + + it("prefers a committed .enkryptify.json in a subdir over a git-scoped setup", async () => { + const repoPath = path.join(tmpDir, "repo-local"); + const subdir = path.join(repoPath, "apps", "app"); + fs.mkdirSync(subdir, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + await config.createConfigure( + repoPath, + { path: repoPath, workspace_slug: "ws-git", project_slug: "git", environment_id: "env-git" }, + { scope: "git" }, + ); + writeLocalFile(subdir, { workspace: "ws-file", project: "app", environment: "env-file" }); + + const found = await config.findProjectConfig(subdir); + expect(found.project_slug).toBe("app"); + }); + + it("throws a clear error for a malformed .enkryptify.json", async () => { + const dir = path.join(tmpDir, "broken"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, ".enkryptify.json"), "{not json", "utf-8"); + + await expect(config.findProjectConfig(dir)).rejects.toThrow("invalid JSON"); + }); + + it("throws when .enkryptify.json is missing required fields", async () => { + const dir = path.join(tmpDir, "incomplete"); + writeLocalFile(dir, { workspace: "ws" }); + + await expect(config.findProjectConfig(dir)).rejects.toThrow("missing required fields"); + }); + + it("does not read a .enkryptify.json located above the repository root", async () => { + const ancestor = path.join(tmpDir, "ancestor-local"); + const repoPath = path.join(ancestor, "repo"); + const subdir = path.join(repoPath, "src"); + fs.mkdirSync(subdir, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoPath }); + // A project file above the repo root must be ignored. + writeLocalFile(ancestor, { workspace: "ws", project: "x", environment: "env" }); + + await expect(config.findProjectConfig(subdir)).rejects.toThrow("No project configured"); + }); }); diff --git a/tests/integration/import-command.test.ts b/tests/integration/import-command.test.ts new file mode 100644 index 0000000..c73d306 --- /dev/null +++ b/tests/integration/import-command.test.ts @@ -0,0 +1,154 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FAKE_ENVIRONMENTS, FAKE_TEAMS, FAKE_WORKSPACES } from "../helpers/fixtures"; + +vi.mock("@/lib/logger"); +vi.mock("@/lib/config"); +vi.mock("@/lib/input"); +vi.mock("@/ui/Confirm"); +vi.mock("@/ui/SelectItem"); +vi.mock("@/api/auth"); + +vi.mock("@/api/httpClient", () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +import http from "@/api/httpClient"; +import { client } from "@/api/client"; +import { importCommand } from "@/cmd/import"; +import { config } from "@/lib/config"; +import { getTextInput } from "@/lib/input"; +import { confirm } from "@/ui/Confirm"; +import { selectName } from "@/ui/SelectItem"; + +describe("import command", () => { + let tmpDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ek-import-")); + vi.spyOn(process, "cwd").mockReturnValue(tmpDir); + vi.mocked(config.isAuthenticated).mockResolvedValue(true); + vi.mocked(confirm).mockResolvedValue(false); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("imports the default .env file into the selected target", async () => { + await fs.writeFile(path.join(tmpDir, ".env"), "DATABASE_URL=postgres://localhost\nAPI_KEY=secret-value\n"); + + vi.mocked(http.get).mockImplementation((url: string) => { + if (url === "/v1/workspace") return Promise.resolve({ data: [FAKE_WORKSPACES[0]] }); + if (url === "/v1/workspace/test-workspace/project") { + return Promise.resolve({ + data: [ + { + id: "team-1", + name: "Test Team", + projects: [{ id: "proj-1", name: "Test Project", slug: "test-project" }], + }, + ], + }); + } + if (url === "/v1/workspace/test-workspace/project/test-project/environment") { + return Promise.resolve({ data: [FAKE_ENVIRONMENTS[0]] }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + vi.mocked(http.post).mockResolvedValue({ data: { success: true } }); + + const result = await importCommand(); + + expect(result).toEqual({ workspace_slug: "test-workspace", imported: 2 }); + expect(selectName).not.toHaveBeenCalled(); + expect(http.post).toHaveBeenCalledWith("/v1/workspace/test-workspace/project/test-project/secret", { + environments: ["env-test-123"], + secrets: [ + { key: "DATABASE_URL", value: "postgres://localhost", type: "runtime", dataType: "text" }, + { key: "API_KEY", value: "secret-value", type: "runtime", dataType: "text" }, + ], + }); + await expect(fs.access(path.join(tmpDir, ".env"))).resolves.toBeUndefined(); + }); + + it("deletes the source file after a successful import when confirmed", async () => { + const envPath = path.join(tmpDir, "custom.env"); + await fs.writeFile(envPath, "API_KEY=secret-value\n"); + vi.mocked(confirm).mockResolvedValue(true); + + vi.mocked(http.get).mockImplementation((url: string) => { + if (url === "/v1/workspace") return Promise.resolve({ data: [FAKE_WORKSPACES[0]] }); + if (url === "/v1/workspace/test-workspace/project") { + return Promise.resolve({ + data: [{ projects: [{ id: "proj-1", name: "Test Project", slug: "test-project" }] }], + }); + } + if (url === "/v1/workspace/test-workspace/project/test-project/environment") { + return Promise.resolve({ data: [FAKE_ENVIRONMENTS[0]] }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + vi.mocked(http.post).mockResolvedValue({ data: { success: true } }); + + await importCommand("custom.env"); + + await expect(fs.access(envPath)).rejects.toThrow(); + }); + + it("can create a project and environment while selecting an import target", async () => { + let environmentCreated = false; + vi.mocked(http.get).mockImplementation((url: string) => { + if (url === "/v1/workspace") return Promise.resolve({ data: [FAKE_WORKSPACES[0]] }); + if (url === "/v1/workspace/test-workspace/project") return Promise.resolve({ data: [] }); + if (url === "/v1/workspace/test-workspace/team") return Promise.resolve({ data: FAKE_TEAMS }); + if (url === "/v1/workspace/test-workspace/project/new-project/environment") { + return Promise.resolve({ data: environmentCreated ? [{ id: "env-new", name: "preview" }] : [] }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + vi.mocked(http.post).mockImplementation((url: string) => { + if (url === "/v1/workspace/test-workspace/project") { + return Promise.resolve({ data: { id: "proj-new", name: "New Project", slug: "new-project" } }); + } + if (url === "/v1/workspace/test-workspace/project/new-project/environment") { + environmentCreated = true; + return Promise.resolve({ data: { success: true } }); + } + if (url === "/v1/workspace/test-workspace/project/new-project/secret") { + return Promise.resolve({ data: { success: true } }); + } + return Promise.reject(new Error(`Unexpected URL: ${url}`)); + }); + vi.mocked(getTextInput) + .mockResolvedValueOnce("New Project") + .mockResolvedValueOnce("new-project") + .mockResolvedValueOnce("preview"); + + const target = await client.selectImportTarget(tmpDir); + await client.importSecrets(target.config, [{ key: "API_KEY", value: "secret-value" }]); + + expect(http.post).toHaveBeenCalledWith("/v1/workspace/test-workspace/project", { + name: "New Project", + slug: "new-project", + teamId: "team-1", + }); + expect(http.post).toHaveBeenCalledWith("/v1/workspace/test-workspace/project/new-project/environment", { + name: "preview", + hasPersonalOverrides: false, + }); + expect(http.post).toHaveBeenCalledWith("/v1/workspace/test-workspace/project/new-project/secret", { + environments: ["env-new"], + secrets: [{ key: "API_KEY", value: "secret-value", type: "runtime", dataType: "text" }], + }); + }); +}); diff --git a/tests/unit/import.test.ts b/tests/unit/import.test.ts new file mode 100644 index 0000000..f454382 --- /dev/null +++ b/tests/unit/import.test.ts @@ -0,0 +1,69 @@ +import { promises as fs } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { parseDotenvContent } from "@/cmd/import"; + +describe("parseDotenvContent", () => { + it("parses common dotenv syntax", () => { + const secrets = parseDotenvContent(` +# Comment +PLAIN=value +WITH_EXPORT=one +export EXPORTED=two +DOUBLE="hello\\nworld" +SINGLE='hello # world' +INLINE=value # comment +URL=https://example.com/#hash +`); + + expect(secrets).toEqual([ + { key: "PLAIN", value: "value" }, + { key: "WITH_EXPORT", value: "one" }, + { key: "EXPORTED", value: "two" }, + { key: "DOUBLE", value: "hello\nworld" }, + { key: "SINGLE", value: "hello # world" }, + { key: "INLINE", value: "value" }, + { key: "URL", value: "https://example.com/#hash" }, + ]); + }); + + it("parses multiline quoted values", () => { + const secrets = parseDotenvContent(`PRIVATE_KEY="-----BEGIN KEY----- +abc +-----END KEY-----" +JSON_VALUE="{ +"type": "service_account", +"private_key": "line one\\nline two" +}"`); + + expect(secrets).toEqual([ + { key: "PRIVATE_KEY", value: "-----BEGIN KEY-----\nabc\n-----END KEY-----" }, + { + key: "JSON_VALUE", + value: '{\n"type": "service_account",\n"private_key": "line one\nline two"\n}', + }, + ]); + }); + + it("parses the provided sample env file", async () => { + const content = await fs.readFile("/Users/siebebaree/Documents/Enkryptify/test.env", "utf8"); + const secrets = parseDotenvContent(content); + + expect(secrets).toHaveLength(13); + expect(secrets.find((secret) => secret.key === "SYNC_GITHUB_PRIVATE_KEY")?.value).toContain( + "-----BEGIN RSA PRIVATE KEY-----", + ); + expect(secrets.find((secret) => secret.key === "GOOGLE_JSON")?.value).toContain('"type": "service_account"'); + }); + + it("rejects malformed lines", () => { + expect(() => parseDotenvContent("NOT VALID")).toThrow("Could not parse .env line 1"); + }); + + it("rejects duplicate keys", () => { + expect(() => parseDotenvContent("API_KEY=one\nAPI_KEY=two")).toThrow('Duplicate secret "API_KEY"'); + }); + + it("rejects empty values because the API cannot create them", () => { + expect(() => parseDotenvContent('EMPTY_QUOTED=""')).toThrow('Secret "EMPTY_QUOTED" has an empty value'); + }); +}); From 5ddba5f0b512be29b2ac3105f111dd8c84426d9f Mon Sep 17 00:00:00 2001 From: SiebeBaree Date: Fri, 29 May 2026 16:34:09 +0200 Subject: [PATCH 2/2] fix: coderabbit changes --- tests/fixtures/test.env | 50 +++++++++++++++++++++++++++++++++++++++ tests/unit/import.test.ts | 6 ++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/test.env diff --git a/tests/fixtures/test.env b/tests/fixtures/test.env new file mode 100644 index 0000000..4b96a28 --- /dev/null +++ b/tests/fixtures/test.env @@ -0,0 +1,50 @@ +# App +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NODE_ENV="development" + +# Database (PostgreSQL) +DATABASE_URL=postgresql://dev_user:dev_password_123@localhost:5432/myapp_dev + +# OAuth (Google) +GOOGLE_CLIENT_ID=1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=GOCSPX-abcdefghijklmnopqrstu123456 + +# Stripe (test keys) +STRIPE_PUBLIC_KEY=pk_test_51NabcXYZ1234567890abcdefghijklmnop +STRIPE_SECRET_KEY=sk_test_51NabcXYZ1234567890qrstuvwxyzabcdef + +# Email (SMTP) +SMTP_HOST="smtp.mailtrap.io" +SMTP_PORT=2525 +SMTP_USER="dev_mail_user" +SMTP_PASS="dev_mail_password" + +SYNC_GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCSBQnoAJLz0pvH03FH4OlIWyT2quFHH/cB346Zwajy/qPQiVgz +CvIU2AIlUgrpiGPG4gfF31Z4JNb2FxbrBk51LB5ujqLwAy9cSFw6lZsT4N6B9unB +HxwFSQKa0LGy7jmpmMEbhb9OjThSJdfaRayYIzP7Ks1GVXry40HLgdBd9wIDAQAB +AoGAOoYweT85puXjsAyMG64Gcgyt3PuSPSqPmr2abv/SkkoDKELUCek6K6aD/adL +Xlxe+UiAqwsfohnJ2mpYsiwd53Pp4Ytiq0bE4fgQWhoXkRiDKCxNQi3fjTFctgTS +w7lJlSpRYyvwt2GO+DI6m9bgakcff+we9CmCJYfnQu+XbaECQQDwm0LQQhZl1B0L +efJoEBXaF7CXqIEBAWxnEA7IM0lhv7XqRoQgkNomVLW6qiQX8c+UOGOJgx1jX79F +gdf2oKfRAkEAm1yZ6R9cGbv1tsjc0hRNhj411IFxcPA4fC4QAl2aCPDAhoE+QSa+ +D12xUnHnerEdI7DC9Hu8RcXzzua5B5xjRwJACiTRlUwj+5kMvG4gvShoc3BqPoqZ +Hby8oD+6D9CxuFBH0B+29FSHDfSmUL9ZlDTapooWEDcZ5xWjT7/gpgIx8QJARgoM +FM2fbraOwlVxP4AJpxBaoi+s3ZZeUJVPgFGERK2MjdasIyD9I95AN4PEMEqycUmZ +yjASI35nOIpJNgYptwJBAIptc5uUWuNHiyWBpOTaPQif1Cm5I+TZr860AcAMr19g +5GYbAEPxNtjL0LLY4XvkhV/6m3mRjSXmfE8qpGjrHso= +-----END RSA PRIVATE KEY-----" + +GOOGLE_JSON="{ +"type": "service_account", +"project_id": "enkryptify", +"private_key_id": "de98uq8uf32d9pd9u0d2u0", +"private_key": "-----BEGIN RSA PRIVATE KEY----\nMIICXQIBAAKBgQCau6TMXtk8t/gSOCCFBB+p67CpLKO86nI7yhPpPWpHrcyruVf0\neAzW1F69L3fTWut1kWSbbmD8lB0bNkiK7hSZTMOgY+4dnToNa7PvJYewrXpVbMCI\nhxaeb+r8cOCD/l2MBy7nxEByNGgzq01rHjnBe46j01+4/gjLlSQsu751BwIDAQAB\nAoGBAJOg9FsJI7sKy2SWqHBAvvUgKi0+qfpUtSjWZKjkL8Kzz9MHwyM8ZwORG9Lk\nty6wUHabgaHKbj6OEjckU5I6FOuxK/5yWh4aqTczcJLwAJC5tpTcGeRU00LBmo/w\ndnoICUmqV5A5cBbraVnZsFOKE/JmjvAVXGFdmseWeA3Weh4BAkEA2j8E7NEWMqTy\nLnBvr5vxzhxsWAOTPuO2gBmv2YDag1N0DR/gqzPR2d1lgHvTfHklUujfuVCnQ57f\n8lkPXD6u3QJBALV/9TdXIao5lgMxZFao7QM8DKPgEWO+/mK6K0TPHIQyX4+KM9W1\nCuLLgR52OM0WTp8T/I0Jmsrv7QGmBTUEqzMCQQCTkHisBtOTeqOlcrCRdEeSrPU0\nrXYp153WD4gu8EjO8uZM2Xj3SRpizKeMsCzWxLLP1FUw36+4sPuKyVzxahChAkBm\ndd7zp8+Mbkfec5KmTWTHj62/EW4ftiGbkGd+x8DcbCeAO8+5VCPaFnJExQ6Z0H7/\n1OOcpxBogft1E8kavhD7AkAzzjzJ0nAz/NidaW3X0vhMUGA3uuMkv5OY+LfQO6uB\nw1Lm74zjvysE8WPwBO6cHgSAdo6ki1emO0LVrDIswtAC\n-----END RSA PRIVATE KEY-----\n", +"client_email": "test@enkryptify.iam.gserviceaccount.com", +"client_id": "9384089439085439", +"auth_uri": "https://accounts.google.com/o/oauth2/auth", +"token_uri": "https://oauth2.googleapis.com/token", +"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", +"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test%40enkryptify.iam.gserviceaccount.com", +"universe_domain": "googleapis.com" +}" diff --git a/tests/unit/import.test.ts b/tests/unit/import.test.ts index f454382..a1252ac 100644 --- a/tests/unit/import.test.ts +++ b/tests/unit/import.test.ts @@ -1,7 +1,11 @@ import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parseDotenvContent } from "@/cmd/import"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + describe("parseDotenvContent", () => { it("parses common dotenv syntax", () => { const secrets = parseDotenvContent(` @@ -45,7 +49,7 @@ JSON_VALUE="{ }); it("parses the provided sample env file", async () => { - const content = await fs.readFile("/Users/siebebaree/Documents/Enkryptify/test.env", "utf8"); + const content = await fs.readFile(path.join(__dirname, "../fixtures/test.env"), "utf8"); const secrets = parseDotenvContent(content); expect(secrets).toHaveLength(13);