@dypai-ai/mcp 1.0.10 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +9 -32
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +91 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +411 -0
- package/src/tools/sync/push.js +397 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codegen — generates TypeScript types + a typed api wrapper from the
|
|
3
|
+
* local dypai/ folder. Output lives inside src/ (by default src/dypai/)
|
|
4
|
+
* so it plays nicely with the user's tsconfig / bundler / `@/` alias.
|
|
5
|
+
*
|
|
6
|
+
* Reads:
|
|
7
|
+
* dypai/schema.sql → src/dypai/database.ts
|
|
8
|
+
* dypai/endpoints/**.yaml → src/dypai/api.ts
|
|
9
|
+
*
|
|
10
|
+
* Re-run is idempotent and cheap (<1s for typical projects). Auto-fired by
|
|
11
|
+
* pull / push / DDL so frontend types stay in sync without manual steps.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile, readdir, mkdir, writeFile, access } from "fs/promises"
|
|
15
|
+
import { join, resolve as resolvePath, dirname } from "path"
|
|
16
|
+
import YAML from "yaml"
|
|
17
|
+
import { readLocalConfig } from "./sync/planner.js"
|
|
18
|
+
|
|
19
|
+
// ─── Naming helpers ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export function toCamel(name) {
|
|
22
|
+
return String(name).replace(/[-_](\w)/g, (_, c) => c.toUpperCase())
|
|
23
|
+
.replace(/^\w/, c => c.toLowerCase())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toPascal(name) {
|
|
27
|
+
const c = toCamel(name)
|
|
28
|
+
return c.charAt(0).toUpperCase() + c.slice(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── SQL → TS type mapping ──────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function sqlTypeToTs(sqlType) {
|
|
34
|
+
const t = String(sqlType).toLowerCase().trim()
|
|
35
|
+
if (/^(uuid|text|varchar|char|character|citext|name)/.test(t)) return "string"
|
|
36
|
+
if (/^(timestamp|date|time|interval)/.test(t)) return "string"
|
|
37
|
+
if (/^(int|smallint|bigint|integer|numeric|decimal|real|double|serial|bigserial|float|money)/.test(t)) return "number"
|
|
38
|
+
if (/^(boolean|bool)/.test(t)) return "boolean"
|
|
39
|
+
if (/^(json|jsonb)/.test(t)) return "unknown"
|
|
40
|
+
if (/^bytea/.test(t)) return "string"
|
|
41
|
+
if (/\[\]$/.test(t)) {
|
|
42
|
+
const inner = sqlTypeToTs(t.replace(/\[\]$/, ""))
|
|
43
|
+
return `${inner}[]`
|
|
44
|
+
}
|
|
45
|
+
// Enums and user-defined types → string at MVP (could be refined later)
|
|
46
|
+
return "string"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Generate database.ts from schema.sql ───────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function parseCreateTable(ddl) {
|
|
52
|
+
// Extracts { table, columns: [{name, type, nullable, hasDefault}] }
|
|
53
|
+
const m = ddl.match(/CREATE\s+TABLE\s+public\.(\w+)\s*\(([\s\S]*?)\);/i)
|
|
54
|
+
if (!m) return null
|
|
55
|
+
const table = m[1]
|
|
56
|
+
const body = m[2]
|
|
57
|
+
const lines = body.split(/,\s*\n/).map(l => l.trim()).filter(Boolean)
|
|
58
|
+
const columns = []
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
// Skip constraint lines (PRIMARY KEY (...), FOREIGN KEY...)
|
|
61
|
+
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|FOREIGN KEY|CHECK)\b/i.test(line)) continue
|
|
62
|
+
const colMatch = line.match(/^(\w+)\s+([^,]+?)(?:\s+(NOT NULL|NULL))?(?:\s+(PRIMARY KEY))?(?:\s+(DEFAULT\s+.+))?$/i)
|
|
63
|
+
if (!colMatch) {
|
|
64
|
+
const simple = line.match(/^(\w+)\s+(\w+(?:\s*\(\d+(?:\s*,\s*\d+)?\))?(?:\[\])?)\b/)
|
|
65
|
+
if (simple) {
|
|
66
|
+
columns.push({
|
|
67
|
+
name: simple[1],
|
|
68
|
+
type: simple[2],
|
|
69
|
+
nullable: !/NOT NULL/i.test(line),
|
|
70
|
+
hasDefault: /DEFAULT/i.test(line),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
columns.push({
|
|
76
|
+
name: colMatch[1],
|
|
77
|
+
type: colMatch[2].trim(),
|
|
78
|
+
nullable: !/NOT NULL/i.test(line),
|
|
79
|
+
hasDefault: /DEFAULT/i.test(line),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
return { table, columns }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function generateDatabaseTypes(schemaSql) {
|
|
86
|
+
// Split on the CREATE TABLE boundaries (keeping the "CREATE" prefix)
|
|
87
|
+
const tables = []
|
|
88
|
+
const re = /CREATE\s+TABLE\s+public\.\w+\s*\([\s\S]*?\);/gi
|
|
89
|
+
let m
|
|
90
|
+
while ((m = re.exec(schemaSql)) !== null) {
|
|
91
|
+
const parsed = parseCreateTable(m[0])
|
|
92
|
+
if (parsed) tables.push(parsed)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines = [
|
|
96
|
+
"// Auto-generated by dypai. Do NOT edit by hand — regenerated on every pull / push / DDL.",
|
|
97
|
+
"// Derived from dypai/schema.sql.",
|
|
98
|
+
"",
|
|
99
|
+
"export interface Database {",
|
|
100
|
+
" public: {",
|
|
101
|
+
" Tables: {",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
for (const t of tables) {
|
|
105
|
+
lines.push(` ${t.table}: {`)
|
|
106
|
+
// Row = exactly what comes out of SELECT
|
|
107
|
+
lines.push(` Row: {`)
|
|
108
|
+
for (const c of t.columns) {
|
|
109
|
+
const ts = sqlTypeToTs(c.type)
|
|
110
|
+
const opt = c.nullable ? " | null" : ""
|
|
111
|
+
lines.push(` ${c.name}: ${ts}${opt}`)
|
|
112
|
+
}
|
|
113
|
+
lines.push(` }`)
|
|
114
|
+
// Insert = what you can pass to INSERT (optional for nullable or with default)
|
|
115
|
+
lines.push(` Insert: {`)
|
|
116
|
+
for (const c of t.columns) {
|
|
117
|
+
const ts = sqlTypeToTs(c.type)
|
|
118
|
+
const optional = c.nullable || c.hasDefault ? "?" : ""
|
|
119
|
+
const or = c.nullable ? " | null" : ""
|
|
120
|
+
lines.push(` ${c.name}${optional}: ${ts}${or}`)
|
|
121
|
+
}
|
|
122
|
+
lines.push(` }`)
|
|
123
|
+
// Update = partial of Insert
|
|
124
|
+
lines.push(` Update: Partial<Database["public"]["Tables"]["${t.table}"]["Insert"]>`)
|
|
125
|
+
lines.push(` }`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
lines.push(" }")
|
|
129
|
+
lines.push(" }")
|
|
130
|
+
lines.push("}")
|
|
131
|
+
lines.push("")
|
|
132
|
+
return lines.join("\n")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── JSON Schema → TS inline ────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function jsonSchemaToTs(schema, indent = "") {
|
|
138
|
+
if (!schema || typeof schema !== "object") return "unknown"
|
|
139
|
+
if (Array.isArray(schema.enum) && schema.enum.length) {
|
|
140
|
+
return schema.enum.map(v => typeof v === "string" ? JSON.stringify(v) : String(v)).join(" | ")
|
|
141
|
+
}
|
|
142
|
+
const type = schema.type
|
|
143
|
+
if (type === "string") return "string"
|
|
144
|
+
if (type === "number" || type === "integer") return "number"
|
|
145
|
+
if (type === "boolean") return "boolean"
|
|
146
|
+
if (type === "null") return "null"
|
|
147
|
+
if (type === "array") {
|
|
148
|
+
const inner = jsonSchemaToTs(schema.items, indent)
|
|
149
|
+
return `Array<${inner}>`
|
|
150
|
+
}
|
|
151
|
+
if (type === "object" || schema.properties) {
|
|
152
|
+
const props = schema.properties || {}
|
|
153
|
+
const required = new Set(schema.required || [])
|
|
154
|
+
const keys = Object.keys(props)
|
|
155
|
+
if (!keys.length) return "Record<string, unknown>"
|
|
156
|
+
const next = indent + " "
|
|
157
|
+
const body = keys.map(k => {
|
|
158
|
+
const opt = required.has(k) ? "" : "?"
|
|
159
|
+
return `${next}${JSON.stringify(k)}${opt}: ${jsonSchemaToTs(props[k], next)}`
|
|
160
|
+
}).join("\n")
|
|
161
|
+
return `{\n${body}\n${indent}}`
|
|
162
|
+
}
|
|
163
|
+
// Union of types (e.g. ["object","string"])
|
|
164
|
+
if (Array.isArray(type)) {
|
|
165
|
+
return type.map(t => jsonSchemaToTs({ ...schema, type: t }, indent)).join(" | ")
|
|
166
|
+
}
|
|
167
|
+
return "unknown"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Generate api.ts from endpoint YAMLs ────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async function collectEndpoints(rootDir) {
|
|
173
|
+
const endpointsDir = join(rootDir, "endpoints")
|
|
174
|
+
const results = []
|
|
175
|
+
async function walk(dir) {
|
|
176
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
177
|
+
for (const e of entries) {
|
|
178
|
+
const full = join(dir, e.name)
|
|
179
|
+
if (e.isDirectory()) await walk(full)
|
|
180
|
+
else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
|
|
181
|
+
try {
|
|
182
|
+
const raw = await readFile(full, "utf8")
|
|
183
|
+
const doc = YAML.parse(raw) || {}
|
|
184
|
+
if (doc.name) results.push(doc)
|
|
185
|
+
} catch { /* skip malformed */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await walk(endpointsDir)
|
|
190
|
+
return results
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isBodyMethod(m) {
|
|
194
|
+
return ["POST", "PUT", "PATCH"].includes(String(m || "GET").toUpperCase())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function generateApiTypes(endpoints, sdkImport = "../lib/dypai") {
|
|
198
|
+
const methods = []
|
|
199
|
+
const usedNames = new Set()
|
|
200
|
+
|
|
201
|
+
for (const ep of endpoints) {
|
|
202
|
+
let methodName = toCamel(ep.name)
|
|
203
|
+
// Ensure JS-safe identifier
|
|
204
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(methodName)) {
|
|
205
|
+
methodName = methodName.replace(/[^A-Za-z0-9]/g, "_")
|
|
206
|
+
}
|
|
207
|
+
let unique = methodName
|
|
208
|
+
let i = 2
|
|
209
|
+
while (usedNames.has(unique)) {
|
|
210
|
+
unique = `${methodName}${i++}`
|
|
211
|
+
}
|
|
212
|
+
usedNames.add(unique)
|
|
213
|
+
|
|
214
|
+
const http = String(ep.method || "GET").toUpperCase()
|
|
215
|
+
const hasBody = isBodyMethod(http)
|
|
216
|
+
const inputTs = ep.input ? jsonSchemaToTs(ep.input, " ") : "void"
|
|
217
|
+
const outputTs = ep.output ? jsonSchemaToTs(ep.output, " ") : "unknown"
|
|
218
|
+
|
|
219
|
+
const inputParam = hasBody
|
|
220
|
+
? (inputTs === "void" ? "body?: Record<string, unknown>" : `body: ${inputTs}`)
|
|
221
|
+
: (inputTs === "void" ? "params?: Record<string, unknown>" : `params?: ${inputTs}`)
|
|
222
|
+
|
|
223
|
+
const callArg = hasBody ? "body" : "params"
|
|
224
|
+
const httpLower = http.toLowerCase()
|
|
225
|
+
|
|
226
|
+
// Short JSDoc header
|
|
227
|
+
const desc = (ep.description || "").replace(/\*\//g, "* /")
|
|
228
|
+
|
|
229
|
+
methods.push([
|
|
230
|
+
` /**`,
|
|
231
|
+
` * ${http} /${ep.name}`,
|
|
232
|
+
desc ? ` * ${desc}` : null,
|
|
233
|
+
` */`,
|
|
234
|
+
` ${unique}: (${inputParam}) =>`,
|
|
235
|
+
` dypai.api.${httpLower}<${outputTs}>(${JSON.stringify(ep.name)}, ${callArg}),`,
|
|
236
|
+
``,
|
|
237
|
+
].filter(Boolean).join("\n"))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return [
|
|
241
|
+
"// Auto-generated by dypai. Do NOT edit by hand — regenerated on every pull / push.",
|
|
242
|
+
"// Derived from dypai/endpoints/*.yaml.",
|
|
243
|
+
"",
|
|
244
|
+
`import { dypai } from ${JSON.stringify(sdkImport)}`,
|
|
245
|
+
"",
|
|
246
|
+
"export const api = {",
|
|
247
|
+
...methods,
|
|
248
|
+
"} as const",
|
|
249
|
+
"",
|
|
250
|
+
].join("\n")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Orchestrator ───────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
async function ensureDir(p) {
|
|
256
|
+
await mkdir(p, { recursive: true })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function fileExists(p) {
|
|
260
|
+
try { await access(p); return true } catch { return false }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Main entry: regenerate types from the dypai/ folder into the configured
|
|
265
|
+
* output directory (defaults to src/dypai/ inside the same workspace).
|
|
266
|
+
*
|
|
267
|
+
* Non-destructive when the workspace structure can't be resolved (e.g. no
|
|
268
|
+
* src/ folder) — skips silently so the action that triggered it doesn't fail.
|
|
269
|
+
*/
|
|
270
|
+
export async function regenerateTypes(rootDir, opts = {}) {
|
|
271
|
+
const config = await readLocalConfig(rootDir)
|
|
272
|
+
const codegenCfg = config?.codegen || {}
|
|
273
|
+
// Resolution: output_dir is relative to the WORKSPACE (parent of dypai/),
|
|
274
|
+
// not to dypai/ itself.
|
|
275
|
+
const workspace = dirname(rootDir)
|
|
276
|
+
const outputDir = resolvePath(workspace, codegenCfg.output_dir || "src/dypai")
|
|
277
|
+
const sdkImport = codegenCfg.sdk_import || "../lib/dypai"
|
|
278
|
+
|
|
279
|
+
const files_written = []
|
|
280
|
+
const skipped = []
|
|
281
|
+
|
|
282
|
+
// Ensure src/ exists — if not, skip with a note (probably not a typical web project)
|
|
283
|
+
const srcDir = resolvePath(workspace, "src")
|
|
284
|
+
if (!(await fileExists(srcDir)) && !codegenCfg.output_dir) {
|
|
285
|
+
return {
|
|
286
|
+
skipped: true,
|
|
287
|
+
reason: "no src/ folder found — skipping codegen. Set codegen.output_dir in dypai.config.yaml to override.",
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await ensureDir(outputDir)
|
|
292
|
+
|
|
293
|
+
// database.ts (only if schema.sql exists)
|
|
294
|
+
const schemaPath = join(rootDir, "schema.sql")
|
|
295
|
+
if (await fileExists(schemaPath)) {
|
|
296
|
+
const schema = await readFile(schemaPath, "utf8")
|
|
297
|
+
const dbTs = generateDatabaseTypes(schema)
|
|
298
|
+
const dbPath = join(outputDir, "database.ts")
|
|
299
|
+
await writeFile(dbPath, dbTs, "utf8")
|
|
300
|
+
files_written.push(dbPath)
|
|
301
|
+
} else {
|
|
302
|
+
skipped.push("database.ts (no schema.sql)")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// api.ts (only if endpoints/ has at least one yaml)
|
|
306
|
+
const endpoints = await collectEndpoints(rootDir)
|
|
307
|
+
if (endpoints.length > 0) {
|
|
308
|
+
const apiTs = generateApiTypes(endpoints, sdkImport)
|
|
309
|
+
const apiPath = join(outputDir, "api.ts")
|
|
310
|
+
await writeFile(apiPath, apiTs, "utf8")
|
|
311
|
+
files_written.push(apiPath)
|
|
312
|
+
} else {
|
|
313
|
+
skipped.push("api.ts (no endpoint YAMLs)")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// index.ts
|
|
317
|
+
if (files_written.length > 0) {
|
|
318
|
+
const exports = []
|
|
319
|
+
if (files_written.some(f => f.endsWith("database.ts"))) exports.push('export type { Database } from "./database"')
|
|
320
|
+
if (files_written.some(f => f.endsWith("api.ts"))) exports.push('export { api } from "./api"')
|
|
321
|
+
const indexPath = join(outputDir, "index.ts")
|
|
322
|
+
await writeFile(indexPath, exports.join("\n") + "\n", "utf8")
|
|
323
|
+
files_written.push(indexPath)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
regenerated: true,
|
|
328
|
+
output_dir: outputDir,
|
|
329
|
+
files_written,
|
|
330
|
+
endpoints_count: endpoints.length,
|
|
331
|
+
skipped: skipped.length ? skipped : undefined,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── Standalone tool ────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
export const dypaiCodegenTool = {
|
|
338
|
+
name: "dypai_codegen",
|
|
339
|
+
description:
|
|
340
|
+
"Regenerate TypeScript types from dypai/schema.sql + dypai/endpoints/*.yaml into src/dypai/ (default). " +
|
|
341
|
+
"Produces: database.ts (Database interface for tables), api.ts (typed wrapper around dypai.api.*), index.ts (re-exports). " +
|
|
342
|
+
"Auto-runs after dypai_pull, dypai_push, and DDL via execute_sql — this tool is for manual/CI regeneration. " +
|
|
343
|
+
"Configure output location via dypai.config.yaml codegen.output_dir and codegen.sdk_import.",
|
|
344
|
+
inputSchema: {
|
|
345
|
+
type: "object",
|
|
346
|
+
properties: {
|
|
347
|
+
root_dir: { type: "string", default: "./dypai", description: "Path to the dypai/ folder." },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
async execute({ root_dir = "./dypai" } = {}) {
|
|
351
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
352
|
+
const result = await regenerateTypes(rootDir)
|
|
353
|
+
if (result.skipped) {
|
|
354
|
+
return { success: true, ...result, hint: result.reason }
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
success: true,
|
|
358
|
+
...result,
|
|
359
|
+
hint: `Import from '@/dypai' (or relative): \`import { api } from '@/dypai'\`. Types regenerate automatically on pull/push/DDL.`,
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
}
|
package/src/tools/deploy.js
CHANGED
|
@@ -129,34 +129,12 @@ function detectFramework(dir) {
|
|
|
129
129
|
} catch { return null }
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Supports: React, Vite, Next.js, Astro, SvelteKit, Nuxt, Vue, CRA, and more.
|
|
140
|
-
DYPAI auto-detects the framework and routes to the optimal backend.
|
|
141
|
-
|
|
142
|
-
After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
|
|
143
|
-
|
|
144
|
-
inputSchema: {
|
|
145
|
-
type: "object",
|
|
146
|
-
properties: {
|
|
147
|
-
sourceDirectory: {
|
|
148
|
-
type: "string",
|
|
149
|
-
description: "Absolute path to the project source directory (e.g. /Users/me/my-app). Must contain a package.json.",
|
|
150
|
-
},
|
|
151
|
-
project_id: {
|
|
152
|
-
type: "string",
|
|
153
|
-
description: "Project UUID. Required.",
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
required: ["sourceDirectory", "project_id"],
|
|
157
|
-
},
|
|
158
|
-
|
|
159
|
-
async execute({ sourceDirectory, project_id }) {
|
|
132
|
+
/**
|
|
133
|
+
* Pure deploy function — used by the consolidated `manage_frontend` tool.
|
|
134
|
+
* Reads the source directory, uploads to the API, returns a structured
|
|
135
|
+
* response the caller can hand back to the agent.
|
|
136
|
+
*/
|
|
137
|
+
export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
160
138
|
if (!existsSync(sourceDirectory)) {
|
|
161
139
|
return { error: `Directory not found: ${sourceDirectory}` }
|
|
162
140
|
}
|
|
@@ -201,8 +179,8 @@ After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
|
|
|
201
179
|
build_status: "queued",
|
|
202
180
|
next_step: {
|
|
203
181
|
action: "poll_build_status",
|
|
204
|
-
tool: "
|
|
205
|
-
arg: { project_id },
|
|
182
|
+
tool: "manage_frontend",
|
|
183
|
+
arg: { operation: "build_status", project_id },
|
|
206
184
|
interval_seconds: 5,
|
|
207
185
|
expected_total_seconds: 60,
|
|
208
186
|
terminal_statuses: ["success", "failure"],
|
|
@@ -210,11 +188,10 @@ After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
|
|
|
210
188
|
message:
|
|
211
189
|
`Deploy accepted — ${files.length} files (${Math.round(total / 1024)} KB) queued. ` +
|
|
212
190
|
`The build is running in the cloud (${label}, typically 20–60s). ` +
|
|
213
|
-
`Call
|
|
191
|
+
`Call manage_frontend({operation:"build_status", project_id:"${project_id}"}) every ~5s until status is "success" or "failure" ` +
|
|
214
192
|
`before reporting completion to the user. The site is NOT live yet.${skippedMsg}`,
|
|
215
193
|
}
|
|
216
194
|
} catch (e) {
|
|
217
195
|
return { error: `Deploy failed: ${e.message}` }
|
|
218
196
|
}
|
|
219
|
-
},
|
|
220
197
|
}
|
package/src/tools/domains.js
CHANGED
|
@@ -1,73 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* manage_domain — single entry point for custom-domain operations.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates what used to be add_domain / list_domains / remove_domain into
|
|
5
|
+
* one `operation`-dispatched tool (same pattern as manage_users / manage_roles).
|
|
6
|
+
* `verify` is also included so the agent can trigger an on-demand DNS/SSL
|
|
7
|
+
* check after the user configures their CNAME.
|
|
3
8
|
*/
|
|
4
9
|
|
|
5
10
|
import { api } from "../api.js"
|
|
6
11
|
|
|
7
|
-
export const
|
|
8
|
-
name: "
|
|
9
|
-
description:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
export const manageDomainTool = {
|
|
13
|
+
name: "manage_domain",
|
|
14
|
+
description:
|
|
15
|
+
"Manage custom domains for the project's frontend. " +
|
|
16
|
+
"Operations: list (all domains + DNS/SSL status), add (returns the CNAME the user must configure at their registrar — SSL is automatic), " +
|
|
17
|
+
"remove (disconnect a domain), verify (force an on-demand DNS/SSL re-check for a domain). " +
|
|
18
|
+
"The platform polls DNS automatically in the background, so `verify` is only needed when the user just updated their DNS and wants immediate feedback.",
|
|
13
19
|
|
|
14
20
|
inputSchema: {
|
|
15
21
|
type: "object",
|
|
16
22
|
properties: {
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
operation: {
|
|
24
|
+
type: "string",
|
|
25
|
+
enum: ["list", "add", "remove", "verify"],
|
|
26
|
+
description: "Which action to run.",
|
|
27
|
+
},
|
|
28
|
+
project_id: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
|
|
31
|
+
},
|
|
32
|
+
domain: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "Domain name (required for add, remove, verify). E.g. 'www.example.com'.",
|
|
35
|
+
},
|
|
19
36
|
},
|
|
20
|
-
required: ["
|
|
37
|
+
required: ["operation"],
|
|
21
38
|
},
|
|
22
39
|
|
|
23
|
-
async execute({ project_id, domain }) {
|
|
24
|
-
|
|
25
|
-
return
|
|
26
|
-
} catch (e) {
|
|
27
|
-
return { error: e.message }
|
|
40
|
+
async execute({ operation, project_id, domain } = {}) {
|
|
41
|
+
if (!operation) {
|
|
42
|
+
return { success: false, error: "operation is required (list | add | remove | verify)." }
|
|
28
43
|
}
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const listDomainsTool = {
|
|
33
|
-
name: "list_domains",
|
|
34
|
-
description: "List all custom domains configured for the project's frontend.",
|
|
35
|
-
|
|
36
|
-
inputSchema: {
|
|
37
|
-
type: "object",
|
|
38
|
-
properties: {
|
|
39
|
-
project_id: { type: "string", description: "Project UUID." },
|
|
40
|
-
},
|
|
41
|
-
required: ["project_id"],
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
async execute({ project_id }) {
|
|
45
|
-
try {
|
|
46
|
-
return await api.get(`/api/engine/${project_id}/frontend/domains`)
|
|
47
|
-
} catch (e) {
|
|
48
|
-
return { error: e.message }
|
|
44
|
+
if (!project_id) {
|
|
45
|
+
return { success: false, error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly." }
|
|
49
46
|
}
|
|
50
|
-
},
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export const removeDomainTool = {
|
|
54
|
-
name: "remove_domain",
|
|
55
|
-
description: "Remove a custom domain from the project's frontend.",
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
domain: { type: "string", description: "Domain to remove." },
|
|
62
|
-
},
|
|
63
|
-
required: ["project_id", "domain"],
|
|
64
|
-
},
|
|
48
|
+
const needsDomain = ["add", "remove", "verify"]
|
|
49
|
+
if (needsDomain.includes(operation) && !domain) {
|
|
50
|
+
return { success: false, error: `operation '${operation}' requires a 'domain' argument.` }
|
|
51
|
+
}
|
|
65
52
|
|
|
66
|
-
async execute({ project_id, domain }) {
|
|
67
53
|
try {
|
|
68
|
-
|
|
54
|
+
switch (operation) {
|
|
55
|
+
case "list":
|
|
56
|
+
return await api.get(`/api/engine/${project_id}/frontend/domains`)
|
|
57
|
+
case "add":
|
|
58
|
+
return await api.post(`/api/engine/${project_id}/frontend/domains`, { domain })
|
|
59
|
+
case "remove":
|
|
60
|
+
return await api.delete(`/api/engine/${project_id}/frontend/domains/${encodeURIComponent(domain)}`)
|
|
61
|
+
case "verify":
|
|
62
|
+
return await api.get(`/api/engine/${project_id}/frontend/domains/${encodeURIComponent(domain)}/verify`)
|
|
63
|
+
default:
|
|
64
|
+
return { success: false, error: `Unknown operation '${operation}'. Use list | add | remove | verify.` }
|
|
65
|
+
}
|
|
69
66
|
} catch (e) {
|
|
70
|
-
return { error: e.message }
|
|
67
|
+
return { success: false, error: e.message, operation, domain: domain || null }
|
|
71
68
|
}
|
|
72
69
|
},
|
|
73
70
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side enrichment pass-through.
|
|
3
|
+
*
|
|
4
|
+
* Response enrichment (hints on errors, next_step on non-obvious success) lives
|
|
5
|
+
* on the REMOTE MCP server now (dypai-mcp, Python) so every client — local
|
|
6
|
+
* MCP, dashboard, direct curl — gets the same agent-UX treatment. Keeping
|
|
7
|
+
* this file as a no-op stub lets us re-introduce client-only enrichment
|
|
8
|
+
* later (e.g. for local filesystem context) without touching index.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function enrichSuccess(_toolName, result) {
|
|
12
|
+
return result
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function enrichError(_toolName, error) {
|
|
16
|
+
return error
|
|
17
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manage_frontend — single entry point for the frontend deploy lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates what used to be five separate tools:
|
|
5
|
+
* - deploy_frontend → operation: deploy
|
|
6
|
+
* - get_frontend_status → operation: status
|
|
7
|
+
* - get_build_status → operation: build_status
|
|
8
|
+
* - list_deployments → operation: list_deployments
|
|
9
|
+
* - get_deployment_logs → operation: logs
|
|
10
|
+
*
|
|
11
|
+
* Same pattern as manage_users / manage_roles / manage_domain. Cuts 5 tools to 1.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { api } from "../api.js"
|
|
15
|
+
import { deployFromSource } from "./deploy.js"
|
|
16
|
+
|
|
17
|
+
export const manageFrontendTool = {
|
|
18
|
+
name: "manage_frontend",
|
|
19
|
+
description:
|
|
20
|
+
"Manage the project's frontend deploy lifecycle. Operations:\n" +
|
|
21
|
+
" - deploy: upload source files from a local directory and queue a build. Returns immediately with build_status=\"queued\" — poll with build_status until \"success\" or \"failure\".\n" +
|
|
22
|
+
" - status: current live deploy info (URL, last deploy time, size).\n" +
|
|
23
|
+
" - build_status: progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
|
|
24
|
+
" - list_deployments: recent deploy history (status, commit, duration, URL).\n" +
|
|
25
|
+
" - logs: build logs for a specific deployment (needs deployment_id from list_deployments).",
|
|
26
|
+
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
operation: {
|
|
31
|
+
type: "string",
|
|
32
|
+
enum: ["deploy", "status", "build_status", "list_deployments", "logs"],
|
|
33
|
+
description: "Which action to run.",
|
|
34
|
+
},
|
|
35
|
+
project_id: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted (except `deploy`, which needs it explicit).",
|
|
38
|
+
},
|
|
39
|
+
sourceDirectory: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description: "deploy only. Absolute path to the project source directory (must contain package.json).",
|
|
42
|
+
},
|
|
43
|
+
deployment_id: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "logs only. Deployment UUID obtained from list_deployments.",
|
|
46
|
+
},
|
|
47
|
+
limit: {
|
|
48
|
+
type: "number",
|
|
49
|
+
description: "list_deployments only. Max deployments to return (default 10, max 20).",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["operation"],
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async execute({ operation, project_id, sourceDirectory, deployment_id, limit } = {}) {
|
|
56
|
+
if (!operation) {
|
|
57
|
+
return { success: false, error: "operation is required (deploy | status | build_status | list_deployments | logs)." }
|
|
58
|
+
}
|
|
59
|
+
if (!project_id) {
|
|
60
|
+
return { success: false, error: "project_id is required. Set it in dypai.config.yaml or pass it explicitly." }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
switch (operation) {
|
|
65
|
+
case "deploy":
|
|
66
|
+
if (!sourceDirectory) {
|
|
67
|
+
return { success: false, error: "operation 'deploy' requires 'sourceDirectory' (absolute path to your frontend project root)." }
|
|
68
|
+
}
|
|
69
|
+
return await deployFromSource({ sourceDirectory, project_id })
|
|
70
|
+
|
|
71
|
+
case "status":
|
|
72
|
+
return await api.get(`/api/engine/${project_id}/frontend`)
|
|
73
|
+
|
|
74
|
+
case "build_status":
|
|
75
|
+
return await api.get(`/api/engine/${project_id}/frontend/build-status`)
|
|
76
|
+
|
|
77
|
+
case "list_deployments":
|
|
78
|
+
return await api.get(`/api/engine/${project_id}/frontend/deployments?limit=${limit || 10}`)
|
|
79
|
+
|
|
80
|
+
case "logs":
|
|
81
|
+
if (!deployment_id) {
|
|
82
|
+
return { success: false, error: "operation 'logs' requires 'deployment_id'. Call list_deployments first to get the ID." }
|
|
83
|
+
}
|
|
84
|
+
return await api.get(`/api/engine/${project_id}/frontend/deployments/${deployment_id}/logs`)
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
return { success: false, error: `Unknown operation '${operation}'. Use deploy | status | build_status | list_deployments | logs.` }
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return { success: false, error: e.message, operation }
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|