@gajae-code/utils 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,6 +80,13 @@ export declare function getTrustedConfigRootDir(): string;
80
80
  * selection and resolve the same storage lane.
81
81
  */
82
82
  export declare function setAgentDir(dir: string): void;
83
+ /**
84
+ * Rebuild the resolver from the current trusted environment. Callers that
85
+ * temporarily used {@link setAgentDir} must first restore the original
86
+ * GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR values, then call this function;
87
+ * an originally absent override remains absent and keeps following HOME.
88
+ */
89
+ export declare function resetAgentDirFromEnvironment(): void;
83
90
  /** Get the agent config directory (~/.gjc/agent). */
84
91
  export declare function getAgentDir(): string;
85
92
  /** Resolver-owned profile classification, stable across HOME refreshes. */
@@ -24,6 +24,7 @@ export * as procmgr from "./procmgr";
24
24
  export * as prompt from "./prompt";
25
25
  export * as ptree from "./ptree";
26
26
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
27
+ export * from "./safe-error";
27
28
  export * from "./safe-stderr";
28
29
  export * from "./sanitize-text";
29
30
  export * from "./snowflake";
@@ -39,16 +39,18 @@ export declare function debug(message: string, context?: Record<string, unknown>
39
39
  export declare function printTimings(): void;
40
40
  /**
41
41
  * Begin recording startup timings under a new root span.
42
- * Idempotent: a second call while already recording is a no-op so that side-effect
43
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
42
+ * Idempotent: a second call while already recording is a no-op so that the
43
+ * early starter (cli.ts, the first CLI statement) and the explicit starter
44
+ * (main.ts) can coexist.
45
+ *
46
+ * The root is anchored at the process-start origin rather than at the call:
47
+ * `performance.now()` counts milliseconds since `performance.timeOrigin`
48
+ * (process start) on Bun, so `start: 0` makes `Total` the true
49
+ * time-since-process-start. Runtime bootstrap plus static module linking and
50
+ * evaluation then land in the root's self time instead of being invisible
51
+ * before the first statement of cli.ts.
44
52
  */
45
53
  export declare function startTiming(): void;
46
- /**
47
- * Record an externally-measured span as a leaf child of the active span (or root
48
- * when no span is active). Used by the module-load timing plugin to splice load
49
- * events into the tree retroactively.
50
- */
51
- export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number): void;
52
54
  /**
53
55
  * End timing window and clear buffers.
54
56
  */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Throw-proof error description.
3
+ *
4
+ * Hostile or exotic thrown values can make `String(value)` throw (a `toString`
5
+ * that throws, a revoked proxy, a null-prototype object) and a `message` getter
6
+ * can throw too. Logging or recording a failure must never become a second
7
+ * failure, so every failure path that stringifies a caught value uses this.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { safeErrorDescription } from "@gajae-code/utils";
12
+ *
13
+ * try {
14
+ * await risky();
15
+ * } catch (error) {
16
+ * logger.warn("risky failed", { error: safeErrorDescription(error) });
17
+ * }
18
+ * ```
19
+ */
20
+ export declare function safeErrorDescription(value: unknown): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.16.7",
4
+ "version": "0.17.1",
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.16.7",
34
+ "@gajae-code/natives": "0.17.1",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -25,6 +25,16 @@ export function redactCrashSecrets(text: string): string {
25
25
  redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
26
26
  redacted = redacted.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "«redacted-github-token»");
27
27
  redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
28
+ // The five shapes below are the ones `crash/upstream/envelope.ts` already
29
+ // classifies as credential-like and refuses to transmit. That refusal only
30
+ // guards the Sentry frame fields; the persisted crash log and the
31
+ // `gjc crash report` body — which the user files as a public issue — reach
32
+ // egress through this function alone, so the same shapes have to be named here.
33
+ redacted = redacted.replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "«redacted-npm-token»");
34
+ redacted = redacted.replace(/\bglpat-[A-Za-z0-9_-]{20,}\b/g, "«redacted-gitlab-token»");
35
+ // Stripe separates with `_`, so the `sk-` rule above never matched one.
36
+ redacted = redacted.replace(/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g, "«redacted-api-key»");
37
+ redacted = redacted.replace(/\bhf_[A-Za-z0-9]{20,}\b/g, "«redacted-api-key»");
28
38
  // A PEM block carries the key material itself, so it is redacted whole rather
29
39
  // than line by line. It runs before the narrower rules because they would
30
40
  // otherwise chew on the base64 body and leave a truncated key behind.
@@ -70,6 +80,8 @@ export const CRASH_REDACTION_MARKERS: readonly string[] = [
70
80
  "«redacted-api-key»",
71
81
  "«redacted-github-token»",
72
82
  "«redacted-slack-token»",
83
+ "«redacted-npm-token»",
84
+ "«redacted-gitlab-token»",
73
85
  "«redacted-aws-key»",
74
86
  "«redacted-private-key»",
75
87
  "«redacted-google-api-key»",
package/src/dirs.ts CHANGED
@@ -673,6 +673,18 @@ export function setAgentDir(dir: string): void {
673
673
  process.env.GJC_CODING_AGENT_DIR = dir;
674
674
  }
675
675
 
676
+ /**
677
+ * Rebuild the resolver from the current trusted environment. Callers that
678
+ * temporarily used {@link setAgentDir} must first restore the original
679
+ * GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR values, then call this function;
680
+ * an originally absent override remains absent and keeps following HOME.
681
+ */
682
+ export function resetAgentDirFromEnvironment(): void {
683
+ const snapshot = dirs.trustSnapshot;
684
+ const override = trustedValue("GJC_CODING_AGENT_DIR", snapshot) ?? trustedValue("PI_CODING_AGENT_DIR", snapshot);
685
+ dirs = new DirResolver(override, snapshot);
686
+ }
687
+
676
688
  /** Get the agent config directory (~/.gjc/agent). */
677
689
  export function getAgentDir(): string {
678
690
  dirs.refreshConfigDirOverride();
@@ -743,7 +755,14 @@ export function getPluginsDir(home?: string): string {
743
755
  if (home !== undefined) {
744
756
  const explicitPath = () => path.join(home, resolveConfigDirName(dirs.trustSnapshot), "plugins");
745
757
  try {
746
- if (home !== dirs.trustedHome) return explicitPath();
758
+ const canonical = (value: string): string => {
759
+ try {
760
+ return fs.realpathSync.native(value);
761
+ } catch {
762
+ return path.resolve(value);
763
+ }
764
+ };
765
+ if (canonical(home) !== canonical(dirs.trustedHome)) return explicitPath();
747
766
  } catch {
748
767
  // An explicit home is the caller's documented escape hatch. If the
749
768
  // authoritative home is unavailable, do not let its fail-closed resolver
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * as procmgr from "./procmgr";
24
24
  export * as prompt from "./prompt";
25
25
  export * as ptree from "./ptree";
26
26
  export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
27
+ export * from "./safe-error";
27
28
  export * from "./safe-stderr";
28
29
  export * from "./sanitize-text";
29
30
  export * from "./snowflake";
package/src/logger.ts CHANGED
@@ -94,6 +94,7 @@ function applyTransports(
94
94
  modules: { winston: WinstonModule; DailyRotateFile: DailyRotateFileCtor },
95
95
  ): void {
96
96
  logger.clear();
97
+ logger.silent = !transportOptions.console && !transportOptions.file;
97
98
  if (transportOptions.file) {
98
99
  logger.add(
99
100
  makeFileTransport(
@@ -249,21 +250,21 @@ export function printTimings(): void {
249
250
  }
250
251
 
251
252
  gRootSpan.end = performance.now();
253
+ // Close still-open spans (e.g. cli:dispatch, which wraps the very run that
254
+ // prints) so they report elapsed-at-print instead of a misleading 0ms.
255
+ const root = gRootSpan;
256
+ const printNow = root.end;
257
+ const closeOpenSpans = (span: Span): void => {
258
+ for (const child of span.children) closeOpenSpans(child);
259
+ if (span.end === undefined && !span.point) span.end = printNow;
260
+ };
261
+ closeOpenSpans(root);
252
262
  const lines: string[] = [];
253
263
  lines.push("");
254
264
  lines.push("--- Startup timings (hierarchical) ---");
255
- const work: Span[] = [];
256
- const loads: Span[] = [];
257
- for (const child of gRootSpan.children) {
258
- if (isModuleLoadSpan(child)) loads.push(child);
259
- else work.push(child);
260
- }
261
- for (const child of work.sort((a, b) => a.start - b.start)) {
265
+ for (const child of [...gRootSpan.children].sort((a, b) => a.start - b.start)) {
262
266
  printSpan(child, 0, lines);
263
267
  }
264
- if (loads.length > 0) {
265
- printModuleLoadSummary(loads, 0, lines);
266
- }
267
268
  const totalMs = (gRootSpan.end - gRootSpan.start).toFixed(1);
268
269
  lines.push(`Total: ${totalMs}ms`);
269
270
  lines.push("--------------------------------------");
@@ -274,46 +275,28 @@ export function printTimings(): void {
274
275
 
275
276
  /**
276
277
  * Begin recording startup timings under a new root span.
277
- * Idempotent: a second call while already recording is a no-op so that side-effect
278
- * starters (see module-timer.ts) and explicit starters (main.ts) can coexist.
278
+ * Idempotent: a second call while already recording is a no-op so that the
279
+ * early starter (cli.ts, the first CLI statement) and the explicit starter
280
+ * (main.ts) can coexist.
281
+ *
282
+ * The root is anchored at the process-start origin rather than at the call:
283
+ * `performance.now()` counts milliseconds since `performance.timeOrigin`
284
+ * (process start) on Bun, so `start: 0` makes `Total` the true
285
+ * time-since-process-start. Runtime bootstrap plus static module linking and
286
+ * evaluation then land in the root's self time instead of being invisible
287
+ * before the first statement of cli.ts.
279
288
  */
280
289
  export function startTiming(): void {
281
290
  if (gRecordTimings) return;
282
291
  gRootSpan = {
283
292
  op: "(root)",
284
- start: performance.now(),
293
+ start: 0,
285
294
  parent: undefined,
286
295
  children: [],
287
296
  };
288
297
  gRecordTimings = true;
289
298
  }
290
299
 
291
- /**
292
- * Record an externally-measured span as a leaf child of the active span (or root
293
- * when no span is active). Used by the module-load timing plugin to splice load
294
- * events into the tree retroactively.
295
- */
296
- export function recordModuleLoadSpan(path: string, start: number, durationMs: number): void {
297
- if (!gRecordTimings || !gRootSpan) return;
298
- const parent = spanStorage.getStore() ?? gRootSpan;
299
- const span: Span = {
300
- op: `load:${shortenLoadPath(path)}`,
301
- start,
302
- end: start + durationMs,
303
- parent,
304
- children: [],
305
- };
306
- parent.children.push(span);
307
- }
308
-
309
- function shortenLoadPath(p: string): string {
310
- const cwd = process.cwd();
311
- if (p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
312
- const home = process.env.HOME;
313
- if (home && p.startsWith(`${home}/`)) return `~/${p.slice(home.length + 1)}`;
314
- return p;
315
- }
316
-
317
300
  /**
318
301
  * End timing window and clear buffers.
319
302
  */
@@ -359,13 +342,6 @@ function fmtMs(ms: number): string {
359
342
  return `${ms.toFixed(0)}ms`;
360
343
  }
361
344
 
362
- const MODULE_LOAD_PREFIX = "load:";
363
- const MODULE_LOAD_VERBOSE_TOP = 10;
364
-
365
- function isModuleLoadSpan(span: Span): boolean {
366
- return span.op.startsWith(MODULE_LOAD_PREFIX);
367
- }
368
-
369
345
  function printSpan(span: Span, depth: number, lines: string[]): void {
370
346
  const indent = " ".repeat(depth);
371
347
  if (span.point) {
@@ -380,51 +356,9 @@ function printSpan(span: Span, depth: number, lines: string[]): void {
380
356
  const selfStr = span.children.length > 0 && self > LOGGED_TIMING_THRESHOLD_MS ? ` (self ${fmtMs(self)})` : "";
381
357
  lines.push(`${indent}${span.op}: ${fmtMs(dur)}${selfStr}${tag}`);
382
358
 
383
- // Split children into work spans and module-load spans for summarization.
384
- const work: Span[] = [];
385
- const loads: Span[] = [];
386
- for (const child of span.children) {
387
- if (isModuleLoadSpan(child)) loads.push(child);
388
- else work.push(child);
389
- }
390
- for (const child of work.sort((a, b) => a.start - b.start)) {
359
+ for (const child of [...span.children].sort((a, b) => a.start - b.start)) {
391
360
  printSpan(child, depth + 1, lines);
392
361
  }
393
- if (loads.length > 0) {
394
- printModuleLoadSummary(loads, depth + 1, lines);
395
- }
396
- }
397
-
398
- /** Collapse the (typically hundreds of) module-load spans into one summary line. */
399
- function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]): void {
400
- const childIndent = " ".repeat(depth);
401
- const grandIndent = " ".repeat(depth + 1);
402
- let unionStart = Number.POSITIVE_INFINITY;
403
- let unionEnd = 0;
404
- let totalSelf = 0;
405
- for (const span of loads) {
406
- if (span.end === undefined) continue;
407
- if (span.start < unionStart) unionStart = span.start;
408
- if (span.end > unionEnd) unionEnd = span.end;
409
- totalSelf += span.end - span.start;
410
- }
411
- const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
412
- lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
413
- // Resolve GJC-first with PI fallback inline (no ./env import) so this
414
- // foundational logger stays off the env module's dependency graph, which the
415
- // tab-worker native-free runtime contract (issue-2598-repro) walks.
416
- const showAll = (process.env.GJC_TIMING?.trim() || process.env.PI_TIMING?.trim()) === "full";
417
- const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
418
- const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
419
- for (const span of visible) {
420
- const dur = durationOf(span);
421
- if (dur < LOGGED_TIMING_THRESHOLD_MS) break;
422
- const tag = isParallel(span) ? " [parallel]" : "";
423
- lines.push(`${grandIndent}${span.op}: ${fmtMs(dur)}${tag}`);
424
- }
425
- if (!showAll && sorted.length > MODULE_LOAD_VERBOSE_TOP) {
426
- lines.push(`${grandIndent}… ${sorted.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
427
- }
428
362
  }
429
363
 
430
364
  /** A span is parallel if it overlaps a sibling that started before it. */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Throw-proof error description.
3
+ *
4
+ * Hostile or exotic thrown values can make `String(value)` throw (a `toString`
5
+ * that throws, a revoked proxy, a null-prototype object) and a `message` getter
6
+ * can throw too. Logging or recording a failure must never become a second
7
+ * failure, so every failure path that stringifies a caught value uses this.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { safeErrorDescription } from "@gajae-code/utils";
12
+ *
13
+ * try {
14
+ * await risky();
15
+ * } catch (error) {
16
+ * logger.warn("risky failed", { error: safeErrorDescription(error) });
17
+ * }
18
+ * ```
19
+ */
20
+ export function safeErrorDescription(value: unknown): string {
21
+ let isError = false;
22
+ try {
23
+ isError = value instanceof Error;
24
+ } catch {
25
+ // Hostile proxies can throw from getPrototypeOf during instanceof.
26
+ }
27
+ if (isError) {
28
+ try {
29
+ const message = (value as { message?: unknown }).message;
30
+ if (typeof message === "string") return message;
31
+ } catch {
32
+ // Hostile error getters must not replace the primary failure.
33
+ }
34
+ }
35
+ try {
36
+ return String(value);
37
+ } catch {
38
+ return "<unprintable error>";
39
+ }
40
+ }