@dypai-ai/mcp 1.6.16 → 1.6.18

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.
@@ -25,6 +25,7 @@ import {
25
25
  } from "./planner.js"
26
26
  import { runValidation } from "./validate.js"
27
27
  import { runGenerateEndpointTypes } from "./generate-types.js"
28
+ import { writeBackendSourceBaseline } from "./source-diff.js"
28
29
  // Codegen removed from v1 — see pull.js note.
29
30
  // import { regenerateTypes } from "../codegen.js"
30
31
 
@@ -34,7 +35,7 @@ const VALID_RESPONSE_CARDINALITY = new Set(["single", "many", "zero_or_one"])
34
35
  const PUSH_CONCURRENCY = Math.max(1, Number(process.env.DYPAI_PUSH_CONCURRENCY || 1))
35
36
  const PUSH_RETRY_ATTEMPTS = Math.max(1, Number(process.env.DYPAI_PUSH_RETRY_ATTEMPTS || 3))
36
37
  const PUSH_RETRY_DELAY_MS = Math.max(100, Number(process.env.DYPAI_PUSH_RETRY_DELAY_MS || 500))
37
- const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "endpoints", "migrations", "types", "lib"]
38
+ const BACKEND_SOURCE_CHECKPOINT_DIRS = ["flows", "automations", "endpoints", "migrations", "types", "lib"]
38
39
  const BACKEND_SOURCE_CHECKPOINT_EXACT = ["schema.sql", "realtime.yaml"]
39
40
  const BACKEND_SOURCE_CHECKPOINT_EXTS = new Set([
40
41
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
@@ -136,6 +137,8 @@ function normalizeWorkflowSourceFile(row) {
136
137
  const raw = typeof source?.file === "string" ? source.file.trim().replace(/\\/g, "/") : ""
137
138
  if (raw.startsWith("dypai/flows/") && raw.endsWith(".flow.ts")) return raw
138
139
  if (raw.startsWith("flows/") && raw.endsWith(".flow.ts")) return `dypai/${raw}`
140
+ if (raw.startsWith("dypai/automations/") && raw.endsWith(".automation.ts")) return raw
141
+ if (raw.startsWith("automations/") && raw.endsWith(".automation.ts")) return `dypai/${raw}`
139
142
  return null
140
143
  }
141
144
 
@@ -195,6 +198,29 @@ async function checkpointBackendSource(projectId, rootDir, plan, remote) {
195
198
  }
196
199
  }
197
200
 
201
+ async function refreshBackendSourceBaseline(projectId, rootDir, sourceCheckpoint) {
202
+ if (sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true) {
203
+ return {
204
+ ok: false,
205
+ skipped: true,
206
+ reason: "source_checkpoint_failed",
207
+ }
208
+ }
209
+ try {
210
+ const baseline = await writeBackendSourceBaseline(rootDir, projectId)
211
+ return {
212
+ ok: true,
213
+ files: Array.isArray(baseline.files) ? baseline.files.length : 0,
214
+ path: "dypai/.dypai/backend-baseline.json",
215
+ }
216
+ } catch (error) {
217
+ return {
218
+ ok: false,
219
+ error: error?.message || String(error),
220
+ }
221
+ }
222
+ }
223
+
198
224
  /**
199
225
  * Run an async worker over an array with bounded concurrency. Workers never
200
226
  * throw (they handle their own errors via the push errors[] accumulator),
@@ -275,7 +301,7 @@ function assertMutationOK(result, op, name) {
275
301
  * Returns true when the API staged the change as a draft (default behavior)
276
302
  * instead of applying it directly to live. Both the draft branch and the
277
303
  * direct-write branch are valid successful outcomes — drafts just need to
278
- * be reported back to the user so they know to run dypai_deploy_production(confirm:true).
304
+ * be reported back to the user so they know to publish the drafts explicitly.
279
305
  */
280
306
  function isDraftResponse(result) {
281
307
  return result && typeof result === "object" && result.applied_to === "draft"
@@ -439,7 +465,7 @@ export const dypaiPushTool = {
439
465
  },
440
466
  dry_run: {
441
467
  type: "boolean",
442
- description: "If true, compute the plan and return it without applying. Same as dypai_diff.",
468
+ description: "If true, compile and compute the backend plan without applying or regenerating types. For a fast source-only preview use dypai_diff.",
443
469
  default: false,
444
470
  },
445
471
  force: {
@@ -499,11 +525,13 @@ export const dypaiPushTool = {
499
525
  }
500
526
  }
501
527
 
502
- let typesGenerated = null
503
- try {
504
- typesGenerated = await runGenerateEndpointTypes(rootDir, targetProjectId)
505
- } catch (e) {
506
- typesGenerated = { ok: false, error: e.message }
528
+ let typesGenerated = dry_run ? { ok: true, skipped: true, reason: "dry_run" } : null
529
+ if (!dry_run) {
530
+ try {
531
+ typesGenerated = await runGenerateEndpointTypes(rootDir, targetProjectId)
532
+ } catch (e) {
533
+ typesGenerated = { ok: false, error: e.message }
534
+ }
507
535
  }
508
536
 
509
537
  let local
@@ -594,6 +622,9 @@ export const dypaiPushTool = {
594
622
  ? null
595
623
  : await checkpointBackendSource(targetProjectId, rootDir, plan, remote)
596
624
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
625
+ const sourceBaseline = !dry_run && !sourceCheckpointFailed
626
+ ? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
627
+ : undefined
597
628
  return {
598
629
  success: !sourceCheckpointFailed,
599
630
  applied: false,
@@ -602,6 +633,7 @@ export const dypaiPushTool = {
602
633
  plan,
603
634
  types: typesGenerated,
604
635
  source_checkpoint: sourceCheckpoint || undefined,
636
+ source_baseline: sourceBaseline,
605
637
  hint: sourceCheckpointFailed
606
638
  ? "Backend runtime was unchanged, but DYPAI could not save the local dypai/ source to the Studio branch. Retry dypai_push once the GitHub/source checkpoint issue is resolved."
607
639
  : undefined,
@@ -725,7 +757,7 @@ export const dypaiPushTool = {
725
757
  ]
726
758
 
727
759
  // How many ops landed in draft state. When `draftCount > 0` the user
728
- // must run dypai_deploy_production(confirm:true) to expose the changes
760
+ // must publish drafts explicitly to expose the changes
729
761
  // on live; tests against live won't see them yet. Realtime drafts are
730
762
  // also counted — the API returns `applied_to: "draft"` and a
731
763
  // `drafts_queued` array when realtime policies were staged.
@@ -752,6 +784,9 @@ export const dypaiPushTool = {
752
784
  reason: "push_had_errors",
753
785
  }
754
786
  const sourceCheckpointFailed = sourceCheckpoint?.ok === false && sourceCheckpoint?.skipped !== true
787
+ const sourceBaseline = errors.length === 0 && !sourceCheckpointFailed
788
+ ? await refreshBackendSourceBaseline(targetProjectId, rootDir, sourceCheckpoint)
789
+ : undefined
755
790
 
756
791
  return {
757
792
  success: errors.length === 0 && !sourceCheckpointFailed,
@@ -786,6 +821,7 @@ export const dypaiPushTool = {
786
821
  errors: errors.length ? errors : undefined,
787
822
  types: typesGenerated,
788
823
  source_checkpoint: sourceCheckpoint,
824
+ source_baseline: sourceBaseline,
789
825
  // Only one next_step — and only when it's non-obvious. Drafts win
790
826
  // over the test suggestion because tests against live won't see
791
827
  // the change until the drafts are promoted.
@@ -794,7 +830,7 @@ export const dypaiPushTool = {
794
830
  ? `${endpointTotal} endpoint(s) saved, ${failedTotal} failed. Fix failed endpoints and push again. Failed: ${errors.slice(0, 3).map((item) => item.endpoint || item.group || item.op).join(", ")}${errors.length > 3 ? "…" : ""}`
795
831
  : "Fix the offending YAMLs and push again."
796
832
  : draftCount > 0
797
- ? `${draftCount} backend change(s) saved to preview — they're not live yet. Verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') or ask the user to test the preview. Publish with dypai_deploy_production(confirm:true) ONLY after explicit approval.`
833
+ ? `${draftCount} backend change(s) saved as drafts — they're not live yet. Review with manage_drafts(operation:'list'), verify with dypai_test_endpoint(mode:'draft', endpoint:'<name>') when useful, then publish with manage_drafts(operation:'publish', confirm:true) or dypai_deploy_production(target:'backend', confirm:true) ONLY after explicit approval.`
798
834
  : changedNames.length
799
835
  ? `Test changed endpoint(s) with dypai_test_endpoint: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
800
836
  : undefined,
@@ -0,0 +1,217 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdir, readFile, readdir, stat, writeFile } from "fs/promises"
3
+ import { dirname, join } from "path"
4
+
5
+ const BACKEND_SOURCE_DIRS = ["flows", "automations", "endpoints", "migrations", "types", "lib"]
6
+ const BACKEND_SOURCE_EXACT = [
7
+ "schema.sql",
8
+ "realtime.yaml",
9
+ "credentials.yaml",
10
+ "node-catalog.json",
11
+ "capability-catalog.json",
12
+ "capability-brief.md",
13
+ "dypai.config.yaml",
14
+ ]
15
+ const BACKEND_SOURCE_EXTS = new Set([
16
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
17
+ ".json", ".yaml", ".yml", ".sql", ".md", ".txt",
18
+ ])
19
+
20
+ function extOfPath(path) {
21
+ const filename = path.split("/").pop() || path
22
+ return filename.includes(".") ? `.${filename.split(".").pop().toLowerCase()}` : ""
23
+ }
24
+
25
+ function isTextBackendPath(path) {
26
+ return BACKEND_SOURCE_EXTS.has(extOfPath(path))
27
+ }
28
+
29
+ function normalizePath(path) {
30
+ return String(path || "").replace(/\\/g, "/").replace(/^\/+/, "")
31
+ }
32
+
33
+ export function isTrackedBackendSourcePath(path) {
34
+ const normalized = normalizePath(path)
35
+ if (!normalized.startsWith("dypai/")) return false
36
+ if (normalized.startsWith("dypai/.dypai/")) return false
37
+ const rel = normalized.slice("dypai/".length)
38
+ if (BACKEND_SOURCE_EXACT.includes(rel)) return true
39
+ return BACKEND_SOURCE_DIRS.some((dir) => rel.startsWith(`${dir}/`)) && isTextBackendPath(rel)
40
+ }
41
+
42
+ function hashContent(content) {
43
+ return createHash("sha256").update(content).digest("hex")
44
+ }
45
+
46
+ async function addFile(files, absPath, relPath) {
47
+ if (!isTextBackendPath(relPath)) return
48
+ const content = await readFile(absPath, "utf8")
49
+ files.push({
50
+ path: `dypai/${relPath}`,
51
+ content,
52
+ sha256: hashContent(content),
53
+ })
54
+ }
55
+
56
+ async function walkDir(files, absDir, relBase) {
57
+ let entries = []
58
+ try {
59
+ entries = await readdir(absDir, { withFileTypes: true })
60
+ } catch {
61
+ return
62
+ }
63
+ for (const entry of entries) {
64
+ if (entry.name.startsWith(".")) continue
65
+ const relPath = `${relBase}/${entry.name}`.replace(/^\/+/, "")
66
+ const absPath = join(absDir, entry.name)
67
+ if (entry.isDirectory()) {
68
+ await walkDir(files, absPath, relPath)
69
+ } else if (entry.isFile()) {
70
+ await addFile(files, absPath, relPath)
71
+ }
72
+ }
73
+ }
74
+
75
+ export async function collectBackendSourceFiles(rootDir) {
76
+ const files = []
77
+
78
+ for (const relPath of BACKEND_SOURCE_EXACT) {
79
+ const absPath = join(rootDir, relPath)
80
+ try {
81
+ const s = await stat(absPath)
82
+ if (s.isFile()) await addFile(files, absPath, relPath)
83
+ } catch {
84
+ // optional
85
+ }
86
+ }
87
+
88
+ for (const dir of BACKEND_SOURCE_DIRS) {
89
+ await walkDir(files, join(rootDir, dir), dir)
90
+ }
91
+
92
+ files.sort((a, b) => a.path.localeCompare(b.path))
93
+ return files
94
+ }
95
+
96
+ function normalizeBaseline(raw) {
97
+ const parsed = JSON.parse(raw)
98
+ const files = parsed?.files
99
+ const result = new Map()
100
+ if (Array.isArray(files)) {
101
+ for (const file of files) {
102
+ if (typeof file?.path === "string" && typeof file?.sha256 === "string" && isTrackedBackendSourcePath(file.path)) {
103
+ result.set(file.path, file.sha256)
104
+ }
105
+ }
106
+ } else if (files && typeof files === "object") {
107
+ for (const [path, value] of Object.entries(files)) {
108
+ if (!isTrackedBackendSourcePath(path)) continue
109
+ if (typeof value === "string") result.set(path, value)
110
+ else if (value && typeof value === "object" && typeof value.sha256 === "string") result.set(path, value.sha256)
111
+ }
112
+ }
113
+ return result
114
+ }
115
+
116
+ export async function readBackendSourceBaseline(rootDir) {
117
+ try {
118
+ const raw = await readFile(join(rootDir, ".dypai", "backend-baseline.json"), "utf8")
119
+ return {
120
+ ok: true,
121
+ baseline: normalizeBaseline(raw),
122
+ }
123
+ } catch (error) {
124
+ if (error?.code === "ENOENT") {
125
+ return {
126
+ ok: false,
127
+ missing: true,
128
+ baseline: new Map(),
129
+ }
130
+ }
131
+ return {
132
+ ok: false,
133
+ error: error?.message || String(error),
134
+ baseline: new Map(),
135
+ }
136
+ }
137
+ }
138
+
139
+ export async function diffBackendSource(rootDir) {
140
+ const currentFiles = await collectBackendSourceFiles(rootDir)
141
+ const current = new Map(currentFiles.map((file) => [file.path, file.sha256]))
142
+ const baselineResult = await readBackendSourceBaseline(rootDir)
143
+
144
+ if (!baselineResult.ok && !baselineResult.missing) {
145
+ return {
146
+ ok: false,
147
+ reason: "baseline_parse_error",
148
+ error: baselineResult.error,
149
+ }
150
+ }
151
+
152
+ if (baselineResult.missing) {
153
+ return {
154
+ ok: true,
155
+ baseline: false,
156
+ files: {
157
+ created: currentFiles.map((file) => file.path),
158
+ updated: [],
159
+ deleted: [],
160
+ },
161
+ summary: {
162
+ create: currentFiles.length,
163
+ update: 0,
164
+ delete: 0,
165
+ unchanged: 0,
166
+ },
167
+ }
168
+ }
169
+
170
+ const baseline = baselineResult.baseline
171
+ const created = []
172
+ const updated = []
173
+ const deleted = []
174
+ const unchanged = []
175
+
176
+ for (const [path, hash] of current) {
177
+ if (!baseline.has(path)) created.push(path)
178
+ else if (baseline.get(path) !== hash) updated.push(path)
179
+ else unchanged.push(path)
180
+ }
181
+ for (const path of baseline.keys()) {
182
+ if (!current.has(path)) deleted.push(path)
183
+ }
184
+
185
+ return {
186
+ ok: true,
187
+ baseline: true,
188
+ files: {
189
+ created,
190
+ updated,
191
+ deleted,
192
+ },
193
+ summary: {
194
+ create: created.length,
195
+ update: updated.length,
196
+ delete: deleted.length,
197
+ unchanged: unchanged.length,
198
+ },
199
+ }
200
+ }
201
+
202
+ export async function writeBackendSourceBaseline(rootDir, projectId = null) {
203
+ const files = await collectBackendSourceFiles(rootDir)
204
+ const payload = {
205
+ synced_at: new Date().toISOString(),
206
+ ...(projectId ? { project_id: projectId } : {}),
207
+ files: files.map((file) => ({
208
+ path: file.path,
209
+ sha256: file.sha256,
210
+ })),
211
+ }
212
+ const target = join(rootDir, ".dypai", "backend-baseline.json")
213
+ await mkdir(dirname(target), { recursive: true })
214
+ await writeFile(target, JSON.stringify(payload, null, 2) + "\n", "utf8")
215
+ return payload
216
+ }
217
+