@gajae-code/utils 0.17.1 → 0.17.2

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.
@@ -11,6 +11,8 @@
11
11
  * move data to the new locations. No filesystem existence checks are performed
12
12
  * — if the env var is set, gjc trusts that the migration has been done.
13
13
  */
14
+ import { canonicalEnvKey } from "./env-file";
15
+ export { canonicalEnvKey };
14
16
  /** App name (e.g. "gjc") */
15
17
  export declare const APP_NAME: string;
16
18
  /** Config directory name (e.g. ".gjc") */
@@ -50,14 +52,6 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
50
52
  export declare function getProjectDir(): string;
51
53
  /** Set the project directory. */
52
54
  export declare function setProjectDir(dir: string): void;
53
- /**
54
- * Windows environment variable names are case-insensitive, so a project dotenv
55
- * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
56
- * provenance lookup here is spelled in upper case, so the snapshot must be
57
- * keyed the same way or the declaration is invisible to the guard while still
58
- * being live in the process. POSIX names are case-sensitive and must not fold.
59
- */
60
- export declare function canonicalEnvKey(name: string): string;
61
55
  export declare function getConfigAgentDirName(): string;
62
56
  /** Get the config root directory (~/.gjc). */
63
57
  export declare function getConfigRootDir(): string;
@@ -109,6 +103,29 @@ export declare function getReportsDir(): string;
109
103
  export declare function getLogsDir(): string;
110
104
  /** Get the path to a dated log file (~/.gjc/logs/gjc.YYYY-MM-DD.log). */
111
105
  export declare function getLogPath(date?: Date): string;
106
+ /**
107
+ * The logs directory the process actually reads and writes.
108
+ *
109
+ * A trusted `GJC_LOG_DIR` redirects the sink — the test preload pins it to a
110
+ * per-process temp directory so `bun test` stops appending fixture
111
+ * `level:error` records to the operator's shared log (issue #5618) — and
112
+ * everything else falls back to {@link getLogsDir}.
113
+ *
114
+ * The override is provenance-checked through the same {@link trustedValue} rule
115
+ * as the config and agent directories: Bun loads `cwd/.env` into `process.env`
116
+ * before any module runs, so a repository could otherwise redirect where the
117
+ * operator's production logs are written. A dynamic (`$`/backtick) declaration
118
+ * is rejected there too, which fails closed onto the canonical path.
119
+ *
120
+ * This is deliberately *not* folded into {@link getLogsDir}: that function's
121
+ * config-root semantics are pinned by tests, and the preload sets `GJC_LOG_DIR`
122
+ * for every test process, so folding the override in would move it under every
123
+ * test. Writers and readers must both come through here instead — the split
124
+ * between the two is what let the transport and the log readers disagree.
125
+ */
126
+ export declare function getEffectiveLogsDir(): string;
127
+ /** Get the dated log file under {@link getEffectiveLogsDir}. */
128
+ export declare function getEffectiveLogPath(date?: Date): string;
112
129
  /**
113
130
  * Get the plugins directory (~/.gjc/plugins or its XDG equivalent).
114
131
  *
@@ -32,3 +32,40 @@ export declare function parseShellEnvFile(filePath: string): Record<string, stri
32
32
  export declare function parseEnvFile(filePath: string): Record<string, string>;
33
33
  /** Parse dotenv content that has already been read from a trusted file. */
34
34
  export declare function parseEnvFileContent(content: string): Record<string, string>;
35
+ /**
36
+ * What the caller's checkout declares through its dotenv files.
37
+ *
38
+ * `values` is the merged declaration set, later layers winning. `dynamic` holds
39
+ * the keys whose surviving declaration is one Bun expands at load time, which
40
+ * every provenance guard refuses outright because a value comparison cannot see
41
+ * what such a declaration became.
42
+ */
43
+ export interface ProjectEnvSnapshot {
44
+ values: Record<string, string>;
45
+ dynamic: Set<string>;
46
+ }
47
+ /**
48
+ * Windows environment variable names are case-insensitive, so a project dotenv
49
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
50
+ * provenance lookup is spelled in upper case, so the snapshot must be keyed the
51
+ * same way or the declaration is invisible to the guard while still being live
52
+ * in the process. POSIX names are case-sensitive and must not fold.
53
+ */
54
+ export declare function canonicalEnvKey(name: string): string;
55
+ /**
56
+ * The layered dotenv declarations Bun overlays into `process.env` for a cwd.
57
+ *
58
+ * Lives in this leaf module so every consumer shares ONE notion of provenance.
59
+ * `dirs.ts` resolves the config, agent and log directories from it, and
60
+ * `scripts/test-preload.ts` decides test isolation from it — a second, narrower
61
+ * reader (only `cwd/.env`, its own regex, its own dynamic test) is how a
62
+ * `GJC_LOG_DIR` declared in `.env.local` / `.env.$NODE_ENV` came to be honored
63
+ * by the preload and then rejected in production, silently routing test log
64
+ * records to the operator's canonical sink. This module imports only `node:fs`,
65
+ * `node:path` and the import-free `./spawn-env`, so importing it has no side
66
+ * effects and cannot freeze resolver state the way importing `dirs.ts` would.
67
+ *
68
+ * `.env.local` is deliberately skipped when `NODE_ENV === "test"`, matching the
69
+ * convention that a local override file is not part of a test run.
70
+ */
71
+ export declare function projectEnvSnapshot(cwd?: string): ProjectEnvSnapshot;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.17.1",
4
+ "version": "0.17.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.17.1",
34
+ "@gajae-code/natives": "0.17.2",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/dirs.ts CHANGED
@@ -16,7 +16,15 @@ import * as fs from "node:fs";
16
16
  import * as os from "node:os";
17
17
  import * as path from "node:path";
18
18
  import { engines, version } from "../package.json" with { type: "json" };
19
- import { parseEnvFile } from "./env-file";
19
+ import { canonicalEnvKey, type ProjectEnvSnapshot, projectEnvSnapshot } from "./env-file";
20
+
21
+ // The provenance snapshot and its key fold live in the leaf `env-file` module so
22
+ // `scripts/test-preload.ts` can share this exact logic without importing this
23
+ // module (whose load-time resolver construction would freeze state before the
24
+ // preload sets its isolation variables). Re-exported here because `env.ts` and
25
+ // `test/env-provenance.windows.test.ts` import `canonicalEnvKey` from this
26
+ // module's public surface.
27
+ export { canonicalEnvKey };
20
28
 
21
29
  /** App name (e.g. "gjc") */
22
30
  export const APP_NAME: string = "gjc";
@@ -170,39 +178,6 @@ function sanitizeConfigDirName(value: string | undefined): string | undefined {
170
178
  return trimmed;
171
179
  }
172
180
 
173
- /**
174
- * Windows environment variable names are case-insensitive, so a project dotenv
175
- * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
176
- * provenance lookup here is spelled in upper case, so the snapshot must be
177
- * keyed the same way or the declaration is invisible to the guard while still
178
- * being live in the process. POSIX names are case-sensitive and must not fold.
179
- */
180
- export function canonicalEnvKey(name: string): string {
181
- return process.platform === "win32" ? name.toUpperCase() : name;
182
- }
183
-
184
- function projectEnvSnapshot(cwd = process.cwd()): { values: Record<string, string>; dynamic: Set<string> } {
185
- const nodeEnv = process.env.NODE_ENV;
186
- const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
187
- const files = [
188
- ".env",
189
- ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
190
- ...(validNodeEnv !== "test" ? [".env.local"] : []),
191
- ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
192
- ];
193
- const values: Record<string, string> = {};
194
- const dynamic = new Set<string>();
195
- for (const file of files) {
196
- for (const [rawKey, value] of Object.entries(parseEnvFile(path.join(cwd, file)))) {
197
- const key = canonicalEnvKey(rawKey);
198
- values[key] = value;
199
- if (/[$`]/.test(value)) dynamic.add(key);
200
- else dynamic.delete(key);
201
- }
202
- }
203
- return { values, dynamic };
204
- }
205
-
206
181
  /**
207
182
  * Resolve an environment value only when it is not supplied by the caller's
208
183
  * project dotenv (or when the inherited value is observably distinct).
@@ -220,10 +195,7 @@ function projectEnvSnapshot(cwd = process.cwd()): { values: Record<string, strin
220
195
  * happens to carry the identical value loses the override, which is the same
221
196
  * trade-off `resolveLiveCredentialEnvValue` already makes.
222
197
  */
223
- function trustedValue(
224
- name: string,
225
- project: { values: Record<string, string>; dynamic: Set<string> },
226
- ): string | undefined {
198
+ function trustedValue(name: string, project: ProjectEnvSnapshot): string | undefined {
227
199
  const value = process.env[name];
228
200
  if (!value) return undefined;
229
201
  const key = canonicalEnvKey(name);
@@ -232,7 +204,7 @@ function trustedValue(
232
204
  return value;
233
205
  }
234
206
 
235
- function resolveConfigDirName(project: { values: Record<string, string>; dynamic: Set<string> }): string {
207
+ function resolveConfigDirName(project: ProjectEnvSnapshot): string {
236
208
  return (
237
209
  sanitizeConfigDirName(trustedValue("GJC_CONFIG_DIR", project)) ??
238
210
  sanitizeConfigDirName(trustedValue("PI_CONFIG_DIR", project)) ??
@@ -369,7 +341,7 @@ function accountHomeFromSystem(): AccountHome | undefined {
369
341
  * per call is what makes the contract call-time; the provenance comparison above
370
342
  * is what keeps an untrusted mutable home from being honored.
371
343
  */
372
- function resolveTrustedHome(project: { values: Record<string, string>; dynamic: Set<string> }): string {
344
+ function resolveTrustedHome(project: ProjectEnvSnapshot): string {
373
345
  const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
374
346
  const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
375
347
  const declaredHome = project.values[declaredHomeKey];
@@ -425,7 +397,7 @@ type XdgCategory = "data" | "state" | "cache";
425
397
  class DirResolver {
426
398
  configRoot: string;
427
399
  agentDir: string;
428
- readonly #projectEnv: { values: Record<string, string>; dynamic: Set<string> };
400
+ readonly #projectEnv: ProjectEnvSnapshot;
429
401
  #configDirName: string;
430
402
  readonly #agentDirOverride: boolean;
431
403
  #trustedHome: string;
@@ -491,10 +463,7 @@ class DirResolver {
491
463
  * change storage lane when a home refresh made its path coincide with the
492
464
  * new default.
493
465
  */
494
- private refreshCategoryDirs(
495
- snapshot: { values: Record<string, string>; dynamic: Set<string> },
496
- isDefault: boolean,
497
- ): void {
466
+ private refreshCategoryDirs(snapshot: ProjectEnvSnapshot, isDefault: boolean): void {
498
467
  let xdgData: string | undefined;
499
468
  let xdgState: string | undefined;
500
469
  let xdgCache: string | undefined;
@@ -619,7 +588,7 @@ class DirResolver {
619
588
  this.refreshConfigDirOverride();
620
589
  if (!this.#homeAvailable) throw new Error("User state is unavailable: no trustworthy home directory");
621
590
  }
622
- get trustSnapshot(): { values: Record<string, string>; dynamic: Set<string> } {
591
+ get trustSnapshot(): ProjectEnvSnapshot {
623
592
  return this.#projectEnv;
624
593
  }
625
594
  }
@@ -735,9 +704,44 @@ export function getLogsDir(): string {
735
704
  return dirs.rootSubdir("logs", "state");
736
705
  }
737
706
 
707
+ /** Dated log file name, shared by the canonical and effective log paths so they cannot drift. */
708
+ function datedLogFileName(date: Date): string {
709
+ return `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`;
710
+ }
711
+
738
712
  /** Get the path to a dated log file (~/.gjc/logs/gjc.YYYY-MM-DD.log). */
739
713
  export function getLogPath(date = new Date()): string {
740
- return path.join(getLogsDir(), `${APP_NAME}.${date.toISOString().slice(0, 10)}.log`);
714
+ return path.join(getLogsDir(), datedLogFileName(date));
715
+ }
716
+
717
+ /**
718
+ * The logs directory the process actually reads and writes.
719
+ *
720
+ * A trusted `GJC_LOG_DIR` redirects the sink — the test preload pins it to a
721
+ * per-process temp directory so `bun test` stops appending fixture
722
+ * `level:error` records to the operator's shared log (issue #5618) — and
723
+ * everything else falls back to {@link getLogsDir}.
724
+ *
725
+ * The override is provenance-checked through the same {@link trustedValue} rule
726
+ * as the config and agent directories: Bun loads `cwd/.env` into `process.env`
727
+ * before any module runs, so a repository could otherwise redirect where the
728
+ * operator's production logs are written. A dynamic (`$`/backtick) declaration
729
+ * is rejected there too, which fails closed onto the canonical path.
730
+ *
731
+ * This is deliberately *not* folded into {@link getLogsDir}: that function's
732
+ * config-root semantics are pinned by tests, and the preload sets `GJC_LOG_DIR`
733
+ * for every test process, so folding the override in would move it under every
734
+ * test. Writers and readers must both come through here instead — the split
735
+ * between the two is what let the transport and the log readers disagree.
736
+ */
737
+ export function getEffectiveLogsDir(): string {
738
+ const override = trustedValue("GJC_LOG_DIR", dirs.trustSnapshot)?.trim();
739
+ return override || getLogsDir();
740
+ }
741
+
742
+ /** Get the dated log file under {@link getEffectiveLogsDir}. */
743
+ export function getEffectiveLogPath(date = new Date()): string {
744
+ return path.join(getEffectiveLogsDir(), datedLogFileName(date));
741
745
  }
742
746
 
743
747
  /**
package/src/env-file.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * below both of them.
7
7
  */
8
8
  import * as fs from "node:fs";
9
+ import * as path from "node:path";
9
10
  import { isSafeEnvValue } from "./spawn-env";
10
11
 
11
12
  const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -155,3 +156,65 @@ export function parseEnvFileContent(content: string): Record<string, string> {
155
156
 
156
157
  return result;
157
158
  }
159
+
160
+ /**
161
+ * What the caller's checkout declares through its dotenv files.
162
+ *
163
+ * `values` is the merged declaration set, later layers winning. `dynamic` holds
164
+ * the keys whose surviving declaration is one Bun expands at load time, which
165
+ * every provenance guard refuses outright because a value comparison cannot see
166
+ * what such a declaration became.
167
+ */
168
+ export interface ProjectEnvSnapshot {
169
+ values: Record<string, string>;
170
+ dynamic: Set<string>;
171
+ }
172
+
173
+ /**
174
+ * Windows environment variable names are case-insensitive, so a project dotenv
175
+ * line `userprofile=...` is what `process.env.USERPROFILE` resolves to. Every
176
+ * provenance lookup is spelled in upper case, so the snapshot must be keyed the
177
+ * same way or the declaration is invisible to the guard while still being live
178
+ * in the process. POSIX names are case-sensitive and must not fold.
179
+ */
180
+ export function canonicalEnvKey(name: string): string {
181
+ return process.platform === "win32" ? name.toUpperCase() : name;
182
+ }
183
+
184
+ /**
185
+ * The layered dotenv declarations Bun overlays into `process.env` for a cwd.
186
+ *
187
+ * Lives in this leaf module so every consumer shares ONE notion of provenance.
188
+ * `dirs.ts` resolves the config, agent and log directories from it, and
189
+ * `scripts/test-preload.ts` decides test isolation from it — a second, narrower
190
+ * reader (only `cwd/.env`, its own regex, its own dynamic test) is how a
191
+ * `GJC_LOG_DIR` declared in `.env.local` / `.env.$NODE_ENV` came to be honored
192
+ * by the preload and then rejected in production, silently routing test log
193
+ * records to the operator's canonical sink. This module imports only `node:fs`,
194
+ * `node:path` and the import-free `./spawn-env`, so importing it has no side
195
+ * effects and cannot freeze resolver state the way importing `dirs.ts` would.
196
+ *
197
+ * `.env.local` is deliberately skipped when `NODE_ENV === "test"`, matching the
198
+ * convention that a local override file is not part of a test run.
199
+ */
200
+ export function projectEnvSnapshot(cwd = process.cwd()): ProjectEnvSnapshot {
201
+ const nodeEnv = process.env.NODE_ENV;
202
+ const validNodeEnv = nodeEnv && /^[A-Za-z0-9_-]+$/.test(nodeEnv) ? nodeEnv : undefined;
203
+ const files = [
204
+ ".env",
205
+ ...(validNodeEnv ? [`.env.${validNodeEnv}`] : []),
206
+ ...(validNodeEnv !== "test" ? [".env.local"] : []),
207
+ ...(validNodeEnv ? [`.env.${validNodeEnv}.local`] : []),
208
+ ];
209
+ const values: Record<string, string> = {};
210
+ const dynamic = new Set<string>();
211
+ for (const file of files) {
212
+ for (const [rawKey, value] of Object.entries(parseEnvFile(path.join(cwd, file)))) {
213
+ const key = canonicalEnvKey(rawKey);
214
+ values[key] = value;
215
+ if (/[$`]/.test(value)) dynamic.add(key);
216
+ else dynamic.delete(key);
217
+ }
218
+ }
219
+ return { values, dynamic };
220
+ }
package/src/logger.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs";
14
14
  import type * as winston from "winston";
15
- import { getLogsDir } from "./dirs";
15
+ import { getEffectiveLogsDir } from "./dirs";
16
16
 
17
17
  /** Ensure a logs directory exists; return the resolved path. */
18
18
  function ensureDir(dir: string): string {
@@ -73,10 +73,22 @@ function makeLogFormat(winston: WinstonModule): winston.Logform.Format {
73
73
  );
74
74
  }
75
75
 
76
- /** Build a rotating file transport, materializing the target directory lazily. */
76
+ /**
77
+ * Build a rotating file transport, materializing the target directory lazily.
78
+ *
79
+ * Destination precedence:
80
+ * 1. `dir` — an explicit path from {@link setTransports}(`{ file: "<path>" }`).
81
+ * 2. {@link getEffectiveLogsDir} — the provenance-checked `GJC_LOG_DIR`
82
+ * override, else the real config root (`~/.gjc/logs`).
83
+ *
84
+ * The resolution is centralized in `dirs.ts` rather than read from the
85
+ * environment here: the log *readers* (report bundles, the debug log view, the
86
+ * HTTP dump directory) call the same helper, and a second env read in this file
87
+ * is what let the transport write somewhere the readers never looked.
88
+ */
77
89
  function makeFileTransport(DailyRotateFile: DailyRotateFileCtor, dir?: string): Transport {
78
90
  return new DailyRotateFile({
79
- dirname: ensureDir(dir ?? getLogsDir()),
91
+ dirname: ensureDir(dir ?? getEffectiveLogsDir()),
80
92
  filename: "gjc.%DATE%.log",
81
93
  datePattern: "YYYY-MM-DD",
82
94
  maxSize: "10m",