acrylblends 0.0.0-stage → 0.3.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.
Files changed (144) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +118 -0
  3. package/dist/index.js +454 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/acryl-inspect/LICENSE +21 -0
  7. package/plugins/acryl-inspect/plugin.json +11 -0
  8. package/plugins/acryl-inspect/src/index.ts +3 -0
  9. package/plugins/acryl-inspect/src/report.ts +123 -0
  10. package/plugins/acryl-inspect/src/service.ts +66 -0
  11. package/plugins/acryl-inspect/tests/report.spec.ts +64 -0
  12. package/plugins/acryl-inspect/tests/service.spec.ts +120 -0
  13. package/plugins/acryl-inspect/upstream.json +18 -0
  14. package/plugins/acryl-node-server/LICENSE +21 -0
  15. package/plugins/acryl-node-server/plugin.json +12 -0
  16. package/plugins/acryl-node-server/src/index.ts +94 -0
  17. package/plugins/acryl-node-server/tests/server.spec.ts +105 -0
  18. package/plugins/acryl-node-server/upstream.json +18 -0
  19. package/plugins/acryl-router/LICENSE +21 -0
  20. package/plugins/acryl-router/plugin.json +12 -0
  21. package/plugins/acryl-router/src/compose.ts +21 -0
  22. package/plugins/acryl-router/src/context.ts +59 -0
  23. package/plugins/acryl-router/src/helpers/index.ts +3 -0
  24. package/plugins/acryl-router/src/helpers/read-body.ts +59 -0
  25. package/plugins/acryl-router/src/helpers/sse.ts +83 -0
  26. package/plugins/acryl-router/src/helpers/validate.ts +44 -0
  27. package/plugins/acryl-router/src/http-error.ts +21 -0
  28. package/plugins/acryl-router/src/index.ts +6 -0
  29. package/plugins/acryl-router/src/middleware/body-limit.ts +25 -0
  30. package/plugins/acryl-router/src/middleware/cors.ts +46 -0
  31. package/plugins/acryl-router/src/middleware/index.ts +6 -0
  32. package/plugins/acryl-router/src/middleware/logger.ts +21 -0
  33. package/plugins/acryl-router/src/middleware/loopback-only.ts +82 -0
  34. package/plugins/acryl-router/src/middleware/request-id.ts +24 -0
  35. package/plugins/acryl-router/src/middleware/secure-headers.ts +23 -0
  36. package/plugins/acryl-router/src/pattern.ts +89 -0
  37. package/plugins/acryl-router/src/registrar.ts +90 -0
  38. package/plugins/acryl-router/src/service.ts +164 -0
  39. package/plugins/acryl-router/src/table.ts +156 -0
  40. package/plugins/acryl-router/src/types.ts +73 -0
  41. package/plugins/acryl-router/src/util.ts +23 -0
  42. package/plugins/acryl-router/tests/api.spec.ts +223 -0
  43. package/plugins/acryl-router/tests/conformance.ts +170 -0
  44. package/plugins/acryl-router/tests/helpers.spec.ts +172 -0
  45. package/plugins/acryl-router/tests/live-install.spec.ts +64 -0
  46. package/plugins/acryl-router/tests/loader.spec.ts +71 -0
  47. package/plugins/acryl-router/tests/middleware.spec.ts +183 -0
  48. package/plugins/acryl-router/tests/service.spec.ts +211 -0
  49. package/plugins/acryl-router/tests/table.spec.ts +47 -0
  50. package/plugins/acryl-router/upstream.json +20 -0
  51. package/plugins/acryl-router-http/LICENSE +21 -0
  52. package/plugins/acryl-router-http/plugin.json +11 -0
  53. package/plugins/acryl-router-http/src/bridge.ts +52 -0
  54. package/plugins/acryl-router-http/src/index.ts +63 -0
  55. package/plugins/acryl-router-http/src/serve.ts +24 -0
  56. package/plugins/acryl-router-http/tests/adapter.spec.ts +170 -0
  57. package/plugins/acryl-router-http/tests/stream.spec.ts +133 -0
  58. package/plugins/acryl-router-http/tests/surfaces.spec.ts +99 -0
  59. package/plugins/acryl-router-http/upstream.json +22 -0
  60. package/plugins/acryl-state/LICENSE +21 -0
  61. package/plugins/acryl-state/plugin.json +12 -0
  62. package/plugins/acryl-state/src/cell.ts +67 -0
  63. package/plugins/acryl-state/src/index.ts +3 -0
  64. package/plugins/acryl-state/src/react.ts +11 -0
  65. package/plugins/acryl-state/src/service.ts +101 -0
  66. package/plugins/acryl-state/tests/cell.spec.ts +71 -0
  67. package/plugins/acryl-state/tests/react.spec.ts +39 -0
  68. package/plugins/acryl-state/tests/service.spec.ts +132 -0
  69. package/plugins/acryl-state/upstream.json +20 -0
  70. package/plugins/agent/agent.ts +152 -0
  71. package/plugins/agent/index.ts +20 -0
  72. package/plugins/agent/memory.ts +24 -0
  73. package/plugins/agent/model.ts +79 -0
  74. package/plugins/agent/plugin.json +10 -0
  75. package/plugins/agent/tools.ts +50 -0
  76. package/plugins/builder/index.ts +193 -0
  77. package/plugins/builder/plugin.json +11 -0
  78. package/plugins/builder/script.ts +161 -0
  79. package/plugins/extensions/index.ts +755 -0
  80. package/plugins/extensions/ledger.ts +39 -0
  81. package/plugins/extensions/plugin.json +12 -0
  82. package/plugins/extensions/resolve.ts +30 -0
  83. package/plugins/extensions/stage.ts +44 -0
  84. package/plugins/instance/index.ts +167 -0
  85. package/plugins/instance/plugin.json +10 -0
  86. package/plugins/manifest/index.ts +635 -0
  87. package/plugins/manifest/lock.ts +94 -0
  88. package/plugins/manifest/plugin.json +13 -0
  89. package/plugins/manifest/upgrade-cli.ts +70 -0
  90. package/plugins/manifest/upgrade.ts +97 -0
  91. package/plugins/openai/index.ts +159 -0
  92. package/plugins/openai/plugin.json +11 -0
  93. package/plugins/openai/prompt.ts +35 -0
  94. package/plugins/sandbox/index.ts +150 -0
  95. package/plugins/sandbox/plugin.json +8 -0
  96. package/plugins/sandbox/runner.mjs +18 -0
  97. package/plugins/save/cli.ts +19 -0
  98. package/plugins/save/index.ts +55 -0
  99. package/plugins/save/plugin.json +12 -0
  100. package/plugins/settings/index.ts +270 -0
  101. package/plugins/settings/merge.ts +79 -0
  102. package/plugins/settings/plugin.json +13 -0
  103. package/plugins/tasks/index.ts +246 -0
  104. package/plugins/tasks/plugin.json +11 -0
  105. package/plugins/tasks/store.ts +86 -0
  106. package/plugins/tsconfig.json +27 -0
  107. package/templates/agent/Dockerfile +15 -0
  108. package/templates/agent/README.md +52 -0
  109. package/templates/agent/blend.yaml +56 -0
  110. package/templates/agent/dockerignore +3 -0
  111. package/templates/agent/gitignore +2 -0
  112. package/templates/agent/package.json +23 -0
  113. package/templates/agent/src/app.ts +98 -0
  114. package/templates/agent/src/main.ts +25 -0
  115. package/templates/agent/src/routes.ts +53 -0
  116. package/templates/agent/template.json +16 -0
  117. package/templates/agent/test/agent.test.ts +45 -0
  118. package/templates/agent/test/app.test.ts +46 -0
  119. package/templates/agent/test/builder.test.ts +194 -0
  120. package/templates/agent/test/model.test.ts +88 -0
  121. package/templates/agent/tsconfig.json +19 -0
  122. package/templates/blank/README.md +26 -0
  123. package/templates/blank/blend.yaml +23 -0
  124. package/templates/blank/gitignore +2 -0
  125. package/templates/blank/package.json +23 -0
  126. package/templates/blank/src/app.ts +61 -0
  127. package/templates/blank/src/main.ts +20 -0
  128. package/templates/blank/src/routes.ts +14 -0
  129. package/templates/blank/template.json +1 -0
  130. package/templates/blank/test/app.test.ts +56 -0
  131. package/templates/blank/tsconfig.json +19 -0
  132. package/templates/todo/README.md +27 -0
  133. package/templates/todo/blend.yaml +25 -0
  134. package/templates/todo/gitignore +2 -0
  135. package/templates/todo/package.json +23 -0
  136. package/templates/todo/src/app.ts +71 -0
  137. package/templates/todo/src/main.ts +19 -0
  138. package/templates/todo/src/page.ts +28 -0
  139. package/templates/todo/src/todo.ts +96 -0
  140. package/templates/todo/template.json +1 -0
  141. package/templates/todo/test/todo.test.ts +145 -0
  142. package/templates/todo/tsconfig.json +19 -0
  143. package/templates/todo/upstream.json +12 -0
  144. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Scaffold Cordis apps: acrylblends init.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "acrylblends": "./bin/acrylblends.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "dist",
22
+ "templates",
23
+ "plugins"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "clean": "rm -rf dist",
28
+ "test": "vitest run",
29
+ "prepack": "pnpm run build && node scripts-pack.mjs copy",
30
+ "postpack": "node scripts-pack.mjs remove"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.19.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/acrylblends/acrylblends.git",
38
+ "directory": "packages/cli"
39
+ },
40
+ "homepage": "https://acrylblends.github.io",
41
+ "keywords": [
42
+ "cordis",
43
+ "scaffold"
44
+ ],
45
+ "private": false,
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "4.0.4"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "acryl-inspect",
3
+ "description": "Introspection: what every plugin requires, provides and registers, and what unloads with it. Vendored from ACRYL.",
4
+ "requires": ["acryl-router"],
5
+ "inject": ["router"],
6
+ "provides": ["inspect"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ },
10
+ "mount": "root.plugin(inspect); ctx.inspect.plugins(), ctx.inspect.explain('name'), ctx.inspect.render()"
11
+ }
@@ -0,0 +1,3 @@
1
+ export { Inspect, apply, name } from './service.ts'
2
+ export { explain, renderReports, reportFiber, stateName } from './report.ts'
3
+ export type { EffectLabel, FiberStateName, FiberView, PluginReport, RouteReport } from './report.ts'
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The per-plugin contract, read from the running system (spec 046, M4). Cordis already knows a plugin's name, its state, the
3
+ * services it requires (`inject`) and a labeled tree of everything it registered (`getEffects()`), so there is no second
4
+ * declaration to keep in step with the code. The only convention is the label: an effect says what it is, prefixed by its
5
+ * owner (`acryl-router: GET /a`). Pure functions over a structural view of a fiber, so they are testable without Cordis.
6
+ */
7
+
8
+ /** The plugin states Cordis defines, by their numeric values (`FiberState`). */
9
+ const STATE_NAMES = ['PENDING', 'LOADING', 'ACTIVE', 'FAILED', 'DISPOSED', 'UNLOADING'] as const
10
+ export type FiberStateName = typeof STATE_NAMES[number]
11
+
12
+ export interface EffectLabel {
13
+ readonly label: string
14
+ readonly children: readonly EffectLabel[]
15
+ }
16
+
17
+ /** The slice of a Cordis fiber this module reads. */
18
+ export interface FiberView {
19
+ readonly state: number
20
+ readonly inject: Readonly<Record<string, unknown>>
21
+ readonly runtime: { readonly name?: string | undefined } | null
22
+ /** The context the fiber was loaded from; its own `fiber` is the owning plugin when this fiber is an inline `ctx.inject(...)` callback. */
23
+ readonly parent?: { readonly fiber?: FiberView | undefined } | undefined
24
+ getEffects(): readonly EffectLabel[]
25
+ }
26
+
27
+ export interface RouteReport {
28
+ readonly method: string
29
+ readonly path: string
30
+ }
31
+
32
+ export interface PluginReport {
33
+ readonly name: string
34
+ readonly state: FiberStateName
35
+ /** Services the plugin waits for before it starts. */
36
+ readonly requires: readonly string[]
37
+ /** Services it provides. */
38
+ readonly provides: readonly string[]
39
+ /** Routes it registered on the router. */
40
+ readonly routes: readonly RouteReport[]
41
+ /** Router middleware paths it registered. */
42
+ readonly middleware: readonly string[]
43
+ /** Every other labeled registration (event listeners, timers, commands, tools). */
44
+ readonly other: readonly string[]
45
+ /** How many registrations disappear when it unloads: all of them. */
46
+ readonly removedOnUnload: number
47
+ }
48
+
49
+ const PROVIDE = /^ctx\.provide\("(.+)"\)$/
50
+ const ROUTE = /^acryl-router: (GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|\*) (\/\S*)$/
51
+ const USE = /^acryl-router: use (\/\S*)$/
52
+
53
+ function flatten(effects: readonly EffectLabel[]): string[] {
54
+ return effects.flatMap(effect => [effect.label, ...flatten(effect.children)])
55
+ }
56
+
57
+ export function stateName(state: number): FiberStateName {
58
+ return STATE_NAMES[state] ?? 'FAILED'
59
+ }
60
+
61
+ const isNamed = (fiber: FiberView): boolean => (fiber.runtime?.name ?? '') !== ''
62
+
63
+ /** The named plugin a fiber belongs to: itself, or for an unnamed inline callback (`ctx.inject([...], inner => ...)`) the nearest named ancestor. */
64
+ export function ownerOf(fiber: FiberView): FiberView {
65
+ let current = fiber
66
+ while (!isNamed(current)) {
67
+ const parent = current.parent?.fiber
68
+ if (parent === undefined || parent === current) return fiber
69
+ current = parent
70
+ }
71
+ return current
72
+ }
73
+
74
+ /** Describe one fiber, counting the registrations of the unnamed inline callbacks it owns (`adopted`) as its own. */
75
+ export function reportFiber(fiber: FiberView, adopted: readonly FiberView[] = []): PluginReport {
76
+ const provides: string[] = []
77
+ const routes: RouteReport[] = []
78
+ const middleware: string[] = []
79
+ const other: string[] = []
80
+ const labels = [fiber, ...adopted].flatMap(source => flatten(source.getEffects()))
81
+ for (const label of labels) {
82
+ const provided = PROVIDE.exec(label)
83
+ const route = ROUTE.exec(label)
84
+ const used = USE.exec(label)
85
+ if (provided?.[1] !== undefined) provides.push(provided[1])
86
+ else if (route?.[1] !== undefined && route[2] !== undefined) routes.push({ method: route[1], path: route[2] })
87
+ else if (used?.[1] !== undefined) middleware.push(used[1])
88
+ else other.push(label)
89
+ }
90
+ return {
91
+ name: isNamed(fiber) ? fiber.runtime?.name ?? 'anonymous' : 'anonymous',
92
+ state: stateName(fiber.state),
93
+ requires: Object.keys(fiber.inject).sort(),
94
+ provides: provides.sort(),
95
+ routes,
96
+ middleware,
97
+ other,
98
+ removedOnUnload: labels.length,
99
+ }
100
+ }
101
+
102
+ const list = (items: readonly string[]): string => items.length === 0 ? 'nothing' : items.join(', ')
103
+
104
+ /** A short, human explanation of one plugin: what it needs, what it adds, and what happens when it unloads. */
105
+ export function explain(report: PluginReport): string {
106
+ const lines = [
107
+ `${report.name} (${report.state})`,
108
+ ` needs: ${list(report.requires)}`,
109
+ ` provides: ${list(report.provides)}`,
110
+ ` routes: ${list(report.routes.map(route => `${route.method} ${route.path}`))}`,
111
+ ` middleware:${report.middleware.length === 0 ? ' nothing' : ` ${report.middleware.join(', ')}`}`,
112
+ ]
113
+ if (report.other.length > 0) lines.push(` also: ${report.other.join('; ')}`)
114
+ lines.push(report.state === 'PENDING'
115
+ ? ` waiting: it starts when ${list(report.requires)} ${report.requires.length === 1 ? 'is' : 'are'} available`
116
+ : ` unloading: removes ${String(report.removedOnUnload)} registration${report.removedOnUnload === 1 ? '' : 's'} and nothing else`)
117
+ return lines.join('\n')
118
+ }
119
+
120
+ /** One block per plugin, in registration order. */
121
+ export function renderReports(reports: readonly PluginReport[]): string {
122
+ return reports.map(explain).join('\n\n')
123
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The ACRYL inspector (`ctx.inspect`): what every running plugin requires, provides and registers, read from the live
3
+ * registry. It owns no state and registers nothing of its own beyond an optional read-only pair of router routes, which are
4
+ * reachable in process unless some plugin mounts their prefix over HTTP.
5
+ */
6
+
7
+ import { Context, Service } from '@deepseek-ai/cordis'
8
+ import { loopbackOnly } from '../../acryl-router/src/middleware/index.ts'
9
+ import type {} from '../../acryl-router/src/index.ts'
10
+ import { explain, ownerOf, renderReports, reportFiber, type FiberView, type PluginReport } from './report.ts'
11
+
12
+ declare module '@deepseek-ai/cordis' {
13
+ interface Context {
14
+ inspect: Inspect
15
+ }
16
+ }
17
+
18
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
19
+ export const name = 'acryl-inspect'
20
+
21
+ export class Inspect extends Service {
22
+ constructor(ctx: Context) {
23
+ super(ctx, 'inspect')
24
+ }
25
+
26
+ /** A report for every live plugin instance, in registry order. */
27
+ plugins(): PluginReport[] {
28
+ const fibers = [...this.ctx.registry.values()].flatMap(runtime => [...runtime.fibers] as unknown as FiberView[])
29
+ // An unnamed inline callback is part of the plugin that wrote it: its routes and mounts are that plugin's.
30
+ const adopted = new Map<FiberView, FiberView[]>()
31
+ const owners: FiberView[] = []
32
+ for (const fiber of fibers) {
33
+ const owner = ownerOf(fiber)
34
+ if (owner === fiber) owners.push(fiber)
35
+ else adopted.set(owner, [...(adopted.get(owner) ?? []), fiber])
36
+ }
37
+ return owners.map(fiber => reportFiber(fiber, adopted.get(fiber) ?? []))
38
+ }
39
+
40
+ /** The text explanation of the plugin called `pluginName`, or `undefined` when none is registered. */
41
+ explain(pluginName: string): string | undefined {
42
+ const found = this.plugins().filter(report => report.name === pluginName)
43
+ return found.length === 0 ? undefined : found.map(explain).join('\n\n')
44
+ }
45
+
46
+ /** The whole system as text. */
47
+ render(): string {
48
+ return renderReports(this.plugins())
49
+ }
50
+ }
51
+
52
+ export function apply(ctx: Context): void {
53
+ const inspect = new Inspect(ctx)
54
+ // The same facts on the router, for any surface; present only where a router is. A request over a socket must come from this machine to this app;
55
+ // the terminal calls the router in process and is the host's own code.
56
+ ctx.inject(['router'], inner => {
57
+ const api = inner.router.group('/api/inspect', loopbackOnly({ inProcess: 'allow' }))
58
+ api.get('/plugins', c => c.json(inspect.plugins()))
59
+ api.get('/plugins/:name', c => {
60
+ const text = inspect.explain(c.param('name') ?? '')
61
+ return text === undefined ? c.json({ error: 'no such plugin' }, 404) : c.text(text)
62
+ })
63
+ // Over HTTP only where an adapter exists (Web and Desktop); the terminal needs none.
64
+ inner.inject(['routerHttp'], http => { http.routerHttp.mount('/api/inspect') })
65
+ })
66
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { explain, renderReports, reportFiber, stateName, type FiberView } from '../src/report.ts'
3
+
4
+ function fiber(over: Partial<FiberView> & { effects?: FiberView['getEffects'] extends () => infer R ? R : never } = {}): FiberView {
5
+ const { effects = [], ...rest } = over
6
+ return { state: 2, inject: {}, runtime: { name: 'demo' }, getEffects: () => effects, ...rest }
7
+ }
8
+
9
+ describe('reportFiber', () => {
10
+ it('sorts effects into provides, routes, middleware and everything else, including nested ones', () => {
11
+ const report = reportFiber(fiber({
12
+ inject: { router: null, acrylSettings: null },
13
+ effects: [
14
+ { label: 'ctx.provide("things")', children: [] },
15
+ { label: 'acryl-router: GET /api/things/:id', children: [] },
16
+ { label: 'acryl-router: POST /api/things', children: [{ label: 'inner timer', children: [] }] },
17
+ { label: 'acryl-router: use /api', children: [] },
18
+ { label: 'ctx.on("internal/status")', children: [] },
19
+ ],
20
+ }))
21
+ expect(report).toEqual({
22
+ name: 'demo',
23
+ state: 'ACTIVE',
24
+ requires: ['acrylSettings', 'router'],
25
+ provides: ['things'],
26
+ routes: [{ method: 'GET', path: '/api/things/:id' }, { method: 'POST', path: '/api/things' }],
27
+ middleware: ['/api'],
28
+ other: ['inner timer', 'ctx.on("internal/status")'],
29
+ removedOnUnload: 6,
30
+ })
31
+ })
32
+
33
+ it('names the state and an anonymous plugin', () => {
34
+ expect(stateName(0)).toBe('PENDING')
35
+ expect(stateName(2)).toBe('ACTIVE')
36
+ expect(stateName(5)).toBe('UNLOADING')
37
+ expect(reportFiber(fiber({ runtime: null })).name).toBe('anonymous')
38
+ })
39
+ })
40
+
41
+ describe('explain', () => {
42
+ it('says what a plugin needs, adds and removes on unload', () => {
43
+ const text = explain(reportFiber(fiber({
44
+ inject: { router: null },
45
+ effects: [{ label: 'acryl-router: GET /a', children: [] }],
46
+ })))
47
+ expect(text).toContain('demo (ACTIVE)')
48
+ expect(text).toContain('needs: router')
49
+ expect(text).toContain('routes: GET /a')
50
+ expect(text).toContain('unloading: removes 1 registration and nothing else')
51
+ })
52
+
53
+ it('says a pending plugin is waiting for its services', () => {
54
+ const text = explain(reportFiber(fiber({ state: 0, inject: { router: null, webServer: null } })))
55
+ expect(text).toContain('demo (PENDING)')
56
+ expect(text).toContain('it starts when router, webServer are available')
57
+ })
58
+
59
+ it('renders several plugins as blocks', () => {
60
+ const a = reportFiber(fiber({ runtime: { name: 'a' } }))
61
+ const b = reportFiber(fiber({ runtime: { name: 'b' } }))
62
+ expect(renderReports([a, b]).split('\n\n')).toHaveLength(2)
63
+ })
64
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The inspector against real Cordis plugins: what it reports is what Cordis holds, so the tests build a small system and
3
+ * compare the report with what the plugins actually did.
4
+ */
5
+
6
+ import { Context } from '@deepseek-ai/cordis'
7
+ import { apply as routerApply, name as routerName } from '../../acryl-router/src/index.ts'
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { apply, name, type PluginReport } from '../src/index.ts'
10
+
11
+ const contexts: Context[] = []
12
+ const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
13
+ afterEach(async () => {
14
+ while (contexts.length > 0) await contexts.pop()?.fiber.dispose()
15
+ })
16
+
17
+ const market = {
18
+ name: 'demo-market',
19
+ inject: ['router'],
20
+ apply(ctx: Context): void {
21
+ ctx.router.use('/api/demo', async (_c, next) => next())
22
+ ctx.router.get('/api/demo/state', c => c.json({ ok: true }))
23
+ ctx.router.post('/api/demo/sources', c => c.json({ ok: true }, 201))
24
+ },
25
+ }
26
+
27
+ async function system(): Promise<Context> {
28
+ const ctx = new Context()
29
+ contexts.push(ctx)
30
+ ctx.plugin({ name: routerName, apply: routerApply }, { trailingSlash: 'strict' })
31
+ ctx.plugin({ name, apply })
32
+ await settle()
33
+ return ctx
34
+ }
35
+
36
+ const byName = (reports: readonly PluginReport[], pluginName: string): PluginReport => {
37
+ const found = reports.find(report => report.name === pluginName)
38
+ if (found === undefined) throw new Error(`no report for ${pluginName}`)
39
+ return found
40
+ }
41
+
42
+ describe('acryl-inspect', () => {
43
+ it('counts what an inline ctx.inject callback registers as the owning plugin\'s', async () => {
44
+ const ctx = await system()
45
+ ctx.plugin({
46
+ name: 'demo-inline',
47
+ apply(inner: Context): void {
48
+ inner.inject(['router'], deeper => { deeper.router.get('/api/inline', c => c.json({ ok: true })) })
49
+ },
50
+ })
51
+ await settle()
52
+ const reports = ctx.inspect.plugins()
53
+ expect(byName(reports, 'demo-inline').routes).toEqual([{ method: 'GET', path: '/api/inline' }])
54
+ expect(reports.every(report => report.name !== 'anonymous')).toBe(true)
55
+ })
56
+
57
+ it('reports what a plugin requires, provides and registers, from the running system', async () => {
58
+ const ctx = await system()
59
+ ctx.plugin(market)
60
+ await settle()
61
+ const reports = ctx.inspect.plugins()
62
+
63
+ expect(byName(reports, 'acryl-router')).toMatchObject({ state: 'ACTIVE', requires: [], provides: ['router'] })
64
+ expect(byName(reports, 'acryl-inspect')).toMatchObject({ state: 'ACTIVE', provides: ['inspect'] })
65
+ const demo = byName(reports, 'demo-market')
66
+ expect(demo).toMatchObject({
67
+ state: 'ACTIVE',
68
+ requires: ['router'],
69
+ provides: [],
70
+ routes: [{ method: 'GET', path: '/api/demo/state' }, { method: 'POST', path: '/api/demo/sources' }],
71
+ middleware: ['/api/demo'],
72
+ removedOnUnload: 3,
73
+ })
74
+ })
75
+
76
+ it('shows a plugin whose service is missing as PENDING and says what it waits for', async () => {
77
+ const ctx = await system()
78
+ ctx.plugin({ name: 'needs-a-thing', inject: ['neverProvided'], apply() { /* never runs */ } })
79
+ await settle()
80
+ expect(byName(ctx.inspect.plugins(), 'needs-a-thing')).toMatchObject({ state: 'PENDING', requires: ['neverProvided'] })
81
+ expect(ctx.inspect.explain('needs-a-thing')).toContain('it starts when neverProvided is available')
82
+ })
83
+
84
+ it('stops listing a plugin that unloaded, and the router really lost what the report said would go', async () => {
85
+ const ctx = await system()
86
+ const baseline = ctx.router.size
87
+ const fiber = ctx.plugin(market)
88
+ await settle()
89
+ const promised = byName(ctx.inspect.plugins(), 'demo-market').removedOnUnload
90
+ expect(ctx.router.size - baseline).toBe(promised)
91
+
92
+ await fiber.dispose()
93
+ await settle()
94
+ expect(ctx.inspect.plugins().some(report => report.name === 'demo-market' && report.state === 'ACTIVE')).toBe(false)
95
+ expect(ctx.router.size).toBe(baseline)
96
+ })
97
+
98
+ it('serves the same facts on the router, in process', async () => {
99
+ const ctx = await system()
100
+ ctx.plugin(market)
101
+ await settle()
102
+ const list = await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins'))
103
+ expect(list.status).toBe(200)
104
+ const reports = await list.json() as PluginReport[]
105
+ expect(byName(reports, 'demo-market').routes).toHaveLength(2)
106
+
107
+ const one = await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins/demo-market'))
108
+ expect(await one.text()).toContain('removes 3 registrations and nothing else')
109
+ expect((await ctx.router.fetch(new Request('http://acryl.test/api/inspect/plugins/nope'))).status).toBe(404)
110
+ })
111
+
112
+ it('needs no router: without one it still answers in code and registers no routes', async () => {
113
+ const ctx = new Context()
114
+ contexts.push(ctx)
115
+ ctx.plugin({ name, apply })
116
+ await settle()
117
+ expect(byName(ctx.inspect.plugins(), 'acryl-inspect').routes).toEqual([])
118
+ expect(ctx.inspect.render()).toContain('acryl-inspect (ACTIVE)')
119
+ })
120
+ })
@@ -0,0 +1,18 @@
1
+ {
2
+ "source": {
3
+ "repo": "acryldev/acryl",
4
+ "path": "plugins/acryl-inspect",
5
+ "commit": "11fed295161c0282d144b5ac62616988b58d8189"
6
+ },
7
+ "vendored": [
8
+ "src",
9
+ "tests",
10
+ "LICENSE"
11
+ ],
12
+ "rewrittenSpecifiers": 3,
13
+ "digest": "sha256:0fa799474c66b7a2a159c17ea143030b9fc6a38fcb4219f5139e78fd4508bdb7",
14
+ "testsNotRun": {},
15
+ "notice": [
16
+ "LICENSE"
17
+ ]
18
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebBoxes s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "acryl-node-server",
3
+ "description": "The standalone HTTP server as a Cordis plugin: one node:http listener that sends every request to the router. Port, loopback host and next-free-port scan; ends open streams and frees the port when unloaded. Vendored from ACRYL.",
4
+ "requires": ["acryl-router", "acryl-router-http"],
5
+ "inject": ["router"],
6
+ "provides": ["nodeServer"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@deepseek-ai/schemastery": "3.18.4"
10
+ },
11
+ "mount": "root.plugin(router); root.plugin(nodeServer, { port: 3000, hostname: '127.0.0.1', scan: 20 }); ctx.nodeServer.url"
12
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The framework's standalone HTTP adapter (spec 046), the standalone counterpart of the Host adapter. It owns one
3
+ * `node:http` listener and hands every request to `ctx.router.fetch`, with the socket's peer address and local port as connection facts.
4
+ * Use it in an app that runs without the ACRYL Host; inside the Host, `acryl-router-http` mounts prefixes on the Host's own web server.
5
+ * One adapter per runtime: a plugin keeps asking `ctx.inject(['routerHttp'], ...)` for a mount, which simply never fires here, because this
6
+ * adapter serves the whole router.
7
+ *
8
+ * A configured port that is taken is followed by the next free one, at most `scan` further ports; port 0 lets the system choose. Disposal ends
9
+ * open streams, closes every connection and returns once the port is free.
10
+ */
11
+
12
+ import { createServer, type Server } from 'node:http'
13
+ import type { AddressInfo } from 'node:net'
14
+ import { Context, Service } from '@deepseek-ai/cordis'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { serve } from '../../acryl-router-http/src/index.ts'
17
+ import type {} from '../../acryl-router/src/index.ts'
18
+
19
+ /** Stable Cordis plugin name, equal to the package name and the Loader row id. */
20
+ export const name = 'acryl-node-server'
21
+ export const inject = ['router']
22
+
23
+ export interface Config {
24
+ hostname: string
25
+ port: number
26
+ scan: number
27
+ }
28
+
29
+ export const Config: z<Config> = z.object({
30
+ hostname: z.string().default('127.0.0.1').description('Address to bind. Loopback by default; binding elsewhere exposes the app to the network.'),
31
+ port: z.number().min(0).max(65535).default(0).description('Port to listen on; 0 lets the system choose. A taken port is followed by the next free one.'),
32
+ scan: z.number().min(0).default(20).description('How many further ports to try when the configured one is taken.'),
33
+ })
34
+
35
+ declare module '@deepseek-ai/cordis' {
36
+ interface Context {
37
+ nodeServer: NodeServer
38
+ }
39
+ }
40
+
41
+ export class NodeServer extends Service {
42
+ readonly hostname: string
43
+ readonly config: Config
44
+ private readonly server: Server
45
+ private bound = 0
46
+
47
+ constructor(ctx: Context, config: Config) {
48
+ super(ctx, 'nodeServer')
49
+ this.config = config
50
+ this.hostname = config.hostname
51
+ const router = ctx.router
52
+ this.server = createServer((req, res) => { void serve(router, req, res) })
53
+ ctx.effect(() => async () => {
54
+ this.server.closeAllConnections()
55
+ if (this.server.listening) await new Promise<void>(resolve => { this.server.close(() => { resolve() }) })
56
+ }, 'acryl-node-server: close listener and open connections')
57
+ }
58
+
59
+ /** The port in use, 0 before the server listens. */
60
+ get port(): number {
61
+ return this.bound
62
+ }
63
+
64
+ /** The address clients use. */
65
+ get url(): string {
66
+ return `http://${this.hostname.includes(':') ? `[${this.hostname}]` : this.hostname}:${String(this.bound)}`
67
+ }
68
+
69
+ /** Bind, scanning past taken ports. */
70
+ async listen(): Promise<void> {
71
+ const { port, scan, hostname } = this.config
72
+ const last = port === 0 ? 0 : Math.min(65535, port + scan)
73
+ for (let candidate = port; candidate <= last; candidate += 1) {
74
+ try {
75
+ await new Promise<void>((resolve, reject) => {
76
+ const onError = (error: Error): void => { reject(error) }
77
+ this.server.once('error', onError)
78
+ this.server.listen(candidate, hostname, () => { this.server.off('error', onError); resolve() })
79
+ })
80
+ this.bound = (this.server.address() as AddressInfo).port
81
+ return
82
+ } catch (error) {
83
+ if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error
84
+ }
85
+ }
86
+ throw new Error(`acryl-node-server: no free port from ${String(port)} to ${String(last)} on ${hostname}`)
87
+ }
88
+ }
89
+
90
+ /** Loads once the server listens, so a plugin that injects `nodeServer` can rely on `url`. A failure to bind fails the plugin. */
91
+ export async function apply(ctx: Context, config: Partial<Config> = {}): Promise<void> {
92
+ const server = new NodeServer(ctx, { hostname: '127.0.0.1', port: 0, scan: 20, ...config })
93
+ await server.listen()
94
+ }