relic 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relic",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "The Relic CLI for managing and sharing secrets. Encrypted on your device, never exposed to anyone else. Not even us.",
5
5
  "keywords": [
6
6
  "cli",
@@ -16,10 +16,12 @@
16
16
  "module": "index.ts",
17
17
  "type": "module",
18
18
  "bin": {
19
- "relic": "index.ts"
19
+ "relic": "dist/cli.js"
20
20
  },
21
21
  "scripts": {
22
+ "build": "bun run scripts/build.ts",
22
23
  "build:runner": "cd ../../packages/runner && cargo build --release",
24
+ "prepublishOnly": "bun run build",
23
25
  "dev:cli": "DEV=true bun run build:runner && bun run index.ts",
24
26
  "log:watch": "bun run ../../packages/logger/scripts/watch.ts",
25
27
  "lint": "biome check --write .",
@@ -28,36 +30,34 @@
28
30
  "test": "bun test"
29
31
  },
30
32
  "files": [
31
- "index.ts",
32
- "commands/**",
33
- "lib/**",
34
- "ffi/**",
33
+ "dist/**",
35
34
  "prebuilds/**",
36
35
  "README.md"
37
36
  ],
38
- "devDependencies": {
39
- "@repo/typescript-config": "*",
40
- "@types/bun": "latest",
41
- "bun-types": "^1.3.1"
42
- },
43
- "peerDependencies": {
44
- "typescript": "^5"
45
- },
46
- "engines": {
47
- "node": ">=18"
48
- },
49
37
  "dependencies": {
38
+ "argon2": "^0.41.1"
39
+ },
40
+ "devDependencies": {
50
41
  "@clack/prompts": "^0.11.0",
51
42
  "@repo/auth": "*",
52
43
  "@repo/backend": "*",
53
44
  "@repo/crypto": "*",
54
45
  "@repo/logger": "*",
55
46
  "@repo/tui": "*",
47
+ "@repo/typescript-config": "*",
48
+ "@types/bun": "latest",
49
+ "bun-types": "^1.3.1",
56
50
  "commander": "^13.1.0",
57
51
  "convex": "catalog:",
58
52
  "open": "^11.0.0",
59
53
  "ora": "^8.2.0",
60
54
  "picocolors": "^1.1.1",
61
55
  "smol-toml": "^1.6.0"
56
+ },
57
+ "peerDependencies": {
58
+ "typescript": "^5"
59
+ },
60
+ "engines": {
61
+ "node": ">=18"
62
62
  }
63
63
  }
package/commands/init.ts DELETED
@@ -1,105 +0,0 @@
1
- import * as p from "@clack/prompts";
2
- import { AuthenticationError, validateSession } from "@repo/auth";
3
- import { createLogger, trackEvent } from "@repo/logger";
4
- import pc from "picocolors";
5
-
6
- const log = createLogger("cli");
7
-
8
- import { getApi, type ProjectListItem } from "../lib/api";
9
- import {
10
- configExists,
11
- createConfig,
12
- createRelicDir,
13
- getConfigFilePath,
14
- saveConfig,
15
- } from "../lib/config";
16
-
17
- interface ProjectOption extends ProjectListItem {
18
- isShared: boolean;
19
- }
20
-
21
- export default async function init() {
22
- p.intro(pc.bgCyan(pc.black(" relic init ")));
23
-
24
- if (await configExists()) {
25
- p.log.warn(pc.yellow("Already initialized"));
26
- p.outro(pc.dim("Delete relic.toml to re-initialize"));
27
- return;
28
- }
29
-
30
- const spinner = p.spinner();
31
- spinner.start("Checking authentication...");
32
-
33
- const sessionValidation = await validateSession();
34
- if (!sessionValidation.isValid || sessionValidation.isExpired) {
35
- spinner.stop("Not logged in");
36
- p.log.error(pc.yellow("Not logged in"));
37
- p.outro(pc.dim("Run `relic login` to authenticate"));
38
- process.exit(1);
39
- }
40
-
41
- spinner.message("Loading projects...");
42
-
43
- try {
44
- const api = getApi();
45
- const [ownedProjects, sharedProjects] = await Promise.all([
46
- api.listProjects(),
47
- api.listSharedProjects(),
48
- ]);
49
-
50
- const allProjects: ProjectOption[] = [
51
- ...ownedProjects.filter((p) => !p.isArchived).map((p) => ({ ...p, isShared: false })),
52
- ...sharedProjects.filter((p) => !p.isArchived).map((p) => ({ ...p, isShared: true })),
53
- ];
54
-
55
- spinner.stop("Projects loaded");
56
-
57
- if (allProjects.length === 0) {
58
- p.log.warn(pc.yellow("No projects found. Run `relic` to create a new project"));
59
- process.exit(1);
60
- }
61
-
62
- const selectedProjectId = await p.select({
63
- message: "Select a project",
64
- options: allProjects.map((project) => ({
65
- value: project.id,
66
- label: project.isShared ? `${project.name} ${pc.dim("(shared)")}` : project.name,
67
- })),
68
- });
69
-
70
- if (p.isCancel(selectedProjectId)) {
71
- p.cancel("Operation cancelled");
72
- process.exit(0);
73
- }
74
-
75
- const selectedProject = allProjects.find((p) => p.id === selectedProjectId);
76
- if (!selectedProject) {
77
- p.log.error(pc.red("Failed to find selected project"));
78
- process.exit(1);
79
- }
80
-
81
- spinner.start("Saving configuration...");
82
- const config = createConfig(selectedProject.id);
83
- await saveConfig(config);
84
- await createRelicDir();
85
- spinner.stop("Configuration saved");
86
-
87
- trackEvent("cli_project_initialized", { success: true });
88
- p.log.success(pc.green(`Initialized Relic for ${selectedProject.name}`));
89
- p.outro(pc.dim(`Config saved to ${getConfigFilePath()}`));
90
- } catch (err) {
91
- spinner.stop("Failed");
92
-
93
- if (err instanceof AuthenticationError) {
94
- p.log.error(pc.yellow("Not logged in"));
95
- p.outro(pc.dim("Run `relic login` to authenticate"));
96
- process.exit(1);
97
- }
98
-
99
- log.error("Init failed", err);
100
- trackEvent("cli_project_initialized", { success: false });
101
- const message = err instanceof Error ? err.message : "Failed to initialize";
102
- p.log.error(pc.red(`Error: ${message}`));
103
- process.exit(1);
104
- }
105
- }
package/commands/login.ts DELETED
@@ -1,85 +0,0 @@
1
- import {
2
- type DeviceAuthStatus,
3
- type DeviceCodeResponse,
4
- deviceAuth,
5
- validateSession,
6
- } from "@repo/auth";
7
- import { createLogger, trackEvent } from "@repo/logger";
8
- import ora from "ora";
9
- import pc from "picocolors";
10
-
11
- const log = createLogger("cli");
12
-
13
- function formatCode(code: string): string {
14
- if (code.includes("-")) return code;
15
- if (code.length === 8) return `${code.slice(0, 4)}-${code.slice(4)}`;
16
- return code;
17
- }
18
-
19
- export default async function login() {
20
- const spinner = ora("Connecting to server...").start();
21
-
22
- try {
23
- // Check if already logged in
24
- const sessionValidation = await validateSession();
25
- if (sessionValidation.isValid && !sessionValidation.isExpired) {
26
- spinner.succeed(pc.green("Already logged in"));
27
- return;
28
- }
29
-
30
- trackEvent("cli_login_started");
31
-
32
- let userCode: string | null = null;
33
- let verificationUri: string | null = null;
34
- let currentStatus: DeviceAuthStatus | "starting" | "approved" = "starting";
35
-
36
- const result = await deviceAuth.startAuth({
37
- onCodeReceived: (code: DeviceCodeResponse) => {
38
- userCode = code.user_code;
39
- verificationUri = code.verification_uri_complete;
40
-
41
- spinner.stop();
42
- console.log();
43
- console.log("Your verification code:");
44
- console.log();
45
- console.log(` ${pc.bold(pc.green(formatCode(userCode)))}`);
46
- console.log();
47
- if (verificationUri) {
48
- console.log(pc.dim(`Opening browser to: ${verificationUri}`));
49
- }
50
- console.log();
51
- spinner.start("Waiting for authorization...");
52
- },
53
- onStatusChange: (newStatus: DeviceAuthStatus) => {
54
- currentStatus = newStatus;
55
- },
56
- onSuccess: () => {
57
- currentStatus = "approved";
58
- },
59
- onError: (error: Error) => {
60
- spinner.fail(pc.red(`Error: ${error.message}`));
61
- process.exit(1);
62
- },
63
- });
64
-
65
- if (result.success) {
66
- trackEvent("cli_login_completed", { success: true });
67
- spinner.succeed(pc.green("Login successful!"));
68
- } else if (result.error) {
69
- trackEvent("cli_login_completed", { success: false, reason: currentStatus });
70
- if ((currentStatus as string) === "denied") {
71
- spinner.fail(pc.red("Authorization denied"));
72
- } else if ((currentStatus as string) === "expired") {
73
- spinner.fail(pc.red("Code expired. Please try again."));
74
- } else {
75
- spinner.fail(pc.red(`Error: ${result.error.message}`));
76
- }
77
- process.exit(1);
78
- }
79
- } catch (err) {
80
- log.error("Login failed", err);
81
- trackEvent("cli_login_completed", { success: false });
82
- spinner.fail(pc.red(err instanceof Error ? err.message : String(err)));
83
- process.exit(1);
84
- }
85
- }
@@ -1,39 +0,0 @@
1
- import {
2
- clearCachedUserKeys,
3
- clearPassword,
4
- clearSession,
5
- getUserKeyCacheDb,
6
- validateSession,
7
- } from "@repo/auth";
8
- import { createLogger, trackEvent } from "@repo/logger";
9
- import ora from "ora";
10
- import pc from "picocolors";
11
-
12
- const log = createLogger("cli");
13
-
14
- export default async function logout() {
15
- const spinner = ora("Checking session...").start();
16
-
17
- try {
18
- const session = await validateSession();
19
-
20
- if (!session.isValid) {
21
- spinner.warn(pc.yellow("Not logged in"));
22
- return;
23
- }
24
-
25
- spinner.text = "Logging out...";
26
- const userKeyDb = await getUserKeyCacheDb();
27
- clearCachedUserKeys(userKeyDb);
28
- await clearSession();
29
- await clearPassword();
30
- trackEvent("cli_logout", { success: true });
31
- spinner.succeed(pc.green("Logged out"));
32
- } catch (err) {
33
- log.error("Logout failed", err);
34
- trackEvent("cli_logout", { success: false });
35
- const message = err instanceof Error ? err.message : "Failed to logout";
36
- spinner.fail(pc.red(`Error: ${message}`));
37
- process.exit(1);
38
- }
39
- }
@@ -1,154 +0,0 @@
1
- import { validateSession } from "@repo/auth";
2
- import { trackEvent } from "@repo/logger";
3
- import ora from "ora";
4
- import pc from "picocolors";
5
- import { type Environment, type Folder, getApi, type ProjectListItem } from "../lib/api";
6
-
7
- interface ProjectWithDetails extends ProjectListItem {
8
- isShared: boolean;
9
- environments: Array<Environment & { folders: Folder[] }>;
10
- }
11
-
12
- const TREE = {
13
- BRANCH: "├── ",
14
- LAST_BRANCH: "└── ",
15
- VERTICAL: "│ ",
16
- EMPTY: " ",
17
- } as const;
18
-
19
- function renderProjectTree(projects: ProjectWithDetails[]): void {
20
- if (projects.length === 0) {
21
- console.log(pc.dim("No projects found"));
22
- console.log(pc.dim("Create one at app.relic.so"));
23
- return;
24
- }
25
-
26
- console.log(pc.bold("Your Projects"));
27
- console.log();
28
-
29
- for (let projectIndex = 0; projectIndex < projects.length; projectIndex++) {
30
- const project = projects[projectIndex]!;
31
- const isLastProject = projectIndex === projects.length - 1;
32
- const projectPrefix = isLastProject ? TREE.LAST_BRANCH : TREE.BRANCH;
33
- const childPrefix = isLastProject ? TREE.EMPTY : TREE.VERTICAL;
34
-
35
- const badges: string[] = [];
36
- if (project.isShared) badges.push("shared");
37
- if (project.isArchived) badges.push("archived");
38
- const badgeText = badges.length > 0 ? pc.dim(` (${badges.join(", ")})`) : "";
39
-
40
- const projectName = project.isArchived ? pc.dim(project.name) : pc.bold(project.name);
41
-
42
- console.log(`${pc.dim(projectPrefix)}${projectName}${badgeText}`);
43
-
44
- for (let envIndex = 0; envIndex < project.environments.length; envIndex++) {
45
- const env = project.environments[envIndex]!;
46
- const isLastEnv = envIndex === project.environments.length - 1;
47
- const envPrefix = isLastEnv ? TREE.LAST_BRANCH : TREE.BRANCH;
48
- const envChildPrefix = isLastEnv ? TREE.EMPTY : TREE.VERTICAL;
49
-
50
- const envColor = env.color || "white";
51
- const colorFn =
52
- envColor in pc
53
- ? (pc as unknown as Record<string, (s: string) => string>)[envColor]!
54
- : (s: string) => s;
55
- console.log(`${pc.dim(childPrefix)}${pc.dim(envPrefix)}${colorFn(env.name)}`);
56
-
57
- for (let folderIndex = 0; folderIndex < env.folders.length; folderIndex++) {
58
- const folder = env.folders[folderIndex]!;
59
- const isLastFolder = folderIndex === env.folders.length - 1;
60
- const folderPrefix = isLastFolder ? TREE.LAST_BRANCH : TREE.BRANCH;
61
-
62
- console.log(
63
- `${pc.dim(childPrefix)}${pc.dim(envChildPrefix)}${pc.dim(folderPrefix)}${pc.dim(`${folder.name}/`)}`,
64
- );
65
- }
66
- }
67
- }
68
- }
69
-
70
- export default async function projects() {
71
- const spinner = ora("Connecting...").start();
72
-
73
- try {
74
- const sessionValidation = await validateSession();
75
- if (!sessionValidation.isValid || sessionValidation.isExpired) {
76
- spinner.stop();
77
- console.log(pc.yellow("Not logged in"));
78
- console.log(pc.dim("Run `relic login` to authenticate"));
79
- return;
80
- }
81
-
82
- const api = getApi();
83
-
84
- spinner.text = "Fetching projects...";
85
- const [ownedProjects, sharedProjects] = await Promise.all([
86
- api.listProjects(),
87
- api.listSharedProjects(),
88
- ]);
89
-
90
- const allProjects: ProjectWithDetails[] = [
91
- ...ownedProjects.map((p) => ({
92
- ...p,
93
- isShared: false,
94
- environments: [] as Array<Environment & { folders: Folder[] }>,
95
- })),
96
- ...sharedProjects.map((p) => ({
97
- ...p,
98
- isShared: true,
99
- environments: [] as Array<Environment & { folders: Folder[] }>,
100
- })),
101
- ];
102
-
103
- spinner.text = "Fetching environments...";
104
- const projectsWithEnvs = await Promise.all(
105
- allProjects.map(async (project) => {
106
- try {
107
- const environments = await api.getProjectEnvironments(project.id);
108
- return {
109
- ...project,
110
- environments: environments.map((e) => ({ ...e, folders: [] as Folder[] })),
111
- };
112
- } catch {
113
- return { ...project, environments: [] };
114
- }
115
- }),
116
- );
117
-
118
- spinner.text = "Fetching folders...";
119
- const projectsWithFolders = await Promise.all(
120
- projectsWithEnvs.map(async (project) => {
121
- const environmentsWithFolders = await Promise.all(
122
- project.environments.map(async (env) => {
123
- try {
124
- const data = await api.getEnvironmentData(env.id);
125
- return { ...env, folders: data.folders };
126
- } catch {
127
- return { ...env, folders: [] };
128
- }
129
- }),
130
- );
131
- return { ...project, environments: environmentsWithFolders };
132
- }),
133
- );
134
-
135
- trackEvent("cli_command_executed", { command: "projects", count: projectsWithFolders.length });
136
- spinner.stop();
137
- renderProjectTree(projectsWithFolders);
138
- } catch (err) {
139
- const message = err instanceof Error ? err.message : "Failed to fetch projects";
140
- // Handle auth-related errors more gracefully
141
- if (
142
- message.includes("Not authenticated") ||
143
- message.includes("JWT") ||
144
- message.includes("token")
145
- ) {
146
- spinner.stop();
147
- console.log(pc.yellow("Not logged in"));
148
- console.log(pc.dim("Run `relic login` to authenticate"));
149
- return;
150
- }
151
- spinner.fail(pc.red(`Error: ${message}`));
152
- process.exit(1);
153
- }
154
- }
@@ -1,240 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
- import type { CachedUserKeys } from "@repo/auth";
3
- import type { RunOptions } from "./run";
4
-
5
- const userKeyState: { value: CachedUserKeys | null } = { value: null };
6
-
7
- const mockGetCachedUserKeys = mock(() => userKeyState.value);
8
- const mockCacheUserKeys = mock((_db: unknown, keys: CachedUserKeys) => {
9
- userKeyState.value = keys;
10
- });
11
- const mockClearCachedUserKeys = mock(() => {
12
- userKeyState.value = null;
13
- });
14
- const mockGetPasswordFromStorage = mock(() => Promise.resolve("test-password"));
15
- const mockGetUserKeyCacheDb = mock(() => Promise.resolve({}));
16
-
17
- const mockExportSecretsViaApiKey = mock(() =>
18
- Promise.resolve({
19
- secrets: [{ key: "API_KEY", encryptedValue: "enc_val_1" }],
20
- count: 1,
21
- encryptedProjectKey: "enc_project_key",
22
- environmentId: "env_123",
23
- folderId: null,
24
- }),
25
- );
26
- const mockFetchUserKeysViaApiKey = mock(() =>
27
- Promise.resolve({
28
- encryptedPrivateKey: "fresh_encrypted_private_key",
29
- salt: "fresh_salt",
30
- publicKey: "fresh_public_key",
31
- }),
32
- );
33
- const mockDecryptSecrets = mock(() =>
34
- Promise.resolve([{ key: "API_KEY", value: "decrypted-value" }]),
35
- );
36
- const mockUnwrapProjectKey = mock(() =>
37
- Promise.resolve("mock_project_key" as unknown as CryptoKey),
38
- );
39
-
40
- mock.module("@repo/auth", () => ({
41
- cacheUserKeys: mockCacheUserKeys,
42
- clearCachedUserKeys: mockClearCachedUserKeys,
43
- getCachedUserKeys: mockGetCachedUserKeys,
44
- getPasswordFromStorage: mockGetPasswordFromStorage,
45
- getUserKeyCacheDb: mockGetUserKeyCacheDb,
46
- hasPassword: mock(() => Promise.resolve(true)),
47
- validateSession: mock(() => Promise.resolve({ isValid: true, isExpired: false })),
48
- }));
49
-
50
- class ProPlanRequiredError extends Error {
51
- upgradeUrl: string;
52
- constructor(message: string, upgradeUrl: string) {
53
- super(message);
54
- this.name = "ProPlanRequiredError";
55
- this.upgradeUrl = upgradeUrl;
56
- }
57
- }
58
-
59
- mock.module("../lib/api", () => ({
60
- exportSecretsViaApiKey: mockExportSecretsViaApiKey,
61
- fetchUserKeysViaApiKey: mockFetchUserKeysViaApiKey,
62
- getApi: mock(() => ({})),
63
- ProPlanRequiredError,
64
- }));
65
-
66
- mock.module("../lib/crypto", () => ({
67
- decryptSecrets: mockDecryptSecrets,
68
- getProjectKey: mock(() => Promise.resolve("unused_project_key")),
69
- ProjectKeyError: class ProjectKeyError extends Error {
70
- code: string;
71
- constructor(message: string, code: string) {
72
- super(message);
73
- this.code = code;
74
- }
75
- },
76
- }));
77
-
78
- mock.module("@repo/crypto", () => ({
79
- unwrapProjectKey: mockUnwrapProjectKey,
80
- }));
81
-
82
- const { prepareSecretsWithApiKey } = await import("./run");
83
-
84
- const DEFAULT_OPTIONS: RunOptions = {
85
- environment: "development",
86
- };
87
-
88
- describe("prepareSecretsWithApiKey", () => {
89
- beforeEach(() => {
90
- process.env.RELIC_API_KEY = "test-api-key";
91
- userKeyState.value = null;
92
-
93
- mockGetCachedUserKeys.mockClear();
94
- mockCacheUserKeys.mockClear();
95
- mockClearCachedUserKeys.mockClear();
96
- mockGetPasswordFromStorage.mockClear();
97
- mockGetUserKeyCacheDb.mockClear();
98
- mockExportSecretsViaApiKey.mockClear();
99
- mockFetchUserKeysViaApiKey.mockClear();
100
- mockDecryptSecrets.mockClear();
101
- mockUnwrapProjectKey.mockClear();
102
-
103
- mockGetPasswordFromStorage.mockImplementation(() => Promise.resolve("test-password"));
104
- mockExportSecretsViaApiKey.mockImplementation(() =>
105
- Promise.resolve({
106
- secrets: [{ key: "API_KEY", encryptedValue: "enc_val_1" }],
107
- count: 1,
108
- encryptedProjectKey: "enc_project_key",
109
- environmentId: "env_123",
110
- folderId: null,
111
- }),
112
- );
113
- mockFetchUserKeysViaApiKey.mockImplementation(() =>
114
- Promise.resolve({
115
- encryptedPrivateKey: "fresh_encrypted_private_key",
116
- salt: "fresh_salt",
117
- publicKey: "fresh_public_key",
118
- }),
119
- );
120
- mockDecryptSecrets.mockImplementation(() =>
121
- Promise.resolve([{ key: "API_KEY", value: "decrypted-value" }]),
122
- );
123
- mockUnwrapProjectKey.mockImplementation(() =>
124
- Promise.resolve("mock_project_key" as unknown as CryptoKey),
125
- );
126
- });
127
-
128
- afterEach(() => {
129
- delete process.env.RELIC_API_KEY;
130
- });
131
-
132
- test("retries with fresh keys when cached API-key keys are stale", async () => {
133
- userKeyState.value = {
134
- encryptedPrivateKey: "stale_encrypted_private_key",
135
- salt: "stale_salt",
136
- keysUpdatedAt: 1,
137
- };
138
-
139
- let unwrapCalls = 0;
140
- mockUnwrapProjectKey.mockImplementation(() => {
141
- unwrapCalls++;
142
- if (unwrapCalls === 1) {
143
- return Promise.reject(new Error("Failed to unwrap project key"));
144
- }
145
- return Promise.resolve("mock_project_key" as unknown as CryptoKey);
146
- });
147
-
148
- const result = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS);
149
-
150
- expect(mockClearCachedUserKeys).toHaveBeenCalledTimes(1);
151
- expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
152
- expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(2);
153
- expect(mockUnwrapProjectKey).toHaveBeenNthCalledWith(
154
- 1,
155
- "enc_project_key",
156
- "stale_encrypted_private_key",
157
- "test-password",
158
- "stale_salt",
159
- );
160
- expect(mockUnwrapProjectKey).toHaveBeenNthCalledWith(
161
- 2,
162
- "enc_project_key",
163
- "fresh_encrypted_private_key",
164
- "test-password",
165
- "fresh_salt",
166
- );
167
- expect(userKeyState.value?.encryptedPrivateKey).toBe("fresh_encrypted_private_key");
168
- expect(userKeyState.value?.salt).toBe("fresh_salt");
169
- expect(result.secrets).toEqual({ API_KEY: "decrypted-value" });
170
- });
171
-
172
- test("does not retry when keys are not from cache", async () => {
173
- userKeyState.value = null;
174
- mockUnwrapProjectKey.mockImplementation(() =>
175
- Promise.reject(new Error("Failed to unwrap project key")),
176
- );
177
-
178
- await expect(prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS)).rejects.toThrow(
179
- "Failed to unwrap project key",
180
- );
181
-
182
- // first fetch is for normal key resolution; there should be no extra fetch for retry
183
- expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
184
- expect(mockClearCachedUserKeys).not.toHaveBeenCalled();
185
- expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(1);
186
- });
187
-
188
- test("bubbles error when retry with fresh keys also fails", async () => {
189
- userKeyState.value = {
190
- encryptedPrivateKey: "stale_encrypted_private_key",
191
- salt: "stale_salt",
192
- keysUpdatedAt: 1,
193
- };
194
-
195
- mockUnwrapProjectKey.mockImplementation(() =>
196
- Promise.reject(new Error("Failed to unwrap project key")),
197
- );
198
-
199
- await expect(prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS)).rejects.toThrow(
200
- "Failed to unwrap project key",
201
- );
202
-
203
- expect(mockClearCachedUserKeys).toHaveBeenCalledTimes(1);
204
- expect(mockFetchUserKeysViaApiKey).toHaveBeenCalledTimes(1);
205
- expect(mockUnwrapProjectKey).toHaveBeenCalledTimes(2);
206
- });
207
-
208
- test("propagates ProPlanRequiredError from export", async () => {
209
- mockExportSecretsViaApiKey.mockImplementation(() =>
210
- Promise.reject(
211
- new ProPlanRequiredError(
212
- "API keys require a Pro plan.",
213
- "https://relic.so/dashboard?action=upgrade",
214
- ),
215
- ),
216
- );
217
-
218
- const err = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS).catch((e) => e);
219
-
220
- expect(err).toBeInstanceOf(ProPlanRequiredError);
221
- expect(err.message).toBe("API keys require a Pro plan.");
222
- expect(err.upgradeUrl).toBe("https://relic.so/dashboard?action=upgrade");
223
- });
224
-
225
- test("propagates ProPlanRequiredError from user keys fetch", async () => {
226
- mockFetchUserKeysViaApiKey.mockImplementation(() =>
227
- Promise.reject(
228
- new ProPlanRequiredError(
229
- "API keys require a Pro plan.",
230
- "https://relic.so/dashboard?action=upgrade",
231
- ),
232
- ),
233
- );
234
-
235
- const err = await prepareSecretsWithApiKey("project_123", DEFAULT_OPTIONS).catch((e) => e);
236
-
237
- expect(err).toBeInstanceOf(ProPlanRequiredError);
238
- expect(err.upgradeUrl).toBe("https://relic.so/dashboard?action=upgrade");
239
- });
240
- });