relic 0.2.0 → 0.4.0
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/README.md +129 -0
- package/commands/init.ts +105 -0
- package/commands/login.ts +85 -0
- package/commands/logout.ts +39 -0
- package/commands/projects.ts +154 -0
- package/commands/run.api-key.test.ts +240 -0
- package/commands/run.test.ts +404 -0
- package/commands/run.ts +532 -0
- package/commands/run.validation.test.ts +135 -0
- package/commands/telemetry.ts +28 -0
- package/commands/whoami.ts +45 -0
- package/ffi/bridge.ts +36 -0
- package/ffi/constants.ts +3 -0
- package/ffi/helper.ts +134 -0
- package/index.ts +116 -0
- package/lib/api.ts +411 -0
- package/lib/config.ts +118 -0
- package/lib/crypto.ts +81 -0
- package/lib/types.ts +1 -0
- package/package.json +62 -6
- package/prebuilds/darwin-arm64/.gitkeep +0 -0
- package/prebuilds/darwin-arm64/librelic_runner.d +1 -0
- package/prebuilds/darwin-arm64/librelic_runner.dylib +0 -0
- package/prebuilds/darwin-x64/.gitkeep +0 -0
- package/prebuilds/darwin-x64/librelic_runner.d +1 -0
- package/prebuilds/darwin-x64/librelic_runner.dylib +0 -0
- package/prebuilds/linux-x64/.gitkeep +0 -0
- package/prebuilds/linux-x64/librelic_runner.d +1 -0
- package/prebuilds/linux-x64/librelic_runner.so +0 -0
- package/prebuilds/win32-x64/.gitkeep +0 -0
- package/prebuilds/win32-x64/relic_runner.dll +0 -0
- package/index.js +0 -52
package/commands/run.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { ptr } from "bun:ffi";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
cacheUserKeys,
|
|
5
|
+
clearCachedUserKeys,
|
|
6
|
+
getCachedUserKeys,
|
|
7
|
+
getPasswordFromStorage,
|
|
8
|
+
getUserKeyCacheDb,
|
|
9
|
+
hasPassword,
|
|
10
|
+
validateSession,
|
|
11
|
+
} from "@repo/auth";
|
|
12
|
+
import { createLogger, trackEvent } from "@repo/logger";
|
|
13
|
+
import {
|
|
14
|
+
cacheEnvironments,
|
|
15
|
+
cacheFolders,
|
|
16
|
+
cacheProject,
|
|
17
|
+
cacheSecrets,
|
|
18
|
+
getCacheDb,
|
|
19
|
+
getCachedEnvironmentId,
|
|
20
|
+
getCachedFolderId,
|
|
21
|
+
getCachedSecrets,
|
|
22
|
+
loadCachedEncryptedProjectKey,
|
|
23
|
+
loadSecretsLastCachedTime,
|
|
24
|
+
} from "helpers/cache";
|
|
25
|
+
import type { SecretScope } from "lib/types";
|
|
26
|
+
import ora from "ora";
|
|
27
|
+
import pc from "picocolors";
|
|
28
|
+
import { RunnerBridge } from "../ffi/bridge";
|
|
29
|
+
import {
|
|
30
|
+
exportSecretsViaApiKey,
|
|
31
|
+
fetchUserKeysViaApiKey,
|
|
32
|
+
getApi,
|
|
33
|
+
ProPlanRequiredError,
|
|
34
|
+
type ProtectedApi,
|
|
35
|
+
type SecretData,
|
|
36
|
+
} from "../lib/api";
|
|
37
|
+
import { findConfig } from "../lib/config";
|
|
38
|
+
import { decryptSecrets, getProjectKey, ProjectKeyError } from "../lib/crypto";
|
|
39
|
+
|
|
40
|
+
const log = createLogger("cli");
|
|
41
|
+
|
|
42
|
+
export interface RunOptions {
|
|
43
|
+
environment: string;
|
|
44
|
+
folder?: string;
|
|
45
|
+
scope?: SecretScope;
|
|
46
|
+
project?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PrepareSecretsResult {
|
|
50
|
+
secrets: Record<string, string>;
|
|
51
|
+
count: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isApiKeyMode(): boolean {
|
|
55
|
+
return !!process.env.RELIC_API_KEY;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function resolveUserKeysWithApiKey(
|
|
59
|
+
userKeyDb: Database,
|
|
60
|
+
apiKey: string,
|
|
61
|
+
): Promise<{ encryptedPrivateKey: string; salt: string; fromCache: boolean }> {
|
|
62
|
+
const cachedKeys = getCachedUserKeys(userKeyDb);
|
|
63
|
+
if (cachedKeys) {
|
|
64
|
+
return {
|
|
65
|
+
encryptedPrivateKey: cachedKeys.encryptedPrivateKey,
|
|
66
|
+
salt: cachedKeys.salt,
|
|
67
|
+
fromCache: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const keys = await fetchUserKeysViaApiKey(apiKey);
|
|
72
|
+
|
|
73
|
+
cacheUserKeys(userKeyDb, {
|
|
74
|
+
encryptedPrivateKey: keys.encryptedPrivateKey,
|
|
75
|
+
salt: keys.salt,
|
|
76
|
+
publicKey: keys.publicKey,
|
|
77
|
+
keysUpdatedAt: Date.now(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { encryptedPrivateKey: keys.encryptedPrivateKey, salt: keys.salt, fromCache: false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function prepareSecretsWithApiKey(
|
|
84
|
+
projectId: string,
|
|
85
|
+
options: RunOptions,
|
|
86
|
+
): Promise<PrepareSecretsResult> {
|
|
87
|
+
const apiKey = process.env.RELIC_API_KEY;
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
throw new Error("RELIC_API_KEY is required for API key mode.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const password = await getPasswordFromStorage();
|
|
93
|
+
if (!password) {
|
|
94
|
+
throw new Error("RELIC_PASSWORD is required for API key mode.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const userKeyDb = await getUserKeyCacheDb();
|
|
98
|
+
const userKeys = await resolveUserKeysWithApiKey(userKeyDb, apiKey);
|
|
99
|
+
|
|
100
|
+
const result = await exportSecretsViaApiKey(apiKey, {
|
|
101
|
+
projectId,
|
|
102
|
+
environmentName: options.environment,
|
|
103
|
+
folderName: options.folder,
|
|
104
|
+
scope: options.scope,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (result.count === 0) {
|
|
108
|
+
throw new Error("No secrets found");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { unwrapProjectKey } = await import("@repo/crypto");
|
|
112
|
+
let projectKey: CryptoKey;
|
|
113
|
+
try {
|
|
114
|
+
projectKey = await unwrapProjectKey(
|
|
115
|
+
result.encryptedProjectKey,
|
|
116
|
+
userKeys.encryptedPrivateKey,
|
|
117
|
+
password,
|
|
118
|
+
userKeys.salt,
|
|
119
|
+
);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (!userKeys.fromCache) {
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
clearCachedUserKeys(userKeyDb);
|
|
126
|
+
const freshKeys = await fetchUserKeysViaApiKey(apiKey);
|
|
127
|
+
cacheUserKeys(userKeyDb, {
|
|
128
|
+
encryptedPrivateKey: freshKeys.encryptedPrivateKey,
|
|
129
|
+
salt: freshKeys.salt,
|
|
130
|
+
publicKey: freshKeys.publicKey,
|
|
131
|
+
keysUpdatedAt: Date.now(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
projectKey = await unwrapProjectKey(
|
|
135
|
+
result.encryptedProjectKey,
|
|
136
|
+
freshKeys.encryptedPrivateKey,
|
|
137
|
+
password,
|
|
138
|
+
freshKeys.salt,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const decryptedSecrets = await decryptSecrets(
|
|
143
|
+
projectKey,
|
|
144
|
+
result.secrets.map((s) => ({ key: s.key, encryptedValue: s.encryptedValue })),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const secretsObj: Record<string, string> = {};
|
|
148
|
+
for (const secret of decryptedSecrets) {
|
|
149
|
+
secretsObj[secret.key] = secret.value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { secrets: secretsObj, count: result.count };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveUserKeys(
|
|
156
|
+
userKeyDb: Database,
|
|
157
|
+
api: ProtectedApi,
|
|
158
|
+
): Promise<{ encryptedPrivateKey: string; salt: string; fromCache: boolean }> {
|
|
159
|
+
const cachedKeys = getCachedUserKeys(userKeyDb);
|
|
160
|
+
|
|
161
|
+
if (cachedKeys) {
|
|
162
|
+
return {
|
|
163
|
+
encryptedPrivateKey: cachedKeys.encryptedPrivateKey,
|
|
164
|
+
salt: cachedKeys.salt,
|
|
165
|
+
fromCache: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const user = await api.getFullUser();
|
|
170
|
+
if (!user.encryptedPrivateKey || !user.salt) {
|
|
171
|
+
throw new Error("No encryption keys found. Run 'relic tui' to set up your keys first.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
cacheUserKeys(userKeyDb, {
|
|
175
|
+
encryptedPrivateKey: user.encryptedPrivateKey,
|
|
176
|
+
salt: user.salt,
|
|
177
|
+
keysUpdatedAt: user.keysUpdatedAt ?? Date.now(),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
encryptedPrivateKey: user.encryptedPrivateKey,
|
|
182
|
+
salt: user.salt,
|
|
183
|
+
fromCache: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function resolveSecrets(
|
|
188
|
+
db: Database,
|
|
189
|
+
projectId: string,
|
|
190
|
+
options: RunOptions,
|
|
191
|
+
api: ProtectedApi,
|
|
192
|
+
): Promise<{ secrets: SecretData[]; encryptedProjectKey: string }> {
|
|
193
|
+
const cachedEnvironmentId = getCachedEnvironmentId(db, projectId, options.environment);
|
|
194
|
+
|
|
195
|
+
if (cachedEnvironmentId) {
|
|
196
|
+
const cachedFolderId = options.folder
|
|
197
|
+
? getCachedFolderId(db, projectId, cachedEnvironmentId, options.folder)
|
|
198
|
+
: null;
|
|
199
|
+
|
|
200
|
+
const isCached = options.folder ? !!cachedFolderId : true;
|
|
201
|
+
|
|
202
|
+
const lastCachedAt = isCached
|
|
203
|
+
? loadSecretsLastCachedTime(db, projectId, cachedEnvironmentId, cachedFolderId ?? undefined)
|
|
204
|
+
: null;
|
|
205
|
+
|
|
206
|
+
let lastUpdatedAt: number | null = null;
|
|
207
|
+
|
|
208
|
+
if (lastCachedAt !== null) {
|
|
209
|
+
const secretsCacheValidation = await api.getSecretsCacheValidation(
|
|
210
|
+
projectId,
|
|
211
|
+
cachedEnvironmentId,
|
|
212
|
+
cachedFolderId ?? undefined,
|
|
213
|
+
);
|
|
214
|
+
if (secretsCacheValidation) {
|
|
215
|
+
lastUpdatedAt = secretsCacheValidation.updatedAt;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const cacheIsValid = isCached && lastCachedAt && lastUpdatedAt && lastCachedAt >= lastUpdatedAt;
|
|
220
|
+
|
|
221
|
+
if (cacheIsValid) {
|
|
222
|
+
const cachedSecrets = getCachedSecrets(
|
|
223
|
+
db,
|
|
224
|
+
projectId,
|
|
225
|
+
cachedEnvironmentId,
|
|
226
|
+
cachedFolderId ?? undefined,
|
|
227
|
+
options.scope,
|
|
228
|
+
);
|
|
229
|
+
const cachedProjectKey = loadCachedEncryptedProjectKey(db, projectId);
|
|
230
|
+
|
|
231
|
+
if (cachedSecrets && cachedProjectKey) {
|
|
232
|
+
return { secrets: cachedSecrets, encryptedProjectKey: cachedProjectKey };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result = await api.exportSecrets({
|
|
238
|
+
projectId,
|
|
239
|
+
environmentName: options.environment,
|
|
240
|
+
folderName: options.folder,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (result.count === 0) {
|
|
244
|
+
throw new Error("No secrets found");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
cacheProject(db, projectId, result.encryptedProjectKey);
|
|
248
|
+
cacheEnvironments(db, projectId, [{ id: result.environmentId, name: options.environment }]);
|
|
249
|
+
if (result.folderId && options.folder) {
|
|
250
|
+
cacheFolders(db, projectId, [
|
|
251
|
+
{ id: result.folderId, environmentId: result.environmentId, name: options.folder },
|
|
252
|
+
]);
|
|
253
|
+
}
|
|
254
|
+
cacheSecrets(
|
|
255
|
+
db,
|
|
256
|
+
projectId,
|
|
257
|
+
result.environmentId,
|
|
258
|
+
result.folderId ?? undefined,
|
|
259
|
+
result.secrets,
|
|
260
|
+
Date.now(),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const secrets = options.scope
|
|
264
|
+
? result.secrets.filter((s) => s.scope === options.scope)
|
|
265
|
+
: result.secrets;
|
|
266
|
+
|
|
267
|
+
return { secrets, encryptedProjectKey: result.encryptedProjectKey };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function resolveProjectKey(
|
|
271
|
+
encryptedProjectKey: string,
|
|
272
|
+
userEncryptedPrivateKey: string,
|
|
273
|
+
userSalt: string,
|
|
274
|
+
fromCache: boolean,
|
|
275
|
+
userKeyDb: Database,
|
|
276
|
+
api: ProtectedApi,
|
|
277
|
+
): Promise<CryptoKey> {
|
|
278
|
+
try {
|
|
279
|
+
return await getProjectKey(encryptedProjectKey, userEncryptedPrivateKey, userSalt);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
const isDecryptionFailure = err instanceof ProjectKeyError && err.code === "DECRYPTION_FAILED";
|
|
282
|
+
|
|
283
|
+
if (fromCache && isDecryptionFailure) {
|
|
284
|
+
clearCachedUserKeys(userKeyDb);
|
|
285
|
+
|
|
286
|
+
const freshUser = await api.getFullUser();
|
|
287
|
+
if (!freshUser.encryptedPrivateKey || !freshUser.salt) {
|
|
288
|
+
throw new Error("No encryption keys found. Run 'relic tui' to set up your keys first.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
cacheUserKeys(userKeyDb, {
|
|
292
|
+
encryptedPrivateKey: freshUser.encryptedPrivateKey,
|
|
293
|
+
salt: freshUser.salt,
|
|
294
|
+
keysUpdatedAt: freshUser.keysUpdatedAt ?? Date.now(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return await getProjectKey(
|
|
298
|
+
encryptedProjectKey,
|
|
299
|
+
freshUser.encryptedPrivateKey,
|
|
300
|
+
freshUser.salt,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function prepareSecrets(
|
|
309
|
+
projectId: string,
|
|
310
|
+
options: RunOptions,
|
|
311
|
+
db: Database,
|
|
312
|
+
userKeyDb: Database,
|
|
313
|
+
api: ProtectedApi,
|
|
314
|
+
): Promise<PrepareSecretsResult> {
|
|
315
|
+
const userKeys = await resolveUserKeys(userKeyDb, api);
|
|
316
|
+
|
|
317
|
+
const { secrets, encryptedProjectKey } = await resolveSecrets(db, projectId, options, api);
|
|
318
|
+
|
|
319
|
+
const projectKey = await resolveProjectKey(
|
|
320
|
+
encryptedProjectKey,
|
|
321
|
+
userKeys.encryptedPrivateKey,
|
|
322
|
+
userKeys.salt,
|
|
323
|
+
userKeys.fromCache,
|
|
324
|
+
userKeyDb,
|
|
325
|
+
api,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const decryptedSecrets = await decryptSecrets(
|
|
329
|
+
projectKey,
|
|
330
|
+
secrets.map((s) => ({ key: s.key, encryptedValue: s.encryptedValue })),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const secretsObj: Record<string, string> = {};
|
|
334
|
+
for (const secret of decryptedSecrets) {
|
|
335
|
+
secretsObj[secret.key] = secret.value;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { secrets: secretsObj, count: secrets.length };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function resolveProjectId(options: RunOptions): string | null {
|
|
342
|
+
if (options.project) return options.project;
|
|
343
|
+
if (process.env.RELIC_PROJECT_ID) return process.env.RELIC_PROJECT_ID;
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function runWithApiKey(
|
|
348
|
+
command: string[],
|
|
349
|
+
options: RunOptions,
|
|
350
|
+
startTime: number,
|
|
351
|
+
): Promise<void> {
|
|
352
|
+
const spinner = ora("Authenticating with API key...").start();
|
|
353
|
+
|
|
354
|
+
const projectId = resolveProjectId(options);
|
|
355
|
+
if (!projectId) {
|
|
356
|
+
const configResult = await findConfig();
|
|
357
|
+
if (configResult) {
|
|
358
|
+
return runWithApiKeyAndProjectId(
|
|
359
|
+
command,
|
|
360
|
+
options,
|
|
361
|
+
configResult.config.project_id,
|
|
362
|
+
spinner,
|
|
363
|
+
startTime,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
spinner.fail(pc.red("Project ID is required. Use --project <id> or set RELIC_PROJECT_ID."));
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return runWithApiKeyAndProjectId(command, options, projectId, spinner, startTime);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function runWithApiKeyAndProjectId(
|
|
374
|
+
command: string[],
|
|
375
|
+
options: RunOptions,
|
|
376
|
+
projectId: string,
|
|
377
|
+
spinner: ReturnType<typeof ora>,
|
|
378
|
+
startTime: number,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
spinner.text = "Fetching secrets via API key...";
|
|
381
|
+
const { secrets, count } = await prepareSecretsWithApiKey(projectId, options);
|
|
382
|
+
|
|
383
|
+
spinner.succeed(pc.green(`Injected ${count} secret${count !== 1 ? "s" : ""}`));
|
|
384
|
+
|
|
385
|
+
await executeCommand(command, secrets, count, startTime);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function runWithSession(
|
|
389
|
+
command: string[],
|
|
390
|
+
options: RunOptions,
|
|
391
|
+
startTime: number,
|
|
392
|
+
): Promise<void> {
|
|
393
|
+
const spinner = ora("Checking authentication...").start();
|
|
394
|
+
|
|
395
|
+
const sessionValidation = await validateSession();
|
|
396
|
+
if (!sessionValidation.isValid || sessionValidation.isExpired) {
|
|
397
|
+
spinner.fail(pc.red("Not logged in. Run 'relic login' first."));
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
spinner.text = "Verifying password...";
|
|
402
|
+
const hasPass = await hasPassword();
|
|
403
|
+
if (!hasPass) {
|
|
404
|
+
spinner.fail(pc.red("No password set. Run 'relic tui' to set up your password first."));
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!(await getPasswordFromStorage())) {
|
|
409
|
+
spinner.fail(pc.red("Could not retrieve password. Please re-authenticate."));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
spinner.text = "Loading configuration...";
|
|
414
|
+
const configResult = await findConfig();
|
|
415
|
+
if (!configResult) {
|
|
416
|
+
spinner.fail(pc.red("No relic.toml found. Run 'relic init' first."));
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const projectId = resolveProjectId(options) ?? configResult.config.project_id;
|
|
421
|
+
|
|
422
|
+
spinner.text = "Preparing secrets...";
|
|
423
|
+
const db = await getCacheDb();
|
|
424
|
+
const userKeyDb = await getUserKeyCacheDb();
|
|
425
|
+
const api = getApi();
|
|
426
|
+
|
|
427
|
+
const { secrets, count } = await prepareSecrets(projectId, options, db, userKeyDb, api);
|
|
428
|
+
|
|
429
|
+
spinner.succeed(pc.green(`Injected ${count} secret${count !== 1 ? "s" : ""}`));
|
|
430
|
+
|
|
431
|
+
await executeCommand(command, secrets, count, startTime);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function executeCommand(
|
|
435
|
+
command: string[],
|
|
436
|
+
secrets: Record<string, string>,
|
|
437
|
+
count: number,
|
|
438
|
+
startTime: number,
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
const runner = await RunnerBridge.getInstance();
|
|
441
|
+
|
|
442
|
+
const commandJson = JSON.stringify(command);
|
|
443
|
+
const secretsJson = JSON.stringify(secrets);
|
|
444
|
+
|
|
445
|
+
const commandBuffer = Buffer.from(`${commandJson}\0`, "utf-8");
|
|
446
|
+
const secretsBuffer = Buffer.from(`${secretsJson}\0`, "utf-8");
|
|
447
|
+
|
|
448
|
+
const commandPtr = ptr(commandBuffer);
|
|
449
|
+
const secretsPtr = ptr(secretsBuffer);
|
|
450
|
+
|
|
451
|
+
let exitCode = -1;
|
|
452
|
+
try {
|
|
453
|
+
exitCode = runner.runWithSecrets(commandPtr, secretsPtr);
|
|
454
|
+
} finally {
|
|
455
|
+
commandBuffer.fill(0);
|
|
456
|
+
secretsBuffer.fill(0);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
trackEvent("cli_run_completed", {
|
|
460
|
+
secret_count: count,
|
|
461
|
+
exit_code: exitCode,
|
|
462
|
+
duration_ms: Date.now() - startTime,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
process.exit(exitCode);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export default async function run(command: string[], options: RunOptions) {
|
|
469
|
+
if (!options.environment) {
|
|
470
|
+
console.error(pc.red("Error: --env is required"));
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (command.length === 0) {
|
|
475
|
+
console.error(pc.red("Error: No command specified"));
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (options.scope && !["client", "server", "shared"].includes(options.scope.toLowerCase())) {
|
|
480
|
+
console.error(pc.red("Error: --scope must be: client, server, or shared"));
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
if (options.scope) {
|
|
484
|
+
options.scope = options.scope.toLowerCase() as SecretScope;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const startTime = Date.now();
|
|
488
|
+
trackEvent("cli_run_started", {
|
|
489
|
+
has_folder: !!options.folder,
|
|
490
|
+
has_scope: !!options.scope,
|
|
491
|
+
mode: isApiKeyMode() ? "api_key" : "session",
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
try {
|
|
495
|
+
if (isApiKeyMode()) {
|
|
496
|
+
await runWithApiKey(command, options, startTime);
|
|
497
|
+
} else {
|
|
498
|
+
await runWithSession(command, options, startTime);
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log.error("Run failed", err);
|
|
502
|
+
trackEvent("cli_run_completed", { success: false, duration_ms: Date.now() - startTime });
|
|
503
|
+
|
|
504
|
+
if (err instanceof ProPlanRequiredError) {
|
|
505
|
+
const spinner = ora();
|
|
506
|
+
spinner.fail(pc.red(err.message));
|
|
507
|
+
console.log();
|
|
508
|
+
console.log(pc.dim(" Upgrade at: ") + pc.underline(err.upgradeUrl));
|
|
509
|
+
console.log();
|
|
510
|
+
|
|
511
|
+
if (process.stdin.isTTY) {
|
|
512
|
+
const readline = await import("node:readline");
|
|
513
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
514
|
+
console.log(pc.dim(" Press Enter to open the upgrade page, or Ctrl+C to exit."));
|
|
515
|
+
await new Promise<void>((resolve) =>
|
|
516
|
+
rl.once("line", () => {
|
|
517
|
+
rl.close();
|
|
518
|
+
resolve();
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
const openModule = await import("open");
|
|
522
|
+
await openModule.default(err.upgradeUrl);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const spinner = ora();
|
|
529
|
+
spinner.fail(pc.red(err instanceof Error ? err.message : String(err)));
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("@repo/logger", () => ({
|
|
4
|
+
createLogger: () => ({ error: mock(), info: mock(), debug: mock(), warn: mock() }),
|
|
5
|
+
trackEvent: mock(),
|
|
6
|
+
trackError: mock(),
|
|
7
|
+
initLogger: mock(() => Promise.resolve()),
|
|
8
|
+
flushTelemetry: mock(() => Promise.resolve()),
|
|
9
|
+
saveTelemetryPreference: mock(),
|
|
10
|
+
getTelemetryPreference: mock(() => null),
|
|
11
|
+
isFirstRun: mock(() => false),
|
|
12
|
+
getConfigDir: mock(() => "/tmp"),
|
|
13
|
+
getLogsDir: mock(() => "/tmp"),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
mock.module("ora", () => ({
|
|
17
|
+
default: () => ({
|
|
18
|
+
start: mock(function (this: unknown) {
|
|
19
|
+
return this;
|
|
20
|
+
}),
|
|
21
|
+
stop: mock(),
|
|
22
|
+
succeed: mock(),
|
|
23
|
+
warn: mock(),
|
|
24
|
+
fail: mock(),
|
|
25
|
+
set text(_v: string) {
|
|
26
|
+
// no-op
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module("../lib/api", () => ({
|
|
32
|
+
getApi: mock(() => ({})),
|
|
33
|
+
exportSecretsViaApiKey: mock(),
|
|
34
|
+
fetchUserKeysViaApiKey: mock(),
|
|
35
|
+
ProPlanRequiredError: class extends Error {
|
|
36
|
+
upgradeUrl: string;
|
|
37
|
+
constructor(msg: string, url: string) {
|
|
38
|
+
super(msg);
|
|
39
|
+
this.upgradeUrl = url;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
mock.module("../lib/config", () => ({
|
|
45
|
+
findConfig: mock(() => Promise.resolve(null)),
|
|
46
|
+
configExists: mock(() => Promise.resolve(false)),
|
|
47
|
+
createConfig: mock(() => ({ project_id: "" })),
|
|
48
|
+
createRelicDir: mock(() => Promise.resolve("")),
|
|
49
|
+
getConfigFilePath: mock(() => "relic.toml"),
|
|
50
|
+
saveConfig: mock(() => Promise.resolve("")),
|
|
51
|
+
loadConfig: mock(() => Promise.resolve(null)),
|
|
52
|
+
getRelicDir: mock(() => ""),
|
|
53
|
+
getCacheDbPath: mock(() => ""),
|
|
54
|
+
findRelicDir: mock(() => Promise.resolve(null)),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
mock.module("../lib/crypto", () => ({
|
|
58
|
+
getProjectKey: mock(),
|
|
59
|
+
decryptSecrets: mock(),
|
|
60
|
+
ProjectKeyError: class extends Error {
|
|
61
|
+
code: string;
|
|
62
|
+
constructor(msg: string, code: string) {
|
|
63
|
+
super(msg);
|
|
64
|
+
this.code = code;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
mock.module("../ffi/bridge", () => ({
|
|
70
|
+
RunnerBridge: { getInstance: mock(() => Promise.resolve({})) },
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// NOTE: Do not mock helpers/cache or @repo/auth here. mock.module() is global and persists
|
|
74
|
+
// to other test files. run.test.ts needs real implementations. Our scope validation test
|
|
75
|
+
// exits before any cache/auth usage, so no mocks are needed.
|
|
76
|
+
|
|
77
|
+
const { default: run, resolveProjectId } = await import("./run");
|
|
78
|
+
|
|
79
|
+
describe("run validation", () => {
|
|
80
|
+
let exitSpy: ReturnType<typeof spyOn>;
|
|
81
|
+
let errorSpy: ReturnType<typeof spyOn>;
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
exitSpy = spyOn(process, "exit").mockImplementation(() => {
|
|
85
|
+
throw new Error("process.exit");
|
|
86
|
+
});
|
|
87
|
+
errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
exitSpy.mockRestore();
|
|
92
|
+
errorSpy.mockRestore();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("rejects invalid scope before reaching decrypt pipeline", async () => {
|
|
96
|
+
try {
|
|
97
|
+
await run(["echo", "hi"], { environment: "prod", scope: "invalid" as any });
|
|
98
|
+
} catch {
|
|
99
|
+
// process.exit throws
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
103
|
+
const output = errorSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(" ");
|
|
104
|
+
expect(output).toContain("--scope must be");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("resolveProjectId", () => {
|
|
109
|
+
const originalEnv = process.env.RELIC_PROJECT_ID;
|
|
110
|
+
|
|
111
|
+
afterEach(() => {
|
|
112
|
+
if (originalEnv !== undefined) {
|
|
113
|
+
process.env.RELIC_PROJECT_ID = originalEnv;
|
|
114
|
+
} else {
|
|
115
|
+
delete process.env.RELIC_PROJECT_ID;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns options.project when provided", () => {
|
|
120
|
+
const result = resolveProjectId({ environment: "prod", project: "proj_123" });
|
|
121
|
+
expect(result).toBe("proj_123");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("falls back to RELIC_PROJECT_ID env var", () => {
|
|
125
|
+
process.env.RELIC_PROJECT_ID = "env_proj_456";
|
|
126
|
+
const result = resolveProjectId({ environment: "prod" });
|
|
127
|
+
expect(result).toBe("env_proj_456");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("returns null when neither is set", () => {
|
|
131
|
+
delete process.env.RELIC_PROJECT_ID;
|
|
132
|
+
const result = resolveProjectId({ environment: "prod" });
|
|
133
|
+
expect(result).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getTelemetryPreference, saveTelemetryPreference } from "@repo/logger";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
|
|
4
|
+
export async function telemetryStatus() {
|
|
5
|
+
const preference = getTelemetryPreference();
|
|
6
|
+
|
|
7
|
+
if (preference === null) {
|
|
8
|
+
console.log(`Telemetry: ${pc.green("enabled")} ${pc.dim("(default)")}`);
|
|
9
|
+
} else if (preference) {
|
|
10
|
+
console.log(`Telemetry: ${pc.green("enabled")}`);
|
|
11
|
+
} else {
|
|
12
|
+
console.log(`Telemetry: ${pc.yellow("disabled")}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log();
|
|
16
|
+
console.log(pc.dim("Relic collects anonymous usage data to improve the product."));
|
|
17
|
+
console.log(pc.dim("No secrets, project names, or personal data are ever collected."));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function telemetryEnable() {
|
|
21
|
+
saveTelemetryPreference(true);
|
|
22
|
+
console.log(pc.green("Telemetry enabled"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function telemetryDisable() {
|
|
26
|
+
saveTelemetryPreference(false);
|
|
27
|
+
console.log(pc.yellow("Telemetry disabled"));
|
|
28
|
+
}
|