@dypai-ai/mcp 1.5.9 → 1.5.11
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/package.json +1 -1
- package/src/index.js +42 -18
- package/src/tools/capability-kits.js +567 -0
- package/src/tools/sync/planner.js +76 -3
- package/src/tools/sync/test.js +49 -20
- package/src/tools/sync/validate.js +35 -19
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -35,6 +35,10 @@ import { manageFrontendTool } from "./tools/frontend.js"
|
|
|
35
35
|
// import { scaffoldTool } from "./tools/scaffold.js"
|
|
36
36
|
import { manageDomainTool } from "./tools/domains.js"
|
|
37
37
|
import { bulkUpsertTool } from "./tools/bulk-upsert.js"
|
|
38
|
+
import {
|
|
39
|
+
searchCapabilityKitsTool,
|
|
40
|
+
manageCapabilityKitTool,
|
|
41
|
+
} from "./tools/capability-kits.js"
|
|
38
42
|
import { uploadFile } from "./tools/storage.js"
|
|
39
43
|
// dypaiTestTool (YAML test-suite runner) is intentionally not imported — deferred to v2.
|
|
40
44
|
// The format works but needs fixtures/auto-rollback/scaffolder + proper docs before being surfaced.
|
|
@@ -82,6 +86,9 @@ const LOCAL_TOOLS = [
|
|
|
82
86
|
manageDomainTool,
|
|
83
87
|
// ── Data ──────────────────────────────────────────────────────────────────
|
|
84
88
|
bulkUpsertTool,
|
|
89
|
+
// ── Capability kits (local source installer) ──────────────────────────────
|
|
90
|
+
searchCapabilityKitsTool,
|
|
91
|
+
manageCapabilityKitTool,
|
|
85
92
|
// ── Git-first source of truth ─────────────────────────────────────────────
|
|
86
93
|
// dypai_describe was merged into dypai_pull (now returns an `overview` block).
|
|
87
94
|
dypaiPullTool,
|
|
@@ -538,6 +545,17 @@ forms, calendars, or domain-specific screens), use \`search_design_patterns\`
|
|
|
538
545
|
with the app/starter/screen/style context. It returns curated recipes; adapt
|
|
539
546
|
them to the project instead of inventing generic starter UI.
|
|
540
547
|
|
|
548
|
+
For complex reusable capabilities (calendar booking, interactive maps, CRUD
|
|
549
|
+
tables, Kanban boards, upload/document flows, dashboards, rich editors), also
|
|
550
|
+
use \`search_capability_kits\`. If a strong kit matches, inspect it with
|
|
551
|
+
\`manage_capability_kit(operation: "inspect")\` and install it with
|
|
552
|
+
\`manage_capability_kit(operation: "apply")\` instead of building that complex
|
|
553
|
+
component from scratch. Installed kit code is editable workspace source; after
|
|
554
|
+
install, if the tool returns dependencies, add any missing packages to the
|
|
555
|
+
target workspace package.json and install them locally (never globally or in
|
|
556
|
+
the MCP server). Then wire the kit into the app, apply SQL if needed, validate
|
|
557
|
+
endpoints, and verify the frontend.
|
|
558
|
+
|
|
541
559
|
**The template system exists to save time when the fit is obvious, not to force-match every request.** When in doubt → blank is always correct. Iterating up from blank is cheaper than deleting 80% of a mismatched template.
|
|
542
560
|
|
|
543
561
|
## The one legit follow-up question
|
|
@@ -683,9 +701,10 @@ Internally this means:
|
|
|
683
701
|
|
|
684
702
|
1. edit backend files
|
|
685
703
|
2. validate local backend changes
|
|
686
|
-
3.
|
|
687
|
-
4.
|
|
688
|
-
5.
|
|
704
|
+
3. test changed endpoint YAML with \`dypai_test_endpoint(mode:'local')\` when practical
|
|
705
|
+
4. save them to the preview environment
|
|
706
|
+
5. test the preview version when practical
|
|
707
|
+
6. then tell the user it is ready to try
|
|
689
708
|
|
|
690
709
|
Never ask the user whether to run the internal save-to-preview step. It is safe, reversible, and required for the user to test the actual change.
|
|
691
710
|
|
|
@@ -881,12 +900,12 @@ Editing files inside \`dypai/\` only changes YOUR DISK. The platform doesn't see
|
|
|
881
900
|
\`\`\`
|
|
882
901
|
|
|
883
902
|
Practical consequences — internalize these:
|
|
884
|
-
- **Never publish backend changes just to test them.**
|
|
903
|
+
- **Never publish backend changes just to test them.** First test the local YAML directly with \`dypai_test_endpoint(mode:'local')\`; only after that save to preview with \`dypai_push\` and verify the staged draft when needed.
|
|
885
904
|
- **After EVERY meaningful backend change set, call \`dypai_push\`.** Don't batch a session's worth of edits hoping to push at the end — if you forget, the user tests the preview and sees the OLD behavior. The push is cheap, idempotent, and creates ONE preview version per resource (subsequent pushes overwrite the pending preview version, not stack new ones).
|
|
886
905
|
- **\`dypai_push\` is the internal save-to-preview step. It is NOT a production publish.** Live traffic is untouched. You can run it repeatedly without affecting real users. In user-facing prose, say "listo para probar" or "en previsualización", not "pushed" or "draft".
|
|
887
906
|
- **The preview host (\`dev-<project_id>.dypai.dev\`) only sees what you've saved to preview.** A change still only on disk is invisible to the user's preview. If the user says "I tested it and nothing changed", first check whether the backend change was saved to preview after the last edit.
|
|
888
907
|
- **\`dypai_validate\` before \`dypai_push\`** — push runs validate as a pre-flight, but running it explicitly first gives you the lint output without committing. Cheap insurance.
|
|
889
|
-
- **Order during a multi-step backend feature**: edit → \`dypai_validate\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\`
|
|
908
|
+
- **Order during a multi-step backend feature**: edit → \`dypai_validate\` → \`dypai_test_endpoint(mode:'local')\` → \`dypai_push\` → \`dypai_test_endpoint(mode:'draft')\` when you need to verify the saved preview. Repeat per coherent change. ONLY when the user explicitly approves production do \`manage_drafts(operation:'list')\` → \`manage_drafts(operation:'publish', confirm:true)\`.
|
|
890
909
|
- **DDL is the exception**: \`execute_sql\` with CREATE / ALTER / DROP TABLE applies to live IMMEDIATELY (no preview layer for schema). Preview only exists for endpoints / webhooks / crons / realtime policies. Summarize destructive DDL to the user before running it.
|
|
891
910
|
|
|
892
911
|
## User intent → tool to call (decision table)
|
|
@@ -898,7 +917,7 @@ Use this BEFORE picking a tool. If unsure which row matches, ask the user.
|
|
|
898
917
|
| "Create a new project" | \`search_project_templates\` (find a starter) | \`create_project(template_slug: ...)\` |
|
|
899
918
|
| "Show me what we have" / "I want to work on existing project X" | \`list_projects\` → \`dypai_pull\` (backend) + \`manage_frontend(sync)\` (frontend) | Read \`dypai/\` files + \`src/\` |
|
|
900
919
|
| "This is a private admin app / public site / user portal / multi-role app" | \`manage_project_access_profile(operation:'update')\` | Then implement the actual auth/UI/data behavior normally |
|
|
901
|
-
| "Add/change a backend endpoint, table, cron, webhook, agent, integration" | Edit files in \`dypai/\` | \`dypai_validate\` → \`dypai_push\` |
|
|
920
|
+
| "Add/change a backend endpoint, table, cron, webhook, agent, integration" | Edit files in \`dypai/\` | \`dypai_validate\` → \`dypai_test_endpoint(mode:'local')\` for changed endpoints → \`dypai_push\` |
|
|
902
921
|
| "Publish my backend changes" / "make it live" | \`manage_drafts(operation:'list')\` to show what's pending | \`manage_drafts(operation:'publish', confirm:true)\` |
|
|
903
922
|
| "Test an endpoint before publishing" | \`dypai_test_endpoint(mode:'local')\` (your edits) or \`(mode:'draft')\` (after push) | — |
|
|
904
923
|
| "Test the new endpoint from my local frontend, end-to-end, before publishing" | Tell user: their local frontend already points to \`https://dev-<project_id>.dypai.dev\` (set by \`manage_frontend(sync)\`), which serves drafts on top of live. So after \`dypai_push\` the local UI hits the draft overlay automatically — nothing else to do. | — |
|
|
@@ -930,21 +949,23 @@ User: "Add a /api/list-tasks endpoint that returns the current user's tasks, and
|
|
|
930
949
|
2. manage_frontend(operation:'sync', ...) # materialize frontend if not already on disk
|
|
931
950
|
3. # Backend: create the endpoint
|
|
932
951
|
Write dypai/endpoints/list-tasks.yaml # trigger.http_api auth_mode:jwt + dypai_database query
|
|
933
|
-
4. dypai_validate # catch
|
|
934
|
-
5.
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
7.
|
|
952
|
+
4. dypai_validate # catch YAML/placeholder issues
|
|
953
|
+
5. dypai_test_endpoint(endpoint:'list-tasks', mode:'local', as_user:'<user_id>')
|
|
954
|
+
# verifies the local YAML before saving anything to preview
|
|
955
|
+
6. dypai_push # saves to preview, NOT production
|
|
956
|
+
7. dypai_test_endpoint(endpoint:'list-tasks', mode:'draft', as_user:'<user_id>')
|
|
957
|
+
# optional final sanity: verifies the preview version; do NOT publish just to test
|
|
958
|
+
8. # Frontend: call the new endpoint from React
|
|
938
959
|
Edit src/pages/Dashboard.tsx # useEndpoint('list-tasks')
|
|
939
|
-
|
|
960
|
+
9. # Test locally/browser if available. Then tell the user in plain language:
|
|
940
961
|
# "Ya está listo para probar. Abre la previsualización y revisa la lista de tareas. Todavía no está publicado para tus usuarios."
|
|
941
|
-
|
|
962
|
+
10. # ONLY after the user confirms it is good:
|
|
942
963
|
manage_drafts(operation:'list') # internal: inspect what will publish
|
|
943
|
-
|
|
944
|
-
|
|
964
|
+
11. manage_drafts(operation:'publish', confirm:true) # backend live after explicit approval
|
|
965
|
+
12. manage_frontend(operation:'deploy', sourceDirectory, confirm:true) # frontend live after explicit approval
|
|
945
966
|
\`\`\`
|
|
946
967
|
|
|
947
|
-
**Testing rule**: never publish backend changes just to test them.
|
|
968
|
+
**Testing rule**: never publish backend changes just to test them. Verify local YAML first with \`dypai_test_endpoint(mode:'local')\`, then save to preview and test \`mode:'draft'\` or the dev URL when needed. **Production order rule**: when you are truly publishing a full-stack change, publish backend BEFORE deploying the frontend; otherwise the live UI may call backend functionality that is not live yet.
|
|
948
969
|
|
|
949
970
|
## Debugging user-reported errors — \`search_logs\` is your starting point
|
|
950
971
|
|
|
@@ -1011,6 +1032,8 @@ Each item has \`type\` (\`execution\` | \`execution_failed\` | \`log\`), \`level
|
|
|
1011
1032
|
|
|
1012
1033
|
DYPAI has NO standalone "edge functions". Every piece of server-side logic (API endpoints, crons, webhooks, AI agents) is a workflow endpoint under \`dypai/endpoints/<name>.yaml\`. A workflow is either a chain of native nodes (\`dypai_database\`, \`http_request\`, \`agent\`, \`stripe\`, etc.) OR a single \`javascript_code\` / \`python_code\` node for custom logic. Mix freely.
|
|
1013
1034
|
|
|
1035
|
+
Endpoint naming is strict: the YAML \`name\` is the public API slug and must exactly match the file basename. Example: \`dypai/endpoints/list-videos.yaml\` must declare \`name: list-videos\`. Use lowercase letters, numbers, hyphens, or underscores only; never spaces or human titles. Put labels like "Listar videos" in \`description\`, not \`name\`. If \`id\` is present, it must match \`name\`; usually omit \`id\`.
|
|
1036
|
+
|
|
1014
1037
|
Mental translations: "edge function" → workflow with one code node; "cron" → \`trigger.schedule\` in the YAML; "webhook receiver" → \`trigger.webhook\`; "internal API" → \`trigger.http_api auth_mode:jwt\`.
|
|
1015
1038
|
|
|
1016
1039
|
→ Full workflow patterns + YAML shape: \`search_docs("workflow patterns")\` and \`search_docs("trigger model")\`. Node catalog (full input/output schemas): read \`dypai/node-catalog.json\`.
|
|
@@ -1030,12 +1053,13 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1030
1053
|
## Top gotchas (the expensive ones)
|
|
1031
1054
|
|
|
1032
1055
|
1. **Forgetting \`WHERE user_id = \${current_user_id}\`** — users see each other's data. #1 multi-tenancy bug. The engine does NOT auto-filter. RLS doesn't exist.
|
|
1033
|
-
2. **Editing YAML without \`dypai_push\`** —
|
|
1056
|
+
2. **Editing YAML without \`dypai_push\`** — \`dypai_test_endpoint(mode:'local')\` can test your file edits, but the preview/frontend cannot see them until \`dypai_push\` saves them to draft. Symptom: *"I tested it in preview and nothing changed"*. Test local first, then push when the changed endpoint is ready for preview.
|
|
1034
1057
|
3. **Treating \`dypai_push\` as a deploy** — it's "save as draft", not publish. Live traffic is untouched until \`manage_drafts(publish, confirm:true)\`. Push freely, only ask the user before publish.
|
|
1035
1058
|
4. **\`public\` auth_mode with \`\${current_user_id}\`** — no JWT → placeholder empty → SQL fails or returns wrong data. Use \`jwt\` if you need the user.
|
|
1036
1059
|
5. **Missing \`return: true\`** — endpoint returns \`null\`. Every path that should produce an HTTP response needs one node with \`return: true\`.
|
|
1037
1060
|
6. **\`tool_ids\` in YAML instead of \`tools\`** — write \`tools: [name1, name2]\`. \`tool_ids\` bypasses the codec and fails silently in prod.
|
|
1038
1061
|
7. **Putting workflow placeholders inside \`javascript_code.code\`** — code is raw JavaScript, so JS template literals like \`\${where.join(" AND ")}\` are safe and not rendered by DYPAI. Pass workflow values via \`input_data\`, \`ctx.nodes\`, \`ctx.user\`, or \`ctx.env\`; do not write \`\${input.email}\` inside code or set \`code\` from another node output.
|
|
1062
|
+
8. **Human endpoint names** — \`name: Listar videos\` in \`list-videos.yaml\` creates a draft the frontend cannot call as \`list-videos\`. \`dypai_validate\` and \`dypai_push\` reject this; fix the slug instead of testing around it.
|
|
1039
1063
|
|
|
1040
1064
|
→ Longer list of common pitfalls + fixes: \`search_docs("troubleshooting")\`.
|
|
1041
1065
|
|
|
@@ -1046,7 +1070,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1046
1070
|
- **AI chat**: single endpoint with \`agent\` node + \`memory_key: "chat:\${current_user_id}"\`. Frontend \`dypai.api.stream()\`.
|
|
1047
1071
|
- **Payments (Stripe)**: \`stripe\` node for ops. Webhook endpoint with \`trigger.webhook\` + \`stripe_webhook: true\` (auto-verifies signature).
|
|
1048
1072
|
- **Cron**: \`trigger.schedule: { cron: "0 9 * * *", timezone: "..." }\`. Same workflow syntax as HTTP endpoints.
|
|
1049
|
-
- **File upload**: frontend \`dypai.api.upload(endpoint, file)\`. Endpoint = one \`dypai_storage\` node
|
|
1073
|
+
- **File upload**: frontend \`dypai.api.upload(endpoint, file, { params: { operation:'upload', file_path } })\`. Endpoint = one \`dypai_storage\` node with \`return:true\`. SDK handles signed-URL PUT and owns \`confirm\`/\`client_upload\`; never pass those flags from frontend params.
|
|
1050
1074
|
- **Live dashboard**: normal endpoint + frontend \`useRealtime(table, { onInsert: refetch })\`.
|
|
1051
1075
|
- **Multi-tenant**: every row has \`org_id\` or \`user_id\`. Every endpoint filters by \`\${current_user_id}\`.
|
|
1052
1076
|
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync } from "fs"
|
|
2
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path"
|
|
3
|
+
import { fileURLToPath } from "url"
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
const DEFAULT_LIMIT = 5
|
|
7
|
+
|
|
8
|
+
function readJson(path) {
|
|
9
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeList(value) {
|
|
17
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim()) : []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function walkUp(start, predicate) {
|
|
21
|
+
let cursor = resolve(start)
|
|
22
|
+
for (let i = 0; i < 10; i++) {
|
|
23
|
+
if (predicate(cursor)) return cursor
|
|
24
|
+
const parent = dirname(cursor)
|
|
25
|
+
if (parent === cursor) break
|
|
26
|
+
cursor = parent
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveKitsRoot(inputRoot) {
|
|
32
|
+
const candidates = [
|
|
33
|
+
inputRoot,
|
|
34
|
+
process.env.DYPAI_CAPABILITY_KITS_ROOT,
|
|
35
|
+
].filter(Boolean)
|
|
36
|
+
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const root = resolve(candidate)
|
|
39
|
+
if (existsSync(join(root, "kits"))) return root
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fromCwd = walkUp(process.cwd(), (dir) => existsSync(join(dir, "dypai-capability-kits", "kits")))
|
|
43
|
+
if (fromCwd) return join(fromCwd, "dypai-capability-kits")
|
|
44
|
+
|
|
45
|
+
const fromThisFile = walkUp(__dirname, (dir) => existsSync(join(dir, "dypai-capability-kits", "kits")))
|
|
46
|
+
if (fromThisFile) return join(fromThisFile, "dypai-capability-kits")
|
|
47
|
+
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Capability kit repo not found. Set DYPAI_CAPABILITY_KITS_ROOT to the local dypai-capability-kits directory.",
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveWorkspaceRoot(inputRoot) {
|
|
54
|
+
if (inputRoot) {
|
|
55
|
+
const root = resolve(inputRoot)
|
|
56
|
+
mkdirSync(root, { recursive: true })
|
|
57
|
+
return root
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const envCandidates = [
|
|
61
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
62
|
+
process.env.DYPAI_WORKSPACE_ROOT,
|
|
63
|
+
process.env.PROJECT_ROOT,
|
|
64
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
|
|
65
|
+
].filter(Boolean)
|
|
66
|
+
|
|
67
|
+
for (const candidate of envCandidates) {
|
|
68
|
+
const root = resolve(candidate)
|
|
69
|
+
if (existsSync(root)) return root
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fromCwd = walkUp(process.cwd(), (dir) =>
|
|
73
|
+
existsSync(join(dir, ".git")) ||
|
|
74
|
+
existsSync(join(dir, "package.json")) ||
|
|
75
|
+
existsSync(join(dir, "dypai")) ||
|
|
76
|
+
existsSync(join(dir, "src")),
|
|
77
|
+
)
|
|
78
|
+
if (fromCwd) return fromCwd
|
|
79
|
+
|
|
80
|
+
throw new Error("Could not determine workspace root. Pass workspace_root as an absolute project path.")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeTarget(workspaceRoot, targetPath) {
|
|
84
|
+
if (!targetPath || typeof targetPath !== "string") throw new Error("Invalid target path")
|
|
85
|
+
if (isAbsolute(targetPath)) throw new Error(`Target must be workspace-relative, got absolute path: ${targetPath}`)
|
|
86
|
+
const normalized = targetPath.replace(/\\/g, "/").replace(/^\/+/, "")
|
|
87
|
+
if (normalized.includes("..")) throw new Error(`Target path cannot contain '..': ${targetPath}`)
|
|
88
|
+
const full = resolve(workspaceRoot, normalized)
|
|
89
|
+
const rel = relative(workspaceRoot, full)
|
|
90
|
+
if (rel.startsWith("..") || rel === "" || isAbsolute(rel)) {
|
|
91
|
+
throw new Error(`Target escapes workspace root: ${targetPath}`)
|
|
92
|
+
}
|
|
93
|
+
return full
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function kitDir(kitsRoot, slug, version) {
|
|
97
|
+
if (!slug || !version) throw new Error("slug and version are required")
|
|
98
|
+
return join(kitsRoot, "kits", slug, version)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadKit(kitsRoot, slug, version) {
|
|
102
|
+
const dir = kitDir(kitsRoot, slug, version)
|
|
103
|
+
const manifestPath = join(dir, "kit.json")
|
|
104
|
+
if (!existsSync(manifestPath)) throw new Error(`Kit not found: ${slug}@${version}`)
|
|
105
|
+
const manifest = readJson(manifestPath)
|
|
106
|
+
return { dir, manifest }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listKits(kitsRoot) {
|
|
110
|
+
const kitsDir = join(kitsRoot, "kits")
|
|
111
|
+
const kits = []
|
|
112
|
+
for (const slug of readdirSync(kitsDir)) {
|
|
113
|
+
const slugDir = join(kitsDir, slug)
|
|
114
|
+
if (!existsSync(slugDir) || slug === "index.json") continue
|
|
115
|
+
for (const version of readdirSync(slugDir)) {
|
|
116
|
+
const manifestPath = join(slugDir, version, "kit.json")
|
|
117
|
+
if (!existsSync(manifestPath)) continue
|
|
118
|
+
kits.push({ dir: join(slugDir, version), manifest: readJson(manifestPath) })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return kits
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function searchText(kit) {
|
|
125
|
+
const parts = [
|
|
126
|
+
kit.slug,
|
|
127
|
+
kit.name,
|
|
128
|
+
kit.kitType,
|
|
129
|
+
kit.category,
|
|
130
|
+
kit.description,
|
|
131
|
+
kit.useWhen,
|
|
132
|
+
kit.avoidWhen,
|
|
133
|
+
kit.userFacingSummary,
|
|
134
|
+
kit.agentInstructions,
|
|
135
|
+
...normalizeList(kit.appTypes),
|
|
136
|
+
...normalizeList(kit.screenTypes),
|
|
137
|
+
...normalizeList(kit.featureTags),
|
|
138
|
+
...normalizeList(kit.visualStyles),
|
|
139
|
+
...normalizeList(kit.provides),
|
|
140
|
+
...normalizeList(kit.requires),
|
|
141
|
+
...normalizeList(kit.dependencies),
|
|
142
|
+
]
|
|
143
|
+
return parts.filter(Boolean).join(" ").toLowerCase()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function matchesFilter(kit, filters = {}) {
|
|
147
|
+
if (!isObject(filters)) return true
|
|
148
|
+
if (filters.category && kit.category !== filters.category) return false
|
|
149
|
+
if (filters.maturity) {
|
|
150
|
+
const allowed = Array.isArray(filters.maturity) ? filters.maturity : [filters.maturity]
|
|
151
|
+
if (!allowed.includes(kit.maturity)) return false
|
|
152
|
+
}
|
|
153
|
+
const listChecks = [
|
|
154
|
+
["app_type", "appTypes"],
|
|
155
|
+
["screen_type", "screenTypes"],
|
|
156
|
+
["feature_tag", "featureTags"],
|
|
157
|
+
]
|
|
158
|
+
for (const [filterKey, kitKey] of listChecks) {
|
|
159
|
+
if (filters[filterKey] && !normalizeList(kit[kitKey]).includes(filters[filterKey])) return false
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(filters.requires)) {
|
|
162
|
+
const requires = normalizeList(kit.requires)
|
|
163
|
+
for (const required of filters.requires) {
|
|
164
|
+
if (!requires.includes(required)) return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function scoreKit(query, kit) {
|
|
171
|
+
const text = searchText(kit)
|
|
172
|
+
const terms = String(query || "")
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.split(/[^a-z0-9áéíóúñ]+/i)
|
|
175
|
+
.map((term) => term.trim())
|
|
176
|
+
.filter((term) => term.length >= 3)
|
|
177
|
+
if (!terms.length) return 0
|
|
178
|
+
let score = 0
|
|
179
|
+
for (const term of terms) {
|
|
180
|
+
if (kit.slug?.includes(term)) score += 5
|
|
181
|
+
if (kit.category?.toLowerCase() === term) score += 4
|
|
182
|
+
if (text.includes(term)) score += 1
|
|
183
|
+
}
|
|
184
|
+
return score
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assetIndex(dir) {
|
|
188
|
+
const out = []
|
|
189
|
+
function visit(abs, rel = "") {
|
|
190
|
+
for (const name of readdirSync(abs, { withFileTypes: true })) {
|
|
191
|
+
const childAbs = join(abs, name.name)
|
|
192
|
+
const childRel = rel ? `${rel}/${name.name}` : name.name
|
|
193
|
+
if (name.isDirectory()) visit(childAbs, childRel)
|
|
194
|
+
else out.push({ logicalPath: childRel, sizeBytes: readFileSync(childAbs).length })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
visit(dir)
|
|
198
|
+
return out.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stripFrontendPrefix(source) {
|
|
202
|
+
return source.replace(/^frontend\/src\//, "")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function defaultTargetForSource(manifest, source, kind) {
|
|
206
|
+
if (kind === "frontend") return `src/dypai-kits/${manifest.slug}/${stripFrontendPrefix(source)}`
|
|
207
|
+
if (kind === "backend") return `dypai/endpoints/${manifest.slug}/${source.split("/").pop()}`
|
|
208
|
+
if (kind === "database") return `dypai/kit-installations/${manifest.slug}/${manifest.version}/${source.split("/").pop()}`
|
|
209
|
+
return `.dypai/kits/${manifest.slug}/${source.split("/").pop()}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pluralize(word) {
|
|
213
|
+
if (!word) return word
|
|
214
|
+
if (word.endsWith("s")) return word
|
|
215
|
+
if (word.endsWith("y")) return `${word.slice(0, -1)}ies`
|
|
216
|
+
return `${word}s`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function namingContext(manifest, naming = {}) {
|
|
220
|
+
const adaptation = isObject(manifest.adaptation) ? manifest.adaptation : {}
|
|
221
|
+
const sourceEntity = adaptation.defaultEntity || normalizeList(adaptation.entityAliases)[0] || ""
|
|
222
|
+
const sourcePlural = adaptation.defaultEndpointPrefix || pluralize(sourceEntity)
|
|
223
|
+
const sourceTable = adaptation.defaultTableName || sourcePlural
|
|
224
|
+
const targetEntity = naming.entity || sourceEntity
|
|
225
|
+
const targetPlural = naming.endpointPrefix || pluralize(targetEntity)
|
|
226
|
+
const targetTable = naming.tableName || targetPlural
|
|
227
|
+
return { sourceEntity, sourcePlural, sourceTable, targetEntity, targetPlural, targetTable }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function replaceWholeWords(text, replacements) {
|
|
231
|
+
let out = text
|
|
232
|
+
for (const [from, to] of replacements) {
|
|
233
|
+
if (!from || !to || from === to) continue
|
|
234
|
+
out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
|
|
235
|
+
}
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function adaptText(content, manifest, naming, kind) {
|
|
240
|
+
const n = namingContext(manifest, naming)
|
|
241
|
+
if (!n.sourceEntity && !n.sourcePlural && !n.sourceTable) return content
|
|
242
|
+
if (kind === "frontend") return content
|
|
243
|
+
|
|
244
|
+
if (kind === "backend") {
|
|
245
|
+
const withEndpointName = content.replace(/^name:\s*([a-z0-9-]+)\s*$/m, (_line, endpointName) => {
|
|
246
|
+
return `name: ${adaptTarget(endpointName, manifest, naming)}`
|
|
247
|
+
})
|
|
248
|
+
return replaceWholeWords(withEndpointName, [
|
|
249
|
+
[n.sourceTable, n.targetTable],
|
|
250
|
+
[n.sourceEntity, n.targetEntity],
|
|
251
|
+
])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return replaceWholeWords(content, [
|
|
255
|
+
[n.sourceTable, n.targetTable],
|
|
256
|
+
[n.sourcePlural, n.targetTable],
|
|
257
|
+
[n.sourceEntity, n.targetEntity],
|
|
258
|
+
])
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function adaptTarget(target, manifest, naming) {
|
|
262
|
+
const n = namingContext(manifest, naming)
|
|
263
|
+
return replaceWholeWords(target, [
|
|
264
|
+
[n.sourcePlural, n.targetPlural],
|
|
265
|
+
[n.sourceEntity, n.targetEntity],
|
|
266
|
+
[n.sourceTable, n.targetTable],
|
|
267
|
+
])
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function installRecordPath(workspaceRoot) {
|
|
271
|
+
return join(workspaceRoot, ".dypai", "kits", "installed.json")
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readInstallRecord(workspaceRoot) {
|
|
275
|
+
const path = installRecordPath(workspaceRoot)
|
|
276
|
+
if (!existsSync(path)) return { schemaVersion: 1, kits: [] }
|
|
277
|
+
try {
|
|
278
|
+
const parsed = readJson(path)
|
|
279
|
+
if (isObject(parsed) && Array.isArray(parsed.kits)) return parsed
|
|
280
|
+
} catch {}
|
|
281
|
+
return { schemaVersion: 1, kits: [] }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeInstallRecord(workspaceRoot, record) {
|
|
285
|
+
const path = installRecordPath(workspaceRoot)
|
|
286
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
287
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ownedTargets(record, slug, version) {
|
|
291
|
+
const targets = new Set()
|
|
292
|
+
for (const item of record.kits || []) {
|
|
293
|
+
if (item.slug === slug && item.version === version) {
|
|
294
|
+
for (const file of item.files || []) {
|
|
295
|
+
if (file.target) targets.add(file.target)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return targets
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind, overwrite, record, copied, skipped }) {
|
|
303
|
+
const targetAbs = safeTarget(workspaceRoot, targetRel)
|
|
304
|
+
const targetExists = existsSync(targetAbs)
|
|
305
|
+
if (targetExists) {
|
|
306
|
+
if (overwrite === "fail") throw new Error(`Target already exists: ${targetRel}`)
|
|
307
|
+
if (overwrite !== "replace" || !ownedTargets(record, manifest.slug, manifest.version).has(targetRel)) {
|
|
308
|
+
skipped.push(targetRel)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
mkdirSync(dirname(targetAbs), { recursive: true })
|
|
314
|
+
const isText = /\.(tsx?|jsx?|ya?ml|json|md|sql|css|scss|html)$/i.test(sourceAbs)
|
|
315
|
+
if (isText) {
|
|
316
|
+
const content = adaptText(readFileSync(sourceAbs, "utf8"), manifest, naming, kind)
|
|
317
|
+
writeFileSync(targetAbs, content)
|
|
318
|
+
} else {
|
|
319
|
+
copyFileSync(sourceAbs, targetAbs)
|
|
320
|
+
}
|
|
321
|
+
copied.push(targetRel)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const searchCapabilityKitsTool = {
|
|
325
|
+
name: "search_capability_kits",
|
|
326
|
+
description: "Search local DYPAI capability kits before building complex UI like calendars, maps, Kanban, upload flows, CRUD tables, dashboards, or editors.",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {
|
|
330
|
+
query: { type: "string", description: "Feature need and app domain, e.g. booking calendar for hotel reservations." },
|
|
331
|
+
limit: { type: "integer", default: DEFAULT_LIMIT, minimum: 1, maximum: 10 },
|
|
332
|
+
filters: { type: "object", description: "Optional filters: category, maturity, app_type, screen_type, feature_tag, requires." },
|
|
333
|
+
kits_root: { type: "string", description: "Optional local path to dypai-capability-kits. Defaults to DYPAI_CAPABILITY_KITS_ROOT or sibling repo." },
|
|
334
|
+
},
|
|
335
|
+
required: ["query"],
|
|
336
|
+
},
|
|
337
|
+
async execute({ query, limit = DEFAULT_LIMIT, filters = {}, kits_root }) {
|
|
338
|
+
const root = resolveKitsRoot(kits_root)
|
|
339
|
+
const kits = listKits(root)
|
|
340
|
+
.filter(({ manifest }) => matchesFilter(manifest, filters))
|
|
341
|
+
.map(({ manifest }) => ({ manifest, score: scoreKit(query, manifest) }))
|
|
342
|
+
.filter((item) => item.score > 0 || !query)
|
|
343
|
+
.sort((a, b) => b.score - a.score)
|
|
344
|
+
.slice(0, Math.max(1, Math.min(Number(limit) || DEFAULT_LIMIT, 10)))
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
source: "local_repo",
|
|
349
|
+
kitsRoot: root,
|
|
350
|
+
count: kits.length,
|
|
351
|
+
kits: kits.map(({ manifest, score }) => ({
|
|
352
|
+
slug: manifest.slug,
|
|
353
|
+
version: manifest.version,
|
|
354
|
+
name: manifest.name,
|
|
355
|
+
category: manifest.category,
|
|
356
|
+
maturity: manifest.maturity,
|
|
357
|
+
description: manifest.description,
|
|
358
|
+
useWhen: manifest.useWhen,
|
|
359
|
+
provides: manifest.provides,
|
|
360
|
+
requires: manifest.requires,
|
|
361
|
+
dependencies: manifest.dependencies,
|
|
362
|
+
frontendManifest: manifest.frontendManifest,
|
|
363
|
+
backendManifest: manifest.backendManifest,
|
|
364
|
+
databaseManifest: manifest.databaseManifest,
|
|
365
|
+
score,
|
|
366
|
+
})),
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function inspectCapabilityKit({ slug, version = "1.0.0", kits_root }) {
|
|
372
|
+
const root = resolveKitsRoot(kits_root)
|
|
373
|
+
const { dir, manifest } = loadKit(root, slug, version)
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
operation: "inspect",
|
|
377
|
+
source: "local_repo",
|
|
378
|
+
kitsRoot: root,
|
|
379
|
+
kit: manifest,
|
|
380
|
+
assets: assetIndex(dir),
|
|
381
|
+
agentNotes: existsSync(join(dir, "agent.md")) ? readFileSync(join(dir, "agent.md"), "utf8") : undefined,
|
|
382
|
+
checklist: existsSync(join(dir, "verification", "checklist.md"))
|
|
383
|
+
? readFileSync(join(dir, "verification", "checklist.md"), "utf8")
|
|
384
|
+
: undefined,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function applyCapabilityKit({
|
|
389
|
+
slug,
|
|
390
|
+
version = "1.0.0",
|
|
391
|
+
targetFeature = "",
|
|
392
|
+
install = {},
|
|
393
|
+
naming = {},
|
|
394
|
+
target = {},
|
|
395
|
+
overwrite = "skip",
|
|
396
|
+
workspace_root,
|
|
397
|
+
kits_root,
|
|
398
|
+
}) {
|
|
399
|
+
const root = resolveKitsRoot(kits_root)
|
|
400
|
+
const workspaceRoot = resolveWorkspaceRoot(workspace_root)
|
|
401
|
+
const { dir, manifest } = loadKit(root, slug, version)
|
|
402
|
+
const record = readInstallRecord(workspaceRoot)
|
|
403
|
+
const previousEntries = (record.kits || []).filter((item) => item.slug === manifest.slug && item.version === manifest.version)
|
|
404
|
+
const copied = []
|
|
405
|
+
const skipped = []
|
|
406
|
+
|
|
407
|
+
const installFrontend = install.frontend !== false
|
|
408
|
+
const installBackend = install.backend !== false
|
|
409
|
+
const installDatabase = install.database !== false
|
|
410
|
+
|
|
411
|
+
if (installFrontend) {
|
|
412
|
+
for (const asset of manifest.frontendManifest || []) {
|
|
413
|
+
const sourceAbs = join(dir, asset.source)
|
|
414
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${asset.source}`)
|
|
415
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "frontend")
|
|
416
|
+
if (target.frontendDir) {
|
|
417
|
+
targetRel = join(target.frontendDir, stripFrontendPrefix(asset.source)).split(sep).join("/")
|
|
418
|
+
}
|
|
419
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "frontend", overwrite, record, copied, skipped })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (installBackend) {
|
|
424
|
+
for (const asset of manifest.backendManifest || []) {
|
|
425
|
+
const sourceAbs = join(dir, asset.source)
|
|
426
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${asset.source}`)
|
|
427
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "backend")
|
|
428
|
+
targetRel = join(dirname(targetRel), adaptTarget(basename(targetRel), manifest, naming)).split(sep).join("/")
|
|
429
|
+
if (target.endpointDir) {
|
|
430
|
+
targetRel = join(target.endpointDir, adaptTarget(asset.source.split("/").pop(), manifest, naming)).split(sep).join("/")
|
|
431
|
+
}
|
|
432
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "backend", overwrite, record, copied, skipped })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (installDatabase && isObject(manifest.databaseManifest)) {
|
|
437
|
+
for (const key of ["schema", "seed"]) {
|
|
438
|
+
const source = manifest.databaseManifest[key]
|
|
439
|
+
if (!source) continue
|
|
440
|
+
const sourceAbs = join(dir, source)
|
|
441
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${source}`)
|
|
442
|
+
let targetRel = defaultTargetForSource(manifest, source, "database")
|
|
443
|
+
if (target.databaseDir) {
|
|
444
|
+
targetRel = join(target.databaseDir, source.split("/").pop()).split(sep).join("/")
|
|
445
|
+
}
|
|
446
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "database", overwrite, record, copied, skipped })
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const imported = (manifest.frontendManifest || []).map((asset) => ({
|
|
451
|
+
name: asset.exportName,
|
|
452
|
+
from: target.frontendDir
|
|
453
|
+
? `@/${join(target.frontendDir, stripFrontendPrefix(asset.source)).replace(/^src\//, "").replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "")}`
|
|
454
|
+
: asset.importPath,
|
|
455
|
+
})).filter((item) => item.name && item.from)
|
|
456
|
+
|
|
457
|
+
const backendEndpoints = (manifest.backendManifest || []).map((asset) =>
|
|
458
|
+
adaptTarget(asset.endpointName || asset.source.split("/").pop().replace(/\.ya?ml$/, ""), manifest, naming),
|
|
459
|
+
)
|
|
460
|
+
const databaseTables = normalizeList(manifest.databaseManifest?.tables).map((table) =>
|
|
461
|
+
adaptTarget(table, manifest, naming),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const previousFiles = previousEntries.flatMap((item) => Array.isArray(item.files) ? item.files : [])
|
|
465
|
+
const fileMap = new Map()
|
|
466
|
+
for (const file of previousFiles) {
|
|
467
|
+
if (file?.target) fileMap.set(file.target, file)
|
|
468
|
+
}
|
|
469
|
+
for (const targetPath of copied) {
|
|
470
|
+
fileMap.set(targetPath, { target: targetPath })
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const entry = {
|
|
474
|
+
slug: manifest.slug,
|
|
475
|
+
version: manifest.version,
|
|
476
|
+
installedAt: new Date().toISOString(),
|
|
477
|
+
targetFeature,
|
|
478
|
+
files: [...fileMap.values()],
|
|
479
|
+
skippedFiles: skipped,
|
|
480
|
+
naming,
|
|
481
|
+
}
|
|
482
|
+
record.kits = (record.kits || []).filter((item) => !(item.slug === manifest.slug && item.version === manifest.version))
|
|
483
|
+
record.kits.push(entry)
|
|
484
|
+
writeInstallRecord(workspaceRoot, record)
|
|
485
|
+
|
|
486
|
+
const dependencies = manifest.dependencies || []
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
ok: true,
|
|
490
|
+
operation: "apply",
|
|
491
|
+
slug: manifest.slug,
|
|
492
|
+
version: manifest.version,
|
|
493
|
+
workspaceRoot,
|
|
494
|
+
installed: {
|
|
495
|
+
files: copied,
|
|
496
|
+
skipped,
|
|
497
|
+
recordFile: ".dypai/kits/installed.json",
|
|
498
|
+
},
|
|
499
|
+
imports: imported,
|
|
500
|
+
backend: {
|
|
501
|
+
endpoints: backendEndpoints,
|
|
502
|
+
requiresPublish: Boolean(manifest.install?.requiresBackendPublish || backendEndpoints.length),
|
|
503
|
+
},
|
|
504
|
+
database: {
|
|
505
|
+
tables: databaseTables,
|
|
506
|
+
requiresExecuteSql: Boolean(manifest.install?.requiresExecuteSql || databaseTables.length),
|
|
507
|
+
},
|
|
508
|
+
dependencies,
|
|
509
|
+
nextSteps: [
|
|
510
|
+
...(dependencies.length
|
|
511
|
+
? [`Add any missing package dependencies to the target workspace package.json, then install them locally before building: ${dependencies.join(", ")}.`]
|
|
512
|
+
: []),
|
|
513
|
+
"Wire installed frontend components into the selected app page or route.",
|
|
514
|
+
...(databaseTables.length ? ["Apply the installed schema SQL with execute_sql if the tables do not already exist."] : []),
|
|
515
|
+
...(backendEndpoints.length ? ["Run backend validation and endpoint tests for installed/renamed endpoints."] : []),
|
|
516
|
+
"Run frontend verification after wiring imports and data callbacks.",
|
|
517
|
+
],
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export const manageCapabilityKitTool = {
|
|
522
|
+
name: "manage_capability_kit",
|
|
523
|
+
description: "Inspect or install a local DYPAI capability kit. Use operation='inspect' after search when you need full manifest details, then operation='apply' to copy editable source into the workspace. Apply never executes SQL, publishes backend, deploys frontend, or installs npm packages.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
type: "object",
|
|
526
|
+
properties: {
|
|
527
|
+
operation: { type: "string", enum: ["inspect", "apply"], description: "inspect returns manifest/assets. apply copies kit files into the workspace." },
|
|
528
|
+
slug: { type: "string" },
|
|
529
|
+
version: { type: "string", default: "1.0.0" },
|
|
530
|
+
targetFeature: { type: "string", description: "Domain/feature being built, e.g. hotel reservations." },
|
|
531
|
+
install: {
|
|
532
|
+
type: "object",
|
|
533
|
+
properties: {
|
|
534
|
+
frontend: { type: "boolean", default: true },
|
|
535
|
+
backend: { type: "boolean", default: true },
|
|
536
|
+
database: { type: "boolean", default: true },
|
|
537
|
+
examples: { type: "boolean", default: false },
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
naming: {
|
|
541
|
+
type: "object",
|
|
542
|
+
properties: {
|
|
543
|
+
entity: { type: "string", description: "Singular domain entity, e.g. reservation." },
|
|
544
|
+
endpointPrefix: { type: "string", description: "Plural endpoint/table-friendly domain name, e.g. reservations." },
|
|
545
|
+
tableName: { type: "string", description: "Database table name, e.g. reservations." },
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
target: {
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: {
|
|
551
|
+
frontendDir: { type: "string" },
|
|
552
|
+
endpointDir: { type: "string" },
|
|
553
|
+
databaseDir: { type: "string" },
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
overwrite: { type: "string", enum: ["skip", "fail", "replace"], default: "skip" },
|
|
557
|
+
workspace_root: { type: "string", description: "Optional absolute path to the target app workspace." },
|
|
558
|
+
kits_root: { type: "string", description: "Optional local path to dypai-capability-kits." },
|
|
559
|
+
},
|
|
560
|
+
required: ["operation", "slug"],
|
|
561
|
+
},
|
|
562
|
+
async execute(args) {
|
|
563
|
+
if (args?.operation === "inspect") return inspectCapabilityKit(args)
|
|
564
|
+
if (args?.operation === "apply") return applyCapabilityKit(args)
|
|
565
|
+
throw new Error("manage_capability_kit requires operation 'inspect' or 'apply'.")
|
|
566
|
+
},
|
|
567
|
+
}
|
|
@@ -14,6 +14,8 @@ import YAML from "yaml"
|
|
|
14
14
|
import { proxyToolCall } from "../proxy.js"
|
|
15
15
|
import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
|
|
16
16
|
|
|
17
|
+
const ENDPOINT_NAME_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
|
18
|
+
|
|
17
19
|
// ─── Remote ────────────────────────────────────────────────────────────────
|
|
18
20
|
|
|
19
21
|
async function execSql(projectId, sql) {
|
|
@@ -198,6 +200,76 @@ async function findEndpointYamls(endpointsDir) {
|
|
|
198
200
|
return out
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
function endpointFileSlug(rel) {
|
|
204
|
+
const filename = rel.split("/").pop() || ""
|
|
205
|
+
return filename.replace(/\.ya?ml$/i, "")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validateEndpointNameContract(rel, doc) {
|
|
209
|
+
const expectedName = endpointFileSlug(rel)
|
|
210
|
+
if (!doc || typeof doc !== "object") {
|
|
211
|
+
return {
|
|
212
|
+
rule: "endpoint_yaml_root_invalid",
|
|
213
|
+
loc: "root",
|
|
214
|
+
error: "endpoint YAML must parse to an object",
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof doc.name !== "string") {
|
|
219
|
+
return {
|
|
220
|
+
rule: "endpoint_missing_name",
|
|
221
|
+
loc: "name",
|
|
222
|
+
error: `endpoint name must be a string and must match the file basename '${expectedName}'`,
|
|
223
|
+
fix_hint: `Set name: ${expectedName}. The endpoint name is the public API slug and must match the file basename.`,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const name = doc.name.trim()
|
|
228
|
+
if (!name) {
|
|
229
|
+
return {
|
|
230
|
+
rule: "endpoint_missing_name",
|
|
231
|
+
loc: "name",
|
|
232
|
+
error: `endpoint is missing name; use name: ${expectedName}`,
|
|
233
|
+
fix_hint: `Set name: ${expectedName}.`,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (name !== doc.name || !ENDPOINT_NAME_RE.test(name)) {
|
|
237
|
+
return {
|
|
238
|
+
rule: "endpoint_invalid_name_slug",
|
|
239
|
+
loc: "name",
|
|
240
|
+
error: `endpoint name '${doc.name}' is not a valid API slug; use lowercase letters, numbers, hyphens, or underscores only, with no spaces`,
|
|
241
|
+
fix_hint: "Do not put human titles in `name`; use `description` for labels.",
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (name !== expectedName) {
|
|
245
|
+
return {
|
|
246
|
+
rule: "endpoint_name_file_mismatch",
|
|
247
|
+
loc: "name",
|
|
248
|
+
error: `endpoint name '${name}' must match file basename '${expectedName}'`,
|
|
249
|
+
fix_hint: `Use name: ${expectedName}, or rename the file so both match.`,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (doc.id != null) {
|
|
253
|
+
if (typeof doc.id !== "string" || doc.id.trim() !== doc.id || !doc.id.trim()) {
|
|
254
|
+
return {
|
|
255
|
+
rule: "endpoint_invalid_id",
|
|
256
|
+
loc: "id",
|
|
257
|
+
error: "endpoint id must be a non-empty string without surrounding spaces when present",
|
|
258
|
+
fix_hint: "Prefer omitting `id` in endpoint YAML. If present, it must exactly match `name`.",
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (doc.id !== name) {
|
|
262
|
+
return {
|
|
263
|
+
rule: "endpoint_id_name_mismatch",
|
|
264
|
+
loc: "id",
|
|
265
|
+
error: `endpoint id '${doc.id}' must match name '${name}'`,
|
|
266
|
+
fix_hint: "Prefer omitting `id` in endpoint YAML. If present, keep id, name, and file basename identical.",
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
export async function readLocalState(rootDir) {
|
|
202
274
|
const endpointsDir = join(rootDir, "endpoints")
|
|
203
275
|
const yamls = await findEndpointYamls(endpointsDir)
|
|
@@ -209,8 +281,9 @@ export async function readLocalState(rootDir) {
|
|
|
209
281
|
try {
|
|
210
282
|
const raw = await readFile(join(endpointsDir, rel), "utf8")
|
|
211
283
|
const doc = YAML.parse(raw)
|
|
212
|
-
|
|
213
|
-
|
|
284
|
+
const nameError = validateEndpointNameContract(rel, doc)
|
|
285
|
+
if (nameError) {
|
|
286
|
+
errors.push({ file: rel, ...nameError })
|
|
214
287
|
continue
|
|
215
288
|
}
|
|
216
289
|
|
|
@@ -227,7 +300,7 @@ export async function readLocalState(rootDir) {
|
|
|
227
300
|
)
|
|
228
301
|
const fileMap = Object.fromEntries(refs.map((p, i) => [p, contents[i]]))
|
|
229
302
|
|
|
230
|
-
byName[doc.name] = { doc, fileMap }
|
|
303
|
+
byName[doc.name] = { doc, fileMap, file: rel, group }
|
|
231
304
|
} catch (e) {
|
|
232
305
|
errors.push({ file: rel, error: e.message })
|
|
233
306
|
}
|
package/src/tools/sync/test.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dypai_test — YAML-defined tests against endpoints.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* dypai_test — YAML-defined tests against endpoints. By default, endpoint
|
|
3
|
+
* names run through dypai_test_endpoint(mode:'local'), so tests execute the
|
|
4
|
+
* YAML currently on disk before dypai_push. UUID endpoint_id tests keep the
|
|
5
|
+
* legacy remote test_workflow path for backward compatibility.
|
|
5
6
|
*
|
|
6
7
|
* File layout (committable under dypai/tests/):
|
|
7
8
|
* endpoint: create-order # or endpoint_id: <uuid>
|
|
@@ -26,6 +27,7 @@ import { join, resolve as resolvePath } from "path"
|
|
|
26
27
|
import YAML from "yaml"
|
|
27
28
|
import { proxyToolCall } from "../proxy.js"
|
|
28
29
|
import { readLocalConfig } from "./planner.js"
|
|
30
|
+
import { dypaiTestEndpointTool } from "./test-endpoint.js"
|
|
29
31
|
|
|
30
32
|
// ─── Test file discovery ────────────────────────────────────────────────────
|
|
31
33
|
|
|
@@ -58,8 +60,8 @@ function firstCellOf(res) {
|
|
|
58
60
|
return k ? row[k] : undefined
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
/** Resolve an endpoint name to its UUID via system.endpoints
|
|
62
|
-
*
|
|
63
|
+
/** Resolve an endpoint name to its UUID via system.endpoints.
|
|
64
|
+
* Kept for legacy endpoint_id/remote test paths. */
|
|
63
65
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
64
66
|
async function resolveEndpointId(projectId, ref, cache) {
|
|
65
67
|
if (UUID_RE.test(ref)) return ref
|
|
@@ -167,22 +169,41 @@ async function runSingleTest(test, fileCtx) {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
try {
|
|
170
|
-
//
|
|
172
|
+
// Prefer testing the local endpoint YAML by name. This keeps the normal
|
|
173
|
+
// agent loop cheap: edit file -> dypai_test/dypai_test_endpoint -> push.
|
|
171
174
|
const endpointRef = test.endpoint || test.endpoint_id || fileCtx.endpoint
|
|
172
175
|
if (!endpointRef) {
|
|
173
176
|
return { ...result, status: "error", errors: ["No endpoint specified. Set `endpoint` at file root or test level."] }
|
|
174
177
|
}
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
178
|
+
const testMode = test.mode || fileCtx.mode || "local"
|
|
179
|
+
let runResponse
|
|
180
|
+
if (!UUID_RE.test(endpointRef)) {
|
|
181
|
+
runResponse = await dypaiTestEndpointTool.execute({
|
|
182
|
+
endpoint: endpointRef,
|
|
183
|
+
mode: testMode,
|
|
184
|
+
input: test.input || {},
|
|
185
|
+
as_user: test.as_user,
|
|
186
|
+
trace_mode: "minimal",
|
|
187
|
+
root_dir: fileCtx.rootDir,
|
|
188
|
+
project_id: fileCtx.projectId,
|
|
189
|
+
// dypai_test is often a suite with many cases; run dypai_validate once
|
|
190
|
+
// before the suite if you want lint gating. Individual endpoint tests
|
|
191
|
+
// keep their own pre-flight validation by default.
|
|
192
|
+
skip_validation: test.skip_validation ?? fileCtx.skipValidation ?? true,
|
|
193
|
+
})
|
|
194
|
+
} else {
|
|
195
|
+
// Backward-compatible path for old tests that hard-code endpoint_id.
|
|
196
|
+
// This cannot read local YAML because UUIDs refer to remote rows.
|
|
197
|
+
const execArgs = {
|
|
198
|
+
project_id: fileCtx.projectId,
|
|
199
|
+
endpoint_id: endpointRef,
|
|
200
|
+
data: test.input || {},
|
|
201
|
+
trace_mode: "minimal",
|
|
202
|
+
draft_mode: testMode === "live" ? false : true,
|
|
203
|
+
}
|
|
204
|
+
if (test.as_user) execArgs.impersonated_user_id = test.as_user
|
|
205
|
+
runResponse = await proxyToolCall("test_workflow", execArgs)
|
|
182
206
|
}
|
|
183
|
-
if (test.as_user) execArgs.impersonated_user_id = test.as_user
|
|
184
|
-
|
|
185
|
-
const runResponse = await proxyToolCall("test_workflow", execArgs)
|
|
186
207
|
|
|
187
208
|
// Normalize what counts as the workflow "result body" (vary by engine version)
|
|
188
209
|
const body = runResponse?.result ?? runResponse?.data ?? runResponse?.output ?? runResponse
|
|
@@ -194,12 +215,16 @@ async function runSingleTest(test, fileCtx) {
|
|
|
194
215
|
// expect.success — did the workflow complete?
|
|
195
216
|
if ("success" in expect) {
|
|
196
217
|
const status = trace?.status ?? trace?.workflow?.status
|
|
197
|
-
const actualSuccess =
|
|
218
|
+
const actualSuccess = runResponse?.success === false
|
|
219
|
+
? false
|
|
220
|
+
: status
|
|
198
221
|
? status === "completed"
|
|
199
222
|
: !runResponse?.error // fallback: presence of error field
|
|
200
223
|
if (actualSuccess !== expect.success) {
|
|
201
224
|
result.errors.push(`expected success=${expect.success}, got ${actualSuccess}`)
|
|
202
225
|
}
|
|
226
|
+
} else if (runResponse?.success === false) {
|
|
227
|
+
result.errors.push(`execution error: ${runResponse.error || "endpoint test failed"}`)
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
// expect.response — match body
|
|
@@ -260,19 +285,21 @@ async function runSingleTest(test, fileCtx) {
|
|
|
260
285
|
export const dypaiTestTool = {
|
|
261
286
|
name: "dypai_test",
|
|
262
287
|
description:
|
|
263
|
-
"Run YAML-defined tests from dypai/tests/*.test.yaml.
|
|
264
|
-
"
|
|
288
|
+
"Run YAML-defined tests from dypai/tests/*.test.yaml. By default, endpoint tests reference endpoint names and execute the LOCAL YAML from dypai/endpoints/** without requiring dypai_push. " +
|
|
289
|
+
"Each test does: setup_sql → dypai_test_endpoint/test_workflow (with impersonation) → response assertions → db_queries assertions → teardown_sql. " +
|
|
265
290
|
"Pass `only` to run a subset (substring match on test name).",
|
|
266
291
|
inputSchema: {
|
|
267
292
|
type: "object",
|
|
268
293
|
properties: {
|
|
269
294
|
project_id: { type: "string", description: "Project UUID. Auto-resolved from dypai.config.yaml." },
|
|
270
295
|
root_dir: { type: "string", default: "./dypai" },
|
|
296
|
+
mode: { type: "string", enum: ["local", "draft", "live"], default: "local", description: "Endpoint source for tests that reference endpoint names. local reads YAML on disk; draft/live test engine versions." },
|
|
297
|
+
skip_validation: { type: "boolean", default: true, description: "Pass through to local endpoint tests. Default true for suites; run dypai_validate separately for lint gating." },
|
|
271
298
|
only: { type: "string", description: "Only run tests whose name includes this substring." },
|
|
272
299
|
file: { type: "string", description: "Relative path to a single test file under dypai/tests/." },
|
|
273
300
|
},
|
|
274
301
|
},
|
|
275
|
-
async execute({ project_id, root_dir = "./dypai", only, file } = {}) {
|
|
302
|
+
async execute({ project_id, root_dir = "./dypai", mode = "local", skip_validation = true, only, file } = {}) {
|
|
276
303
|
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
277
304
|
const config = await readLocalConfig(rootDir)
|
|
278
305
|
const projectId = project_id || config?.project_id
|
|
@@ -316,6 +343,8 @@ export const dypaiTestTool = {
|
|
|
316
343
|
endpoint: doc.endpoint || doc.endpoint_id,
|
|
317
344
|
rootDir,
|
|
318
345
|
endpointCache: new Map(),
|
|
346
|
+
mode: doc.mode || mode,
|
|
347
|
+
skipValidation: doc.skip_validation ?? skip_validation,
|
|
319
348
|
}
|
|
320
349
|
for (const t of tests) {
|
|
321
350
|
if (only && !(t.name || "").toLowerCase().includes(only.toLowerCase())) continue
|
|
@@ -235,24 +235,26 @@ function isRawCodePlaceholderSource(source, loc) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* Extract the first
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* "input.limit | default(100)" → "input.limit"
|
|
242
|
-
* "input.page ?? 1" → "input.page"
|
|
243
|
-
* "nodes.foo.bar || 'x'" → "nodes.foo.bar"
|
|
244
|
-
* "current_user_id | trim" → "current_user_id"
|
|
245
|
-
* Returns the trimmed/cleaned head (NOT just the leaf identifier — keeps
|
|
246
|
-
* dotted paths intact so callers that split on '.' still work).
|
|
238
|
+
* Extract the first runtime path from an already-validated placeholder.
|
|
239
|
+
* Returns the trimmed/cleaned head (NOT just the leaf identifier), keeping
|
|
240
|
+
* dotted paths intact so callers that split on '.' still work.
|
|
247
241
|
*/
|
|
248
242
|
function stripExprTail(expr) {
|
|
249
|
-
// Cut at the first character that can't be part of a path/identifier
|
|
250
|
-
//
|
|
251
|
-
//
|
|
243
|
+
// Cut at the first character that can't be part of a path/identifier.
|
|
244
|
+
// Bracket access (e.g. items[0]) is preserved; callers split on '[' if
|
|
245
|
+
// they need to.
|
|
252
246
|
const m = /^[\s]*([A-Za-z_$][A-Za-z_$0-9.\[\]]*)/.exec(expr)
|
|
253
247
|
return m ? m[1] : expr.trim()
|
|
254
248
|
}
|
|
255
249
|
|
|
250
|
+
const RUNTIME_PLACEHOLDER_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_-]*|\[[0-9]+\])*$/
|
|
251
|
+
const RUNTIME_PLACEHOLDER_OPERATOR_RE = /(\|\||&&|\?\?|[?:+*/=<>!()]|\s+\b(?:or|and)\b\s+)/i
|
|
252
|
+
|
|
253
|
+
function unsupportedRuntimePlaceholder(expr) {
|
|
254
|
+
const clean = expr.trim()
|
|
255
|
+
return expr !== clean || RUNTIME_PLACEHOLDER_OPERATOR_RE.test(clean) || !RUNTIME_PLACEHOLDER_PATH_RE.test(clean)
|
|
256
|
+
}
|
|
257
|
+
|
|
256
258
|
/** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
|
|
257
259
|
function levenshteinSmall(a, b) {
|
|
258
260
|
if (a === b) return 0
|
|
@@ -407,11 +409,23 @@ function validateEndpoint(entry, ctx) {
|
|
|
407
409
|
for (const { source, loc, value } of sources) {
|
|
408
410
|
// --- Placeholder checks ---
|
|
409
411
|
for (const expr of extractPlaceholders(value)) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
412
|
+
if (unsupportedRuntimePlaceholder(expr)) {
|
|
413
|
+
diagnostics.push({
|
|
414
|
+
severity: "error",
|
|
415
|
+
rule: "unsupported_placeholder_expression",
|
|
416
|
+
endpoint: name, file, loc,
|
|
417
|
+
message: `\${${expr}} is not a simple runtime path the engine can resolve.`,
|
|
418
|
+
fix_hint:
|
|
419
|
+
`DYPAI placeholders are not JavaScript expressions. Use a single path like ` +
|
|
420
|
+
`\${input.file_path}, \${nodes.upload.storage_path}, or \${current_user_id}; ` +
|
|
421
|
+
`put fallback logic in the caller or split it into workflow nodes.`,
|
|
422
|
+
})
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Normalize to the runtime path after rejecting expression syntax above.
|
|
427
|
+
// This keeps the downstream schema checks focused on the referenced
|
|
428
|
+
// input/node path instead of trying to interpret template logic.
|
|
415
429
|
const e = stripExprTail(expr)
|
|
416
430
|
|
|
417
431
|
// ${input.X} or ${input.X.Y}
|
|
@@ -1249,7 +1263,7 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1249
1263
|
schemaTables,
|
|
1250
1264
|
catalog: nodeCatalog.schemas,
|
|
1251
1265
|
knownTypes: nodeCatalog.knownTypes,
|
|
1252
|
-
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
|
|
1266
|
+
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.file || `${e.doc.name}.yaml`}`])),
|
|
1253
1267
|
// Collected during per-endpoint pass; resolved against the remote AFTER
|
|
1254
1268
|
// all endpoints are checked (one batch query instead of N).
|
|
1255
1269
|
suspectedMissingTables: [],
|
|
@@ -1329,9 +1343,11 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1329
1343
|
for (const err of local.errors || []) {
|
|
1330
1344
|
diagnostics.push({
|
|
1331
1345
|
severity: "error",
|
|
1332
|
-
rule: "file_read_error",
|
|
1346
|
+
rule: err.rule || "file_read_error",
|
|
1333
1347
|
file: err.file,
|
|
1348
|
+
loc: err.loc,
|
|
1334
1349
|
message: err.error,
|
|
1350
|
+
fix_hint: err.fix_hint,
|
|
1335
1351
|
})
|
|
1336
1352
|
}
|
|
1337
1353
|
|