@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.
- package/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- 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
|
+
}
|