@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,86 @@
1
+ /**
2
+ * Turn a callback-shaped model call into a stream the method can `for await`.
3
+ * Awaiting the same object waits for the final string. Iteration does not start a second call.
4
+ */
5
+ export function modelStream<Event>(
6
+ run: (emit: (event: Event) => void) => Promise<string>,
7
+ ): AsyncIterable<Event> & Promise<string> {
8
+ const queued: Event[] = []
9
+ let waiting: { resolve: (step: IteratorResult<Event>) => void; reject: (error: unknown) => void } | undefined
10
+ let settled = false
11
+ let failure: unknown
12
+ let started = false
13
+ let resolveText: (value: string) => void
14
+ let rejectText: (error: unknown) => void
15
+ const text = new Promise<string>((resolve, reject) => {
16
+ resolveText = resolve
17
+ rejectText = reject
18
+ })
19
+ void text.catch(() => undefined)
20
+
21
+ const push = (event: Event): void => {
22
+ if (waiting !== undefined) {
23
+ const resume = waiting
24
+ waiting = undefined
25
+ resume.resolve({ value: event, done: false })
26
+ return
27
+ }
28
+ queued.push(event)
29
+ }
30
+
31
+ const finish = (): void => {
32
+ const resume = waiting
33
+ waiting = undefined
34
+ if (resume === undefined) return
35
+ if (failure instanceof Error) resume.reject(failure)
36
+ else resume.resolve({ value: undefined, done: true })
37
+ }
38
+
39
+ const start = (): void => {
40
+ if (started) return
41
+ started = true
42
+ run(push).then((value) => {
43
+ settled = true
44
+ resolveText(value)
45
+ finish()
46
+ }, (error: unknown) => {
47
+ settled = true
48
+ failure = error
49
+ rejectText(error)
50
+ finish()
51
+ })
52
+ }
53
+
54
+ const iterator: AsyncIterator<Event> = {
55
+ next() {
56
+ start()
57
+ const event = queued.shift()
58
+ if (event !== undefined) return Promise.resolve({ value: event, done: false })
59
+ if (failure !== undefined) return Promise.reject(failure instanceof Error ? failure : new Error('model stream failed'))
60
+ if (settled) return Promise.resolve({ value: undefined, done: true })
61
+ return new Promise((resolve, reject) => {
62
+ waiting = { resolve, reject }
63
+ })
64
+ },
65
+ }
66
+
67
+ return {
68
+ [Symbol.asyncIterator]() {
69
+ start()
70
+ return iterator
71
+ },
72
+ then(onFulfilled, onRejected) {
73
+ start()
74
+ return text.then(onFulfilled, onRejected)
75
+ },
76
+ catch(onRejected) {
77
+ start()
78
+ return text.catch(onRejected)
79
+ },
80
+ finally(onFinally) {
81
+ start()
82
+ return text.finally(onFinally)
83
+ },
84
+ [Symbol.toStringTag]: 'Promise',
85
+ } as AsyncIterable<Event> & Promise<string>
86
+ }
@@ -0,0 +1,4 @@
1
+ /** Omit a key when the value is undefined, for exact optional properties. */
2
+ export function present<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
3
+ return value === undefined ? {} : { [key]: value } as Record<Key, Value>
4
+ }
@@ -0,0 +1,35 @@
1
+ import type { AppAgentOptions } from '@mohou/contract'
2
+
3
+ import { HostError } from './codes.ts'
4
+
5
+ /** Directories Host already knows. A missing base is not filled from `process.cwd()`. */
6
+ export interface WorkingDirectoryInput {
7
+ readonly cwdType?: AppAgentOptions['cwdType']
8
+ readonly cwd?: string
9
+ readonly appDir: string
10
+ readonly processDirectory: string
11
+ readonly createTemp: () => string
12
+ }
13
+
14
+ function isAbsolute(path: string): boolean {
15
+ return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
16
+ }
17
+
18
+ /**
19
+ * Resolve the directory an agent run uses.
20
+ * A path with no mode means `custom`. A path plus another mode fails.
21
+ * @param input - call options plus host-owned directories
22
+ */
23
+ export function resolveWorkingDirectory(input: WorkingDirectoryInput): string {
24
+ const mode = input.cwdType ?? (input.cwd === undefined ? 'process' : 'custom')
25
+ if (input.cwd !== undefined && mode !== 'custom') {
26
+ throw new HostError('cwd-invalid', 'cwd requires cwdType custom')
27
+ }
28
+ if (mode === 'app') return input.appDir
29
+ if (mode === 'process') return input.processDirectory
30
+ if (mode === 'temp') return input.createTemp()
31
+ if (input.cwd === undefined || !isAbsolute(input.cwd)) {
32
+ throw new HostError('cwd-invalid', 'custom cwd must be an absolute path')
33
+ }
34
+ return input.cwd
35
+ }
@@ -0,0 +1,22 @@
1
+ /** Codes the metrics call emits. */
2
+
3
+ export const metricsCodes = ['metrics-unreadable'] as const
4
+
5
+ /** A metrics failure. Callers match `code`. */
6
+ export type MetricsCode = (typeof metricsCodes)[number]
7
+
8
+ /** Failure from `ctx.system.metrics`. The message is for a person. */
9
+ export class MetricsError extends Error {
10
+ readonly code: MetricsCode
11
+
12
+ /**
13
+ * @param code - one of {@link metricsCodes}
14
+ * @param message - human text; not the match key
15
+ * @param options - optional `cause`
16
+ */
17
+ constructor(code: MetricsCode, message: string, options?: { cause?: unknown }) {
18
+ super(message, options)
19
+ this.name = 'MetricsError'
20
+ this.code = code
21
+ }
22
+ }
@@ -0,0 +1,48 @@
1
+ import { arch, cpus, freemem, hostname, loadavg, platform, totalmem, uptime } from 'node:os'
2
+
3
+ import type { AppSystemMetrics } from '@mohou/contract'
4
+
5
+ import { MetricsError } from './codes.ts'
6
+
7
+ /**
8
+ * Read one OS snapshot. Windows does not provide a load average, so that field is null.
9
+ * A missing CPU list or a zero memory total throws. Zeros are not filled in.
10
+ */
11
+ function readLoadavg(osPlatform: NodeJS.Platform): AppSystemMetrics['loadavg'] {
12
+ if (osPlatform === 'win32') return null
13
+ const load = loadavg()
14
+ const one = load[0]
15
+ const five = load[1]
16
+ const fifteen = load[2]
17
+ if (one === undefined || five === undefined || fifteen === undefined) {
18
+ throw new MetricsError('metrics-unreadable', 'load average is unreadable')
19
+ }
20
+ return { '1m': one, '5m': five, '15m': fifteen }
21
+ }
22
+
23
+ export function readMetrics(now: Date = new Date(), osPlatform: NodeJS.Platform = platform()): AppSystemMetrics {
24
+ try {
25
+ const cpu = cpus()
26
+ const first = cpu[0]
27
+ const total = totalmem()
28
+ if (first === undefined || total === 0) {
29
+ throw new MetricsError('metrics-unreadable', 'OS snapshot is unreadable')
30
+ }
31
+ const free = freemem()
32
+ const used = total - free
33
+ const load = readLoadavg(osPlatform)
34
+ return {
35
+ platform: platform(),
36
+ arch: arch(),
37
+ hostname: hostname(),
38
+ uptimeSec: uptime(),
39
+ loadavg: load,
40
+ memory: { total, free, used, usedRatio: used / total },
41
+ cpu: { count: cpu.length, model: first.model, speedMHz: first.speed },
42
+ collectedAt: now.toISOString(),
43
+ }
44
+ } catch (error) {
45
+ if (error instanceof MetricsError) throw error
46
+ throw new MetricsError('metrics-unreadable', 'OS snapshot is unreadable', { cause: error })
47
+ }
48
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+
3
+ import type { AppSummary } from '../apps/registry.ts'
4
+ import type { HostEvent } from '../events/host-events.ts'
5
+ import { listHistory, readAppCommit, type HistoryNode } from '../history/store.ts'
6
+ import { storageDatabase } from '../storage/layout.ts'
7
+ import { openStorage } from '../storage/open.ts'
8
+
9
+ /** Owner table export cap. Host policy, not a locked number. */
10
+ export const DEFAULT_OWNER_ROW_CAP = 1_000
11
+
12
+ interface Registry {
13
+ get: (appId: string) => Promise<AppSummary>
14
+ }
15
+
16
+ /**
17
+ * Owner reads. Not authoring tools. Panel HTTP calls these through the loopback routes.
18
+ * @param registry - registered apps
19
+ */
20
+ export function createOwnerReads(registry: Registry, maxRows = DEFAULT_OWNER_ROW_CAP) {
21
+ return {
22
+ readStorage: (appId: string) => readStorage(registry, appId),
23
+ readTable: (appId: string, table: string) => readTable(registry, appId, table, maxRows),
24
+ readHistory: (appId: string) => readHistory(registry, appId),
25
+ readCommit: (appId: string, commitId: string) => readCommit(registry, appId, commitId),
26
+ }
27
+ }
28
+
29
+ async function readStorage(registry: Registry, appId: string): Promise<{ bytes: number; tables: string[] }> {
30
+ const app = await registry.get(appId)
31
+ const file = storageDatabase(app.directory)
32
+ if (!existsSync(file)) return { bytes: 0, tables: [] }
33
+ const storage = await openStorage(app.directory)
34
+ try {
35
+ return { bytes: statSync(file).size, tables: storage.listTables() }
36
+ } finally {
37
+ storage.close()
38
+ }
39
+ }
40
+
41
+ async function readTable(registry: Registry, appId: string, table: string, maxRows: number): Promise<{ rows: unknown[] }> {
42
+ const app = await registry.get(appId)
43
+ const file = storageDatabase(app.directory)
44
+ if (!existsSync(file)) return { rows: [] }
45
+ const storage = await openStorage(app.directory)
46
+ try {
47
+ return { rows: storage.exportTable(table, maxRows) }
48
+ } finally {
49
+ storage.close()
50
+ }
51
+ }
52
+
53
+ async function readHistory(registry: Registry, appId: string): Promise<HistoryNode[]> {
54
+ const app = await registry.get(appId)
55
+ const listed = await listHistory(app.directory)
56
+ const nodes = [...listed.nodes]
57
+ const seen = new Set(nodes.map(node => node.id))
58
+ for (const tip of listed.tips) {
59
+ if (seen.has(tip.commitId)) continue
60
+ const commit = await readAppCommit(app.directory, tip.commitId)
61
+ nodes.push({
62
+ id: tip.commitId,
63
+ message: commit.message,
64
+ time: commit.time,
65
+ parentIds: commit.parentIds,
66
+ })
67
+ seen.add(tip.commitId)
68
+ }
69
+ return nodes
70
+ }
71
+
72
+ async function readCommit(registry: Registry, appId: string, commitId: string) {
73
+ const app = await registry.get(appId)
74
+ return readAppCommit(app.directory, commitId)
75
+ }
76
+
77
+ /** Owner refetch. Does not compile. A missing app throws before any event. */
78
+ export async function reloadView(
79
+ registry: Registry,
80
+ publish: (event: HostEvent) => void,
81
+ appId: string,
82
+ ): Promise<void> {
83
+ const app = await registry.get(appId)
84
+ publish({ type: 'app:reload', appId: app.id })
85
+ }
@@ -0,0 +1,47 @@
1
+ import type { CommandHandle, CommandPolicy } from '../shell/command.ts'
2
+ import { createCommand } from '../shell/command.ts'
3
+ import { PwshError } from './codes.ts'
4
+
5
+ /** UTF-8 preamble. It stays on the first line so later error lines stay accurate. */
6
+ const ENCODING_PREAMBLE = '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [System.Text.UTF8Encoding]::new($false); '
7
+
8
+ /**
9
+ * Executables to try, in order. Windows falls back to Windows PowerShell.
10
+ * macOS and Linux only try `pwsh`. Bash is never a fallback.
11
+ * @param platform - `process.platform`
12
+ */
13
+ export function pwshCandidates(platform = process.platform): readonly string[] {
14
+ return platform === 'win32' ? ['pwsh', 'powershell.exe'] : ['pwsh']
15
+ }
16
+
17
+ /**
18
+ * Build `ctx.pwsh`. Each command is a fresh non-interactive PowerShell.
19
+ * It is not a translation of `ctx.bash`.
20
+ * @param policy - resolved host bounds
21
+ * @param callSignal - aborting it stops the child
22
+ * @param shell - executable name; tests inject a missing one
23
+ */
24
+ export function createPwsh(policy: CommandPolicy, callSignal?: AbortSignal, shell?: string): CommandHandle {
25
+ const candidates = shell === undefined ? [...pwshCandidates()] : [shell]
26
+ let index = 0
27
+ const handle = createCommand(
28
+ policy,
29
+ callSignal,
30
+ command => [candidates[index] ?? 'pwsh', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `${ENCODING_PREAMBLE}${command}`],
31
+ () => new PwshError('pwsh-unavailable', 'pwsh is not available'),
32
+ )
33
+ return {
34
+ async run(command) {
35
+ try {
36
+ return await handle.run(command)
37
+ } catch (error) {
38
+ if (error instanceof PwshError && index < candidates.length - 1) {
39
+ index += 1
40
+ return handle.run(command)
41
+ }
42
+ throw error
43
+ }
44
+ },
45
+ dispose: () => handle.dispose(),
46
+ }
47
+ }
@@ -0,0 +1,21 @@
1
+ /** Codes the PowerShell call emits. Timeout and output cap are not codes. */
2
+
3
+ export const pwshCodes = ['pwsh-unavailable'] as const
4
+
5
+ /** A PowerShell failure. Callers match `code`. */
6
+ export type PwshCode = (typeof pwshCodes)[number]
7
+
8
+ /** Failure from `ctx.pwsh`. The message is for a person. */
9
+ export class PwshError extends Error {
10
+ readonly code: PwshCode
11
+
12
+ /**
13
+ * @param code - one of {@link pwshCodes}
14
+ * @param message - human text; not the match key
15
+ */
16
+ constructor(code: PwshCode, message: string) {
17
+ super(message)
18
+ this.name = 'PwshError'
19
+ this.code = code
20
+ }
21
+ }
@@ -0,0 +1,172 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process'
2
+
3
+ /** Injected bounds. The numbers are host policy and are not locked here. */
4
+ export interface CommandPolicy {
5
+ readonly timeoutMs: number
6
+ readonly maxOutputBytes: number
7
+ }
8
+
9
+ /** One command result. A non-zero exit is not a failure code. */
10
+ export interface CommandResult {
11
+ stdout: string
12
+ stderr: string
13
+ exitCode: number
14
+ }
15
+
16
+ /** A live shell binding. `dispose` waits until every child has exited. */
17
+ export interface CommandHandle {
18
+ run(command: string): Promise<CommandResult>
19
+ dispose(): Promise<void>
20
+ }
21
+
22
+ /** Result used when host policy stops the child. Not a failure code. */
23
+ const STOPPED = 124
24
+
25
+ /** Drop credential-shaped names. Match is case-insensitive. */
26
+ const SENSITIVE_ENV = /KEY|SECRET|TOKEN|PASSWORD/i
27
+
28
+ /** Parent environment minus key, secret, token, and password entries. */
29
+ export function scrubShellEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
30
+ const next: Record<string, string> = {}
31
+ for (const [key, value] of Object.entries(env)) {
32
+ if (value !== undefined && !SENSITIVE_ENV.test(key)) next[key] = value
33
+ }
34
+ return next
35
+ }
36
+
37
+ /**
38
+ * Spawn one command. `argv` is the full executable plus arguments.
39
+ * Stopping a child uses `kill()` with no signal so Windows can terminate it.
40
+ * @param policy - resolved host bounds
41
+ * @param callSignal - aborting it stops the child
42
+ * @param argv - executable and arguments for this command
43
+ * @param unavailable - error when the executable is missing
44
+ */
45
+ export function createCommand(
46
+ policy: CommandPolicy,
47
+ callSignal: AbortSignal | undefined,
48
+ argv: (command: string) => string[],
49
+ unavailable: () => Error,
50
+ ): CommandHandle {
51
+ const children = new Set<ChildProcess>()
52
+ return {
53
+ run: command => run(policy, callSignal, argv(command), children, unavailable),
54
+ dispose: () => dispose(children),
55
+ }
56
+ }
57
+
58
+ async function run(
59
+ policy: CommandPolicy,
60
+ callSignal: AbortSignal | undefined,
61
+ argv: string[],
62
+ children: Set<ChildProcess>,
63
+ unavailable: () => Error,
64
+ ): Promise<CommandResult> {
65
+ if (callSignal?.aborted) return { stdout: '', stderr: '', exitCode: STOPPED }
66
+ const [executable, ...args] = argv
67
+ if (executable === undefined) throw unavailable()
68
+ const child = spawn(executable, args, {
69
+ env: scrubShellEnv(),
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ // A new process group on POSIX so stop() can signal descendants. Windows uses taskkill /T.
72
+ detached: process.platform !== 'win32',
73
+ })
74
+ children.add(child)
75
+ const stdout = collect(child.stdout, policy.maxOutputBytes, () => {
76
+ stop(child)
77
+ })
78
+ const stderr = collect(child.stderr, policy.maxOutputBytes, () => {
79
+ stop(child)
80
+ })
81
+ const timer = setTimeout(() => {
82
+ stop(child)
83
+ }, policy.timeoutMs)
84
+ const onAbort = () => {
85
+ stop(child)
86
+ }
87
+ callSignal?.addEventListener('abort', onAbort, { once: true })
88
+ try {
89
+ const exitCode = await exited(child, unavailable)
90
+ return {
91
+ stdout: await stdout,
92
+ stderr: await stderr,
93
+ exitCode: exitCode ?? STOPPED,
94
+ }
95
+ } finally {
96
+ clearTimeout(timer)
97
+ callSignal?.removeEventListener('abort', onAbort)
98
+ children.delete(child)
99
+ }
100
+ }
101
+
102
+ function collect(stream: NodeJS.ReadableStream | null, maxBytes: number, onCap: () => void): Promise<string> {
103
+ if (stream === null) return Promise.resolve('')
104
+ const chunks: Buffer[] = []
105
+ let total = 0
106
+ return new Promise((resolve) => {
107
+ stream.on('data', (chunk: Buffer) => {
108
+ const remaining = maxBytes - total
109
+ if (remaining <= 0) {
110
+ onCap()
111
+ return
112
+ }
113
+ const slice = chunk.subarray(0, remaining)
114
+ chunks.push(slice)
115
+ total += slice.byteLength
116
+ if (chunk.byteLength > remaining) onCap()
117
+ })
118
+ stream.on('end', () => {
119
+ resolve(Buffer.concat(chunks).toString('utf8'))
120
+ })
121
+ stream.on('error', () => {
122
+ resolve(Buffer.concat(chunks).toString('utf8'))
123
+ })
124
+ })
125
+ }
126
+
127
+ export function stopChild(
128
+ child: ChildProcess,
129
+ platform = process.platform,
130
+ killTree: (pid: number) => void = (pid) => {
131
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
132
+ },
133
+ ): void {
134
+ if (child.exitCode !== null || child.signalCode !== null) return
135
+ const pid = child.pid
136
+ if (pid === undefined) return
137
+ if (platform === 'win32') {
138
+ killTree(pid)
139
+ return
140
+ }
141
+ try {
142
+ process.kill(-pid, 'SIGTERM')
143
+ } catch {
144
+ child.kill()
145
+ }
146
+ }
147
+
148
+ function stop(child: ChildProcess): void {
149
+ stopChild(child)
150
+ }
151
+
152
+ function exited(child: ChildProcess, unavailable: () => Error): Promise<number | null> {
153
+ if (child.exitCode !== null) return Promise.resolve(child.exitCode)
154
+ return new Promise((resolve, reject) => {
155
+ child.once('error', (error: NodeJS.ErrnoException) => {
156
+ if (error.code === 'ENOENT') {
157
+ reject(unavailable())
158
+ return
159
+ }
160
+ reject(error)
161
+ })
162
+ child.once('exit', (code) => {
163
+ resolve(code)
164
+ })
165
+ })
166
+ }
167
+
168
+ async function dispose(children: Set<ChildProcess>): Promise<void> {
169
+ const pending = [...children]
170
+ for (const child of pending) stop(child)
171
+ await Promise.all(pending.map(child => exited(child, () => new Error('shell is not available')).catch(() => null)))
172
+ }
@@ -0,0 +1,33 @@
1
+ /** Codes the storage engine emits. */
2
+
3
+ export const storageCodes = [
4
+ 'storage-not-json',
5
+ 'storage-corrupt',
6
+ 'storage-forbidden',
7
+ 'storage-sql',
8
+ 'storage-statement',
9
+ 'storage-too-large',
10
+ 'storage-version',
11
+ 'storage-backup-too-large',
12
+ 'storage-migration',
13
+ 'cancelled',
14
+ ] as const
15
+
16
+ /** A storage failure. Callers match `code`. */
17
+ export type StorageCode = (typeof storageCodes)[number]
18
+
19
+ /** Failure from the storage engine. The message is for a person. */
20
+ export class StorageError extends Error {
21
+ readonly code: StorageCode
22
+
23
+ /**
24
+ * @param code - one of {@link storageCodes}
25
+ * @param message - human text; not the match key
26
+ * @param options - optional `cause`
27
+ */
28
+ constructor(code: StorageCode, message: string, options?: { cause?: unknown }) {
29
+ super(message, options)
30
+ this.name = 'StorageError'
31
+ this.code = code
32
+ }
33
+ }
@@ -0,0 +1,44 @@
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * On-disk names for one app's storage. Product code asks this module for a path.
5
+ * Do not spell the directory or the database file anywhere else.
6
+ */
7
+ export const storageLayout = {
8
+ dir: 'storage',
9
+ database: 'app.sqlite',
10
+ backup: 'backup.sqlite',
11
+ backupMeta: 'backup.json',
12
+ blocked: 'blocked.json',
13
+ quarantineSuffix: '.quarantine',
14
+ } as const
15
+
16
+ /** The storage directory. Listing and history skip this name. */
17
+ export function storageDir(appDir: string): string {
18
+ return path.join(appDir, storageLayout.dir)
19
+ }
20
+
21
+ /** The live database file. */
22
+ export function storageDatabase(appDir: string): string {
23
+ return path.join(storageDir(appDir), storageLayout.database)
24
+ }
25
+
26
+ /** The one pre-migration snapshot. */
27
+ export function storageBackup(appDir: string): string {
28
+ return path.join(storageDir(appDir), storageLayout.backup)
29
+ }
30
+
31
+ /** Metadata for {@link storageBackup}. */
32
+ export function storageBackupMeta(appDir: string): string {
33
+ return path.join(storageDir(appDir), storageLayout.backupMeta)
34
+ }
35
+
36
+ /** Checksums a restore must not apply again. */
37
+ export function storageBlocked(appDir: string): string {
38
+ return path.join(storageDir(appDir), storageLayout.blocked)
39
+ }
40
+
41
+ /** Marker left beside a quarantined database. A later open does not replace the file. */
42
+ export function storageQuarantine(appDir: string): string {
43
+ return `${storageDatabase(appDir)}${storageLayout.quarantineSuffix}`
44
+ }