@sayknow-cli/utils 0.4.7 → 0.5.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 +5 -21
- package/dist/types/postmortem.d.ts +25 -0
- package/dist/types/procmgr.d.ts +6 -0
- package/dist/types/stream.d.ts +6 -2
- 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 +23 -105
- package/src/format.ts +16 -6
- package/src/frontmatter.ts +18 -6
- package/src/logger.ts +4 -1
- package/src/postmortem.ts +106 -0
- package/src/procmgr.ts +30 -5
- package/src/stream.ts +27 -3
package/dist/types/dirs.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized path helpers for sayknow-cli config directories.
|
|
3
3
|
*
|
|
4
|
-
* Uses PI_CONFIG_DIR
|
|
5
|
-
*
|
|
4
|
+
* Uses SKC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".skc") for the
|
|
5
|
+
* config root and SKC_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. ".skc" 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. ".skc/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.
|
|
@@ -66,3 +46,7 @@ export declare function isBunTestRuntime(): boolean;
|
|
|
66
46
|
*/
|
|
67
47
|
export declare function isCompiledBinary(): boolean;
|
|
68
48
|
export declare function $flag(name: string, def?: boolean): boolean;
|
|
49
|
+
/** Resolve the first flag among keys that has a set value (SKC-first, PI fallback). Matches $flag semantics per key. */
|
|
50
|
+
export declare function $pickflag(...keys: string[]): boolean;
|
|
51
|
+
/** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
|
|
52
|
+
export declare function $pickenvpos(keys: string[], defaultValue: number): number;
|
|
@@ -8,6 +8,31 @@ export declare enum Reason {
|
|
|
8
8
|
UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
|
|
9
9
|
MANUAL = "manual"
|
|
10
10
|
}
|
|
11
|
+
/** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
|
|
12
|
+
export declare const CRASH_LOG_MAX_BYTES: number;
|
|
13
|
+
/**
|
|
14
|
+
* Per-record budget so a single oversized error body cannot bypass the file
|
|
15
|
+
* cap: every persisted record is truncated to this many bytes (UTF-8 safe,
|
|
16
|
+
* with a marker) before the append/reset decision.
|
|
17
|
+
*/
|
|
18
|
+
export declare const CRASH_RECORD_MAX_BYTES: number;
|
|
19
|
+
/**
|
|
20
|
+
* Append a fatal-crash record to the dedicated, rotation-immune crash log
|
|
21
|
+
* (`~/.skc/agent/skc-crash.log`).
|
|
22
|
+
*
|
|
23
|
+
* The daily logger file is gzip-archived at date rollover by every skc process
|
|
24
|
+
* independently; that shared-archive race can truncate a day's log to an empty
|
|
25
|
+
* `.gz`, destroying the `logger.error` crash record written here. This
|
|
26
|
+
* append-only file is never rotated, so a crash stays diagnosable regardless.
|
|
27
|
+
*
|
|
28
|
+
* Fully defensive: it never throws (a failing crash writer must not mask the
|
|
29
|
+
* original fatal) and uses synchronous IO so the record lands before
|
|
30
|
+
* `process.exit`. Returns the path written, or `undefined` on failure.
|
|
31
|
+
*/
|
|
32
|
+
export declare function recordFatalCrash(label: string, reason: unknown, options?: {
|
|
33
|
+
path?: string;
|
|
34
|
+
now?: Date;
|
|
35
|
+
}): string | undefined;
|
|
11
36
|
/**
|
|
12
37
|
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
13
38
|
*
|
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/dist/types/stream.d.ts
CHANGED
|
@@ -17,7 +17,11 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
export type SseEventObserver = (event: ServerSentEvent) => void;
|
|
20
|
-
export
|
|
20
|
+
export interface SseReadOptions {
|
|
21
|
+
maxEventBytes?: number;
|
|
22
|
+
maxTotalBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver, options?: SseReadOptions): AsyncGenerator<T>;
|
|
21
25
|
/**
|
|
22
26
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
23
27
|
*
|
|
@@ -53,7 +57,7 @@ export interface ServerSentEvent {
|
|
|
53
57
|
* }
|
|
54
58
|
* ```
|
|
55
59
|
*/
|
|
56
|
-
export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<ServerSentEvent>;
|
|
60
|
+
export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: SseReadOptions): AsyncGenerator<ServerSentEvent>;
|
|
57
61
|
/**
|
|
58
62
|
* Parse a complete JSONL string, skipping malformed lines instead of throwing.
|
|
59
63
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/utils",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@sayknow-cli/natives": "0.
|
|
34
|
+
"@sayknow-cli/natives": "0.5.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 sayknow-cli config directories.
|
|
3
3
|
*
|
|
4
|
-
* Uses PI_CONFIG_DIR
|
|
5
|
-
*
|
|
4
|
+
* Uses SKC_CONFIG_DIR (legacy alias PI_CONFIG_DIR, default ".skc") for the
|
|
5
|
+
* config root and SKC_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. "skc") */
|
|
20
22
|
export const APP_NAME: string = "skc";
|
|
@@ -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. ".skc" 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
|
+
* `SKC_CODING_AGENT_DIR` is treated.
|
|
186
|
+
*/
|
|
187
|
+
function trustedConfigDirName(name: "SKC_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("SKC_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. ".skc/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: "SKC_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 `SKC_` spelling here split
|
|
330
|
+
* the agent directory in two: `skc gc` operated on the aliased directory while
|
|
331
|
+
* everything reaching `getAgentDir()` stayed on the default.
|
|
332
|
+
*/
|
|
333
|
+
function trustedAgentDirOverride(): string | undefined {
|
|
334
|
+
return trustedAgentDirOverrideFor("SKC_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;
|
|
@@ -279,3 +177,23 @@ export function $flag(name: string, def: boolean = false): boolean {
|
|
|
279
177
|
// would silently read as false while only `FOO=TRUE`/`FOO=1` worked.
|
|
280
178
|
return TRUTHY[value.toUpperCase()] === true;
|
|
281
179
|
}
|
|
180
|
+
|
|
181
|
+
/** Resolve the first flag among keys that has a set value (SKC-first, PI fallback). Matches $flag semantics per key. */
|
|
182
|
+
export function $pickflag(...keys: string[]): boolean {
|
|
183
|
+
for (const key of keys) {
|
|
184
|
+
const value = $env[key]?.trim();
|
|
185
|
+
if (value) return TRUTHY[value.toUpperCase()] === true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
|
|
191
|
+
export function $pickenvpos(keys: string[], defaultValue: number): number {
|
|
192
|
+
for (const key of keys) {
|
|
193
|
+
const raw = $env[key]?.trim();
|
|
194
|
+
if (!raw) continue;
|
|
195
|
+
const parsed = Number.parseInt(raw, 10);
|
|
196
|
+
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
|
197
|
+
}
|
|
198
|
+
return defaultValue;
|
|
199
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -9,7 +9,9 @@ const DAY = 24 * HOUR;
|
|
|
9
9
|
*/
|
|
10
10
|
export function formatDuration(ms: number): string {
|
|
11
11
|
if (ms < SEC) return `${ms}ms`;
|
|
12
|
-
|
|
12
|
+
// Truncate below 60.0s instead of rounding up into the next unit (the minute
|
|
13
|
+
// branch below), mirroring roundBelow/formatByteUnit: e.g. 59_999ms -> "59.9s".
|
|
14
|
+
if (ms < MIN) return `${(roundBelow(ms / 100, 600) / 10).toFixed(1)}s`;
|
|
13
15
|
if (ms < HOUR) {
|
|
14
16
|
const mins = Math.floor(ms / MIN);
|
|
15
17
|
const secs = Math.floor((ms % MIN) / SEC);
|
|
@@ -59,13 +61,21 @@ export function formatBytes(bytes: number): string {
|
|
|
59
61
|
if (bytes < 1024) return `${bytes}B`;
|
|
60
62
|
if (bytes < 1024 * 1024) return `${formatByteUnit(bytes, 1024)}KB`;
|
|
61
63
|
if (bytes < 1024 * 1024 * 1024) return `${formatByteUnit(bytes, 1024 * 1024)}MB`;
|
|
62
|
-
|
|
64
|
+
// The GB branch is terminal (no larger unit), so it must not clamp below the
|
|
65
|
+
// next-unit boundary the way KB/MB do; clamping here reports every value >=
|
|
66
|
+
// 1 TiB as "1023.9GB" (e.g. 2 TiB -> "1023.9GB" instead of "2048.0GB").
|
|
67
|
+
return `${formatByteUnit(bytes, 1024 * 1024 * 1024, false)}GB`;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Format bytes to 1 decimal. Intermediate units (KB/MB) clamp just below the
|
|
72
|
+
* next-unit boundary so a value like 1023.95 KB never rounds up to "1024.0KB";
|
|
73
|
+
* the terminal GB unit passes `clampToNextUnit: false` because it has no next
|
|
74
|
+
* unit to protect against.
|
|
75
|
+
*/
|
|
76
|
+
function formatByteUnit(bytes: number, unit: number, clampToNextUnit = true): string {
|
|
77
|
+
const tenths = Math.round((bytes / unit) * 10);
|
|
78
|
+
return ((clampToNextUnit ? Math.min(tenths, 1024 * 10 - 1) : tenths) / 10).toFixed(1);
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
package/src/frontmatter.ts
CHANGED
|
@@ -124,18 +124,30 @@ export function parseFrontmatter(
|
|
|
124
124
|
const loc = location ?? source;
|
|
125
125
|
const frontmatter: Record<string, unknown> = { ...fallback };
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// Normalize away a leading UTF-8 BOM and CRLF line endings before matching so
|
|
128
|
+
// a BOM-prefixed but otherwise valid document is still recognized as
|
|
129
|
+
// frontmatter (BOM-prefixed Markdown is common on Windows-authored files).
|
|
130
|
+
const normalized = normalize ? stripHtmlComments(content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")) : content;
|
|
131
|
+
// A frontmatter block opens with a line that is exactly `---` (trailing
|
|
132
|
+
// spaces/tabs allowed). A bare `----` banner or a `--- text` heading is not
|
|
133
|
+
// an opener, so a document without frontmatter keeps its body intact rather
|
|
134
|
+
// than having content silently consumed by the fixed-offset slicing below.
|
|
135
|
+
const open = normalized.match(/^---[ \t]*(?:\n|$)/);
|
|
136
|
+
if (!open) {
|
|
129
137
|
return { frontmatter, body: normalized };
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
140
|
+
// The block closes at the next line that is exactly `---`. Searching from the
|
|
141
|
+
// end of the opening `---` (offset 3, not the whole opener) keeps an empty
|
|
142
|
+
// block (`---\n---`) matching as empty frontmatter.
|
|
143
|
+
const afterOpen = normalized.slice(3);
|
|
144
|
+
const close = afterOpen.match(/\n---[ \t]*(?:\n|$)/);
|
|
145
|
+
if (!close || close.index === undefined) {
|
|
134
146
|
return { frontmatter, body: normalized };
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
const metadata =
|
|
138
|
-
const body =
|
|
149
|
+
const metadata = afterOpen.slice(open[0].length - 3, close.index);
|
|
150
|
+
const body = afterOpen.slice(close.index + close[0].length).trim();
|
|
139
151
|
|
|
140
152
|
try {
|
|
141
153
|
// Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness
|
package/src/logger.ts
CHANGED
|
@@ -410,7 +410,10 @@ function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]):
|
|
|
410
410
|
}
|
|
411
411
|
const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
|
|
412
412
|
lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}, sum ${fmtMs(totalSelf)}`);
|
|
413
|
-
|
|
413
|
+
// Resolve SKC-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.SKC_TIMING?.trim() || process.env.PI_TIMING?.trim()) === "full";
|
|
414
417
|
const sorted = [...loads].sort((a, b) => durationOf(b) - durationOf(a));
|
|
415
418
|
const visible = showAll ? sorted : sorted.slice(0, MODULE_LOAD_VERBOSE_TOP);
|
|
416
419
|
for (const span of visible) {
|
package/src/postmortem.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* in response to process exit, signals, or fatal exceptions. It is intended to
|
|
6
6
|
* allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
|
|
7
7
|
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
8
9
|
import inspector from "node:inspector";
|
|
10
|
+
import * as path from "node:path";
|
|
9
11
|
import { isMainThread } from "node:worker_threads";
|
|
10
12
|
import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
|
|
13
|
+
import { getCrashLogPath } from "./dirs";
|
|
11
14
|
import * as logger from "./logger";
|
|
12
15
|
import { safeStderrWrite } from "./safe-stderr";
|
|
13
16
|
|
|
@@ -204,6 +207,104 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
204
207
|
const formattedStack = stackLines.length > 0 ? `\n${stackLines.join("\n")}` : "";
|
|
205
208
|
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
206
209
|
}
|
|
210
|
+
/** Cap for the durable crash log; it is reset past this so a crash loop cannot fill the disk. */
|
|
211
|
+
export const CRASH_LOG_MAX_BYTES = 512 * 1024;
|
|
212
|
+
/**
|
|
213
|
+
* Per-record budget so a single oversized error body cannot bypass the file
|
|
214
|
+
* cap: every persisted record is truncated to this many bytes (UTF-8 safe,
|
|
215
|
+
* with a marker) before the append/reset decision.
|
|
216
|
+
*/
|
|
217
|
+
export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
|
|
218
|
+
const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Best-effort scrub of credential material from a crash record before it is
|
|
222
|
+
* persisted indefinitely. Covers bearer/basic-style headers, key=value or
|
|
223
|
+
* JSON key forms of common credential names, and well-known vendor token
|
|
224
|
+
* shapes. Normal messages and stack frames are untouched; matches are
|
|
225
|
+
* replaced in place so surrounding diagnostic context survives.
|
|
226
|
+
*/
|
|
227
|
+
function redactCrashSecrets(text: string): string {
|
|
228
|
+
let redacted = text;
|
|
229
|
+
redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
|
|
230
|
+
redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
|
|
231
|
+
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
|
|
232
|
+
redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
|
|
233
|
+
redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
|
|
234
|
+
redacted = redacted.replace(/\bAKIA[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
|
|
235
|
+
redacted = redacted.replace(
|
|
236
|
+
/(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
|
|
237
|
+
"$1«redacted»",
|
|
238
|
+
);
|
|
239
|
+
return redacted;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
|
|
244
|
+
* sequence. Keeps the header (timestamp/label/message) at the front, where
|
|
245
|
+
* the diagnostic value is highest.
|
|
246
|
+
*/
|
|
247
|
+
function boundCrashRecord(report: string): string {
|
|
248
|
+
if (Buffer.byteLength(report, "utf8") <= CRASH_RECORD_MAX_BYTES) return report;
|
|
249
|
+
const bytes = Buffer.from(report, "utf8");
|
|
250
|
+
const budget = CRASH_RECORD_MAX_BYTES - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
|
|
251
|
+
let end = budget;
|
|
252
|
+
// Drop trailing continuation bytes of a truncated multi-byte sequence.
|
|
253
|
+
while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
|
|
254
|
+
// Drop the now-incomplete lead byte, if any.
|
|
255
|
+
if (end > 0 && bytes[end - 1] >= 0xc0) end--;
|
|
256
|
+
return bytes.subarray(0, end).toString("utf8") + CRASH_RECORD_TRUNCATION_MARKER;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Append a fatal-crash record to the dedicated, rotation-immune crash log
|
|
261
|
+
* (`~/.skc/agent/skc-crash.log`).
|
|
262
|
+
*
|
|
263
|
+
* The daily logger file is gzip-archived at date rollover by every skc process
|
|
264
|
+
* independently; that shared-archive race can truncate a day's log to an empty
|
|
265
|
+
* `.gz`, destroying the `logger.error` crash record written here. This
|
|
266
|
+
* append-only file is never rotated, so a crash stays diagnosable regardless.
|
|
267
|
+
*
|
|
268
|
+
* Fully defensive: it never throws (a failing crash writer must not mask the
|
|
269
|
+
* original fatal) and uses synchronous IO so the record lands before
|
|
270
|
+
* `process.exit`. Returns the path written, or `undefined` on failure.
|
|
271
|
+
*/
|
|
272
|
+
export function recordFatalCrash(
|
|
273
|
+
label: string,
|
|
274
|
+
reason: unknown,
|
|
275
|
+
options: { path?: string; now?: Date } = {},
|
|
276
|
+
): string | undefined {
|
|
277
|
+
try {
|
|
278
|
+
const err = errorForDiagnostic(reason);
|
|
279
|
+
const target = options.path ?? getCrashLogPath();
|
|
280
|
+
const now = options.now ?? new Date();
|
|
281
|
+
const report = boundCrashRecord(
|
|
282
|
+
`${now.toISOString()} pid=${process.pid} [${label}] ` +
|
|
283
|
+
`${err.name || "Error"}: ${redactCrashSecrets(err.message || "(no message)")}\n` +
|
|
284
|
+
`${redactCrashSecrets(err.stack ?? "")}\n\n`,
|
|
285
|
+
);
|
|
286
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
287
|
+
let existingSize = 0;
|
|
288
|
+
try {
|
|
289
|
+
existingSize = fs.statSync(target).size;
|
|
290
|
+
} catch {}
|
|
291
|
+
// Reset (rather than append) when the file would exceed the cap so the
|
|
292
|
+
// newest crash is always retained without unbounded growth. Every record
|
|
293
|
+
// is individually bounded above, so no single crash can bypass the cap.
|
|
294
|
+
if (existingSize + Buffer.byteLength(report, "utf8") > CRASH_LOG_MAX_BYTES) {
|
|
295
|
+
fs.writeFileSync(target, report, { mode: 0o600 });
|
|
296
|
+
} else {
|
|
297
|
+
fs.appendFileSync(target, report, { mode: 0o600 });
|
|
298
|
+
}
|
|
299
|
+
// A pre-existing file may carry looser permissions; enforce owner-only.
|
|
300
|
+
try {
|
|
301
|
+
fs.chmodSync(target, 0o600);
|
|
302
|
+
} catch {}
|
|
303
|
+
return target;
|
|
304
|
+
} catch {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
207
308
|
|
|
208
309
|
/**
|
|
209
310
|
* True for terminal/pipe/disk write failures (EIO, EPIPE, EBADF, …) on the write side.
|
|
@@ -252,7 +353,12 @@ async function handleFatalError(label: string, reason: unknown, cleanupReason: R
|
|
|
252
353
|
ordinaryFatalStarted = true;
|
|
253
354
|
process.exitCode = 1;
|
|
254
355
|
const err = errorForDiagnostic(reason);
|
|
356
|
+
// Persist first: the rotation-immune record must land before any
|
|
357
|
+
// best-effort stderr output, so a slow or failing stderr cannot cost the
|
|
358
|
+
// crash record. Cleanup (which may itself hang or fail) runs afterwards.
|
|
359
|
+
const crashLogPath = recordFatalCrash(label, err);
|
|
255
360
|
safeStderrWrite(formatFatalError(label, err));
|
|
361
|
+
if (crashLogPath) safeStderrWrite(`[${label}] crash recorded at ${crashLogPath}\n`);
|
|
256
362
|
if (!quietShutdownStarted) {
|
|
257
363
|
logger.error(label === "Uncaught Exception" ? "Uncaught exception" : "Unhandled rejection", {
|
|
258
364
|
err,
|
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 "@sayknow-cli/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 `SKC_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("SKC_BASH_NO_CI", "PI_BASH_NO_CI", "CLAUDE_BASH_NO_CI");
|
|
47
51
|
const inherited = filterProcessEnv(Bun.env);
|
|
48
52
|
delete inherited.SKC_SESSION_FILE;
|
|
49
53
|
delete inherited.SKC_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 `SKC_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 `SKC_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("SKC_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
|
+
* SKC/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
|
*/
|
package/src/stream.ts
CHANGED
|
@@ -125,16 +125,22 @@ class ConcatSink {
|
|
|
125
125
|
this.#length = 0;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
*appendAndFlushLines(chunk: Uint8Array) {
|
|
128
|
+
*appendAndFlushLines(chunk: Uint8Array, maxLineBytes?: () => number) {
|
|
129
129
|
let pos = 0;
|
|
130
130
|
while (pos < chunk.length) {
|
|
131
131
|
const nl = chunk.indexOf(LF, pos);
|
|
132
132
|
if (nl === -1) {
|
|
133
|
+
if (maxLineBytes && this.#length + chunk.length - pos > maxLineBytes()) {
|
|
134
|
+
throw new Error("SSE event exceeds size limit");
|
|
135
|
+
}
|
|
133
136
|
this.append(chunk.subarray(pos));
|
|
134
137
|
return;
|
|
135
138
|
}
|
|
136
139
|
const suffix = chunk.subarray(pos, nl);
|
|
137
140
|
pos = nl + 1;
|
|
141
|
+
if (maxLineBytes && this.#length + suffix.length + 1 > maxLineBytes()) {
|
|
142
|
+
throw new Error("SSE event exceeds size limit");
|
|
143
|
+
}
|
|
138
144
|
if (this.isEmpty) {
|
|
139
145
|
yield suffix;
|
|
140
146
|
} else {
|
|
@@ -201,6 +207,11 @@ class ConcatSink {
|
|
|
201
207
|
*/
|
|
202
208
|
export type SseEventObserver = (event: ServerSentEvent) => void;
|
|
203
209
|
|
|
210
|
+
export interface SseReadOptions {
|
|
211
|
+
maxEventBytes?: number;
|
|
212
|
+
maxTotalBytes?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
204
215
|
function notifySseEventObserver(observer: SseEventObserver | undefined, event: ServerSentEvent): void {
|
|
205
216
|
if (!observer) return;
|
|
206
217
|
try {
|
|
@@ -214,8 +225,9 @@ export async function* readSseJson<T>(
|
|
|
214
225
|
stream: ReadableStream<Uint8Array>,
|
|
215
226
|
signal?: AbortSignal,
|
|
216
227
|
onEvent?: SseEventObserver,
|
|
228
|
+
options?: SseReadOptions,
|
|
217
229
|
): AsyncGenerator<T> {
|
|
218
|
-
for await (const sse of readSseEvents(stream, signal)) {
|
|
230
|
+
for await (const sse of readSseEvents(stream, signal, options)) {
|
|
219
231
|
notifySseEventObserver(onEvent, sse);
|
|
220
232
|
const data = sse.data;
|
|
221
233
|
if (data === "" || data === "[DONE]") {
|
|
@@ -336,14 +348,26 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
|
|
|
336
348
|
export async function* readSseEvents(
|
|
337
349
|
stream: ReadableStream<Uint8Array>,
|
|
338
350
|
signal?: AbortSignal,
|
|
351
|
+
options?: SseReadOptions,
|
|
339
352
|
): AsyncGenerator<ServerSentEvent> {
|
|
340
353
|
const lineBuffer = new ConcatSink();
|
|
341
354
|
const state: SseEventState = { event: null, data: null, raw: [] };
|
|
342
355
|
const source = createAbortableStream(stream, signal);
|
|
356
|
+
let eventBytes = 0;
|
|
357
|
+
let totalBytes = 0;
|
|
343
358
|
try {
|
|
344
359
|
for await (const chunk of source) {
|
|
345
|
-
|
|
360
|
+
totalBytes += chunk.length;
|
|
361
|
+
if (options?.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) {
|
|
362
|
+
throw new Error("SSE stream exceeds size limit");
|
|
363
|
+
}
|
|
364
|
+
for (const line of lineBuffer.appendAndFlushLines(
|
|
365
|
+
chunk,
|
|
366
|
+
() => (options?.maxEventBytes ?? Infinity) - eventBytes,
|
|
367
|
+
)) {
|
|
368
|
+
eventBytes += line.length + 1;
|
|
346
369
|
const event = pushSseLine(line, state);
|
|
370
|
+
if (line.length === 0 || (line.length === 1 && line[0] === 0x0d)) eventBytes = 0;
|
|
347
371
|
if (event) yield event;
|
|
348
372
|
}
|
|
349
373
|
}
|