acrylblends 1.3.1 → 1.4.0
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/dist/index.d.ts +1 -1
- package/dist/index.js +14 -5
- package/dist/index.js.map +1 -1
- package/legacy/README.md +4 -1
- package/legacy/agent/858de7a1ad0b/src/app.ts +289 -0
- package/legacy/agent/858de7a1ad0b/src/routes.ts +145 -0
- package/legacy/agent/b96ba6e3598a/src/app.ts +285 -0
- package/legacy/agent/b96ba6e3598a/src/routes.ts +145 -0
- package/legacy/agent/ecbc84f1c1e5/src/app.ts +291 -0
- package/legacy/agent/ecbc84f1c1e5/src/routes.ts +145 -0
- package/legacy/blank/2447ac6d5d24/src/app.ts +61 -0
- package/legacy/blank/387f7e25aaeb/src/app.ts +81 -0
- package/legacy/blank/5b9f10e1ba5e/src/app.ts +61 -0
- package/legacy/blank/5f6dd8910c50/src/app.ts +152 -0
- package/legacy/blank/5f6dd8910c50/src/routes.ts +75 -0
- package/legacy/blank/a7e8f539ffee/src/app.ts +160 -0
- package/legacy/blank/a7e8f539ffee/src/routes.ts +75 -0
- package/legacy/blank/bdb9735e013b/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/app.ts +61 -0
- package/legacy/blank/c3be1d735b1a/src/routes.ts +18 -0
- package/legacy/blank/e04303a7f8dd/src/app.ts +61 -0
- package/legacy/chat/5120c4a9fa61/src/app.ts +157 -0
- package/legacy/chat/5120c4a9fa61/src/routes.ts +29 -0
- package/legacy/chat/d4464dfb3b04/src/app.ts +165 -0
- package/legacy/chat/d4464dfb3b04/src/routes.ts +29 -0
- package/legacy/frozen.json +7 -0
- package/legacy/layouts.json +146 -45
- package/legacy/nodejs/9d59115118c7/src/app.ts +150 -0
- package/legacy/nodejs/9d59115118c7/src/routes.ts +59 -0
- package/legacy/nodejs/a6a340b4d3a6/src/app.ts +158 -0
- package/legacy/nodejs/a6a340b4d3a6/src/routes.ts +59 -0
- package/legacy/notes/fb43a1c5f3b4/src/app.ts +165 -0
- package/legacy/notes/fb43a1c5f3b4/src/routes.ts +21 -0
- package/legacy/notes/fc3fe7c749d2/src/app.ts +157 -0
- package/legacy/notes/fc3fe7c749d2/src/routes.ts +21 -0
- package/legacy/todo/164274ffcf7f/src/app.ts +145 -0
- package/legacy/todo/164274ffcf7f/src/todo.ts +137 -0
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +40 -9
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +7 -2
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- package/plugins/extensions/index.ts +12 -6
- package/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -0
- /package/legacy/blank/{d6d371c9a65e → 2447ac6d5d24}/src/routes.ts +0 -0
- /package/legacy/blank/{5a91c558af22 → 387f7e25aaeb}/src/routes.ts +0 -0
- /package/legacy/blank/{b2cbb759c889 → 5b9f10e1ba5e}/src/routes.ts +0 -0
- /package/legacy/blank/{9c650cf52bf3 → bdb9735e013b}/src/routes.ts +0 -0
- /package/legacy/blank/{aea589283445 → e04303a7f8dd}/src/routes.ts +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
|
4
|
+
import * as door from '../plugins/acryl-door/src/index.ts'
|
|
5
|
+
import * as nodeServer from '../plugins/acryl-node-server/src/index.ts'
|
|
6
|
+
import * as router from '../plugins/acryl-router/src/index.ts'
|
|
7
|
+
import * as state from '../plugins/acryl-state/src/index.ts'
|
|
8
|
+
import { type AppInstance, instance } from '../plugins/instance/index.ts'
|
|
9
|
+
import {
|
|
10
|
+
appPluginNames,
|
|
11
|
+
blueprintsIn,
|
|
12
|
+
compose,
|
|
13
|
+
loadAppPlugins,
|
|
14
|
+
loadManifest,
|
|
15
|
+
type ParameterValue,
|
|
16
|
+
} from '../plugins/manifest/index.ts'
|
|
17
|
+
import { doorEnabled } from './door.ts'
|
|
18
|
+
import * as todo from './todo.ts'
|
|
19
|
+
import { FRAME_MOUNT_PREFIX } from './todo.ts'
|
|
20
|
+
|
|
21
|
+
/** The plugins this app's code provides. `blend.yaml` chooses, orders and configures them, and adds the app's own plugins from `plugins/` by name. */
|
|
22
|
+
export const registry = {
|
|
23
|
+
'acryl-router': router,
|
|
24
|
+
'acryl-state': state,
|
|
25
|
+
todo,
|
|
26
|
+
'acryl-node-server': nodeServer,
|
|
27
|
+
'acryl-door': door,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const APP_NAME = '__APP_NAME__'
|
|
31
|
+
|
|
32
|
+
/** The manifest next to this app's source. */
|
|
33
|
+
export const MANIFEST = join(fileURLToPath(new URL('..', import.meta.url)), 'blend.yaml')
|
|
34
|
+
/** The app's plugins: the library copies `registry` imports, and the app's own plugins (a feature each), which `blend.yaml` mounts by their folder name. */
|
|
35
|
+
const PLUGINS_DIR = join(fileURLToPath(new URL('..', import.meta.url)), 'plugins')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The app's manifest, resolved. A Blend finds its Blueprint in `blueprints/<id>.yaml`; an app that is itself a Blueprint never looks.
|
|
39
|
+
* `createApp` and the tests read it the same way.
|
|
40
|
+
*/
|
|
41
|
+
export function readManifest(values?: Readonly<Record<string, ParameterValue>>) {
|
|
42
|
+
return loadManifest(MANIFEST, {
|
|
43
|
+
known: [...Object.keys(registry), ...appPluginNames(PLUGINS_DIR)],
|
|
44
|
+
getBlueprint: blueprintsIn(join(fileURLToPath(new URL('..', import.meta.url)), 'blueprints')),
|
|
45
|
+
...(values ? { values } : {}),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The manifest without some rows: without its server (and so its door) the app is reached in process, through `root.router.request`; the door is also left out in production. */
|
|
50
|
+
function without<M extends { plugins: readonly { use: string }[] }>(
|
|
51
|
+
manifest: M,
|
|
52
|
+
...uses: string[]
|
|
53
|
+
): M {
|
|
54
|
+
return {
|
|
55
|
+
...manifest,
|
|
56
|
+
plugins: manifest.plugins.map((row) =>
|
|
57
|
+
uses.includes(row.use) ? { ...row, disabled: true } : row,
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface App {
|
|
63
|
+
readonly root: Context
|
|
64
|
+
/** The address the server listens on; empty when the app was made with `listen: false`. */
|
|
65
|
+
readonly url: string
|
|
66
|
+
stop(): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AppOptions {
|
|
70
|
+
readonly values?: Readonly<Record<string, ParameterValue>>
|
|
71
|
+
/** The path the app is mounted at; absent on its own server, where it keeps `/todo`, and {@link FRAME_MOUNT_PREFIX} in the frame. */
|
|
72
|
+
readonly prefix?: string
|
|
73
|
+
/** Start the HTTP server (default). Tests pass `false` and call `app.root.router.request(path)`: it needs no port, so it also runs where a sandbox forbids listening. */
|
|
74
|
+
readonly listen?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The rows the frame host provides: the app contributes only the plugins that are its own. */
|
|
78
|
+
const HOST_ROWS = [
|
|
79
|
+
'instance',
|
|
80
|
+
'acryl-router',
|
|
81
|
+
'acryl-node-server',
|
|
82
|
+
'acryl-web-server',
|
|
83
|
+
'acryl-page',
|
|
84
|
+
'manifest',
|
|
85
|
+
'save',
|
|
86
|
+
'acryl-door',
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The app's rows, composed into `ctx`: the app's own server calls this from `createApp`, and the generated frame layer mounts it as one row, so the frame
|
|
91
|
+
* runs the app exactly as the app's own server does (same rows, same order, same config). `host: true` leaves the infrastructure rows to the frame host,
|
|
92
|
+
* which is where the door lives too, and mounts the app at {@link FRAME_MOUNT_PREFIX}.
|
|
93
|
+
*/
|
|
94
|
+
export async function composeInto(
|
|
95
|
+
ctx: Context,
|
|
96
|
+
chosen: AppInstance,
|
|
97
|
+
options: AppOptions & { readonly host?: boolean } = {},
|
|
98
|
+
): Promise<Fiber[]> {
|
|
99
|
+
const read = readManifest(options.values)
|
|
100
|
+
const own = options.host !== true && options.listen !== false
|
|
101
|
+
// The frame host mounts the app under its own prefix; the app's own server keeps the app's old path.
|
|
102
|
+
const prefix = options.prefix ?? (options.host === true ? FRAME_MOUNT_PREFIX : undefined)
|
|
103
|
+
const leaveOut = new Set<string>(options.host === true ? HOST_ROWS : [])
|
|
104
|
+
// The server, and the door that needs it, are not mounted when the app is reached in process or inside the frame host.
|
|
105
|
+
if (!own) leaveOut.add('acryl-node-server').add('acryl-door')
|
|
106
|
+
if (!doorEnabled()) leaveOut.add('acryl-door')
|
|
107
|
+
const manifest = without(read, ...leaveOut)
|
|
108
|
+
// The rows that name one of the app's own plugin folders (a feature: plugins/<name>/ and its row) rather than a registry import; each one that runs is imported here.
|
|
109
|
+
const ownRows = [
|
|
110
|
+
...new Set(read.plugins.map((row) => row.use).filter((use) => !Object.hasOwn(registry, use))),
|
|
111
|
+
]
|
|
112
|
+
const ownPlugins = await loadAppPlugins(
|
|
113
|
+
PLUGINS_DIR,
|
|
114
|
+
ownRows.filter((use) => manifest.plugins.some((row) => row.use === use && !row.disabled)),
|
|
115
|
+
)
|
|
116
|
+
return compose(
|
|
117
|
+
ctx,
|
|
118
|
+
manifest,
|
|
119
|
+
{ ...registry, ...ownPlugins },
|
|
120
|
+
{
|
|
121
|
+
// Runtime values the manifest cannot hold: where the list is kept (in the app's home) and, in the frame, the prefix the app answers at.
|
|
122
|
+
todo: {
|
|
123
|
+
file: join(chosen.home, 'todo.json'),
|
|
124
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
125
|
+
},
|
|
126
|
+
// Only the app's own server listens; the frame host owns the server and its port.
|
|
127
|
+
...(own ? { server: { port: chosen.port.start, scan: chosen.port.scan ? 20 : 0 } } : {}),
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The app: `instance` first (where it lives, chosen at run time), then the manifest's rows in order. */
|
|
133
|
+
export async function createApp(chosen: AppInstance, options: AppOptions = {}): Promise<App> {
|
|
134
|
+
const root = new Context()
|
|
135
|
+
const first = root.plugin(instance, chosen)
|
|
136
|
+
await first.await()
|
|
137
|
+
const fibers: Fiber[] = [first, ...(await composeInto(root, chosen, options))]
|
|
138
|
+
return {
|
|
139
|
+
root,
|
|
140
|
+
url: options.listen === false ? '' : root.nodeServer.url,
|
|
141
|
+
async stop() {
|
|
142
|
+
for (const fiber of [...fibers].reverse()) await fiber.dispose()
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference app of the ACRYL Framework: a todo list in one plugin. It needs `router` and `state`, keeps the list in a cell, serves
|
|
3
|
+
* JSON CRUD plus a page, and writes the list to a file so it survives a restart. Nothing else.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { dirname } from 'node:path'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import { Acryl, HTTPException } from '../plugins/acryl-router/src/index.ts'
|
|
10
|
+
import { bodyLimit, loopbackOnly, trimTrailingSlash } from '../plugins/acryl-router/src/middleware/index.ts'
|
|
11
|
+
import { schemaValidator, streamSSE, type StandardSchema } from '../plugins/acryl-router/src/helpers/index.ts'
|
|
12
|
+
import type {} from '../plugins/acryl-state/src/index.ts'
|
|
13
|
+
import { pageHtml } from './page.ts'
|
|
14
|
+
|
|
15
|
+
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
16
|
+
export const name = 'acryl-todo'
|
|
17
|
+
export const inject = ['router', 'state']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The only place the app's mount prefix is named: the app answers under `/todo` on its own server (`npm start`), and the ACRYL frame mounts the same app
|
|
21
|
+
* at `/app` (`npm run dev`). Every URL the page writes is derived from the prefix it is mounted at, so both are served by one source.
|
|
22
|
+
*/
|
|
23
|
+
export const FRAME_MOUNT_PREFIX = '/app'
|
|
24
|
+
|
|
25
|
+
export interface Todo { id: number, title: string, done: boolean }
|
|
26
|
+
export interface Config {
|
|
27
|
+
file?: string
|
|
28
|
+
/**
|
|
29
|
+
* The path the app is mounted at, and the base of every URL its page writes. `/todo` on the app's own server (and the default); the ACRYL frame passes
|
|
30
|
+
* `/app` so the same app answers at the frame's app prefix, with nothing else of its URLs moved.
|
|
31
|
+
*/
|
|
32
|
+
prefix?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Standard Schema for the body of a new todo, written by hand so the example has no schema library. */
|
|
36
|
+
const NewTodo: StandardSchema<{ title: string }> = {
|
|
37
|
+
'~standard': {
|
|
38
|
+
validate: value => {
|
|
39
|
+
const title = (value as { title?: unknown } | null)?.title
|
|
40
|
+
return typeof title === 'string' && title.trim() !== '' && title.length <= 200
|
|
41
|
+
? { value: { title: title.trim() } }
|
|
42
|
+
: { issues: [{ message: 'title must be 1 to 200 characters', path: ['title'] }] }
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Standard Schema for a change to a todo. */
|
|
48
|
+
const TodoChange: StandardSchema<{ done: boolean }> = {
|
|
49
|
+
'~standard': {
|
|
50
|
+
validate: value => {
|
|
51
|
+
const done = (value as { done?: unknown } | null)?.done
|
|
52
|
+
return typeof done === 'boolean' ? { value: { done } } : { issues: [{ message: 'done must be true or false', path: ['done'] }] }
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A todo body is small; anything bigger is refused before it is read. */
|
|
58
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
59
|
+
|
|
60
|
+
/** A missing file is a new list. Any other failure (unreadable, corrupt, half-written) refuses to start: starting empty would overwrite the user's real list on the first save. */
|
|
61
|
+
function load(file: string | undefined): Todo[] {
|
|
62
|
+
if (file === undefined) return []
|
|
63
|
+
let text: string
|
|
64
|
+
try {
|
|
65
|
+
text = readFileSync(file, 'utf8')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
68
|
+
throw new Error(`acryl-todo: cannot read ${file}: ${(error as Error).message}`, { cause: error })
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const parsed: unknown = JSON.parse(text)
|
|
72
|
+
if (!Array.isArray(parsed)) throw new Error('not a list')
|
|
73
|
+
return parsed as Todo[]
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`acryl-todo: ${file} is not a valid todo list (${(error as Error).message}); fix or remove it, it was left untouched`, { cause: error })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function save(file: string, todos: readonly Todo[]): void {
|
|
80
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
81
|
+
const temporary = `${file}.tmp`
|
|
82
|
+
writeFileSync(temporary, JSON.stringify(todos))
|
|
83
|
+
renameSync(temporary, file)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function apply(ctx: Context, config: Config = {}): void {
|
|
87
|
+
const prefix = config.prefix ?? '/todo'
|
|
88
|
+
const todos = ctx.state.cell<readonly Todo[]>('todo.list', load(config.file))
|
|
89
|
+
if (config.file !== undefined) {
|
|
90
|
+
const file = config.file
|
|
91
|
+
ctx.effect(() => todos.subscribe(list => { save(file, list) }), 'acryl-todo: save to file')
|
|
92
|
+
}
|
|
93
|
+
const nextId = (): number => todos.get().reduce((max, todo) => Math.max(max, todo.id), 0) + 1
|
|
94
|
+
|
|
95
|
+
const find = (id: number): Todo => {
|
|
96
|
+
const todo = todos.get().find(item => item.id === id)
|
|
97
|
+
if (todo === undefined) throw new HTTPException(404, { message: 'no such todo' })
|
|
98
|
+
return todo
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `/todo/` is what people type: trimTrailingSlash answers a GET for an unknown path ending in `/` with a 301 to the path without it,
|
|
102
|
+
// so the page is found either way while each route keeps one canonical path (the router itself treats `/a` and `/a/` as different).
|
|
103
|
+
const api = new Acryl()
|
|
104
|
+
.use(loopbackOnly({ inProcess: 'allow' }), bodyLimit({ maxSize: MAX_BODY_BYTES }), trimTrailingSlash())
|
|
105
|
+
.get('/', c => c.html(pageHtml(prefix)))
|
|
106
|
+
.get('/api', c => c.json(todos.get()))
|
|
107
|
+
.post('/api', schemaValidator('json', NewTodo), c => {
|
|
108
|
+
const todo: Todo = { id: nextId(), title: c.req.valid('json').title, done: false }
|
|
109
|
+
todos.update(list => [...list, todo])
|
|
110
|
+
return c.json(todo, 201)
|
|
111
|
+
})
|
|
112
|
+
.patch('/api/:id', schemaValidator('json', TodoChange), c => {
|
|
113
|
+
const { id } = find(Number(c.req.param('id')))
|
|
114
|
+
const { done } = c.req.valid('json')
|
|
115
|
+
todos.update(list => list.map(todo => todo.id === id ? { ...todo, done } : todo))
|
|
116
|
+
return c.json(find(id))
|
|
117
|
+
})
|
|
118
|
+
.delete('/api/:id', c => {
|
|
119
|
+
const { id } = find(Number(c.req.param('id')))
|
|
120
|
+
todos.update(list => list.filter(todo => todo.id !== id))
|
|
121
|
+
return c.body(null, 204)
|
|
122
|
+
})
|
|
123
|
+
.get('/events', c => streamSSE(c, async stream => {
|
|
124
|
+
const ended = new Promise<void>(resolve => { stream.onAbort(resolve) })
|
|
125
|
+
// Writes are asynchronous; chaining them keeps events in the order the list changed.
|
|
126
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
127
|
+
const send = (list: readonly Todo[]): void => { queue = queue.then(() => stream.writeSSE({ data: JSON.stringify(list) })).catch(() => {}) }
|
|
128
|
+
send(todos.get())
|
|
129
|
+
const unsubscribe = todos.subscribe(send)
|
|
130
|
+
try {
|
|
131
|
+
await ended
|
|
132
|
+
} finally {
|
|
133
|
+
unsubscribe()
|
|
134
|
+
}
|
|
135
|
+
}))
|
|
136
|
+
ctx.router.route(prefix === '' ? '/' : prefix, api)
|
|
137
|
+
}
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -102,12 +102,9 @@ interface PluginSharePort {
|
|
|
102
102
|
list(): readonly unknown[];
|
|
103
103
|
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
104
104
|
remove(name: string): Promise<unknown>;
|
|
105
|
-
pull(source: string): Promise<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
readonly reason?: string;
|
|
109
|
-
readonly next?: string;
|
|
110
|
-
}>;
|
|
105
|
+
pull(source: string): Promise<PullView>;
|
|
106
|
+
/** Pull each source of a group's list on its own: one that is refused does not stop the others. */
|
|
107
|
+
pullList(from: string): Promise<PullListView>;
|
|
111
108
|
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | {
|
|
112
109
|
readonly ok: false;
|
|
113
110
|
readonly reason: string;
|
|
@@ -118,6 +115,28 @@ interface PluginSharePort {
|
|
|
118
115
|
readonly reason?: string;
|
|
119
116
|
}>;
|
|
120
117
|
}
|
|
118
|
+
/** What one pull did, or why it was refused. */
|
|
119
|
+
interface PullView {
|
|
120
|
+
readonly ok: boolean;
|
|
121
|
+
readonly action?: string;
|
|
122
|
+
readonly name?: string;
|
|
123
|
+
readonly reason?: string;
|
|
124
|
+
readonly next?: string;
|
|
125
|
+
}
|
|
126
|
+
/** What a list pull did for each source, or why the list itself was refused (then nothing was pulled). */
|
|
127
|
+
type PullListView = {
|
|
128
|
+
readonly ok: true;
|
|
129
|
+
readonly from: string;
|
|
130
|
+
readonly entries: readonly {
|
|
131
|
+
readonly line: number;
|
|
132
|
+
readonly source: string;
|
|
133
|
+
readonly result: PullView;
|
|
134
|
+
}[];
|
|
135
|
+
} | {
|
|
136
|
+
readonly ok: false;
|
|
137
|
+
readonly reason: string;
|
|
138
|
+
readonly next: string;
|
|
139
|
+
};
|
|
121
140
|
/** Who a plan publishes as, and under which license; the service's defaults when absent. */
|
|
122
141
|
interface PublishOptionsView {
|
|
123
142
|
readonly owner?: string;
|
|
@@ -223,6 +242,8 @@ declare class Door {
|
|
|
223
242
|
/** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
|
|
224
243
|
publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown>;
|
|
225
244
|
private pull;
|
|
245
|
+
/** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
|
|
246
|
+
private pullList;
|
|
226
247
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
227
248
|
private insideApp;
|
|
228
249
|
private offeredTools;
|
package/lib/cordis/door.js
CHANGED
|
@@ -464,15 +464,20 @@ const VERB_SPECS = {
|
|
|
464
464
|
}
|
|
465
465
|
},
|
|
466
466
|
"plugin-pull": {
|
|
467
|
-
usage: "plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check
|
|
468
|
-
description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.",
|
|
467
|
+
usage: "plugin pull <source> | --from <list>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), or every plugin of a group's list, check each and start it, live",
|
|
468
|
+
description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing. With from instead of source, pull every source of a group's list (one per line, # comments); each is pulled on its own, so a refused one is named with its reason and the others still come in.",
|
|
469
469
|
arguments: {
|
|
470
470
|
type: "object",
|
|
471
|
-
properties: {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
471
|
+
properties: {
|
|
472
|
+
source: {
|
|
473
|
+
type: "string",
|
|
474
|
+
description: "github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address"
|
|
475
|
+
},
|
|
476
|
+
from: {
|
|
477
|
+
type: "string",
|
|
478
|
+
description: "instead of source: a list of sources, a file in the app (e.g. group.txt) or an https address"
|
|
479
|
+
}
|
|
480
|
+
},
|
|
476
481
|
additionalProperties: false
|
|
477
482
|
}
|
|
478
483
|
},
|
|
@@ -708,7 +713,7 @@ var Door = class {
|
|
|
708
713
|
}
|
|
709
714
|
case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
|
|
710
715
|
case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
|
|
711
|
-
case "plugin-pull": return this.pull(input.source);
|
|
716
|
+
case "plugin-pull": return input.from === void 0 ? this.pull(input.source) : this.pullList(input.from, input.source);
|
|
712
717
|
case "plugin-publish": return this.planPublish(input);
|
|
713
718
|
case "plugin-remove": {
|
|
714
719
|
const name = input.name;
|
|
@@ -997,13 +1002,29 @@ var Door = class {
|
|
|
997
1002
|
async pull(source) {
|
|
998
1003
|
if (typeof source !== "string" || source === "") throw new DoorError("invalid", "plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0");
|
|
999
1004
|
const value = await this.share().pull(source);
|
|
1000
|
-
if (!value.ok) throw new DoorError("refused", `${value.reason ?? "the pull was refused"}
|
|
1005
|
+
if (!value.ok) throw new DoorError("refused", `${sentence(value.reason ?? "the pull was refused")} ${sentence(value.next ?? "nothing changed")}`);
|
|
1001
1006
|
if (value.action !== "unchanged" && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
|
|
1002
1007
|
return {
|
|
1003
1008
|
value,
|
|
1004
1009
|
text: JSON.stringify(value, null, 2)
|
|
1005
1010
|
};
|
|
1006
1011
|
}
|
|
1012
|
+
/** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
|
|
1013
|
+
async pullList(from, source) {
|
|
1014
|
+
if (source !== void 0) throw new DoorError("invalid", "plugin pull takes a source or --from <list>, not both");
|
|
1015
|
+
if (typeof from !== "string" || from === "") throw new DoorError("invalid", "plugin pull --from needs a list, e.g. plugin pull --from group.txt");
|
|
1016
|
+
const value = await this.share().pullList(from);
|
|
1017
|
+
if (!value.ok) throw new DoorError("refused", `${sentence(value.reason)} ${sentence(value.next)}`);
|
|
1018
|
+
if (value.entries.some((entry) => entry.result.ok && entry.result.action !== "unchanged") && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
|
|
1019
|
+
const lines = value.entries.map((entry) => `${entry.source}: ${pullLine(entry.result)}`);
|
|
1020
|
+
const refused = value.entries.filter((entry) => !entry.result.ok);
|
|
1021
|
+
const summary = `${String(value.entries.length - refused.length)} of ${String(value.entries.length)} from ${value.from} pulled`;
|
|
1022
|
+
if (refused.length > 0) throw new DoorError("refused", [`${summary}; ${String(refused.length)} refused, the others are in and running.`, ...lines].join("\n"));
|
|
1023
|
+
return {
|
|
1024
|
+
value,
|
|
1025
|
+
text: [`${summary}.`, ...lines].join("\n")
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1007
1028
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
1008
1029
|
insideApp(folder) {
|
|
1009
1030
|
if (typeof folder !== "string" || folder === "") throw new DoorError("invalid", "plugin add needs a folder, e.g. plugins/pomodoro");
|
|
@@ -1040,6 +1061,16 @@ var Door = class {
|
|
|
1040
1061
|
};
|
|
1041
1062
|
}
|
|
1042
1063
|
};
|
|
1064
|
+
/** One source's outcome in a list pull, as a person reads it. */
|
|
1065
|
+
function pullLine(result) {
|
|
1066
|
+
if (!result.ok) return `refused: ${sentence(result.reason ?? "the pull was refused")} ${sentence(result.next ?? "nothing changed")}`;
|
|
1067
|
+
return `${result.action ?? "pulled"}${result.name === void 0 ? "" : ` ${result.name}`}`;
|
|
1068
|
+
}
|
|
1069
|
+
/** A text as one sentence: the full stop added only when it has no closing punctuation of its own (a reason may end with one, a next step often does). */
|
|
1070
|
+
function sentence(text) {
|
|
1071
|
+
const trimmed = text.trim();
|
|
1072
|
+
return /[.!?]$/u.test(trimmed) ? trimmed : `${trimmed}.`;
|
|
1073
|
+
}
|
|
1043
1074
|
//#endregion
|
|
1044
1075
|
//#region ../../plugins/acryl-door/src/host/chat-tools.ts
|
|
1045
1076
|
/**
|
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
17
17
|
import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
|
|
18
18
|
|
|
19
19
|
const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
|
|
20
|
-
const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
|
|
20
|
+
const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path', '--from'])
|
|
21
21
|
const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
|
|
22
22
|
|
|
23
23
|
export class UsageError extends Error {}
|
|
@@ -139,7 +139,12 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
|
|
|
139
139
|
case 'plugin': {
|
|
140
140
|
if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
|
|
141
141
|
if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
|
|
142
|
-
if (second === 'pull') {
|
|
142
|
+
if (second === 'pull') {
|
|
143
|
+
const from = values.get('--from')
|
|
144
|
+
if (from !== undefined) { if (third !== undefined) throw new UsageError('plugin pull takes a source or --from <list>, not both'); return make('plugin-pull', { from }) }
|
|
145
|
+
if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0, or --from <list>')
|
|
146
|
+
return make('plugin-pull', { source: third })
|
|
147
|
+
}
|
|
143
148
|
if (second === 'publish') {
|
|
144
149
|
if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
|
|
145
150
|
const owner = values.get('--owner')
|
|
@@ -41,11 +41,21 @@ export interface PluginSharePort {
|
|
|
41
41
|
list(): readonly unknown[]
|
|
42
42
|
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
43
43
|
remove(name: string): Promise<unknown>
|
|
44
|
-
pull(source: string): Promise<
|
|
44
|
+
pull(source: string): Promise<PullView>
|
|
45
|
+
/** Pull each source of a group's list on its own: one that is refused does not stop the others. */
|
|
46
|
+
pullList(from: string): Promise<PullListView>
|
|
45
47
|
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
|
|
46
48
|
publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** What one pull did, or why it was refused. */
|
|
52
|
+
export interface PullView { readonly ok: boolean; readonly action?: string; readonly name?: string; readonly reason?: string; readonly next?: string }
|
|
53
|
+
|
|
54
|
+
/** What a list pull did for each source, or why the list itself was refused (then nothing was pulled). */
|
|
55
|
+
export type PullListView =
|
|
56
|
+
| { readonly ok: true; readonly from: string; readonly entries: readonly { readonly line: number; readonly source: string; readonly result: PullView }[] }
|
|
57
|
+
| { readonly ok: false; readonly reason: string; readonly next: string }
|
|
58
|
+
|
|
49
59
|
/** Who a plan publishes as, and under which license; the service's defaults when absent. */
|
|
50
60
|
export interface PublishOptionsView {
|
|
51
61
|
readonly owner?: string
|
|
@@ -159,7 +169,7 @@ export class Door {
|
|
|
159
169
|
}
|
|
160
170
|
case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
|
|
161
171
|
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
162
|
-
case 'plugin-pull': return this.pull(input.source)
|
|
172
|
+
case 'plugin-pull': return input.from === undefined ? this.pull(input.source) : this.pullList(input.from, input.source)
|
|
163
173
|
case 'plugin-publish': return this.planPublish(input)
|
|
164
174
|
case 'plugin-remove': {
|
|
165
175
|
const name = input.name
|
|
@@ -388,12 +398,28 @@ export class Door {
|
|
|
388
398
|
if (typeof source !== 'string' || source === '') throw new DoorError('invalid', 'plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0')
|
|
389
399
|
const value = await this.share().pull(source)
|
|
390
400
|
// A refusal is a failed command, so a script does not read it as done; it changed nothing, and the open pages stay.
|
|
391
|
-
if (!value.ok) throw new DoorError('refused', `${value.reason ?? 'the pull was refused'}
|
|
401
|
+
if (!value.ok) throw new DoorError('refused', `${sentence(value.reason ?? 'the pull was refused')} ${sentence(value.next ?? 'nothing changed')}`)
|
|
392
402
|
// Neither did pulling what is already there change anything.
|
|
393
403
|
if (value.action !== 'unchanged' && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
|
|
394
404
|
return { value, text: JSON.stringify(value, null, 2) }
|
|
395
405
|
}
|
|
396
406
|
|
|
407
|
+
/** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
|
|
408
|
+
private async pullList(from: unknown, source: unknown): Promise<VerbAnswer> {
|
|
409
|
+
if (source !== undefined) throw new DoorError('invalid', 'plugin pull takes a source or --from <list>, not both')
|
|
410
|
+
if (typeof from !== 'string' || from === '') throw new DoorError('invalid', 'plugin pull --from needs a list, e.g. plugin pull --from group.txt')
|
|
411
|
+
const value = await this.share().pullList(from)
|
|
412
|
+
if (!value.ok) throw new DoorError('refused', `${sentence(value.reason)} ${sentence(value.next)}`)
|
|
413
|
+
const changed = value.entries.some(entry => entry.result.ok && entry.result.action !== 'unchanged')
|
|
414
|
+
if (changed && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
|
|
415
|
+
const lines = value.entries.map(entry => `${entry.source}: ${pullLine(entry.result)}`)
|
|
416
|
+
const refused = value.entries.filter(entry => !entry.result.ok)
|
|
417
|
+
const summary = `${String(value.entries.length - refused.length)} of ${String(value.entries.length)} from ${value.from} pulled`
|
|
418
|
+
// Some refused: the command failed, so a script sees it, but the ones that came in stay in and keep running.
|
|
419
|
+
if (refused.length > 0) throw new DoorError('refused', [`${summary}; ${String(refused.length)} refused, the others are in and running.`, ...lines].join('\n'))
|
|
420
|
+
return { value, text: [`${summary}.`, ...lines].join('\n') }
|
|
421
|
+
}
|
|
422
|
+
|
|
397
423
|
/** A folder named by an agent, resolved from the app folder; it must be inside the app. */
|
|
398
424
|
private insideApp(folder: unknown): string {
|
|
399
425
|
if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
|
|
@@ -426,3 +452,15 @@ export class Door {
|
|
|
426
452
|
return { value, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
|
|
427
453
|
}
|
|
428
454
|
}
|
|
455
|
+
|
|
456
|
+
/** One source's outcome in a list pull, as a person reads it. */
|
|
457
|
+
function pullLine(result: PullView): string {
|
|
458
|
+
if (!result.ok) return `refused: ${sentence(result.reason ?? 'the pull was refused')} ${sentence(result.next ?? 'nothing changed')}`
|
|
459
|
+
return `${result.action ?? 'pulled'}${result.name === undefined ? '' : ` ${result.name}`}`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** A text as one sentence: the full stop added only when it has no closing punctuation of its own (a reason may end with one, a next step often does). */
|
|
463
|
+
function sentence(text: string): string {
|
|
464
|
+
const trimmed = text.trim()
|
|
465
|
+
return /[.!?]$/u.test(trimmed) ? trimmed : `${trimmed}.`
|
|
466
|
+
}
|
|
@@ -87,9 +87,9 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
87
87
|
arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
|
|
88
88
|
},
|
|
89
89
|
'plugin-pull': {
|
|
90
|
-
usage: 'plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check
|
|
91
|
-
description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.',
|
|
92
|
-
arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' }
|
|
90
|
+
usage: 'plugin pull <source> | --from <list>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), or every plugin of a group\'s list, check each and start it, live',
|
|
91
|
+
description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing. With from instead of source, pull every source of a group\'s list (one per line, # comments); each is pulled on its own, so a refused one is named with its reason and the others still come in.',
|
|
92
|
+
arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' }, from: { type: 'string', description: 'instead of source: a list of sources, a file in the app (e.g. group.txt) or an https address' } }, additionalProperties: false },
|
|
93
93
|
},
|
|
94
94
|
'plugin-publish': {
|
|
95
95
|
usage: 'plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person\'s own terminal, ask them',
|
|
@@ -116,6 +116,8 @@ export interface ExtensionInfo {
|
|
|
116
116
|
readonly missing: readonly string[]
|
|
117
117
|
/** The source folder no longer exists; the last installed version still runs. */
|
|
118
118
|
readonly stale: boolean
|
|
119
|
+
/** The routes its mount added, as the plugin wrote them (without the frame's prefix), so a surface can link to its pages. */
|
|
120
|
+
readonly routes: readonly { readonly method: string; readonly path: string }[]
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
export type InstallResult =
|
|
@@ -353,6 +355,7 @@ export class Extensions extends Service {
|
|
|
353
355
|
state,
|
|
354
356
|
missing: state === 'pending' ? this.missing(entry.module) : [],
|
|
355
357
|
stale: this.stale.has(entry.name),
|
|
358
|
+
routes: entry.routes.map(({ method, path }) => ({ method, path: this.written(path) })),
|
|
356
359
|
}
|
|
357
360
|
})
|
|
358
361
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
@@ -609,6 +612,13 @@ export class Extensions extends Service {
|
|
|
609
612
|
return servedRoutes(fibers)
|
|
610
613
|
}
|
|
611
614
|
|
|
615
|
+
/** A served path as the plugin wrote it: without the frame's canvas prefix. */
|
|
616
|
+
private written(path: string): string {
|
|
617
|
+
return this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
618
|
+
? path.slice(this.routePrefix.length)
|
|
619
|
+
: path
|
|
620
|
+
}
|
|
621
|
+
|
|
612
622
|
/**
|
|
613
623
|
* A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
|
|
614
624
|
* reached. Refused before the plugin's own check, which the other plugin's answer would pass.
|
|
@@ -624,15 +634,11 @@ export class Extensions extends Service {
|
|
|
624
634
|
[...this.installed.values()].find((entry) =>
|
|
625
635
|
entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
|
|
626
636
|
)?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
|
|
627
|
-
const shown = (path: string): string =>
|
|
628
|
-
this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
629
|
-
? path.slice(this.routePrefix.length)
|
|
630
|
-
: path
|
|
631
637
|
const same =
|
|
632
|
-
taken.path === route.path ? '' : ` (it serves ${taken.method} ${
|
|
638
|
+
taken.path === route.path ? '' : ` (it serves ${taken.method} ${this.written(taken.path)})`
|
|
633
639
|
return {
|
|
634
640
|
stage: 'conflict',
|
|
635
|
-
error: `${route.method} ${
|
|
641
|
+
error: `${route.method} ${this.written(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
|
|
636
642
|
}
|
|
637
643
|
}
|
|
638
644
|
|