@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
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: locked
|
|
3
|
+
updated: 2026-10-01
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @mohou/host
|
|
7
|
+
|
|
8
|
+
Role: `provider`.
|
|
9
|
+
|
|
10
|
+
The call loop: build one `AppContext` from injected ports, run one method, dispose the call. Binding the brain is one part of that loop. Storage is the SQLite file beside that loop. Host also compiles the app and serves the loopback surface. It does not embed a model vendor. Product: [packages](../../../docs/architecture/packages.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mohou/host",
|
|
3
|
+
"version": "1.0.20",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"themes",
|
|
15
|
+
"lib/types",
|
|
16
|
+
"README.md",
|
|
17
|
+
"!**/*.tsbuildinfo"
|
|
18
|
+
],
|
|
19
|
+
"main": "./src/index.ts",
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./src/index.ts",
|
|
24
|
+
"default": "./src/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"./http": {
|
|
27
|
+
"types": "./src/http/layout.ts",
|
|
28
|
+
"default": "./src/http/layout.ts"
|
|
29
|
+
},
|
|
30
|
+
"./diagnostics": {
|
|
31
|
+
"types": "./src/tools/diagnostics.ts",
|
|
32
|
+
"default": "./src/tools/diagnostics.ts"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@hono/node-server": "^2.1.1",
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
39
|
+
"@tailwindcss/node": "^4.3.3",
|
|
40
|
+
"@tailwindcss/oxide": "^4.3.3",
|
|
41
|
+
"better-sqlite3": "^13.0.3",
|
|
42
|
+
"esbuild": "^0.28.2",
|
|
43
|
+
"hono": "^4.13.8",
|
|
44
|
+
"isomorphic-git": "^1.42.2",
|
|
45
|
+
"lodash": "^4.18.1",
|
|
46
|
+
"lodash-es": "^4.18.1",
|
|
47
|
+
"motion": "^12.23.12",
|
|
48
|
+
"react": "^19.2.0",
|
|
49
|
+
"react-dom": "^19.2.0",
|
|
50
|
+
"tailwindcss": "^4.3.3",
|
|
51
|
+
"tw-animate-css": "^1.4.0",
|
|
52
|
+
"@mohou/app-view": "^1.0.20",
|
|
53
|
+
"@mohou/contract": "^1.0.20",
|
|
54
|
+
"@mohou/runtime-provider": "^1.0.20",
|
|
55
|
+
"@mohou/ui": "^1.0.20",
|
|
56
|
+
"@mohou/mcp-client": "^1.0.20"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
60
|
+
"@types/react": "^19.2.0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build:vendor": "node --experimental-strip-types src/compile/build-vendor-cli.ts"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-character gallery badge. A manifest acronym wins. Latin letters are
|
|
3
|
+
* uppercased. Otherwise the first two letters or digits of the name. If the
|
|
4
|
+
* name has fewer, the first two characters.
|
|
5
|
+
* @param name - manifest name, already non-empty
|
|
6
|
+
* @param acronym - author acronym, already two letters or digits when present
|
|
7
|
+
*/
|
|
8
|
+
export function monogram(name: string, acronym?: string): string {
|
|
9
|
+
if (acronym !== undefined) {
|
|
10
|
+
return /^[A-Za-z]{2}$/.test(acronym) ? acronym.toUpperCase() : acronym
|
|
11
|
+
}
|
|
12
|
+
const letters = name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase()
|
|
13
|
+
if (letters.length === 2) return letters
|
|
14
|
+
return name.trim().slice(0, 2).toUpperCase()
|
|
15
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { ContractError, appEntries, parseAppId, resolveManifest, type AppId, type AppListItem } from '@mohou/contract'
|
|
6
|
+
|
|
7
|
+
import { resolveAppPath } from '../files/tools.ts'
|
|
8
|
+
import { hostAppsDir, hostLayout, hostTrashDir } from '../host/layout.ts'
|
|
9
|
+
import { monogram } from './monogram.ts'
|
|
10
|
+
|
|
11
|
+
/** Registry failures that are not definition failures. */
|
|
12
|
+
export class RegistryError extends Error {
|
|
13
|
+
readonly code: 'app-duplicate' | 'app-not-registered' | 'app-not-trashed'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param code - `app-duplicate`, `app-not-registered`, or `app-not-trashed`
|
|
17
|
+
* @param message - human text; not the match key
|
|
18
|
+
*/
|
|
19
|
+
constructor(code: 'app-duplicate' | 'app-not-registered' | 'app-not-trashed', message: string) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.name = 'RegistryError'
|
|
22
|
+
this.code = code
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AppSummary {
|
|
27
|
+
id: AppId
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
version: string
|
|
31
|
+
/** Gallery badge. Author acronym, or a monogram derived from `name`. */
|
|
32
|
+
acronym: string
|
|
33
|
+
/** Manifest tags. Omitted when the author set none. */
|
|
34
|
+
tags?: readonly string[]
|
|
35
|
+
/** Manifest kind. Omitted for an ordinary app. */
|
|
36
|
+
kind?: 'workbench'
|
|
37
|
+
directory: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Owner list row. Directory stays off this object. Times are omitted when history has no commit. */
|
|
41
|
+
export function listedApp(
|
|
42
|
+
app: AppSummary,
|
|
43
|
+
times: { readonly createdAt?: string; readonly updatedAt?: string } = {},
|
|
44
|
+
activity?: { readonly openCount: number; readonly lastOpenedAt: string },
|
|
45
|
+
): ListedApp {
|
|
46
|
+
return {
|
|
47
|
+
id: app.id,
|
|
48
|
+
name: app.name,
|
|
49
|
+
description: app.description,
|
|
50
|
+
version: app.version,
|
|
51
|
+
acronym: app.acronym,
|
|
52
|
+
...app.tags === undefined ? {} : { tags: app.tags },
|
|
53
|
+
...app.kind === undefined ? {} : { kind: app.kind },
|
|
54
|
+
...times.createdAt === undefined ? {} : { createdAt: times.createdAt },
|
|
55
|
+
...times.updatedAt === undefined ? {} : { updatedAt: times.updatedAt },
|
|
56
|
+
...activity === undefined ? {} : { activity },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ListedApp = AppListItem
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Apps on disk under `runtimeRoot/apps`. Register does not overwrite.
|
|
64
|
+
* @param runtimeRoot - host runtime root; not a locked path
|
|
65
|
+
*/
|
|
66
|
+
export function createAppRegistry(runtimeRoot: string) {
|
|
67
|
+
const appsDir = hostAppsDir(runtimeRoot)
|
|
68
|
+
return {
|
|
69
|
+
runtimeRoot,
|
|
70
|
+
list: () => listApps(appsDir, runtimeRoot),
|
|
71
|
+
get: (appId: string) => getApp(appsDir, appId),
|
|
72
|
+
register: (appId: string, files: Record<string, string>) => registerApp(appsDir, appId, files),
|
|
73
|
+
trash: (appId: string) => trashApp(appsDir, runtimeRoot, appId),
|
|
74
|
+
restoreTrashed: (appId: string) => restoreTrashed(appsDir, runtimeRoot, appId),
|
|
75
|
+
listTrash: () => listTrash(runtimeRoot),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function listApps(appsDir: string, runtimeRoot: string): Promise<{ apps: AppSummary[]; runtimeRoot: string }> {
|
|
80
|
+
let names: string[] = []
|
|
81
|
+
try {
|
|
82
|
+
names = await readdir(appsDir)
|
|
83
|
+
} catch {
|
|
84
|
+
return { apps: [], runtimeRoot }
|
|
85
|
+
}
|
|
86
|
+
const apps: AppSummary[] = []
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
const summary = await readSummary(path.join(appsDir, name), name)
|
|
89
|
+
if (summary !== undefined) apps.push(summary)
|
|
90
|
+
}
|
|
91
|
+
return { apps, runtimeRoot }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getApp(appsDir: string, appId: string): Promise<AppSummary> {
|
|
95
|
+
const id = admitId(appId)
|
|
96
|
+
const summary = await readSummary(path.join(appsDir, id), id)
|
|
97
|
+
if (summary === undefined) throw new RegistryError('app-not-registered', `app is not registered: ${appId}`)
|
|
98
|
+
return summary
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function registerApp(appsDir: string, appId: string, files: Record<string, string>): Promise<AppSummary> {
|
|
102
|
+
const id = admitId(appId)
|
|
103
|
+
const finalDir = path.join(appsDir, id)
|
|
104
|
+
if (await exists(finalDir)) throw new RegistryError('app-duplicate', `app already exists: ${id}`)
|
|
105
|
+
const staging = path.join(appsDir, `.registering-${id}`)
|
|
106
|
+
await rm(staging, { recursive: true, force: true })
|
|
107
|
+
await mkdir(staging, { recursive: true })
|
|
108
|
+
try {
|
|
109
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
110
|
+
const full = resolveAppPath(staging, relative)
|
|
111
|
+
await mkdir(path.dirname(full), { recursive: true })
|
|
112
|
+
await writeFile(full, content)
|
|
113
|
+
}
|
|
114
|
+
const manifest = await readManifest(staging, id)
|
|
115
|
+
await rename(staging, finalDir)
|
|
116
|
+
return summaryOf(manifest, finalDir)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
await rm(staging, { recursive: true, force: true })
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function trashApp(appsDir: string, runtimeRoot: string, appId: string): Promise<void> {
|
|
124
|
+
const id = admitId(appId)
|
|
125
|
+
const from = path.join(appsDir, id)
|
|
126
|
+
if (!(await exists(from))) throw new RegistryError('app-not-registered', `app is not registered: ${id}`)
|
|
127
|
+
const trash = hostTrashDir(runtimeRoot)
|
|
128
|
+
await mkdir(trash, { recursive: true })
|
|
129
|
+
const stamp = `${String(Date.now()).padStart(16, '0')}-${randomBytes(2).toString('hex')}`
|
|
130
|
+
await rename(from, path.join(trash, `${id}${hostLayout.trashSep}${stamp}`))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function restoreTrashed(appsDir: string, runtimeRoot: string, appId: string): Promise<AppSummary> {
|
|
134
|
+
const id = admitId(appId)
|
|
135
|
+
const live = path.join(appsDir, id)
|
|
136
|
+
if (await exists(live)) throw new RegistryError('app-duplicate', `app already exists: ${id}`)
|
|
137
|
+
const latest = await newestTrash(runtimeRoot, id)
|
|
138
|
+
if (latest === undefined) throw new RegistryError('app-not-trashed', `app is not in trash: ${id}`)
|
|
139
|
+
await rename(latest, live)
|
|
140
|
+
const summary = await readSummary(live, id)
|
|
141
|
+
if (summary === undefined) throw new RegistryError('app-not-trashed', `app is not in trash: ${id}`)
|
|
142
|
+
return summary
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function listTrash(runtimeRoot: string): Promise<AppSummary[]> {
|
|
146
|
+
const trash = hostTrashDir(runtimeRoot)
|
|
147
|
+
let names: string[] = []
|
|
148
|
+
try {
|
|
149
|
+
names = await readdir(trash)
|
|
150
|
+
} catch {
|
|
151
|
+
return []
|
|
152
|
+
}
|
|
153
|
+
const newest = new Map<string, string>()
|
|
154
|
+
for (const name of names) {
|
|
155
|
+
const split = splitTrashName(name)
|
|
156
|
+
if (split === undefined) continue
|
|
157
|
+
const current = newest.get(split.id)
|
|
158
|
+
if (current === undefined || split.stamp > current) newest.set(split.id, split.stamp)
|
|
159
|
+
}
|
|
160
|
+
const apps: AppSummary[] = []
|
|
161
|
+
for (const [id, stamp] of newest) {
|
|
162
|
+
const summary = await readSummary(path.join(trash, `${id}${hostLayout.trashSep}${stamp}`), id).catch(() => undefined)
|
|
163
|
+
if (summary !== undefined) apps.push(summary)
|
|
164
|
+
}
|
|
165
|
+
return apps.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function splitTrashName(name: string): { id: string; stamp: string } | undefined {
|
|
169
|
+
const at = name.indexOf(hostLayout.trashSep)
|
|
170
|
+
if (at <= 0) return undefined
|
|
171
|
+
const id = name.slice(0, at)
|
|
172
|
+
const stamp = name.slice(at + hostLayout.trashSep.length)
|
|
173
|
+
if (stamp === '') return undefined
|
|
174
|
+
try {
|
|
175
|
+
parseAppId(id)
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
return { id, stamp }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function newestTrash(runtimeRoot: string, id: string): Promise<string | undefined> {
|
|
183
|
+
const trash = hostTrashDir(runtimeRoot)
|
|
184
|
+
let names: string[] = []
|
|
185
|
+
try {
|
|
186
|
+
names = await readdir(trash)
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
const prefix = `${id}${hostLayout.trashSep}`
|
|
191
|
+
const matches = names.filter(name => name.startsWith(prefix)).sort()
|
|
192
|
+
const latest = matches.at(-1)
|
|
193
|
+
return latest === undefined ? undefined : path.join(trash, latest)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function admitId(appId: string): AppId {
|
|
197
|
+
try {
|
|
198
|
+
return parseAppId(appId)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new ContractError('app-id-invalid', `app id is not reverse-DNS: ${appId}`, { cause: error })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function readSummary(directory: string, directoryName: string): Promise<AppSummary | undefined> {
|
|
205
|
+
const manifest = await readManifest(directory, directoryName).catch(() => undefined)
|
|
206
|
+
if (manifest === undefined) return undefined
|
|
207
|
+
return summaryOf(manifest, directory)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function readManifest(directory: string, directoryName: string) {
|
|
211
|
+
const text = await readFile(path.join(directory, appEntries.manifest), 'utf8')
|
|
212
|
+
return resolveManifest(JSON.parse(text) as unknown, directoryName)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summaryOf(
|
|
216
|
+
manifest: { id: AppId; name: string; description: string; version: string; acronym?: string; tags?: readonly string[]; kind?: 'workbench' },
|
|
217
|
+
directory: string,
|
|
218
|
+
): AppSummary {
|
|
219
|
+
return {
|
|
220
|
+
id: manifest.id,
|
|
221
|
+
name: manifest.name,
|
|
222
|
+
description: manifest.description,
|
|
223
|
+
version: manifest.version,
|
|
224
|
+
acronym: monogram(manifest.name, manifest.acronym),
|
|
225
|
+
directory,
|
|
226
|
+
...manifest.tags === undefined ? {} : { tags: manifest.tags },
|
|
227
|
+
...manifest.kind === undefined ? {} : { kind: manifest.kind },
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function exists(directory: string): Promise<boolean> {
|
|
232
|
+
try {
|
|
233
|
+
await readdir(directory)
|
|
234
|
+
return true
|
|
235
|
+
} catch {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { access, cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { hostAppsDir, hostLayout, hostTrashDir } from '../host/layout.ts'
|
|
5
|
+
|
|
6
|
+
/** Once per runtime root. Deleting samples must not re-seed on every boot. */
|
|
7
|
+
export const startupSeedMarker = '.startup-seed'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Skill facades copied into an empty library.
|
|
11
|
+
* `today` = personal home; `board` = second card so glass featured layout has company.
|
|
12
|
+
*/
|
|
13
|
+
export const startupSamples = [
|
|
14
|
+
{ template: 'today', appId: 'com.mohou.today', fallbackName: '今日待办' },
|
|
15
|
+
{ template: 'board', appId: 'com.mohou.board', fallbackName: 'Board' },
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
export type StartupSeedResult =
|
|
19
|
+
| { readonly kind: 'seeded'; readonly appIds: readonly string[] }
|
|
20
|
+
| { readonly kind: 'skipped'; readonly reason: 'marked' | 'has-apps' | 'no-template' }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ensure `apps/` and `trash/` exist. On a fresh runtime (no marker, no apps),
|
|
24
|
+
* copy skill startup facades so the library shows glass cards immediately.
|
|
25
|
+
* @param runtimeRoot - host runtime root
|
|
26
|
+
* @param skillSource - author skill directory that contains `templates/`
|
|
27
|
+
*/
|
|
28
|
+
export async function ensureRuntimeAppsLayout(
|
|
29
|
+
runtimeRoot: string,
|
|
30
|
+
skillSource?: string,
|
|
31
|
+
): Promise<StartupSeedResult> {
|
|
32
|
+
const appsDir = hostAppsDir(runtimeRoot)
|
|
33
|
+
const trashDir = hostTrashDir(runtimeRoot)
|
|
34
|
+
await mkdir(appsDir, { recursive: true })
|
|
35
|
+
await mkdir(trashDir, { recursive: true })
|
|
36
|
+
|
|
37
|
+
const marker = path.join(runtimeRoot, startupSeedMarker)
|
|
38
|
+
if (await present(marker)) return { kind: 'skipped', reason: 'marked' }
|
|
39
|
+
|
|
40
|
+
const existing = await listAppDirs(appsDir)
|
|
41
|
+
if (existing.length > 0) {
|
|
42
|
+
await writeFile(marker, `${JSON.stringify({ seeded: false, reason: 'has-apps' }, null, 2)}\n`)
|
|
43
|
+
return { kind: 'skipped', reason: 'has-apps' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (skillSource === undefined || skillSource.length === 0) {
|
|
47
|
+
await writeFile(marker, `${JSON.stringify({ seeded: false, reason: 'no-template' }, null, 2)}\n`)
|
|
48
|
+
return { kind: 'skipped', reason: 'no-template' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const appIds: string[] = []
|
|
52
|
+
for (const sample of startupSamples) {
|
|
53
|
+
const templateDir = path.join(skillSource, 'templates', sample.template)
|
|
54
|
+
if (!(await present(path.join(templateDir, 'manifest.json')))) continue
|
|
55
|
+
const dest = path.join(appsDir, sample.appId)
|
|
56
|
+
await cp(templateDir, dest, { recursive: true })
|
|
57
|
+
await rewriteManifestId(path.join(dest, 'manifest.json'), sample.appId, sample.fallbackName)
|
|
58
|
+
appIds.push(sample.appId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (appIds.length === 0) {
|
|
62
|
+
await writeFile(marker, `${JSON.stringify({ seeded: false, reason: 'no-template' }, null, 2)}\n`)
|
|
63
|
+
return { kind: 'skipped', reason: 'no-template' }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await writeFile(marker, `${JSON.stringify({ seeded: true, appIds, samples: startupSamples }, null, 2)}\n`)
|
|
67
|
+
return { kind: 'seeded', appIds }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function rewriteManifestId(file: string, appId: string, fallbackName: string): Promise<void> {
|
|
71
|
+
const raw = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>
|
|
72
|
+
raw.id = appId
|
|
73
|
+
if (typeof raw.name !== 'string' || raw.name.length === 0) raw.name = fallbackName
|
|
74
|
+
await writeFile(file, `${JSON.stringify(raw, null, 2)}\n`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function listAppDirs(appsDir: string): Promise<string[]> {
|
|
78
|
+
const names = await readdir(appsDir).catch(() => [] as string[])
|
|
79
|
+
const out: string[] = []
|
|
80
|
+
for (const name of names) {
|
|
81
|
+
if (name.startsWith('.')) continue
|
|
82
|
+
if (name === hostLayout.logs) continue
|
|
83
|
+
if (await present(path.join(appsDir, name, 'manifest.json'))) out.push(name)
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function present(file: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
await access(file)
|
|
91
|
+
return true
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CommandHandle, CommandPolicy, CommandResult } from '../shell/command.ts'
|
|
2
|
+
import { createCommand, scrubShellEnv } from '../shell/command.ts'
|
|
3
|
+
import { BashError } from './codes.ts'
|
|
4
|
+
|
|
5
|
+
export type { CommandPolicy as BashPolicy, CommandResult as BashResult, CommandHandle as BashHandle }
|
|
6
|
+
|
|
7
|
+
/** Parent environment minus key, secret, token, and password entries. */
|
|
8
|
+
export function scrubBashEnv(env: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
9
|
+
return scrubShellEnv(env)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build `ctx.bash`. Each command is a fresh `bash -c`.
|
|
14
|
+
* Bash is not translated into PowerShell. A Windows machine needs `bash` on `PATH`.
|
|
15
|
+
* @param policy - resolved host bounds
|
|
16
|
+
* @param callSignal - aborting it stops the child
|
|
17
|
+
* @param shell - executable name; tests inject a missing one
|
|
18
|
+
*/
|
|
19
|
+
export function createBash(policy: CommandPolicy, callSignal?: AbortSignal, shell = 'bash'): CommandHandle {
|
|
20
|
+
return createCommand(policy, callSignal, command => [shell, '-c', command], () => new BashError('bash-unavailable', 'bash is not available'))
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Codes the bash call emits. Timeout and output cap are not codes. */
|
|
2
|
+
|
|
3
|
+
export const bashCodes = ['bash-unavailable'] as const
|
|
4
|
+
|
|
5
|
+
/** A bash failure. Callers match `code`. */
|
|
6
|
+
export type BashCode = (typeof bashCodes)[number]
|
|
7
|
+
|
|
8
|
+
/** Failure from `ctx.bash`. The message is for a person. */
|
|
9
|
+
export class BashError extends Error {
|
|
10
|
+
readonly code: BashCode
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param code - one of {@link bashCodes}
|
|
14
|
+
* @param message - human text; not the match key
|
|
15
|
+
*/
|
|
16
|
+
constructor(code: BashCode, message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'BashError'
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** One platform-module table. The UI compiler, the backend loader, and the iframe import map all read it. */
|
|
2
|
+
|
|
3
|
+
export type PlatformSide = 'ui' | 'backend' | 'both'
|
|
4
|
+
|
|
5
|
+
/** Served iframe file names. Spell them here, not at each call site. */
|
|
6
|
+
export const platformLayout = {
|
|
7
|
+
root: '/mma',
|
|
8
|
+
runtime: 'runtime.js',
|
|
9
|
+
sdk: 'sdk.js',
|
|
10
|
+
vendors: 'vendors',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export interface PlatformModule {
|
|
14
|
+
specifier: string
|
|
15
|
+
side: PlatformSide
|
|
16
|
+
file?: string
|
|
17
|
+
/** Compile this pattern into the app. A `/*` row is not a served file. */
|
|
18
|
+
bundle?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const runtimeFile = platformRuntimePath()
|
|
22
|
+
const lodashFile = platformVendorPath('lodash')
|
|
23
|
+
const motionFile = platformVendorPath('motion')
|
|
24
|
+
|
|
25
|
+
export const platformModules: readonly PlatformModule[] = [
|
|
26
|
+
{ specifier: '@mohou/contract', side: 'backend' },
|
|
27
|
+
{ specifier: 'react', side: 'ui', file: runtimeFile },
|
|
28
|
+
{ specifier: 'react/jsx-runtime', side: 'ui', file: runtimeFile },
|
|
29
|
+
{ specifier: 'react-dom', side: 'ui', file: runtimeFile },
|
|
30
|
+
{ specifier: 'react-dom/client', side: 'ui', file: runtimeFile },
|
|
31
|
+
{ specifier: '@mohou/ui', side: 'ui', file: platformSdkPath() },
|
|
32
|
+
{ specifier: 'lodash', side: 'both', file: lodashFile },
|
|
33
|
+
{ specifier: 'lodash-es', side: 'both', file: lodashFile },
|
|
34
|
+
{ specifier: 'lodash/*', side: 'both', bundle: true },
|
|
35
|
+
{ specifier: 'lodash-es/*', side: 'both', bundle: true },
|
|
36
|
+
{ specifier: 'motion', side: 'ui', file: motionFile },
|
|
37
|
+
{ specifier: 'motion/react', side: 'ui', file: motionFile },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Whether this side may import the specifier. An exact row wins over a pattern. */
|
|
41
|
+
export function platformModuleAllowed(specifier: string, side: 'ui' | 'backend'): boolean {
|
|
42
|
+
const row = platformRow(specifier)
|
|
43
|
+
if (row === undefined) return false
|
|
44
|
+
return row.side === 'both' || row.side === side
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A pattern row with `bundle` is compiled into the app. The root row stays the served file. */
|
|
48
|
+
export function platformBundled(specifier: string): boolean {
|
|
49
|
+
return platformRow(specifier)?.bundle === true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function platformRow(specifier: string): PlatformModule | undefined {
|
|
53
|
+
const exact = platformModules.find(row => row.specifier === specifier)
|
|
54
|
+
if (exact !== undefined) return exact
|
|
55
|
+
return platformModules.find(row => matchesPattern(row.specifier, specifier))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A trailing `/*` matches that prefix plus at least one character. */
|
|
59
|
+
function matchesPattern(pattern: string, specifier: string): boolean {
|
|
60
|
+
if (!pattern.endsWith('/*')) return false
|
|
61
|
+
const prefix = pattern.slice(0, -1)
|
|
62
|
+
return specifier.startsWith(prefix) && specifier.length > prefix.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Import map for the iframe. Only rows with a served file. */
|
|
66
|
+
export function platformImportMap(): Readonly<Record<string, string>> {
|
|
67
|
+
const imports: Record<string, string> = {}
|
|
68
|
+
for (const row of platformModules) {
|
|
69
|
+
if (row.file === undefined || row.side === 'backend' || row.specifier.includes('*')) continue
|
|
70
|
+
imports[row.specifier] = row.file
|
|
71
|
+
}
|
|
72
|
+
return imports
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function platformRuntimePath(): string {
|
|
76
|
+
return joinPlatform(platformLayout.runtime)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function platformSdkPath(): string {
|
|
80
|
+
return joinPlatform(platformLayout.sdk)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function platformVendorPath(id: string): string {
|
|
84
|
+
return joinPlatform(`${platformLayout.vendors}/${id}.js`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function joinPlatform(name: string): string {
|
|
88
|
+
return `${platformLayout.root}/${name}`
|
|
89
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Codes `resolveAssetUrl` throws in the iframe. Callers match `code`. */
|
|
2
|
+
export const assetInvalidCode = 'asset-invalid' as const
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Admit an author path relative to the app directory.
|
|
6
|
+
* Returns the path inside `assets/`. Throws `asset-invalid` otherwise.
|
|
7
|
+
* Self-contained so the runner can inline it.
|
|
8
|
+
*/
|
|
9
|
+
export function admitAssetRef(raw: string): string {
|
|
10
|
+
function fail(): never {
|
|
11
|
+
const error = new Error('asset path is invalid') as Error & { code: string }
|
|
12
|
+
error.code = 'asset-invalid'
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
if (typeof raw !== 'string' || raw.length === 0) fail()
|
|
16
|
+
const slash = raw.replace(/\\/g, '/')
|
|
17
|
+
if (slash.startsWith('/') || /^[A-Za-z]:\//.test(slash)) fail()
|
|
18
|
+
const fromRoot = slash.startsWith('./') ? slash.slice(2) : slash
|
|
19
|
+
if (!fromRoot.startsWith('assets/')) fail()
|
|
20
|
+
const inner = fromRoot.slice('assets/'.length)
|
|
21
|
+
if (inner.length === 0 || inner.startsWith('/') || inner.startsWith('./') || inner.includes('..') || inner.includes('//')) fail()
|
|
22
|
+
const parts = inner.split('/')
|
|
23
|
+
const segment = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
24
|
+
for (const part of parts) {
|
|
25
|
+
if (!segment.test(part)) fail()
|
|
26
|
+
}
|
|
27
|
+
const last = parts[parts.length - 1] ?? ''
|
|
28
|
+
const dot = last.lastIndexOf('.')
|
|
29
|
+
if (dot <= 0) fail()
|
|
30
|
+
const ext = last.slice(dot + 1).toLowerCase()
|
|
31
|
+
if (ext !== 'png' && ext !== 'jpg' && ext !== 'jpeg' && ext !== 'gif' && ext !== 'webp' && ext !== 'svg') fail()
|
|
32
|
+
return parts.join('/')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Admit the URL tail after `/assets/`. */
|
|
36
|
+
export function admitAssetInner(raw: string): string {
|
|
37
|
+
return admitAssetRef(`assets/${raw}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Content type for an admitted inner path. */
|
|
41
|
+
export function assetMediaType(inner: string): string {
|
|
42
|
+
const ext = inner.slice(inner.lastIndexOf('.') + 1).toLowerCase()
|
|
43
|
+
if (ext === 'png') return 'image/png'
|
|
44
|
+
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
|
|
45
|
+
if (ext === 'gif') return 'image/gif'
|
|
46
|
+
if (ext === 'webp') return 'image/webp'
|
|
47
|
+
return 'image/svg+xml'
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { snapshotSkip } from '../files/skip.ts'
|
|
5
|
+
import { bundleUi } from './bundle-ui.ts'
|
|
6
|
+
|
|
7
|
+
/** Generated UI bundle and stylesheet. Snapshots and listings skip this name. */
|
|
8
|
+
export const autogenDirName = '.autogen'
|
|
9
|
+
|
|
10
|
+
const entryFile = 'entry.js'
|
|
11
|
+
const sheetFile = 'ui.css'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Freshness of the app sources. `.autogen` and other skipped names do not count.
|
|
15
|
+
* The same stamp invalidates the bundle and the stylesheet.
|
|
16
|
+
* @param appDir - absolute app directory
|
|
17
|
+
*/
|
|
18
|
+
export async function sourceStamp(appDir: string): Promise<string> {
|
|
19
|
+
let max = 0
|
|
20
|
+
let count = 0
|
|
21
|
+
const walk = async (dir: string): Promise<void> => {
|
|
22
|
+
const names = await readdir(dir).catch(() => [])
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (snapshotSkip.has(name) || name === autogenDirName) continue
|
|
25
|
+
const file = path.join(dir, name)
|
|
26
|
+
const info = await stat(file).catch(() => undefined)
|
|
27
|
+
if (info === undefined) continue
|
|
28
|
+
if (info.isDirectory()) {
|
|
29
|
+
await walk(file)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
count += 1
|
|
33
|
+
if (info.mtimeMs > max) max = info.mtimeMs
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await walk(appDir)
|
|
37
|
+
return `${max}-${count}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read one generated file when its stamp still matches the sources. */
|
|
41
|
+
export async function readAutogen(appDir: string, file: string): Promise<string | undefined> {
|
|
42
|
+
const dir = path.join(appDir, autogenDirName)
|
|
43
|
+
const saved = await readFile(path.join(dir, `${file}.stamp`), 'utf8').catch(() => '')
|
|
44
|
+
if (saved !== await sourceStamp(appDir)) return undefined
|
|
45
|
+
return readFile(path.join(dir, file), 'utf8').catch(() => undefined)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write one generated file and its stamp. A write failure does not change the returned body. */
|
|
49
|
+
export async function writeAutogen(appDir: string, file: string, body: string): Promise<void> {
|
|
50
|
+
const dir = path.join(appDir, autogenDirName)
|
|
51
|
+
await mkdir(dir, { recursive: true })
|
|
52
|
+
const stamp = await sourceStamp(appDir)
|
|
53
|
+
await writeFile(path.join(dir, file), body)
|
|
54
|
+
await writeFile(path.join(dir, `${file}.stamp`), stamp)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Remove the generated directory. `cleanCaches` uses this for both files. */
|
|
58
|
+
export async function purgeAutogen(appDir: string): Promise<void> {
|
|
59
|
+
await rm(path.join(appDir, autogenDirName), { recursive: true, force: true })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Bundle `ui.tsx`, or read `.autogen/entry.js` when the stamp matches. The bytes are not kept. */
|
|
63
|
+
export async function cachedUiBundle(appDir: string): Promise<string> {
|
|
64
|
+
const hit = await readAutogen(appDir, entryFile)
|
|
65
|
+
if (hit !== undefined) return hit
|
|
66
|
+
const code = (await bundleUi(appDir)).code
|
|
67
|
+
await writeAutogen(appDir, entryFile, code).catch(() => undefined)
|
|
68
|
+
return code
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { sheetFile }
|