acrylblends 1.3.0 → 1.3.1
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.js +5 -3
- package/dist/index.js.map +1 -1
- package/docs/framework/workshop/README.md +2 -0
- package/lib/cordis/door.d.ts +10 -1
- package/lib/cordis/door.js +11 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/host/door.ts +17 -5
- package/plugins/builder/index.ts +1 -1
- package/plugins/builder/plugin.json +1 -1
- package/plugins/extensions/index.ts +57 -2
- package/plugins/extensions/routes.ts +92 -0
- package/plugins/home/index.ts +3 -1
- package/plugins/plugin-share/README.md +16 -1
- package/plugins/plugin-share/index.ts +130 -16
- package/plugins/plugin-share/plugin.json +1 -1
- package/templates/agent/src/app.ts +2 -0
- package/templates/blank/blend.yaml +3 -0
- package/templates/blank/src/app.ts +8 -0
- package/templates/blank/template.json +1 -0
- package/templates/chat/blend.yaml +3 -0
- package/templates/chat/src/app.ts +8 -0
- package/templates/chat/template.json +1 -0
- package/templates/nodejs/blend.yaml +3 -0
- package/templates/nodejs/src/app.ts +8 -0
- package/templates/nodejs/template.json +1 -0
- package/templates/notes/blend.yaml +3 -0
- package/templates/notes/src/app.ts +8 -0
- package/templates/notes/template.json +1 -0
- /package/plugins/{builder → plugin-share}/package.ts +0 -0
|
@@ -87,6 +87,8 @@ npm install
|
|
|
87
87
|
npm run dev
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
npm may warn that 5 packages have install scripts not covered by allowScripts; that is a notice, not an error - nothing to do.
|
|
91
|
+
|
|
90
92
|
`npm install` is bigger than a framework-only app: it brings the frame and the chat engine (about 500 MB together, much less on a warm package
|
|
91
93
|
cache). `npm run dev` starts the app inside the frame and prints one line:
|
|
92
94
|
|
package/lib/cordis/door.d.ts
CHANGED
|
@@ -96,8 +96,12 @@ interface ExtensionsPort {
|
|
|
96
96
|
reload(): Promise<unknown>;
|
|
97
97
|
list(): readonly unknown[];
|
|
98
98
|
}
|
|
99
|
-
/** The slice of acrylblends' `pluginShare` service the pull
|
|
99
|
+
/** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
|
|
100
100
|
interface PluginSharePort {
|
|
101
|
+
/** The plugins installed while the app runs, a pulled one with its source and author. */
|
|
102
|
+
list(): readonly unknown[];
|
|
103
|
+
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
104
|
+
remove(name: string): Promise<unknown>;
|
|
101
105
|
pull(source: string): Promise<{
|
|
102
106
|
readonly ok: boolean;
|
|
103
107
|
readonly action?: string;
|
|
@@ -207,6 +211,11 @@ declare class Door {
|
|
|
207
211
|
private parse;
|
|
208
212
|
/** Run one call on the page and record it. The audit holds which control was touched and how it ended, never what was typed. */
|
|
209
213
|
private callPage;
|
|
214
|
+
/**
|
|
215
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
216
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
217
|
+
*/
|
|
218
|
+
private installed;
|
|
210
219
|
private plugin;
|
|
211
220
|
private share;
|
|
212
221
|
/** Only a plan: no argument of any verb can publish, so an agent never says yes for a person. */
|
package/lib/cordis/door.js
CHANGED
|
@@ -706,14 +706,14 @@ var Door = class {
|
|
|
706
706
|
text: `done${result.target === void 0 ? "" : `: ${result.target.role} "${result.target.name}"`}${result.detail === void 0 ? "" : ` (${result.detail})`}; look again before the next act`
|
|
707
707
|
};
|
|
708
708
|
}
|
|
709
|
-
case "plugin-list": return this.plugin((
|
|
709
|
+
case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
|
|
710
710
|
case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
|
|
711
711
|
case "plugin-pull": return this.pull(input.source);
|
|
712
712
|
case "plugin-publish": return this.planPublish(input);
|
|
713
713
|
case "plugin-remove": {
|
|
714
714
|
const name = input.name;
|
|
715
715
|
if (typeof name !== "string" || name === "") throw new DoorError("invalid", "plugin remove needs a plugin name");
|
|
716
|
-
return this.plugin((
|
|
716
|
+
return this.plugin((plugins) => plugins.remove(name), true, this.installed());
|
|
717
717
|
}
|
|
718
718
|
case "plugin-reload": return this.plugin((extensions) => extensions.reload());
|
|
719
719
|
case "tool-list": {
|
|
@@ -942,8 +942,15 @@ var Door = class {
|
|
|
942
942
|
throw cause;
|
|
943
943
|
}
|
|
944
944
|
}
|
|
945
|
-
|
|
946
|
-
|
|
945
|
+
/**
|
|
946
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
947
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
948
|
+
*/
|
|
949
|
+
installed() {
|
|
950
|
+
return this.deps.pluginShare?.() ?? this.deps.extensions();
|
|
951
|
+
}
|
|
952
|
+
async plugin(run, mutates = true, port = this.deps.extensions()) {
|
|
953
|
+
const extensions = port;
|
|
947
954
|
if (extensions === void 0) throw new DoorError("unavailable", "this door has no extensions plugin to drive. In ACRYL's frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs");
|
|
948
955
|
const value = await run(extensions);
|
|
949
956
|
const unchanged = typeof value === "object" && value !== null && value.action === "unchanged";
|
package/package.json
CHANGED
|
@@ -35,8 +35,12 @@ export interface ExtensionsPort {
|
|
|
35
35
|
list(): readonly unknown[]
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** The slice of acrylblends' `pluginShare` service the pull
|
|
38
|
+
/** The slice of acrylblends' `pluginShare` service the pull, list and remove verbs use. */
|
|
39
39
|
export interface PluginSharePort {
|
|
40
|
+
/** The plugins installed while the app runs, a pulled one with its source and author. */
|
|
41
|
+
list(): readonly unknown[]
|
|
42
|
+
/** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
|
|
43
|
+
remove(name: string): Promise<unknown>
|
|
40
44
|
pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
|
|
41
45
|
planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
|
|
42
46
|
publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
|
|
@@ -153,14 +157,14 @@ export class Door {
|
|
|
153
157
|
const result = await this.act(input, signal, scope)
|
|
154
158
|
return { value: result, text: `done${result.target === undefined ? '' : `: ${result.target.role} "${result.target.name}"`}${result.detail === undefined ? '' : ` (${result.detail})`}; look again before the next act` }
|
|
155
159
|
}
|
|
156
|
-
case 'plugin-list': return this.plugin(
|
|
160
|
+
case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
|
|
157
161
|
case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
|
|
158
162
|
case 'plugin-pull': return this.pull(input.source)
|
|
159
163
|
case 'plugin-publish': return this.planPublish(input)
|
|
160
164
|
case 'plugin-remove': {
|
|
161
165
|
const name = input.name
|
|
162
166
|
if (typeof name !== 'string' || name === '') throw new DoorError('invalid', 'plugin remove needs a plugin name')
|
|
163
|
-
return this.plugin(
|
|
167
|
+
return this.plugin(plugins => plugins.remove(name), true, this.installed())
|
|
164
168
|
}
|
|
165
169
|
case 'plugin-reload': return this.plugin(extensions => extensions.reload())
|
|
166
170
|
case 'tool-list': {
|
|
@@ -337,8 +341,16 @@ export class Door {
|
|
|
337
341
|
}
|
|
338
342
|
}
|
|
339
343
|
|
|
340
|
-
|
|
341
|
-
|
|
344
|
+
/**
|
|
345
|
+
* Who lists and removes the app's plugins: plugin-share where the app has it, as it knows where each pulled plugin came from and is the one app service
|
|
346
|
+
* the frame's host reaches (extensions stays in the app's own realm there); extensions elsewhere.
|
|
347
|
+
*/
|
|
348
|
+
private installed(): Pick<ExtensionsPort, 'list' | 'remove'> | undefined {
|
|
349
|
+
return this.deps.pluginShare?.() ?? this.deps.extensions()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async plugin<Port = ExtensionsPort>(run: (plugins: Port) => unknown, mutates = true, port: Port | undefined = this.deps.extensions() as Port | undefined): Promise<VerbAnswer> {
|
|
353
|
+
const extensions = port
|
|
342
354
|
if (extensions === undefined) throw new DoorError('unavailable', 'this door has no extensions plugin to drive. In ACRYL\'s frame none is needed: a saved change in src/ or plugins/ reloads the app by itself, so do not restart it. Elsewhere, the app has no way to install plugins while it runs')
|
|
343
355
|
const value = await run(extensions)
|
|
344
356
|
// The app changed under the open pages: reload them (not when the plugin was already as it should be), and answer once they are back.
|
package/plugins/builder/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
11
11
|
import { scripted, type Tools } from '../agent/index.ts'
|
|
12
12
|
import { NAME } from '../extensions/index.ts'
|
|
13
13
|
import type {} from '../instance/index.ts'
|
|
14
|
-
import { packagePlugin } from '
|
|
14
|
+
import { packagePlugin } from '../plugin-share/package.ts'
|
|
15
15
|
import { builderRules } from './script.ts'
|
|
16
16
|
|
|
17
17
|
export interface BuilderConfig {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "builder",
|
|
3
3
|
"description": "The agent's tools for building the app it runs in: write, install, read, list and remove extensions, with real results.",
|
|
4
|
-
"requires": ["agent", "extensions", "sandbox"],
|
|
4
|
+
"requires": ["agent", "extensions", "sandbox", "plugin-share"],
|
|
5
5
|
"inject": ["extensions", "tools", "appInstance"],
|
|
6
6
|
"provides": [],
|
|
7
7
|
"dependencies": {
|
|
@@ -32,6 +32,13 @@ import type {} from '../instance/index.ts'
|
|
|
32
32
|
import type { Sandbox } from '../sandbox/index.ts'
|
|
33
33
|
import { appendLedger, type LedgerEntry, readLedger } from './ledger.ts'
|
|
34
34
|
import { type PluginStore, pluginVersion } from './plugin-store.ts'
|
|
35
|
+
import {
|
|
36
|
+
addedRoutes,
|
|
37
|
+
type FiberRoutesView,
|
|
38
|
+
routeClash,
|
|
39
|
+
type ServedRoute,
|
|
40
|
+
servedRoutes,
|
|
41
|
+
} from './routes.ts'
|
|
35
42
|
import { folderPluginStore } from './stage.ts'
|
|
36
43
|
|
|
37
44
|
/** A fiber's lifecycle state, by name (Cordis's own enum is a `const enum`, which does not exist at run time). */
|
|
@@ -93,6 +100,8 @@ export interface ExtensionsConfig {
|
|
|
93
100
|
* are not extensions, so they are neither found nor started here, and installing one is refused with that reason.
|
|
94
101
|
*/
|
|
95
102
|
rows?: readonly string[]
|
|
103
|
+
/** The path the app is mounted under (the frame's canvas, `/app`): a message names a route as the plugin wrote it, without it. */
|
|
104
|
+
routePrefix?: string
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
const READ_ONLY =
|
|
@@ -121,7 +130,7 @@ export type InstallResult =
|
|
|
121
130
|
| {
|
|
122
131
|
readonly ok: false
|
|
123
132
|
/** check: before anything changed; import: loading the code; activate: starting it; verify: its own `check` failed. */
|
|
124
|
-
readonly stage: 'check' | 'import' | 'activate' | 'verify' | 'approval'
|
|
133
|
+
readonly stage: 'check' | 'import' | 'activate' | 'conflict' | 'verify' | 'approval'
|
|
125
134
|
readonly name?: string
|
|
126
135
|
readonly errors: readonly string[]
|
|
127
136
|
/** Present for an update: whether the version that was running is running again. */
|
|
@@ -157,6 +166,8 @@ interface Installed {
|
|
|
157
166
|
readonly dir: string
|
|
158
167
|
readonly module: PluginModule
|
|
159
168
|
fiber: Fiber
|
|
169
|
+
/** The routes its mount added, so a later plugin whose route is taken is told whose it is. */
|
|
170
|
+
readonly routes: readonly ServedRoute[]
|
|
160
171
|
}
|
|
161
172
|
|
|
162
173
|
interface Record_ {
|
|
@@ -245,11 +256,13 @@ export class Extensions extends Service {
|
|
|
245
256
|
*/
|
|
246
257
|
private readonly owner: Context
|
|
247
258
|
private readonly store: PluginStore
|
|
259
|
+
private readonly routePrefix: string
|
|
248
260
|
|
|
249
261
|
constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
|
|
250
262
|
super(ctx, 'extensions')
|
|
251
263
|
this.owner = ctx
|
|
252
264
|
this.store = store
|
|
265
|
+
this.routePrefix = (config.routePrefix ?? '').replace(/\/+$/u, '')
|
|
253
266
|
this.readOnly = config.readOnly === true
|
|
254
267
|
this.off = new Set(config.off ?? [])
|
|
255
268
|
this.rows = new Set(config.rows ?? [])
|
|
@@ -588,6 +601,41 @@ export class Extensions extends Service {
|
|
|
588
601
|
}
|
|
589
602
|
|
|
590
603
|
/** Mount a module as a child fiber and wait until it settles. */
|
|
604
|
+
/** Every route the app serves now, read from the router's labels on every running fiber. */
|
|
605
|
+
private served(): ServedRoute[] {
|
|
606
|
+
const fibers = [...this.ctx.registry.values()].flatMap(
|
|
607
|
+
(runtime) => [...runtime.fibers] as unknown as FiberRoutesView[],
|
|
608
|
+
)
|
|
609
|
+
return servedRoutes(fibers)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 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
|
+
* reached. Refused before the plugin's own check, which the other plugin's answer would pass.
|
|
615
|
+
*/
|
|
616
|
+
private conflict(
|
|
617
|
+
added: readonly ServedRoute[],
|
|
618
|
+
before: readonly ServedRoute[],
|
|
619
|
+
): { stage: 'conflict'; error: string } | undefined {
|
|
620
|
+
const clash = routeClash(added, before)
|
|
621
|
+
if (clash === undefined) return undefined
|
|
622
|
+
const { route, taken } = clash
|
|
623
|
+
const owner =
|
|
624
|
+
[...this.installed.values()].find((entry) =>
|
|
625
|
+
entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
|
|
626
|
+
)?.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
|
+
const same =
|
|
632
|
+
taken.path === route.path ? '' : ` (it serves ${taken.method} ${shown(taken.path)})`
|
|
633
|
+
return {
|
|
634
|
+
stage: 'conflict',
|
|
635
|
+
error: `${route.method} ${shown(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
591
639
|
private async mount(module: PluginModule): Promise<{ fiber: Fiber; error?: unknown }> {
|
|
592
640
|
// An extension is a plugin module whose config, if any, comes from its own defaults.
|
|
593
641
|
const fiber = this.owner.plugin(module as unknown as Parameters<Context['plugin']>[0])
|
|
@@ -666,8 +714,10 @@ export class Extensions extends Service {
|
|
|
666
714
|
}
|
|
667
715
|
|
|
668
716
|
if (previous) await previous.fiber.dispose()
|
|
717
|
+
const before = this.served()
|
|
669
718
|
const mounted = await this.mount(module)
|
|
670
719
|
const state = STATES[mounted.fiber.state] ?? 'failed'
|
|
720
|
+
const added = state === 'active' ? addedRoutes(before, this.served()) : []
|
|
671
721
|
// Commit before "done": a plugin that started still has to pass its own check before the install counts.
|
|
672
722
|
const failure =
|
|
673
723
|
mounted.error !== undefined || state === 'failed'
|
|
@@ -676,7 +726,7 @@ export class Extensions extends Service {
|
|
|
676
726
|
error: message(mounted.error ?? 'the plugin failed to start'),
|
|
677
727
|
}
|
|
678
728
|
: state === 'active'
|
|
679
|
-
? await this.verify(module, mounted.fiber.ctx)
|
|
729
|
+
? (this.conflict(added, before) ?? (await this.verify(module, mounted.fiber.ctx)))
|
|
680
730
|
: undefined
|
|
681
731
|
if (failure) {
|
|
682
732
|
await mounted.fiber.dispose().catch(() => undefined)
|
|
@@ -714,6 +764,7 @@ export class Extensions extends Service {
|
|
|
714
764
|
dir: staged.dir,
|
|
715
765
|
module,
|
|
716
766
|
fiber: mounted.fiber,
|
|
767
|
+
routes: added,
|
|
717
768
|
})
|
|
718
769
|
this.stale.delete(name)
|
|
719
770
|
this.saveState()
|
|
@@ -758,6 +809,7 @@ export class Extensions extends Service {
|
|
|
758
809
|
}
|
|
759
810
|
try {
|
|
760
811
|
const module = await this.load(folder.dir, folder.name)
|
|
812
|
+
const before = this.served()
|
|
761
813
|
const mounted = await this.mount(module)
|
|
762
814
|
this.installed.set(folder.name, {
|
|
763
815
|
name: folder.name,
|
|
@@ -766,6 +818,7 @@ export class Extensions extends Service {
|
|
|
766
818
|
dir: folder.dir,
|
|
767
819
|
module,
|
|
768
820
|
fiber: mounted.fiber,
|
|
821
|
+
routes: addedRoutes(before, this.served()),
|
|
769
822
|
})
|
|
770
823
|
if (mounted.error !== undefined)
|
|
771
824
|
summary.failed.push({ name: folder.name, error: message(mounted.error) })
|
|
@@ -830,6 +883,7 @@ export class Extensions extends Service {
|
|
|
830
883
|
continue
|
|
831
884
|
}
|
|
832
885
|
const module = await this.load(record.dir, name)
|
|
886
|
+
const before = this.served()
|
|
833
887
|
const mounted = await this.mount(module)
|
|
834
888
|
if (mounted.error !== undefined)
|
|
835
889
|
summary.failed.push({ name, error: message(mounted.error) })
|
|
@@ -840,6 +894,7 @@ export class Extensions extends Service {
|
|
|
840
894
|
dir: record.dir,
|
|
841
895
|
module,
|
|
842
896
|
fiber: mounted.fiber,
|
|
897
|
+
routes: addedRoutes(before, this.served()),
|
|
843
898
|
})
|
|
844
899
|
} catch (error) {
|
|
845
900
|
summary.failed.push({ name, error: message(error) })
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which routes a plugin brings, and whether one of them is already served (spec 049 T025). Plugins that do not know each other share one app: the router
|
|
3
|
+
* serves the first mount of a route and ignores the second without a word, so a plugin whose route is taken would look installed and answer nothing.
|
|
4
|
+
* The router labels each route it serves (`acryl-router: serves GET /notes`, under the mount's effect), so the routes are read from the running system.
|
|
5
|
+
* Pure functions over those labels; the service reads them from the registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One route as the router serves it. */
|
|
9
|
+
export interface ServedRoute {
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly path: string
|
|
12
|
+
/** The plugin whose fiber holds the label. */
|
|
13
|
+
readonly owner: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The slice of a Cordis effect this module reads. */
|
|
17
|
+
export interface EffectView {
|
|
18
|
+
readonly label: string
|
|
19
|
+
readonly children: readonly EffectView[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The slice of a Cordis fiber this module reads. */
|
|
23
|
+
export interface FiberRoutesView {
|
|
24
|
+
readonly runtime: { readonly name?: string | undefined } | null
|
|
25
|
+
getEffects(): readonly EffectView[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SERVES = /^acryl-router: serves ([A-Z]+) (\S+)$/u
|
|
29
|
+
|
|
30
|
+
/** Every route served by the given fibers, with the plugin that holds each. `ALL` is middleware or `all`, which wraps routes rather than taking them. */
|
|
31
|
+
export function servedRoutes(fibers: Iterable<FiberRoutesView>): ServedRoute[] {
|
|
32
|
+
const routes: ServedRoute[] = []
|
|
33
|
+
const walk = (effects: readonly EffectView[], owner: string): void => {
|
|
34
|
+
for (const effect of effects) {
|
|
35
|
+
const served = SERVES.exec(effect.label)
|
|
36
|
+
if (served?.[1] !== undefined && served[2] !== undefined && served[1] !== 'ALL')
|
|
37
|
+
routes.push({ method: served[1], path: served[2], owner })
|
|
38
|
+
walk(effect.children, owner)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const fiber of fibers) walk(fiber.getEffects(), fiber.runtime?.name ?? '')
|
|
42
|
+
return routes
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const key = (route: ServedRoute): string => `${route.method} ${route.path}`
|
|
46
|
+
|
|
47
|
+
/** The routes in `after` that `before` did not have: what one mount added, counted, so a second mount of the same route counts as added. */
|
|
48
|
+
export function addedRoutes(
|
|
49
|
+
before: readonly ServedRoute[],
|
|
50
|
+
after: readonly ServedRoute[],
|
|
51
|
+
): ServedRoute[] {
|
|
52
|
+
const left = new Map<string, number>()
|
|
53
|
+
for (const route of before) left.set(key(route), (left.get(key(route)) ?? 0) + 1)
|
|
54
|
+
const added: ServedRoute[] = []
|
|
55
|
+
for (const route of after) {
|
|
56
|
+
const count = left.get(key(route)) ?? 0
|
|
57
|
+
if (count > 0) left.set(key(route), count - 1)
|
|
58
|
+
else added.push(route)
|
|
59
|
+
}
|
|
60
|
+
return added
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const segments = (path: string): string[] => path.split('/').filter((part) => part !== '')
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether one request could match both paths: equal segments, a `:param` matching any one segment, a `*` matching the rest. `/notes` and `/notes/list` are
|
|
67
|
+
* two routes; `/notes/*` and `/notes/list` are not.
|
|
68
|
+
*/
|
|
69
|
+
export function pathsOverlap(a: string, b: string): boolean {
|
|
70
|
+
const x = segments(a)
|
|
71
|
+
const y = segments(b)
|
|
72
|
+
for (let i = 0; ; i += 1) {
|
|
73
|
+
const p = x[i]
|
|
74
|
+
const q = y[i]
|
|
75
|
+
if (p === '*' || q === '*') return true
|
|
76
|
+
if (p === undefined || q === undefined) return p === q
|
|
77
|
+
if (p.startsWith(':') || q.startsWith(':')) continue
|
|
78
|
+
if (p !== q) return false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first route `added` shares with one already served: same method and an overlapping path. */
|
|
83
|
+
export function routeClash(
|
|
84
|
+
added: readonly ServedRoute[],
|
|
85
|
+
served: readonly ServedRoute[],
|
|
86
|
+
): { readonly route: ServedRoute; readonly taken: ServedRoute } | undefined {
|
|
87
|
+
for (const route of added)
|
|
88
|
+
for (const taken of served)
|
|
89
|
+
if (taken.method === route.method && pathsOverlap(taken.path, route.path))
|
|
90
|
+
return { route, taken }
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
package/plugins/home/index.ts
CHANGED
|
@@ -50,7 +50,9 @@ export function createHome(): Home {
|
|
|
50
50
|
`a widget id is a plugin name (lowercase letters, digits and dashes), not "${widget.id}"`,
|
|
51
51
|
)
|
|
52
52
|
if (registered.has(widget.id))
|
|
53
|
-
throw new Error(
|
|
53
|
+
throw new Error(
|
|
54
|
+
`the home page already has a widget "${widget.id}", the plugin ${widget.id}'s; a widget's id is the name of the plugin that puts it there`,
|
|
55
|
+
)
|
|
54
56
|
registered.set(widget.id, widget)
|
|
55
57
|
return () => {
|
|
56
58
|
if (registered.get(widget.id) === widget) registered.delete(widget.id)
|
|
@@ -14,6 +14,21 @@ The app must be running (`npm run dev`), because the pull installs into it live.
|
|
|
14
14
|
|
|
15
15
|
The app remembers where each pulled plugin came from, in the `plugin-share` row of `blend.yaml`. A copy of the app fetches the same bytes at its next start.
|
|
16
16
|
|
|
17
|
+
A plugin whose route or home widget another plugin already has is refused, and the message names that plugin. A different copy of a plugin with the same name is refused too, and the message says where the copy in the app came from.
|
|
18
|
+
|
|
19
|
+
## List and remove
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
npm run acryl -- plugin list
|
|
23
|
+
npm run acryl -- plugin remove <name>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The list names each plugin installed while the app runs, its version and state. A pulled one also shows the source it came from and its author, the GitHub account that released it.
|
|
27
|
+
|
|
28
|
+
Remove takes the plugin out of the running app and forgets its record. Its folder goes too when it still holds the release exactly as pulled; the pull line brings it back. A folder with someone's work in it stays, and the answer says so: the app's own plugin, or a pulled one changed since. The app then starts it again at its next start unless you delete the folder.
|
|
29
|
+
|
|
30
|
+
Both work in the frame as well.
|
|
31
|
+
|
|
17
32
|
## Publish
|
|
18
33
|
|
|
19
34
|
```
|
|
@@ -24,7 +39,7 @@ It prints the plan: account, repository, version, visibility and license. Only a
|
|
|
24
39
|
|
|
25
40
|
## An app made by an older version
|
|
26
41
|
|
|
27
|
-
Every new app has this plugin. `upgrade` does not add it to an older app, so add it and mount it by hand:
|
|
42
|
+
Every new app has this plugin, except one made from the todo template, which installs nothing live. `upgrade` does not add it to an older app, so add it and mount it by hand:
|
|
28
43
|
|
|
29
44
|
```
|
|
30
45
|
npx acrylblends add plugin-share
|