@dypai-ai/mcp 1.0.9 → 1.1.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 +27 -97
- 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 +97 -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 +414 -0
- package/src/tools/sync/push.js +411 -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
|
}
|
|
@@ -180,88 +158,40 @@ After deploying, the site is live at https://{slug}.dypai.app within ~30s.`,
|
|
|
180
158
|
|
|
181
159
|
const label = framework?.label ?? "Project"
|
|
182
160
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
189
|
-
await new Promise(r => setTimeout(r, pollInterval))
|
|
190
|
-
try {
|
|
191
|
-
const status = await api.get(`/api/engine/${project_id}/frontend/build-status`)
|
|
192
|
-
const s = status?.status
|
|
193
|
-
if (s === "success") {
|
|
194
|
-
buildResult = { status: "success", url: result.url, duration: status.duration_seconds }
|
|
195
|
-
break
|
|
196
|
-
}
|
|
197
|
-
if (s === "failure") {
|
|
198
|
-
// Fetch build logs for error context
|
|
199
|
-
let logs = status.build_error || null
|
|
200
|
-
if (!logs) {
|
|
201
|
-
try {
|
|
202
|
-
const deployments = await api.get(`/api/engine/${project_id}/frontend/deployments?limit=1`)
|
|
203
|
-
const depId = deployments?.deployments?.[0]?.id
|
|
204
|
-
if (depId) {
|
|
205
|
-
const logRes = await api.get(`/api/engine/${project_id}/frontend/deployments/${depId}/logs`)
|
|
206
|
-
if (logRes?.logs) {
|
|
207
|
-
const lines = logRes.logs.split("\n")
|
|
208
|
-
logs = lines.slice(-30).join("\n")
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
|
213
|
-
buildResult = { status: "failure", error: logs || "Build failed — check logs with get_deployment_logs" }
|
|
214
|
-
break
|
|
215
|
-
}
|
|
216
|
-
// still queued/building — keep polling
|
|
217
|
-
} catch {
|
|
218
|
-
// build-status not available yet, keep polling
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (!buildResult) {
|
|
223
|
-
// Timed out — build still in progress
|
|
224
|
-
return {
|
|
225
|
-
success: true,
|
|
226
|
-
url: result.url,
|
|
227
|
-
files_pushed: files.length,
|
|
228
|
-
size_bytes: total,
|
|
229
|
-
framework: label,
|
|
230
|
-
build_status: "building",
|
|
231
|
-
message: `Deployed ${files.length} files (${Math.round(total / 1024)} KB). ${label} build still in progress at ${result.url} — use get_build_status to check progress.`,
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (buildResult.status === "failure") {
|
|
236
|
-
return {
|
|
237
|
-
success: false,
|
|
238
|
-
url: result.url,
|
|
239
|
-
files_pushed: files.length,
|
|
240
|
-
framework: label,
|
|
241
|
-
build_status: "failure",
|
|
242
|
-
build_error: buildResult.error,
|
|
243
|
-
message: `Deploy pushed ${files.length} files but the ${label} build FAILED. Build error:\n${buildResult.error}`,
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
161
|
+
// Fire-and-forget: return immediately with "queued" status. The agent
|
|
162
|
+
// is expected to call `get_build_status` to poll progress until the
|
|
163
|
+
// terminal "success" / "failure" is reached. Blocking here would stall
|
|
164
|
+
// the agent for up to 2 minutes and prevent it from doing anything
|
|
165
|
+
// else in parallel.
|
|
247
166
|
const skippedMsg = skipped.length > 0
|
|
248
167
|
? `\n\nSkipped ${skipped.length} file(s):\n${skipped.map(s => ` - ${s.path}: ${s.reason}`).join("\n")}`
|
|
249
168
|
: ""
|
|
250
169
|
|
|
251
170
|
return {
|
|
252
171
|
success: true,
|
|
253
|
-
|
|
172
|
+
deployed: false, // explicit: not live yet, still building
|
|
173
|
+
url: result.url, // final URL once success
|
|
254
174
|
files_pushed: files.length,
|
|
255
175
|
files_skipped: skipped.length,
|
|
256
176
|
skipped_files: skipped.length > 0 ? skipped : undefined,
|
|
257
177
|
size_bytes: total,
|
|
258
178
|
framework: label,
|
|
259
|
-
build_status: "
|
|
260
|
-
|
|
261
|
-
|
|
179
|
+
build_status: "queued",
|
|
180
|
+
next_step: {
|
|
181
|
+
action: "poll_build_status",
|
|
182
|
+
tool: "manage_frontend",
|
|
183
|
+
arg: { operation: "build_status", project_id },
|
|
184
|
+
interval_seconds: 5,
|
|
185
|
+
expected_total_seconds: 60,
|
|
186
|
+
terminal_statuses: ["success", "failure"],
|
|
187
|
+
},
|
|
188
|
+
message:
|
|
189
|
+
`Deploy accepted — ${files.length} files (${Math.round(total / 1024)} KB) queued. ` +
|
|
190
|
+
`The build is running in the cloud (${label}, typically 20–60s). ` +
|
|
191
|
+
`Call manage_frontend({operation:"build_status", project_id:"${project_id}"}) every ~5s until status is "success" or "failure" ` +
|
|
192
|
+
`before reporting completion to the user. The site is NOT live yet.${skippedMsg}`,
|
|
262
193
|
}
|
|
263
194
|
} catch (e) {
|
|
264
195
|
return { error: `Deploy failed: ${e.message}` }
|
|
265
196
|
}
|
|
266
|
-
},
|
|
267
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
|
+
}
|