@dypai-ai/mcp 1.5.19 → 1.5.23
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 +45 -23
- package/src/lib/workflow-placeholder-contract.d.ts +50 -0
- package/src/lib/workflow-placeholder-contract.js +364 -0
- package/src/tools/project-artifacts.js +627 -0
- package/src/tools/sync/codec.js +2 -0
- package/src/tools/sync/pull.js +11 -1
- package/src/tools/sync/push.js +1 -0
- package/src/tools/sync/validate.js +227 -8
- package/src/tools/capability-kits.js +0 -830
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync } from "fs"
|
|
3
|
+
import { tmpdir } from "os"
|
|
4
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path"
|
|
5
|
+
import { fileURLToPath } from "url"
|
|
6
|
+
import { proxyToolCall } from "./proxy.js"
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
|
|
10
|
+
function readJson(path) {
|
|
11
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isObject(value) {
|
|
15
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeList(value) {
|
|
19
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim()) : []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walkUp(start, predicate) {
|
|
23
|
+
let cursor = resolve(start)
|
|
24
|
+
for (let i = 0; i < 10; i++) {
|
|
25
|
+
if (predicate(cursor)) return cursor
|
|
26
|
+
const parent = dirname(cursor)
|
|
27
|
+
if (parent === cursor) break
|
|
28
|
+
cursor = parent
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function installFolderKey(slug) {
|
|
34
|
+
return String(slug || "").replace(/^kit-/, "")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function manifestFromModuleContract(contract) {
|
|
38
|
+
const mount = Array.isArray(contract?.mount_contract?.manifest) ? contract.mount_contract.manifest : []
|
|
39
|
+
const frontendManifest = []
|
|
40
|
+
const backendManifest = []
|
|
41
|
+
const databaseManifest = {}
|
|
42
|
+
for (const entry of mount) {
|
|
43
|
+
if (!entry?.source) continue
|
|
44
|
+
const source = String(entry.source)
|
|
45
|
+
const role = String(entry.role || "")
|
|
46
|
+
if (role === "endpoint" || source.includes("/endpoints/")) {
|
|
47
|
+
backendManifest.push({
|
|
48
|
+
source,
|
|
49
|
+
suggestedTarget: entry.target,
|
|
50
|
+
endpointName: entry.export_name || basename(source).replace(/\.ya?ml$/i, ""),
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (source.endsWith(".sql")) {
|
|
55
|
+
if (source.includes("schema.sql")) databaseManifest.schema = source
|
|
56
|
+
if (source.includes("seed.sql")) databaseManifest.seed = source
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
frontendManifest.push({
|
|
60
|
+
source,
|
|
61
|
+
suggestedTarget: entry.target,
|
|
62
|
+
exportName: entry.export_name,
|
|
63
|
+
importPath: entry.import_path,
|
|
64
|
+
role,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const shellDeps = contract.requires?.shell_dependencies
|
|
69
|
+
const dependencies = Array.isArray(shellDeps)
|
|
70
|
+
? shellDeps
|
|
71
|
+
: Array.isArray(contract.requires?.shell_provides)
|
|
72
|
+
? contract.requires.shell_provides.filter((item) => typeof item === "string" && !item.startsWith("credential:"))
|
|
73
|
+
: []
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
slug: contract.slug,
|
|
78
|
+
version: contract.version || "1.0.0",
|
|
79
|
+
name: contract.name,
|
|
80
|
+
kitType: contract.module_kind || "feature",
|
|
81
|
+
category: contract.category,
|
|
82
|
+
maturity: contract.maturity,
|
|
83
|
+
description: contract.description,
|
|
84
|
+
useWhen: contract.selection?.use_when,
|
|
85
|
+
avoidWhen: contract.selection?.avoid_when,
|
|
86
|
+
userFacingSummary: contract.agent_brief?.summary,
|
|
87
|
+
agentInstructions: contract.agent_brief?.instructions,
|
|
88
|
+
provides: contract.provides?.capabilities || [],
|
|
89
|
+
requires: dependencies,
|
|
90
|
+
dependencies,
|
|
91
|
+
frontendManifest,
|
|
92
|
+
backendManifest,
|
|
93
|
+
databaseManifest,
|
|
94
|
+
install: {
|
|
95
|
+
requiresExecuteSql: Boolean(databaseManifest.schema || databaseManifest.seed),
|
|
96
|
+
requiresBackendPublish: backendManifest.length > 0,
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function appendKitInstallationSql(manifest, artifactDir, contract) {
|
|
102
|
+
const folderKey = installFolderKey(contract.slug)
|
|
103
|
+
const ver = contract.version || "1.0.0"
|
|
104
|
+
const schemaRel = `files/dypai/kit-installations/${folderKey}/${ver}/schema.sql`
|
|
105
|
+
const seedRel = `files/dypai/kit-installations/${folderKey}/${ver}/seed.sql`
|
|
106
|
+
if (!isObject(manifest.databaseManifest)) manifest.databaseManifest = {}
|
|
107
|
+
if (existsSync(join(artifactDir, schemaRel))) manifest.databaseManifest.schema = schemaRel
|
|
108
|
+
if (existsSync(join(artifactDir, seedRel))) manifest.databaseManifest.seed = seedRel
|
|
109
|
+
if (manifest.databaseManifest.schema || manifest.databaseManifest.seed) {
|
|
110
|
+
manifest.install = { ...(manifest.install || {}), requiresExecuteSql: true }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadArtifactFromDir(artifactDir) {
|
|
115
|
+
const contractPath = join(artifactDir, "module.contract.json")
|
|
116
|
+
const templatePath = join(artifactDir, "template.contract.json")
|
|
117
|
+
const contractFile = existsSync(contractPath) ? contractPath : templatePath
|
|
118
|
+
if (!existsSync(contractFile)) throw new Error(`Missing contract in ${artifactDir}`)
|
|
119
|
+
const contract = readJson(contractFile)
|
|
120
|
+
const manifest = manifestFromModuleContract(contract)
|
|
121
|
+
appendKitInstallationSql(manifest, artifactDir, contract)
|
|
122
|
+
return { dir: artifactDir, manifest }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function materializeArtifactFromGithub({ source_repo, source_path, source_ref }) {
|
|
126
|
+
const repo = String(source_repo || "").trim()
|
|
127
|
+
if (!repo) {
|
|
128
|
+
throw new Error("source_repo is required from search_project_artifacts (e.g. dyapps-codes/artifacts).")
|
|
129
|
+
}
|
|
130
|
+
const path = String(source_path || "").trim()
|
|
131
|
+
const ref = String(source_ref || "main").trim() || "main"
|
|
132
|
+
|
|
133
|
+
const remote = await proxyToolCall("fetch_project_artifact", {
|
|
134
|
+
source_repo: repo,
|
|
135
|
+
source_path: path,
|
|
136
|
+
source_ref: ref,
|
|
137
|
+
})
|
|
138
|
+
if (!isObject(remote) || !remote.ok) {
|
|
139
|
+
throw new Error(isObject(remote) && remote.error ? remote.error : "fetch_project_artifact failed")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const key = crypto.createHash("sha256").update(`${repo}|${path}|${ref}`).digest("hex")
|
|
143
|
+
const artifactDir = join(tmpdir(), "dypai-artifact-fetch", key)
|
|
144
|
+
rmSync(artifactDir, { recursive: true, force: true })
|
|
145
|
+
mkdirSync(artifactDir, { recursive: true })
|
|
146
|
+
|
|
147
|
+
for (const file of remote.files || []) {
|
|
148
|
+
if (!file?.path || typeof file.content !== "string") continue
|
|
149
|
+
const target = join(artifactDir, String(file.path))
|
|
150
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
151
|
+
writeFileSync(target, file.content)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isObject(remote.contract)) {
|
|
155
|
+
const contractPath = join(artifactDir, "module.contract.json")
|
|
156
|
+
if (!existsSync(contractPath)) {
|
|
157
|
+
writeFileSync(contractPath, `${JSON.stringify(remote.contract, null, 2)}\n`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const loaded = loadArtifactFromDir(artifactDir)
|
|
162
|
+
return {
|
|
163
|
+
...loaded,
|
|
164
|
+
source: "github",
|
|
165
|
+
source_repo: repo,
|
|
166
|
+
source_path: path,
|
|
167
|
+
source_ref: ref,
|
|
168
|
+
agentNotes: typeof remote.agent_notes === "string" ? remote.agent_notes : undefined,
|
|
169
|
+
checklist: typeof remote.checklist === "string" ? remote.checklist : undefined,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function lookupArtifactGithubSource(slug) {
|
|
174
|
+
const wanted = String(slug || "").trim()
|
|
175
|
+
if (!wanted) throw new Error("slug is required.")
|
|
176
|
+
|
|
177
|
+
const remote = await proxyToolCall("search_project_artifacts", { query: wanted, limit: 20 })
|
|
178
|
+
if (!isObject(remote) || remote.ok === false) {
|
|
179
|
+
throw new Error(isObject(remote) && remote.error ? remote.error : "search_project_artifacts failed")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pools = [remote.artifacts, remote.features, remote.bases].filter(Array.isArray)
|
|
183
|
+
for (const pool of pools) {
|
|
184
|
+
const hit = pool.find((item) => isObject(item) && String(item.slug || "").trim() === wanted)
|
|
185
|
+
if (hit?.source_repo) {
|
|
186
|
+
return {
|
|
187
|
+
source_repo: String(hit.source_repo).trim(),
|
|
188
|
+
source_path: String(hit.source_path || "").trim(),
|
|
189
|
+
source_ref: String(hit.source_ref || "main").trim() || "main",
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Artifact "${wanted}" not found in catalog. Call search_project_artifacts first.`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function resolveArtifactInstallation({ slug, source_repo, source_path, source_ref }) {
|
|
197
|
+
const resolved = String(source_repo || "").trim()
|
|
198
|
+
? {
|
|
199
|
+
source_repo: String(source_repo).trim(),
|
|
200
|
+
source_path: String(source_path || "").trim(),
|
|
201
|
+
source_ref: String(source_ref || "main").trim() || "main",
|
|
202
|
+
}
|
|
203
|
+
: await lookupArtifactGithubSource(slug)
|
|
204
|
+
return materializeArtifactFromGithub(resolved)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function resolveWorkspaceRoot(inputRoot) {
|
|
208
|
+
if (inputRoot) {
|
|
209
|
+
const root = resolve(inputRoot)
|
|
210
|
+
mkdirSync(root, { recursive: true })
|
|
211
|
+
return root
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const envCandidates = [
|
|
215
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
216
|
+
process.env.DYPAI_WORKSPACE_ROOT,
|
|
217
|
+
process.env.PROJECT_ROOT,
|
|
218
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
|
|
219
|
+
].filter(Boolean)
|
|
220
|
+
|
|
221
|
+
for (const candidate of envCandidates) {
|
|
222
|
+
const root = resolve(candidate)
|
|
223
|
+
if (existsSync(root)) return root
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fromCwd = walkUp(process.cwd(), (dir) =>
|
|
227
|
+
existsSync(join(dir, ".git")) ||
|
|
228
|
+
existsSync(join(dir, "package.json")) ||
|
|
229
|
+
existsSync(join(dir, "dypai")) ||
|
|
230
|
+
existsSync(join(dir, "src")),
|
|
231
|
+
)
|
|
232
|
+
if (fromCwd) return fromCwd
|
|
233
|
+
|
|
234
|
+
throw new Error("Could not determine workspace root. Pass workspace_root as an absolute project path.")
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function safeTarget(workspaceRoot, targetPath) {
|
|
238
|
+
if (!targetPath || typeof targetPath !== "string") throw new Error("Invalid target path")
|
|
239
|
+
if (isAbsolute(targetPath)) throw new Error(`Target must be workspace-relative, got absolute path: ${targetPath}`)
|
|
240
|
+
const normalized = targetPath.replace(/\\/g, "/").replace(/^\/+/, "")
|
|
241
|
+
if (normalized.includes("..")) throw new Error(`Target path cannot contain '..': ${targetPath}`)
|
|
242
|
+
const full = resolve(workspaceRoot, normalized)
|
|
243
|
+
const rel = relative(workspaceRoot, full)
|
|
244
|
+
if (rel.startsWith("..") || rel === "" || isAbsolute(rel)) {
|
|
245
|
+
throw new Error(`Target escapes workspace root: ${targetPath}`)
|
|
246
|
+
}
|
|
247
|
+
return full
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function assetIndex(dir) {
|
|
251
|
+
const out = []
|
|
252
|
+
function visit(abs, rel = "") {
|
|
253
|
+
for (const name of readdirSync(abs, { withFileTypes: true })) {
|
|
254
|
+
const childAbs = join(abs, name.name)
|
|
255
|
+
const childRel = rel ? `${rel}/${name.name}` : name.name
|
|
256
|
+
if (name.isDirectory()) visit(childAbs, childRel)
|
|
257
|
+
else out.push({ logicalPath: childRel, sizeBytes: readFileSync(childAbs).length })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
visit(dir)
|
|
261
|
+
return out.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stripFrontendPrefix(source) {
|
|
265
|
+
return source.replace(/^frontend\/src\//, "")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolveArtifactAssetPath(artifactDir, source) {
|
|
269
|
+
const candidates = [source]
|
|
270
|
+
if (source.includes("/endpoints/endpoints/")) {
|
|
271
|
+
candidates.push(source.replace("/endpoints/endpoints/", "/endpoints/"))
|
|
272
|
+
}
|
|
273
|
+
const nested = source.match(/^files\/dypai\/endpoints\/([^/]+)\/endpoints\/(.+)$/)
|
|
274
|
+
if (nested) candidates.push(`files/dypai/endpoints/${nested[1]}/${nested[2]}`)
|
|
275
|
+
for (const candidate of candidates) {
|
|
276
|
+
const full = join(artifactDir, candidate)
|
|
277
|
+
if (existsSync(full)) return full
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`Missing artifact asset: ${source}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function defaultTargetForSource(manifest, source, kind) {
|
|
283
|
+
if (kind === "frontend") return `src/dypai-kits/${manifest.slug}/${stripFrontendPrefix(source)}`
|
|
284
|
+
if (kind === "backend") return `dypai/endpoints/${manifest.slug}/${source.split("/").pop()}`
|
|
285
|
+
if (kind === "database") return `dypai/kit-installations/${manifest.slug}/${manifest.version}/${source.split("/").pop()}`
|
|
286
|
+
return `.dypai/kits/${manifest.slug}/${source.split("/").pop()}`
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function pluralize(word) {
|
|
290
|
+
if (!word) return word
|
|
291
|
+
if (word.endsWith("s")) return word
|
|
292
|
+
if (word.endsWith("y")) return `${word.slice(0, -1)}ies`
|
|
293
|
+
return `${word}s`
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function namingContext(manifest, naming = {}) {
|
|
297
|
+
const adaptation = isObject(manifest.adaptation) ? manifest.adaptation : {}
|
|
298
|
+
const sourceEntity = adaptation.defaultEntity || normalizeList(adaptation.entityAliases)[0] || ""
|
|
299
|
+
const sourcePlural = adaptation.defaultEndpointPrefix || pluralize(sourceEntity)
|
|
300
|
+
const sourceTable = adaptation.defaultTableName || sourcePlural
|
|
301
|
+
const targetEntity = naming.entity || sourceEntity
|
|
302
|
+
const targetPlural = naming.endpointPrefix || pluralize(targetEntity)
|
|
303
|
+
const targetTable = naming.tableName || targetPlural
|
|
304
|
+
return { sourceEntity, sourcePlural, sourceTable, targetEntity, targetPlural, targetTable }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function replaceWholeWords(text, replacements) {
|
|
308
|
+
let out = text
|
|
309
|
+
for (const [from, to] of replacements) {
|
|
310
|
+
if (!from || !to || from === to) continue
|
|
311
|
+
out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
|
|
312
|
+
}
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function adaptText(content, manifest, naming, kind) {
|
|
317
|
+
const n = namingContext(manifest, naming)
|
|
318
|
+
if (!n.sourceEntity && !n.sourcePlural && !n.sourceTable) return content
|
|
319
|
+
if (kind === "frontend") return content
|
|
320
|
+
|
|
321
|
+
if (kind === "backend") {
|
|
322
|
+
const withEndpointName = content.replace(/^name:\s*([a-z0-9-]+)\s*$/m, (_line, endpointName) => {
|
|
323
|
+
return `name: ${adaptTarget(endpointName, manifest, naming)}`
|
|
324
|
+
})
|
|
325
|
+
return replaceWholeWords(withEndpointName, [
|
|
326
|
+
[n.sourceTable, n.targetTable],
|
|
327
|
+
[n.sourceEntity, n.targetEntity],
|
|
328
|
+
])
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return replaceWholeWords(content, [
|
|
332
|
+
[n.sourceTable, n.targetTable],
|
|
333
|
+
[n.sourcePlural, n.targetTable],
|
|
334
|
+
[n.sourceEntity, n.targetEntity],
|
|
335
|
+
])
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function adaptTarget(target, manifest, naming) {
|
|
339
|
+
const n = namingContext(manifest, naming)
|
|
340
|
+
return replaceWholeWords(target, [
|
|
341
|
+
[n.sourcePlural, n.targetPlural],
|
|
342
|
+
[n.sourceEntity, n.targetEntity],
|
|
343
|
+
[n.sourceTable, n.targetTable],
|
|
344
|
+
])
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function installRecordPath(workspaceRoot) {
|
|
348
|
+
return join(workspaceRoot, ".dypai", "artifacts", "installed.json")
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function readInstallRecord(workspaceRoot) {
|
|
352
|
+
const path = installRecordPath(workspaceRoot)
|
|
353
|
+
if (!existsSync(path)) return { schemaVersion: 2, artifacts: [] }
|
|
354
|
+
try {
|
|
355
|
+
const parsed = readJson(path)
|
|
356
|
+
if (isObject(parsed) && Array.isArray(parsed.artifacts)) return parsed
|
|
357
|
+
} catch {}
|
|
358
|
+
return { schemaVersion: 2, artifacts: [] }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function writeInstallRecord(workspaceRoot, record) {
|
|
362
|
+
const path = installRecordPath(workspaceRoot)
|
|
363
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
364
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function ownedTargets(record, slug, version) {
|
|
368
|
+
const targets = new Set()
|
|
369
|
+
for (const item of record.artifacts || []) {
|
|
370
|
+
if (item.slug === slug && item.version === version) {
|
|
371
|
+
for (const file of item.files || []) {
|
|
372
|
+
if (file.target) targets.add(file.target)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return targets
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind, overwrite, record, copied, skipped }) {
|
|
380
|
+
const targetAbs = safeTarget(workspaceRoot, targetRel)
|
|
381
|
+
const targetExists = existsSync(targetAbs)
|
|
382
|
+
if (targetExists) {
|
|
383
|
+
if (overwrite === "fail") throw new Error(`Target already exists: ${targetRel}`)
|
|
384
|
+
if (overwrite !== "replace" || !ownedTargets(record, manifest.slug, manifest.version).has(targetRel)) {
|
|
385
|
+
skipped.push(targetRel)
|
|
386
|
+
return false
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
mkdirSync(dirname(targetAbs), { recursive: true })
|
|
391
|
+
const isText = /\.(tsx?|jsx?|ya?ml|json|md|sql|css|scss|html)$/i.test(sourceAbs)
|
|
392
|
+
if (isText) {
|
|
393
|
+
const content = adaptText(readFileSync(sourceAbs, "utf8"), manifest, naming, kind)
|
|
394
|
+
writeFileSync(targetAbs, content)
|
|
395
|
+
} else {
|
|
396
|
+
copyFileSync(sourceAbs, targetAbs)
|
|
397
|
+
}
|
|
398
|
+
copied.push(targetRel)
|
|
399
|
+
return true
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function inspectProjectArtifact({ slug, version = "1.0.0", source_repo, source_path, source_ref }) {
|
|
403
|
+
const { dir, manifest, source, agentNotes, checklist } = await resolveArtifactInstallation({ slug, source_repo, source_path, source_ref })
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
operation: "inspect",
|
|
407
|
+
source,
|
|
408
|
+
artifactRoot: dir,
|
|
409
|
+
artifact: manifest,
|
|
410
|
+
assets: assetIndex(dir),
|
|
411
|
+
agentNotes: agentNotes || (existsSync(join(dir, "agent.md")) ? readFileSync(join(dir, "agent.md"), "utf8") : undefined),
|
|
412
|
+
checklist: checklist || (existsSync(join(dir, "verification", "checklist.md"))
|
|
413
|
+
? readFileSync(join(dir, "verification", "checklist.md"), "utf8")
|
|
414
|
+
: undefined),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function applyProjectArtifact({
|
|
419
|
+
slug,
|
|
420
|
+
version = "1.0.0",
|
|
421
|
+
targetFeature = "",
|
|
422
|
+
install = {},
|
|
423
|
+
naming = {},
|
|
424
|
+
target = {},
|
|
425
|
+
overwrite = "skip",
|
|
426
|
+
workspace_root,
|
|
427
|
+
source_repo,
|
|
428
|
+
source_path,
|
|
429
|
+
source_ref,
|
|
430
|
+
}) {
|
|
431
|
+
const { dir, manifest, source } = await resolveArtifactInstallation({ slug, source_repo, source_path, source_ref })
|
|
432
|
+
const workspaceRoot = resolveWorkspaceRoot(workspace_root)
|
|
433
|
+
const record = readInstallRecord(workspaceRoot)
|
|
434
|
+
const previousEntries = (record.artifacts || []).filter((item) => item.slug === manifest.slug && item.version === manifest.version)
|
|
435
|
+
const copied = []
|
|
436
|
+
const copiedByKind = {
|
|
437
|
+
frontend: [],
|
|
438
|
+
backend: [],
|
|
439
|
+
database: [],
|
|
440
|
+
}
|
|
441
|
+
const databaseTargetsBySource = new Map()
|
|
442
|
+
const skipped = []
|
|
443
|
+
|
|
444
|
+
const installFrontend = install.frontend !== false
|
|
445
|
+
const installBackend = install.backend !== false
|
|
446
|
+
const installDatabase = install.database !== false
|
|
447
|
+
|
|
448
|
+
if (installFrontend) {
|
|
449
|
+
for (const asset of manifest.frontendManifest || []) {
|
|
450
|
+
const sourceAbs = resolveArtifactAssetPath(dir, asset.source)
|
|
451
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "frontend")
|
|
452
|
+
if (target.frontendDir) {
|
|
453
|
+
targetRel = join(target.frontendDir, stripFrontendPrefix(asset.source)).split(sep).join("/")
|
|
454
|
+
}
|
|
455
|
+
if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "frontend", overwrite, record, copied, skipped })) {
|
|
456
|
+
copiedByKind.frontend.push(targetRel)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (installBackend) {
|
|
462
|
+
for (const asset of manifest.backendManifest || []) {
|
|
463
|
+
const sourceAbs = resolveArtifactAssetPath(dir, asset.source)
|
|
464
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "backend")
|
|
465
|
+
targetRel = join(dirname(targetRel), adaptTarget(basename(targetRel), manifest, naming)).split(sep).join("/")
|
|
466
|
+
if (target.endpointDir) {
|
|
467
|
+
targetRel = join(target.endpointDir, adaptTarget(asset.source.split("/").pop(), manifest, naming)).split(sep).join("/")
|
|
468
|
+
}
|
|
469
|
+
if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "backend", overwrite, record, copied, skipped })) {
|
|
470
|
+
copiedByKind.backend.push(targetRel)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (installDatabase && isObject(manifest.databaseManifest)) {
|
|
476
|
+
for (const key of ["schema", "seed"]) {
|
|
477
|
+
const source = manifest.databaseManifest[key]
|
|
478
|
+
if (!source) continue
|
|
479
|
+
const sourceAbs = join(dir, source)
|
|
480
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${source}`)
|
|
481
|
+
let targetRel = defaultTargetForSource(manifest, source, "database")
|
|
482
|
+
if (target.databaseDir) {
|
|
483
|
+
targetRel = join(target.databaseDir, source.split("/").pop()).split(sep).join("/")
|
|
484
|
+
}
|
|
485
|
+
if (writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "database", overwrite, record, copied, skipped })) {
|
|
486
|
+
copiedByKind.database.push(targetRel)
|
|
487
|
+
databaseTargetsBySource.set(source, targetRel)
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const imported = (manifest.frontendManifest || []).map((asset) => ({
|
|
493
|
+
name: asset.exportName,
|
|
494
|
+
from: target.frontendDir
|
|
495
|
+
? `@/${join(target.frontendDir, stripFrontendPrefix(asset.source)).replace(/^src\//, "").replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "")}`
|
|
496
|
+
: asset.importPath,
|
|
497
|
+
})).filter((item) => item.name && item.from)
|
|
498
|
+
|
|
499
|
+
const backendEndpoints = (manifest.backendManifest || []).map((asset) =>
|
|
500
|
+
adaptTarget(asset.endpointName || asset.source.split("/").pop().replace(/\.ya?ml$/, ""), manifest, naming),
|
|
501
|
+
)
|
|
502
|
+
const databaseTables = normalizeList(manifest.databaseManifest?.tables).map((table) =>
|
|
503
|
+
adaptTarget(table, manifest, naming),
|
|
504
|
+
)
|
|
505
|
+
const schemaSource = installDatabase && isObject(manifest.databaseManifest) && typeof manifest.databaseManifest.schema === "string"
|
|
506
|
+
? manifest.databaseManifest.schema
|
|
507
|
+
: ""
|
|
508
|
+
const seedSource = installDatabase && isObject(manifest.databaseManifest) && typeof manifest.databaseManifest.seed === "string"
|
|
509
|
+
? manifest.databaseManifest.seed
|
|
510
|
+
: ""
|
|
511
|
+
const schemaFile = schemaSource ? databaseTargetsBySource.get(schemaSource) : undefined
|
|
512
|
+
const seedFile = seedSource ? databaseTargetsBySource.get(seedSource) : undefined
|
|
513
|
+
|
|
514
|
+
const previousFiles = previousEntries.flatMap((item) => Array.isArray(item.files) ? item.files : [])
|
|
515
|
+
const fileMap = new Map()
|
|
516
|
+
for (const file of previousFiles) {
|
|
517
|
+
if (file?.target) fileMap.set(file.target, file)
|
|
518
|
+
}
|
|
519
|
+
for (const targetPath of copied) {
|
|
520
|
+
fileMap.set(targetPath, { target: targetPath })
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const entry = {
|
|
524
|
+
slug: manifest.slug,
|
|
525
|
+
version: manifest.version,
|
|
526
|
+
installedAt: new Date().toISOString(),
|
|
527
|
+
targetFeature,
|
|
528
|
+
files: [...fileMap.values()],
|
|
529
|
+
skippedFiles: skipped,
|
|
530
|
+
naming,
|
|
531
|
+
}
|
|
532
|
+
record.artifacts = (record.artifacts || []).filter((item) => !(item.slug === manifest.slug && item.version === manifest.version))
|
|
533
|
+
record.artifacts.push(entry)
|
|
534
|
+
writeInstallRecord(workspaceRoot, record)
|
|
535
|
+
|
|
536
|
+
const dependencies = manifest.dependencies || []
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
operation: "apply",
|
|
541
|
+
source,
|
|
542
|
+
slug: manifest.slug,
|
|
543
|
+
version: manifest.version,
|
|
544
|
+
workspaceRoot,
|
|
545
|
+
installed: {
|
|
546
|
+
files: copied,
|
|
547
|
+
frontendFiles: copiedByKind.frontend,
|
|
548
|
+
backendFiles: copiedByKind.backend,
|
|
549
|
+
databaseFiles: copiedByKind.database,
|
|
550
|
+
skipped,
|
|
551
|
+
recordFile: ".dypai/artifacts/installed.json",
|
|
552
|
+
},
|
|
553
|
+
imports: imported,
|
|
554
|
+
backend: {
|
|
555
|
+
endpoints: backendEndpoints,
|
|
556
|
+
requiresPublish: Boolean(manifest.install?.requiresBackendPublish || backendEndpoints.length),
|
|
557
|
+
},
|
|
558
|
+
database: {
|
|
559
|
+
tables: databaseTables,
|
|
560
|
+
sqlFiles: copiedByKind.database,
|
|
561
|
+
schemaFile,
|
|
562
|
+
seedFile,
|
|
563
|
+
requiresExecuteSql: Boolean(manifest.install?.requiresExecuteSql || databaseTables.length),
|
|
564
|
+
applyWith: "execute_sql",
|
|
565
|
+
schemaRefresh: "automatic_after_successful_ddl",
|
|
566
|
+
},
|
|
567
|
+
dependencies,
|
|
568
|
+
nextSteps: [
|
|
569
|
+
...(dependencies.length
|
|
570
|
+
? [`Add any missing package dependencies to the target workspace package.json, then install them locally before building: ${dependencies.join(", ")}.`]
|
|
571
|
+
: []),
|
|
572
|
+
"Wire installed frontend components into the selected app page or route.",
|
|
573
|
+
...(databaseTables.length ? [
|
|
574
|
+
`Before backend tests, ensure tables exist (${databaseTables.join(", ")}): read ${schemaFile || "the installed schema SQL"} and execute its safe CREATE/ALTER statements with execute_sql. Do not edit dypai/schema.sql; it refreshes automatically.`,
|
|
575
|
+
] : []),
|
|
576
|
+
...(backendEndpoints.length ? ["Run backend validation and endpoint tests for installed/renamed endpoints."] : []),
|
|
577
|
+
"Run frontend verification after wiring imports and data callbacks.",
|
|
578
|
+
],
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export const manageProjectArtifactTool = {
|
|
583
|
+
name: "manage_project_artifact",
|
|
584
|
+
description: "Inspect or install a project artifact into the workspace. Call search_project_artifacts first, then pass the exact slug; the platform resolves GitHub source server-side. Apply never executes SQL, publishes backend, deploys frontend, or installs npm packages.",
|
|
585
|
+
inputSchema: {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
operation: { type: "string", enum: ["inspect", "apply"], description: "inspect returns manifest/assets. apply copies artifact files into the workspace." },
|
|
589
|
+
slug: { type: "string", description: "Exact artifact slug from search_project_artifacts (e.g. kit-pricing-stripe)." },
|
|
590
|
+
version: { type: "string", default: "1.0.0" },
|
|
591
|
+
targetFeature: { type: "string", description: "Domain/feature being built, e.g. hotel reservations." },
|
|
592
|
+
install: {
|
|
593
|
+
type: "object",
|
|
594
|
+
properties: {
|
|
595
|
+
frontend: { type: "boolean", default: true },
|
|
596
|
+
backend: { type: "boolean", default: true },
|
|
597
|
+
database: { type: "boolean", default: true },
|
|
598
|
+
examples: { type: "boolean", default: false },
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
naming: {
|
|
602
|
+
type: "object",
|
|
603
|
+
properties: {
|
|
604
|
+
entity: { type: "string", description: "Singular domain entity, e.g. reservation." },
|
|
605
|
+
endpointPrefix: { type: "string", description: "Plural endpoint/table-friendly domain name, e.g. reservations." },
|
|
606
|
+
tableName: { type: "string", description: "Database table name, e.g. reservations." },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
target: {
|
|
610
|
+
type: "object",
|
|
611
|
+
properties: {
|
|
612
|
+
frontendDir: { type: "string" },
|
|
613
|
+
endpointDir: { type: "string" },
|
|
614
|
+
databaseDir: { type: "string" },
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
overwrite: { type: "string", enum: ["skip", "fail", "replace"], default: "skip" },
|
|
618
|
+
workspace_root: { type: "string", description: "Optional absolute path to the target app workspace." },
|
|
619
|
+
},
|
|
620
|
+
required: ["operation", "slug"],
|
|
621
|
+
},
|
|
622
|
+
async execute(args) {
|
|
623
|
+
if (args?.operation === "inspect") return inspectProjectArtifact(args)
|
|
624
|
+
if (args?.operation === "apply") return applyProjectArtifact(args)
|
|
625
|
+
throw new Error("manage_project_artifact requires operation 'inspect' or 'apply'.")
|
|
626
|
+
},
|
|
627
|
+
}
|
package/src/tools/sync/codec.js
CHANGED
|
@@ -205,6 +205,7 @@ export function serializeEndpoint(row, mapsCtx) {
|
|
|
205
205
|
}
|
|
206
206
|
if (row.input) doc.input = row.input
|
|
207
207
|
if (row.output) doc.output = row.output
|
|
208
|
+
if (row.response_cardinality) doc.response_cardinality = row.response_cardinality
|
|
208
209
|
doc.trigger = triggersToYaml(wf.execution_config?.triggers)
|
|
209
210
|
|
|
210
211
|
const workflow = { nodes }
|
|
@@ -354,6 +355,7 @@ export function deserializeEndpoint(doc, mapsCtx) {
|
|
|
354
355
|
workflow_code,
|
|
355
356
|
input: doc.input || null,
|
|
356
357
|
output: doc.output || null,
|
|
358
|
+
response_cardinality: doc.response_cardinality || null,
|
|
357
359
|
allowed_roles: doc.allowed_roles || [],
|
|
358
360
|
// Accept both `tool: true` (canonical) and `is_tool: true` (engine-style).
|
|
359
361
|
// Without this, an agent that wrote `is_tool` in YAML would silently get
|
package/src/tools/sync/pull.js
CHANGED
|
@@ -194,6 +194,7 @@ input:
|
|
|
194
194
|
|
|
195
195
|
# ─── Output schema (optional but recommended) ───────────────────────────────
|
|
196
196
|
# Describes the response shape. Helps the validator + frontend type-checkers.
|
|
197
|
+
# This is the PUBLIC HTTP body (what dypai.api.*().data receives).
|
|
197
198
|
output:
|
|
198
199
|
type: object
|
|
199
200
|
properties:
|
|
@@ -201,6 +202,15 @@ output:
|
|
|
201
202
|
total: { type: number }
|
|
202
203
|
status: { type: string }
|
|
203
204
|
|
|
205
|
+
# ─── Public response cardinality (optional) ───────────────────────────────────
|
|
206
|
+
# Only needed when the return node is dypai_database (SQL row arrays) and
|
|
207
|
+
# output.type is object. The engine unwraps [{...}] -> {...} at the HTTP boundary.
|
|
208
|
+
# response_cardinality: single # one row / INSERT RETURNING *
|
|
209
|
+
# response_cardinality: many # public body is an array (output.type: array)
|
|
210
|
+
# response_cardinality: zero_or_one # lookup; [] -> null
|
|
211
|
+
# Prefer set_fields (see build_response below) when composing { items, total, ... }.
|
|
212
|
+
# Node-level output_cardinality is internal-only for \${nodes.<id>.field} placeholders.
|
|
213
|
+
|
|
204
214
|
# ─── Workflow ───────────────────────────────────────────────────────────────
|
|
205
215
|
# Nodes are the steps. Placeholders wire data flow between them:
|
|
206
216
|
#
|
|
@@ -600,7 +610,7 @@ export const dypaiPullTool = {
|
|
|
600
610
|
const [endpoints, credentials, groups, schemaSql, nodeCatalogResult, realtimePolicies, draftsResult] = await Promise.all([
|
|
601
611
|
execSql(project_id, `
|
|
602
612
|
SELECT id, name, method, description, workflow_code, input, output,
|
|
603
|
-
allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
|
|
613
|
+
response_cardinality, allowed_roles, is_tool, tool_description, group_id, is_active, updated_at
|
|
604
614
|
FROM system.endpoints
|
|
605
615
|
ORDER BY name
|
|
606
616
|
`),
|
package/src/tools/sync/push.js
CHANGED