@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,970 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { closeSync, openSync, writeSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { appEntries, resolveManifest, type AppApiMethod, type AppConfig, type AppId, type AppWorkbench } from '@mohou/contract'
|
|
7
|
+
import type { McpClient } from '@mohou/mcp-client'
|
|
8
|
+
import type { RuntimeProvider } from '@mohou/runtime-provider'
|
|
9
|
+
|
|
10
|
+
import type { AppSummary } from '../apps/registry.ts'
|
|
11
|
+
import { cachedUiBundle, purgeAutogen } from '../compile/autogen.ts'
|
|
12
|
+
import { loadBackend } from '../compile/load-backend.ts'
|
|
13
|
+
import { reviewApp, type ReviewNotice } from '../compile/review.ts'
|
|
14
|
+
import { DEFAULT_APP_EVENT_TAIL, createAppEvents, type AppEvents } from '../events/app-events.ts'
|
|
15
|
+
import { createErrorRing } from '../events/error-ring.ts'
|
|
16
|
+
import { createHostEvents } from '../events/host-events.ts'
|
|
17
|
+
import { createViewQueries } from '../events/view-eval.ts'
|
|
18
|
+
import { createFileTools, type FileCommit } from '../files/tools.ts'
|
|
19
|
+
import { commitApp, listHistory, resetApp } from '../history/store.ts'
|
|
20
|
+
import { DEFAULT_STORAGE_NOTICE_BYTES } from '../storage/open.ts'
|
|
21
|
+
import { restoreStorageBackup } from '../storage/schema.ts'
|
|
22
|
+
import type { CredentialProvider } from '../credentials/provider.ts'
|
|
23
|
+
import { hostAuthoringToken } from '../host/layout.ts'
|
|
24
|
+
import { checkMcpEditor, maskEditorServers, readMcpEditor, writeMcpEditor, type McpEditorServer } from '../host/mcp-editor.ts'
|
|
25
|
+
import { mcpReferenceSources, type McpLoadFailure } from '../host/mcp.ts'
|
|
26
|
+
import { recordOpen } from '../host/activity.ts'
|
|
27
|
+
import { InstallError } from '../install/codes.ts'
|
|
28
|
+
import { installApp, type InstallRequest, type NpmRun } from '../install/install.ts'
|
|
29
|
+
import { installLayout } from '../install/layout.ts'
|
|
30
|
+
import { bindBrain } from '../kernel/bind-brain.ts'
|
|
31
|
+
import { runCall, type CallCapabilities } from '../kernel/call.ts'
|
|
32
|
+
import { openStorage, type StorageFile } from '../storage/open.ts'
|
|
33
|
+
import { listMcpForAuthor, toolsMcpForAuthor } from './mcp-list.ts'
|
|
34
|
+
import { AuthorError } from './codes.ts'
|
|
35
|
+
|
|
36
|
+
/** Tools this implementation can run. The rest of the catalog is not mounted. */
|
|
37
|
+
export const authorToolNames = [
|
|
38
|
+
'mini_app_list',
|
|
39
|
+
'mini_app_get',
|
|
40
|
+
'mini_app_list_files',
|
|
41
|
+
'mini_app_read',
|
|
42
|
+
'mini_app_edit',
|
|
43
|
+
'mini_app_write',
|
|
44
|
+
'mini_app_delete',
|
|
45
|
+
'mini_app_register',
|
|
46
|
+
'mini_app_reload',
|
|
47
|
+
'mini_app_call',
|
|
48
|
+
'mini_app_mcp_list',
|
|
49
|
+
'mini_app_mcp_tools',
|
|
50
|
+
'mini_app_mcp_add',
|
|
51
|
+
'mini_app_mcp_remove',
|
|
52
|
+
'mini_app_credential_list',
|
|
53
|
+
'mini_app_history_commit',
|
|
54
|
+
'mini_app_history_list',
|
|
55
|
+
'mini_app_history_reset',
|
|
56
|
+
'mini_app_open',
|
|
57
|
+
'mini_app_errors',
|
|
58
|
+
'mini_app_install',
|
|
59
|
+
'mini_app_view_eval',
|
|
60
|
+
] as const
|
|
61
|
+
|
|
62
|
+
export type AuthorToolName = (typeof authorToolNames)[number]
|
|
63
|
+
|
|
64
|
+
/** Commit message for a successful reload. The tool description is the same sentence. */
|
|
65
|
+
export const reloadMessage = 'Compile and reload a mini-app.'
|
|
66
|
+
|
|
67
|
+
/** Byte-carrying file tools. HTTP invoke keeps them. MCP does not list or call them. */
|
|
68
|
+
export const authorByteToolNames = ['mini_app_edit', 'mini_app_write', 'mini_app_delete'] as const
|
|
69
|
+
|
|
70
|
+
export type AuthorByteToolName = (typeof authorByteToolNames)[number]
|
|
71
|
+
|
|
72
|
+
export const authorMcpToolNames = authorToolNames.filter(
|
|
73
|
+
(name): name is Exclude<AuthorToolName, AuthorByteToolName> => {
|
|
74
|
+
return !(authorByteToolNames as readonly string[]).includes(name)
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/** Locked batch cap. A larger batch fails before any entry runs. */
|
|
79
|
+
const CALL_BATCH_MAX = 20
|
|
80
|
+
|
|
81
|
+
export interface AuthorCallPorts {
|
|
82
|
+
readonly credentials: CredentialProvider
|
|
83
|
+
readonly config: AppConfig
|
|
84
|
+
readonly log: (appId: string, ...args: unknown[]) => void
|
|
85
|
+
readonly push: (name: string, params?: unknown) => void
|
|
86
|
+
readonly http: CallCapabilities['http']
|
|
87
|
+
readonly bash: CallCapabilities['bash']
|
|
88
|
+
readonly pwsh: CallCapabilities['pwsh']
|
|
89
|
+
readonly metrics: CallCapabilities['metrics']
|
|
90
|
+
readonly processDirectory: string
|
|
91
|
+
readonly createTemp: () => string
|
|
92
|
+
readonly provider: RuntimeProvider
|
|
93
|
+
readonly workbench?: AppWorkbench
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface LiveApp {
|
|
97
|
+
api: Record<string, AppApiMethod>
|
|
98
|
+
storage: StorageFile
|
|
99
|
+
directory: string
|
|
100
|
+
ui?: string
|
|
101
|
+
kind?: 'workbench'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Registry {
|
|
105
|
+
readonly runtimeRoot: string
|
|
106
|
+
list: () => Promise<{ apps: AppSummary[]; runtimeRoot: string }>
|
|
107
|
+
get: (appId: string) => Promise<AppSummary>
|
|
108
|
+
register: (appId: string, files: Record<string, string>) => Promise<AppSummary>
|
|
109
|
+
trash: (appId: string) => Promise<void>
|
|
110
|
+
restoreTrashed: (appId: string) => Promise<AppSummary>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One authoring implementation. MCP and HTTP call this. They do not grow a second manager.
|
|
115
|
+
* @param options - registry, the external MCP client, and call ports
|
|
116
|
+
*/
|
|
117
|
+
export function createAuthorTools(options: {
|
|
118
|
+
readonly registry: Registry
|
|
119
|
+
readonly mcp: McpClient
|
|
120
|
+
/** Servers the live client left out: boot's list, replaced by every write. The list reports them. */
|
|
121
|
+
readonly readMcpFailures?: () => readonly McpLoadFailure[]
|
|
122
|
+
readonly recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
|
|
123
|
+
/** Process environment. Names the MCP server file when `MINI_APP_MCP_CONFIG` is set. */
|
|
124
|
+
readonly env?: NodeJS.ProcessEnv
|
|
125
|
+
readonly ports: AuthorCallPorts
|
|
126
|
+
/** Called when a commit or a reset changes the tree. The session publishes `app:reload`. */
|
|
127
|
+
readonly onTreeChanged?: (appId: string) => void
|
|
128
|
+
/** Replaces npm in tests. Package names are not passed on the command line. */
|
|
129
|
+
readonly runNpm?: NpmRun
|
|
130
|
+
readonly installTimeoutMs?: number
|
|
131
|
+
/** View query wait when the tool omits one. Host policy, not a locked number. */
|
|
132
|
+
readonly viewTimeoutMs?: number
|
|
133
|
+
/** Author event tail. Host policy, not a locked number. */
|
|
134
|
+
readonly eventTailLength?: number
|
|
135
|
+
/** Bytes past which a committed write may emit one storage-size notice. Not locked. */
|
|
136
|
+
readonly storageNoticeBytes?: number
|
|
137
|
+
/** Platform keyframe names. Empty until a stylesheet owns them. */
|
|
138
|
+
readonly reservedKeyframes?: readonly string[]
|
|
139
|
+
/** Time of an open. Tests pass a fixed clock. */
|
|
140
|
+
readonly now?: () => string
|
|
141
|
+
}) {
|
|
142
|
+
const live = new Map<string, LiveApp>()
|
|
143
|
+
const hostEvents = createHostEvents()
|
|
144
|
+
const appEvents = createAppEvents({ tailLength: options.eventTailLength ?? DEFAULT_APP_EVENT_TAIL })
|
|
145
|
+
const errors = createErrorRing()
|
|
146
|
+
const views = createViewQueries({
|
|
147
|
+
...options.viewTimeoutMs === undefined ? {} : { timeoutMs: options.viewTimeoutMs },
|
|
148
|
+
})
|
|
149
|
+
const bound = { ...options, hostEvents, appEvents, errors, views }
|
|
150
|
+
const inflight = new Set<Promise<unknown>>()
|
|
151
|
+
let stopping = false
|
|
152
|
+
const track = <T>(work: Promise<T>): Promise<T> => {
|
|
153
|
+
if (stopping) return Promise.reject(new AuthorError('cancelled', 'host is stopping'))
|
|
154
|
+
const tracked = work.finally(() => {
|
|
155
|
+
inflight.delete(tracked)
|
|
156
|
+
})
|
|
157
|
+
inflight.add(tracked)
|
|
158
|
+
return tracked
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
names: authorToolNames,
|
|
162
|
+
hostEvents,
|
|
163
|
+
appEvents,
|
|
164
|
+
errors,
|
|
165
|
+
views,
|
|
166
|
+
liveBundle: (appId: string) => live.get(appId)?.ui,
|
|
167
|
+
invoke: (name: string, args: unknown) => track(invoke(bound, live, name, args)),
|
|
168
|
+
callObserved: (appId: string, method: string, args: unknown, publish: (value: unknown) => Promise<void> | void) => {
|
|
169
|
+
return track(oneCall(bound, live, appId, method, args, publish))
|
|
170
|
+
},
|
|
171
|
+
deleteApp: (appId: string) => track(removeApp(bound, live, appId)),
|
|
172
|
+
undeleteApp: (appId: string) => track(restoreDeleted(bound, appId)),
|
|
173
|
+
restoreStorage: (appId: string) => track(restoreAppStorage(bound, live, appId)),
|
|
174
|
+
settle() {
|
|
175
|
+
stopping = true
|
|
176
|
+
return Promise.allSettled([...inflight]).then(() => undefined)
|
|
177
|
+
},
|
|
178
|
+
dispose() {
|
|
179
|
+
stopping = true
|
|
180
|
+
for (const app of live.values()) app.storage.close()
|
|
181
|
+
live.clear()
|
|
182
|
+
appEvents.dispose()
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read `authoring.token`. Shell creates it. This does not invent a token. */
|
|
188
|
+
export async function readAuthoringToken(runtimeRoot: string): Promise<string> {
|
|
189
|
+
const text = await readFile(hostAuthoringToken(runtimeRoot), 'utf8').catch(() => '')
|
|
190
|
+
const token = text.trim()
|
|
191
|
+
if (token === '') throw new AuthorError('authoring-token', 'authoring token is missing')
|
|
192
|
+
return token
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Create the authoring token when the file is absent.
|
|
197
|
+
* A present file is left as it is, including an empty one.
|
|
198
|
+
* POSIX mode bits are not a Windows access lock.
|
|
199
|
+
*/
|
|
200
|
+
export function ensureAuthoringToken(runtimeRoot: string): void {
|
|
201
|
+
const file = hostAuthoringToken(runtimeRoot)
|
|
202
|
+
try {
|
|
203
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
204
|
+
try {
|
|
205
|
+
writeSync(handle, randomBytes(32).toString('hex'))
|
|
206
|
+
} finally {
|
|
207
|
+
closeSync(handle)
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (!isAlreadyThere(error)) throw error
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isAlreadyThere(error: unknown): boolean {
|
|
215
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function invoke(
|
|
219
|
+
options: {
|
|
220
|
+
registry: Registry
|
|
221
|
+
mcp: McpClient
|
|
222
|
+
/** Servers boot left out because a reference names nothing. The author list reports them. */
|
|
223
|
+
readMcpFailures?: () => readonly McpLoadFailure[]
|
|
224
|
+
recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
|
|
225
|
+
env?: NodeJS.ProcessEnv
|
|
226
|
+
ports: AuthorCallPorts
|
|
227
|
+
onTreeChanged?: (appId: string) => void
|
|
228
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
229
|
+
appEvents: AppEvents
|
|
230
|
+
errors: ReturnType<typeof createErrorRing>
|
|
231
|
+
views: ReturnType<typeof createViewQueries>
|
|
232
|
+
runNpm?: NpmRun
|
|
233
|
+
installTimeoutMs?: number
|
|
234
|
+
storageNoticeBytes?: number
|
|
235
|
+
},
|
|
236
|
+
live: Map<string, LiveApp>,
|
|
237
|
+
name: string,
|
|
238
|
+
args: unknown,
|
|
239
|
+
): Promise<unknown> {
|
|
240
|
+
if (!isAuthorTool(name)) {
|
|
241
|
+
throw new AuthorError('unknown-tool', `unknown tool: ${name}. Catalog: ${authorToolNames.join(', ')}`)
|
|
242
|
+
}
|
|
243
|
+
const input = name === 'mini_app_list' || name === 'mini_app_mcp_list' || name === 'mini_app_credential_list' ? {} : record(args)
|
|
244
|
+
switch (name) {
|
|
245
|
+
case 'mini_app_list':
|
|
246
|
+
return options.registry.list()
|
|
247
|
+
case 'mini_app_get':
|
|
248
|
+
return options.registry.get(requiredString(input, 'appId'))
|
|
249
|
+
case 'mini_app_list_files':
|
|
250
|
+
return { files: await filesFor(options, requiredString(input, 'appId')).list() }
|
|
251
|
+
case 'mini_app_read':
|
|
252
|
+
return filesFor(options, requiredString(input, 'appId')).read(
|
|
253
|
+
requiredString(input, 'path'),
|
|
254
|
+
windowOf(input),
|
|
255
|
+
input.numbered === true,
|
|
256
|
+
)
|
|
257
|
+
case 'mini_app_edit':
|
|
258
|
+
return filesFor(options, requiredString(input, 'appId')).edit(
|
|
259
|
+
requiredString(input, 'path'),
|
|
260
|
+
editsOf(input),
|
|
261
|
+
input.commit !== false,
|
|
262
|
+
)
|
|
263
|
+
case 'mini_app_write':
|
|
264
|
+
return filesFor(options, requiredString(input, 'appId')).write(
|
|
265
|
+
requiredString(input, 'path'),
|
|
266
|
+
requiredString(input, 'content'),
|
|
267
|
+
input.commit !== false,
|
|
268
|
+
)
|
|
269
|
+
case 'mini_app_delete':
|
|
270
|
+
return filesFor(options, requiredString(input, 'appId')).delete(
|
|
271
|
+
requiredString(input, 'path'),
|
|
272
|
+
input.commit !== false,
|
|
273
|
+
)
|
|
274
|
+
case 'mini_app_register': {
|
|
275
|
+
const registered = await options.registry.register(requiredString(input, 'appId'), registerManifest(input))
|
|
276
|
+
return { ...registered, needed: neededFiles(registered.directory) }
|
|
277
|
+
}
|
|
278
|
+
case 'mini_app_reload':
|
|
279
|
+
return reload(options, live, requiredString(input, 'appId'), optionalCleanCaches(input.cleanCaches))
|
|
280
|
+
case 'mini_app_call':
|
|
281
|
+
return callApp(options, live, input)
|
|
282
|
+
case 'mini_app_mcp_list':
|
|
283
|
+
return listMcpForAuthor(options.mcp, options.readMcpFailures?.() ?? [])
|
|
284
|
+
case 'mini_app_mcp_tools':
|
|
285
|
+
return toolsMcpForAuthor(options.mcp, requiredString(input, 'serverId'), optionalString(input, 'toolName'), options.readMcpFailures?.() ?? [])
|
|
286
|
+
case 'mini_app_mcp_add':
|
|
287
|
+
return addMcpServer(options, input)
|
|
288
|
+
case 'mini_app_mcp_remove':
|
|
289
|
+
return removeMcpServer(options, input)
|
|
290
|
+
case 'mini_app_credential_list':
|
|
291
|
+
return { credentials: await options.ports.credentials.list() }
|
|
292
|
+
case 'mini_app_history_commit':
|
|
293
|
+
return historyCommit(options, requiredString(input, 'appId'), requiredString(input, 'message'))
|
|
294
|
+
case 'mini_app_history_list':
|
|
295
|
+
return historyList(options, requiredString(input, 'appId'), optionalLimit(input.limit))
|
|
296
|
+
case 'mini_app_history_reset':
|
|
297
|
+
return historyReset(options, requiredString(input, 'appId'), requiredString(input, 'commitId'))
|
|
298
|
+
case 'mini_app_open':
|
|
299
|
+
return openApp(options, requiredString(input, 'appId'), optionalString(input, 'title'))
|
|
300
|
+
case 'mini_app_errors':
|
|
301
|
+
return readErrors(options, requiredString(input, 'appId'), optionalSince(input.since), optionalClear(input.clear))
|
|
302
|
+
case 'mini_app_install':
|
|
303
|
+
return installPackages(options, requiredString(input, 'appId'), installRequest(input), input.commit !== false)
|
|
304
|
+
case 'mini_app_view_eval':
|
|
305
|
+
return evalView(options, requiredString(input, 'appId'), input)
|
|
306
|
+
default:
|
|
307
|
+
return name satisfies never
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function filesFor(
|
|
312
|
+
options: { registry: Registry; onTreeChanged?: (appId: string) => void },
|
|
313
|
+
appId: string,
|
|
314
|
+
) {
|
|
315
|
+
const tools = async () => {
|
|
316
|
+
const app = await options.registry.get(appId)
|
|
317
|
+
return createFileTools(app.directory, {
|
|
318
|
+
commit: message => trackedCommit(app.directory, app.id, message, options.onTreeChanged),
|
|
319
|
+
protectedPath: file => file === installLayout.manifest || file === installLayout.lockfile,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
list: async () => (await tools()).list(),
|
|
324
|
+
read: async (relative: string, window?: { start?: number; end?: number }, numbered?: boolean) =>
|
|
325
|
+
(await tools()).read(relative, window, numbered),
|
|
326
|
+
edit: async (relative: string, edits: Array<{ oldText: string; newText: string }>, commit: boolean) => {
|
|
327
|
+
return (await tools()).edit(relative, edits, commit)
|
|
328
|
+
},
|
|
329
|
+
write: async (relative: string, content: string, commit: boolean) => {
|
|
330
|
+
return (await tools()).write(relative, content, commit)
|
|
331
|
+
},
|
|
332
|
+
delete: async (relative: string, commit: boolean) => {
|
|
333
|
+
return (await tools()).delete(relative, commit)
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function trackedCommit(
|
|
339
|
+
appDir: string,
|
|
340
|
+
appId: string,
|
|
341
|
+
message: string,
|
|
342
|
+
onTreeChanged: ((appId: string) => void) | undefined,
|
|
343
|
+
): Promise<FileCommit> {
|
|
344
|
+
try {
|
|
345
|
+
const result = await commitApp(appDir, message)
|
|
346
|
+
if (result.status === 'committed') onTreeChanged?.(appId)
|
|
347
|
+
return result
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return { status: 'failed', reason: error instanceof Error ? error.message : 'commit failed' }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function historyCommit(
|
|
354
|
+
options: { registry: Registry; onTreeChanged?: (appId: string) => void },
|
|
355
|
+
appId: string,
|
|
356
|
+
message: string,
|
|
357
|
+
) {
|
|
358
|
+
const app = await options.registry.get(appId)
|
|
359
|
+
const result = await commitApp(app.directory, message)
|
|
360
|
+
if (result.status === 'committed') options.onTreeChanged?.(app.id)
|
|
361
|
+
return result
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function historyList(
|
|
365
|
+
options: { registry: Registry },
|
|
366
|
+
appId: string,
|
|
367
|
+
limit: number | undefined,
|
|
368
|
+
) {
|
|
369
|
+
const app = await options.registry.get(appId)
|
|
370
|
+
return limit === undefined ? listHistory(app.directory) : listHistory(app.directory, limit)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function historyReset(
|
|
374
|
+
options: {
|
|
375
|
+
registry: Registry
|
|
376
|
+
onTreeChanged?: (appId: string) => void
|
|
377
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
378
|
+
},
|
|
379
|
+
appId: string,
|
|
380
|
+
commitId: string,
|
|
381
|
+
) {
|
|
382
|
+
const app = await options.registry.get(appId)
|
|
383
|
+
const result = await resetApp(app.directory, commitId)
|
|
384
|
+
if (result.changed) {
|
|
385
|
+
options.onTreeChanged?.(app.id)
|
|
386
|
+
options.hostEvents.publish({ type: 'app:reload', appId: app.id })
|
|
387
|
+
}
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function openApp(
|
|
392
|
+
options: {
|
|
393
|
+
registry: Registry
|
|
394
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
395
|
+
errors: ReturnType<typeof createErrorRing>
|
|
396
|
+
now?: () => string
|
|
397
|
+
},
|
|
398
|
+
appId: string,
|
|
399
|
+
title: string | undefined,
|
|
400
|
+
) {
|
|
401
|
+
const app = await options.registry.get(appId)
|
|
402
|
+
recordOpen(options.registry.runtimeRoot, app.id, (options.now ?? (() => new Date().toISOString()))())
|
|
403
|
+
options.errors.markOpened(app.id)
|
|
404
|
+
options.hostEvents.publish({
|
|
405
|
+
type: 'app:open',
|
|
406
|
+
appId: app.id,
|
|
407
|
+
...title === undefined ? {} : { title },
|
|
408
|
+
})
|
|
409
|
+
return { panel: options.hostEvents.connected ? 'notified' : 'no-panel-connected' }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function readErrors(
|
|
413
|
+
options: { registry: Registry; errors: ReturnType<typeof createErrorRing> },
|
|
414
|
+
appId: string,
|
|
415
|
+
since: number | undefined,
|
|
416
|
+
clear: boolean,
|
|
417
|
+
) {
|
|
418
|
+
const app = await options.registry.get(appId)
|
|
419
|
+
return since === undefined ? options.errors.read(app.id, undefined, clear) : options.errors.read(app.id, since, clear)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function evalView(
|
|
423
|
+
options: {
|
|
424
|
+
registry: Registry
|
|
425
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
426
|
+
views: ReturnType<typeof createViewQueries>
|
|
427
|
+
},
|
|
428
|
+
appId: string,
|
|
429
|
+
args: Record<string, unknown>,
|
|
430
|
+
) {
|
|
431
|
+
const app = await options.registry.get(appId)
|
|
432
|
+
const timeoutMs = optionalBudget(args.timeoutMs, 'timeoutMs')
|
|
433
|
+
const maxBytes = optionalBudget(args.maxBytes, 'maxBytes')
|
|
434
|
+
const code = args.code
|
|
435
|
+
if (code !== undefined && typeof code !== 'string') throw new AuthorError('tool-args', 'code must be text')
|
|
436
|
+
return options.views.ask({
|
|
437
|
+
appId: app.id,
|
|
438
|
+
connected: options.hostEvents.connected,
|
|
439
|
+
publish: (event) => {
|
|
440
|
+
options.hostEvents.publish(event)
|
|
441
|
+
},
|
|
442
|
+
...timeoutMs === undefined ? {} : { timeoutMs },
|
|
443
|
+
...maxBytes === undefined ? {} : { maxBytes },
|
|
444
|
+
...typeof code === 'string' && code.length > 0 ? { code } : {},
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function optionalBudget(value: unknown, key: string): number | undefined {
|
|
449
|
+
if (value === undefined) return undefined
|
|
450
|
+
if (typeof value !== 'number' || value < 1) throw new AuthorError('tool-args', `${key} must be a positive number`)
|
|
451
|
+
return value
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function installPackages(
|
|
455
|
+
options: {
|
|
456
|
+
registry: Registry
|
|
457
|
+
onTreeChanged?: (appId: string) => void
|
|
458
|
+
runNpm?: NpmRun
|
|
459
|
+
installTimeoutMs?: number
|
|
460
|
+
},
|
|
461
|
+
appId: string,
|
|
462
|
+
request: InstallRequest,
|
|
463
|
+
commit: boolean,
|
|
464
|
+
) {
|
|
465
|
+
const app = await options.registry.get(appId)
|
|
466
|
+
try {
|
|
467
|
+
const result = await installApp(app.directory, request, {
|
|
468
|
+
...options.runNpm === undefined ? {} : { run: options.runNpm },
|
|
469
|
+
...options.installTimeoutMs === undefined ? {} : { timeoutMs: options.installTimeoutMs },
|
|
470
|
+
})
|
|
471
|
+
if (result.ok && commit && (request.packages.length > 0 || request.remove.length > 0)) {
|
|
472
|
+
await trackedCommit(app.directory, app.id, 'install dependencies', options.onTreeChanged)
|
|
473
|
+
}
|
|
474
|
+
return result
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof InstallError) {
|
|
477
|
+
return { ok: false, packages: {}, lockfile: null, code: error.code, message: error.message }
|
|
478
|
+
}
|
|
479
|
+
throw error
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function installRequest(args: Record<string, unknown>): InstallRequest {
|
|
484
|
+
return {
|
|
485
|
+
packages: packageSpecs(args.packages),
|
|
486
|
+
remove: nameList(args.remove),
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function packageSpecs(value: unknown): InstallRequest['packages'] {
|
|
491
|
+
if (value === undefined) return []
|
|
492
|
+
if (!Array.isArray(value)) throw new AuthorError('tool-args', 'packages must be a list')
|
|
493
|
+
return value.map((item) => {
|
|
494
|
+
const spec = record(item)
|
|
495
|
+
const version = spec.version
|
|
496
|
+
if (version !== undefined && typeof version !== 'string') throw new AuthorError('tool-args', 'version must be text')
|
|
497
|
+
return {
|
|
498
|
+
name: requiredString(spec, 'name'),
|
|
499
|
+
...typeof version === 'string' ? { version } : {},
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function nameList(value: unknown): string[] {
|
|
505
|
+
if (value === undefined) return []
|
|
506
|
+
if (!Array.isArray(value)) throw new AuthorError('tool-args', 'remove must be a list')
|
|
507
|
+
return value.map((item) => {
|
|
508
|
+
if (typeof item !== 'string' || item.length === 0) throw new AuthorError('tool-args', 'remove names must be text')
|
|
509
|
+
return item
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function restoreAppStorage(
|
|
514
|
+
options: { registry: Registry },
|
|
515
|
+
live: Map<string, LiveApp>,
|
|
516
|
+
appId: string,
|
|
517
|
+
) {
|
|
518
|
+
const app = await options.registry.get(appId)
|
|
519
|
+
live.get(app.id)?.storage.close()
|
|
520
|
+
live.delete(app.id)
|
|
521
|
+
restoreStorageBackup(app.directory)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function removeApp(
|
|
525
|
+
options: { registry: Registry; errors: ReturnType<typeof createErrorRing> },
|
|
526
|
+
live: Map<string, LiveApp>,
|
|
527
|
+
appId: string,
|
|
528
|
+
) {
|
|
529
|
+
const app = await options.registry.get(appId)
|
|
530
|
+
live.get(app.id)?.storage.close()
|
|
531
|
+
live.delete(app.id)
|
|
532
|
+
options.errors.forget(app.id)
|
|
533
|
+
await options.registry.trash(app.id)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function restoreDeleted(
|
|
537
|
+
options: { registry: Registry },
|
|
538
|
+
appId: string,
|
|
539
|
+
) {
|
|
540
|
+
return options.registry.restoreTrashed(appId)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function reload(
|
|
544
|
+
options: {
|
|
545
|
+
registry: Registry
|
|
546
|
+
onTreeChanged?: (appId: string) => void
|
|
547
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
548
|
+
errors: ReturnType<typeof createErrorRing>
|
|
549
|
+
views: ReturnType<typeof createViewQueries>
|
|
550
|
+
storageNoticeBytes?: number
|
|
551
|
+
reservedKeyframes?: readonly string[]
|
|
552
|
+
},
|
|
553
|
+
live: Map<string, LiveApp>,
|
|
554
|
+
appId: string,
|
|
555
|
+
cleanCaches = true,
|
|
556
|
+
): Promise<{
|
|
557
|
+
ok: boolean
|
|
558
|
+
errors: Array<{ code: string; message: string }>
|
|
559
|
+
notices: ReviewNotice[]
|
|
560
|
+
compiled: { backend: boolean; ui: boolean }
|
|
561
|
+
committed: FileCommit & { id?: string }
|
|
562
|
+
caches: { appCss: 'dropped'; views: 'not-open' | 'refetch'; cleanCaches: boolean }
|
|
563
|
+
}> {
|
|
564
|
+
let opened: StorageFile | undefined
|
|
565
|
+
try {
|
|
566
|
+
const app = await options.registry.get(appId)
|
|
567
|
+
if (cleanCaches) await purgeAutogen(app.directory)
|
|
568
|
+
opened = await openStorage(app.directory, storageNotice(options, app.id))
|
|
569
|
+
const loaded = await loadBackend(app.directory)
|
|
570
|
+
const review = await reviewApp(app.directory, {
|
|
571
|
+
...options.reservedKeyframes === undefined ? {} : { reservedKeyframes: options.reservedKeyframes },
|
|
572
|
+
})
|
|
573
|
+
if (review.errors.length > 0) {
|
|
574
|
+
opened.close()
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
errors: review.errors,
|
|
578
|
+
notices: review.notices,
|
|
579
|
+
compiled: { backend: false, ui: false },
|
|
580
|
+
committed: { status: 'skipped' },
|
|
581
|
+
caches: { appCss: 'dropped', views: 'not-open', cleanCaches },
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
await cachedUiBundle(app.directory)
|
|
585
|
+
const previous = live.get(app.id)
|
|
586
|
+
live.set(app.id, { api: loaded.api, storage: opened, directory: app.directory })
|
|
587
|
+
previous?.storage.close()
|
|
588
|
+
const committed = await commitApp(app.directory, reloadMessage).catch((error: unknown) => ({
|
|
589
|
+
status: 'failed' as const,
|
|
590
|
+
reason: error instanceof Error ? error.message : 'commit failed',
|
|
591
|
+
}))
|
|
592
|
+
if (committed.status === 'committed') options.onTreeChanged?.(app.id)
|
|
593
|
+
options.errors.clearReload(app.id)
|
|
594
|
+
options.views.forgetAlive(app.id)
|
|
595
|
+
const views = options.hostEvents.connected ? 'refetch' as const : 'not-open' as const
|
|
596
|
+
options.hostEvents.publish({ type: 'app:reload', appId: app.id })
|
|
597
|
+
return {
|
|
598
|
+
ok: committed.status !== 'failed',
|
|
599
|
+
errors: committed.status === 'failed' ? [{ code: 'commit-failed', message: committed.reason ?? 'commit failed' }] : [],
|
|
600
|
+
notices: review.notices,
|
|
601
|
+
compiled: { backend: true, ui: true },
|
|
602
|
+
committed,
|
|
603
|
+
caches: { appCss: 'dropped', views, cleanCaches },
|
|
604
|
+
}
|
|
605
|
+
} catch (error) {
|
|
606
|
+
opened?.close()
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
errors: [errorOf(error)],
|
|
610
|
+
notices: [],
|
|
611
|
+
compiled: { backend: false, ui: false },
|
|
612
|
+
committed: { status: 'skipped' },
|
|
613
|
+
caches: { appCss: 'dropped', views: 'not-open', cleanCaches },
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function callApp(
|
|
619
|
+
options: {
|
|
620
|
+
registry: Registry
|
|
621
|
+
mcp: McpClient
|
|
622
|
+
ports: AuthorCallPorts
|
|
623
|
+
appEvents: AppEvents
|
|
624
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
625
|
+
storageNoticeBytes?: number
|
|
626
|
+
},
|
|
627
|
+
live: Map<string, LiveApp>,
|
|
628
|
+
input: Record<string, unknown>,
|
|
629
|
+
): Promise<unknown> {
|
|
630
|
+
const appId = requiredString(input, 'appId')
|
|
631
|
+
const batch = input.calls
|
|
632
|
+
if (batch !== undefined) {
|
|
633
|
+
if (!Array.isArray(batch) || batch.length === 0) throw new AuthorError('tool-args', 'calls must be a non-empty list')
|
|
634
|
+
if (batch.length > CALL_BATCH_MAX) throw new AuthorError('call-batch', `calls exceed ${CALL_BATCH_MAX}`)
|
|
635
|
+
const results = []
|
|
636
|
+
for (const entry of batch) {
|
|
637
|
+
const item = record(entry)
|
|
638
|
+
results.push(await oneCall(options, live, appId, requiredString(item, 'method'), item.args))
|
|
639
|
+
}
|
|
640
|
+
return { results }
|
|
641
|
+
}
|
|
642
|
+
return oneCall(options, live, appId, requiredString(input, 'method'), input.args)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function oneCall(
|
|
646
|
+
options: {
|
|
647
|
+
registry: Registry
|
|
648
|
+
mcp: McpClient
|
|
649
|
+
ports: AuthorCallPorts
|
|
650
|
+
appEvents: AppEvents
|
|
651
|
+
hostEvents: ReturnType<typeof createHostEvents>
|
|
652
|
+
storageNoticeBytes?: number
|
|
653
|
+
},
|
|
654
|
+
live: Map<string, LiveApp>,
|
|
655
|
+
appId: string,
|
|
656
|
+
method: string,
|
|
657
|
+
args: unknown,
|
|
658
|
+
publish?: (value: unknown) => Promise<void> | void,
|
|
659
|
+
): Promise<{ ok: true; value: unknown } | { ok: false; error: { code: string; message: string } }> {
|
|
660
|
+
try {
|
|
661
|
+
const app = await ensureLive(options, live, appId)
|
|
662
|
+
const brain = await bindBrain({
|
|
663
|
+
provider: options.ports.provider,
|
|
664
|
+
appDir: app.directory,
|
|
665
|
+
processDirectory: options.ports.processDirectory,
|
|
666
|
+
createTemp: options.ports.createTemp,
|
|
667
|
+
push: (name, params) => {
|
|
668
|
+
options.appEvents.push(app.id, name, params)
|
|
669
|
+
},
|
|
670
|
+
})
|
|
671
|
+
try {
|
|
672
|
+
const value = await runCall(capabilitiesFor(options, app, brain), app.api, method, args, publish)
|
|
673
|
+
return { ok: true, value }
|
|
674
|
+
} finally {
|
|
675
|
+
await brain.stop()
|
|
676
|
+
}
|
|
677
|
+
} catch (error) {
|
|
678
|
+
return { ok: false, error: errorOf(error) }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function storageNotice(
|
|
683
|
+
options: { hostEvents: ReturnType<typeof createHostEvents>; storageNoticeBytes?: number },
|
|
684
|
+
appId: string,
|
|
685
|
+
): { noticeBytes: number; onNotice: (notice: { table: string; keys: string[] }) => void } {
|
|
686
|
+
return {
|
|
687
|
+
noticeBytes: options.storageNoticeBytes ?? DEFAULT_STORAGE_NOTICE_BYTES,
|
|
688
|
+
onNotice: (notice) => {
|
|
689
|
+
options.hostEvents.publish({ type: 'storage-size', appId, table: notice.table, keys: notice.keys })
|
|
690
|
+
},
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function ensureLive(
|
|
695
|
+
options: { registry: Registry; hostEvents: ReturnType<typeof createHostEvents>; storageNoticeBytes?: number },
|
|
696
|
+
live: Map<string, LiveApp>,
|
|
697
|
+
appId: string,
|
|
698
|
+
): Promise<LiveApp & { id: AppId }> {
|
|
699
|
+
const summary = await options.registry.get(appId)
|
|
700
|
+
const cached = live.get(summary.id)
|
|
701
|
+
if (cached !== undefined && cached.directory === summary.directory) return { ...withKind(cached, summary.kind), id: summary.id }
|
|
702
|
+
const storage = await openStorage(summary.directory, storageNotice(options, summary.id))
|
|
703
|
+
try {
|
|
704
|
+
const loaded = await loadBackend(summary.directory)
|
|
705
|
+
const next = withKind({ api: loaded.api, storage, directory: summary.directory }, summary.kind)
|
|
706
|
+
live.set(summary.id, next)
|
|
707
|
+
return { ...next, id: summary.id }
|
|
708
|
+
} catch (error) {
|
|
709
|
+
storage.close()
|
|
710
|
+
throw error
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function capabilitiesFor(
|
|
715
|
+
options: { mcp: McpClient; ports: AuthorCallPorts; appEvents: AppEvents },
|
|
716
|
+
app: LiveApp & { id: AppId },
|
|
717
|
+
brain: CallCapabilities['brain'],
|
|
718
|
+
): CallCapabilities {
|
|
719
|
+
const ports = options.ports
|
|
720
|
+
return {
|
|
721
|
+
appId: app.id,
|
|
722
|
+
appDir: app.directory,
|
|
723
|
+
state: {},
|
|
724
|
+
storage: app.storage.connect(),
|
|
725
|
+
credentials: ports.credentials,
|
|
726
|
+
config: ports.config,
|
|
727
|
+
log: (...args) => { ports.log(app.id, ...args) },
|
|
728
|
+
push: (name, params) => {
|
|
729
|
+
options.appEvents.push(app.id, name, params)
|
|
730
|
+
},
|
|
731
|
+
http: ports.http,
|
|
732
|
+
bash: ports.bash,
|
|
733
|
+
pwsh: ports.pwsh,
|
|
734
|
+
metrics: ports.metrics,
|
|
735
|
+
mcp: (serverId, toolName, args) => options.mcp.call(serverId, toolName, args ?? {}),
|
|
736
|
+
brain,
|
|
737
|
+
...app.kind === 'workbench' && ports.workbench !== undefined ? { workbench: ports.workbench } : {},
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function withKind(app: LiveApp, kind: 'workbench' | undefined): LiveApp {
|
|
742
|
+
if (kind === undefined) {
|
|
743
|
+
const { kind: gone, ...rest } = app
|
|
744
|
+
void gone
|
|
745
|
+
return rest
|
|
746
|
+
}
|
|
747
|
+
return { ...app, kind }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function errorOf(error: unknown): { code: string; message: string } {
|
|
751
|
+
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
|
752
|
+
? error.code
|
|
753
|
+
: 'unknown-tool'
|
|
754
|
+
const message = error instanceof Error ? error.message : 'tool failed'
|
|
755
|
+
return { code, message }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function isAuthorTool(name: string): name is AuthorToolName {
|
|
759
|
+
return (authorToolNames as readonly string[]).includes(name)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function isAuthorMcpTool(name: string): boolean {
|
|
763
|
+
return isAuthorTool(name) && !(authorByteToolNames as readonly string[]).includes(name)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function record(value: unknown): Record<string, unknown> {
|
|
767
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
768
|
+
throw new AuthorError('tool-args', 'tool arguments must be an object')
|
|
769
|
+
}
|
|
770
|
+
return value as Record<string, unknown>
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function requiredString(args: Record<string, unknown>, key: string): string {
|
|
774
|
+
const value = args[key]
|
|
775
|
+
if (typeof value !== 'string' || value.length === 0) throw new AuthorError('tool-args', `${key} is required`)
|
|
776
|
+
return value
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function optionalString(args: Record<string, unknown>, key: string): string | undefined {
|
|
780
|
+
const value = args[key]
|
|
781
|
+
if (value === undefined || value === '') return undefined
|
|
782
|
+
if (typeof value !== 'string') throw new AuthorError('tool-args', `${key} must be text`)
|
|
783
|
+
return value
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function optionalLimit(value: unknown): number | undefined {
|
|
787
|
+
if (value === undefined) return undefined
|
|
788
|
+
if (typeof value !== 'number' || value < 1) throw new AuthorError('tool-args', 'limit must be a positive number')
|
|
789
|
+
return value
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function optionalSince(value: unknown): number | undefined {
|
|
793
|
+
if (value === undefined) return undefined
|
|
794
|
+
if (typeof value !== 'number' || value < 0) throw new AuthorError('tool-args', 'since must be a non-negative number')
|
|
795
|
+
return value
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function optionalClear(value: unknown): boolean {
|
|
799
|
+
if (value === undefined) return false
|
|
800
|
+
if (typeof value !== 'boolean') throw new AuthorError('tool-args', 'clear must be a boolean')
|
|
801
|
+
return value
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Default true. No cache directory is named, so the flag is recorded and nothing is removed. */
|
|
805
|
+
function optionalCleanCaches(value: unknown): boolean {
|
|
806
|
+
if (value === undefined) return true
|
|
807
|
+
if (typeof value !== 'boolean') throw new AuthorError('tool-args', 'cleanCaches must be a boolean')
|
|
808
|
+
return value
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Add or replace one MCP server: check it by opening it, write the row, and hand the live client
|
|
813
|
+
* the set it just wrote, so the server is usable without a restart. A failed check leaves both the
|
|
814
|
+
* file and the client alone unless `force` is true.
|
|
815
|
+
*/
|
|
816
|
+
async function addMcpServer(
|
|
817
|
+
options: {
|
|
818
|
+
registry: Registry
|
|
819
|
+
mcp: McpClient
|
|
820
|
+
env?: NodeJS.ProcessEnv
|
|
821
|
+
ports: AuthorCallPorts
|
|
822
|
+
recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
|
|
823
|
+
},
|
|
824
|
+
input: Record<string, unknown>,
|
|
825
|
+
) {
|
|
826
|
+
const env = options.env ?? process.env
|
|
827
|
+
const root = options.registry.runtimeRoot
|
|
828
|
+
const server = mcpServerRow(input)
|
|
829
|
+
const sources = mcpReferenceSources(env, options.ports.credentials)
|
|
830
|
+
const check = input.check === false ? undefined : await checkMcpEditor(server, env, sources)
|
|
831
|
+
// The write input is the file itself. The masked rows are for the answer, and writing those back
|
|
832
|
+
// would replace every other server's secret with its own mask.
|
|
833
|
+
const file = await readMcpEditor(root, env)
|
|
834
|
+
if (check !== undefined && !check.ok && input.force !== true) {
|
|
835
|
+
return { added: false, id: server.id, check, servers: maskEditorServers(file) }
|
|
836
|
+
}
|
|
837
|
+
const resolved = await writeMcpEditor(root, [...file.filter(row => row.id !== server.id), server], env, sources)
|
|
838
|
+
await options.mcp.setServers(resolved.servers)
|
|
839
|
+
options.recordMcpFailures?.(resolved.failures)
|
|
840
|
+
return {
|
|
841
|
+
added: true,
|
|
842
|
+
id: server.id,
|
|
843
|
+
...check === undefined ? {} : { check },
|
|
844
|
+
servers: await readMcpEditor(root, env, true),
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Remove one MCP server by id, live. An unknown id changes nothing. */
|
|
849
|
+
async function removeMcpServer(
|
|
850
|
+
options: {
|
|
851
|
+
registry: Registry
|
|
852
|
+
mcp: McpClient
|
|
853
|
+
env?: NodeJS.ProcessEnv
|
|
854
|
+
ports: AuthorCallPorts
|
|
855
|
+
recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
|
|
856
|
+
},
|
|
857
|
+
input: Record<string, unknown>,
|
|
858
|
+
) {
|
|
859
|
+
const env = options.env ?? process.env
|
|
860
|
+
const root = options.registry.runtimeRoot
|
|
861
|
+
const id = requiredString(input, 'id')
|
|
862
|
+
const sources = mcpReferenceSources(env, options.ports.credentials)
|
|
863
|
+
const file = await readMcpEditor(root, env)
|
|
864
|
+
if (!file.some(row => row.id === id)) return { removed: false, id, servers: maskEditorServers(file) }
|
|
865
|
+
const resolved = await writeMcpEditor(root, file.filter(row => row.id !== id), env, sources)
|
|
866
|
+
await options.mcp.setServers(resolved.servers)
|
|
867
|
+
options.recordMcpFailures?.(resolved.failures)
|
|
868
|
+
return { removed: true, id, servers: await readMcpEditor(root, env, true) }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function mcpServerRow(input: Record<string, unknown>): McpEditorServer {
|
|
872
|
+
const id = requiredString(input, 'id')
|
|
873
|
+
const command = optionalString(input, 'command')
|
|
874
|
+
const url = optionalString(input, 'url')
|
|
875
|
+
if (command === undefined && url === undefined) throw new AuthorError('tool-args', 'give command or url')
|
|
876
|
+
const description = optionalString(input, 'description')
|
|
877
|
+
const transport = mcpTransport(input.transport)
|
|
878
|
+
const args = stringList(input.args, 'args')
|
|
879
|
+
const env = stringMap(input.env, 'env')
|
|
880
|
+
const headers = stringMap(input.headers, 'headers')
|
|
881
|
+
return {
|
|
882
|
+
id,
|
|
883
|
+
...description === undefined ? {} : { description },
|
|
884
|
+
...input.enabled === false ? { enabled: false } : {},
|
|
885
|
+
...command === undefined ? {} : { command },
|
|
886
|
+
...args === undefined ? {} : { args },
|
|
887
|
+
...env === undefined ? {} : { env },
|
|
888
|
+
...url === undefined ? {} : { url },
|
|
889
|
+
...transport === undefined ? {} : { transport },
|
|
890
|
+
...headers === undefined ? {} : { headers },
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function mcpTransport(value: unknown): McpEditorServer['transport'] {
|
|
895
|
+
if (value === undefined) return undefined
|
|
896
|
+
if (value !== 'sse' && value !== 'streamable-http') throw new AuthorError('tool-args', 'transport must be sse or streamable-http')
|
|
897
|
+
return value
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function stringList(value: unknown, key: string): string[] | undefined {
|
|
901
|
+
if (value === undefined) return undefined
|
|
902
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
|
|
903
|
+
throw new AuthorError('tool-args', `${key} must be an array of text`)
|
|
904
|
+
}
|
|
905
|
+
return value as string[]
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function stringMap(value: unknown, key: string): Record<string, string> | undefined {
|
|
909
|
+
if (value === undefined) return undefined
|
|
910
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
911
|
+
throw new AuthorError('tool-args', `${key} must be an object of text`)
|
|
912
|
+
}
|
|
913
|
+
const out: Record<string, string> = {}
|
|
914
|
+
for (const [name, item] of Object.entries(value)) {
|
|
915
|
+
if (typeof item !== 'string') throw new AuthorError('tool-args', `${key}.${name} must be text`)
|
|
916
|
+
out[name] = item
|
|
917
|
+
}
|
|
918
|
+
return out
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function windowOf(args: Record<string, unknown>): { start?: number; end?: number } | undefined {
|
|
922
|
+
const start = args.start
|
|
923
|
+
const end = args.end
|
|
924
|
+
if (start === undefined && end === undefined) return undefined
|
|
925
|
+
return {
|
|
926
|
+
...typeof start === 'number' ? { start } : {},
|
|
927
|
+
...typeof end === 'number' ? { end } : {},
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function editsOf(args: Record<string, unknown>): Array<{ oldText: string; newText: string }> {
|
|
932
|
+
const edits = args.edits
|
|
933
|
+
if (!Array.isArray(edits) || edits.length === 0) throw new AuthorError('tool-args', 'edits are required')
|
|
934
|
+
return edits.map((edit) => {
|
|
935
|
+
const item = record(edit)
|
|
936
|
+
return { oldText: requiredString(item, 'oldText'), newText: typeof item.newText === 'string' ? item.newText : '' }
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function registerManifest(input: Record<string, unknown>): Record<string, string> {
|
|
941
|
+
if ('files' in input) throw new AuthorError('tool-args', 'files is not a register argument')
|
|
942
|
+
const appId = requiredString(input, 'appId')
|
|
943
|
+
const raw: Record<string, unknown> = {
|
|
944
|
+
id: appId,
|
|
945
|
+
name: requiredString(input, 'name'),
|
|
946
|
+
description: requiredString(input, 'description'),
|
|
947
|
+
version: requiredString(input, 'version'),
|
|
948
|
+
entry: appEntries.ui,
|
|
949
|
+
}
|
|
950
|
+
if (input.acronym !== undefined) raw.acronym = input.acronym
|
|
951
|
+
if (input.tags !== undefined) raw.tags = input.tags
|
|
952
|
+
if (input.kind !== undefined) raw.kind = input.kind
|
|
953
|
+
const admitted = resolveManifest(raw, appId)
|
|
954
|
+
const body: Record<string, unknown> = {
|
|
955
|
+
id: admitted.id,
|
|
956
|
+
name: admitted.name,
|
|
957
|
+
description: admitted.description,
|
|
958
|
+
version: admitted.version,
|
|
959
|
+
entry: admitted.entry,
|
|
960
|
+
}
|
|
961
|
+
if (admitted.acronym !== undefined) body.acronym = admitted.acronym
|
|
962
|
+
if (admitted.tags !== undefined) body.tags = admitted.tags
|
|
963
|
+
if (admitted.kind !== undefined) body.kind = admitted.kind
|
|
964
|
+
return { [appEntries.manifest]: `${JSON.stringify(body, null, 2)}
|
|
965
|
+
` }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function neededFiles(directory: string): readonly string[] {
|
|
969
|
+
return [path.join(directory, appEntries.ui), path.join(directory, appEntries.backend)]
|
|
970
|
+
}
|