@vladpazych/dexter 0.1.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/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log formatting for terminal and file output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { bold, dim, gray, red, reset, yellow } from "../terminal/colors.ts"
|
|
6
|
+
import type { LogLevel, PipedEntry } from "./types.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get terminal width from multiple sources.
|
|
10
|
+
* Priority: COLUMNS env > stdout.columns > 120
|
|
11
|
+
*/
|
|
12
|
+
function getTerminalWidth(): number {
|
|
13
|
+
// COLUMNS env var (set by shell or parent)
|
|
14
|
+
const envColumns = process.env.COLUMNS
|
|
15
|
+
if (envColumns) {
|
|
16
|
+
const parsed = parseInt(envColumns, 10)
|
|
17
|
+
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// stdout.columns (works when TTY)
|
|
21
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
22
|
+
return process.stdout.columns
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Default fallback
|
|
26
|
+
return 120
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LEVEL_LABELS: Record<LogLevel, string> = {
|
|
30
|
+
trace: "T",
|
|
31
|
+
debug: "D",
|
|
32
|
+
info: "I",
|
|
33
|
+
warn: "W",
|
|
34
|
+
error: "E",
|
|
35
|
+
fatal: "F",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const LEVEL_COLORS: Record<LogLevel, string> = {
|
|
39
|
+
trace: gray,
|
|
40
|
+
debug: dim,
|
|
41
|
+
info: "",
|
|
42
|
+
warn: yellow,
|
|
43
|
+
error: red,
|
|
44
|
+
fatal: bold + red,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fields to exclude from key=value output */
|
|
48
|
+
const SUPPRESSED = new Set(["level", "time", "pid", "hostname", "name", "msg", "message", "v", "timestamp"])
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format fields as key=value pairs.
|
|
52
|
+
* @param fields - Log fields
|
|
53
|
+
* @param maxValueLen - Max length per value (truncate with ...)
|
|
54
|
+
*/
|
|
55
|
+
function formatFields(fields: Record<string, unknown>, maxValueLen = 20): string {
|
|
56
|
+
const pairs: string[] = []
|
|
57
|
+
|
|
58
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
59
|
+
if (SUPPRESSED.has(key) || value === undefined) continue
|
|
60
|
+
|
|
61
|
+
let strValue = typeof value === "string" ? value : JSON.stringify(value)
|
|
62
|
+
if (strValue.length > maxValueLen) {
|
|
63
|
+
strValue = `${strValue.slice(0, maxValueLen - 1)}…`
|
|
64
|
+
}
|
|
65
|
+
pairs.push(`${key}=${strValue}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return pairs.join(" ")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format file:line prefix with fixed-width line number.
|
|
73
|
+
*/
|
|
74
|
+
function formatPrefix(logFile: string, lineNumber: number): string {
|
|
75
|
+
const line = lineNumber.toString().padEnd(5, " ")
|
|
76
|
+
return `${logFile}:${line}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type FormatTerminalOptions = {
|
|
80
|
+
/** Terminal width for truncation */
|
|
81
|
+
width?: number
|
|
82
|
+
/** Max width per field value */
|
|
83
|
+
maxValueLen?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip ANSI escape codes for length calculation.
|
|
88
|
+
*/
|
|
89
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI pattern
|
|
90
|
+
const ANSI_REGEX = /\x1b\[[0-9;]*m/g
|
|
91
|
+
function stripAnsiLocal(str: string): string {
|
|
92
|
+
return str.replace(ANSI_REGEX, "")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format log entry for terminal output.
|
|
97
|
+
*
|
|
98
|
+
* Format: "L file:line message kv..."
|
|
99
|
+
* - Level character is colored
|
|
100
|
+
* - Message preserves original ANSI codes (e.g., vite colors)
|
|
101
|
+
* - Truncated to terminal width
|
|
102
|
+
* - file:line is clickable in most terminals
|
|
103
|
+
*/
|
|
104
|
+
export function formatTerminal(entry: PipedEntry, options: FormatTerminalOptions = {}): string {
|
|
105
|
+
const { width = getTerminalWidth(), maxValueLen = 20 } = options
|
|
106
|
+
|
|
107
|
+
const label = LEVEL_LABELS[entry.level]
|
|
108
|
+
const levelColor = LEVEL_COLORS[entry.level]
|
|
109
|
+
const prefix = formatPrefix(entry.logFile, entry.lineNumber)
|
|
110
|
+
const kv = formatFields(entry.fields, maxValueLen)
|
|
111
|
+
|
|
112
|
+
let message = entry.message
|
|
113
|
+
let kvPart = kv ? ` ${kv}` : ""
|
|
114
|
+
|
|
115
|
+
// Calculate visible length (without ANSI codes)
|
|
116
|
+
const messagePlain = stripAnsiLocal(message)
|
|
117
|
+
|
|
118
|
+
// Fixed parts: "L " + prefix + " "
|
|
119
|
+
const fixedWidth = 2 + prefix.length + 1
|
|
120
|
+
const available = width - fixedWidth
|
|
121
|
+
|
|
122
|
+
if (available > 0) {
|
|
123
|
+
const total = messagePlain.length + kvPart.length
|
|
124
|
+
|
|
125
|
+
if (total > available) {
|
|
126
|
+
// Truncate kv first, then message
|
|
127
|
+
if (kvPart.length > 0 && messagePlain.length < available - 3) {
|
|
128
|
+
const kvMax = available - messagePlain.length - 4
|
|
129
|
+
kvPart = kvMax > 0 ? ` ${kv.slice(0, kvMax)}…` : ""
|
|
130
|
+
} else {
|
|
131
|
+
kvPart = ""
|
|
132
|
+
if (messagePlain.length > available - 1) {
|
|
133
|
+
// Truncate message (approximate - may cut mid-ANSI sequence)
|
|
134
|
+
const truncLen = available - 2
|
|
135
|
+
if (truncLen > 0 && truncLen < messagePlain.length) {
|
|
136
|
+
// Simple truncation for messages with ANSI - preserve some content
|
|
137
|
+
message = `${message.slice(0, truncLen + (message.length - messagePlain.length))}${reset}…`
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Color only the level character, preserve message colors
|
|
145
|
+
// Dim the file:line prefix for reduced visual noise
|
|
146
|
+
const coloredLabel = levelColor ? `${levelColor}${label}${reset}` : label
|
|
147
|
+
const dimmedPrefix = `${dim}${prefix}${reset}`
|
|
148
|
+
return `${coloredLabel} ${dimmedPrefix} ${message}${kvPart}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format log entry for file output.
|
|
153
|
+
*
|
|
154
|
+
* Format: "L HH:MM:SS message kv..."
|
|
155
|
+
* - No colors (ANSI stripped)
|
|
156
|
+
* - No truncation (full content)
|
|
157
|
+
* - No file:line (the file IS the source)
|
|
158
|
+
*/
|
|
159
|
+
export function formatFile(entry: PipedEntry): string {
|
|
160
|
+
const label = LEVEL_LABELS[entry.level]
|
|
161
|
+
const time = new Date(entry.timestamp).toISOString().slice(11, 19)
|
|
162
|
+
const kv = formatFields(entry.fields, 1000) // No truncation
|
|
163
|
+
const message = stripAnsiLocal(entry.message) // Strip ANSI for file
|
|
164
|
+
|
|
165
|
+
const kvPart = kv ? ` ${kv}` : ""
|
|
166
|
+
return `${label} ${time} ${message}${kvPart}`
|
|
167
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log line parsing.
|
|
3
|
+
*
|
|
4
|
+
* Handles both structured JSON (pino, etc.) and raw text output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { stripAnsi } from "../terminal/colors.ts"
|
|
8
|
+
import type { LogEntry, LogLevel } from "./types.ts"
|
|
9
|
+
|
|
10
|
+
/** Pino numeric level to LogLevel */
|
|
11
|
+
const PINO_LEVELS: Record<number, LogLevel> = {
|
|
12
|
+
10: "trace",
|
|
13
|
+
20: "debug",
|
|
14
|
+
30: "info",
|
|
15
|
+
40: "warn",
|
|
16
|
+
50: "error",
|
|
17
|
+
60: "fatal",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a JSON log line (pino format).
|
|
22
|
+
* Returns null if not valid JSON or missing required fields.
|
|
23
|
+
*/
|
|
24
|
+
export function parseJson(line: string): LogEntry | null {
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(line) as Record<string, unknown>
|
|
27
|
+
|
|
28
|
+
// Pino uses numeric levels
|
|
29
|
+
if (typeof data.level === "number") {
|
|
30
|
+
return {
|
|
31
|
+
level: PINO_LEVELS[data.level] ?? "info",
|
|
32
|
+
message: (data.msg as string) ?? "",
|
|
33
|
+
fields: data,
|
|
34
|
+
timestamp: (data.time as number) ?? Date.now(),
|
|
35
|
+
raw: line,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// String level (other loggers)
|
|
40
|
+
if (typeof data.level === "string") {
|
|
41
|
+
return {
|
|
42
|
+
level: data.level as LogLevel,
|
|
43
|
+
message: (data.msg as string) ?? (data.message as string) ?? "",
|
|
44
|
+
fields: data,
|
|
45
|
+
timestamp: (data.time as number) ?? (data.timestamp as number) ?? Date.now(),
|
|
46
|
+
raw: line,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Detect log level from raw text content.
|
|
58
|
+
*/
|
|
59
|
+
function detectLevel(text: string): LogLevel {
|
|
60
|
+
const lower = text.toLowerCase()
|
|
61
|
+
|
|
62
|
+
// Error patterns
|
|
63
|
+
if (
|
|
64
|
+
lower.includes("error") ||
|
|
65
|
+
lower.includes("failed") ||
|
|
66
|
+
lower.includes("exception") ||
|
|
67
|
+
lower.includes("eaddrinuse") ||
|
|
68
|
+
lower.includes("enoent") ||
|
|
69
|
+
lower.includes("eacces") ||
|
|
70
|
+
text.startsWith("throw ") ||
|
|
71
|
+
text.includes("Emitted 'error'")
|
|
72
|
+
) {
|
|
73
|
+
return "error"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Stack trace and error object patterns (continuation of error)
|
|
77
|
+
if (
|
|
78
|
+
text.trimStart().startsWith("at ") ||
|
|
79
|
+
text.includes("node:") ||
|
|
80
|
+
(text.startsWith(" ") && text.includes("(")) ||
|
|
81
|
+
/^\s*\^$/.test(text) ||
|
|
82
|
+
/code: '/.test(text) ||
|
|
83
|
+
/errno:/.test(text) ||
|
|
84
|
+
/syscall:/.test(text) ||
|
|
85
|
+
/address:/.test(text) ||
|
|
86
|
+
/^\s*port:/.test(text) ||
|
|
87
|
+
/^\s*\}$/.test(text)
|
|
88
|
+
) {
|
|
89
|
+
return "error"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Warning patterns
|
|
93
|
+
if (lower.includes("warn") || lower.includes("deprecat")) {
|
|
94
|
+
return "warn"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (lower.includes("debug")) {
|
|
98
|
+
return "debug"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return "info"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a raw text line (non-JSON output).
|
|
106
|
+
* Returns null for empty lines.
|
|
107
|
+
* Preserves original ANSI codes in message for terminal passthrough.
|
|
108
|
+
*/
|
|
109
|
+
export function parseRaw(line: string): LogEntry | null {
|
|
110
|
+
const trimmed = line.trim()
|
|
111
|
+
if (!trimmed) return null
|
|
112
|
+
|
|
113
|
+
// Strip ANSI only for level detection, preserve original in message
|
|
114
|
+
const cleaned = stripAnsi(trimmed)
|
|
115
|
+
if (!cleaned) return null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
level: detectLevel(cleaned),
|
|
119
|
+
message: trimmed, // Preserve original ANSI codes
|
|
120
|
+
fields: {},
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
raw: line,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse any log line (JSON or raw).
|
|
128
|
+
*/
|
|
129
|
+
export function parse(line: string): LogEntry | null {
|
|
130
|
+
return parseJson(line) ?? parseRaw(line)
|
|
131
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process spawning with output capture and routing.
|
|
3
|
+
*
|
|
4
|
+
* Spawns a child process, captures stdout/stderr, parses each line,
|
|
5
|
+
* and routes to file and terminal with appropriate formatting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type ChildProcess, spawn as nodeSpawn } from "node:child_process"
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { dirname, join, resolve } from "node:path"
|
|
11
|
+
import { createInterface } from "node:readline"
|
|
12
|
+
import { setColorEnabled } from "../terminal/colors.ts"
|
|
13
|
+
import { formatFile, formatTerminal } from "./format.ts"
|
|
14
|
+
import { parse } from "./parse.ts"
|
|
15
|
+
import type { PipedEntry } from "./types.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find monorepo root by looking for package.json with workspaces.
|
|
19
|
+
* Falls back to start directory.
|
|
20
|
+
*/
|
|
21
|
+
function findMonorepoRoot(startDir: string): string {
|
|
22
|
+
let dir = startDir
|
|
23
|
+
while (dir !== dirname(dir)) {
|
|
24
|
+
const pkgPath = join(dir, "package.json")
|
|
25
|
+
if (existsSync(pkgPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
|
|
28
|
+
if (pkg.workspaces) {
|
|
29
|
+
return dir
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Invalid JSON, continue searching
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
dir = dirname(dir)
|
|
36
|
+
}
|
|
37
|
+
return startDir
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PipeOptions = {
|
|
41
|
+
/** Short name for display and log file (e.g., "api", "web") */
|
|
42
|
+
name: string
|
|
43
|
+
/** Command to run */
|
|
44
|
+
cmd: string
|
|
45
|
+
/** Command arguments */
|
|
46
|
+
args: string[]
|
|
47
|
+
/** Working directory */
|
|
48
|
+
cwd?: string
|
|
49
|
+
/**
|
|
50
|
+
* Log file path. Defaults to `.logs/{name}.log` in monorepo root.
|
|
51
|
+
* If relative, resolved from cwd.
|
|
52
|
+
*/
|
|
53
|
+
logFile?: string
|
|
54
|
+
/** Terminal width for truncation (auto-detected if not specified) */
|
|
55
|
+
width?: number
|
|
56
|
+
/** Additional environment variables */
|
|
57
|
+
env?: Record<string, string>
|
|
58
|
+
/** Callback for each parsed log entry */
|
|
59
|
+
onLog?: (entry: PipedEntry) => void
|
|
60
|
+
/** Callback on process exit */
|
|
61
|
+
onExit?: (code: number | null) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PipeHandle = {
|
|
65
|
+
/** The spawned child process */
|
|
66
|
+
child: ChildProcess
|
|
67
|
+
/** Send SIGTERM to the process */
|
|
68
|
+
stop: () => void
|
|
69
|
+
/** Promise that resolves when process exits */
|
|
70
|
+
wait: () => Promise<number | null>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Spawn a process with output piping.
|
|
75
|
+
*
|
|
76
|
+
* - Captures stdout/stderr line by line
|
|
77
|
+
* - Parses each line (JSON or raw)
|
|
78
|
+
* - Writes full content to log file
|
|
79
|
+
* - Writes truncated colored output to terminal
|
|
80
|
+
*/
|
|
81
|
+
export function pipe(options: PipeOptions): PipeHandle {
|
|
82
|
+
// Enable colors for dexter output (override NO_COLOR from parent shell)
|
|
83
|
+
setColorEnabled(true)
|
|
84
|
+
|
|
85
|
+
const cwd = options.cwd ?? process.cwd()
|
|
86
|
+
const monorepoRoot = findMonorepoRoot(cwd)
|
|
87
|
+
|
|
88
|
+
// Default log file: {name}.log in monorepo root
|
|
89
|
+
const logFile = options.logFile ?? join(monorepoRoot, `${options.name}.log`)
|
|
90
|
+
const logFilePath = logFile.startsWith("/") ? logFile : resolve(cwd, logFile)
|
|
91
|
+
|
|
92
|
+
// Display name is just the short name (e.g., "api", "web")
|
|
93
|
+
const displayName = options.name
|
|
94
|
+
|
|
95
|
+
// Ensure directory exists and truncate file
|
|
96
|
+
try {
|
|
97
|
+
mkdirSync(dirname(logFilePath), { recursive: true })
|
|
98
|
+
} catch {
|
|
99
|
+
// dir exists
|
|
100
|
+
}
|
|
101
|
+
writeFileSync(logFilePath, "")
|
|
102
|
+
|
|
103
|
+
let lineNumber = 0
|
|
104
|
+
|
|
105
|
+
// Remove NO_COLOR to avoid conflict with our color handling
|
|
106
|
+
const { NO_COLOR: _, ...cleanEnv } = process.env
|
|
107
|
+
|
|
108
|
+
// Get terminal width for output truncation
|
|
109
|
+
// Priority: explicit option > stdout.columns > COLUMNS env > default
|
|
110
|
+
let terminalWidth = options.width
|
|
111
|
+
if (!terminalWidth) {
|
|
112
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
113
|
+
terminalWidth = process.stdout.columns
|
|
114
|
+
} else if (process.env.COLUMNS) {
|
|
115
|
+
terminalWidth = parseInt(process.env.COLUMNS, 10) || 120
|
|
116
|
+
} else {
|
|
117
|
+
terminalWidth = 120
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const child = nodeSpawn(options.cmd, options.args, {
|
|
122
|
+
cwd,
|
|
123
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
124
|
+
env: {
|
|
125
|
+
...cleanEnv,
|
|
126
|
+
// Pass terminal width to child for proper truncation
|
|
127
|
+
COLUMNS: String(terminalWidth),
|
|
128
|
+
// Enable colors in child processes (vite, etc.)
|
|
129
|
+
FORCE_COLOR: "1",
|
|
130
|
+
// Enable colors in dexter's output
|
|
131
|
+
DEXTER_COLOR: "1",
|
|
132
|
+
...options.env,
|
|
133
|
+
// Signal to apps: output JSON for parsing, don't write own files
|
|
134
|
+
LOG_FORMAT: "json",
|
|
135
|
+
DEV_RUNNER: "1",
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const handleLine = (line: string) => {
|
|
140
|
+
const parsed = parse(line)
|
|
141
|
+
if (!parsed) return
|
|
142
|
+
|
|
143
|
+
lineNumber++
|
|
144
|
+
|
|
145
|
+
const entry: PipedEntry = {
|
|
146
|
+
...parsed,
|
|
147
|
+
source: options.name,
|
|
148
|
+
logFile: displayName,
|
|
149
|
+
lineNumber,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Write to file (full content, source of truth)
|
|
153
|
+
const fileLine = formatFile(entry)
|
|
154
|
+
appendFileSync(logFilePath, `${fileLine}\n`)
|
|
155
|
+
|
|
156
|
+
// Write to terminal (truncated, colored)
|
|
157
|
+
const terminalLine = formatTerminal(entry, { width: terminalWidth })
|
|
158
|
+
process.stdout.write(`${terminalLine}\n`)
|
|
159
|
+
|
|
160
|
+
// Callback
|
|
161
|
+
options.onLog?.(entry)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Capture stdout
|
|
165
|
+
if (child.stdout) {
|
|
166
|
+
const rl = createInterface({ input: child.stdout })
|
|
167
|
+
rl.on("line", handleLine)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Capture stderr
|
|
171
|
+
if (child.stderr) {
|
|
172
|
+
const rl = createInterface({ input: child.stderr })
|
|
173
|
+
rl.on("line", handleLine)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Exit handling
|
|
177
|
+
const exitPromise = new Promise<number | null>((resolve) => {
|
|
178
|
+
child.on("exit", (code) => {
|
|
179
|
+
options.onExit?.(code)
|
|
180
|
+
resolve(code)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Forward signals
|
|
185
|
+
const forwardSignal = (signal: NodeJS.Signals) => {
|
|
186
|
+
process.on(signal, () => child.kill(signal))
|
|
187
|
+
}
|
|
188
|
+
forwardSignal("SIGINT")
|
|
189
|
+
forwardSignal("SIGTERM")
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
child,
|
|
193
|
+
stop: () => child.kill("SIGTERM"),
|
|
194
|
+
wait: () => exitPromise,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Spawn and wait for exit (convenience wrapper).
|
|
200
|
+
*/
|
|
201
|
+
export async function pipeAndWait(options: PipeOptions): Promise<number> {
|
|
202
|
+
const handle = pipe(options)
|
|
203
|
+
const code = await handle.wait()
|
|
204
|
+
return code ?? 0
|
|
205
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe module types.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
|
|
6
|
+
|
|
7
|
+
export type LogEntry = {
|
|
8
|
+
/** Log level */
|
|
9
|
+
level: LogLevel
|
|
10
|
+
/** Main message */
|
|
11
|
+
message: string
|
|
12
|
+
/** Structured fields (from JSON logs) */
|
|
13
|
+
fields: Record<string, unknown>
|
|
14
|
+
/** Unix timestamp ms */
|
|
15
|
+
timestamp: number
|
|
16
|
+
/** Original raw line */
|
|
17
|
+
raw: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PipedEntry = LogEntry & {
|
|
21
|
+
/** Source identifier (e.g., "dimas-server") */
|
|
22
|
+
source: string
|
|
23
|
+
/** Log file path */
|
|
24
|
+
logFile: string
|
|
25
|
+
/** Line number in log file */
|
|
26
|
+
lineNumber: number
|
|
27
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI terminal colors with NO_COLOR/FORCE_COLOR support.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1. DEXTER_COLOR=1 → enabled (dexter override)
|
|
6
|
+
* 2. NO_COLOR set → disabled (accessibility)
|
|
7
|
+
* 3. FORCE_COLOR=1 → enabled
|
|
8
|
+
* 4. TTY detection → enabled if TTY
|
|
9
|
+
* 5. Default → enabled (dev-friendly default)
|
|
10
|
+
*
|
|
11
|
+
* https://no-color.org/
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let _colorEnabled: boolean | null = null
|
|
15
|
+
|
|
16
|
+
function isColorEnabled(): boolean {
|
|
17
|
+
if (_colorEnabled !== null) return _colorEnabled
|
|
18
|
+
|
|
19
|
+
// Dexter-specific override (highest priority)
|
|
20
|
+
if (process.env.DEXTER_COLOR === "1") {
|
|
21
|
+
_colorEnabled = true
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Explicit disable
|
|
26
|
+
if (process.env.NO_COLOR && process.env.NO_COLOR !== "0") {
|
|
27
|
+
_colorEnabled = false
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Explicit enable
|
|
32
|
+
if (process.env.FORCE_COLOR === "1") {
|
|
33
|
+
_colorEnabled = true
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// TTY detection
|
|
38
|
+
if (process.stdout.isTTY === true) {
|
|
39
|
+
_colorEnabled = true
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
if (process.stdout.isTTY === false) {
|
|
43
|
+
_colorEnabled = false
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Default: enable for better dev experience
|
|
48
|
+
_colorEnabled = true
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Enable or disable colors programmatically */
|
|
53
|
+
export function setColorEnabled(enabled: boolean): void {
|
|
54
|
+
_colorEnabled = enabled
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Raw ANSI codes */
|
|
58
|
+
export const reset = "\x1b[0m"
|
|
59
|
+
export const bold = "\x1b[1m"
|
|
60
|
+
export const dim = "\x1b[2m"
|
|
61
|
+
export const red = "\x1b[31m"
|
|
62
|
+
export const green = "\x1b[32m"
|
|
63
|
+
export const yellow = "\x1b[33m"
|
|
64
|
+
export const blue = "\x1b[34m"
|
|
65
|
+
export const magenta = "\x1b[35m"
|
|
66
|
+
export const cyan = "\x1b[36m"
|
|
67
|
+
export const gray = "\x1b[90m"
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wrap text with color code and reset (respects color enabled).
|
|
71
|
+
*/
|
|
72
|
+
export const wrap = (colorCode: string) => (text: string) => (isColorEnabled() ? `${colorCode}${text}${reset}` : text)
|
|
73
|
+
|
|
74
|
+
export const c = {
|
|
75
|
+
reset,
|
|
76
|
+
bold,
|
|
77
|
+
dim,
|
|
78
|
+
red: wrap(red),
|
|
79
|
+
green: wrap(green),
|
|
80
|
+
yellow: wrap(yellow),
|
|
81
|
+
blue: wrap(blue),
|
|
82
|
+
magenta: wrap(magenta),
|
|
83
|
+
cyan: wrap(cyan),
|
|
84
|
+
gray: wrap(gray),
|
|
85
|
+
dimmed: wrap(dim),
|
|
86
|
+
bolded: wrap(bold),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Strip ANSI escape codes from string.
|
|
91
|
+
* Uses Bun's native Zig implementation (6-57x faster than regex).
|
|
92
|
+
*/
|
|
93
|
+
export function stripAnsi(str: string): string {
|
|
94
|
+
return Bun.stripANSI(str)
|
|
95
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const version = "0.1.0"
|