@sayknow-cli/utils 0.7.11 → 0.7.13
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/broken-pipe.d.ts +4 -3
- package/dist/types/canonical-log-dir.d.ts +20 -0
- package/dist/types/cli-metadata.d.ts +33 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/crash-fingerprint.d.ts +82 -0
- package/dist/types/crash-journal.d.ts +84 -0
- package/dist/types/crash-redaction.d.ts +20 -0
- package/dist/types/dirs.d.ts +127 -36
- package/dist/types/env-file.d.ts +59 -0
- package/dist/types/env.d.ts +19 -1
- package/dist/types/error-classification.d.ts +12 -0
- package/dist/types/fetch-retry.d.ts +6 -3
- package/dist/types/header-value.d.ts +10 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/logger.d.ts +22 -8
- package/dist/types/native-process.d.ts +12 -0
- package/dist/types/procmgr.d.ts +1 -35
- package/dist/types/safe-error.d.ts +20 -0
- package/dist/types/sanitize-text.d.ts +10 -0
- package/dist/types/shell-config.d.ts +58 -0
- package/dist/types/stream.d.ts +2 -1
- package/dist/types/which.d.ts +1 -1
- package/package.json +2 -2
- package/src/broken-pipe.ts +16 -4
- package/src/canonical-log-dir.ts +59 -0
- package/src/cli-metadata.ts +74 -0
- package/src/cli.ts +30 -2
- package/src/crash-fingerprint.ts +294 -0
- package/src/crash-journal.ts +292 -0
- package/src/crash-redaction.ts +558 -0
- package/src/dirs.ts +583 -165
- package/src/env-file.ts +326 -20
- package/src/env.ts +321 -35
- package/src/error-classification.ts +31 -0
- package/src/fetch-retry.ts +23 -10
- package/src/frontmatter.ts +28 -2
- package/src/glob.ts +13 -10
- package/src/header-value.ts +15 -0
- package/src/index.ts +6 -0
- package/src/logger.ts +252 -97
- package/src/native-process.ts +19 -0
- package/src/postmortem.ts +1 -55
- package/src/procmgr.ts +16 -212
- package/src/ptree.ts +3 -2
- package/src/safe-error.ts +40 -0
- package/src/sanitize-text.ts +14 -0
- package/src/shell-config.ts +329 -0
- package/src/stream.ts +32 -1
- package/src/which.ts +6 -5
package/src/env.ts
CHANGED
|
@@ -1,23 +1,174 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
1
2
|
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
|
-
import { getAgentDir, getConfigRootDir } from "./dirs";
|
|
4
|
+
import { canonicalEnvKey, getAgentDir, getConfigRootDir, getTrustedHomeDir, pathIdentityKey } from "./dirs";
|
|
4
5
|
import { isSafeEnvName, isSafeEnvValue } from "./spawn-env";
|
|
5
6
|
|
|
6
7
|
export { filterProcessEnv, isSafeEnvName, isSafeEnvValue } from "./spawn-env";
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
parseEnvFile,
|
|
11
|
+
parseEnvFileContent,
|
|
12
|
+
parseShellEnvFile,
|
|
13
|
+
projectEnvLayerFiles,
|
|
14
|
+
projectEnvSnapshot,
|
|
15
|
+
} from "./env-file";
|
|
9
16
|
|
|
10
17
|
// Re-exported so the public surface of this module is unchanged.
|
|
11
18
|
export { isValidEnvName, parseEnvFile, parseShellEnvFile } from "./env-file";
|
|
12
19
|
|
|
20
|
+
function findClosingBrace(value: string, open: number): number {
|
|
21
|
+
let depth = 1;
|
|
22
|
+
for (let index = open + 2; index < value.length; index++) {
|
|
23
|
+
if (value[index] === "\\") {
|
|
24
|
+
index++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (value[index] === "$" && value[index + 1] === "{") {
|
|
28
|
+
depth++;
|
|
29
|
+
index++;
|
|
30
|
+
} else if (value[index] === "}" && --depth === 0) {
|
|
31
|
+
return index;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function expandProjectEnvValue(value: string, resolve: (name: string) => string | undefined): string {
|
|
38
|
+
let result = "";
|
|
39
|
+
for (let index = 0; index < value.length; index++) {
|
|
40
|
+
const char = value[index];
|
|
41
|
+
if (char === "\\" && value[index + 1] === "$") {
|
|
42
|
+
result += "$";
|
|
43
|
+
index++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (char !== "$") {
|
|
47
|
+
result += char;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let name: string | undefined;
|
|
52
|
+
let end = index;
|
|
53
|
+
let fallback: string | undefined;
|
|
54
|
+
if (value[index + 1] === "{") {
|
|
55
|
+
const close = findClosingBrace(value, index);
|
|
56
|
+
const expression = close < 0 ? undefined : value.slice(index + 2, close);
|
|
57
|
+
const match = expression && /^([A-Za-z_][A-Za-z0-9_]*)(?::-(.*))?$/.exec(expression);
|
|
58
|
+
if (match) {
|
|
59
|
+
name = match[1];
|
|
60
|
+
fallback = match[2];
|
|
61
|
+
end = close;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(value.slice(index + 1));
|
|
65
|
+
if (match) {
|
|
66
|
+
name = match[0];
|
|
67
|
+
end = index + name.length;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!name) {
|
|
71
|
+
result += "$";
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
result += resolve(name) ?? (fallback === undefined ? "" : expandProjectEnvValue(fallback, resolve));
|
|
75
|
+
index = end;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadProjectEnv(): {
|
|
81
|
+
values: Record<string, string>;
|
|
82
|
+
loadedValues: Record<string, string>;
|
|
83
|
+
dynamic: Set<string>;
|
|
84
|
+
} {
|
|
85
|
+
const cwd = process.cwd();
|
|
86
|
+
const nodeEnv = process.env.NODE_ENV || Bun.env.NODE_ENV;
|
|
87
|
+
// Same file list as projectEnvSnapshot, so a loaded value stays project-controlled.
|
|
88
|
+
const files = projectEnvLayerFiles(nodeEnv);
|
|
89
|
+
const values: Record<string, string> = {};
|
|
90
|
+
const dynamic = new Set<string>();
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const parsed = parseEnvFile(path.join(cwd, file));
|
|
93
|
+
for (const [rawKey, value] of Object.entries(parsed)) {
|
|
94
|
+
// Windows environment names are case-insensitive, so the guard lookups
|
|
95
|
+
// below must see the same key Bun loaded into `process.env`.
|
|
96
|
+
const key = canonicalEnvKey(rawKey);
|
|
97
|
+
values[key] = value;
|
|
98
|
+
// Track dynamic provenance only for the winning declaration.
|
|
99
|
+
if (/[$`]/.test(value)) dynamic.add(key);
|
|
100
|
+
else dynamic.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Quoted escapes are not `$` or backticks, but Bun autoload decodes `\n`
|
|
104
|
+
// and `\r` inside double quotes. `projectEnvSnapshot` already refuses that
|
|
105
|
+
// shape. Union those keys. Do not copy its values: an unparsed declaration
|
|
106
|
+
// is stored there as "" and must not overlay this layer. File selection
|
|
107
|
+
// stays above, so a higher static layer still clears a lower dynamic one.
|
|
108
|
+
for (const key of projectEnvSnapshot(cwd).dynamic) dynamic.add(key);
|
|
109
|
+
|
|
110
|
+
const resolved = new Map<string, string>();
|
|
111
|
+
const resolving = new Set<string>();
|
|
112
|
+
const resolveProjectValue = (key: string): string | undefined => {
|
|
113
|
+
const inherited = Bun.env[key];
|
|
114
|
+
if (inherited !== undefined) return inherited;
|
|
115
|
+
const cached = resolved.get(key);
|
|
116
|
+
if (cached !== undefined) return cached;
|
|
117
|
+
if (!Object.hasOwn(values, key) || resolving.has(key)) return undefined;
|
|
118
|
+
|
|
119
|
+
resolving.add(key);
|
|
120
|
+
const value = expandProjectEnvValue(values[key] ?? "", name => resolveProjectValue(canonicalEnvKey(name)));
|
|
121
|
+
resolving.delete(key);
|
|
122
|
+
resolved.set(key, value);
|
|
123
|
+
return value;
|
|
124
|
+
};
|
|
125
|
+
for (const [key, value] of Object.entries(values)) {
|
|
126
|
+
resolved.set(
|
|
127
|
+
key,
|
|
128
|
+
expandProjectEnvValue(value, name => resolveProjectValue(canonicalEnvKey(name))),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return { values, loadedValues: Object.fromEntries(resolved), dynamic };
|
|
132
|
+
}
|
|
133
|
+
|
|
13
134
|
function resolveFileEnvValue(file: Record<string, string>, name: string): string | undefined {
|
|
14
135
|
if (!isSafeEnvName(name)) return undefined;
|
|
15
|
-
const value = file[name];
|
|
136
|
+
const value = file[canonicalEnvKey(name)];
|
|
16
137
|
if (value === undefined || !isSafeEnvValue(value)) return undefined;
|
|
17
138
|
const trimmed = value.trim();
|
|
18
139
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
19
140
|
}
|
|
20
141
|
|
|
142
|
+
type TrustedAgentEnvRead =
|
|
143
|
+
| { status: "missing"; values: Record<string, string> }
|
|
144
|
+
| { status: "unavailable"; values: Record<string, string> }
|
|
145
|
+
| { status: "ok"; values: Record<string, string> };
|
|
146
|
+
|
|
147
|
+
function readTrustedAgentEnv(): TrustedAgentEnvRead {
|
|
148
|
+
let filePath: string;
|
|
149
|
+
try {
|
|
150
|
+
filePath = path.join(getAgentDir(), ".env");
|
|
151
|
+
} catch {
|
|
152
|
+
return { status: "unavailable", values: {} };
|
|
153
|
+
}
|
|
154
|
+
let fileDescriptor: number | undefined;
|
|
155
|
+
try {
|
|
156
|
+
const linkStats = fs.lstatSync(filePath);
|
|
157
|
+
if (!linkStats.isFile()) return { status: "unavailable", values: {} };
|
|
158
|
+
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
159
|
+
fileDescriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
|
|
160
|
+
const fileStats = fs.fstatSync(fileDescriptor);
|
|
161
|
+
if (!fileStats.isFile()) return { status: "unavailable", values: {} };
|
|
162
|
+
const content = fs.readFileSync(fileDescriptor, "utf-8");
|
|
163
|
+
return { status: "ok", values: parseEnvFileContent(content) };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
166
|
+
return code === "ENOENT" ? { status: "missing", values: {} } : { status: "unavailable", values: {} };
|
|
167
|
+
} finally {
|
|
168
|
+
if (fileDescriptor !== undefined) fs.closeSync(fileDescriptor);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
21
172
|
function filterCredentialInheritedEnv(env: Record<string, string | undefined>): Record<string, string> {
|
|
22
173
|
const result: Record<string, string> = {};
|
|
23
174
|
for (const key in env) {
|
|
@@ -25,34 +176,108 @@ function filterCredentialInheritedEnv(env: Record<string, string | undefined>):
|
|
|
25
176
|
if (!isSafeEnvName(key) || value === undefined || !isSafeEnvValue(value)) continue;
|
|
26
177
|
|
|
27
178
|
// Bun may have already loaded cwd/.env before JS runs. It does not expose the
|
|
28
|
-
// source of each entry, so
|
|
29
|
-
//
|
|
30
|
-
// credential-only
|
|
179
|
+
// source of each entry, so a matching project declaration is ambiguous. A
|
|
180
|
+
// dynamic dotenv declaration is also ambiguous even when expansion changes
|
|
181
|
+
// its runtime value. Exclude those from the credential-only snapshot while
|
|
182
|
+
// keeping them available through $env.
|
|
31
183
|
const projectValue = resolveFileEnvValue(projectEnv, key);
|
|
32
|
-
if (projectValue !== undefined && projectValue === value)
|
|
184
|
+
if (projectValue !== undefined && (projectSnapshot.dynamic.has(canonicalEnvKey(key)) || projectValue === value))
|
|
185
|
+
continue;
|
|
33
186
|
|
|
34
187
|
result[key] = value;
|
|
35
188
|
}
|
|
36
189
|
return result;
|
|
37
190
|
}
|
|
38
191
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
192
|
+
// Parse the current project's .env first. Bun may have overlaid HOME from it
|
|
193
|
+
// before this module runs, so a declared HOME must never select user credential
|
|
194
|
+
// files for the credential-only snapshot.
|
|
195
|
+
const projectSnapshot = loadProjectEnv();
|
|
196
|
+
const projectEnv = projectSnapshot.values;
|
|
197
|
+
|
|
198
|
+
/** Project dotenv as it was when this module loaded, before a later `chdir` or file delete. */
|
|
199
|
+
export function startupProjectEnvSnapshot(): { values: Record<string, string>; dynamic: Set<string> } {
|
|
200
|
+
return { values: projectSnapshot.values, dynamic: projectSnapshot.dynamic };
|
|
201
|
+
}
|
|
202
|
+
const authoritativeHomeKey = process.platform === "win32" ? "USERPROFILE" : "HOME";
|
|
203
|
+
const declaredHomeKey = canonicalEnvKey(authoritativeHomeKey);
|
|
204
|
+
const declaredHome = projectEnv[declaredHomeKey];
|
|
205
|
+
const runtimeHome = process.env[authoritativeHomeKey];
|
|
206
|
+
const rejectProjectHome =
|
|
207
|
+
declaredHome !== undefined &&
|
|
208
|
+
runtimeHome !== undefined &&
|
|
209
|
+
(projectSnapshot.dynamic.has(declaredHomeKey) || declaredHome === runtimeHome);
|
|
210
|
+
let trustedEnvHome: string | undefined;
|
|
211
|
+
try {
|
|
212
|
+
trustedEnvHome = rejectProjectHome ? getTrustedHomeDir() : os.homedir();
|
|
213
|
+
} catch {
|
|
214
|
+
// No trustworthy account home means no user credential files are trusted.
|
|
215
|
+
trustedEnvHome = undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `path.resolve` does not follow symlinks, so macOS `/tmp` vs `/private/tmp`,
|
|
219
|
+
// a symlink home, or a bind mount compares unequal and would load the checkout
|
|
220
|
+
// as the trusted home. Keep the lexical check, then compare device/inode, then
|
|
221
|
+
// `pathIdentityKey` when the filesystem has no usable inode.
|
|
222
|
+
function credentialHomeMatchesCwd(home: string): boolean {
|
|
223
|
+
const cwd = process.cwd();
|
|
224
|
+
if (path.resolve(home) === path.resolve(cwd)) return true;
|
|
225
|
+
try {
|
|
226
|
+
const left = fs.statSync(home, { bigint: true });
|
|
227
|
+
const right = fs.statSync(cwd, { bigint: true });
|
|
228
|
+
if (left.isDirectory() && right.isDirectory() && left.ino !== 0n && right.ino !== 0n) {
|
|
229
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
// Missing or unreadable: do not treat this directory as a credential home.
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
return pathIdentityKey(home) === pathIdentityKey(cwd);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Eagerly parse the trusted user's env files and the project .env (from cwd).
|
|
239
|
+
// When cwd is that home, both the dotenv and the shell startup files are the
|
|
240
|
+
// checkout, so neither is a credential source.
|
|
241
|
+
const credentialHomeIsCwd = trustedEnvHome !== undefined && credentialHomeMatchesCwd(trustedEnvHome);
|
|
242
|
+
const homeShellEnv =
|
|
243
|
+
trustedEnvHome && !credentialHomeIsCwd
|
|
244
|
+
? {
|
|
245
|
+
...parseShellEnvFile(path.join(trustedEnvHome, ".zshenv")),
|
|
246
|
+
...parseShellEnvFile(path.join(trustedEnvHome, ".zprofile")),
|
|
247
|
+
...parseShellEnvFile(path.join(trustedEnvHome, ".zshrc")),
|
|
248
|
+
...parseShellEnvFile(path.join(trustedEnvHome, ".bash_profile")),
|
|
249
|
+
...parseShellEnvFile(path.join(trustedEnvHome, ".bashrc")),
|
|
250
|
+
}
|
|
251
|
+
: {};
|
|
252
|
+
const homeEnv = trustedEnvHome && !credentialHomeIsCwd ? parseEnvFile(path.join(trustedEnvHome, ".env")) : {};
|
|
253
|
+
let piEnv: Record<string, string> = {};
|
|
254
|
+
let agentEnv: Record<string, string> = {};
|
|
255
|
+
try {
|
|
256
|
+
piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
|
|
257
|
+
agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
|
|
258
|
+
} catch {
|
|
259
|
+
// Keep credential resolution fail-closed when trusted user state is unavailable.
|
|
260
|
+
}
|
|
261
|
+
const initialTrustedAgentEnv = readTrustedAgentEnv();
|
|
262
|
+
const projectLoadedEnv: Record<string, string | undefined> = Object.fromEntries(
|
|
263
|
+
Object.keys(projectEnv).map(key => [key, Bun.env[key]]),
|
|
264
|
+
);
|
|
51
265
|
|
|
52
266
|
const inheritedEnv = filterCredentialInheritedEnv(Bun.env);
|
|
267
|
+
const rotatingAgentEnvNames = new Set(Object.keys(agentEnv));
|
|
53
268
|
|
|
54
269
|
export function $inheritedEnv(name: string): string | undefined {
|
|
55
|
-
|
|
270
|
+
const snapshotValue = resolveFileEnvValue(inheritedEnv, name);
|
|
271
|
+
if (snapshotValue === undefined) return undefined;
|
|
272
|
+
// The snapshot records provenance — this key was inherited from the launching
|
|
273
|
+
// shell rather than the caller's cwd/.env — and pins the value so a later
|
|
274
|
+
// in-process write cannot swap the credential we authenticate with. It is not
|
|
275
|
+
// a cache that outlives the variable: once the key is removed from the live
|
|
276
|
+
// environment it is no longer inherited, so deletion is honoured. Without
|
|
277
|
+
// this, a credential present at import time can never be suppressed (tests
|
|
278
|
+
// that clear provider env vars silently keep resolving the real credential).
|
|
279
|
+
if (Bun.env[name] === undefined) return undefined;
|
|
280
|
+
return snapshotValue;
|
|
56
281
|
}
|
|
57
282
|
|
|
58
283
|
function resolveLiveCredentialEnvValue(name: string): string | undefined {
|
|
@@ -62,11 +287,12 @@ function resolveLiveCredentialEnvValue(name: string): string | undefined {
|
|
|
62
287
|
const trimmed = value.trim();
|
|
63
288
|
if (trimmed.length === 0) return undefined;
|
|
64
289
|
|
|
65
|
-
const projectValue = resolveFileEnvValue(projectEnv, name);
|
|
66
290
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
291
|
+
Object.hasOwn(projectEnv, canonicalEnvKey(name)) &&
|
|
292
|
+
resolveFileEnvValue(inheritedEnv, name) === undefined &&
|
|
293
|
+
(projectSnapshot.dynamic.has(canonicalEnvKey(name)) ||
|
|
294
|
+
trimmed === resolveFileEnvValue(projectEnv, name) ||
|
|
295
|
+
trimmed === projectLoadedEnv[canonicalEnvKey(name)])
|
|
70
296
|
) {
|
|
71
297
|
return undefined;
|
|
72
298
|
}
|
|
@@ -74,8 +300,44 @@ function resolveLiveCredentialEnvValue(name: string): string | undefined {
|
|
|
74
300
|
return trimmed;
|
|
75
301
|
}
|
|
76
302
|
|
|
77
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Names a repository `.env` must not install into the process environment.
|
|
305
|
+
* `NODE_TLS_REJECT_UNAUTHORIZED=0` disables certificate verification for every
|
|
306
|
+
* fetch in the process, so a project `.env` pairing it with a proxy would expose
|
|
307
|
+
* provider credentials to whoever answers the proxy.
|
|
308
|
+
* `SKC_HARNESS_SOCKET_DIR` and `SKC_HARNESS_ROOT_REGISTRY_DIR` select the harness
|
|
309
|
+
* control socket directory and the session-root registry. The harness reads both
|
|
310
|
+
* from the process environment after this module loads, so a project `.env` must
|
|
311
|
+
* not point either at a path the repository controls.
|
|
312
|
+
*/
|
|
313
|
+
const PROJECT_DENIED_ENV_NAMES = [
|
|
314
|
+
"NODE_TLS_REJECT_UNAUTHORIZED",
|
|
315
|
+
"SKC_HARNESS_SOCKET_DIR",
|
|
316
|
+
"SKC_HARNESS_ROOT_REGISTRY_DIR",
|
|
317
|
+
] as const;
|
|
318
|
+
|
|
319
|
+
// Bun may have loaded cwd/.env before JS runs. Drop a value the project declares
|
|
320
|
+
// (same provenance rule as the credential snapshot) and never copy it below.
|
|
321
|
+
// Removing `NODE_TLS_REJECT_UNAUTHORIZED` restores verification: Bun reads that
|
|
322
|
+
// switch per TLS connection. The same drop covers the harness directory names.
|
|
323
|
+
// A quoted escape is dynamic even when the line parser did not store a value.
|
|
324
|
+
for (const name of PROJECT_DENIED_ENV_NAMES) {
|
|
325
|
+
const key = canonicalEnvKey(name);
|
|
326
|
+
const declared = projectEnv[key];
|
|
327
|
+
if (declared === undefined && !projectSnapshot.dynamic.has(key)) continue;
|
|
328
|
+
const runtime = Bun.env[name];
|
|
329
|
+
if (
|
|
330
|
+
runtime !== undefined &&
|
|
331
|
+
(projectSnapshot.dynamic.has(key) || runtime === declared || runtime.trim() === declared)
|
|
332
|
+
) {
|
|
333
|
+
delete Bun.env[name];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const projectDeniedKeys = new Set<string>(PROJECT_DENIED_ENV_NAMES.map(canonicalEnvKey));
|
|
337
|
+
|
|
338
|
+
for (const file of [projectSnapshot.loadedValues, agentEnv, piEnv, homeEnv, homeShellEnv]) {
|
|
78
339
|
for (const key in file) {
|
|
340
|
+
if (file === projectSnapshot.loadedValues && projectDeniedKeys.has(key)) continue;
|
|
79
341
|
if (!Bun.env[key]) {
|
|
80
342
|
Bun.env[key] = file[key];
|
|
81
343
|
}
|
|
@@ -126,6 +388,28 @@ export function $credentialEnv(name: string): string | undefined {
|
|
|
126
388
|
);
|
|
127
389
|
}
|
|
128
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Resolve a credential that may rotate in the trusted agent `.env` while this
|
|
393
|
+
* process remains alive.
|
|
394
|
+
*
|
|
395
|
+
* Presence in the agent file establishes that file as the credential's
|
|
396
|
+
* authority, even when the launching shell inherited an older value. This is
|
|
397
|
+
* what lets an atomic token rotation repair a long-lived shell instead of
|
|
398
|
+
* falling back to its revoked snapshot. Removal is authoritative too, so a
|
|
399
|
+
* deleted token cannot silently reappear from that snapshot. Project `.env`
|
|
400
|
+
* files are never consulted; shell values retain their normal precedence for
|
|
401
|
+
* names the agent file does not own.
|
|
402
|
+
*/
|
|
403
|
+
export function $rotatingCredentialEnv(name: string): string | undefined {
|
|
404
|
+
if (!isSafeEnvName(name)) return undefined;
|
|
405
|
+
const agentRead = readTrustedAgentEnv();
|
|
406
|
+
if (agentRead.status === "ok" && Object.hasOwn(agentRead.values, name)) rotatingAgentEnvNames.add(name);
|
|
407
|
+
if (initialTrustedAgentEnv.status === "unavailable" && !rotatingAgentEnvNames.has(name)) return undefined;
|
|
408
|
+
if (!rotatingAgentEnvNames.has(name)) return $credentialEnv(name);
|
|
409
|
+
if (agentRead.status !== "ok") return undefined;
|
|
410
|
+
return resolveFileEnvValue(agentRead.values, name);
|
|
411
|
+
}
|
|
412
|
+
|
|
129
413
|
/**
|
|
130
414
|
* Resolve the first credential env value from the given keys, excluding cwd/.env overlays.
|
|
131
415
|
*/
|
|
@@ -137,16 +421,20 @@ export function $pickCredentialEnv(...keys: string[]): string | undefined {
|
|
|
137
421
|
return undefined;
|
|
138
422
|
}
|
|
139
423
|
|
|
424
|
+
function parsePositiveInteger(raw: string | undefined): number | undefined {
|
|
425
|
+
const value = raw?.trim();
|
|
426
|
+
if (!value || !/^\d+$/.test(value)) return undefined;
|
|
427
|
+
const parsed = Number(value);
|
|
428
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
140
431
|
/**
|
|
141
432
|
* Parses a positive decimal integer from `$env[name]`.
|
|
142
|
-
* Empty, invalid,
|
|
433
|
+
* Empty, invalid, unsafe, zero, or negative values return `defaultValue`.
|
|
143
434
|
*/
|
|
144
435
|
export function $envpos(name: string, defaultValue: number): number {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
const parsed = Number.parseInt(raw, 10);
|
|
148
|
-
if (Number.isNaN(parsed) || parsed <= 0) return defaultValue;
|
|
149
|
-
return parsed;
|
|
436
|
+
const parsed = parsePositiveInteger($env[name]);
|
|
437
|
+
return parsed ?? defaultValue;
|
|
150
438
|
}
|
|
151
439
|
|
|
152
440
|
/** True when `BUN_ENV` or `NODE_ENV` is the string `test`. */
|
|
@@ -190,10 +478,8 @@ export function $pickflag(...keys: string[]): boolean {
|
|
|
190
478
|
/** Resolve the first positive integer among keys, else defaultValue (SKC-first). Set-but-invalid keys are skipped. */
|
|
191
479
|
export function $pickenvpos(keys: string[], defaultValue: number): number {
|
|
192
480
|
for (const key of keys) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
195
|
-
const parsed = Number.parseInt(raw, 10);
|
|
196
|
-
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
|
481
|
+
const parsed = parsePositiveInteger($env[key]);
|
|
482
|
+
if (parsed !== undefined) return parsed;
|
|
197
483
|
}
|
|
198
484
|
return defaultValue;
|
|
199
485
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-package classification for errors that describe an expected outcome
|
|
3
|
+
* rather than an unexpected failure.
|
|
4
|
+
*
|
|
5
|
+
* The symbol is registered globally so the marker survives package duplication
|
|
6
|
+
* and worker/VM boundaries without relying on a particular Error constructor.
|
|
7
|
+
*/
|
|
8
|
+
export const DESIGNED_ERROR = Symbol.for("sayknow-cli.designed-error");
|
|
9
|
+
|
|
10
|
+
type MarkedError = object & { readonly [DESIGNED_ERROR]?: unknown };
|
|
11
|
+
|
|
12
|
+
/** Mark an Error as a designed outcome before it crosses package boundaries. */
|
|
13
|
+
export function markDesignedError<T extends Error>(error: T): T {
|
|
14
|
+
Object.defineProperty(error, DESIGNED_ERROR, {
|
|
15
|
+
configurable: false,
|
|
16
|
+
enumerable: false,
|
|
17
|
+
value: true,
|
|
18
|
+
writable: false,
|
|
19
|
+
});
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Return true when a throwable carries the trusted designed-outcome marker. */
|
|
24
|
+
export function isDesignedError(error: unknown): boolean {
|
|
25
|
+
if ((typeof error !== "object" && typeof error !== "function") || error === null) return false;
|
|
26
|
+
try {
|
|
27
|
+
return (error as MarkedError)[DESIGNED_ERROR] === true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/fetch-retry.ts
CHANGED
|
@@ -81,15 +81,18 @@ export interface FetchWithRetryOptions extends RequestInit {
|
|
|
81
81
|
/**
|
|
82
82
|
* Per-delay cap. Server-provided `Retry-After` hints exceeding this return
|
|
83
83
|
* the current response immediately — caller deals with the `!response.ok`.
|
|
84
|
-
* Default
|
|
84
|
+
* Scheduled values are also capped at the platform timer ceiling. Default
|
|
85
|
+
* `60_000`.
|
|
85
86
|
*/
|
|
86
87
|
maxDelayMs?: number;
|
|
87
88
|
/**
|
|
88
89
|
* Fallback delay schedule when no server hint is present. Number, array
|
|
89
90
|
* (indexed by attempt, clamped to last), or function. Default exponential
|
|
90
|
-
* `500ms * 2 ** attempt` capped at `maxDelayMs
|
|
91
|
+
* `500ms * 2 ** attempt` capped at `maxDelayMs` and the platform timer
|
|
92
|
+
* ceiling. Values that remain negative or non-finite after capping retry
|
|
93
|
+
* immediately.
|
|
91
94
|
*/
|
|
92
|
-
defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
|
|
95
|
+
defaultDelayMs?: number | readonly number[] | ((attempt: number, error?: unknown) => number);
|
|
93
96
|
/**
|
|
94
97
|
* Optional per-attempt overlay merged into the base `RequestInit` each try.
|
|
95
98
|
* Headers from the overlay shallow-merge over the base. Useful for auth
|
|
@@ -106,6 +109,9 @@ export interface FetchWithRetryOptions extends RequestInit {
|
|
|
106
109
|
|
|
107
110
|
const DEFAULT_MAX_DELAY_MS = 60_000;
|
|
108
111
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
112
|
+
// Node-compatible timers coerce larger delays to 1 ms. Bun follows the same
|
|
113
|
+
// signed 32-bit boundary, so every scheduler input must stay at or below it.
|
|
114
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
109
115
|
|
|
110
116
|
/**
|
|
111
117
|
* Fetch with bounded retries and sensible defaults. Retries on any
|
|
@@ -142,7 +148,8 @@ export async function fetchWithRetry(
|
|
|
142
148
|
if (signal?.aborted) throw new Error("Request was aborted");
|
|
143
149
|
const wrapped = wrapNetworkError(error);
|
|
144
150
|
if (attempt + 1 >= maxAttempts) throw wrapped;
|
|
145
|
-
|
|
151
|
+
const delayMs = normalizeRetryDelay(resolveDefaultDelay(defaultDelayMs, attempt, error), maxDelayMs);
|
|
152
|
+
await scheduler.wait(delayMs, { signal });
|
|
146
153
|
continue;
|
|
147
154
|
}
|
|
148
155
|
|
|
@@ -152,7 +159,8 @@ export async function fetchWithRetry(
|
|
|
152
159
|
const hint = extractRetryHint(response, await response.clone().text());
|
|
153
160
|
if (hint !== undefined && hint > maxDelayMs) return response;
|
|
154
161
|
|
|
155
|
-
const delayMs =
|
|
162
|
+
const delayMs = normalizeRetryDelay(hint ?? resolveDefaultDelay(defaultDelayMs, attempt), maxDelayMs);
|
|
163
|
+
void response.body?.cancel().catch(() => undefined);
|
|
156
164
|
await scheduler.wait(delayMs, { signal });
|
|
157
165
|
}
|
|
158
166
|
}
|
|
@@ -186,12 +194,17 @@ function wrapNetworkError(error: unknown): Error {
|
|
|
186
194
|
function resolveDefaultDelay(
|
|
187
195
|
option: FetchWithRetryOptions["defaultDelayMs"],
|
|
188
196
|
attempt: number,
|
|
189
|
-
|
|
197
|
+
error?: unknown,
|
|
190
198
|
): number {
|
|
191
|
-
if (option === undefined) return
|
|
192
|
-
if (typeof option === "number") return
|
|
193
|
-
if (typeof option === "function") return
|
|
194
|
-
return
|
|
199
|
+
if (option === undefined) return 500 * 2 ** attempt;
|
|
200
|
+
if (typeof option === "number") return option;
|
|
201
|
+
if (typeof option === "function") return option(attempt, error);
|
|
202
|
+
return option[Math.min(attempt, option.length - 1)] ?? 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeRetryDelay(delayMs: number, maxDelayMs: number): number {
|
|
206
|
+
const cappedDelayMs = Math.min(delayMs, maxDelayMs, MAX_TIMER_DELAY_MS);
|
|
207
|
+
return Number.isFinite(cappedDelayMs) && cappedDelayMs >= 0 ? cappedDelayMs : 0;
|
|
195
208
|
}
|
|
196
209
|
|
|
197
210
|
/**
|
package/src/frontmatter.ts
CHANGED
|
@@ -2,8 +2,29 @@ import { YAML } from "bun";
|
|
|
2
2
|
import { truncate } from "./format";
|
|
3
3
|
import * as logger from "./logger";
|
|
4
4
|
|
|
5
|
+
const HTML_COMMENT_OPEN = "<!--";
|
|
6
|
+
const HTML_COMMENT_CLOSE = "-->";
|
|
7
|
+
|
|
8
|
+
// One forward scan. `/<!--[\s\S]*?-->/g` retries every unterminated opener
|
|
9
|
+
// against the rest of the input, which is quadratic in the opener count.
|
|
10
|
+
// An opener with no later close does not match, so that tail stays unchanged.
|
|
5
11
|
function stripHtmlComments(content: string): string {
|
|
6
|
-
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
let from = 0;
|
|
14
|
+
let i = 0;
|
|
15
|
+
const n = content.length;
|
|
16
|
+
while (i < n) {
|
|
17
|
+
const start = content.indexOf(HTML_COMMENT_OPEN, i);
|
|
18
|
+
if (start < 0) break;
|
|
19
|
+
const end = content.indexOf(HTML_COMMENT_CLOSE, start + HTML_COMMENT_OPEN.length);
|
|
20
|
+
if (end < 0) break;
|
|
21
|
+
if (start > from) parts.push(content.slice(from, start));
|
|
22
|
+
from = end + HTML_COMMENT_CLOSE.length;
|
|
23
|
+
i = from;
|
|
24
|
+
}
|
|
25
|
+
if (from === 0) return content;
|
|
26
|
+
if (from < n) parts.push(content.slice(from));
|
|
27
|
+
return parts.join("");
|
|
7
28
|
}
|
|
8
29
|
|
|
9
30
|
function stripLooseScalarTrailingCommas(metadata: string): string {
|
|
@@ -69,7 +90,12 @@ function normalizeKeys<T>(obj: T): T {
|
|
|
69
90
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
70
91
|
const nk = key.includes("-") ? kebabToCamel(key) : key;
|
|
71
92
|
const nv = normalizeKeys(value);
|
|
72
|
-
|
|
93
|
+
// Assignment would route "__proto__" through Object.prototype's setter.
|
|
94
|
+
if (nk === "__proto__") {
|
|
95
|
+
Object.defineProperty(result, nk, { value: nv, writable: true, enumerable: true, configurable: true });
|
|
96
|
+
} else {
|
|
97
|
+
result[nk] = nv;
|
|
98
|
+
}
|
|
73
99
|
if (nk !== key || nv !== value) changed = true;
|
|
74
100
|
}
|
|
75
101
|
return (changed ? result : obj) as T;
|
package/src/glob.ts
CHANGED
|
@@ -137,6 +137,10 @@ export async function loadGitignorePatterns(baseDir: string): Promise<string[]>
|
|
|
137
137
|
*/
|
|
138
138
|
export async function globPaths(patterns: string | string[], options: GlobPathsOptions = {}): Promise<string[]> {
|
|
139
139
|
const { cwd, exclude, signal, timeoutMs, dot, onlyFiles = true, gitignore } = options;
|
|
140
|
+
const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
141
|
+
const combinedSignal =
|
|
142
|
+
signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
|
|
143
|
+
throwIfGlobAborted(combinedSignal);
|
|
140
144
|
|
|
141
145
|
// Build exclude list: always exclude .git, exclude node_modules unless pattern references it
|
|
142
146
|
const patternArray = Array.isArray(patterns) ? patterns : [patterns];
|
|
@@ -147,6 +151,7 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
|
|
|
147
151
|
|
|
148
152
|
if (gitignore) {
|
|
149
153
|
const gitignorePatterns = await loadGitignorePatterns(cwd ?? getProjectDir());
|
|
154
|
+
throwIfGlobAborted(combinedSignal);
|
|
150
155
|
effectiveExclude = [...effectiveExclude, ...gitignorePatterns];
|
|
151
156
|
}
|
|
152
157
|
|
|
@@ -158,11 +163,6 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
|
|
|
158
163
|
// file; dedupe so a path is returned at most once regardless of pattern overlap.
|
|
159
164
|
const seen = new Set<string>();
|
|
160
165
|
|
|
161
|
-
// Combine timeout and abort signals
|
|
162
|
-
const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
163
|
-
const combinedSignal =
|
|
164
|
-
signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : (signal ?? timeoutSignal);
|
|
165
|
-
|
|
166
166
|
for (const pattern of patternArray) {
|
|
167
167
|
const glob = new Glob(pattern);
|
|
168
168
|
const scanOptions = {
|
|
@@ -173,11 +173,7 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
|
|
|
173
173
|
};
|
|
174
174
|
|
|
175
175
|
for await (const entry of glob.scan(scanOptions)) {
|
|
176
|
-
|
|
177
|
-
const reason = combinedSignal.reason;
|
|
178
|
-
if (reason instanceof Error) throw reason;
|
|
179
|
-
throw new DOMException("Aborted", "AbortError");
|
|
180
|
-
}
|
|
176
|
+
throwIfGlobAborted(combinedSignal);
|
|
181
177
|
|
|
182
178
|
// Check exclusion patterns
|
|
183
179
|
const normalized = entry.replace(/\\/g, "/");
|
|
@@ -190,7 +186,14 @@ export async function globPaths(patterns: string | string[], options: GlobPathsO
|
|
|
190
186
|
seen.add(normalized);
|
|
191
187
|
allResults.push(normalized);
|
|
192
188
|
}
|
|
189
|
+
throwIfGlobAborted(combinedSignal);
|
|
193
190
|
}
|
|
194
191
|
|
|
195
192
|
return allResults;
|
|
196
193
|
}
|
|
194
|
+
|
|
195
|
+
function throwIfGlobAborted(signal: AbortSignal | undefined): void {
|
|
196
|
+
if (!signal?.aborted) return;
|
|
197
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
198
|
+
throw new DOMException("Aborted", "AbortError");
|
|
199
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize a value destined for an HTTP header.
|
|
3
|
+
*
|
|
4
|
+
* OS-derived components (kernel release, hostname, os.version) can contain
|
|
5
|
+
* non-ASCII characters — e.g. Android kernel releases such as
|
|
6
|
+
* `4.4.302-Minimal™-EAS-QTI_Haptic-R26` — which `Headers`/`fetch` reject
|
|
7
|
+
* before the request is ever sent. Strip everything outside printable
|
|
8
|
+
* ASCII so header construction can never throw on runtime-derived values.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
|
|
12
|
+
|
|
13
|
+
export function sanitizeHeaderComponent(value: string): string {
|
|
14
|
+
return value.replace(NON_PRINTABLE_ASCII, "");
|
|
15
|
+
}
|