@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.
@@ -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
+ }
@@ -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
@@ -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
  `),
@@ -57,6 +57,7 @@ function endpointPayload(row) {
57
57
  if (row.group_id) p.group_id = row.group_id
58
58
  if (row.input) p.input = row.input
59
59
  if (row.output) p.output = row.output
60
+ if (row.response_cardinality) p.response_cardinality = row.response_cardinality
60
61
  return p
61
62
  }
62
63