@afora/mcp 0.0.0-stage → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +957 -0
- package/dist/extract-child.d.ts +1 -0
- package/dist/extract-child.js +22 -0
- package/dist/extract.d.ts +11 -0
- package/dist/guide.d.ts +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1087 -0
- package/dist/process.d.ts +12 -0
- package/dist/server.d.ts +18 -0
- package/dist/sink.d.ts +83 -0
- package/dist/spec-file.d.ts +24 -0
- package/dist/tools.d.ts +12 -0
- package/dist/typecheck.d.ts +28 -0
- package/package.json +62 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
// src/guide.ts
|
|
2
|
+
var GUIDE = `# afora.json guide
|
|
3
|
+
|
|
4
|
+
\`afora.json\` describes a domain. \`afora_generate\` turns it into TypeScript on \`@afora/core\`
|
|
5
|
+
(one file per kind of declaration, under \`output.dir\`, default \`src/domain\`). The generated files
|
|
6
|
+
start with "// Generated by @afora/codegen from afora.json. Do not edit." and are overwritten on
|
|
7
|
+
every run, so change the JSON, never those files. To add behavior, extend the generated class in
|
|
8
|
+
a file of your own.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. \`afora_catalog\`: what you can use (Std properties, record plugins, rules).
|
|
13
|
+
2. \`afora_spec_read\`: the current spec (or the one extracted from the code).
|
|
14
|
+
3. Edit with \`afora_spec_upsert\` / \`afora_spec_remove\` (one declaration at a time; the whole
|
|
15
|
+
spec must stay valid, so declare what a declaration uses first) or \`afora_spec_write\`.
|
|
16
|
+
4. \`afora_spec_validate\`, then \`afora_generate\` (\`dryRun: true\` to preview). The result has the
|
|
17
|
+
TypeScript diagnostics of the generated files: fix the spec until there are none.
|
|
18
|
+
|
|
19
|
+
## Shape
|
|
20
|
+
|
|
21
|
+
\`\`\`json
|
|
22
|
+
{
|
|
23
|
+
"version": 1,
|
|
24
|
+
"name": "blog",
|
|
25
|
+
"output": { "dir": "src/domain" },
|
|
26
|
+
"provider": "inMemory",
|
|
27
|
+
"properties": { "Age": { "base": "integer", "with": { "min": 0, "max": 150 } },
|
|
28
|
+
"Status": { "enum": ["draft", "published"] } },
|
|
29
|
+
"blueprints": { "Address": { "fields": { "street": "string", "zip": "string" } } },
|
|
30
|
+
"records": { "Money": { "fields": { "amount": "number", "currency": "string" } } },
|
|
31
|
+
"entities": { "User": { "fields": { "name": "text", "email": "email", "password": "password",
|
|
32
|
+
"age": "Age?", "address": "Address", "tags": "string[]" },
|
|
33
|
+
"unique": ["email"], "change": ["name"] },
|
|
34
|
+
"Post": { "fields": { "title": "text", "status": "Status",
|
|
35
|
+
"author": "User", "coauthors": "User[]" } } },
|
|
36
|
+
"environments": { "Database": { "fields": { "DATABASE_URL": "url", "PORT": "integer" } } },
|
|
37
|
+
"repositories": { "Users": { "entity": "User", "actions": ["findById", "findByEmail", "create", "update"],
|
|
38
|
+
"environments": ["Database"] },
|
|
39
|
+
"Posts": { "entity": "Post", "actions": ["findById", "findManyByAuthor", "create"],
|
|
40
|
+
"relations": ["Users"] } }
|
|
41
|
+
}
|
|
42
|
+
\`\`\`
|
|
43
|
+
|
|
44
|
+
## Fields
|
|
45
|
+
|
|
46
|
+
- Shorthand string: \`"email"\`, \`"Age?"\` (optional), \`"string[]"\` (list), \`"User[]?"\` (optional list).
|
|
47
|
+
- Lowercase names are Std properties (see \`afora_catalog\`: id, slug, email, phone, url, ip, password,
|
|
48
|
+
string, text, number, integer, boolean, date, dateTime...). PascalCase names are declarations of
|
|
49
|
+
the spec (properties, blueprints, records, entities).
|
|
50
|
+
- Object form: \`{ "type": "string", "optional": true, "list": { "minItems": 1, "maxItems": 5 }, "with": { "pattern": "^a" } }\`.
|
|
51
|
+
\`with\` applies to each item of a list and only takes options valid for the type (min/max/pattern for strings,
|
|
52
|
+
min/max/exclusiveMin/exclusiveMax/multipleOf for numbers, plus title/description/examples/deprecated/readOnly/writeOnly/default).
|
|
53
|
+
- Enum: \`{ "enum": ["a", "b"] }\`. Nested object in place: \`{ "fields": { "lat": "number", "lng": "number" } }\`.
|
|
54
|
+
- A property of your own: \`{ "base": "integer", "with": {...} }\`, \`{ "enum": [...] }\`, or
|
|
55
|
+
\`{ "opaque": true, "import": "./my-property" }\` for a hand-written one (imported as it is).
|
|
56
|
+
|
|
57
|
+
## Rules the validator enforces
|
|
58
|
+
|
|
59
|
+
- Declaration names are PascalCase and unique across every kind; names exported by @afora/core are taken.
|
|
60
|
+
- No circular references between declarations (store an id instead of the entity).
|
|
61
|
+
- An entity cannot declare \`id\`, \`createdAt\`, \`updatedAt\`, \`deletedAt\` (it gets them). No record member names
|
|
62
|
+
(\`toJSON\`, \`set\`, \`onSet\`...); see \`reservedRecordKeys\` in the catalog. \`blueprint\` and \`options\` are valid fields.
|
|
63
|
+
- \`unique\`, \`sensitive\`, \`change\` must name fields (or keys of its plugins); \`change\` cannot name an identity key.
|
|
64
|
+
\`password\` is always sensitive and \`slug\` always unique; \`id\` is always unique.
|
|
65
|
+
- \`plugins\`: Std record plugins (publishable, archivable, taggable, ownable, authenticatable); their keys cannot be declared again.
|
|
66
|
+
- Repository actions: \`findById\`; \`findBy<Key>\` only for unique keys (id, \`unique\`, slug fields, unique plugin keys such as
|
|
67
|
+
\`email\` of authenticatable); \`findManyBy<Key>\` only for keys that are not unique; \`create\`, \`update\`, \`delete\`, \`count\`, and the
|
|
68
|
+
queries \`findMany\`, \`exists\`, \`findPage\` (no key needed; \`where\`, \`orderBy\`, \`limit\`, \`offset\` at runtime).
|
|
69
|
+
- \`provider\` (top level for the application, and optionally on a repository, which then uses its own): \`"inMemory"\`,
|
|
70
|
+
\`{ "use": "<name>", "options": {...} }\` with a name from the \`providers\` of \`afora_catalog\` (external ones are given
|
|
71
|
+
to the server with \`--providers\`), or \`{ "import": "./db.ts#provider" }\` for one created by hand. Options are JSON or
|
|
72
|
+
\`{ "import": "./db.ts#db" }\` references (any depth); imports are relative to \`output.dir\` and never run by the validator.
|
|
73
|
+
Omitting \`provider\` is not the same as \`"inMemory"\`: a repository without one uses the application's. For a provider
|
|
74
|
+
not created with \`providerOf\`, \`afora_extract\` needs \`imports\` (\`provider\` / \`repositories.<Name>.provider\`).
|
|
75
|
+
- Repository \`deletion\`: \`"hard"\` (default) physically removes rows; \`"soft"\` retains \`deletedAt\` and hides deleted rows from reads, counts and relations.
|
|
76
|
+
- \`relations\` must list exactly one repository for each entity the repository's entity holds, at any depth (inside
|
|
77
|
+
records, blueprints and nested objects), not through another entity.
|
|
78
|
+
- Environments hold properties only (no lists, no objects).
|
|
79
|
+
- \`extends\`: other specs (relative path to their afora.json, or a package) whose declarations you can reference;
|
|
80
|
+
the same name for different declarations is a conflict.
|
|
81
|
+
|
|
82
|
+
## Exceptions and use cases
|
|
83
|
+
|
|
84
|
+
\`\`\`json
|
|
85
|
+
{
|
|
86
|
+
"exceptions": { "UserAlreadyExists": { "layer": "use-case", "message": "A user with this email already exists." } },
|
|
87
|
+
"useCases": {
|
|
88
|
+
"Register": {
|
|
89
|
+
"description": "Signs a user up.", "tags": ["users"],
|
|
90
|
+
"input": { "fields": { "name": "text", "email": "email", "plainPassword": "string" } },
|
|
91
|
+
"response": { "fields": { "user": "User" } },
|
|
92
|
+
"dependencies": { "users": "Users" },
|
|
93
|
+
"errors": ["UserAlreadyExists", "RepositoryUniqueViolation"],
|
|
94
|
+
"implementation": { "steps": [
|
|
95
|
+
{ "find": "users.findByEmail", "args": ["$input.email"], "as": "existing" },
|
|
96
|
+
{ "ensure": "absent", "value": "$existing", "error": "UserAlreadyExists" },
|
|
97
|
+
{ "run": "hashPassword", "fn": { "import": "../hash#hashPassword" }, "args": ["$input.plainPassword"], "as": "hash" },
|
|
98
|
+
{ "build": "User", "values": { "name": "$input.name", "email": "$input.email", "password": "$hash" }, "as": "user" },
|
|
99
|
+
{ "create": "users", "value": "$user", "as": "saved" },
|
|
100
|
+
{ "return": { "user": "$saved" } }
|
|
101
|
+
] }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
\`\`\`
|
|
106
|
+
|
|
107
|
+
- \`exceptions\`: \`{ layer, message? }\`; \`layer\` is one of \`exceptionLayers\` of \`afora_catalog\`; the message is "<Name>." when omitted.
|
|
108
|
+
- \`useCases\`: \`input\`, \`response\` and \`context\` (optional) are a blueprint name or \`{ "fields": {...} }\`. \`dependencies\` map a key to a use case, repository
|
|
109
|
+
or environment. \`errors\` are declared exceptions or exceptions exported by @afora/core (\`coreExceptions\` of \`afora_catalog\`).
|
|
110
|
+
- \`implementation\` is \`{ "steps": [...] }\` (generated complete, no hand-written file) or \`{ "import": "../handlers/x.ts#Name" }\`:
|
|
111
|
+
the generated file has \`abstract class <Name>Base\`; write \`class Name extends <Name>Base\` with \`execute\` at that path.
|
|
112
|
+
A use case cannot list an imported (hand-written) use case as a dependency.
|
|
113
|
+
- Steps run in order and \`return\` is last, exactly once: \`find\` ("<repo>.<read action>"), \`ensure\` (exists | absent | equals | notEquals | true | false;
|
|
114
|
+
\`other\` for the comparisons, \`error\` a declared exception name or a core one, listed in \`errors\`; optional \`details\`), \`build\` (a record or
|
|
115
|
+
entity name, \`values\`, \`as\`), \`change\` ($ref, \`key\` in the class's change keys, \`value\`), \`invoke\` ($ref, \`method\`, \`args\`),
|
|
116
|
+
\`create\` / \`update\` / \`delete\` (repository key, \`value\`), \`call\` (use case key, \`input\`) and \`run\` (a unique name, \`fn\` is
|
|
117
|
+
\`{ "import": "../x.ts#fn" }\`, \`args\`; the function gets (...args, { context })). \`as\` binds the result.
|
|
118
|
+
- Values: \`"$input.key"\`, \`"$context.key"\`, \`"$env.<dependency>.VAR"\`, \`"$name.path.0"\` (a bound result); a literal; \`{ "literal": "$5" }\` for a string that
|
|
119
|
+
starts with $; or an object/array of values. No expressions: compute with \`run\`.
|
|
120
|
+
- \`afora_extract\` needs \`imports\` for \`useCases.<Name>\` (hand-written execute) and \`useCases.<Name>.run.<name>\` (run functions).
|
|
121
|
+
|
|
122
|
+
## Commands and outputs
|
|
123
|
+
|
|
124
|
+
\`\`\`json
|
|
125
|
+
{
|
|
126
|
+
"commands": {
|
|
127
|
+
"Users": {
|
|
128
|
+
"description": "Accounts.", "tags": ["public"],
|
|
129
|
+
"context": { "fields": { "userId": "id?" } },
|
|
130
|
+
"useCases": { "register": "Register" },
|
|
131
|
+
"transaction": true
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"outputs": {
|
|
135
|
+
"Api": {
|
|
136
|
+
"commands": ["Users"],
|
|
137
|
+
"statuses": { "UserAlreadyExists": 409 },
|
|
138
|
+
"pipe": [
|
|
139
|
+
{ "transform": "select", "options": { "tags": ["public"] } },
|
|
140
|
+
{ "sink": "openapi", "options": { "info": { "title": "Blog", "version": "1.0.0" } } }
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
- \`commands\`: \`useCases\` maps an operation name (an identifier) to a declared use case; \`context\` is a blueprint name or \`{ "fields": {...} }\`.
|
|
148
|
+
\`transaction: true\` runs each call in a transaction of the repositories' provider: it is checked when the code runs, not by the validator
|
|
149
|
+
(the provider is unknown then). \`events\` is \`"after-commit"\` (default), \`"manual"\` or \`{ "import": "./events.ts#dispatch" }\` (a handler);
|
|
150
|
+
\`onEventError\` is an import and only with \`after-commit\`. Defaults (\`transaction: false\`, \`events: "after-commit"\`, no tags) are not written.
|
|
151
|
+
- \`outputs\`: \`commands\` are declared commands; \`statuses\` maps an exception name (declared or exported by @afora/core) to a status from 400 to 599;
|
|
152
|
+
\`pipe\` is transforms and, last, at most one sink, each \`{ "transform" | "sink": name, "options"?: {...JSON} }\` with a name from the \`stages\` of
|
|
153
|
+
\`afora_catalog\` (\`select\`, \`openapi\`, plus the ones given to the server with \`--stages\`), or \`{ "import": "./stages.ts#auth" }\` for a stage written by hand.
|
|
154
|
+
- \`afora_extract\` needs \`imports\` for \`commands.<Name>.events\`, \`commands.<Name>.onEventError\` and \`outputs.<Name>.pipe.<index>\` (hand-written ones).
|
|
155
|
+
`;
|
|
156
|
+
// src/server.ts
|
|
157
|
+
import {
|
|
158
|
+
catalog as catalog2,
|
|
159
|
+
specSchema as specSchema2
|
|
160
|
+
} from "@afora/codegen";
|
|
161
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
162
|
+
import {
|
|
163
|
+
CallToolRequestSchema,
|
|
164
|
+
ListResourcesRequestSchema,
|
|
165
|
+
ListToolsRequestSchema,
|
|
166
|
+
ReadResourceRequestSchema
|
|
167
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
168
|
+
|
|
169
|
+
// src/extract.ts
|
|
170
|
+
import { existsSync } from "node:fs";
|
|
171
|
+
import { realpath } from "node:fs/promises";
|
|
172
|
+
import { fileURLToPath } from "node:url";
|
|
173
|
+
import { confinePath } from "@afora/codegen";
|
|
174
|
+
|
|
175
|
+
// src/process.ts
|
|
176
|
+
import { spawn } from "node:child_process";
|
|
177
|
+
function run(command, args, options) {
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
let output = "";
|
|
180
|
+
let timedOut = false;
|
|
181
|
+
const child = spawn(command, [...args], {
|
|
182
|
+
cwd: options.cwd,
|
|
183
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
184
|
+
});
|
|
185
|
+
const timer = setTimeout(() => {
|
|
186
|
+
timedOut = true;
|
|
187
|
+
child.kill("SIGKILL");
|
|
188
|
+
}, options.timeoutMs);
|
|
189
|
+
child.stdout.on("data", (chunk) => {
|
|
190
|
+
output += chunk;
|
|
191
|
+
});
|
|
192
|
+
child.stderr.on("data", (chunk) => {
|
|
193
|
+
output += chunk;
|
|
194
|
+
});
|
|
195
|
+
child.on("error", () => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
resolve({ code: null, output, missing: true, timedOut });
|
|
198
|
+
});
|
|
199
|
+
child.on("close", (code) => {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
resolve({ code, output, missing: false, timedOut });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/extract.ts
|
|
207
|
+
var START = `
|
|
208
|
+
<<<AFORA_SPEC>>>
|
|
209
|
+
`;
|
|
210
|
+
var END = `
|
|
211
|
+
<<<END_AFORA_SPEC>>>
|
|
212
|
+
`;
|
|
213
|
+
|
|
214
|
+
class ToolError extends Error {
|
|
215
|
+
name = "ToolError";
|
|
216
|
+
}
|
|
217
|
+
function childPath() {
|
|
218
|
+
for (const extension of ["js", "ts"]) {
|
|
219
|
+
const url = new URL(`./extract-child.${extension}`, import.meta.url);
|
|
220
|
+
const path = fileURLToPath(url);
|
|
221
|
+
if (existsSync(path))
|
|
222
|
+
return path;
|
|
223
|
+
}
|
|
224
|
+
throw new ToolError("The extract helper is missing from the installation.");
|
|
225
|
+
}
|
|
226
|
+
function parseAppReference(reference) {
|
|
227
|
+
const [file, name = "App"] = reference.split("#");
|
|
228
|
+
if (!file) {
|
|
229
|
+
throw new ToolError('Expected "path/to/file.ts#ExportName", e.g. "src/app.ts#App".');
|
|
230
|
+
}
|
|
231
|
+
return { file, name };
|
|
232
|
+
}
|
|
233
|
+
async function extractFromApp(root, reference, outputDir, imports) {
|
|
234
|
+
const { file, name } = parseAppReference(reference);
|
|
235
|
+
const confined = await confinePath(root, file);
|
|
236
|
+
if (!confined.ok) {
|
|
237
|
+
throw new ToolError(`The application file ${file} is ${confined.reason}.`);
|
|
238
|
+
}
|
|
239
|
+
if (!existsSync(confined.absolute)) {
|
|
240
|
+
throw new ToolError(`The application file ${file} does not exist.`);
|
|
241
|
+
}
|
|
242
|
+
const real = await realpath(confined.absolute);
|
|
243
|
+
const bun = process.versions.bun ? process.execPath : "bun";
|
|
244
|
+
const result = await run(bun, [
|
|
245
|
+
childPath(),
|
|
246
|
+
real,
|
|
247
|
+
name,
|
|
248
|
+
outputDir ?? "",
|
|
249
|
+
...imports ? [JSON.stringify(imports)] : []
|
|
250
|
+
], { cwd: root, timeoutMs: 60000 });
|
|
251
|
+
if (result.missing) {
|
|
252
|
+
throw new ToolError("Bun is needed to import the application and was not found on PATH.");
|
|
253
|
+
}
|
|
254
|
+
if (result.timedOut)
|
|
255
|
+
throw new ToolError("Importing the application timed out.");
|
|
256
|
+
const start = result.output.indexOf(START);
|
|
257
|
+
const end = result.output.indexOf(END, start);
|
|
258
|
+
if (result.code !== 0 || start === -1 || end === -1) {
|
|
259
|
+
throw new ToolError(`Could not extract the application:
|
|
260
|
+
${result.output.trim().slice(-2000)}`);
|
|
261
|
+
}
|
|
262
|
+
return JSON.parse(result.output.slice(start + START.length, end));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/spec-file.ts
|
|
266
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
267
|
+
import { mkdir, readFile, realpath as realpath2, writeFile } from "node:fs/promises";
|
|
268
|
+
import { dirname, join } from "node:path";
|
|
269
|
+
import {
|
|
270
|
+
COLLECTION_KEYS,
|
|
271
|
+
confinePath as confinePath2,
|
|
272
|
+
resolveExtended,
|
|
273
|
+
SpecLoadError,
|
|
274
|
+
validateSpec
|
|
275
|
+
} from "@afora/codegen";
|
|
276
|
+
var SPEC_FILE = "afora.json";
|
|
277
|
+
function codegenOptionsOf(project) {
|
|
278
|
+
return {
|
|
279
|
+
...project.providers ? { providers: project.providers } : {},
|
|
280
|
+
...project.stages ? { stages: project.stages } : {}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
async function openProject(directory, app, providers, stages) {
|
|
284
|
+
let root;
|
|
285
|
+
try {
|
|
286
|
+
root = await realpath2(directory);
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error(`The project directory ${directory} does not exist.`);
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
root,
|
|
292
|
+
...app ? { app } : {},
|
|
293
|
+
...providers ? { providers } : {},
|
|
294
|
+
...stages ? { stages } : {}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
async function readSpec(project) {
|
|
298
|
+
const path = join(project.root, SPEC_FILE);
|
|
299
|
+
if (!existsSync2(path))
|
|
300
|
+
return;
|
|
301
|
+
const confined = await confinePath2(project.root, SPEC_FILE);
|
|
302
|
+
if (!confined.ok) {
|
|
303
|
+
throw new ToolError(`${SPEC_FILE} is ${confined.reason}.`);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return JSON.parse(await readFile(confined.absolute, "utf8"));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
throw new ToolError(`${SPEC_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function writeSpec(project, spec) {
|
|
312
|
+
const confined = await confinePath2(project.root, SPEC_FILE);
|
|
313
|
+
if (!confined.ok) {
|
|
314
|
+
throw new ToolError(`${SPEC_FILE} is ${confined.reason}; it was not written.`);
|
|
315
|
+
}
|
|
316
|
+
await mkdir(dirname(confined.absolute), { recursive: true });
|
|
317
|
+
await writeFile(confined.absolute, `${JSON.stringify(ordered(spec), null, "\t")}
|
|
318
|
+
`);
|
|
319
|
+
}
|
|
320
|
+
var TOP_ORDER = [
|
|
321
|
+
"$schema",
|
|
322
|
+
"version",
|
|
323
|
+
"name",
|
|
324
|
+
"extends",
|
|
325
|
+
"output",
|
|
326
|
+
"provider",
|
|
327
|
+
...COLLECTION_KEYS
|
|
328
|
+
];
|
|
329
|
+
function ordered(spec) {
|
|
330
|
+
const source = spec;
|
|
331
|
+
const keys = [
|
|
332
|
+
...TOP_ORDER.filter((key) => (key in source)),
|
|
333
|
+
...Object.keys(source).filter((key) => !TOP_ORDER.includes(key))
|
|
334
|
+
];
|
|
335
|
+
return Object.fromEntries(keys.map((key) => [key, source[key]]));
|
|
336
|
+
}
|
|
337
|
+
async function extendedOf(project, spec) {
|
|
338
|
+
try {
|
|
339
|
+
return {
|
|
340
|
+
extended: await resolveExtended(spec, join(project.root, SPEC_FILE)),
|
|
341
|
+
issues: []
|
|
342
|
+
};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
if (!(error instanceof SpecLoadError))
|
|
345
|
+
throw error;
|
|
346
|
+
return {
|
|
347
|
+
extended: {},
|
|
348
|
+
issues: [
|
|
349
|
+
{
|
|
350
|
+
path: ["extends"],
|
|
351
|
+
code: "unresolved_extends",
|
|
352
|
+
message: error.message
|
|
353
|
+
}
|
|
354
|
+
]
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function validateInProject(project, spec) {
|
|
359
|
+
const loaded = typeof spec === "object" && spec !== null ? await extendedOf(project, spec) : { extended: {}, issues: [] };
|
|
360
|
+
const issues = validateSpec(spec, loaded.extended, codegenOptionsOf(project));
|
|
361
|
+
const known = new Set(issues.map((issue) => `${issue.code}@${issue.path.join(".")}`));
|
|
362
|
+
return {
|
|
363
|
+
extended: loaded.extended,
|
|
364
|
+
issues: [
|
|
365
|
+
...issues,
|
|
366
|
+
...loaded.issues.filter((issue) => !known.has(`${issue.code}@${issue.path.join(".")}`) && !issues.some((other) => other.code === "unresolved_extends"))
|
|
367
|
+
]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/tools.ts
|
|
372
|
+
import {
|
|
373
|
+
catalog,
|
|
374
|
+
declarationSchemas,
|
|
375
|
+
fieldDefinition,
|
|
376
|
+
generate,
|
|
377
|
+
InvalidSpecError,
|
|
378
|
+
specSchema,
|
|
379
|
+
writeGenerated
|
|
380
|
+
} from "@afora/codegen";
|
|
381
|
+
import { Type } from "typebox";
|
|
382
|
+
|
|
383
|
+
// src/typecheck.ts
|
|
384
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
385
|
+
import { join as join2, relative, resolve } from "node:path";
|
|
386
|
+
var DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
|
|
387
|
+
function parseDiagnostics(output) {
|
|
388
|
+
const found = [];
|
|
389
|
+
for (const text of output.split(/\r?\n/)) {
|
|
390
|
+
const match = DIAGNOSTIC.exec(text);
|
|
391
|
+
if (match) {
|
|
392
|
+
found.push({
|
|
393
|
+
path: match[1],
|
|
394
|
+
file: match[1],
|
|
395
|
+
line: Number(match[2]),
|
|
396
|
+
column: Number(match[3]),
|
|
397
|
+
code: match[4],
|
|
398
|
+
message: match[5]
|
|
399
|
+
});
|
|
400
|
+
} else if (/^\s+\S/.test(text) && found.length > 0) {
|
|
401
|
+
const last = found[found.length - 1];
|
|
402
|
+
last.message += `
|
|
403
|
+
${text.trim()}`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return found;
|
|
407
|
+
}
|
|
408
|
+
async function typecheck(root, files) {
|
|
409
|
+
if (!existsSync3(join2(root, "tsconfig.json"))) {
|
|
410
|
+
return { ran: false, reason: "The project has no tsconfig.json." };
|
|
411
|
+
}
|
|
412
|
+
const mine = new Set(files.map((file) => resolve(root, file)));
|
|
413
|
+
const commands = [
|
|
414
|
+
["bunx", ["tsc", "--noEmit", "-p", root]],
|
|
415
|
+
["npx", ["--no-install", "tsc", "--noEmit", "-p", root]]
|
|
416
|
+
];
|
|
417
|
+
for (const [command, args] of commands) {
|
|
418
|
+
const result = await run(command, args, { cwd: root, timeoutMs: 180000 });
|
|
419
|
+
if (result.missing)
|
|
420
|
+
continue;
|
|
421
|
+
if (result.timedOut) {
|
|
422
|
+
return { ran: false, reason: "tsc timed out after 180 seconds." };
|
|
423
|
+
}
|
|
424
|
+
const all = parseDiagnostics(result.output);
|
|
425
|
+
if (all.length === 0 && result.code !== 0) {
|
|
426
|
+
return {
|
|
427
|
+
ran: false,
|
|
428
|
+
reason: `tsc failed without diagnostics: ${result.output.trim().slice(0, 500)}`
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const diagnostics = all.filter((item) => mine.has(resolve(root, item.path))).map(({ path, ...item }) => ({
|
|
432
|
+
...item,
|
|
433
|
+
file: relative(root, resolve(root, path))
|
|
434
|
+
}));
|
|
435
|
+
return {
|
|
436
|
+
ran: true,
|
|
437
|
+
ok: diagnostics.length === 0,
|
|
438
|
+
diagnostics,
|
|
439
|
+
otherErrors: all.length - diagnostics.length
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
ran: false,
|
|
444
|
+
reason: "Neither bunx nor npx is available to run tsc."
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/tools.ts
|
|
449
|
+
var json = (schema) => JSON.parse(JSON.stringify(schema));
|
|
450
|
+
var KINDS = [
|
|
451
|
+
"property",
|
|
452
|
+
"blueprint",
|
|
453
|
+
"record",
|
|
454
|
+
"entity",
|
|
455
|
+
"environment",
|
|
456
|
+
"repository",
|
|
457
|
+
"exception",
|
|
458
|
+
"useCase",
|
|
459
|
+
"command",
|
|
460
|
+
"output"
|
|
461
|
+
];
|
|
462
|
+
var COLLECTION_OF = {
|
|
463
|
+
property: "properties",
|
|
464
|
+
blueprint: "blueprints",
|
|
465
|
+
record: "records",
|
|
466
|
+
entity: "entities",
|
|
467
|
+
environment: "environments",
|
|
468
|
+
repository: "repositories",
|
|
469
|
+
exception: "exceptions",
|
|
470
|
+
useCase: "useCases",
|
|
471
|
+
command: "commands",
|
|
472
|
+
output: "outputs"
|
|
473
|
+
};
|
|
474
|
+
var specInput = () => {
|
|
475
|
+
const { $defs: _defs, ...schema } = json(specSchema);
|
|
476
|
+
return schema;
|
|
477
|
+
};
|
|
478
|
+
var FIELD_DEFS = { Field: json(fieldDefinition) };
|
|
479
|
+
function issuesResult(issues) {
|
|
480
|
+
return { valid: issues.length === 0, issues };
|
|
481
|
+
}
|
|
482
|
+
async function currentSpec(project) {
|
|
483
|
+
const spec = await readSpec(project);
|
|
484
|
+
if (spec)
|
|
485
|
+
return { spec, source: "file" };
|
|
486
|
+
if (project.app) {
|
|
487
|
+
return {
|
|
488
|
+
spec: await extractFromApp(project.root, project.app),
|
|
489
|
+
source: "app"
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return { spec: { version: 1 }, source: "new" };
|
|
493
|
+
}
|
|
494
|
+
function asSpec(value) {
|
|
495
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
496
|
+
throw new ToolError("`spec` must be a JSON object (the contents of afora.json).");
|
|
497
|
+
}
|
|
498
|
+
return value;
|
|
499
|
+
}
|
|
500
|
+
function kindOf(value) {
|
|
501
|
+
if (!KINDS.includes(value)) {
|
|
502
|
+
throw new ToolError(`\`kind\` must be one of: ${KINDS.join(", ")}.`);
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
function nameOf(value) {
|
|
507
|
+
if (typeof value !== "string" || value === "") {
|
|
508
|
+
throw new ToolError("`name` must be the PascalCase name of the declaration.");
|
|
509
|
+
}
|
|
510
|
+
return value;
|
|
511
|
+
}
|
|
512
|
+
async function saveIfValid(project, spec, extra = {}) {
|
|
513
|
+
const { issues } = await validateInProject(project, spec);
|
|
514
|
+
if (issues.length > 0) {
|
|
515
|
+
return {
|
|
516
|
+
written: false,
|
|
517
|
+
...issuesResult(issues),
|
|
518
|
+
hint: "Nothing was written. Fix the issues (declare what a declaration uses first) and retry.",
|
|
519
|
+
...extra
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
await writeSpec(project, spec);
|
|
523
|
+
return { written: true, path: "afora.json", ...issuesResult([]), ...extra };
|
|
524
|
+
}
|
|
525
|
+
var tools = [
|
|
526
|
+
{
|
|
527
|
+
name: "afora_catalog",
|
|
528
|
+
description: "What an afora.json can use, read from @afora/core: the Std properties (type, flags sensitive/unique/optional, and the `with` options each accepts), factories (enum), the Std record plugins (keys they add, unique/sensitive/change keys, methods), reserved record/entity keys, identity keys and the repository action rules. The `providers` list is what `provider.use` may name; `exceptionLayers` and `coreExceptions` are what an exception's `layer` and a use case's `errors` may use; `stages` (select, openapi and the ones given to the server with --stages) are what an output's `pipe` may name with `transform` or `sink`. Call this first; do not guess property names.",
|
|
529
|
+
inputSchema: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {},
|
|
532
|
+
additionalProperties: false
|
|
533
|
+
},
|
|
534
|
+
readOnly: true,
|
|
535
|
+
handle: async (_args, project) => ({
|
|
536
|
+
...catalog(),
|
|
537
|
+
providers: [
|
|
538
|
+
{
|
|
539
|
+
name: "inMemory",
|
|
540
|
+
builtin: true,
|
|
541
|
+
note: 'provider: "inMemory" (or { "use": "inMemory" }), no options.'
|
|
542
|
+
},
|
|
543
|
+
...project.providers ?? []
|
|
544
|
+
],
|
|
545
|
+
stages: [
|
|
546
|
+
...catalog().stages,
|
|
547
|
+
...(project.stages ?? []).map((stage) => ({
|
|
548
|
+
...stage,
|
|
549
|
+
builtin: false
|
|
550
|
+
}))
|
|
551
|
+
]
|
|
552
|
+
})
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "afora_spec_read",
|
|
556
|
+
description: "Reads the project's afora.json, with its validation issues. If there is none and the server was started with --app, returns the spec extracted from that application's code (not written). Use it before editing.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
type: "object",
|
|
559
|
+
properties: {},
|
|
560
|
+
additionalProperties: false
|
|
561
|
+
},
|
|
562
|
+
readOnly: true,
|
|
563
|
+
async handle(_args, project) {
|
|
564
|
+
const spec = await readSpec(project);
|
|
565
|
+
if (spec) {
|
|
566
|
+
const { issues } = await validateInProject(project, spec);
|
|
567
|
+
return {
|
|
568
|
+
source: "file",
|
|
569
|
+
path: "afora.json",
|
|
570
|
+
spec,
|
|
571
|
+
...issuesResult(issues)
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
if (project.app) {
|
|
575
|
+
const extracted = await extractFromApp(project.root, project.app);
|
|
576
|
+
return {
|
|
577
|
+
source: "app",
|
|
578
|
+
app: project.app,
|
|
579
|
+
spec: extracted,
|
|
580
|
+
note: "Extracted from the code; nothing was written. afora_spec_write or afora_spec_upsert saves a spec as afora.json."
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
source: "none",
|
|
585
|
+
spec: null,
|
|
586
|
+
note: 'There is no afora.json yet. Start with afora_spec_upsert (it creates one) or afora_spec_write with { "version": 1, ... }.'
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "afora_extract",
|
|
592
|
+
description: "Extracts the spec from an existing @afora/core Application in the project, by importing the module in a Bun subprocess (this runs the module's code). Does not write anything: review it, then save with afora_spec_write. Properties that cannot be expressed in JSON become opaque imports.",
|
|
593
|
+
inputSchema: json(Type.Object({
|
|
594
|
+
app: Type.Optional(Type.String({
|
|
595
|
+
description: 'Path inside the project and export name, e.g. "src/app.ts#App". Defaults to the --app the server was started with.'
|
|
596
|
+
})),
|
|
597
|
+
outputDir: Type.Optional(Type.String({
|
|
598
|
+
description: "output.dir for the spec; src/domain by default."
|
|
599
|
+
})),
|
|
600
|
+
imports: Type.Optional(Type.Record(Type.String(), Type.String(), {
|
|
601
|
+
description: 'Where what cannot be described is imported from: a property name -> module, and "./file.ts#export" (relative to output.dir) for a provider not created with providerOf ("provider" or "repositories.<Name>.provider"), a use case with a hand-written execute ("useCases.<Name>") and a run function of a steps use case ("useCases.<Name>.run.<name>"), a command handler ("commands.<Name>.events", "commands.<Name>.onEventError") and a stage written by hand ("outputs.<Name>.pipe.<index>"). Without it extraction fails with an error naming the key.'
|
|
602
|
+
}))
|
|
603
|
+
}, { additionalProperties: false })),
|
|
604
|
+
readOnly: true,
|
|
605
|
+
async handle(args, project) {
|
|
606
|
+
const app = typeof args.app === "string" ? args.app : project.app;
|
|
607
|
+
if (!app) {
|
|
608
|
+
throw new ToolError('Pass `app` ("src/app.ts#App") or start the server with --app.');
|
|
609
|
+
}
|
|
610
|
+
const outputDir = typeof args.outputDir === "string" ? args.outputDir : undefined;
|
|
611
|
+
const imports = typeof args.imports === "object" && args.imports !== null && !Array.isArray(args.imports) ? args.imports : undefined;
|
|
612
|
+
const spec = await extractFromApp(project.root, app, outputDir, imports);
|
|
613
|
+
const { issues } = await validateInProject(project, spec);
|
|
614
|
+
return { source: "app", app, spec, ...issuesResult(issues) };
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
name: "afora_spec_validate",
|
|
619
|
+
description: "Validates a spec without writing anything and returns the issues ({ path, code, message }). Pass `spec` to check a draft, or omit it to check the afora.json on disk. Rules covered (use cases: references, dependency kinds, errors registered or core, every step rule with the codes unknown_step, invalid_reference, unbound_reference, unknown_key, unknown_dependency, unknown_action, write_in_find, undeclared_error, missing_return, misplaced_return, invalid_step_name, invalid_class, invalid_change_key, invalid_value, invalid_import; commands: unknown_use_case, invalid_use_cases, invalid_operation, invalid_events, event_error_requires_after_commit; outputs: unknown_command, invalid_commands, invalid_pipe, invalid_stage, unknown_stage, stage_kind_mismatch, sink_not_last, invalid_stage_options, invalid_statuses, invalid_status, unknown_exception): unknown references, name collisions, cycles, reserved and identity keys, unique/sensitive/change naming missing keys, repository action rules (findBy only for unique keys, findManyBy only for non-unique; findMany, exists, findPage need no key), providers (unknown_provider, invalid_provider, invalid_import), relations covering the entities held, invalid `with` options.",
|
|
620
|
+
inputSchema: {
|
|
621
|
+
type: "object",
|
|
622
|
+
properties: {
|
|
623
|
+
spec: {
|
|
624
|
+
...specInput(),
|
|
625
|
+
description: "A draft spec. Omit to validate afora.json on disk."
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
additionalProperties: false,
|
|
629
|
+
$defs: FIELD_DEFS
|
|
630
|
+
},
|
|
631
|
+
readOnly: true,
|
|
632
|
+
async handle(args, project) {
|
|
633
|
+
const spec = args.spec ?? await readSpec(project);
|
|
634
|
+
if (spec === undefined) {
|
|
635
|
+
throw new ToolError("There is no afora.json to validate; pass `spec`.");
|
|
636
|
+
}
|
|
637
|
+
const { issues } = await validateInProject(project, spec);
|
|
638
|
+
return issuesResult(issues);
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
name: "afora_spec_upsert",
|
|
643
|
+
description: "Adds or replaces ONE declaration in afora.json (creating the file if needed), validates the whole spec and writes only if it is valid. Cheaper than resending the spec. `kind` is property | blueprint | record | entity | environment | repository | exception | useCase | command | output; `declaration` is its JSON (see the schema). Declare what it references first (e.g. a blueprint before the entity that uses it, the entity before its repository). If invalid, nothing is written and the issues are returned.",
|
|
644
|
+
inputSchema: {
|
|
645
|
+
type: "object",
|
|
646
|
+
properties: {
|
|
647
|
+
kind: {
|
|
648
|
+
type: "string",
|
|
649
|
+
enum: [...KINDS],
|
|
650
|
+
description: "Which collection the declaration belongs to."
|
|
651
|
+
},
|
|
652
|
+
name: {
|
|
653
|
+
type: "string",
|
|
654
|
+
pattern: "^[A-Z][A-Za-z0-9]*$",
|
|
655
|
+
description: "PascalCase name, unique across all kinds."
|
|
656
|
+
},
|
|
657
|
+
declaration: {
|
|
658
|
+
description: 'The declaration. property: { base, with? } | { enum } | { opaque, import }. blueprint/environment: { fields }. record/entity: { fields, unique?, sensitive?, change?, plugins? }. repository: { entity, actions, provider?, deletion?: hard | soft, relations?, environments? }. exception: { layer, message? }. useCase: { description?, tags?, deprecated?, input, response, context?, dependencies?, errors?, implementation: { steps: [...] } | { import: "../handlers/x.ts#Name" } } (see the guide for steps). command: { description?, tags?, context?, useCases: { op: "UseCaseName" }, transaction?, events?: "after-commit" | "manual" | { import }, onEventError?: { import } }. output: { commands: [names], statuses?: { ExceptionName: 400-599 }, pipe: [{ transform, options? } | { sink, options? } | { import }] }. provider: "inMemory" | { use, options? } | { import: "./file.ts#name" } (the application provider is the top-level `provider` of the spec: afora_spec_write).',
|
|
659
|
+
anyOf: Object.values(declarationSchemas).map((schema) => json(schema))
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
required: ["kind", "name", "declaration"],
|
|
663
|
+
additionalProperties: false,
|
|
664
|
+
$defs: FIELD_DEFS
|
|
665
|
+
},
|
|
666
|
+
readOnly: false,
|
|
667
|
+
async handle(args, project) {
|
|
668
|
+
const kind = kindOf(args.kind);
|
|
669
|
+
const name = nameOf(args.name);
|
|
670
|
+
const declaration = args.declaration;
|
|
671
|
+
if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
|
|
672
|
+
throw new ToolError("`declaration` must be a JSON object.");
|
|
673
|
+
}
|
|
674
|
+
const { spec, source } = await currentSpec(project);
|
|
675
|
+
const collection = COLLECTION_OF[kind];
|
|
676
|
+
const existing = spec[collection];
|
|
677
|
+
const next = {
|
|
678
|
+
...spec,
|
|
679
|
+
[collection]: { ...existing ?? {}, [name]: declaration }
|
|
680
|
+
};
|
|
681
|
+
return saveIfValid(project, next, {
|
|
682
|
+
action: existing && name in existing ? "replaced" : "added",
|
|
683
|
+
kind,
|
|
684
|
+
name,
|
|
685
|
+
...source === "app" ? {
|
|
686
|
+
note: "There was no afora.json: started from the spec extracted from the application."
|
|
687
|
+
} : {}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: "afora_spec_remove",
|
|
693
|
+
description: "Removes ONE declaration from afora.json, validates the whole spec and writes only if it stays valid (so remove what depends on it first: a repository before its entity, an entity before the blueprint it uses is not needed, but anything referencing it must go or change).",
|
|
694
|
+
inputSchema: json(Type.Object({
|
|
695
|
+
kind: Type.Union(KINDS.map((kind) => Type.Literal(kind))),
|
|
696
|
+
name: Type.String()
|
|
697
|
+
}, { additionalProperties: false })),
|
|
698
|
+
readOnly: false,
|
|
699
|
+
async handle(args, project) {
|
|
700
|
+
const kind = kindOf(args.kind);
|
|
701
|
+
const name = nameOf(args.name);
|
|
702
|
+
const { spec } = await currentSpec(project);
|
|
703
|
+
const collection = COLLECTION_OF[kind];
|
|
704
|
+
const existing = spec[collection];
|
|
705
|
+
if (!existing || !(name in existing)) {
|
|
706
|
+
throw new ToolError(`There is no ${kind} named "${name}" in afora.json.`);
|
|
707
|
+
}
|
|
708
|
+
const { [name]: _removed, ...rest } = existing;
|
|
709
|
+
const next = { ...spec };
|
|
710
|
+
if (Object.keys(rest).length > 0)
|
|
711
|
+
next[collection] = rest;
|
|
712
|
+
else
|
|
713
|
+
delete next[collection];
|
|
714
|
+
return saveIfValid(project, next, {
|
|
715
|
+
action: "removed",
|
|
716
|
+
kind,
|
|
717
|
+
name
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "afora_spec_write",
|
|
723
|
+
description: "Replaces the whole afora.json with `spec` (validated first; invalid specs are not written). Use it to create the spec in one go or to save one from afora_extract.",
|
|
724
|
+
inputSchema: {
|
|
725
|
+
type: "object",
|
|
726
|
+
properties: { spec: specInput() },
|
|
727
|
+
required: ["spec"],
|
|
728
|
+
additionalProperties: false,
|
|
729
|
+
$defs: FIELD_DEFS
|
|
730
|
+
},
|
|
731
|
+
readOnly: false,
|
|
732
|
+
async handle(args, project) {
|
|
733
|
+
return saveIfValid(project, asSpec(args.spec), { action: "replaced" });
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: "afora_generate",
|
|
738
|
+
description: "Generates the TypeScript from afora.json. With dryRun: true returns the files (path and contents) and writes nothing. Otherwise writes them under output.dir (overwriting or removing only files that carry the 'Generated by @afora/codegen' banner; anything else makes it refuse and write nothing), then runs `bunx tsc --noEmit -p <project>` if the project has a tsconfig.json and returns only the diagnostics of the generated files. Fix the spec, not the generated files.",
|
|
739
|
+
inputSchema: json(Type.Object({
|
|
740
|
+
dryRun: Type.Optional(Type.Boolean({ description: "Return the files without writing." })),
|
|
741
|
+
typecheck: Type.Optional(Type.Boolean({
|
|
742
|
+
description: "Run tsc after writing. Default true."
|
|
743
|
+
}))
|
|
744
|
+
}, { additionalProperties: false })),
|
|
745
|
+
readOnly: false,
|
|
746
|
+
async handle(args, project) {
|
|
747
|
+
const spec = await readSpec(project);
|
|
748
|
+
if (!spec) {
|
|
749
|
+
throw new ToolError("There is no afora.json to generate from. Create it with afora_spec_write or afora_spec_upsert.");
|
|
750
|
+
}
|
|
751
|
+
const { extended, issues } = await validateInProject(project, spec);
|
|
752
|
+
if (issues.length > 0)
|
|
753
|
+
return { generated: false, ...issuesResult(issues) };
|
|
754
|
+
let files;
|
|
755
|
+
try {
|
|
756
|
+
files = generate(spec, extended, codegenOptionsOf(project));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
if (error instanceof InvalidSpecError) {
|
|
759
|
+
return { generated: false, ...issuesResult([...error.issues]) };
|
|
760
|
+
}
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
if (args.dryRun === true) {
|
|
764
|
+
const preview = await writeGenerated(project.root, files, {
|
|
765
|
+
dryRun: true
|
|
766
|
+
});
|
|
767
|
+
return {
|
|
768
|
+
generated: true,
|
|
769
|
+
dryRun: true,
|
|
770
|
+
files,
|
|
771
|
+
wouldRemove: preview.removed,
|
|
772
|
+
refused: preview.refused
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const result = await writeGenerated(project.root, files);
|
|
776
|
+
if (result.refused.length > 0) {
|
|
777
|
+
return {
|
|
778
|
+
generated: false,
|
|
779
|
+
refused: result.refused,
|
|
780
|
+
hint: "Nothing was written. These files exist and are not generated ones (no banner), or are outside the project. Change output.dir or move them."
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const check = args.typecheck === false ? { ran: false, reason: "Skipped (typecheck: false)." } : await typecheck(project.root, files.map((file) => file.path));
|
|
784
|
+
return {
|
|
785
|
+
generated: true,
|
|
786
|
+
written: result.written,
|
|
787
|
+
unchanged: result.unchanged,
|
|
788
|
+
removed: result.removed,
|
|
789
|
+
typecheck: check
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
];
|
|
794
|
+
|
|
795
|
+
// src/server.ts
|
|
796
|
+
var RESOURCES = [
|
|
797
|
+
{
|
|
798
|
+
uri: "afora://spec/schema",
|
|
799
|
+
name: "afora.json JSON Schema",
|
|
800
|
+
description: "The JSON Schema of afora.json (version 1).",
|
|
801
|
+
mimeType: "application/json",
|
|
802
|
+
read: () => JSON.stringify(specSchema2, null, 2)
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
uri: "afora://catalog",
|
|
806
|
+
name: "afora catalog",
|
|
807
|
+
description: "Std properties and their `with` options, record plugins, reserved keys and repository rules, read from @afora/core.",
|
|
808
|
+
mimeType: "application/json",
|
|
809
|
+
read: () => JSON.stringify(catalog2(), null, 2)
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
uri: "afora://spec/guide",
|
|
813
|
+
name: "afora.json guide",
|
|
814
|
+
description: "A short guide to writing afora.json: workflow, shape, fields and the rules the validator enforces.",
|
|
815
|
+
mimeType: "text/markdown",
|
|
816
|
+
read: () => GUIDE
|
|
817
|
+
}
|
|
818
|
+
];
|
|
819
|
+
async function createServer(options = {}) {
|
|
820
|
+
const project = await openProject(options.project ?? process.cwd(), options.app, options.providers, options.stages);
|
|
821
|
+
const server = new Server({ name: "afora", version: "0.0.1" }, {
|
|
822
|
+
capabilities: { tools: {}, resources: {} },
|
|
823
|
+
instructions: "Builds a domain from an afora.json: afora_catalog to see what exists, afora_spec_read, then afora_spec_upsert (one declaration at a time) or afora_spec_write, afora_spec_validate and afora_generate. Read the resource afora://spec/guide for the format and rules. Never edit the generated files; change the spec."
|
|
824
|
+
});
|
|
825
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
826
|
+
tools: tools.map((tool) => ({
|
|
827
|
+
name: tool.name,
|
|
828
|
+
description: tool.description,
|
|
829
|
+
inputSchema: tool.inputSchema,
|
|
830
|
+
annotations: {
|
|
831
|
+
readOnlyHint: tool.readOnly,
|
|
832
|
+
destructiveHint: false,
|
|
833
|
+
openWorldHint: false
|
|
834
|
+
}
|
|
835
|
+
}))
|
|
836
|
+
}));
|
|
837
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
838
|
+
const tool = tools.find((candidate) => candidate.name === request.params.name);
|
|
839
|
+
if (!tool) {
|
|
840
|
+
return {
|
|
841
|
+
isError: true,
|
|
842
|
+
content: [
|
|
843
|
+
{ type: "text", text: `Unknown tool "${request.params.name}".` }
|
|
844
|
+
]
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
const result = await tool.handle(request.params.arguments ?? {}, project);
|
|
849
|
+
return {
|
|
850
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
851
|
+
};
|
|
852
|
+
} catch (error) {
|
|
853
|
+
const message = error instanceof ToolError || error instanceof Error ? error.message : String(error);
|
|
854
|
+
return { isError: true, content: [{ type: "text", text: message }] };
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
858
|
+
resources: RESOURCES.map(({ uri, name, description, mimeType }) => ({
|
|
859
|
+
uri,
|
|
860
|
+
name,
|
|
861
|
+
description,
|
|
862
|
+
mimeType
|
|
863
|
+
}))
|
|
864
|
+
}));
|
|
865
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
866
|
+
const resource = RESOURCES.find((candidate) => candidate.uri === request.params.uri);
|
|
867
|
+
if (!resource)
|
|
868
|
+
throw new Error(`Unknown resource ${request.params.uri}.`);
|
|
869
|
+
return {
|
|
870
|
+
contents: [
|
|
871
|
+
{
|
|
872
|
+
uri: resource.uri,
|
|
873
|
+
mimeType: resource.mimeType,
|
|
874
|
+
text: resource.read()
|
|
875
|
+
}
|
|
876
|
+
]
|
|
877
|
+
};
|
|
878
|
+
});
|
|
879
|
+
return server;
|
|
880
|
+
}
|
|
881
|
+
// src/sink.ts
|
|
882
|
+
import {
|
|
883
|
+
InvalidOutputStage,
|
|
884
|
+
outputValueOf,
|
|
885
|
+
problemOf,
|
|
886
|
+
sinkOf,
|
|
887
|
+
stageCatalogOf,
|
|
888
|
+
statusOf,
|
|
889
|
+
toOpenAPI
|
|
890
|
+
} from "@afora/core";
|
|
891
|
+
import { Server as Server2 } from "@modelcontextprotocol/sdk/server/index.js";
|
|
892
|
+
import {
|
|
893
|
+
CallToolRequestSchema as CallToolRequestSchema2,
|
|
894
|
+
ListToolsRequestSchema as ListToolsRequestSchema2
|
|
895
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
896
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
897
|
+
function validate(options) {
|
|
898
|
+
const given = options;
|
|
899
|
+
for (const key of Object.keys(given)) {
|
|
900
|
+
if (!["name", "version", "prefix", "include", "context"].includes(key)) {
|
|
901
|
+
return `mcpTools has no option "${key}".`;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
for (const key of ["name", "version"]) {
|
|
905
|
+
if (typeof given[key] !== "string" || given[key] === "") {
|
|
906
|
+
return `Expected mcpTools.${key} to be a non-empty string.`;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const { prefix, include, context } = given;
|
|
910
|
+
if (prefix !== undefined && (typeof prefix !== "string" || !/^[A-Za-z0-9_.-]*$/.test(prefix))) {
|
|
911
|
+
return "Expected mcpTools.prefix to use only letters, digits, _, . and -.";
|
|
912
|
+
}
|
|
913
|
+
if (include !== undefined && (!Array.isArray(include) || include.some((id) => typeof id !== "string" || id === ""))) {
|
|
914
|
+
return "Expected mcpTools.include to be a list of operation ids.";
|
|
915
|
+
}
|
|
916
|
+
if (context !== undefined) {
|
|
917
|
+
if (!isObject(context))
|
|
918
|
+
return "Expected mcpTools.context to be an object.";
|
|
919
|
+
for (const [key, name] of Object.entries(context)) {
|
|
920
|
+
if (typeof name !== "string" || name === "") {
|
|
921
|
+
return `Expected mcpTools.context["${key}"] to be a value name.`;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
var kebab = (text) => text.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9_.-]+/g, "_").toLowerCase();
|
|
928
|
+
function scalarTypesOf(blueprint) {
|
|
929
|
+
const schema = toOpenAPI(blueprint);
|
|
930
|
+
const types = {};
|
|
931
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
932
|
+
types[key] = [property.type ?? []].flat();
|
|
933
|
+
}
|
|
934
|
+
return types;
|
|
935
|
+
}
|
|
936
|
+
function coerce(text, types) {
|
|
937
|
+
if (!types || types.includes("string"))
|
|
938
|
+
return text;
|
|
939
|
+
const trimmed = text.trim();
|
|
940
|
+
if (types.includes("boolean") && (trimmed === "true" || trimmed === "false")) {
|
|
941
|
+
return trimmed === "true";
|
|
942
|
+
}
|
|
943
|
+
if (types.includes("integer") || types.includes("number")) {
|
|
944
|
+
const number = trimmed === "" ? Number.NaN : Number(trimmed);
|
|
945
|
+
if (Number.isFinite(number))
|
|
946
|
+
return number;
|
|
947
|
+
}
|
|
948
|
+
return text;
|
|
949
|
+
}
|
|
950
|
+
var text = (value, isError) => ({
|
|
951
|
+
...isError ? { isError } : {},
|
|
952
|
+
content: [{ type: "text", text: JSON.stringify(value) }]
|
|
953
|
+
});
|
|
954
|
+
var mcpTools = sinkOf({
|
|
955
|
+
name: "mcpTools",
|
|
956
|
+
validate,
|
|
957
|
+
build: (operations, options, { output }) => {
|
|
958
|
+
const prefix = options.prefix ?? "";
|
|
959
|
+
const mapping = options.context ?? {};
|
|
960
|
+
const include = options.include;
|
|
961
|
+
if (include) {
|
|
962
|
+
const known = new Set(operations.map((operation) => operation.id));
|
|
963
|
+
const unknown = include.find((id) => !known.has(id));
|
|
964
|
+
if (unknown !== undefined) {
|
|
965
|
+
throw new InvalidOutputStage({
|
|
966
|
+
source: "mcpTools",
|
|
967
|
+
details: {
|
|
968
|
+
reason: `mcpTools.include names "${unknown}", which is not an operation.`
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const entries = new Map;
|
|
974
|
+
for (const operation of operations) {
|
|
975
|
+
if (include && !include.includes(operation.id))
|
|
976
|
+
continue;
|
|
977
|
+
const name = `${prefix}${kebab(operation.command)}_${kebab(operation.name)}`;
|
|
978
|
+
if (name.length > 128 || entries.has(name)) {
|
|
979
|
+
throw new InvalidOutputStage({
|
|
980
|
+
source: "mcpTools",
|
|
981
|
+
details: {
|
|
982
|
+
reason: `mcpTools tool name "${name}" of ${operation.id} is duplicated or longer than 128 characters.`
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
const schema = toOpenAPI(operation.input);
|
|
987
|
+
const description = [
|
|
988
|
+
operation.description ?? `${operation.command}.${operation.name}`,
|
|
989
|
+
operation.deprecated ? "Deprecated." : undefined
|
|
990
|
+
].filter(Boolean).join(" ");
|
|
991
|
+
entries.set(name, {
|
|
992
|
+
operation,
|
|
993
|
+
definition: Object.freeze({
|
|
994
|
+
name,
|
|
995
|
+
description,
|
|
996
|
+
inputSchema: {
|
|
997
|
+
...schema,
|
|
998
|
+
type: "object"
|
|
999
|
+
},
|
|
1000
|
+
operation: operation.id
|
|
1001
|
+
})
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
const tools = [...entries.values()].map((entry) => entry.definition);
|
|
1005
|
+
const call = async (name, args, extra) => {
|
|
1006
|
+
const entry = entries.get(name);
|
|
1007
|
+
if (!entry) {
|
|
1008
|
+
return text({
|
|
1009
|
+
type: "urn:afora:exception:NotFound",
|
|
1010
|
+
title: "NotFound",
|
|
1011
|
+
status: 404,
|
|
1012
|
+
detail: `Unknown tool "${name}".`
|
|
1013
|
+
}, true);
|
|
1014
|
+
}
|
|
1015
|
+
const { operation } = entry;
|
|
1016
|
+
const options_ = {};
|
|
1017
|
+
if (operation.context) {
|
|
1018
|
+
const types = scalarTypesOf(operation.context);
|
|
1019
|
+
const context = {};
|
|
1020
|
+
for (const key of Object.keys(operation.context.properties)) {
|
|
1021
|
+
const source = mapping[key];
|
|
1022
|
+
const value = source === undefined ? undefined : extra?.values?.[source];
|
|
1023
|
+
if (value !== undefined)
|
|
1024
|
+
context[key] = coerce(value, types[key]);
|
|
1025
|
+
}
|
|
1026
|
+
options_.context = context;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const response = await operation.invoke(args ?? {}, options_);
|
|
1030
|
+
return text(outputValueOf(response) ?? null);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
const status = statusOf(error, operation, output.statuses);
|
|
1033
|
+
return text(problemOf(error, { status }), true);
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
const register = (server) => {
|
|
1037
|
+
server.setRequestHandler(ListToolsRequestSchema2, async () => ({
|
|
1038
|
+
tools: tools.map(({ name, description, inputSchema }) => ({
|
|
1039
|
+
name,
|
|
1040
|
+
description,
|
|
1041
|
+
inputSchema
|
|
1042
|
+
}))
|
|
1043
|
+
}));
|
|
1044
|
+
server.setRequestHandler(CallToolRequestSchema2, async (request, extra) => {
|
|
1045
|
+
const values = {};
|
|
1046
|
+
const meta = request.params._meta;
|
|
1047
|
+
for (const [key, value] of Object.entries(meta ?? {})) {
|
|
1048
|
+
if (typeof value === "string")
|
|
1049
|
+
values[key] = value;
|
|
1050
|
+
}
|
|
1051
|
+
const headers = extra.requestInfo?.headers ?? {};
|
|
1052
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
1053
|
+
const one = Array.isArray(value) ? value[0] : value;
|
|
1054
|
+
if (one !== undefined)
|
|
1055
|
+
values[key.toLowerCase()] = one;
|
|
1056
|
+
}
|
|
1057
|
+
const result = await call(request.params.name, request.params.arguments, { values });
|
|
1058
|
+
return { ...result, content: [...result.content] };
|
|
1059
|
+
});
|
|
1060
|
+
return server;
|
|
1061
|
+
};
|
|
1062
|
+
return {
|
|
1063
|
+
tools,
|
|
1064
|
+
call,
|
|
1065
|
+
register,
|
|
1066
|
+
server: () => register(new Server2({ name: options.name, version: options.version }, { capabilities: { tools: {} } }))
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
var McpStages = stageCatalogOf(mcpTools);
|
|
1071
|
+
var stages = [
|
|
1072
|
+
{
|
|
1073
|
+
name: "mcpTools",
|
|
1074
|
+
module: "@afora/mcp",
|
|
1075
|
+
export: "mcpTools",
|
|
1076
|
+
kind: "sink"
|
|
1077
|
+
}
|
|
1078
|
+
];
|
|
1079
|
+
export {
|
|
1080
|
+
GUIDE,
|
|
1081
|
+
McpStages,
|
|
1082
|
+
createServer,
|
|
1083
|
+
mcpTools,
|
|
1084
|
+
parseDiagnostics,
|
|
1085
|
+
stages,
|
|
1086
|
+
tools
|
|
1087
|
+
};
|