@mohou/host 1.0.20

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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. package/themes/theme-zoro.css +4 -0
@@ -0,0 +1,248 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ import { ConfigError } from './codes.ts'
6
+
7
+ export const authorMcpServerId = 'mini-app'
8
+
9
+ /**
10
+ * `dsh` is not a JSON file: DSH reads MCP servers from the YAML plugin list in the profile's
11
+ * `cordis.patch.yml`, so that format is merged as text (see `writeDshPatch`).
12
+ */
13
+ export type AuthorMcpFormat = 'mcpServers' | 'claude' | 'opencode' | 'dsh'
14
+
15
+ /** One assistant file Shell named. */
16
+ export interface AuthorMcpAgent {
17
+ readonly id: string
18
+ readonly label: string
19
+ readonly file: string
20
+ readonly detectDir: string
21
+ readonly format: AuthorMcpFormat
22
+ readonly adapter?: string
23
+ }
24
+
25
+ export interface AuthorMcpLayout {
26
+ readonly agents: readonly AuthorMcpAgent[]
27
+ }
28
+
29
+ export interface AuthorMcpLive {
30
+ readonly url: string
31
+ readonly token: string
32
+ readonly description: string
33
+ }
34
+
35
+ export interface AuthorMcpAgentStatus extends AuthorMcpAgent {
36
+ readonly dest: string
37
+ readonly homePresent: boolean
38
+ readonly installed: boolean
39
+ readonly updateAvailable: boolean
40
+ }
41
+
42
+ export interface AuthorMcpStatus {
43
+ readonly agents: readonly AuthorMcpAgentStatus[]
44
+ }
45
+
46
+ export async function readAuthorMcp(layout: AuthorMcpLayout, live: AuthorMcpLive): Promise<AuthorMcpStatus> {
47
+ const agents = await Promise.all(layout.agents.map(async (agent) => {
48
+ const installed = await hasServer(agent, live)
49
+ return {
50
+ ...agent,
51
+ dest: agent.file,
52
+ homePresent: await present(agent.detectDir),
53
+ installed: installed.ok,
54
+ updateAvailable: installed.ok && !installed.current,
55
+ }
56
+ }))
57
+ return { agents }
58
+ }
59
+
60
+ /**
61
+ * Merge the live authoring server into each selected assistant file.
62
+ * Other servers in that file stay.
63
+ */
64
+ export async function writeAuthorMcp(
65
+ layout: AuthorMcpLayout,
66
+ agentIds: readonly string[],
67
+ live: AuthorMcpLive,
68
+ ): Promise<AuthorMcpStatus> {
69
+ const selected = layout.agents.filter(agent => agentIds.includes(agent.id))
70
+ for (const agent of selected) await writeAgent(agent, live)
71
+ return readAuthorMcp(layout, live)
72
+ }
73
+
74
+ /** Open a dest this layout already owns. */
75
+ export async function revealAuthorMcp(
76
+ layout: AuthorMcpLayout,
77
+ dest: string,
78
+ platform = process.platform,
79
+ ): Promise<void> {
80
+ const resolved = path.resolve(dest)
81
+ const allowed = new Set(layout.agents.map(agent => path.resolve(agent.file)))
82
+ if (!allowed.has(resolved)) throw new ConfigError('config-invalid', 'mcp file is not in the list')
83
+ await access(resolved)
84
+ await openPath(resolved, platform)
85
+ }
86
+
87
+ async function writeAgent(agent: AuthorMcpAgent, live: AuthorMcpLive): Promise<void> {
88
+ if (agent.format === 'dsh') {
89
+ await writeDshPatch(agent.file, live)
90
+ return
91
+ }
92
+ const root = await readRoot(agent.file)
93
+ const key = nestKey(agent.format)
94
+ const nest = isRecord(root[key]) ? { ...root[key] } : {}
95
+ nest[authorMcpServerId] = entryOf(agent.format, live)
96
+ const next = { ...root, [key]: nest }
97
+ await mkdir(path.dirname(agent.file), { recursive: true })
98
+ await writeFile(agent.file, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
99
+ }
100
+
101
+ /** First and last line of the block this product owns inside a DSH patch file. */
102
+ const dshMarkStart = `# >>> mohou:${authorMcpServerId}`
103
+ const dshMarkEnd = `# <<< mohou:${authorMcpServerId}`
104
+
105
+ /**
106
+ * The plugin entry DSH needs. `serverName` is what DSH turns into `mcp__<serverName>__<tool>`.
107
+ * A failed connection must not stop the harness from booting.
108
+ */
109
+ function dshBlock(live: AuthorMcpLive): string {
110
+ return [
111
+ `${dshMarkStart} — written by Mohou Settings → Agent. Delete this whole block to uninstall.`,
112
+ '- insert:',
113
+ ` - id: mcp-${authorMcpServerId}`,
114
+ ' name: "@deepseek-ai/dsh-mcp-client"',
115
+ ' config:',
116
+ ` serverName: ${authorMcpServerId}`,
117
+ ' transport: streamable-http',
118
+ ` url: ${live.url}`,
119
+ ' headers:',
120
+ ` Authorization: "Bearer ${live.token}"`,
121
+ ' failOnStartupError: false',
122
+ dshMarkEnd,
123
+ '',
124
+ ].join('\n')
125
+ }
126
+
127
+ /**
128
+ * Merge the entry into the profile's patch layer as text. The file may hold hand-written YAML
129
+ * (`!!js` tags included), so it is never re-serialised: everything outside our two markers is
130
+ * copied through byte for byte.
131
+ */
132
+ async function writeDshPatch(file: string, live: AuthorMcpLive): Promise<void> {
133
+ const text = await readText(file)
134
+ const stripped = dropDshBlock(text)
135
+ const head = stripped.trimEnd()
136
+ const next = head.length === 0 ? dshBlock(live) : `${head}\n\n${dshBlock(live)}`
137
+ await mkdir(path.dirname(file), { recursive: true })
138
+ await writeFile(file, next, 'utf8')
139
+ }
140
+
141
+ function dropDshBlock(text: string): string {
142
+ const start = text.indexOf(dshMarkStart)
143
+ if (start < 0) return text
144
+ const end = text.indexOf(dshMarkEnd, start)
145
+ if (end < 0) return text.slice(0, start)
146
+ const after = text.indexOf('\n', end)
147
+ return `${text.slice(0, start)}${after < 0 ? '' : text.slice(after + 1)}`
148
+ }
149
+
150
+ async function readText(file: string): Promise<string> {
151
+ return await readFile(file, 'utf8').catch((error: unknown) => {
152
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
153
+ throw error
154
+ })
155
+ }
156
+
157
+ async function hasServer(agent: AuthorMcpAgent, live: AuthorMcpLive): Promise<{ ok: boolean; current: boolean }> {
158
+ if (agent.format === 'dsh') return hasDshBlock(agent.file, live)
159
+ const root = await readRoot(agent.file).catch(() => undefined)
160
+ if (root === undefined) return { ok: false, current: false }
161
+ const nest = root[nestKey(agent.format)]
162
+ if (!isRecord(nest)) return { ok: false, current: false }
163
+ const row = nest[authorMcpServerId]
164
+ if (!isRecord(row)) return { ok: false, current: false }
165
+ const headers = isRecord(row.headers) ? row.headers : {}
166
+ const current = row.url === live.url && headers.Authorization === `Bearer ${live.token}`
167
+ return { ok: true, current }
168
+ }
169
+
170
+ /** Installed when our markers are there; current only when the block matches the live server. */
171
+ async function hasDshBlock(file: string, live: AuthorMcpLive): Promise<{ ok: boolean; current: boolean }> {
172
+ const text = await readText(file)
173
+ const start = text.indexOf(dshMarkStart)
174
+ if (start < 0) return { ok: false, current: false }
175
+ const end = text.indexOf(dshMarkEnd, start)
176
+ if (end < 0) return { ok: true, current: false }
177
+ const lineEnd = text.indexOf('\n', end)
178
+ const block = lineEnd < 0 ? text.slice(start) : text.slice(start, lineEnd)
179
+ return { ok: true, current: block === dshBlock(live).trimEnd() }
180
+ }
181
+
182
+ async function readRoot(file: string): Promise<Record<string, unknown>> {
183
+ const text = await readFile(file, 'utf8').catch((error: unknown) => {
184
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') return ''
185
+ throw error
186
+ })
187
+ if (text.trim().length === 0) return {}
188
+ let parsed: unknown
189
+ try {
190
+ parsed = JSON.parse(stripJsonc(text))
191
+ } catch {
192
+ throw new ConfigError('config-invalid', `mcp file is not JSON: ${file}`)
193
+ }
194
+ if (!isRecord(parsed)) throw new ConfigError('config-invalid', `mcp file is not an object: ${file}`)
195
+ return parsed
196
+ }
197
+
198
+ function entryOf(format: AuthorMcpFormat, live: AuthorMcpLive): Record<string, unknown> {
199
+ const headers = { Authorization: `Bearer ${live.token}` }
200
+ if (format === 'opencode') return { type: 'remote', url: live.url, enabled: true, headers }
201
+ if (format === 'claude') return { type: 'http', url: live.url, headers }
202
+ return {
203
+ url: live.url,
204
+ transport: 'streamable-http',
205
+ description: live.description,
206
+ headers,
207
+ _monkeyagent: { description: live.description },
208
+ }
209
+ }
210
+
211
+ function nestKey(format: AuthorMcpFormat): 'mcpServers' | 'mcp' {
212
+ return format === 'opencode' ? 'mcp' : 'mcpServers'
213
+ }
214
+
215
+ function stripJsonc(text: string): string {
216
+ return text
217
+ .replace(/\/\*[\s\S]*?\*\//g, '')
218
+ .replace(/^\s*\/\/.*$/gm, '')
219
+ .replace(/,(\s*[}\]])/g, '$1')
220
+ }
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
224
+ }
225
+
226
+ async function present(dir: string): Promise<boolean> {
227
+ try {
228
+ await access(dir)
229
+ return true
230
+ } catch {
231
+ return false
232
+ }
233
+ }
234
+
235
+ function openPath(target: string, platform: string): Promise<void> {
236
+ const launched = platform === 'win32'
237
+ ? { command: 'explorer', args: [target] }
238
+ : platform === 'darwin'
239
+ ? { command: 'open', args: [target] }
240
+ : undefined
241
+ if (launched === undefined) throw new ConfigError('config-invalid', `open folder is not implemented on ${platform}`)
242
+ return new Promise((resolve, reject) => {
243
+ const child = spawn(launched.command, launched.args, { stdio: 'ignore', detached: true })
244
+ child.once('error', reject)
245
+ child.unref()
246
+ resolve()
247
+ })
248
+ }
@@ -0,0 +1,196 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { access, cp, mkdir, readFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { ConfigError } from './codes.ts'
7
+
8
+ /** One assistant folder Shell named. `skillsDir` is the parent `skills` directory. */
9
+ export interface AuthorSkillAgent {
10
+ readonly id: string
11
+ readonly label: string
12
+ readonly skillsDir: string
13
+ readonly detectDir: string
14
+ }
15
+
16
+ export interface AuthorSkillLayout {
17
+ readonly source: string
18
+ readonly agents: readonly AuthorSkillAgent[]
19
+ }
20
+
21
+ export interface AuthorSkillCopyStatus {
22
+ readonly dest: string
23
+ readonly installed: boolean
24
+ readonly version: string | null
25
+ readonly updateAvailable: boolean
26
+ }
27
+
28
+ export interface AuthorSkillAgentStatus extends AuthorSkillAgent, AuthorSkillCopyStatus {
29
+ readonly homePresent: boolean
30
+ }
31
+
32
+ export interface AuthorSkillStatus {
33
+ readonly skillId: string
34
+ readonly version: string | null
35
+ readonly agents: readonly AuthorSkillAgentStatus[]
36
+ readonly customs: readonly (AuthorSkillCopyStatus & { readonly dir: string })[]
37
+ }
38
+
39
+ /** Skill folder name taken from the source directory. */
40
+ export function skillIdOf(source: string): string {
41
+ const id = path.basename(source).trim()
42
+ if (id.length === 0) throw new ConfigError('config-invalid', 'author skill source is invalid')
43
+ return id
44
+ }
45
+
46
+ /**
47
+ * Admit a custom skills parent. The last segment must be `skills`.
48
+ * @param raw - path the panel sent
49
+ * @param home - for expanding `~`
50
+ */
51
+ export function admitSkillsDir(raw: string, home: string): string {
52
+ const trimmed = raw.trim()
53
+ if (trimmed.length === 0) throw new ConfigError('config-invalid', 'skill directory is empty')
54
+ const expanded = trimmed.startsWith('~/') || trimmed === '~'
55
+ ? path.join(home, trimmed.slice(1).replace(/^[\\/]/, ''))
56
+ : trimmed
57
+ const resolved = path.resolve(expanded)
58
+ if (path.basename(resolved) !== 'skills') {
59
+ throw new ConfigError('config-invalid', 'skill directory must end with skills')
60
+ }
61
+ return resolved
62
+ }
63
+
64
+ /** `x.y.z` from the first YAML block of SKILL.md. Anything else is absent. */
65
+ export function skillVersionOf(markdown: string): string | null {
66
+ if (!markdown.startsWith('---')) return null
67
+ const end = markdown.indexOf('\n---', 3)
68
+ if (end < 0) return null
69
+ const match = /^version:\s*["']?([0-9]+\.[0-9]+\.[0-9]+)["']?\s*$/m.exec(markdown.slice(3, end))
70
+ return match?.[1] ?? null
71
+ }
72
+
73
+ export async function readAuthorSkill(
74
+ layout: AuthorSkillLayout,
75
+ customDirs: readonly string[] = [],
76
+ home = homedir(),
77
+ ): Promise<AuthorSkillStatus> {
78
+ const skillId = skillIdOf(layout.source)
79
+ const version = await readSkillVersion(path.join(layout.source, 'SKILL.md'))
80
+ const agents = await Promise.all(layout.agents.map(async (agent) => {
81
+ const dest = path.join(agent.skillsDir, skillId)
82
+ const copy = await copyStatus(dest, version)
83
+ return {
84
+ ...agent,
85
+ ...copy,
86
+ homePresent: await present(agent.detectDir),
87
+ }
88
+ }))
89
+ const customs = await Promise.all(customDirs.map(async (raw) => {
90
+ const dir = admitSkillsDir(raw, home)
91
+ const dest = path.join(dir, skillId)
92
+ return { dir, ...await copyStatus(dest, version) }
93
+ }))
94
+ return { skillId, version, agents, customs }
95
+ }
96
+
97
+ /**
98
+ * Copy the skill into each selected assistant or custom skills dir.
99
+ */
100
+ export async function writeAuthorSkill(
101
+ layout: AuthorSkillLayout,
102
+ agentIds: readonly string[],
103
+ customDirs: readonly string[],
104
+ home = homedir(),
105
+ ): Promise<AuthorSkillStatus> {
106
+ await access(path.join(layout.source, 'SKILL.md'))
107
+ const skillId = skillIdOf(layout.source)
108
+ const selected = layout.agents.filter(agent => agentIds.includes(agent.id))
109
+ const dirs = [...selected.map(agent => agent.skillsDir), ...customDirs.map(raw => admitSkillsDir(raw, home))]
110
+ for (const dir of dirs) {
111
+ const dest = path.join(dir, skillId)
112
+ await mkdir(dir, { recursive: true })
113
+ await cp(layout.source, dest, { recursive: true, force: true })
114
+ }
115
+ return readAuthorSkill(layout, customDirs, home)
116
+ }
117
+
118
+ /** Open a dest this layout already owns. */
119
+ export async function revealAuthorSkill(
120
+ layout: AuthorSkillLayout,
121
+ dest: string,
122
+ home = homedir(),
123
+ platform = process.platform,
124
+ ): Promise<void> {
125
+ const status = await readAuthorSkill(layout, [], home)
126
+ const allowed = new Set(status.agents.map(agent => agent.dest))
127
+ const resolved = path.resolve(dest)
128
+ if (!allowed.has(resolved) && path.basename(path.dirname(resolved)) !== 'skills') {
129
+ throw new ConfigError('config-invalid', 'skill folder is not in the list')
130
+ }
131
+ if (path.basename(path.dirname(resolved)) === 'skills') admitSkillsDir(path.dirname(resolved), home)
132
+ await access(path.join(resolved, 'SKILL.md'))
133
+ await openDir(resolved, platform)
134
+ }
135
+
136
+ async function copyStatus(dest: string, sourceVersion: string | null): Promise<AuthorSkillCopyStatus> {
137
+ const installed = await hasSkill(dest)
138
+ const version = installed ? await readSkillVersion(path.join(dest, 'SKILL.md')) : null
139
+ return {
140
+ dest,
141
+ installed,
142
+ version,
143
+ updateAvailable: installed && sourceVersion !== null && (version === null || compareSkillVersion(sourceVersion, version) > 0),
144
+ }
145
+ }
146
+
147
+ async function readSkillVersion(file: string): Promise<string | null> {
148
+ try {
149
+ return skillVersionOf(await readFile(file, 'utf8'))
150
+ } catch {
151
+ return null
152
+ }
153
+ }
154
+
155
+ function compareSkillVersion(left: string, right: string): number {
156
+ const a = left.split('.').map(Number)
157
+ const b = right.split('.').map(Number)
158
+ for (let i = 0; i < 3; i++) {
159
+ const delta = (a[i] ?? 0) - (b[i] ?? 0)
160
+ if (delta !== 0) return delta > 0 ? 1 : -1
161
+ }
162
+ return 0
163
+ }
164
+
165
+ async function hasSkill(dest: string): Promise<boolean> {
166
+ try {
167
+ await access(path.join(dest, 'SKILL.md'))
168
+ return true
169
+ } catch {
170
+ return false
171
+ }
172
+ }
173
+
174
+ async function present(dir: string): Promise<boolean> {
175
+ try {
176
+ await access(dir)
177
+ return true
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ function openDir(dir: string, platform: string): Promise<void> {
184
+ const launched = platform === 'win32'
185
+ ? { command: 'explorer', args: [dir] }
186
+ : platform === 'darwin'
187
+ ? { command: 'open', args: [dir] }
188
+ : undefined
189
+ if (launched === undefined) throw new ConfigError('config-invalid', `open folder is not implemented on ${platform}`)
190
+ return new Promise((resolve, reject) => {
191
+ const child = spawn(launched.command, launched.args, { stdio: 'ignore', detached: true })
192
+ child.once('error', reject)
193
+ child.unref()
194
+ resolve()
195
+ })
196
+ }
@@ -0,0 +1,21 @@
1
+ /** Codes the host config emits. Callers match `code`. */
2
+
3
+ export const configCodes = ['config-invalid', 'config-missing'] as const
4
+
5
+ export type ConfigCode = (typeof configCodes)[number]
6
+
7
+ /** A present config failed. The file is unchanged. */
8
+ export class ConfigError extends Error {
9
+ readonly code: ConfigCode
10
+
11
+ /**
12
+ * @param code - one of {@link configCodes}
13
+ * @param message - human text; not the match key
14
+ * @param options - optional `cause`
15
+ */
16
+ constructor(code: ConfigCode, message: string, options?: { cause?: unknown }) {
17
+ super(message, options)
18
+ this.name = 'ConfigError'
19
+ this.code = code
20
+ }
21
+ }