@captainsafia/burrow 1.2.0 → 1.3.0-preview.4437197

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.
Files changed (3) hide show
  1. package/dist/api.d.ts +237 -0
  2. package/dist/api.js +327 -0
  3. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -1,11 +1,46 @@
1
1
  // Generated by dts-bundle-generator v9.5.1
2
2
 
3
+ export interface TrustedPath {
4
+ path: string;
5
+ inode: string;
6
+ trustedAt: string;
7
+ }
3
8
  export interface ResolvedSecret {
4
9
  key: string;
5
10
  value: string;
6
11
  sourcePath: string;
7
12
  }
8
13
  export type ExportFormat = "shell" | "bash" | "fish" | "powershell" | "cmd" | "dotenv" | "json";
14
+ export interface TrustCheckResult {
15
+ trusted: boolean;
16
+ trustedPath?: string;
17
+ reason?: "not-trusted" | "inode-mismatch" | "path-not-found";
18
+ }
19
+ export interface LoadedSecret {
20
+ key: string;
21
+ value: string;
22
+ sourcePath: string;
23
+ }
24
+ export interface HookState {
25
+ /** Currently loaded secret keys and their values */
26
+ secrets: LoadedSecret[];
27
+ /** The last directory that triggered loading */
28
+ lastDir: string;
29
+ /** Timestamp when secrets were loaded */
30
+ loadedAt: string;
31
+ /** Keys that were skipped due to conflicts */
32
+ skippedKeys: string[];
33
+ }
34
+ export interface HookDiff {
35
+ /** Keys to unset from the environment */
36
+ unset: string[];
37
+ /** Secrets to set in the environment */
38
+ set: LoadedSecret[];
39
+ /** Keys that already exist in the environment and were skipped */
40
+ skipped: string[];
41
+ /** Keys that remain unchanged */
42
+ unchanged: string[];
43
+ }
9
44
  /**
10
45
  * Configuration options for creating a BurrowClient instance.
11
46
  */
@@ -82,6 +117,88 @@ export interface RemoveOptions {
82
117
  */
83
118
  path?: string;
84
119
  }
120
+ /**
121
+ * Options for the `trust` method.
122
+ */
123
+ export interface TrustOptions {
124
+ /**
125
+ * Directory path to trust.
126
+ * Defaults to the current working directory.
127
+ */
128
+ path?: string;
129
+ }
130
+ /**
131
+ * Options for the `untrust` method.
132
+ */
133
+ export interface UntrustOptions {
134
+ /**
135
+ * Directory path to untrust.
136
+ * Defaults to the current working directory.
137
+ */
138
+ path?: string;
139
+ }
140
+ /**
141
+ * Options for the `isTrusted` method.
142
+ */
143
+ export interface IsTrustedOptions {
144
+ /**
145
+ * Directory path to check for trust.
146
+ * Defaults to the current working directory.
147
+ */
148
+ path?: string;
149
+ }
150
+ /**
151
+ * Result of the `trust` method.
152
+ */
153
+ export interface TrustResult {
154
+ /**
155
+ * The canonicalized path that was trusted.
156
+ */
157
+ path: string;
158
+ /**
159
+ * The filesystem inode/file ID for the trusted path.
160
+ */
161
+ inode: string;
162
+ }
163
+ /**
164
+ * Options for the `hook` method.
165
+ */
166
+ export interface HookOptions {
167
+ /**
168
+ * The shell to generate hook commands for.
169
+ */
170
+ shell: "bash" | "zsh" | "fish";
171
+ /**
172
+ * Whether to use colored output.
173
+ * Defaults to respecting NO_COLOR environment variable.
174
+ */
175
+ useColor?: boolean;
176
+ }
177
+ /**
178
+ * Result of the `hook` method.
179
+ */
180
+ export interface HookResult {
181
+ /**
182
+ * Shell commands to execute for environment updates.
183
+ */
184
+ commands: string[];
185
+ /**
186
+ * Optional message to display to the user.
187
+ */
188
+ message?: string;
189
+ /**
190
+ * The diff that was computed.
191
+ */
192
+ diff: HookDiff;
193
+ /**
194
+ * Whether the directory is trusted.
195
+ */
196
+ trusted: boolean;
197
+ /**
198
+ * Reason if directory is not trusted.
199
+ */
200
+ notTrustedReason?: "not-trusted" | "inode-mismatch" | "path-not-found" | "autoload-disabled";
201
+ }
85
202
  /**
86
203
  * Options for the `export` method.
87
204
  */
@@ -135,6 +252,8 @@ export declare class BurrowClient {
135
252
  private readonly storage;
136
253
  private readonly resolver;
137
254
  private readonly pathOptions;
255
+ private readonly trustManager;
256
+ private readonly hookStateManager;
138
257
  /**
139
258
  * Creates a new BurrowClient instance.
140
259
  *
@@ -307,6 +426,124 @@ export declare class BurrowClient {
307
426
  * ```
308
427
  */
309
428
  resolve(cwd?: string): Promise<Map<string, ResolvedSecret>>;
429
+ /**
430
+ * Trusts a directory for auto-loading secrets.
431
+ *
432
+ * When a directory is trusted, navigating into it (or its subdirectories)
433
+ * will automatically load secrets into the shell environment via the hook.
434
+ *
435
+ * @param options - Trust options including target path
436
+ * @returns The trusted path info with canonicalized path and inode
437
+ *
438
+ * @example
439
+ * ```typescript
440
+ * // Trust current directory
441
+ * await client.trust();
442
+ *
443
+ * // Trust specific path
444
+ * const result = await client.trust({ path: '/projects/myapp' });
445
+ * console.log(`Trusted: ${result.path}`);
446
+ * ```
447
+ */
448
+ trust(options?: TrustOptions): Promise<TrustResult>;
449
+ /**
450
+ * Removes trust from a directory.
451
+ *
452
+ * After untrusting, the directory (and its subdirectories) will no longer
453
+ * auto-load secrets. Secrets currently loaded remain until the next directory change.
454
+ *
455
+ * @param options - Untrust options including target path
456
+ * @returns true if the path was trusted and is now untrusted, false if it wasn't trusted
457
+ *
458
+ * @example
459
+ * ```typescript
460
+ * const removed = await client.untrust({ path: '/projects/myapp' });
461
+ * if (removed) {
462
+ * console.log('Directory is no longer trusted');
463
+ * }
464
+ * ```
465
+ */
466
+ untrust(options?: UntrustOptions): Promise<boolean>;
467
+ /**
468
+ * Checks if a directory is trusted for auto-loading.
469
+ *
470
+ * Trust can be inherited from ancestor directories. Also validates that
471
+ * the inode matches to detect directory replacements.
472
+ *
473
+ * @param options - Options including path to check
474
+ * @returns Trust check result with status and reason if not trusted
475
+ *
476
+ * @example
477
+ * ```typescript
478
+ * const result = await client.isTrusted({ path: '/projects/myapp/src' });
479
+ * if (result.trusted) {
480
+ * console.log(`Trusted via: ${result.trustedPath}`);
481
+ * } else {
482
+ * console.log(`Not trusted: ${result.reason}`);
483
+ * }
484
+ * ```
485
+ */
486
+ isTrusted(options?: IsTrustedOptions): Promise<TrustCheckResult>;
487
+ /**
488
+ * Lists all trusted directories.
489
+ *
490
+ * @returns Array of all trusted path entries
491
+ *
492
+ * @example
493
+ * ```typescript
494
+ * const trusted = await client.listTrusted();
495
+ * for (const entry of trusted) {
496
+ * console.log(`${entry.path} (trusted at ${entry.trustedAt})`);
497
+ * }
498
+ * ```
499
+ */
500
+ listTrusted(): Promise<TrustedPath[]>;
501
+ /**
502
+ * Gets the current hook state (loaded secrets).
503
+ *
504
+ * @returns The current hook state or undefined if no secrets are loaded
505
+ *
506
+ * @example
507
+ * ```typescript
508
+ * const state = await client.getHookState();
509
+ * if (state) {
510
+ * console.log(`${state.secrets.length} secrets loaded from ${state.lastDir}`);
511
+ * }
512
+ * ```
513
+ */
514
+ getHookState(): Promise<HookState | undefined>;
515
+ /**
516
+ * Clears the hook state (unloads all secrets tracking).
517
+ *
518
+ * Note: This only clears the state file. The actual environment variables
519
+ * remain set until the shell exits or they are explicitly unset.
520
+ */
521
+ clearHookState(): Promise<void>;
522
+ /**
523
+ * Processes a directory change for the shell hook.
524
+ *
525
+ * This is the main method called by shell hooks on directory change.
526
+ * It checks trust, resolves secrets, computes the diff, and returns
527
+ * the commands needed to update the environment.
528
+ *
529
+ * @param cwd - The new working directory
530
+ * @param options - Hook options including shell type
531
+ * @returns Hook result with commands to execute and optional message
532
+ *
533
+ * @example
534
+ * ```typescript
535
+ * const result = await client.hook('/projects/myapp', { shell: 'bash' });
536
+ * if (result.trusted) {
537
+ * for (const cmd of result.commands) {
538
+ * console.log(cmd); // Execute in shell
539
+ * }
540
+ * if (result.message) {
541
+ * console.error(result.message);
542
+ * }
543
+ * }
544
+ * ```
545
+ */
546
+ hook(cwd: string, options: HookOptions): Promise<HookResult>;
310
547
  /**
311
548
  * Closes the database connection and releases resources.
312
549
  * After calling this method, the client instance should not be used.
package/dist/api.js CHANGED
@@ -80,6 +80,14 @@ class Storage {
80
80
  )
81
81
  `);
82
82
  this.db.run("CREATE INDEX IF NOT EXISTS idx_secrets_path ON secrets (path)");
83
+ this.db.run(`
84
+ CREATE TABLE IF NOT EXISTS trusted_paths (
85
+ path TEXT PRIMARY KEY,
86
+ inode TEXT NOT NULL,
87
+ trusted_at TEXT NOT NULL
88
+ )
89
+ `);
90
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_trusted_paths_inode ON trusted_paths (inode)");
83
91
  const versionResult = this.db.query("PRAGMA user_version").get();
84
92
  const currentVersion = versionResult?.user_version ?? 0;
85
93
  if (currentVersion === 0) {
@@ -139,6 +147,61 @@ class Storage {
139
147
  db.query("DELETE FROM secrets WHERE path = ? AND key = ?").run(canonicalPath, key);
140
148
  return true;
141
149
  }
150
+ async addTrustedPath(canonicalPath, inode) {
151
+ const db = await this.ensureDb();
152
+ const trustedAt = new Date().toISOString();
153
+ db.query(`
154
+ INSERT INTO trusted_paths (path, inode, trusted_at)
155
+ VALUES (?, ?, ?)
156
+ ON CONFLICT(path) DO UPDATE SET
157
+ inode = excluded.inode,
158
+ trusted_at = excluded.trusted_at
159
+ `).run(canonicalPath, inode, trustedAt);
160
+ }
161
+ async removeTrustedPath(canonicalPath) {
162
+ const db = await this.ensureDb();
163
+ const existing = db.query("SELECT path FROM trusted_paths WHERE path = ?").get(canonicalPath);
164
+ if (!existing) {
165
+ return false;
166
+ }
167
+ db.query("DELETE FROM trusted_paths WHERE path = ?").run(canonicalPath);
168
+ return true;
169
+ }
170
+ async getTrustedPath(canonicalPath) {
171
+ const db = await this.ensureDb();
172
+ const row = db.query("SELECT path, inode, trusted_at FROM trusted_paths WHERE path = ?").get(canonicalPath);
173
+ if (!row) {
174
+ return;
175
+ }
176
+ return {
177
+ path: row.path,
178
+ inode: row.inode,
179
+ trustedAt: row.trusted_at
180
+ };
181
+ }
182
+ async getAllTrustedPaths() {
183
+ const db = await this.ensureDb();
184
+ const rows = db.query("SELECT path, inode, trusted_at FROM trusted_paths ORDER BY path").all();
185
+ return rows.map((row) => ({
186
+ path: row.path,
187
+ inode: row.inode,
188
+ trustedAt: row.trusted_at
189
+ }));
190
+ }
191
+ async getTrustedAncestorPaths(canonicalPath) {
192
+ const db = await this.ensureDb();
193
+ let rows;
194
+ if (isWindows()) {
195
+ rows = db.query("SELECT path, inode, trusted_at FROM trusted_paths WHERE ? = path OR ? LIKE path || '\\' || '%' OR (length(path) = 3 AND path LIKE '_:\\' AND ? LIKE path || '%')").all(canonicalPath, canonicalPath, canonicalPath);
196
+ } else {
197
+ rows = db.query("SELECT path, inode, trusted_at FROM trusted_paths WHERE ? = path OR ? LIKE path || '/' || '%' OR path = '/'").all(canonicalPath, canonicalPath);
198
+ }
199
+ return rows.map((row) => ({
200
+ path: row.path,
201
+ inode: row.inode,
202
+ trustedAt: row.trusted_at
203
+ }));
204
+ }
142
205
  close() {
143
206
  if (this.db) {
144
207
  this.db.close();
@@ -355,11 +418,213 @@ function format(secrets, fmt, options = {}) {
355
418
  throw new Error(`Unknown format: ${fmt}`);
356
419
  }
357
420
  }
421
+ // src/core/trust.ts
422
+ import { stat } from "node:fs/promises";
423
+ async function getInode(path) {
424
+ const stats = await stat(path);
425
+ return stats.ino.toString();
426
+ }
427
+
428
+ class TrustManager {
429
+ storage;
430
+ pathOptions;
431
+ constructor(options) {
432
+ this.storage = options.storage;
433
+ this.pathOptions = {
434
+ followSymlinks: options.followSymlinks
435
+ };
436
+ }
437
+ async trust(targetPath) {
438
+ const canonicalPath = await canonicalize(targetPath, this.pathOptions);
439
+ const inode = await getInode(canonicalPath);
440
+ await this.storage.addTrustedPath(canonicalPath, inode);
441
+ return { path: canonicalPath, inode };
442
+ }
443
+ async untrust(targetPath) {
444
+ const canonicalPath = await canonicalize(targetPath, this.pathOptions);
445
+ return this.storage.removeTrustedPath(canonicalPath);
446
+ }
447
+ async isTrusted(targetPath) {
448
+ let canonicalPath;
449
+ try {
450
+ canonicalPath = await canonicalize(targetPath, this.pathOptions);
451
+ } catch {
452
+ return { trusted: false, reason: "path-not-found" };
453
+ }
454
+ const trustedAncestors = await this.storage.getTrustedAncestorPaths(canonicalPath);
455
+ if (trustedAncestors.length === 0) {
456
+ return { trusted: false, reason: "not-trusted" };
457
+ }
458
+ trustedAncestors.sort((a, b) => b.path.length - a.path.length);
459
+ for (const trusted of trustedAncestors) {
460
+ try {
461
+ const currentInode = await getInode(trusted.path);
462
+ if (currentInode === trusted.inode) {
463
+ return { trusted: true, trustedPath: trusted.path };
464
+ }
465
+ } catch {}
466
+ }
467
+ return { trusted: false, reason: "inode-mismatch" };
468
+ }
469
+ async list() {
470
+ return this.storage.getAllTrustedPaths();
471
+ }
472
+ }
473
+ // src/core/hook.ts
474
+ import { readFile, writeFile, rm, mkdir as mkdir2 } from "node:fs/promises";
475
+ import { join as join3 } from "node:path";
476
+ var HOOK_STATE_FILE = "hook-state.json";
477
+
478
+ class HookStateManager {
479
+ configDir;
480
+ stateFilePath;
481
+ constructor(options = {}) {
482
+ this.configDir = options.configDir ?? getConfigDir();
483
+ this.stateFilePath = join3(this.configDir, HOOK_STATE_FILE);
484
+ }
485
+ async load() {
486
+ try {
487
+ const content = await readFile(this.stateFilePath, "utf-8");
488
+ return JSON.parse(content);
489
+ } catch {
490
+ return;
491
+ }
492
+ }
493
+ async save(state) {
494
+ await mkdir2(this.configDir, { recursive: true });
495
+ await writeFile(this.stateFilePath, JSON.stringify(state, null, 2));
496
+ }
497
+ async clear() {
498
+ try {
499
+ await rm(this.stateFilePath, { force: true });
500
+ } catch {}
501
+ }
502
+ computeDiff(currentState, newSecrets, env = process.env) {
503
+ const currentKeys = new Map;
504
+ if (currentState) {
505
+ for (const secret of currentState.secrets) {
506
+ currentKeys.set(secret.key, secret);
507
+ }
508
+ }
509
+ const skippedFromState = new Set(currentState?.skippedKeys ?? []);
510
+ const unset = [];
511
+ const set = [];
512
+ const skipped = [];
513
+ const unchanged = [];
514
+ for (const [key] of currentKeys) {
515
+ if (!newSecrets.has(key)) {
516
+ unset.push(key);
517
+ }
518
+ }
519
+ for (const [key, secret] of newSecrets) {
520
+ const loadedSecret = {
521
+ key: secret.key,
522
+ value: secret.value,
523
+ sourcePath: secret.sourcePath
524
+ };
525
+ const current = currentKeys.get(key);
526
+ if (current) {
527
+ if (current.value === secret.value && current.sourcePath === secret.sourcePath) {
528
+ unchanged.push(key);
529
+ } else {
530
+ set.push(loadedSecret);
531
+ }
532
+ } else {
533
+ const envValue = env[key];
534
+ if (envValue !== undefined && !skippedFromState.has(key)) {
535
+ skipped.push(key);
536
+ } else {
537
+ set.push(loadedSecret);
538
+ }
539
+ }
540
+ }
541
+ return { unset, set, skipped, unchanged };
542
+ }
543
+ async applyDiff(currentState, diff, newDir) {
544
+ const secretsMap = new Map;
545
+ if (currentState) {
546
+ for (const secret of currentState.secrets) {
547
+ if (!diff.unset.includes(secret.key)) {
548
+ secretsMap.set(secret.key, secret);
549
+ }
550
+ }
551
+ }
552
+ for (const secret of diff.set) {
553
+ secretsMap.set(secret.key, secret);
554
+ }
555
+ const skippedKeys = [...currentState?.skippedKeys ?? []];
556
+ for (const key of diff.skipped) {
557
+ if (!skippedKeys.includes(key)) {
558
+ skippedKeys.push(key);
559
+ }
560
+ }
561
+ const newState = {
562
+ secrets: Array.from(secretsMap.values()),
563
+ lastDir: newDir,
564
+ loadedAt: new Date().toISOString(),
565
+ skippedKeys
566
+ };
567
+ await this.save(newState);
568
+ return newState;
569
+ }
570
+ }
571
+ function formatHookMessage(diff, useColor = true) {
572
+ const loaded = diff.set.length;
573
+ const unloaded = diff.unset.length;
574
+ const skippedCount = diff.skipped.length;
575
+ if (loaded === 0 && unloaded === 0) {
576
+ return;
577
+ }
578
+ const dim = useColor ? "\x1B[2m" : "";
579
+ const reset = useColor ? "\x1B[0m" : "";
580
+ let message = `${dim}burrow: `;
581
+ if (unloaded > 0 && loaded > 0) {
582
+ message += `unloaded ${unloaded}, loaded ${loaded}`;
583
+ } else if (loaded > 0) {
584
+ message += `loaded ${loaded} secret${loaded === 1 ? "" : "s"}`;
585
+ } else {
586
+ message += `unloaded ${unloaded} secret${unloaded === 1 ? "" : "s"}`;
587
+ }
588
+ if (skippedCount > 0) {
589
+ message += ` (${skippedCount} skipped)`;
590
+ }
591
+ message += reset;
592
+ return message;
593
+ }
594
+ function generateShellCommands(diff, shell) {
595
+ const commands = [];
596
+ for (const key of diff.unset) {
597
+ if (shell === "fish") {
598
+ commands.push(`set -e ${key}`);
599
+ } else {
600
+ commands.push(`unset ${key}`);
601
+ }
602
+ }
603
+ for (const secret of diff.set) {
604
+ const escapedValue = escapeShellValue2(secret.value, shell);
605
+ if (shell === "fish") {
606
+ commands.push(`set -gx ${secret.key} ${escapedValue}`);
607
+ } else {
608
+ commands.push(`export ${secret.key}=${escapedValue}`);
609
+ }
610
+ }
611
+ return commands;
612
+ }
613
+ function escapeShellValue2(value, shell) {
614
+ if (shell === "fish") {
615
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
616
+ } else {
617
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
618
+ return `$'${escaped}'`;
619
+ }
620
+ }
358
621
  // src/api.ts
359
622
  class BurrowClient {
360
623
  storage;
361
624
  resolver;
362
625
  pathOptions;
626
+ trustManager;
627
+ hookStateManager;
363
628
  constructor(options = {}) {
364
629
  this.storage = new Storage({
365
630
  configDir: options.configDir,
@@ -372,6 +637,13 @@ class BurrowClient {
372
637
  this.pathOptions = {
373
638
  followSymlinks: options.followSymlinks
374
639
  };
640
+ this.trustManager = new TrustManager({
641
+ storage: this.storage,
642
+ followSymlinks: options.followSymlinks
643
+ });
644
+ this.hookStateManager = new HookStateManager({
645
+ configDir: options.configDir
646
+ });
375
647
  }
376
648
  async set(key, value, options = {}) {
377
649
  assertValidEnvKey(key);
@@ -407,6 +679,61 @@ class BurrowClient {
407
679
  async resolve(cwd) {
408
680
  return this.resolver.resolve(cwd);
409
681
  }
682
+ async trust(options = {}) {
683
+ const targetPath = options.path ?? process.cwd();
684
+ return this.trustManager.trust(targetPath);
685
+ }
686
+ async untrust(options = {}) {
687
+ const targetPath = options.path ?? process.cwd();
688
+ return this.trustManager.untrust(targetPath);
689
+ }
690
+ async isTrusted(options = {}) {
691
+ const targetPath = options.path ?? process.cwd();
692
+ return this.trustManager.isTrusted(targetPath);
693
+ }
694
+ async listTrusted() {
695
+ return this.trustManager.list();
696
+ }
697
+ async getHookState() {
698
+ return this.hookStateManager.load();
699
+ }
700
+ async clearHookState() {
701
+ return this.hookStateManager.clear();
702
+ }
703
+ async hook(cwd, options) {
704
+ if (process.env["BURROW_AUTOLOAD"] === "0") {
705
+ const emptyDiff = { unset: [], set: [], skipped: [], unchanged: [] };
706
+ return {
707
+ commands: [],
708
+ diff: emptyDiff,
709
+ trusted: false,
710
+ notTrustedReason: "autoload-disabled"
711
+ };
712
+ }
713
+ const trustResult = await this.isTrusted({ path: cwd });
714
+ if (!trustResult.trusted) {
715
+ const emptyDiff = { unset: [], set: [], skipped: [], unchanged: [] };
716
+ return {
717
+ commands: [],
718
+ diff: emptyDiff,
719
+ trusted: false,
720
+ notTrustedReason: trustResult.reason
721
+ };
722
+ }
723
+ const newSecrets = await this.resolve(cwd);
724
+ const currentState = await this.hookStateManager.load();
725
+ const diff = this.hookStateManager.computeDiff(currentState, newSecrets);
726
+ const commands = generateShellCommands(diff, options.shell);
727
+ await this.hookStateManager.applyDiff(currentState, diff, cwd);
728
+ const useColor = options.useColor ?? !process.env["NO_COLOR"];
729
+ const message = formatHookMessage(diff, useColor);
730
+ return {
731
+ commands,
732
+ message,
733
+ diff,
734
+ trusted: true
735
+ };
736
+ }
410
737
  close() {
411
738
  this.storage.close();
412
739
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@captainsafia/burrow",
3
- "version": "1.2.0",
3
+ "version": "1.3.0-preview.4437197",
4
4
  "description": "Platform-agnostic, directory-scoped secrets manager",
5
5
  "type": "module",
6
6
  "main": "dist/api.js",