Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 73 additions & 71 deletions src/api/auth.ts

Large diffs are not rendered by default.

34 changes: 31 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export type RunOptions = {
[key: string]: string | undefined;
};

// Result of the interactive configure flow. The explicit status lets the
// caller honor the user's choice instead of inferring intent from a raw
// ProjectConfig: "configured" means a new/overwritten setup was built and
// should be persisted; "kept" means the user declined to change anything and
// nothing should be persisted.
export type ConfigureOutcome = {
status: "configured" | "kept";
scope: ConfigureScope;
config: ProjectConfig;
};

type Workspace = {
id: string;
name: string;
Expand Down Expand Up @@ -98,12 +109,29 @@ class EnkryptifyClient {
await this.auth.login(options);
}

async configure(options: string, configureOptions?: { scope?: ConfigureScope }): Promise<ProjectConfig> {
async configure(options: string, configureOptions?: { scope?: ConfigureScope }): Promise<ConfigureOutcome> {
const scope = configureOptions?.scope ?? "path";
const setup = await config.getConfigure(options, configureOptions);
if (setup) {
const overwrite = await confirm("Setup already exists. Overwrite?");
if (!overwrite) {
return setup;
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(
"A path-only setup already exists for this directory. Replace it with a Git-repository setup?",
);
if (!replace) {
// The user declined: keep the existing path setup untouched
// and report the effective scope so nothing is persisted.
return { status: "kept", scope: "path", config: pathSetup };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -238,7 +266,7 @@ class EnkryptifyClient {
`Setup completed successfully! Workspace: ${selectedWorkspace.name}, Project: ${selectedProject.name}, Environment: ${environmentName}`,
);

return projectConfig;
return { status: "configured", scope, config: projectConfig };
}

async run(config: ProjectConfig, options?: RunOptions): Promise<Secret[]> {
Expand Down
21 changes: 17 additions & 4 deletions src/cmd/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,20 @@ const GIT_SCOPE_LABEL = "Git repository (recommended)";
const PATH_SCOPE_LABEL = "This path only";

async function resolveConfigureScope(projectPath: string, options: ConfigureCommandOptions): Promise<ConfigureScope> {
const gitRepo = await getGitRepoInfo(projectPath);

if (options.git) {
if (!gitRepo) {
throw new CLIError(
"No Git repository found.",
"The current directory is not inside a Git repository.",
'Run "ek configure" without --git to set up this path, or run it from inside a Git repository.',
"/cli/troubleshooting#configuration",
);
}
return "git";
}

const gitRepo = await getGitRepoInfo(projectPath);
if (!gitRepo) {
return "path";
}
Expand All @@ -41,11 +50,15 @@ export async function configure(options: ConfigureCommandOptions = {}): Promise<
const projectPath = process.cwd();
const scope = await resolveConfigureScope(projectPath, options);

const projectConfig = await client.configure(projectPath, { scope });
const outcome = await client.configure(projectPath, { scope });

await config.createConfigure(projectPath, projectConfig, { scope });
// Only persist when a new/overwritten setup was actually built. When the
// user declined to change anything ("kept"), leave the existing config as-is.
if (outcome.status === "configured") {
await config.createConfigure(projectPath, outcome.config, { scope });
}

return projectConfig;
return outcome.config;
}

export function registerConfigureCommand(program: Command) {
Expand Down
75 changes: 61 additions & 14 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CLIError } from "@/lib/errors";
import { getGitRepoInfo } from "@/lib/git";
import { type GitRepoInfo, getGitRepoInfo } from "@/lib/git";
import { logger } from "@/lib/logger";
import * as fs from "fs/promises";
import * as os from "os";
Expand Down Expand Up @@ -197,13 +197,15 @@ async function isAuthenticated(): Promise<boolean> {
return config.providers?.["enkryptify"] != null;
}

async function getSetupKey(projectPath: string, scope: ConfigureScope): Promise<string> {
// `gitRepo` may be passed in to avoid re-running git when the caller already
// resolved it. `undefined` means "not resolved yet", `null` means "resolved, not a repo".
async function getSetupKey(projectPath: string, scope: ConfigureScope, gitRepo?: GitRepoInfo | null): Promise<string> {
if (scope === "path") {
return path.resolve(projectPath);
}

const gitRepo = await getGitRepoInfo(projectPath);
if (!gitRepo) {
const repo = gitRepo === undefined ? await getGitRepoInfo(projectPath) : gitRepo;
if (!repo) {
throw new CLIError(
"No Git repository found.",
"The current directory is not inside a Git repository.",
Expand All @@ -212,7 +214,7 @@ async function getSetupKey(projectPath: string, scope: ConfigureScope): Promise<
);
}

return gitRepo.setupKey;
return repo.setupKey;
}

async function createConfigureWithOptions(
Expand All @@ -221,13 +223,32 @@ async function createConfigureWithOptions(
options: ConfigureOptions = {},
): Promise<void> {
const config = await loadConfig();
const setupKey = await getSetupKey(projectPath, options.scope ?? "path");
const scope = options.scope ?? "path";
const gitRepo = await getGitRepoInfo(projectPath);
const setupKey = await getSetupKey(projectPath, scope, gitRepo);

if (!config.setups) config.setups = {};

const { path: _, ...setupData } = projectConfig;
config.setups[setupKey] = setupData;

// When switching this directory to Git scope, drop the now-redundant
// path-only setup for the same directory. Otherwise the path entry would
// shadow the Git setup in findProjectConfig and the switch would silently
// have no effect. We only do this for the same directory, so unrelated
// nested path setups elsewhere in the repo are preserved. Both the resolved
// and the symlink-canonical path are removed so a stale setup saved under a
// symlinked alias (e.g. /var vs /private/var) is also cleared.
if (scope === "git") {
const pathKey = path.resolve(projectPath);
const realPathKey = await fs.realpath(projectPath).catch(() => pathKey);
for (const key of new Set([pathKey, realPathKey])) {
if (key !== setupKey && config.setups[key]) {
delete config.setups[key];
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

await saveConfig(config);
}

Expand All @@ -245,23 +266,49 @@ async function hasAnyProject(): Promise<boolean> {

async function findProjectConfig(startPath: string): Promise<ProjectConfig> {
const config = await loadConfig();
const setups = config.setups ?? {};
const gitRepo = await getGitRepoInfo(startPath);

// Resolve the repo root through symlinks so the boundary comparison below
// is reliable (e.g. macOS /var vs /private/var).
let repoRootReal: string | null = null;
if (gitRepo) {
repoRootReal = await fs.realpath(gitRepo.root).catch(() => path.resolve(gitRepo.root));
}

let currentPath = path.resolve(startPath);
const root = path.parse(currentPath).root;

while (currentPath !== root) {
const normalizedPath = path.resolve(currentPath);
const setup = config.setups?.[normalizedPath];
// 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) {
const setup = setups[currentPath];
if (setup) {
return { path: normalizedPath, ...setup };
return { path: currentPath, ...setup };
}

if (gitRepo && repoRootReal) {
const currentReal = await fs.realpath(currentPath).catch(() => currentPath);
if (currentReal === repoRootReal) {
const gitSetup = setups[gitRepo.setupKey];
if (gitSetup) {
return { path: gitRepo.setupKey, ...gitSetup };
}
}
}

if (currentPath === root) break;
currentPath = path.dirname(currentPath);
}

const gitRepo = await getGitRepoInfo(startPath);
// 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 setup = config.setups?.[gitRepo.setupKey];
if (setup) {
return { path: gitRepo.setupKey, ...setup };
const gitSetup = setups[gitRepo.setupKey];
if (gitSetup) {
return { path: gitRepo.setupKey, ...gitSetup };
}
}

Expand Down
17 changes: 2 additions & 15 deletions src/lib/secretCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ function buildCacheKey(workspaceSlug: string, projectSlug: string, environmentKe
return `${CACHE_KEY_PREFIX}${workspaceSlug}/${projectSlug}/${environmentKey}`;
}

function decode(encoded: string): CacheEntry | null {
try {
const json = atob(encoded);
return JSON.parse(json) as CacheEntry;
} catch {
return null;
}
}

async function readCache(key: string): Promise<CacheEntry | null> {
try {
return await secureStore.getSecretCacheEntry(key);
Expand All @@ -54,10 +45,6 @@ async function writeCache(key: string, secrets: Secret[]): Promise<void> {
}
}

async function readLegacyCache(key: string): Promise<CacheEntry | null> {
return await secureStore.migrateLegacySecretCacheEntry(key, decode);
}

export async function fetchSecretsWithCache(
config: ProjectConfig,
runOptions: { env?: string; project?: string },
Expand Down Expand Up @@ -100,7 +87,7 @@ export async function fetchSecretsWithCache(

// --offline: use cache only, never fetch
if (cacheOptions.offline) {
const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey));
const cached = await readCache(cacheKey);
if (!cached) {
throw new CLIError(
"No cached secrets available.",
Expand All @@ -112,7 +99,7 @@ export async function fetchSecretsWithCache(
}

// Normal mode: check TTL, fetch if stale, fallback to cache on error
const cached = (await readCache(cacheKey)) ?? (await readLegacyCache(cacheKey));
const cached = await readCache(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return { secrets: cached.secrets, fromCache: true, cacheReason: "ttl" };
}
Expand Down
26 changes: 0 additions & 26 deletions src/lib/secureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,30 +138,4 @@ export const secureStore = {
};
});
},

async migrateLegacySecretCacheEntry(
cacheKey: string,
decode: (raw: string) => StoredSecretCacheEntry | null,
): Promise<StoredSecretCacheEntry | null> {
try {
const raw = await keyring.get(cacheKey);
if (!raw) return null;

const entry = decode(raw);
if (!entry) return null;

await updateStore((store) => {
store.secretCache = {
...(store.secretCache ?? {}),
[cacheKey]: entry,
};
});

await keyring.delete(cacheKey).catch(() => undefined);

return entry;
} catch {
return null;
}
},
};
67 changes: 67 additions & 0 deletions tests/integration/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,73 @@ describe("config (integration)", () => {
expect(found.path).toBe(path.resolve(childPath));
});

it("does not let an ancestor path setup shadow a git-scoped repo", async () => {
const ancestorPath = path.join(tmpDir, "ancestor");
const repoPath = path.join(ancestorPath, "repo");
const subdirPath = path.join(repoPath, "src");
fs.mkdirSync(subdirPath, { recursive: true });
execFileSync("git", ["init"], { cwd: repoPath });

// Ancestor directory configured with path scope.
await config.createConfigure(ancestorPath, {
path: ancestorPath,
workspace_slug: "ws-ancestor",
project_slug: "proj-ancestor",
environment_id: "env-ancestor",
});

// The repo itself configured with git scope.
await config.createConfigure(
repoPath,
{
path: repoPath,
workspace_slug: "ws-git",
project_slug: "proj-git",
environment_id: "env-git",
},
{ scope: "git" },
);

const found = await config.findProjectConfig(subdirPath);
expect(found.workspace_slug).toBe("ws-git");
expect(found.path).toMatch(/^git:/);
});

it("switching a directory from path to git scope removes the stale path setup", async () => {
const repoPath = path.join(tmpDir, "switch-scope");
fs.mkdirSync(repoPath, { recursive: true });
execFileSync("git", ["init"], { cwd: repoPath });

// First configured as path scope.
await config.createConfigure(repoPath, {
path: repoPath,
workspace_slug: "ws-path",
project_slug: "proj-path",
environment_id: "env-path",
});

// Re-configured as git scope for the same directory.
await config.createConfigure(
repoPath,
{
path: repoPath,
workspace_slug: "ws-git",
project_slug: "proj-git",
environment_id: "env-git",
},
{ scope: "git" },
);

const stored = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const keys = Object.keys(stored.setups);
expect(keys).toHaveLength(1);
expect(keys[0]).toMatch(/^git:/);

const found = await config.findProjectConfig(repoPath);
expect(found.workspace_slug).toBe("ws-git");
expect(found.path).toMatch(/^git:/);
});

it("throws CLIError when no config found", async () => {
const randomPath = path.join(tmpDir, "no-config-here");
fs.mkdirSync(randomPath, { recursive: true });
Expand Down
Loading
Loading