@huaqiu/dsh-plugin-log 0.3.11
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/LICENSE +21 -0
- package/lib/client.d.ts +26 -0
- package/lib/client.js +186 -0
- package/lib/index.d.mts +107 -0
- package/lib/index.mjs +456 -0
- package/package.json +36 -0
- package/src/client.ts +94 -0
- package/src/index.ts +445 -0
- package/src/levels.ts +39 -0
- package/src/redact.ts +95 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
6
|
+
//#region src/levels.ts
|
|
7
|
+
/** Levels ordered from most to least verbose. */
|
|
8
|
+
const LOG_LEVELS = [
|
|
9
|
+
"debug",
|
|
10
|
+
"info",
|
|
11
|
+
"warn",
|
|
12
|
+
"error"
|
|
13
|
+
];
|
|
14
|
+
const RANK = {
|
|
15
|
+
debug: 10,
|
|
16
|
+
info: 20,
|
|
17
|
+
warn: 30,
|
|
18
|
+
error: 40
|
|
19
|
+
};
|
|
20
|
+
/** Numeric rank of a level — higher means more severe. */
|
|
21
|
+
function levelRank(level) {
|
|
22
|
+
return RANK[level];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parse a level from an arbitrary (env-supplied) value.
|
|
26
|
+
*
|
|
27
|
+
* Unparseable or empty input falls back instead of throwing: a bad
|
|
28
|
+
* `DSH_PLUGIN_LOG_LEVEL` in a customer environment must degrade to "log
|
|
29
|
+
* normally", never to "crash the plugin host".
|
|
30
|
+
*/
|
|
31
|
+
function parseLevel(value, fallback) {
|
|
32
|
+
if (typeof value !== "string") return fallback;
|
|
33
|
+
const normalized = value.trim().toLowerCase();
|
|
34
|
+
return LOG_LEVELS.includes(normalized) ? normalized : fallback;
|
|
35
|
+
}
|
|
36
|
+
/** True when `level` is at least as severe as `threshold`. */
|
|
37
|
+
function isEnabled(level, threshold) {
|
|
38
|
+
return levelRank(level) >= levelRank(threshold);
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/redact.ts
|
|
42
|
+
/**
|
|
43
|
+
* Credential redaction for `@huaqiu/dsh-plugin-log`.
|
|
44
|
+
*
|
|
45
|
+
* The whole point of a shared plugin log is that it is safe to hand to someone
|
|
46
|
+
* else when debugging — which means it must never become a second, unmanaged
|
|
47
|
+
* copy of the user's credential. Everything written through this logger goes
|
|
48
|
+
* through `redact()` first, so a caller cannot leak a token by accident.
|
|
49
|
+
*
|
|
50
|
+
* Two rules:
|
|
51
|
+
*
|
|
52
|
+
* 1. **Key-based** — any field whose name looks credential-ish
|
|
53
|
+
* (`token`, `authorization`, `password`, `cookie`, `apiKey`, …) has its
|
|
54
|
+
* value replaced, at any nesting depth.
|
|
55
|
+
* 2. **Shape-based** — string values that look like a bearer header or a
|
|
56
|
+
* long opaque secret are replaced even when the key is innocent
|
|
57
|
+
* (`headers: ['Authorization: Bearer ey…']`).
|
|
58
|
+
*
|
|
59
|
+
* Redaction is deliberately key-name based rather than "redact every long
|
|
60
|
+
* string": log readability matters, and most long strings (project paths,
|
|
61
|
+
* URLs, artifact ids) carry no secret.
|
|
62
|
+
*/
|
|
63
|
+
/** Field names whose values are always replaced. Matches at any depth. */
|
|
64
|
+
const SENSITIVE_KEY = /(token|secret|password|passwd|pwd|authorization|cookie|api[-_]?key|access[-_]?key|credential)/i;
|
|
65
|
+
/** `Bearer <opaque>` / `Basic <opaque>` inside a free-form string. */
|
|
66
|
+
const BEARER_IN_STRING = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/i;
|
|
67
|
+
/** Strings at least this long that look like a single opaque credential blob. */
|
|
68
|
+
const OPAQUE_SECRET = /^[A-Za-z0-9_-]{32,}$/;
|
|
69
|
+
const REDACTED = "[redacted]";
|
|
70
|
+
/** Maximum object depth walked before the value is collapsed. Cycle-safe. */
|
|
71
|
+
const MAX_DEPTH = 6;
|
|
72
|
+
/**
|
|
73
|
+
* Return a copy of `value` with credential-ish fields replaced.
|
|
74
|
+
*
|
|
75
|
+
* Never throws and never mutates the caller's object — a logging call must not
|
|
76
|
+
* be able to change plugin state or crash the host.
|
|
77
|
+
*/
|
|
78
|
+
function redact(value, depth = 0) {
|
|
79
|
+
try {
|
|
80
|
+
return redactInner(value, depth, /* @__PURE__ */ new WeakSet());
|
|
81
|
+
} catch {
|
|
82
|
+
return REDACTED;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function redactInner(value, depth, seen) {
|
|
86
|
+
if (value === null || value === void 0) return value;
|
|
87
|
+
if (typeof value === "string") return redactString(value);
|
|
88
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return value;
|
|
89
|
+
if (typeof value === "function") return "[function]";
|
|
90
|
+
if (typeof value === "symbol") return value.toString();
|
|
91
|
+
if (value instanceof Error) return {
|
|
92
|
+
name: value.name,
|
|
93
|
+
message: redactString(value.message),
|
|
94
|
+
...value.stack ? { stack: value.stack } : {}
|
|
95
|
+
};
|
|
96
|
+
if (value instanceof Date) return value.toISOString();
|
|
97
|
+
if (depth >= MAX_DEPTH) return "[deep]";
|
|
98
|
+
if (seen.has(value)) return "[circular]";
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
seen.add(value);
|
|
101
|
+
const out = value.map((item) => redactInner(item, depth + 1, seen));
|
|
102
|
+
seen.delete(value);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === "object") {
|
|
106
|
+
seen.add(value);
|
|
107
|
+
const out = {};
|
|
108
|
+
for (const [key, raw] of Object.entries(value)) out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactInner(raw, depth + 1, seen);
|
|
109
|
+
seen.delete(value);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
/** Redact credentials embedded in an otherwise ordinary string. */
|
|
115
|
+
function redactString(value) {
|
|
116
|
+
if (BEARER_IN_STRING.test(value)) return value.replace(BEARER_IN_STRING, "$1 " + REDACTED);
|
|
117
|
+
if (OPAQUE_SECRET.test(value)) return REDACTED;
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/index.ts
|
|
122
|
+
/**
|
|
123
|
+
* `@huaqiu/dsh-plugin-log` — one server-side log for every Huaqiu DSH plugin.
|
|
124
|
+
*
|
|
125
|
+
* ## Why this exists
|
|
126
|
+
*
|
|
127
|
+
* Plugin diagnostics used to live only in the browser console: each plugin's
|
|
128
|
+
* browser half logged to `console.*`, and the node half — the half that
|
|
129
|
+
* actually owns credentials, HTTP routes and agent tools — logged nothing at
|
|
130
|
+
* all outside the DSH process's stdout, which HQ Edge only captures as an
|
|
131
|
+
* opaque text blob. When a `deepseek-harness` upgrade broke credential
|
|
132
|
+
* propagation, the only way to see what the node half resolved was to add
|
|
133
|
+
* temporary prints and re-run.
|
|
134
|
+
*
|
|
135
|
+
* This package gives every plugin one shared, file-backed, cross-platform log
|
|
136
|
+
* under the DSH home:
|
|
137
|
+
*
|
|
138
|
+
* <DSH_HOME>/logs/dsh-plugins.log (current)
|
|
139
|
+
* <DSH_HOME>/logs/dsh-plugins.1.log (previous, after rotation)
|
|
140
|
+
*
|
|
141
|
+
* ## DSH home resolution
|
|
142
|
+
*
|
|
143
|
+
* The directory comes from `@deepseek-ai/dsh-home-paths`, the same single-root
|
|
144
|
+
* helper the rest of DSH uses, so every override HQ Edge (or any other host)
|
|
145
|
+
* performs is honoured for free:
|
|
146
|
+
*
|
|
147
|
+
* explicit `configure({ dir })` > $DSH_PLUGIN_LOG_DIR > $DSH_HOME > ~/.dsh
|
|
148
|
+
*
|
|
149
|
+
* HQ Edge spawns DSH with `DSH_HOME` pointing at its own versioned, per-user
|
|
150
|
+
* data directory (`…/HQ/hq-edge/<ver>/dsh-home`), so plugin logs land next to
|
|
151
|
+
* the rest of that installation's state and never in the user's `~/.dsh`.
|
|
152
|
+
*
|
|
153
|
+
* ## Cross-platform notes
|
|
154
|
+
*
|
|
155
|
+
* - Pure `node:fs` / `node:os` / `node:path` — no native modules, no shelling
|
|
156
|
+
* out, nothing that differs between macOS, Linux and Windows but the path
|
|
157
|
+
* separators (handled by `node:path`).
|
|
158
|
+
* - Rotation uses unlink-then-rename, because Windows cannot rename over an
|
|
159
|
+
* existing file.
|
|
160
|
+
* - File names never embed `:` or other Windows-illegal characters.
|
|
161
|
+
* - If the log directory cannot be created (read-only install, locked-down
|
|
162
|
+
* profile) we fall back to the OS temp dir, and if that also fails we keep
|
|
163
|
+
* logging to the console. A logging failure must never take a plugin down.
|
|
164
|
+
*
|
|
165
|
+
* ## Safety
|
|
166
|
+
*
|
|
167
|
+
* - Every field passes through `redact()`: credential-shaped keys and values
|
|
168
|
+
* are replaced with `[redacted]`, so the log is safe to share.
|
|
169
|
+
* - `getLogger()` never throws. Neither does any log call.
|
|
170
|
+
*/
|
|
171
|
+
const DEFAULT_FILE_NAME = "dsh-plugins.log";
|
|
172
|
+
const DEFAULT_MAX_BYTES = 5242880;
|
|
173
|
+
const DEFAULT_MAX_FILES = 4;
|
|
174
|
+
const DEFAULT_LEVEL = "info";
|
|
175
|
+
const DEFAULT_CONSOLE_LEVEL = "warn";
|
|
176
|
+
/**
|
|
177
|
+
* Process-wide logger state, held on `globalThis`.
|
|
178
|
+
*
|
|
179
|
+
* Plugins are built independently, so each one gets its OWN bundled copy of
|
|
180
|
+
* this module. Without a shared home, "one unified log" would degrade into one
|
|
181
|
+
* sink (and one rotation counter) per plugin. Keying the state off a
|
|
182
|
+
* `Symbol.for` on `globalThis` makes every copy — bundled, external, or
|
|
183
|
+
* duplicated across installs — cooperate inside the same DSH process.
|
|
184
|
+
*/
|
|
185
|
+
const STATE_KEY = Symbol.for("@huaqiu/dsh-plugin-log/state");
|
|
186
|
+
function state() {
|
|
187
|
+
const g = globalThis;
|
|
188
|
+
const existing = g[STATE_KEY];
|
|
189
|
+
if (existing) return existing;
|
|
190
|
+
const fresh = {
|
|
191
|
+
override: null,
|
|
192
|
+
resolved: null,
|
|
193
|
+
sink: null,
|
|
194
|
+
bootstrapped: false
|
|
195
|
+
};
|
|
196
|
+
g[STATE_KEY] = fresh;
|
|
197
|
+
return fresh;
|
|
198
|
+
}
|
|
199
|
+
const getOverride = () => state().override;
|
|
200
|
+
const setOverride = (value) => {
|
|
201
|
+
state().override = value;
|
|
202
|
+
};
|
|
203
|
+
const getSink = () => state().sink;
|
|
204
|
+
const setSink = (value) => {
|
|
205
|
+
state().sink = value;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Programmatically configure logging. Call before the first `getLogger()`;
|
|
209
|
+
* later calls take effect on the next `resetLogging()` (tests, host re-init).
|
|
210
|
+
*/
|
|
211
|
+
function configureLogging(options) {
|
|
212
|
+
setOverride({
|
|
213
|
+
...getOverride() ?? {},
|
|
214
|
+
...options
|
|
215
|
+
});
|
|
216
|
+
state().resolved = null;
|
|
217
|
+
}
|
|
218
|
+
/** Forget all configuration and cached loggers. Test/teardown helper. */
|
|
219
|
+
function resetLogging() {
|
|
220
|
+
const s = state();
|
|
221
|
+
s.override = null;
|
|
222
|
+
s.resolved = null;
|
|
223
|
+
s.sink = null;
|
|
224
|
+
s.bootstrapped = false;
|
|
225
|
+
}
|
|
226
|
+
function env(name) {
|
|
227
|
+
const value = process.env[name];
|
|
228
|
+
return value !== void 0 && value.trim().length > 0 ? value.trim() : void 0;
|
|
229
|
+
}
|
|
230
|
+
function readInt(value, fallback) {
|
|
231
|
+
if (value === void 0) return fallback;
|
|
232
|
+
const parsed = Number.parseInt(value, 10);
|
|
233
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Pick the log directory, honouring every override in precedence order.
|
|
237
|
+
* Falls back to the OS temp directory when the DSH home is not writable — a
|
|
238
|
+
* plugin that cannot log to its preferred location still logs somewhere.
|
|
239
|
+
*/
|
|
240
|
+
function resolveLogDir(explicit) {
|
|
241
|
+
const candidates = [];
|
|
242
|
+
if (explicit !== void 0 && explicit.length > 0) candidates.push(explicit);
|
|
243
|
+
const fromEnv = env("DSH_PLUGIN_LOG_DIR");
|
|
244
|
+
if (fromEnv) candidates.push(fromEnv);
|
|
245
|
+
candidates.push(dshHomePath("logs"));
|
|
246
|
+
candidates.push(join(tmpdir(), "hq-dsh-plugins", "logs"));
|
|
247
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
248
|
+
const dir = candidates[i];
|
|
249
|
+
if (!dir) continue;
|
|
250
|
+
try {
|
|
251
|
+
mkdirSync(dir, { recursive: true });
|
|
252
|
+
return {
|
|
253
|
+
dir,
|
|
254
|
+
fallback: i > 0
|
|
255
|
+
};
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
dir: candidates[candidates.length - 1] ?? ".",
|
|
260
|
+
fallback: true
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function currentConfig() {
|
|
264
|
+
const s = state();
|
|
265
|
+
if (s.resolved) return s.resolved;
|
|
266
|
+
const o = s.override;
|
|
267
|
+
const { dir } = resolveLogDir(o?.dir);
|
|
268
|
+
const consoleRaw = o?.consoleLevel ?? env("DSH_PLUGIN_LOG_CONSOLE");
|
|
269
|
+
s.resolved = {
|
|
270
|
+
dir,
|
|
271
|
+
fileName: o?.fileName ?? env("DSH_PLUGIN_LOG_FILE") ?? DEFAULT_FILE_NAME,
|
|
272
|
+
level: o?.level ?? parseLevel(env("DSH_PLUGIN_LOG_LEVEL"), DEFAULT_LEVEL),
|
|
273
|
+
consoleLevel: o?.consoleLevel === "off" ? "off" : consoleRaw === "off" || consoleRaw === "none" || consoleRaw === "0" ? "off" : consoleRaw === "all" || consoleRaw === "1" || consoleRaw === "true" ? "debug" : parseLevel(consoleRaw, DEFAULT_CONSOLE_LEVEL),
|
|
274
|
+
maxBytes: o?.maxBytes ?? readInt(env("DSH_PLUGIN_LOG_MAX_BYTES"), DEFAULT_MAX_BYTES),
|
|
275
|
+
maxFiles: o?.maxFiles ?? readInt(env("DSH_PLUGIN_LOG_MAX_FILES"), DEFAULT_MAX_FILES)
|
|
276
|
+
};
|
|
277
|
+
return s.resolved;
|
|
278
|
+
}
|
|
279
|
+
/** Absolute path of the current log file, or `null` before first use. */
|
|
280
|
+
function logFilePath() {
|
|
281
|
+
const s = getSink();
|
|
282
|
+
return s ? s.path : null;
|
|
283
|
+
}
|
|
284
|
+
/** Absolute directory plugin logs are written to. */
|
|
285
|
+
function logDir() {
|
|
286
|
+
return currentConfig().dir;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Serialized appender with size-based rotation.
|
|
290
|
+
*
|
|
291
|
+
* Writes are chained on a single promise so concurrent log calls from different
|
|
292
|
+
* plugins in the same process can never interleave or race the rotation.
|
|
293
|
+
*/
|
|
294
|
+
var FileSink = class {
|
|
295
|
+
dir;
|
|
296
|
+
fileName;
|
|
297
|
+
maxBytes;
|
|
298
|
+
maxFiles;
|
|
299
|
+
path;
|
|
300
|
+
queue = Promise.resolve();
|
|
301
|
+
bytes;
|
|
302
|
+
constructor(dir, fileName, maxBytes, maxFiles) {
|
|
303
|
+
this.dir = dir;
|
|
304
|
+
this.fileName = fileName;
|
|
305
|
+
this.maxBytes = maxBytes;
|
|
306
|
+
this.maxFiles = maxFiles;
|
|
307
|
+
this.path = join(dir, fileName);
|
|
308
|
+
this.bytes = this.currentSize();
|
|
309
|
+
}
|
|
310
|
+
currentSize() {
|
|
311
|
+
try {
|
|
312
|
+
return statSync(this.path).size;
|
|
313
|
+
} catch {
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Rotate when appending `next` bytes would exceed the cap. */
|
|
318
|
+
rotateIfNeeded(next) {
|
|
319
|
+
if (this.maxBytes <= 0) return;
|
|
320
|
+
if (this.bytes + next <= this.maxBytes) return;
|
|
321
|
+
try {
|
|
322
|
+
for (let i = this.maxFiles - 1; i >= 1; i -= 1) {
|
|
323
|
+
const from = i === 1 ? join(this.dir, this.fileName) : this.rotated(i - 1);
|
|
324
|
+
const to = this.rotated(i);
|
|
325
|
+
if (!exists(from)) continue;
|
|
326
|
+
if (exists(to)) rmSync(to, { force: true });
|
|
327
|
+
renameSync(from, to);
|
|
328
|
+
}
|
|
329
|
+
this.bytes = this.currentSize();
|
|
330
|
+
} catch {}
|
|
331
|
+
}
|
|
332
|
+
rotated(index) {
|
|
333
|
+
const dot = this.fileName.lastIndexOf(".");
|
|
334
|
+
const stem = dot > 0 ? this.fileName.slice(0, dot) : this.fileName;
|
|
335
|
+
const ext = dot > 0 ? this.fileName.slice(dot) : "";
|
|
336
|
+
return join(this.dir, `${stem}.${index}${ext}`);
|
|
337
|
+
}
|
|
338
|
+
/** Enqueue one already-serialized line. Never rejects. */
|
|
339
|
+
write(line) {
|
|
340
|
+
const size = Buffer.byteLength(line, "utf8");
|
|
341
|
+
this.bytes += size;
|
|
342
|
+
this.queue = this.queue.then(async () => {
|
|
343
|
+
this.rotateIfNeeded(size);
|
|
344
|
+
await appendFile(this.path, line, "utf8");
|
|
345
|
+
}).catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
/** Wait for everything enqueued so far to reach the file. */
|
|
348
|
+
flush() {
|
|
349
|
+
return this.queue;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
function exists(path) {
|
|
353
|
+
try {
|
|
354
|
+
statSync(path);
|
|
355
|
+
return true;
|
|
356
|
+
} catch {
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function currentSink() {
|
|
361
|
+
const existing = getSink();
|
|
362
|
+
if (existing) return existing;
|
|
363
|
+
const cfg = currentConfig();
|
|
364
|
+
const created = new FileSink(cfg.dir, cfg.fileName, cfg.maxBytes, cfg.maxFiles);
|
|
365
|
+
setSink(created);
|
|
366
|
+
return created;
|
|
367
|
+
}
|
|
368
|
+
/** Wait for all pending writes to land (shutdown hooks, tests). */
|
|
369
|
+
async function flushLogs() {
|
|
370
|
+
await getSink()?.flush();
|
|
371
|
+
}
|
|
372
|
+
function consoleMethod(level) {
|
|
373
|
+
if (level === "error") return "error";
|
|
374
|
+
if (level === "warn") return "warn";
|
|
375
|
+
if (level === "info") return "info";
|
|
376
|
+
return "log";
|
|
377
|
+
}
|
|
378
|
+
function createLogger(component, defaults) {
|
|
379
|
+
const emit = (level, message, fields) => {
|
|
380
|
+
try {
|
|
381
|
+
const cfg = currentConfig();
|
|
382
|
+
if (!isEnabled(level, cfg.level) && !(cfg.consoleLevel !== "off" && isEnabled(level, cfg.consoleLevel))) return;
|
|
383
|
+
const record = {
|
|
384
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
385
|
+
level,
|
|
386
|
+
component,
|
|
387
|
+
pid: process.pid,
|
|
388
|
+
msg: message
|
|
389
|
+
};
|
|
390
|
+
if (Object.keys(defaults).length > 0) Object.assign(record, defaults);
|
|
391
|
+
if (fields && Object.keys(fields).length > 0) Object.assign(record, fields);
|
|
392
|
+
const safe = redact(record);
|
|
393
|
+
if (isEnabled(level, cfg.level)) currentSink().write(`${JSON.stringify(safe)}\n`);
|
|
394
|
+
if (cfg.consoleLevel !== "off" && isEnabled(level, cfg.consoleLevel)) {
|
|
395
|
+
const { msg, ...rest } = safe;
|
|
396
|
+
console[consoleMethod(level)](`[${component}] ${String(msg)}`, rest);
|
|
397
|
+
}
|
|
398
|
+
} catch {}
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
component,
|
|
402
|
+
debug: (message, fields) => emit("debug", message, fields),
|
|
403
|
+
info: (message, fields) => emit("info", message, fields),
|
|
404
|
+
warn: (message, fields) => emit("warn", message, fields),
|
|
405
|
+
error: (message, fields) => emit("error", message, fields),
|
|
406
|
+
child: (fields) => createLogger(component, {
|
|
407
|
+
...defaults,
|
|
408
|
+
...fields
|
|
409
|
+
})
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Get (or create) the logger for `component`.
|
|
414
|
+
*
|
|
415
|
+
* `component` should be the short plugin name used everywhere else in its
|
|
416
|
+
* output — `dsh-auth`, `dsh-artifacts`, `dsh-schematic-gen` — so the unified
|
|
417
|
+
* file can be filtered with a single grep.
|
|
418
|
+
*/
|
|
419
|
+
function getLogger(component, defaults = {}) {
|
|
420
|
+
bootstrap();
|
|
421
|
+
return createLogger(component, defaults);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Write one record describing where this process is logging.
|
|
425
|
+
*
|
|
426
|
+
* This is the line that makes the next "we upgraded deepseek-harness and
|
|
427
|
+
* something stopped syncing" investigation cheap: it pins the DSH home, the
|
|
428
|
+
* resolved log file, the platform and the DSH/plugin versions in use at the
|
|
429
|
+
* moment the first plugin touched the log.
|
|
430
|
+
*/
|
|
431
|
+
function bootstrap() {
|
|
432
|
+
const s = state();
|
|
433
|
+
if (s.bootstrapped) return;
|
|
434
|
+
s.bootstrapped = true;
|
|
435
|
+
const cfg = currentConfig();
|
|
436
|
+
const logger = createLogger("dsh-plugin-log", {});
|
|
437
|
+
let dshHome = null;
|
|
438
|
+
try {
|
|
439
|
+
dshHome = resolveDshHome();
|
|
440
|
+
} catch {
|
|
441
|
+
dshHome = null;
|
|
442
|
+
}
|
|
443
|
+
logger.info("plugin log ready", {
|
|
444
|
+
logFile: join(cfg.dir, cfg.fileName),
|
|
445
|
+
logDir: cfg.dir,
|
|
446
|
+
dshHome,
|
|
447
|
+
level: cfg.level,
|
|
448
|
+
consoleLevel: cfg.consoleLevel,
|
|
449
|
+
pid: process.pid,
|
|
450
|
+
node: process.version,
|
|
451
|
+
platform: process.platform,
|
|
452
|
+
arch: process.arch
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
export { LOG_LEVELS, REDACTED, configureLogging, flushLogs, getLogger, levelRank, logDir, logFilePath, parseLevel, redact, resetLogging };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huaqiu/dsh-plugin-log",
|
|
3
|
+
"version": "0.3.11",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.mjs",
|
|
6
|
+
"types": "./lib/index.d.mts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.mts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"types": "./lib/client.d.ts",
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^24.0.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"build": "tsdown",
|
|
34
|
+
"test": "vitest run"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-plugin-log/client` — browser half of the shared plugin log.
|
|
3
|
+
*
|
|
4
|
+
* The browser has no filesystem, so there is nothing to unify: this module
|
|
5
|
+
* keeps the *same* `PluginLogger` surface as the node half (so a plugin can log
|
|
6
|
+
* identically from either half) but writes to the console with a stable
|
|
7
|
+
* `[component] message` prefix and a structured payload as the second
|
|
8
|
+
* argument — which is what the existing client-side debugging already relies on.
|
|
9
|
+
*
|
|
10
|
+
* It also keeps a small in-memory ring of the most recent records. That costs
|
|
11
|
+
* nothing and gives a user something to copy out of the devtools console
|
|
12
|
+
* (`dumpPluginLogs()`) when a file is not available.
|
|
13
|
+
*
|
|
14
|
+
* Redaction is shared with the node half, so a credential can never reach the
|
|
15
|
+
* browser console through this logger either.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isEnabled, parseLevel, type LogLevel } from './levels.js'
|
|
19
|
+
import { redact } from './redact.js'
|
|
20
|
+
|
|
21
|
+
export type { LogLevel } from './levels.js'
|
|
22
|
+
export type LogFields = Record<string, unknown>
|
|
23
|
+
|
|
24
|
+
export interface PluginLogger {
|
|
25
|
+
readonly component: string
|
|
26
|
+
debug(message: string, fields?: LogFields): void
|
|
27
|
+
info(message: string, fields?: LogFields): void
|
|
28
|
+
warn(message: string, fields?: LogFields): void
|
|
29
|
+
error(message: string, fields?: LogFields): void
|
|
30
|
+
child(fields: LogFields): PluginLogger
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Most recent records kept in memory for `dumpPluginLogs()`. */
|
|
34
|
+
const RING_SIZE = 200
|
|
35
|
+
const ring: Array<Record<string, unknown>> = []
|
|
36
|
+
|
|
37
|
+
const DEFAULT_LEVEL: LogLevel = 'debug'
|
|
38
|
+
|
|
39
|
+
function currentLevel(): LogLevel {
|
|
40
|
+
const global = globalThis as { DSH_PLUGIN_LOG_LEVEL?: string }
|
|
41
|
+
return parseLevel(global.DSH_PLUGIN_LOG_LEVEL, DEFAULT_LEVEL)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function consoleMethod(level: LogLevel): 'log' | 'info' | 'warn' | 'error' {
|
|
45
|
+
if (level === 'error') return 'error'
|
|
46
|
+
if (level === 'warn') return 'warn'
|
|
47
|
+
if (level === 'info') return 'info'
|
|
48
|
+
return 'log'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createLogger(component: string, defaults: LogFields): PluginLogger {
|
|
52
|
+
const emit = (level: LogLevel, message: string, fields?: LogFields): void => {
|
|
53
|
+
try {
|
|
54
|
+
if (!isEnabled(level, currentLevel())) return
|
|
55
|
+
const record: Record<string, unknown> = {
|
|
56
|
+
ts: new Date().toISOString(),
|
|
57
|
+
level,
|
|
58
|
+
component,
|
|
59
|
+
msg: message,
|
|
60
|
+
}
|
|
61
|
+
if (Object.keys(defaults).length > 0) Object.assign(record, defaults)
|
|
62
|
+
if (fields && Object.keys(fields).length > 0) Object.assign(record, fields)
|
|
63
|
+
|
|
64
|
+
const safe = redact(record) as Record<string, unknown>
|
|
65
|
+
ring.push(safe)
|
|
66
|
+
if (ring.length > RING_SIZE) ring.shift()
|
|
67
|
+
|
|
68
|
+
const { msg, ...rest } = safe
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console[consoleMethod(level)](`[${component}] ${String(msg)}`, rest)
|
|
71
|
+
} catch {
|
|
72
|
+
/* logging must never throw */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
component,
|
|
78
|
+
debug: (message, fields) => emit('debug', message, fields),
|
|
79
|
+
info: (message, fields) => emit('info', message, fields),
|
|
80
|
+
warn: (message, fields) => emit('warn', message, fields),
|
|
81
|
+
error: (message, fields) => emit('error', message, fields),
|
|
82
|
+
child: (fields) => createLogger(component, { ...defaults, ...fields }),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get (or create) the browser logger for `component`. */
|
|
87
|
+
export function getLogger(component: string, defaults: LogFields = {}): PluginLogger {
|
|
88
|
+
return createLogger(component, defaults)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Snapshot of the in-memory ring — handy from the devtools console. */
|
|
92
|
+
export function dumpPluginLogs(): Array<Record<string, unknown>> {
|
|
93
|
+
return ring.slice()
|
|
94
|
+
}
|