@secrefs/node 0.1.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.
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // bin/secrefs.ts
27
+ var import_node_fs = require("fs");
28
+ var import_node_path2 = __toESM(require("path"), 1);
29
+ var import_commander = require("commander");
30
+ var import_cross_spawn = __toESM(require("cross-spawn"), 1);
31
+
32
+ // src/providers/aws.ts
33
+ var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
34
+
35
+ // src/providers/base.ts
36
+ var SecretFetchError = class extends Error {
37
+ constructor(provider, path3, cause) {
38
+ super(`[${provider}] failed to fetch secret at "${path3}": ${errorMessage(cause)}`);
39
+ this.provider = provider;
40
+ this.path = path3;
41
+ this.name = "SecretFetchError";
42
+ }
43
+ provider;
44
+ path;
45
+ };
46
+ var BaseSecretProvider = class {
47
+ async fetchBatch(requests) {
48
+ const settled = await Promise.allSettled(requests.map((r) => this.fetchOne(r)));
49
+ return settled.map((result, i) => {
50
+ const request = requests[i];
51
+ if (result.status === "fulfilled") {
52
+ return result.value;
53
+ }
54
+ throw new SecretFetchError(this.name, request?.path ?? "<unknown>", result.reason);
55
+ });
56
+ }
57
+ };
58
+ function errorMessage(err) {
59
+ if (err instanceof Error) return err.message;
60
+ return String(err);
61
+ }
62
+ function extractField(raw, field, context) {
63
+ if (!field) return raw;
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(raw);
67
+ } catch {
68
+ throw new Error(
69
+ `[${context.provider}] secret at "${context.path}" is not JSON, cannot extract field "${field}"`
70
+ );
71
+ }
72
+ let current = parsed;
73
+ for (const part of field.split(".")) {
74
+ if (current === null || typeof current !== "object") {
75
+ throw new Error(
76
+ `[${context.provider}] field "${field}" not found in secret at "${context.path}"`
77
+ );
78
+ }
79
+ current = current[part];
80
+ }
81
+ if (current === void 0) {
82
+ throw new Error(
83
+ `[${context.provider}] field "${field}" not found in secret at "${context.path}"`
84
+ );
85
+ }
86
+ return typeof current === "object" ? JSON.stringify(current) : String(current);
87
+ }
88
+
89
+ // src/ttlCache.ts
90
+ var TtlCache = class {
91
+ /** Settled values, only populated when a TTL is configured. */
92
+ entries = /* @__PURE__ */ new Map();
93
+ /** Requests currently in flight, tracked separately from `entries`
94
+ * because coalescing and caching are different things: sharing an
95
+ * unsettled request holds no value past the moment it resolves, so it
96
+ * stays correct even with caching fully disabled. */
97
+ inFlight = /* @__PURE__ */ new Map();
98
+ ttlMs;
99
+ now;
100
+ constructor(options = {}) {
101
+ this.ttlMs = options.ttlMs ?? 0;
102
+ this.now = options.now ?? Date.now;
103
+ }
104
+ /**
105
+ * Returns the cached value for `key` if it's still fresh, otherwise
106
+ * calls `load` and caches that. In-flight promises are shared, so N
107
+ * concurrent expansions of the same reference make one request rather
108
+ * than N even when the TTL is zero - that's request coalescing, not
109
+ * caching, and it doesn't hold a value past its use.
110
+ *
111
+ * A rejected load is evicted rather than remembered, so a transient
112
+ * failure doesn't become a sticky one.
113
+ */
114
+ async fetch(key, load) {
115
+ const pendingExisting = this.inFlight.get(key);
116
+ if (pendingExisting) return pendingExisting;
117
+ const cached = this.entries.get(key);
118
+ if (cached && this.ttlMs > 0 && this.now() - cached.storedAt < this.ttlMs) {
119
+ return cached.value;
120
+ }
121
+ const pending = load();
122
+ this.inFlight.set(key, pending);
123
+ try {
124
+ const value = await pending;
125
+ if (this.ttlMs > 0) {
126
+ this.entries.set(key, { value: Promise.resolve(value), storedAt: this.now() });
127
+ }
128
+ return value;
129
+ } catch (err) {
130
+ this.entries.delete(key);
131
+ throw err;
132
+ } finally {
133
+ this.inFlight.delete(key);
134
+ }
135
+ }
136
+ /** Drops everything - used when a credential changes underneath the
137
+ * cache and anything fetched with the old one is suspect. */
138
+ clear() {
139
+ this.entries.clear();
140
+ }
141
+ };
142
+
143
+ // src/controlPlaneClient.ts
144
+ var ControlPlaneRequestError = class extends Error {
145
+ constructor(status, message) {
146
+ super(message);
147
+ this.status = status;
148
+ this.name = "ControlPlaneRequestError";
149
+ }
150
+ status;
151
+ };
152
+ var ControlPlaneClient = class {
153
+ baseUrl;
154
+ token;
155
+ fetchImpl;
156
+ constructor(options) {
157
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
158
+ this.token = options.token;
159
+ this.fetchImpl = options.fetchImpl ?? fetch;
160
+ }
161
+ /** Authenticates, authorizes, and resolves a credential for `alias`/`path`
162
+ * - see the control plane's `POST /v1/credentials/mint`. Throws
163
+ * `ControlPlaneRequestError` for any non-2xx response. */
164
+ async mintCredential(alias, path3) {
165
+ let response;
166
+ try {
167
+ response = await this.fetchImpl(`${this.baseUrl}/v1/credentials/mint`, {
168
+ method: "POST",
169
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
170
+ body: JSON.stringify({ alias, path: path3 })
171
+ });
172
+ } catch (err) {
173
+ throw new Error(
174
+ `could not reach control plane at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`
175
+ );
176
+ }
177
+ if (!response.ok) {
178
+ const body = await response.json().catch(() => ({}));
179
+ throw new ControlPlaneRequestError(
180
+ response.status,
181
+ body.error ?? `control plane returned ${response.status} for alias "${alias}" path "${path3}"`
182
+ );
183
+ }
184
+ return await response.json();
185
+ }
186
+ };
187
+
188
+ // src/providers/aws.ts
189
+ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
190
+ name = "aws";
191
+ explicitClient;
192
+ region;
193
+ controlPlane;
194
+ controlPlaneClient;
195
+ ambientClient = null;
196
+ rawCache;
197
+ constructor(options = {}) {
198
+ super();
199
+ this.explicitClient = options.client;
200
+ this.region = options.region;
201
+ this.controlPlane = options.controlPlane;
202
+ this.rawCache = new TtlCache({ ttlMs: options.cacheTtlMs });
203
+ if (this.controlPlane) {
204
+ this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
205
+ }
206
+ }
207
+ /** Resolves the `SecretsManagerClient` to use for one `path` - lazily
208
+ * built and reused in ambient mode, freshly minted per call in
209
+ * control-plane mode. `explicitClient` (test injection) always wins. */
210
+ async clientFor(path3) {
211
+ if (this.explicitClient) return this.explicitClient;
212
+ if (this.controlPlane && this.controlPlaneClient) {
213
+ const minted = await this.controlPlaneClient.mintCredential(this.controlPlane.alias, path3);
214
+ if (minted.provider !== "aws") {
215
+ throw new Error(
216
+ `control plane returned a "${minted.provider}" credential for alias "${this.controlPlane.alias}", expected "aws"`
217
+ );
218
+ }
219
+ return this.buildClientFromMintedCredentials(minted.credentials);
220
+ }
221
+ if (!this.ambientClient) this.ambientClient = new import_client_secrets_manager.SecretsManagerClient({ region: this.region });
222
+ return this.ambientClient;
223
+ }
224
+ buildClientFromMintedCredentials(credentials) {
225
+ return new import_client_secrets_manager.SecretsManagerClient({
226
+ region: this.region,
227
+ credentials: {
228
+ accessKeyId: credentials.accessKeyId,
229
+ secretAccessKey: credentials.secretAccessKey,
230
+ sessionToken: credentials.sessionToken
231
+ }
232
+ });
233
+ }
234
+ getRaw(path3) {
235
+ return this.rawCache.fetch(path3, async () => {
236
+ try {
237
+ const client = await this.clientFor(path3);
238
+ const response = await client.send(new import_client_secrets_manager.GetSecretValueCommand({ SecretId: path3 }));
239
+ if (typeof response.SecretString === "string") {
240
+ return response.SecretString;
241
+ }
242
+ if (response.SecretBinary) {
243
+ return Buffer.from(response.SecretBinary).toString("utf8");
244
+ }
245
+ throw new Error(`secret "${path3}" has no SecretString or SecretBinary payload`);
246
+ } catch (err) {
247
+ throw new Error(`could not fetch secret "${path3}": ${errorMessage(err)}`);
248
+ }
249
+ });
250
+ }
251
+ async fetchOne(request) {
252
+ const raw = await this.getRaw(request.path);
253
+ return extractField(raw, request.field, { provider: this.name, path: request.path });
254
+ }
255
+ async healthCheck() {
256
+ try {
257
+ if (this.controlPlane) {
258
+ const controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
259
+ try {
260
+ await controlPlaneClient.mintCredential(this.controlPlane.alias, "__secrefs_health_check__");
261
+ } catch (err) {
262
+ if (err instanceof ControlPlaneRequestError) {
263
+ return { provider: this.name, ok: true, message: "control plane reachable" };
264
+ }
265
+ throw err;
266
+ }
267
+ return { provider: this.name, ok: true };
268
+ }
269
+ const client = await this.clientFor("__secrefs_health_check__");
270
+ await client.send(new import_client_secrets_manager.ListSecretsCommand({ MaxResults: 1 }));
271
+ return { provider: this.name, ok: true };
272
+ } catch (err) {
273
+ return { provider: this.name, ok: false, message: errorMessage(err) };
274
+ }
275
+ }
276
+ };
277
+
278
+ // src/providers/vault.ts
279
+ var import_node_vault = __toESM(require("node-vault"), 1);
280
+ var VaultProvider = class extends BaseSecretProvider {
281
+ name = "vault";
282
+ explicitClient;
283
+ endpoint;
284
+ token;
285
+ client = null;
286
+ dataCache;
287
+ constructor(options = {}) {
288
+ super();
289
+ this.explicitClient = options.client;
290
+ this.endpoint = options.endpoint ?? process.env.VAULT_ADDR;
291
+ this.token = options.token ?? process.env.VAULT_TOKEN;
292
+ this.dataCache = new TtlCache({ ttlMs: options.cacheTtlMs });
293
+ }
294
+ getClient() {
295
+ if (this.explicitClient) return this.explicitClient;
296
+ if (this.client) return this.client;
297
+ if (!this.endpoint) {
298
+ throw new Error("VAULT_ADDR is not set (required for sec://vault/... references)");
299
+ }
300
+ if (!this.token) {
301
+ throw new Error("VAULT_TOKEN is not set (required for sec://vault/... references)");
302
+ }
303
+ this.client = (0, import_node_vault.default)({ endpoint: this.endpoint, token: this.token });
304
+ return this.client;
305
+ }
306
+ getData(path3) {
307
+ return this.dataCache.fetch(
308
+ path3,
309
+ () => this.getClient().read(path3).then((response) => {
310
+ const outer = response.data;
311
+ if (outer === void 0 || outer === null) {
312
+ throw new Error(`no data returned for path "${path3}"`);
313
+ }
314
+ if (typeof outer === "object" && "data" in outer && "metadata" in outer) {
315
+ return outer.data;
316
+ }
317
+ return outer;
318
+ }).catch((err) => {
319
+ throw new Error(`could not read Vault path "${path3}": ${errorMessage(err)}`);
320
+ })
321
+ );
322
+ }
323
+ async fetchOne(request) {
324
+ const data = await this.getData(request.path);
325
+ if (!request.field) {
326
+ const keys = Object.keys(data);
327
+ if (keys.length === 1) {
328
+ const only = data[keys[0]];
329
+ return typeof only === "string" ? only : JSON.stringify(only);
330
+ }
331
+ return JSON.stringify(data);
332
+ }
333
+ return extractField(JSON.stringify(data), request.field, {
334
+ provider: this.name,
335
+ path: request.path
336
+ });
337
+ }
338
+ async healthCheck() {
339
+ try {
340
+ await this.getClient().health();
341
+ return { provider: this.name, ok: true };
342
+ } catch (err) {
343
+ return { provider: this.name, ok: false, message: errorMessage(err) };
344
+ }
345
+ }
346
+ };
347
+
348
+ // src/providers/local.ts
349
+ var import_promises = require("fs/promises");
350
+ var import_node_path = __toESM(require("path"), 1);
351
+ var DEFAULT_FILENAME = ".secrefs.local.json";
352
+ var LocalProvider = class extends BaseSecretProvider {
353
+ name = "local";
354
+ filePath;
355
+ /** Re-read on every fetch. The file is local and tiny, and caching
356
+ * it meant editing it mid-session silently did nothing. */
357
+ cache = null;
358
+ cacheFile;
359
+ constructor(options = {}) {
360
+ super();
361
+ this.filePath = options.filePath ?? process.env.SECREFS_LOCAL_FILE ?? import_node_path.default.join(process.cwd(), DEFAULT_FILENAME);
362
+ this.cacheFile = options.cacheFile ?? false;
363
+ }
364
+ async load() {
365
+ if (this.cache && this.cacheFile) return this.cache;
366
+ let raw;
367
+ try {
368
+ raw = await (0, import_promises.readFile)(this.filePath, "utf8");
369
+ } catch (err) {
370
+ throw new Error(
371
+ `[local] could not read local secrets file at "${this.filePath}": ${err instanceof Error ? err.message : String(err)}. This file is gitignored by convention - see .secrefs.local.json in .gitignore.`
372
+ );
373
+ }
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(raw);
377
+ } catch (err) {
378
+ throw new Error(
379
+ `[local] "${this.filePath}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
380
+ );
381
+ }
382
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
383
+ throw new Error(`[local] "${this.filePath}" must contain a top-level JSON object`);
384
+ }
385
+ this.cache = parsed;
386
+ return this.cache;
387
+ }
388
+ async fetchOne(request) {
389
+ const data = await this.load();
390
+ if (!(request.path in data)) {
391
+ throw new Error(`[local] no entry for path "${request.path}" in ${this.filePath}`);
392
+ }
393
+ const entry = data[request.path];
394
+ const raw = typeof entry === "string" ? entry : JSON.stringify(entry);
395
+ return extractField(raw, request.field, { provider: this.name, path: request.path });
396
+ }
397
+ async healthCheck() {
398
+ try {
399
+ await this.load();
400
+ return { provider: this.name, ok: true, message: this.filePath };
401
+ } catch (err) {
402
+ return {
403
+ provider: this.name,
404
+ ok: false,
405
+ message: err instanceof Error ? err.message : String(err)
406
+ };
407
+ }
408
+ }
409
+ };
410
+
411
+ // src/providers/bitwarden.ts
412
+ var import_sdk_napi = require("@bitwarden/sdk-napi");
413
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
414
+ var BitwardenProvider = class extends BaseSecretProvider {
415
+ name = "bitwarden";
416
+ explicitClient;
417
+ ambientAccessToken;
418
+ ambientOrganizationId;
419
+ apiUrl;
420
+ identityUrl;
421
+ stateFile;
422
+ controlPlane;
423
+ controlPlaneClient;
424
+ client = null;
425
+ loggedInAccessToken = null;
426
+ loggedIn = null;
427
+ organizationId;
428
+ /** Secret name -> id, populated by one `list()` call the first time a
429
+ * non-UUID path is requested. Invalidated if `organizationId` ever
430
+ * changes (control-plane mode, defensively - static in practice). */
431
+ nameToId = null;
432
+ constructor(options = {}) {
433
+ super();
434
+ this.explicitClient = options.client;
435
+ this.apiUrl = options.apiUrl ?? process.env.BWS_API_URL;
436
+ this.identityUrl = options.identityUrl ?? process.env.BWS_IDENTITY_URL;
437
+ this.stateFile = options.stateFile;
438
+ this.controlPlane = options.controlPlane;
439
+ if (this.controlPlane) {
440
+ this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
441
+ } else {
442
+ this.ambientAccessToken = options.accessToken ?? process.env.BWS_ACCESS_TOKEN;
443
+ this.ambientOrganizationId = options.organizationId ?? process.env.BWS_ORGANIZATION_ID;
444
+ this.organizationId = this.ambientOrganizationId;
445
+ }
446
+ }
447
+ getClient() {
448
+ if (this.explicitClient) return this.explicitClient;
449
+ if (this.client) return this.client;
450
+ this.client = new import_sdk_napi.BitwardenClient({
451
+ apiUrl: this.apiUrl,
452
+ identityUrl: this.identityUrl
453
+ });
454
+ return this.client;
455
+ }
456
+ async loginWith(accessToken, organizationId) {
457
+ if (organizationId !== this.organizationId) {
458
+ this.nameToId = null;
459
+ this.organizationId = organizationId;
460
+ }
461
+ if (this.loggedInAccessToken === accessToken && this.loggedIn) return this.loggedIn;
462
+ this.loggedInAccessToken = accessToken;
463
+ this.loggedIn = this.getClient().auth().loginAccessToken(accessToken, this.stateFile).catch((err) => {
464
+ this.loggedIn = null;
465
+ this.loggedInAccessToken = null;
466
+ throw new Error(`could not authenticate with the given access token: ${errorMessage(err)}`);
467
+ });
468
+ return this.loggedIn;
469
+ }
470
+ /** Ensures a session exists for `path`. Ambient mode logs in once
471
+ * (memoized) with the ambient token; control-plane mode requests a
472
+ * distribution for this specific `path` every call - see the
473
+ * `controlPlane` option's docs for why that RBAC check has to be
474
+ * per-path even though the token it returns doesn't vary. */
475
+ async ensureLoggedInFor(path3) {
476
+ if (!this.controlPlane) {
477
+ if (!this.ambientAccessToken) {
478
+ throw new Error("BWS_ACCESS_TOKEN is not set (required for sec://bitwarden/... references)");
479
+ }
480
+ return this.loginWith(this.ambientAccessToken, this.ambientOrganizationId);
481
+ }
482
+ const minted = await this.controlPlaneClient.mintCredential(this.controlPlane.alias, path3);
483
+ if (minted.provider !== "bitwarden") {
484
+ throw new Error(
485
+ `control plane returned a "${minted.provider}" credential for alias "${this.controlPlane.alias}", expected "bitwarden"`
486
+ );
487
+ }
488
+ return this.loginWith(minted.credentials.accessToken, minted.credentials.organizationId);
489
+ }
490
+ /** Assumes `ensureLoggedInFor(path)` has already run for this exact
491
+ * `path` - callers always do that first, so `this.organizationId` is
492
+ * already whatever this path's session resolved to. */
493
+ async resolveSecretId(path3) {
494
+ if (UUID_PATTERN.test(path3)) return path3;
495
+ if (!this.organizationId) {
496
+ throw new Error(
497
+ `"${path3}" is not a secret UUID, and no organizationId is available to look up a secret by name (set BWS_ORGANIZATION_ID, use the UUID directly, or - in control-plane mode - the distributed credential didn't include one)`
498
+ );
499
+ }
500
+ if (!this.nameToId) {
501
+ const organizationId = this.organizationId;
502
+ this.nameToId = (async () => {
503
+ const { data } = await this.getClient().secrets().list(organizationId);
504
+ return new Map(data.map((s) => [s.key, s.id]));
505
+ })();
506
+ }
507
+ const map = await this.nameToId;
508
+ const id = map.get(path3);
509
+ if (!id) {
510
+ throw new Error(`no secret named "${path3}" found in organization "${this.organizationId}"`);
511
+ }
512
+ return id;
513
+ }
514
+ async fetchOne(request) {
515
+ let id;
516
+ let secret;
517
+ try {
518
+ await this.ensureLoggedInFor(request.path);
519
+ id = await this.resolveSecretId(request.path);
520
+ secret = await this.getClient().secrets().get(id);
521
+ } catch (err) {
522
+ throw new Error(`could not fetch secret "${request.path}": ${errorMessage(err)}`);
523
+ }
524
+ return extractField(secret.value, request.field, { provider: this.name, path: request.path });
525
+ }
526
+ async healthCheck() {
527
+ try {
528
+ await this.ensureLoggedInFor("__secrefs_health_check__");
529
+ return { provider: this.name, ok: true };
530
+ } catch (err) {
531
+ return { provider: this.name, ok: false, message: errorMessage(err) };
532
+ }
533
+ }
534
+ };
535
+
536
+ // src/parser.ts
537
+ var SEC_REF_PATTERN = /^sec:\/\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\/([^\s#]+)(?:#([^\s#]+))?$/;
538
+ var SecRefParseError = class extends Error {
539
+ constructor(raw, reason) {
540
+ super(`Invalid secret reference "${raw}": ${reason}`);
541
+ this.raw = raw;
542
+ this.reason = reason;
543
+ this.name = "SecRefParseError";
544
+ }
545
+ raw;
546
+ reason;
547
+ };
548
+ function isSecretRef(value) {
549
+ return typeof value === "string" && value.startsWith("sec://");
550
+ }
551
+ function parseSecretRef(raw) {
552
+ if (typeof raw !== "string") {
553
+ throw new SecRefParseError(String(raw), "reference must be a string");
554
+ }
555
+ const trimmed = raw.trim();
556
+ if (!trimmed.startsWith("sec://")) {
557
+ throw new SecRefParseError(raw, 'must start with "sec://"');
558
+ }
559
+ const match = SEC_REF_PATTERN.exec(trimmed);
560
+ if (!match) {
561
+ throw new SecRefParseError(
562
+ raw,
563
+ "does not match sec://<provider>/<path>[#field] format"
564
+ );
565
+ }
566
+ const [, provider, path3, field] = match;
567
+ if (!provider) {
568
+ throw new SecRefParseError(raw, "missing provider alias");
569
+ }
570
+ if (!path3) {
571
+ throw new SecRefParseError(raw, "missing secret path");
572
+ }
573
+ return {
574
+ raw,
575
+ provider: provider.toLowerCase(),
576
+ path: path3,
577
+ field: field || void 0
578
+ };
579
+ }
580
+
581
+ // src/resolver.ts
582
+ var SecRefsResolutionError = class extends Error {
583
+ constructor(errors) {
584
+ super(
585
+ `Failed to resolve ${errors.length} secret reference(s):
586
+ ` + errors.map((e) => ` - ${e.key}: ${e.ref} -> ${e.message}`).join("\n")
587
+ );
588
+ this.errors = errors;
589
+ this.name = "SecRefsResolutionError";
590
+ }
591
+ errors;
592
+ };
593
+ async function resolveOne(ref, providers) {
594
+ const provider = providers[ref.provider];
595
+ if (!provider) {
596
+ const available = Object.keys(providers).join(", ") || "none configured";
597
+ throw new Error(`unknown provider "${ref.provider}" (available: ${available})`);
598
+ }
599
+ const request = { path: ref.path, field: ref.field };
600
+ return provider.fetchOne(request);
601
+ }
602
+ async function expandKeyValueMap(input, options) {
603
+ const strict = options.strict ?? true;
604
+ const output = {};
605
+ const pending = [];
606
+ for (const [key, value] of Object.entries(input)) {
607
+ if (value === void 0) continue;
608
+ if (!isSecretRef(value)) {
609
+ output[key] = value;
610
+ continue;
611
+ }
612
+ try {
613
+ pending.push({ key, ref: parseSecretRef(value) });
614
+ } catch (err) {
615
+ if (strict) throw err;
616
+ output[key] = value;
617
+ }
618
+ }
619
+ if (pending.length === 0) {
620
+ return output;
621
+ }
622
+ const settled = await Promise.allSettled(
623
+ pending.map(({ ref }) => resolveOne(ref, options.providers))
624
+ );
625
+ const errors = [];
626
+ settled.forEach((result, i) => {
627
+ const { key, ref } = pending[i];
628
+ if (result.status === "fulfilled") {
629
+ output[key] = result.value;
630
+ } else {
631
+ errors.push({ key, ref: ref.raw, message: errorMessage(result.reason) });
632
+ }
633
+ });
634
+ if (errors.length > 0) {
635
+ throw new SecRefsResolutionError(errors);
636
+ }
637
+ return output;
638
+ }
639
+ async function expandProcessEnv(options) {
640
+ const resolved = await expandKeyValueMap(process.env, options);
641
+ const changedKeys = [];
642
+ for (const [key, value] of Object.entries(resolved)) {
643
+ if (process.env[key] !== value) {
644
+ process.env[key] = value;
645
+ changedKeys.push(key);
646
+ }
647
+ }
648
+ return changedKeys;
649
+ }
650
+ async function checkReferences(input, options) {
651
+ const results = [];
652
+ const parsedEntries = [];
653
+ for (const [key, value] of Object.entries(input)) {
654
+ if (value === void 0 || !isSecretRef(value)) continue;
655
+ try {
656
+ parsedEntries.push({ key, ref: parseSecretRef(value) });
657
+ } catch (err) {
658
+ results.push({ key, ref: value, provider: "unknown", ok: false, message: errorMessage(err) });
659
+ }
660
+ }
661
+ const settled = await Promise.allSettled(
662
+ parsedEntries.map(({ ref }) => resolveOne(ref, options.providers))
663
+ );
664
+ settled.forEach((result, i) => {
665
+ const { key, ref } = parsedEntries[i];
666
+ results.push({
667
+ key,
668
+ ref: ref.raw,
669
+ provider: ref.provider,
670
+ ok: result.status === "fulfilled",
671
+ message: result.status === "rejected" ? errorMessage(result.reason) : void 0
672
+ });
673
+ });
674
+ return results;
675
+ }
676
+
677
+ // src/envFile.ts
678
+ var import_dotenv = require("dotenv");
679
+ var UNQUOTED_SEC_REF_LINE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(sec:\/\/\S.*)$/;
680
+ function recoverTruncatedSecRefs(rawText, parsed) {
681
+ const result = { ...parsed };
682
+ for (const line of rawText.split(/\r?\n/)) {
683
+ const match = UNQUOTED_SEC_REF_LINE.exec(line);
684
+ if (!match) continue;
685
+ const [, key, value] = match;
686
+ if (!key || value === void 0) continue;
687
+ result[key] = value.trimEnd();
688
+ }
689
+ return result;
690
+ }
691
+ function parseEnvFileText(rawText) {
692
+ const parsed = (0, import_dotenv.parse)(rawText);
693
+ return recoverTruncatedSecRefs(rawText, parsed);
694
+ }
695
+
696
+ // src/index.ts
697
+ function createDefaultProviders() {
698
+ return {
699
+ aws: new AwsSecretsManagerProvider(),
700
+ vault: new VaultProvider(),
701
+ local: new LocalProvider(),
702
+ bitwarden: new BitwardenProvider()
703
+ };
704
+ }
705
+ var SecRefs = class {
706
+ providers;
707
+ strict;
708
+ constructor(options = {}) {
709
+ this.providers = options.providers ?? createDefaultProviders();
710
+ this.strict = options.strict ?? true;
711
+ }
712
+ get expandOptions() {
713
+ return { providers: this.providers, strict: this.strict };
714
+ }
715
+ /**
716
+ * Expands every `sec://` value found in `process.env`, mutating it in
717
+ * place. Returns the list of env var names that were rewritten.
718
+ */
719
+ async init() {
720
+ return expandProcessEnv(this.expandOptions);
721
+ }
722
+ /**
723
+ * Expands `sec://` values in an arbitrary key/value map (e.g. a parsed
724
+ * `.env` file) without touching `process.env`.
725
+ */
726
+ async expandEnv(env) {
727
+ return expandKeyValueMap(env, this.expandOptions);
728
+ }
729
+ /** Expands a single string if it's a `sec://` reference; otherwise returns it unchanged. */
730
+ async expandString(value) {
731
+ if (!isSecretRef(value)) return value;
732
+ const resolved = await expandKeyValueMap({ __value__: value }, this.expandOptions);
733
+ return resolved.__value__;
734
+ }
735
+ /**
736
+ * Dry-run validation of every `sec://` reference in `env` (defaults to
737
+ * `process.env`). Never returns plaintext secret values.
738
+ */
739
+ async check(env = process.env) {
740
+ return checkReferences(env, this.expandOptions);
741
+ }
742
+ };
743
+ var secRefs = new SecRefs();
744
+
745
+ // bin/secrefs.ts
746
+ var CLI_VERSION = "0.1.0";
747
+ function loadEnvFile(opts) {
748
+ if (opts.envFile === false) return;
749
+ const envFilePath = import_node_path2.default.resolve(process.cwd(), opts.envFile);
750
+ if (!(0, import_node_fs.existsSync)(envFilePath)) return;
751
+ let rawText;
752
+ try {
753
+ rawText = (0, import_node_fs.readFileSync)(envFilePath, "utf8");
754
+ } catch (err) {
755
+ console.error(
756
+ `secrefs: failed to read ${envFilePath}: ${err instanceof Error ? err.message : String(err)}`
757
+ );
758
+ process.exitCode = 1;
759
+ throw err;
760
+ }
761
+ let parsed;
762
+ try {
763
+ parsed = parseEnvFileText(rawText);
764
+ } catch (err) {
765
+ console.error(
766
+ `secrefs: failed to parse ${envFilePath}: ${err instanceof Error ? err.message : String(err)}`
767
+ );
768
+ process.exitCode = 1;
769
+ throw err;
770
+ }
771
+ for (const [key, value] of Object.entries(parsed)) {
772
+ if (process.env[key] === void 0) {
773
+ process.env[key] = value;
774
+ }
775
+ }
776
+ }
777
+ async function runCommand(commandArgs, opts) {
778
+ if (commandArgs.length === 0) {
779
+ console.error("secrefs run: no command given. Usage: secrefs run -- <command> [args...]");
780
+ process.exitCode = 1;
781
+ return;
782
+ }
783
+ try {
784
+ loadEnvFile(opts);
785
+ } catch {
786
+ return;
787
+ }
788
+ const instance = new SecRefs();
789
+ try {
790
+ const changedKeys = await instance.init();
791
+ if (changedKeys.length > 0) {
792
+ console.error(
793
+ `secrefs: resolved ${changedKeys.length} secret reference(s): ${changedKeys.join(", ")}`
794
+ );
795
+ }
796
+ } catch (err) {
797
+ if (err instanceof SecRefsResolutionError) {
798
+ console.error("secrefs: failed to resolve one or more secret references:");
799
+ console.error(err.message);
800
+ } else {
801
+ console.error(`secrefs: ${err instanceof Error ? err.message : String(err)}`);
802
+ }
803
+ process.exitCode = 1;
804
+ return;
805
+ }
806
+ const [cmd, ...args] = commandArgs;
807
+ const child = (0, import_cross_spawn.default)(cmd, args, { stdio: "inherit", env: process.env });
808
+ const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"];
809
+ const handlers = /* @__PURE__ */ new Map();
810
+ for (const signal of forwardedSignals) {
811
+ const handler = () => {
812
+ if (child.pid) child.kill(signal);
813
+ };
814
+ handlers.set(signal, handler);
815
+ process.on(signal, handler);
816
+ }
817
+ const cleanupSignalHandlers = () => {
818
+ for (const [signal, handler] of handlers) {
819
+ process.removeListener(signal, handler);
820
+ }
821
+ };
822
+ child.on("error", (err) => {
823
+ cleanupSignalHandlers();
824
+ console.error(`secrefs: failed to start "${cmd}": ${err.message}`);
825
+ process.exitCode = 1;
826
+ });
827
+ child.on("exit", (code, signal) => {
828
+ cleanupSignalHandlers();
829
+ if (signal) {
830
+ process.kill(process.pid, signal);
831
+ } else {
832
+ process.exitCode = code ?? 0;
833
+ }
834
+ });
835
+ }
836
+ async function checkCommand(opts) {
837
+ try {
838
+ loadEnvFile(opts);
839
+ } catch {
840
+ return;
841
+ }
842
+ const instance = new SecRefs();
843
+ const results = await instance.check();
844
+ if (results.length === 0) {
845
+ console.log("secrefs check: no sec:// references found in the environment.");
846
+ return;
847
+ }
848
+ let failureCount = 0;
849
+ for (const result of results) {
850
+ const icon = result.ok ? "\u2713" : "\u2717";
851
+ console.log(`${icon} ${result.key} -> ${result.ref}`);
852
+ if (!result.ok) {
853
+ failureCount += 1;
854
+ console.error(` ${result.message}`);
855
+ }
856
+ }
857
+ const okCount = results.length - failureCount;
858
+ console.log(`
859
+ ${okCount}/${results.length} reference(s) resolved successfully.`);
860
+ if (failureCount > 0) {
861
+ process.exitCode = 1;
862
+ }
863
+ }
864
+ var program = new import_commander.Command();
865
+ program.name("secrefs").description("BYOV secret reference engine - expand sec:// references in memory at runtime").version(CLI_VERSION);
866
+ program.command("run").description("Resolve sec:// references and spawn a child process with the hydrated environment").option("-f, --env-file <path>", "path to a .env file to load before resolving", ".env").option("--no-env-file", "skip loading a .env file").argument("<command...>", "command to run, e.g. secrefs run -- node server.js").action(async (commandArgs, opts) => {
867
+ await runCommand(commandArgs, opts);
868
+ });
869
+ program.command("check").description("Validate sec:// references and provider reachability without printing plaintext values").option("-f, --env-file <path>", "path to a .env file to load before checking", ".env").option("--no-env-file", "skip loading a .env file").action(async (opts) => {
870
+ await checkCommand(opts);
871
+ });
872
+ program.parseAsync(process.argv).catch((err) => {
873
+ console.error(`secrefs: unexpected error: ${err instanceof Error ? err.message : String(err)}`);
874
+ process.exitCode = 1;
875
+ });
876
+ //# sourceMappingURL=secrefs.cjs.map