@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
package/bin/dexter
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vladpazych/dexter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agentic development toolkit — output rendering, env config, CLI framework for Claude Code hooks",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/vladpazych/dexter.git",
|
|
9
|
+
"directory": "packages/dexter"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"bin"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./meta": "./src/meta/index.ts",
|
|
19
|
+
"./output": "./src/output/index.ts",
|
|
20
|
+
"./env": "./src/env/index.ts",
|
|
21
|
+
"./pipe": "./src/pipe/index.ts",
|
|
22
|
+
"./terminal": "./src/terminal/index.ts",
|
|
23
|
+
"./claude": "./src/claude/index.ts",
|
|
24
|
+
"./config/eslint": "./src/config/eslint.js",
|
|
25
|
+
"./config/tsconfig/base.json": "./src/config/tsconfig/base.json",
|
|
26
|
+
"./config/tsconfig/node.json": "./src/config/tsconfig/node.json",
|
|
27
|
+
"./config/tsconfig/react.json": "./src/config/tsconfig/react.json"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"dexter": "./bin/dexter"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"test": "bun test"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"zod": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/bun": "^1"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dexter standalone CLI — scaffolding and utilities.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* dexter init Scaffold meta/ + .claude/ in current repo
|
|
6
|
+
* dexter version Print version
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { version } from "./version.js"
|
|
10
|
+
|
|
11
|
+
const [, , cmd] = process.argv
|
|
12
|
+
|
|
13
|
+
switch (cmd) {
|
|
14
|
+
case "init":
|
|
15
|
+
console.log("dexter init — not yet implemented")
|
|
16
|
+
break
|
|
17
|
+
|
|
18
|
+
case "version":
|
|
19
|
+
case "--version":
|
|
20
|
+
case "-v":
|
|
21
|
+
console.log(version)
|
|
22
|
+
break
|
|
23
|
+
|
|
24
|
+
case "--help":
|
|
25
|
+
case "-h":
|
|
26
|
+
case undefined:
|
|
27
|
+
console.log(`dexter v${version} — agentic development toolkit
|
|
28
|
+
|
|
29
|
+
Usage: dexter <command>
|
|
30
|
+
|
|
31
|
+
Commands:
|
|
32
|
+
init Scaffold meta/ + .claude/ in current repo
|
|
33
|
+
version Print version`)
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
default:
|
|
37
|
+
console.error(`Unknown command: ${cmd}`)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-owned configuration from environment variables.
|
|
3
|
+
*
|
|
4
|
+
* Each app declares its schema via defineConfig(), which reads process.env,
|
|
5
|
+
* validates types, enforces required fields, and returns a typed object.
|
|
6
|
+
* Metadata is attached via Symbol for printConfig() to read.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { validateBoolean, validateEnum, validateNumber, validatePort, validateString, validateUrl } from "./validate.ts"
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// TYPES — schema definition
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
type EnvType = "string" | "port" | "url" | "number" | "boolean" | "enum"
|
|
16
|
+
|
|
17
|
+
type FieldDef = {
|
|
18
|
+
readonly env: string
|
|
19
|
+
readonly type?: EnvType
|
|
20
|
+
readonly values?: readonly string[]
|
|
21
|
+
readonly default?: unknown
|
|
22
|
+
readonly required?: boolean
|
|
23
|
+
readonly sensitive?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type Schema = { readonly [key: string]: FieldDef | Schema }
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// TYPES — output inference
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/** Map field's `type` property to the TypeScript value type. */
|
|
33
|
+
type FieldValueType<F> = F extends { type: "port" | "number" }
|
|
34
|
+
? number
|
|
35
|
+
: F extends { type: "boolean" }
|
|
36
|
+
? boolean
|
|
37
|
+
: F extends { type: "enum"; values: readonly (infer V)[] }
|
|
38
|
+
? V
|
|
39
|
+
: string
|
|
40
|
+
|
|
41
|
+
/** True when the field is guaranteed to have a value (required or defaulted). */
|
|
42
|
+
type IsPresent<F> = F extends { required: true }
|
|
43
|
+
? true
|
|
44
|
+
: F extends { default: undefined }
|
|
45
|
+
? false
|
|
46
|
+
: F extends { default: unknown }
|
|
47
|
+
? true
|
|
48
|
+
: false
|
|
49
|
+
|
|
50
|
+
/** Output type for a single field: value type, optionally undefined. */
|
|
51
|
+
type FieldOutput<F> = IsPresent<F> extends true ? FieldValueType<F> : FieldValueType<F> | undefined
|
|
52
|
+
|
|
53
|
+
/** Recursive output type for the entire schema. */
|
|
54
|
+
export type ConfigOutput<S> = {
|
|
55
|
+
-readonly [K in keyof S]: S[K] extends { env: string } ? FieldOutput<S[K]> : ConfigOutput<S[K]>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// METADATA — hidden on config object for printConfig
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export const CONFIG_META = Symbol.for("dexter.config.meta")
|
|
63
|
+
|
|
64
|
+
export type FieldMeta = {
|
|
65
|
+
path: string
|
|
66
|
+
env: string
|
|
67
|
+
sensitive: boolean
|
|
68
|
+
type: EnvType
|
|
69
|
+
values?: readonly string[]
|
|
70
|
+
required: boolean
|
|
71
|
+
hasDefault: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ConfigMeta = {
|
|
75
|
+
name?: string
|
|
76
|
+
fields: FieldMeta[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// ERROR
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export class ConfigError extends Error {
|
|
84
|
+
readonly issues: string[]
|
|
85
|
+
|
|
86
|
+
constructor(issues: string[]) {
|
|
87
|
+
super(`Invalid environment configuration\n${issues.map((i) => ` ${i}`).join("\n")}`)
|
|
88
|
+
this.name = "ConfigError"
|
|
89
|
+
this.issues = issues
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// RUNTIME
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
function isField(value: unknown): value is FieldDef {
|
|
98
|
+
return (
|
|
99
|
+
typeof value === "object" &&
|
|
100
|
+
value !== null &&
|
|
101
|
+
"env" in value &&
|
|
102
|
+
typeof (value as Record<string, unknown>).env === "string"
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function coerce(raw: string, type: EnvType, values?: readonly string[]): unknown {
|
|
107
|
+
switch (type) {
|
|
108
|
+
case "string":
|
|
109
|
+
return validateString(raw)
|
|
110
|
+
case "port":
|
|
111
|
+
return validatePort(raw)
|
|
112
|
+
case "number":
|
|
113
|
+
return validateNumber(raw)
|
|
114
|
+
case "boolean":
|
|
115
|
+
return validateBoolean(raw)
|
|
116
|
+
case "url":
|
|
117
|
+
return validateUrl(raw)
|
|
118
|
+
case "enum":
|
|
119
|
+
return validateEnum(raw, values ?? [])
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function defineConfig<const S extends Schema>(schema: S): ConfigOutput<S>
|
|
124
|
+
export function defineConfig<const S extends Schema>(name: string, schema: S): ConfigOutput<S>
|
|
125
|
+
export function defineConfig<const S extends Schema>(nameOrSchema: string | S, maybeSchema?: S): ConfigOutput<S> {
|
|
126
|
+
const name = typeof nameOrSchema === "string" ? nameOrSchema : undefined
|
|
127
|
+
const schema = typeof nameOrSchema === "string" ? maybeSchema! : nameOrSchema
|
|
128
|
+
|
|
129
|
+
const errors: string[] = []
|
|
130
|
+
const fields: FieldMeta[] = []
|
|
131
|
+
|
|
132
|
+
function walk(obj: Record<string, unknown>, path: string[]): Record<string, unknown> {
|
|
133
|
+
const result: Record<string, unknown> = {}
|
|
134
|
+
|
|
135
|
+
for (const [key, def] of Object.entries(obj)) {
|
|
136
|
+
if (isField(def)) {
|
|
137
|
+
const fieldPath = [...path, key].join(".")
|
|
138
|
+
const type: EnvType = (def.type as EnvType) ?? "string"
|
|
139
|
+
|
|
140
|
+
fields.push({
|
|
141
|
+
path: fieldPath,
|
|
142
|
+
env: def.env,
|
|
143
|
+
sensitive: def.sensitive ?? false,
|
|
144
|
+
type,
|
|
145
|
+
values: def.values,
|
|
146
|
+
required: def.required ?? false,
|
|
147
|
+
hasDefault: "default" in def,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const raw = process.env[def.env]
|
|
151
|
+
|
|
152
|
+
if (raw === undefined || raw === "") {
|
|
153
|
+
if (def.required) {
|
|
154
|
+
errors.push(`${def.env}: required but not set`)
|
|
155
|
+
} else if ("default" in def) {
|
|
156
|
+
result[key] = def.default
|
|
157
|
+
} else {
|
|
158
|
+
result[key] = undefined
|
|
159
|
+
}
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
result[key] = coerce(raw, type, def.values)
|
|
165
|
+
} catch (msg) {
|
|
166
|
+
errors.push(`${def.env}: ${msg}`)
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
result[key] = walk(def as Record<string, unknown>, [...path, key])
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const config = walk(schema as unknown as Record<string, unknown>, [])
|
|
177
|
+
|
|
178
|
+
if (errors.length > 0) {
|
|
179
|
+
throw new ConfigError(errors)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
Object.defineProperty(config, CONFIG_META, {
|
|
183
|
+
value: { name, fields } satisfies ConfigMeta,
|
|
184
|
+
enumerable: false,
|
|
185
|
+
configurable: false,
|
|
186
|
+
writable: false,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return config as ConfigOutput<S>
|
|
190
|
+
}
|
package/src/env/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Loading: dotenv plumbing
|
|
2
|
+
export type { LoadResult } from "./loader.ts"
|
|
3
|
+
export { applyEnv, loadEnv, parseEnvFile } from "./loader.ts"
|
|
4
|
+
|
|
5
|
+
// Config: app-owned schema, validation, metadata
|
|
6
|
+
export type { ConfigMeta, ConfigOutput, FieldMeta, Schema } from "./define.ts"
|
|
7
|
+
export { CONFIG_META, ConfigError, defineConfig } from "./define.ts"
|
|
8
|
+
|
|
9
|
+
// Print: formatted output with sensitive masking
|
|
10
|
+
export { formatConfig, printConfig } from "./print.ts"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load environment variables from .env files.
|
|
3
|
+
* Order: .env (defaults) → .env.local (overrides)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
export type LoadResult = {
|
|
10
|
+
env: Record<string, string>
|
|
11
|
+
sources: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Parse a plain key=value .env file. Ignores comments and empty lines. */
|
|
15
|
+
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
16
|
+
if (!existsSync(filePath)) return {}
|
|
17
|
+
const content = readFileSync(filePath, "utf-8")
|
|
18
|
+
const result: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
for (const line of content.split("\n")) {
|
|
21
|
+
const trimmed = line.trim()
|
|
22
|
+
if (trimmed.startsWith("#") || trimmed === "") continue
|
|
23
|
+
|
|
24
|
+
const match = trimmed.match(/^([A-Z][A-Z0-9_]*)=(.*)$/)
|
|
25
|
+
if (match) {
|
|
26
|
+
let value = match[2]
|
|
27
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
28
|
+
value = value.slice(1, -1)
|
|
29
|
+
}
|
|
30
|
+
result[match[1]] = value
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadEnv(rootDir: string): LoadResult {
|
|
38
|
+
const env: Record<string, string> = {}
|
|
39
|
+
const sources: Record<string, string> = {}
|
|
40
|
+
|
|
41
|
+
const merge = (filename: string) => {
|
|
42
|
+
const values = parseEnvFile(join(rootDir, filename))
|
|
43
|
+
for (const [key, value] of Object.entries(values)) {
|
|
44
|
+
if (value !== "") {
|
|
45
|
+
env[key] = value
|
|
46
|
+
sources[key] = filename
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
merge(".env")
|
|
52
|
+
merge(".env.local")
|
|
53
|
+
|
|
54
|
+
return { env, sources }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function applyEnv(env: Record<string, string>): void {
|
|
58
|
+
for (const [key, value] of Object.entries(env)) {
|
|
59
|
+
process.env[key] = value
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/env/print.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config printing with sensitive value masking.
|
|
3
|
+
*
|
|
4
|
+
* Reads metadata attached by defineConfig() via CONFIG_META symbol.
|
|
5
|
+
* Nested schema keys become section headers in the output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { c } from "../terminal/colors.ts"
|
|
9
|
+
import { CONFIG_META, type ConfigMeta, type FieldMeta } from "./define.ts"
|
|
10
|
+
|
|
11
|
+
const MASK = "••••"
|
|
12
|
+
const UNSET = "—"
|
|
13
|
+
const BOX_TOP = "┌"
|
|
14
|
+
const BOX_MID = "│"
|
|
15
|
+
const BOX_BOT = "└"
|
|
16
|
+
|
|
17
|
+
function getMeta(config: unknown): ConfigMeta | undefined {
|
|
18
|
+
if (typeof config === "object" && config !== null && CONFIG_META in config) {
|
|
19
|
+
return (config as Record<symbol, unknown>)[CONFIG_META] as ConfigMeta
|
|
20
|
+
}
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatValue(raw: unknown, field: FieldMeta): string {
|
|
25
|
+
if (raw === undefined || raw === null) return c.gray(UNSET)
|
|
26
|
+
if (field.sensitive) return c.gray(MASK)
|
|
27
|
+
return String(raw)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getNestedValue(obj: unknown, path: string): unknown {
|
|
31
|
+
let current = obj
|
|
32
|
+
for (const key of path.split(".")) {
|
|
33
|
+
if (typeof current !== "object" || current === null) return undefined
|
|
34
|
+
current = (current as Record<string, unknown>)[key]
|
|
35
|
+
}
|
|
36
|
+
return current
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format config as a printable string with sections and masking.
|
|
41
|
+
*/
|
|
42
|
+
export function formatConfig(config: unknown, name?: string): string {
|
|
43
|
+
const meta = getMeta(config)
|
|
44
|
+
if (!meta) return String(config)
|
|
45
|
+
|
|
46
|
+
const label = name ?? meta.name ?? "config"
|
|
47
|
+
const lines: string[] = []
|
|
48
|
+
|
|
49
|
+
lines.push(`${c.gray(BOX_TOP)} ${c.bolded(label)}`)
|
|
50
|
+
|
|
51
|
+
// Group fields by their first path segment (section)
|
|
52
|
+
const sections = new Map<string, FieldMeta[]>()
|
|
53
|
+
for (const field of meta.fields) {
|
|
54
|
+
const parts = field.path.split(".")
|
|
55
|
+
const section = parts.length > 1 ? parts[0] : ""
|
|
56
|
+
const existing = sections.get(section) ?? []
|
|
57
|
+
existing.push(field)
|
|
58
|
+
sections.set(section, existing)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Find longest label for alignment
|
|
62
|
+
const allLabels = meta.fields.map((f) => {
|
|
63
|
+
const parts = f.path.split(".")
|
|
64
|
+
return parts[parts.length - 1]
|
|
65
|
+
})
|
|
66
|
+
const maxLen = Math.max(...allLabels.map((l) => l.length))
|
|
67
|
+
|
|
68
|
+
let first = true
|
|
69
|
+
for (const [section, fields] of sections) {
|
|
70
|
+
if (!first) lines.push(c.gray(BOX_MID))
|
|
71
|
+
first = false
|
|
72
|
+
|
|
73
|
+
if (section) {
|
|
74
|
+
lines.push(`${c.gray(BOX_MID)} ${c.cyan(section)}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const parts = field.path.split(".")
|
|
79
|
+
const key = parts[parts.length - 1]
|
|
80
|
+
const indent = section ? " " : ""
|
|
81
|
+
const value = getNestedValue(config, field.path)
|
|
82
|
+
const formatted = formatValue(value, field)
|
|
83
|
+
const padding = " ".repeat(maxLen - key.length + 2)
|
|
84
|
+
|
|
85
|
+
lines.push(`${c.gray(BOX_MID)} ${indent}${c.gray(key)}${padding}${formatted}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push(c.gray(BOX_BOT))
|
|
90
|
+
return lines.join("\n")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Print config to stdout with sections and sensitive value masking.
|
|
95
|
+
*/
|
|
96
|
+
export function printConfig(config: unknown, name?: string): void {
|
|
97
|
+
console.log(formatConfig(config, name))
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure validators for environment variable types.
|
|
3
|
+
* Each returns the coerced value or throws a descriptive message.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function validateString(value: string): string {
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validatePort(value: string): number {
|
|
11
|
+
const n = parseInt(value, 10)
|
|
12
|
+
if (isNaN(n) || n < 1 || n > 65535) {
|
|
13
|
+
throw `expected port number (1–65535), got "${value}"`
|
|
14
|
+
}
|
|
15
|
+
return n
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateNumber(value: string): number {
|
|
19
|
+
const n = parseFloat(value)
|
|
20
|
+
if (isNaN(n)) {
|
|
21
|
+
throw `expected number, got "${value}"`
|
|
22
|
+
}
|
|
23
|
+
return n
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateBoolean(value: string): boolean {
|
|
27
|
+
if (value === "true" || value === "1") return true
|
|
28
|
+
if (value === "false" || value === "0") return false
|
|
29
|
+
throw `expected true/false/1/0, got "${value}"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateUrl(value: string): string {
|
|
33
|
+
try {
|
|
34
|
+
new URL(value)
|
|
35
|
+
return value
|
|
36
|
+
} catch {
|
|
37
|
+
throw `expected valid URL, got "${value}"`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateEnum(value: string, allowed: readonly string[]): string {
|
|
42
|
+
if (!allowed.includes(value)) {
|
|
43
|
+
throw `expected ${allowed.join(" | ")}, got "${value}"`
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vladpazych/dexter — agentic development toolkit.
|
|
3
|
+
*
|
|
4
|
+
* Subpath exports:
|
|
5
|
+
* dexter/meta — CLI factory, hook framework, domain commands
|
|
6
|
+
* dexter/output — polymorphic structured output (block, field, render)
|
|
7
|
+
* dexter/env — env file loading
|
|
8
|
+
* dexter/pipe — pipe utilities
|
|
9
|
+
* dexter/terminal — terminal helpers
|
|
10
|
+
* dexter/claude — .claude folder management
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { version } from "./version.ts"
|
|
14
|
+
|
|
15
|
+
// Re-export top-level env utilities for convenience
|
|
16
|
+
export { applyEnv, loadEnv } from "./env/index.ts"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FsPort adapter — wraps Node fs for file system operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, mkdirSync } from "node:fs"
|
|
6
|
+
|
|
7
|
+
import type { FsPort } from "../ports.ts"
|
|
8
|
+
|
|
9
|
+
export function createNodeFs(): FsPort {
|
|
10
|
+
return {
|
|
11
|
+
exists: (path) => existsSync(path),
|
|
12
|
+
readFile: (path) => readFileSync(path, "utf-8"),
|
|
13
|
+
writeFile: (path, content) => writeFileSync(path, content),
|
|
14
|
+
readDir: (path) =>
|
|
15
|
+
readdirSync(path, { withFileTypes: true }).map((d) => ({
|
|
16
|
+
name: d.name,
|
|
17
|
+
isDirectory: d.isDirectory(),
|
|
18
|
+
})),
|
|
19
|
+
unlink: (path) => unlinkSync(path),
|
|
20
|
+
mkdir: (path) => mkdirSync(path, { recursive: true }),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitPort adapter — wraps Bun.spawnSync for git operations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { GitPort } from "../ports.ts"
|
|
6
|
+
|
|
7
|
+
export function createBunGit(): GitPort {
|
|
8
|
+
return {
|
|
9
|
+
run(args, env) {
|
|
10
|
+
const result = Bun.spawnSync(["git", ...args], {
|
|
11
|
+
stdout: "pipe",
|
|
12
|
+
stderr: "pipe",
|
|
13
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
14
|
+
})
|
|
15
|
+
return {
|
|
16
|
+
success: result.success,
|
|
17
|
+
stdout: result.stdout.toString(),
|
|
18
|
+
stderr: result.stderr.toString(),
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
checkIgnore(file) {
|
|
22
|
+
const result = Bun.spawnSync(["git", "check-ignore", "-q", "--", file], {
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "pipe",
|
|
25
|
+
})
|
|
26
|
+
return result.success
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GlobPort adapter — wraps Bun.Glob for pattern matching.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { GlobPort } from "../ports.ts"
|
|
6
|
+
|
|
7
|
+
export function createBunGlob(): GlobPort {
|
|
8
|
+
return {
|
|
9
|
+
match(pattern, candidates) {
|
|
10
|
+
const glob = new Bun.Glob(pattern)
|
|
11
|
+
return candidates.filter((f) => glob.match(f))
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter wiring — creates ControlPorts from real implementations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { homedir, tmpdir } from "node:os"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
|
|
9
|
+
import { createBunGit } from "./git.ts"
|
|
10
|
+
import { createNodeFs } from "./fs.ts"
|
|
11
|
+
import { createNodeProcess } from "./process.ts"
|
|
12
|
+
import { createBunGlob } from "./glob.ts"
|
|
13
|
+
|
|
14
|
+
export function createControlPorts(root: string): ControlPorts {
|
|
15
|
+
return {
|
|
16
|
+
git: createBunGit(),
|
|
17
|
+
fs: createNodeFs(),
|
|
18
|
+
process: createNodeProcess(),
|
|
19
|
+
glob: createBunGlob(),
|
|
20
|
+
tmpdir: () => tmpdir(),
|
|
21
|
+
homedir: () => homedir(),
|
|
22
|
+
root,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcessPort adapter — wraps child_process.spawn + readline for streaming output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process"
|
|
6
|
+
import { createInterface } from "node:readline"
|
|
7
|
+
|
|
8
|
+
import type { ProcessPort } from "../ports.ts"
|
|
9
|
+
|
|
10
|
+
export function createNodeProcess(): ProcessPort {
|
|
11
|
+
return {
|
|
12
|
+
spawn({ cmd, args, cwd, env, timeout }) {
|
|
13
|
+
const child = spawn(cmd, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
16
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
20
|
+
if (timeout) {
|
|
21
|
+
timer = setTimeout(() => child.kill(), timeout)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
onLine(stream, cb) {
|
|
26
|
+
const source = stream === "stdout" ? child.stdout : child.stderr
|
|
27
|
+
createInterface({ input: source }).on("line", cb)
|
|
28
|
+
},
|
|
29
|
+
wait() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
child.on("close", (code) => {
|
|
32
|
+
if (timer) clearTimeout(timer)
|
|
33
|
+
resolve(code)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|