@vellumai/credential-executor 0.7.0 → 0.7.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": "@vellumai/credential-executor",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "bin": {
10
10
  "credential-executor": "./src/main.ts",
11
- "credential-executor-managed": "./src/managed-main.ts"
11
+ "credential-executor-managed": "./src/managed-main.ts",
12
+ "ces": "./src/cli.ts"
12
13
  },
13
14
  "scripts": {
14
15
  "dev": "bun run src/main.ts",
@@ -0,0 +1,185 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { apiKeyToCredentialsMigration } from "../migrations/002-api-keys-to-credentials.js";
4
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Creates an in-memory SecureKeyBackend backed by a Map<string, string>.
12
+ * Allows us to assert state before/after migration without relying on mocked
13
+ * function call tracking.
14
+ */
15
+ function makeMapBackend(
16
+ initial: Record<string, string> = {},
17
+ ): SecureKeyBackend & { store: Map<string, string> } {
18
+ const store = new Map<string, string>(Object.entries(initial));
19
+ return {
20
+ store,
21
+ get: (_key: string) => Promise.resolve(store.get(_key)),
22
+ set: (_key: string, value: string) => {
23
+ store.set(_key, value);
24
+ return Promise.resolve(true);
25
+ },
26
+ delete: (_key: string) => {
27
+ const existed = store.has(_key);
28
+ store.delete(_key);
29
+ return Promise.resolve({ deleted: existed });
30
+ },
31
+ list: () => Promise.resolve([...store.keys()]),
32
+ } as unknown as SecureKeyBackend & { store: Map<string, string> };
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Tests
37
+ // ---------------------------------------------------------------------------
38
+
39
+ describe("apiKeyToCredentialsMigration (002)", () => {
40
+ // -------------------------------------------------------------------------
41
+ // run()
42
+ // -------------------------------------------------------------------------
43
+
44
+ describe("run()", () => {
45
+ test("bare key present — writes credential key and deletes bare key", async () => {
46
+ const backend = makeMapBackend({ anthropic: "sk-ant-123" });
47
+
48
+ await apiKeyToCredentialsMigration.run(backend);
49
+
50
+ expect(backend.store.get("credential/anthropic/api_key")).toBe(
51
+ "sk-ant-123",
52
+ );
53
+ expect(backend.store.has("anthropic")).toBe(false);
54
+ });
55
+
56
+ test("idempotent — credential key already exists: bare key deleted, credential value unchanged", async () => {
57
+ const backend = makeMapBackend({
58
+ anthropic: "sk-ant-new",
59
+ "credential/anthropic/api_key": "sk-ant-existing",
60
+ });
61
+
62
+ await apiKeyToCredentialsMigration.run(backend);
63
+
64
+ // Credential key must NOT be overwritten
65
+ expect(backend.store.get("credential/anthropic/api_key")).toBe(
66
+ "sk-ant-existing",
67
+ );
68
+ // Bare key must be removed
69
+ expect(backend.store.has("anthropic")).toBe(false);
70
+ });
71
+
72
+ test("no bare key for provider — no write and no delete for that provider", async () => {
73
+ // Store only has a key for openai; anthropic has nothing
74
+ const backend = makeMapBackend({ openai: "sk-openai-abc" });
75
+
76
+ await apiKeyToCredentialsMigration.run(backend);
77
+
78
+ // openai should be migrated
79
+ expect(backend.store.get("credential/openai/api_key")).toBe(
80
+ "sk-openai-abc",
81
+ );
82
+ expect(backend.store.has("openai")).toBe(false);
83
+
84
+ // anthropic: credential key should NOT exist (no accidental write)
85
+ expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
86
+ });
87
+
88
+ test("multiple providers — each handled independently", async () => {
89
+ const backend = makeMapBackend({
90
+ anthropic: "sk-ant-multi",
91
+ openai: "sk-openai-multi",
92
+ gemini: "gemini-key",
93
+ brave: "brave-key",
94
+ });
95
+
96
+ await apiKeyToCredentialsMigration.run(backend);
97
+
98
+ // All bare keys gone
99
+ for (const provider of ["anthropic", "openai", "gemini", "brave"]) {
100
+ expect(backend.store.has(provider)).toBe(false);
101
+ }
102
+
103
+ // All credential keys present
104
+ expect(backend.store.get("credential/anthropic/api_key")).toBe(
105
+ "sk-ant-multi",
106
+ );
107
+ expect(backend.store.get("credential/openai/api_key")).toBe(
108
+ "sk-openai-multi",
109
+ );
110
+ expect(backend.store.get("credential/gemini/api_key")).toBe("gemini-key");
111
+ expect(backend.store.get("credential/brave/api_key")).toBe("brave-key");
112
+
113
+ // Providers that had no bare key should have no credential key
114
+ for (const provider of [
115
+ "ollama",
116
+ "fireworks",
117
+ "openrouter",
118
+ "perplexity",
119
+ "deepgram",
120
+ "xai",
121
+ ]) {
122
+ expect(backend.store.has(`credential/${provider}/api_key`)).toBe(false);
123
+ }
124
+ });
125
+
126
+ test("set() failure — bare key preserved, credential key absent", async () => {
127
+ const backend = makeMapBackend({ anthropic: "sk-ant-123" });
128
+ // Simulate a write failure
129
+ backend.set = (_key: string, _value: string) => Promise.resolve(false);
130
+
131
+ await apiKeyToCredentialsMigration.run(backend);
132
+
133
+ // Bare key must survive — it was not deleted because set() failed
134
+ expect(backend.store.get("anthropic")).toBe("sk-ant-123");
135
+ // Credential key must not exist
136
+ expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
137
+ });
138
+
139
+ test("run() is idempotent — running twice leaves store in same state as once", async () => {
140
+ const backend = makeMapBackend({
141
+ anthropic: "sk-ant-idem",
142
+ openai: "sk-openai-idem",
143
+ });
144
+
145
+ await apiKeyToCredentialsMigration.run(backend);
146
+ // Capture state after first run
147
+ const afterFirst = new Map(backend.store);
148
+
149
+ await apiKeyToCredentialsMigration.run(backend);
150
+ // State after second run must match first run
151
+ expect(backend.store).toEqual(afterFirst);
152
+ });
153
+ });
154
+
155
+ // -------------------------------------------------------------------------
156
+ // down()
157
+ // -------------------------------------------------------------------------
158
+
159
+ describe("down()", () => {
160
+ test("reverses a migrated key back to bare name", async () => {
161
+ const backend = makeMapBackend({
162
+ "credential/anthropic/api_key": "sk-ant-rev",
163
+ });
164
+
165
+ await apiKeyToCredentialsMigration.down(backend);
166
+
167
+ expect(backend.store.get("anthropic")).toBe("sk-ant-rev");
168
+ expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
169
+ });
170
+
171
+ test("idempotent — bare key already exists: credential key deleted, bare key value unchanged", async () => {
172
+ const backend = makeMapBackend({
173
+ "credential/anthropic/api_key": "sk-ant-cred",
174
+ anthropic: "sk-ant-original",
175
+ });
176
+
177
+ await apiKeyToCredentialsMigration.down(backend);
178
+
179
+ // Bare key value must NOT be overwritten
180
+ expect(backend.store.get("anthropic")).toBe("sk-ant-original");
181
+ // Credential key must be removed
182
+ expect(backend.store.has("credential/anthropic/api_key")).toBe(false);
183
+ });
184
+ });
185
+ });
@@ -0,0 +1,227 @@
1
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { SecureKeyBackend } from "@vellumai/credential-storage";
4
+
5
+ import type { CesMigration } from "../migrations/types.js";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock state
9
+ // ---------------------------------------------------------------------------
10
+
11
+ let mockFileExists = false;
12
+ let mockFileContents: string | null = null;
13
+ let useMocks = true;
14
+
15
+ const existsSyncFn = mock((_path: string): boolean => mockFileExists);
16
+ const mkdirSyncFn = mock((): void => {});
17
+ const readFileSyncFn = mock((): string => {
18
+ if (mockFileContents === null) {
19
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
20
+ }
21
+ return mockFileContents;
22
+ });
23
+ const writeFileSyncFn = mock((): void => {});
24
+ const renameSyncFn = mock((): void => {});
25
+ const logWarnFn = mock((): void => {});
26
+ const logInfoFn = mock((): void => {});
27
+ const logErrorFn = mock((..._args: unknown[]): void => {});
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Mock modules — before importing module under test
31
+ //
32
+ // mock.module is process-global in bun. To avoid poisoning other test files,
33
+ // each overridden function delegates to a real implementation (captured via
34
+ // require() before mocking) once `useMocks` is flipped false in afterAll.
35
+ // All other node:fs exports are forwarded unchanged.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
39
+ const _realFs = require("node:fs");
40
+ /* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
41
+
42
+ mock.module("node:fs", () => {
43
+ const proxy: Record<string, unknown> = {};
44
+ // Forward every export from the real module.
45
+ for (const key of Object.keys(_realFs)) {
46
+ proxy[key] = _realFs[key];
47
+ }
48
+ // Override only the five functions the migration runner uses.
49
+ // The proxy captures args as any[] and delegates to either our mocks or the
50
+ // real fs. We cast through Function.apply to satisfy overloaded signatures.
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ type AnyFn = (...args: any[]) => any;
53
+ proxy.existsSync = (...a: unknown[]) =>
54
+ useMocks ? existsSyncFn(a[0] as string) : (_realFs.existsSync as AnyFn)(...a);
55
+ proxy.mkdirSync = (...a: unknown[]) =>
56
+ useMocks ? (mkdirSyncFn as AnyFn)(...a) : (_realFs.mkdirSync as AnyFn)(...a);
57
+ proxy.readFileSync = (...a: unknown[]) =>
58
+ useMocks ? (readFileSyncFn as AnyFn)(...a) : (_realFs.readFileSync as AnyFn)(...a);
59
+ proxy.writeFileSync = (...a: unknown[]) =>
60
+ useMocks ? (writeFileSyncFn as AnyFn)(...a) : (_realFs.writeFileSync as AnyFn)(...a);
61
+ proxy.renameSync = (...a: unknown[]) =>
62
+ useMocks ? (renameSyncFn as AnyFn)(...a) : (_realFs.renameSync as AnyFn)(...a);
63
+ return proxy;
64
+ });
65
+
66
+ // Intercept pino at the package level (same technique as workspace-migrations-runner.test.ts)
67
+ // so that the lazy proxy in getLogger() returns our mock child logger.
68
+ const mockChildLogger = {
69
+ debug: (): void => {},
70
+ info: logInfoFn,
71
+ warn: logWarnFn,
72
+ error: logErrorFn,
73
+ child: () => mockChildLogger,
74
+ };
75
+ const mockPinoLogger = Object.assign(() => mockChildLogger, {
76
+ destination: () => ({}),
77
+ multistream: () => ({}),
78
+ });
79
+ mock.module("pino", () => ({ default: mockPinoLogger }));
80
+ mock.module("pino-pretty", () => ({ default: (): object => ({}) }));
81
+
82
+ // Import after mocking
83
+ import { runCesMigrations } from "../migrations/runner.js";
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Restore real behavior after all tests so other files aren't poisoned.
87
+ // ---------------------------------------------------------------------------
88
+ afterAll(() => {
89
+ useMocks = false;
90
+ });
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const CES_DATA_ROOT = "/tmp/test-ces";
97
+
98
+ function makeBackend(): SecureKeyBackend {
99
+ return {
100
+ get: mock(() => Promise.resolve(undefined)),
101
+ set: mock(() => Promise.resolve(true)),
102
+ delete: mock(() => Promise.resolve({ deleted: true })),
103
+ list: mock(() => Promise.resolve([])),
104
+ } as unknown as SecureKeyBackend;
105
+ }
106
+
107
+ function makeMigration(id: string): CesMigration {
108
+ return {
109
+ id,
110
+ description: `Migration ${id}`,
111
+ run: mock((): void => {}),
112
+ down: mock((): void => {}),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Tests
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("runCesMigrations", () => {
121
+ beforeEach(() => {
122
+ mockFileExists = false;
123
+ mockFileContents = null;
124
+ existsSyncFn.mockClear();
125
+ mkdirSyncFn.mockClear();
126
+ readFileSyncFn.mockClear();
127
+ writeFileSyncFn.mockClear();
128
+ renameSyncFn.mockClear();
129
+ logWarnFn.mockClear();
130
+ logInfoFn.mockClear();
131
+ logErrorFn.mockClear();
132
+ });
133
+
134
+ test("fresh install — no checkpoint file — runs all migrations", async () => {
135
+ const backend = makeBackend();
136
+ const m1 = makeMigration("001");
137
+ const m2 = makeMigration("002");
138
+
139
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
140
+
141
+ expect(m1.run).toHaveBeenCalledTimes(1);
142
+ expect(m2.run).toHaveBeenCalledTimes(1);
143
+ expect(m1.run).toHaveBeenCalledWith(backend);
144
+ });
145
+
146
+ test("already-completed migration is skipped", async () => {
147
+ mockFileExists = true;
148
+ mockFileContents = JSON.stringify({
149
+ applied: {
150
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "completed" },
151
+ },
152
+ });
153
+ const backend = makeBackend();
154
+ const m1 = makeMigration("001");
155
+ const m2 = makeMigration("002");
156
+
157
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
158
+
159
+ expect(m1.run).not.toHaveBeenCalled();
160
+ expect(m2.run).toHaveBeenCalledTimes(1);
161
+ });
162
+
163
+ test("interrupted migration (started status) is re-run", async () => {
164
+ mockFileExists = true;
165
+ mockFileContents = JSON.stringify({
166
+ applied: {
167
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "started" },
168
+ },
169
+ });
170
+ const backend = makeBackend();
171
+ const m1 = makeMigration("001");
172
+
173
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1]);
174
+
175
+ expect(m1.run).toHaveBeenCalledTimes(1);
176
+ expect(logWarnFn).toHaveBeenCalled();
177
+ });
178
+
179
+ test("failed migration is NOT re-run", async () => {
180
+ mockFileExists = true;
181
+ mockFileContents = JSON.stringify({
182
+ applied: {
183
+ "001": { appliedAt: "2025-01-01T00:00:00.000Z", status: "failed" },
184
+ },
185
+ });
186
+ const backend = makeBackend();
187
+ const m1 = makeMigration("001");
188
+
189
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1]);
190
+
191
+ expect(m1.run).not.toHaveBeenCalled();
192
+ });
193
+
194
+ test("duplicate migration IDs throw at startup", async () => {
195
+ const backend = makeBackend();
196
+ const m1 = makeMigration("001");
197
+ const m2 = makeMigration("001");
198
+
199
+ await expect(
200
+ runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]),
201
+ ).rejects.toThrow('Duplicate CES migration id: "001"');
202
+
203
+ expect(m1.run).not.toHaveBeenCalled();
204
+ });
205
+
206
+ test("migration that throws is marked failed and startup continues", async () => {
207
+ const backend = makeBackend();
208
+ const m1 = makeMigration("001");
209
+ const m2 = makeMigration("002");
210
+ (m1.run as ReturnType<typeof mock>).mockImplementation((): never => {
211
+ throw new Error("m1 blew up");
212
+ });
213
+
214
+ await runCesMigrations(CES_DATA_ROOT, backend, [m1, m2]);
215
+
216
+ // m2 should still run after m1's failure
217
+ expect(m2.run).toHaveBeenCalledTimes(1);
218
+ // error was logged
219
+ expect(logErrorFn).toHaveBeenCalled();
220
+
221
+ // Checkpoint writes: started m1, failed m1, started m2, completed m2 = 4
222
+ expect(writeFileSyncFn).toHaveBeenCalledTimes(4);
223
+ const failedWrite = (writeFileSyncFn.mock.calls[1] as unknown[])[1] as string;
224
+ const failedParsed = JSON.parse(failedWrite);
225
+ expect(failedParsed.applied["001"].status).toBe("failed");
226
+ });
227
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CES CLI integration tests.
3
+ *
4
+ * Exercises the CLI entrypoint (src/cli.ts) against a temporary encrypted
5
+ * key store. Each test gets a fresh temp directory with its own keys.enc
6
+ * and store.key so tests are fully isolated.
7
+ */
8
+
9
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
10
+ import { mkdirSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { tmpdir } from "node:os";
14
+
15
+ const CLI_PATH = join(import.meta.dir, "..", "cli.ts");
16
+
17
+ function makeTempDir(): string {
18
+ const dir = join(tmpdir(), `ces-cli-test-${randomUUID()}`);
19
+ mkdirSync(dir, { recursive: true });
20
+ return dir;
21
+ }
22
+
23
+ function runCli(
24
+ args: string[],
25
+ env: Record<string, string>,
26
+ ): { exitCode: number; stdout: string; stderr: string } {
27
+ const result = Bun.spawnSync(["bun", "run", CLI_PATH, ...args], {
28
+ env: { ...process.env, ...env },
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ });
32
+ return {
33
+ exitCode: result.exitCode,
34
+ stdout: result.stdout.toString(),
35
+ stderr: result.stderr.toString(),
36
+ };
37
+ }
38
+
39
+ describe("ces", () => {
40
+ let secDir: string;
41
+ let env: Record<string, string>;
42
+
43
+ beforeEach(() => {
44
+ secDir = makeTempDir();
45
+ env = { CREDENTIAL_SECURITY_DIR: secDir };
46
+ });
47
+
48
+ afterEach(() => {
49
+ rmSync(secDir, { recursive: true, force: true });
50
+ });
51
+
52
+ test("list on empty store shows no credentials", () => {
53
+ const { exitCode, stdout } = runCli(["list"], env);
54
+ expect(exitCode).toBe(0);
55
+ expect(stdout.trim()).toBe("(no credentials stored)");
56
+ });
57
+
58
+ test("set + get round-trip", () => {
59
+ const account = "credential/vellum/platform_organization_id";
60
+ const value = "test-org-uuid-1234";
61
+
62
+ const set = runCli(["set", account, value], env);
63
+ expect(set.exitCode).toBe(0);
64
+ expect(set.stdout).toContain(`Set: ${account}`);
65
+
66
+ const get = runCli(["get", account], env);
67
+ expect(get.exitCode).toBe(0);
68
+ expect(get.stdout).toBe(value);
69
+ });
70
+
71
+ test("list shows stored credentials", () => {
72
+ runCli(["set", "credential/vellum/org_id", "org-1"], env);
73
+ runCli(["set", "credential/vellum/user_id", "user-1"], env);
74
+
75
+ const { exitCode, stdout } = runCli(["list"], env);
76
+ expect(exitCode).toBe(0);
77
+ expect(stdout).toContain("credential/vellum/org_id");
78
+ expect(stdout).toContain("credential/vellum/user_id");
79
+ });
80
+
81
+ test("get on missing key exits 1", () => {
82
+ const { exitCode, stderr } = runCli(["get", "credential/nonexistent/key"], env);
83
+ expect(exitCode).toBe(1);
84
+ expect(stderr).toContain("Not found");
85
+ });
86
+
87
+ test("set + delete + get shows not found", () => {
88
+ const account = "credential/test/deleteme";
89
+ runCli(["set", account, "temporary"], env);
90
+
91
+ const del = runCli(["delete", account], env);
92
+ expect(del.exitCode).toBe(0);
93
+ expect(del.stdout).toContain("Deleted");
94
+
95
+ const get = runCli(["get", account], env);
96
+ expect(get.exitCode).toBe(1);
97
+ expect(get.stderr).toContain("Not found");
98
+ });
99
+
100
+ test("delete on missing key exits 1", () => {
101
+ const { exitCode, stderr } = runCli(["delete", "credential/nonexistent/key"], env);
102
+ expect(exitCode).toBe(1);
103
+ expect(stderr).toContain("Not found");
104
+ });
105
+
106
+ test("no args prints usage", () => {
107
+ const { exitCode, stdout } = runCli([], env);
108
+ expect(exitCode).toBe(0);
109
+ expect(stdout).toContain("CES CLI");
110
+ expect(stdout).toContain("ces list");
111
+ });
112
+
113
+ test("--help prints usage", () => {
114
+ const { exitCode, stdout } = runCli(["--help"], env);
115
+ expect(exitCode).toBe(0);
116
+ expect(stdout).toContain("CES CLI");
117
+ });
118
+
119
+ test("unknown command exits 1", () => {
120
+ const { exitCode, stderr } = runCli(["frobnicate"], env);
121
+ expect(exitCode).toBe(1);
122
+ expect(stderr).toContain("Unknown command");
123
+ });
124
+
125
+ test("set without value exits 1", () => {
126
+ const { exitCode, stderr } = runCli(["set", "credential/test/key"], env);
127
+ expect(exitCode).toBe(1);
128
+ expect(stderr).toContain("Usage");
129
+ });
130
+
131
+ test("overwrite existing credential", () => {
132
+ const account = "credential/vellum/overwrite_test";
133
+ runCli(["set", account, "first"], env);
134
+ runCli(["set", account, "second"], env);
135
+
136
+ const { stdout } = runCli(["get", account], env);
137
+ expect(stdout).toBe("second");
138
+ });
139
+ });