@gajae-code/utils 0.11.10 → 0.12.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/dist/types/dirs.d.ts +3 -3
- package/dist/types/env-file.d.ts +21 -0
- package/dist/types/env.d.ts +1 -21
- package/dist/types/procmgr.d.ts +6 -0
- package/package.json +2 -2
- package/src/cli.ts +11 -2
- package/src/dirs.ts +90 -4
- package/src/env-file.ts +115 -0
- package/src/env.ts +3 -105
- package/src/procmgr.ts +30 -5
package/dist/types/dirs.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized path helpers for gajae-code config directories.
|
|
3
3
|
*
|
|
4
|
-
* Uses PI_CONFIG_DIR
|
|
5
|
-
*
|
|
4
|
+
* Uses GJC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".gjc") for the
|
|
5
|
+
* config root and GJC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
|
|
6
|
+
* override the agent directory.
|
|
6
7
|
*
|
|
7
8
|
* On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
|
|
8
9
|
* variables are set, paths are redirected to XDG-compliant locations under
|
|
@@ -44,7 +45,6 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
|
|
|
44
45
|
export declare function getProjectDir(): string;
|
|
45
46
|
/** Set the project directory. */
|
|
46
47
|
export declare function setProjectDir(dir: string): void;
|
|
47
|
-
/** Get the config directory name relative to home (e.g. ".gjc" or PI_CONFIG_DIR override). */
|
|
48
48
|
export declare function getConfigDirName(): string;
|
|
49
49
|
/** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
|
|
50
50
|
export declare function getConfigAgentDirName(): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict shell-identifier shape. Used for dotenv keys we accept into
|
|
3
|
+
* `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
|
|
4
|
+
* so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isValidEnvName(name: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Parses simple POSIX shell environment assignments from files such as
|
|
9
|
+
* ~/.zshrc without executing user shell code. Supports `export KEY=value` and
|
|
10
|
+
* `KEY=value`, including single/double quoted literal values. Dynamic shell
|
|
11
|
+
* expressions are intentionally ignored because evaluating startup files would
|
|
12
|
+
* run arbitrary code during CLI startup.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseShellEnvFile(filePath: string): Record<string, string>;
|
|
15
|
+
/**
|
|
16
|
+
* Parses a .env file synchronously and extracts key-value string pairs.
|
|
17
|
+
* Ignores lines that are empty or start with '#'. Trims whitespace.
|
|
18
|
+
* Allows values to be quoted with single or double quotes.
|
|
19
|
+
* Returns an object of key-value pairs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseEnvFile(filePath: string): Record<string, string>;
|
package/dist/types/env.d.ts
CHANGED
|
@@ -1,25 +1,5 @@
|
|
|
1
1
|
export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
|
|
2
|
-
|
|
3
|
-
* Strict shell-identifier shape. Used for dotenv keys we accept into
|
|
4
|
-
* `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
|
|
5
|
-
* so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
|
|
6
|
-
*/
|
|
7
|
-
export declare function isValidEnvName(name: string): boolean;
|
|
8
|
-
/**
|
|
9
|
-
* Parses simple POSIX shell environment assignments from files such as
|
|
10
|
-
* ~/.zshrc without executing user shell code. Supports `export KEY=value` and
|
|
11
|
-
* `KEY=value`, including single/double quoted literal values. Dynamic shell
|
|
12
|
-
* expressions are intentionally ignored because evaluating startup files would
|
|
13
|
-
* run arbitrary code during CLI startup.
|
|
14
|
-
*/
|
|
15
|
-
export declare function parseShellEnvFile(filePath: string): Record<string, string>;
|
|
16
|
-
/**
|
|
17
|
-
* Parses a .env file synchronously and extracts key-value string pairs.
|
|
18
|
-
* Ignores lines that are empty or start with '#'. Trims whitespace.
|
|
19
|
-
* Allows values to be quoted with single or double quotes.
|
|
20
|
-
* Returns an object of key-value pairs.
|
|
21
|
-
*/
|
|
22
|
-
export declare function parseEnvFile(filePath: string): Record<string, string>;
|
|
2
|
+
export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
|
|
23
3
|
export declare function $inheritedEnv(name: string): string | undefined;
|
|
24
4
|
/**
|
|
25
5
|
* Intentional re-export of Bun.env.
|
package/dist/types/procmgr.d.ts
CHANGED
|
@@ -28,6 +28,12 @@ export declare function resolveBasicShell(): string | undefined;
|
|
|
28
28
|
* 4. Fallback: sh
|
|
29
29
|
*/
|
|
30
30
|
export declare function getShellConfig(customShellPath?: string): ShellConfig;
|
|
31
|
+
/**
|
|
32
|
+
* Clear the memoized shell configuration so the next {@link getShellConfig}
|
|
33
|
+
* call re-resolves the shell and re-reads the environment (shell selection and
|
|
34
|
+
* the bash CI/login flags). Primarily for tests that vary those inputs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resetShellConfigCache(): void;
|
|
31
37
|
/**
|
|
32
38
|
* Check if a process is running.
|
|
33
39
|
*/
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/utils",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
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.
|
|
34
|
+
"@gajae-code/natives": "0.12.0",
|
|
35
35
|
"beautiful-mermaid": "^1.1.3",
|
|
36
36
|
"handlebars": "^4.7.9",
|
|
37
37
|
"winston": "^3.19.0",
|
package/src/cli.ts
CHANGED
|
@@ -211,8 +211,11 @@ export abstract class Command {
|
|
|
211
211
|
if (raw === undefined || typeof raw === "boolean") {
|
|
212
212
|
flags[name] = desc.default ?? undefined;
|
|
213
213
|
} else {
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
if (typeof raw !== "string" || !/^-?\d+$/.test(raw)) {
|
|
215
|
+
throw new CliParseError(`Expected integer for --${name}, got "${String(raw)}"`);
|
|
216
|
+
}
|
|
217
|
+
const n = Number(raw);
|
|
218
|
+
if (!Number.isSafeInteger(n)) {
|
|
216
219
|
throw new CliParseError(`Expected integer for --${name}, got "${raw}"`);
|
|
217
220
|
}
|
|
218
221
|
flags[name] = n;
|
|
@@ -267,6 +270,12 @@ export abstract class Command {
|
|
|
267
270
|
}
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
if (strict && posIdx < positionals.length) {
|
|
274
|
+
const unexpected = positionals.slice(posIdx);
|
|
275
|
+
const rendered = unexpected.map(value => JSON.stringify(value)).join(", ");
|
|
276
|
+
throw new CliParseError(`Unexpected argument${unexpected.length === 1 ? "" : "s"}: ${rendered}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
270
279
|
return { flags, args, argv: positionals } as never;
|
|
271
280
|
}
|
|
272
281
|
}
|
package/src/dirs.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized path helpers for gajae-code config directories.
|
|
3
3
|
*
|
|
4
|
-
* Uses PI_CONFIG_DIR
|
|
5
|
-
*
|
|
4
|
+
* Uses GJC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".gjc") for the
|
|
5
|
+
* config root and GJC_CODING_AGENT_DIR (legacy alias PI_CODING_AGENT_DIR) to
|
|
6
|
+
* override the agent directory.
|
|
6
7
|
*
|
|
7
8
|
* On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
|
|
8
9
|
* variables are set, paths are redirected to XDG-compliant locations under
|
|
@@ -15,6 +16,7 @@ import * as fs from "node:fs";
|
|
|
15
16
|
import * as os from "node:os";
|
|
16
17
|
import * as path from "node:path";
|
|
17
18
|
import { engines, version } from "../package.json" with { type: "json" };
|
|
19
|
+
import { parseEnvFile } from "./env-file";
|
|
18
20
|
|
|
19
21
|
/** App name (e.g. "gjc") */
|
|
20
22
|
export const APP_NAME: string = "gjc";
|
|
@@ -147,9 +149,57 @@ export function setProjectDir(dir: string): void {
|
|
|
147
149
|
process.chdir(projectDir);
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Reject a configured config-directory name that would escape the home-relative
|
|
154
|
+
* root it is documented to stay under.
|
|
155
|
+
*
|
|
156
|
+
* The configured value names a directory beneath `<home>` — the discovery docs
|
|
157
|
+
* state that "even an absolute-looking configured name is joined beneath
|
|
158
|
+
* `<home>`", which `path.join` delivers for a leading separator but not for
|
|
159
|
+
* `..` segments. Consumers join this name with `<home>` (and with project
|
|
160
|
+
* ancestors) to locate user-level `mcp.json`, `SYSTEM.md`, skills, agents and
|
|
161
|
+
* installed plugins, so a `..` segment would point that discovery at a
|
|
162
|
+
* directory outside the config root entirely. Fall back to the default name
|
|
163
|
+
* instead of honoring an escaping value.
|
|
164
|
+
*/
|
|
165
|
+
function sanitizeConfigDirName(value: string | undefined): string | undefined {
|
|
166
|
+
const trimmed = value?.trim();
|
|
167
|
+
if (!trimmed) return undefined;
|
|
168
|
+
if (path.normalize(trimmed).split(/[\\/]/).includes("..")) return undefined;
|
|
169
|
+
return trimmed;
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
/** Get the config directory name relative to home (e.g. ".gjc" or PI_CONFIG_DIR override). */
|
|
173
|
+
/**
|
|
174
|
+
* Config-directory name, rejected when it comes from the caller's project `.env`.
|
|
175
|
+
*
|
|
176
|
+
* The name is joined with the home directory to build the config root, and that
|
|
177
|
+
* root plus the agent directory beneath it supply two of the `.env` files
|
|
178
|
+
* `$credentialEnv` treats as trusted. Bun loads `cwd/.env` into `process.env`
|
|
179
|
+
* before any module runs, so a repository could otherwise point the config root
|
|
180
|
+
* at a directory it ships and have its own `.env` treated as trusted —
|
|
181
|
+
* recovering every endpoint and credential redirect the boundary rejects.
|
|
182
|
+
*
|
|
183
|
+
* `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
|
|
184
|
+
* it applies the same conservative ambiguity rule directly, matching how
|
|
185
|
+
* `GJC_CODING_AGENT_DIR` is treated.
|
|
186
|
+
*/
|
|
187
|
+
function trustedConfigDirName(name: "GJC_CONFIG_DIR" | "PI_CONFIG_DIR"): string | undefined {
|
|
188
|
+
const value = process.env[name];
|
|
189
|
+
if (!value) return undefined;
|
|
190
|
+
if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
|
|
151
194
|
export function getConfigDirName(): string {
|
|
152
|
-
|
|
195
|
+
// Both guards apply: the value must come from a trusted source (not the
|
|
196
|
+
// caller's project `.env`), and it must still be a single name that stays
|
|
197
|
+
// beneath home once joined.
|
|
198
|
+
return (
|
|
199
|
+
sanitizeConfigDirName(trustedConfigDirName("GJC_CONFIG_DIR")) ??
|
|
200
|
+
sanitizeConfigDirName(trustedConfigDirName("PI_CONFIG_DIR")) ??
|
|
201
|
+
CONFIG_DIR_NAME
|
|
202
|
+
);
|
|
153
203
|
}
|
|
154
204
|
|
|
155
205
|
/** Get the config agent directory name relative to home (e.g. ".gjc/agent" or PI_CONFIG_DIR + "/agent"). */
|
|
@@ -248,7 +298,43 @@ class DirResolver {
|
|
|
248
298
|
}
|
|
249
299
|
}
|
|
250
300
|
|
|
251
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Agent-directory override, rejected when it comes from the caller's project
|
|
303
|
+
* `.env`.
|
|
304
|
+
*
|
|
305
|
+
* This directory selects the agent's own `.env`, which is one of the trusted
|
|
306
|
+
* sources `$credentialEnv` consults. Bun loads `cwd/.env` into `process.env`
|
|
307
|
+
* before any module runs, so a repository could otherwise point this at a
|
|
308
|
+
* directory it ships and have its own `.env` treated as trusted — recovering
|
|
309
|
+
* every redirect the credential boundary is meant to reject.
|
|
310
|
+
*
|
|
311
|
+
* `env.ts` imports this module, so the check cannot go through `$credentialEnv`;
|
|
312
|
+
* it applies the same conservative ambiguity rule directly: a value that matches
|
|
313
|
+
* what the project `.env` sets is not honoured. An operator whose environment
|
|
314
|
+
* happens to carry the identical value loses the override, which is the same
|
|
315
|
+
* trade-off `resolveLiveCredentialEnvValue` already makes.
|
|
316
|
+
*/
|
|
317
|
+
function trustedAgentDirOverrideFor(name: "GJC_CODING_AGENT_DIR" | "PI_CODING_AGENT_DIR"): string | undefined {
|
|
318
|
+
const value = process.env[name];
|
|
319
|
+
if (!value) return undefined;
|
|
320
|
+
if (parseEnvFile(path.join(process.cwd(), ".env"))[name] === value) return undefined;
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Both spellings are honoured, mirroring `getConfigDirName`.
|
|
326
|
+
*
|
|
327
|
+
* `PI_CODING_AGENT_DIR` is the legacy alias this module's own header documents,
|
|
328
|
+
* and parts of the product already resolve it (`gc-runtime.ts:370`,
|
|
329
|
+
* `deep-interview-runtime.ts:384`). Reading only the `GJC_` spelling here split
|
|
330
|
+
* the agent directory in two: `gjc gc` operated on the aliased directory while
|
|
331
|
+
* everything reaching `getAgentDir()` stayed on the default.
|
|
332
|
+
*/
|
|
333
|
+
function trustedAgentDirOverride(): string | undefined {
|
|
334
|
+
return trustedAgentDirOverrideFor("GJC_CODING_AGENT_DIR") ?? trustedAgentDirOverrideFor("PI_CODING_AGENT_DIR");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let dirs = new DirResolver(trustedAgentDirOverride());
|
|
252
338
|
|
|
253
339
|
// Anchor home for the resolver. Captured at module load to stay stable across
|
|
254
340
|
// test mocks of `os.homedir()`. `getPluginsDir(home)` compares against this so
|
package/src/env-file.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-file parsing primitives.
|
|
3
|
+
*
|
|
4
|
+
* Kept in a leaf module so both `env.ts` and `dirs.ts` can use them. `env.ts`
|
|
5
|
+
* imports `dirs.ts`, so anything `dirs.ts` needs from the env layer has to live
|
|
6
|
+
* below both of them.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import { isSafeEnvValue } from "./spawn-env";
|
|
10
|
+
|
|
11
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Strict shell-identifier shape. Used for dotenv keys we accept into
|
|
15
|
+
* `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
|
|
16
|
+
* so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
|
|
17
|
+
*/
|
|
18
|
+
export function isValidEnvName(name: string): boolean {
|
|
19
|
+
return ENV_NAME_RE.test(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function stripInlineShellComment(value: string): string {
|
|
23
|
+
let quote: '"' | "'" | undefined;
|
|
24
|
+
for (let i = 0; i < value.length; i++) {
|
|
25
|
+
const char = value[i];
|
|
26
|
+
if (char === "\\") {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if ((char === '"' || char === "'") && (!quote || quote === char)) {
|
|
31
|
+
quote = quote ? undefined : char;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
|
|
35
|
+
return value.slice(0, i).trimEnd();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return value.trimEnd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parses simple POSIX shell environment assignments from files such as
|
|
43
|
+
* ~/.zshrc without executing user shell code. Supports `export KEY=value` and
|
|
44
|
+
* `KEY=value`, including single/double quoted literal values. Dynamic shell
|
|
45
|
+
* expressions are intentionally ignored because evaluating startup files would
|
|
46
|
+
* run arbitrary code during CLI startup.
|
|
47
|
+
*/
|
|
48
|
+
export function parseShellEnvFile(filePath: string): Record<string, string> {
|
|
49
|
+
const result: Record<string, string> = {};
|
|
50
|
+
try {
|
|
51
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
52
|
+
for (const line of content.split("\n")) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
55
|
+
|
|
56
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
|
57
|
+
if (!match) continue;
|
|
58
|
+
|
|
59
|
+
const key = match[1];
|
|
60
|
+
if (!isValidEnvName(key)) continue;
|
|
61
|
+
|
|
62
|
+
let value = stripInlineShellComment(match[2] ?? "").trim();
|
|
63
|
+
if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
|
|
64
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
65
|
+
value = value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
if (!isSafeEnvValue(value)) continue;
|
|
68
|
+
if (/[$`]/.test(value)) continue;
|
|
69
|
+
|
|
70
|
+
result[key] = value;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// File doesn't exist or can't be read - return empty result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parses a .env file synchronously and extracts key-value string pairs.
|
|
81
|
+
* Ignores lines that are empty or start with '#'. Trims whitespace.
|
|
82
|
+
* Allows values to be quoted with single or double quotes.
|
|
83
|
+
* Returns an object of key-value pairs.
|
|
84
|
+
*/
|
|
85
|
+
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
86
|
+
const result: Record<string, string> = {};
|
|
87
|
+
try {
|
|
88
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
89
|
+
for (const line of content.split("\n")) {
|
|
90
|
+
const trimmed = line.trim();
|
|
91
|
+
// Skip comments and blank lines
|
|
92
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
93
|
+
|
|
94
|
+
const eqIndex = trimmed.indexOf("=");
|
|
95
|
+
if (eqIndex === -1) continue;
|
|
96
|
+
|
|
97
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
98
|
+
if (!isValidEnvName(key)) continue;
|
|
99
|
+
|
|
100
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
101
|
+
|
|
102
|
+
// Remove surrounding quotes (" or ')
|
|
103
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
104
|
+
value = value.slice(1, -1);
|
|
105
|
+
}
|
|
106
|
+
if (!isSafeEnvValue(value)) continue;
|
|
107
|
+
|
|
108
|
+
result[key] = value;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// File doesn't exist or can't be read - return empty result
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import { getAgentDir, getConfigRootDir } from "./dirs";
|
|
@@ -6,111 +5,10 @@ import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
|
|
|
6
5
|
|
|
7
6
|
export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
import { parseEnvFile, parseShellEnvFile } from "./env-file";
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
|
|
14
|
-
* so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
|
|
15
|
-
*/
|
|
16
|
-
export function isValidEnvName(name: string): boolean {
|
|
17
|
-
return ENV_NAME_RE.test(name);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function stripInlineShellComment(value: string): string {
|
|
21
|
-
let quote: '"' | "'" | undefined;
|
|
22
|
-
for (let i = 0; i < value.length; i++) {
|
|
23
|
-
const char = value[i];
|
|
24
|
-
if (char === "\\") {
|
|
25
|
-
i++;
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
if ((char === '"' || char === "'") && (!quote || quote === char)) {
|
|
29
|
-
quote = quote ? undefined : char;
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (char === "#" && !quote && (i === 0 || /\s/.test(value[i - 1] ?? ""))) {
|
|
33
|
-
return value.slice(0, i).trimEnd();
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return value.trimEnd();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Parses simple POSIX shell environment assignments from files such as
|
|
41
|
-
* ~/.zshrc without executing user shell code. Supports `export KEY=value` and
|
|
42
|
-
* `KEY=value`, including single/double quoted literal values. Dynamic shell
|
|
43
|
-
* expressions are intentionally ignored because evaluating startup files would
|
|
44
|
-
* run arbitrary code during CLI startup.
|
|
45
|
-
*/
|
|
46
|
-
export function parseShellEnvFile(filePath: string): Record<string, string> {
|
|
47
|
-
const result: Record<string, string> = {};
|
|
48
|
-
try {
|
|
49
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
50
|
-
for (const line of content.split("\n")) {
|
|
51
|
-
const trimmed = line.trim();
|
|
52
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
53
|
-
|
|
54
|
-
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
|
55
|
-
if (!match) continue;
|
|
56
|
-
|
|
57
|
-
const key = match[1];
|
|
58
|
-
if (!isValidEnvName(key)) continue;
|
|
59
|
-
|
|
60
|
-
let value = stripInlineShellComment(match[2] ?? "").trim();
|
|
61
|
-
if (value.endsWith(";")) value = value.slice(0, -1).trimEnd();
|
|
62
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
63
|
-
value = value.slice(1, -1);
|
|
64
|
-
}
|
|
65
|
-
if (!isSafeEnvValue(value)) continue;
|
|
66
|
-
if (/[$`]/.test(value)) continue;
|
|
67
|
-
|
|
68
|
-
result[key] = value;
|
|
69
|
-
}
|
|
70
|
-
} catch {
|
|
71
|
-
// File doesn't exist or can't be read - return empty result
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Parses a .env file synchronously and extracts key-value string pairs.
|
|
79
|
-
* Ignores lines that are empty or start with '#'. Trims whitespace.
|
|
80
|
-
* Allows values to be quoted with single or double quotes.
|
|
81
|
-
* Returns an object of key-value pairs.
|
|
82
|
-
*/
|
|
83
|
-
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
84
|
-
const result: Record<string, string> = {};
|
|
85
|
-
try {
|
|
86
|
-
const content = fs.readFileSync(filePath, "utf-8");
|
|
87
|
-
for (const line of content.split("\n")) {
|
|
88
|
-
const trimmed = line.trim();
|
|
89
|
-
// Skip comments and blank lines
|
|
90
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
91
|
-
|
|
92
|
-
const eqIndex = trimmed.indexOf("=");
|
|
93
|
-
if (eqIndex === -1) continue;
|
|
94
|
-
|
|
95
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
96
|
-
if (!isValidEnvName(key)) continue;
|
|
97
|
-
|
|
98
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
99
|
-
|
|
100
|
-
// Remove surrounding quotes (" or ')
|
|
101
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
102
|
-
value = value.slice(1, -1);
|
|
103
|
-
}
|
|
104
|
-
if (!isSafeEnvValue(value)) continue;
|
|
105
|
-
|
|
106
|
-
result[key] = value;
|
|
107
|
-
}
|
|
108
|
-
} catch {
|
|
109
|
-
// File doesn't exist or can't be read - return empty result
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return result;
|
|
113
|
-
}
|
|
10
|
+
// Re-exported so the public surface of this module is unchanged.
|
|
11
|
+
export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
|
|
114
12
|
|
|
115
13
|
function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
|
|
116
14
|
if (!isSafeEnvName(name)) return undefined;
|
package/src/procmgr.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { Process, ProcessStatus } from "@gajae-code/natives";
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
|
-
import { $
|
|
5
|
+
import { $pickCredentialEnv, $pickflag, filterProcessEnv } from "./env";
|
|
6
6
|
import { $which } from "./which";
|
|
7
7
|
|
|
8
8
|
export interface ShellConfig {
|
|
@@ -41,9 +41,13 @@ function isExecutable(path: string): boolean {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Build the spawn environment (cached).
|
|
44
|
+
*
|
|
45
|
+
* `CI=true` is injected unless the documented `GJC_BASH_NO_CI` (or its legacy
|
|
46
|
+
* `PI_BASH_NO_CI` / `CLAUDE_BASH_NO_CI` aliases) is set to a canonical truthy
|
|
47
|
+
* flag value.
|
|
44
48
|
*/
|
|
45
49
|
function buildSpawnEnv(shell: string): Record<string, string> {
|
|
46
|
-
const noCI = $
|
|
50
|
+
const noCI = $pickflag("GJC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
|
|
47
51
|
const inherited = filterProcessEnv(Bun.env);
|
|
48
52
|
delete inherited.GJC_SESSION_FILE;
|
|
49
53
|
delete inherited.GJC_MANAGED_OWNER_TRANSCRIPT_PATH;
|
|
@@ -60,18 +64,30 @@ function buildSpawnEnv(shell: string): Record<string, string> {
|
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* Get shell args, optionally including login shell flag.
|
|
63
|
-
*
|
|
67
|
+
*
|
|
68
|
+
* Honors the documented `GJC_BASH_NO_LOGIN` first, with `PI_BASH_NO_LOGIN` and
|
|
69
|
+
* `CLAUDE_BASH_NO_LOGIN` as legacy aliases. Boolean-like values follow the
|
|
70
|
+
* canonical flag contract (`1`/`Y`/`TRUE`/`YES`/`ON`, case-insensitive), so an
|
|
71
|
+
* explicit `GJC_BASH_NO_LOGIN=0` keeps the login shell even when a legacy alias
|
|
72
|
+
* is set to a truthy value.
|
|
64
73
|
*/
|
|
65
74
|
function getShellArgs(): string[] {
|
|
66
|
-
const noLogin = $
|
|
75
|
+
const noLogin = $pickflag("GJC_BASH_NO_LOGIN", "PI_BASH_NO_LOGIN", "CLAUDE_BASH_NO_LOGIN");
|
|
67
76
|
return noLogin ? ["-c"] : ["-l", "-c"];
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
/**
|
|
71
80
|
* Get shell prefix for wrapping commands (profilers, strace, etc.).
|
|
81
|
+
*
|
|
82
|
+
* Resolved from trusted sources only. The prefix is interpolated ahead of every
|
|
83
|
+
* bash command (`${prefix} ${command}`) and executed through the shell, so it is
|
|
84
|
+
* an arbitrary-command-execution surface. `$env` merges the caller's
|
|
85
|
+
* `cwd/.env`, which means repository content could otherwise set it; resolution
|
|
86
|
+
* therefore goes through the non-project resolver (launching shell plus
|
|
87
|
+
* GJC/user-owned `.env` files), matching how provider credentials are resolved.
|
|
72
88
|
*/
|
|
73
89
|
function getShellPrefix(): string | undefined {
|
|
74
|
-
return $
|
|
90
|
+
return $pickCredentialEnv("PI_SHELL_PREFIX", "CLAUDE_CODE_SHELL_PREFIX");
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
/**
|
|
@@ -187,6 +203,15 @@ export function getShellConfig(customShellPath?: string): ShellConfig {
|
|
|
187
203
|
return cachedShellConfig;
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Clear the memoized shell configuration so the next {@link getShellConfig}
|
|
208
|
+
* call re-resolves the shell and re-reads the environment (shell selection and
|
|
209
|
+
* the bash CI/login flags). Primarily for tests that vary those inputs.
|
|
210
|
+
*/
|
|
211
|
+
export function resetShellConfigCache(): void {
|
|
212
|
+
cachedShellConfig = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
190
215
|
/**
|
|
191
216
|
* Check if a process is running.
|
|
192
217
|
*/
|