@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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dypai_diff — preview what dypai_push would change. Read-only, safe.
3
+ */
4
+
5
+ import { resolve as resolvePath } from "path"
6
+ import { fetchRemoteState, readLocalState, readLocalStateSnapshot, readLocalConfig, computePlan } from "./planner.js"
7
+
8
+ export const dypaiDiffTool = {
9
+ name: "dypai_diff",
10
+ description:
11
+ "Compare the local ./dypai/ folder against the remote project state without applying anything. " +
12
+ "Returns a plan: endpoints to create, update (with changed fields), delete, and unchanged. " +
13
+ "Always call this before dypai_push to preview changes safely.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ project_id: {
18
+ type: "string",
19
+ description: "Project UUID. Optional if your token is project-scoped.",
20
+ },
21
+ root_dir: {
22
+ type: "string",
23
+ description: "Root of the dypai/ folder (default: ./dypai).",
24
+ default: "./dypai",
25
+ },
26
+ delete_orphans: {
27
+ type: "boolean",
28
+ description: "If true, endpoints present in remote but missing locally are queued for deletion. Default: false (safe).",
29
+ default: false,
30
+ },
31
+ },
32
+ },
33
+
34
+ async execute({ project_id, root_dir = "./dypai", delete_orphans = false } = {}) {
35
+ const rootDir = resolvePath(process.cwd(), root_dir)
36
+
37
+ const config = await readLocalConfig(rootDir)
38
+ const targetProjectId = project_id || config?.project_id || null
39
+
40
+ const [local, remote, stateSnapshot] = await Promise.all([
41
+ readLocalState(rootDir),
42
+ fetchRemoteState(targetProjectId),
43
+ readLocalStateSnapshot(rootDir),
44
+ ])
45
+
46
+ if (local.errors.length) {
47
+ return {
48
+ success: false,
49
+ phase: "read_local",
50
+ errors: local.errors,
51
+ hint: "Fix the YAML/file errors before running diff again.",
52
+ }
53
+ }
54
+
55
+ const plan = computePlan(local, remote, remote.mapsCtx, {
56
+ deleteOrphans: delete_orphans,
57
+ stateSnapshot,
58
+ })
59
+
60
+ const totalChanges = plan.create.length + plan.update.length + plan.delete.length
61
+ const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
62
+
63
+ return {
64
+ success: true,
65
+ summary: {
66
+ create: plan.create.length,
67
+ update: plan.update.length,
68
+ delete: plan.delete.length,
69
+ unchanged: plan.unchanged.length,
70
+ orphans_ignored: plan.orphansIgnored?.length || 0,
71
+ warnings: plan.warnings?.length || 0,
72
+ },
73
+ // Only include plan details when there are actual changes or warnings
74
+ plan: (totalChanges > 0 || plan.warnings?.length) ? plan : undefined,
75
+ // Conflicts and missing creds deserve surfacing; no-op diffs don't
76
+ hint: hasConflicts
77
+ ? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
78
+ : (plan.warnings || []).some(w => w.type === "missing_credential")
79
+ ? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
80
+ : undefined,
81
+ }
82
+ },
83
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Git-first source-of-truth tooling for DYPAI projects.
3
+ *
4
+ * Architecture:
5
+ * transforms.js — bidirectional field transforms (symmetric by construction)
6
+ * codec.js — endpoint-level serialize / deserialize (structural shape)
7
+ * pull.js — DB → filesystem (uses codec.serializeEndpoint)
8
+ * push.js — filesystem → DB (uses codec.deserializeEndpoint) [pending]
9
+ * diff.js — compares both sides without writing [pending]
10
+ */
11
+
12
+ export { dypaiPullTool } from "./pull.js"
13
+ export { dypaiDiffTool } from "./diff.js"
14
+ export { dypaiPushTool } from "./push.js"
15
+ export { dypaiDescribeTool } from "./describe.js"
16
+ export { dypaiValidateTool } from "./validate.js"
17
+ export { dypaiTestTool } from "./test.js"
18
+ export { dypaiTestEndpointTool } from "./test-endpoint.js"
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Planner — the brain behind diff and push.
3
+ *
4
+ * Reads local YAML + referenced files from disk, fetches the remote DB state,
5
+ * and produces a plan: { create, update, delete, unchanged }.
6
+ *
7
+ * Both sides are normalized through the same codec so canvas, default
8
+ * trigger blocks, and other noise don't show up as false-positive diffs.
9
+ */
10
+
11
+ import { readFile, readdir } from "fs/promises"
12
+ import { join } from "path"
13
+ import YAML from "yaml"
14
+ import { proxyToolCall } from "../proxy.js"
15
+ import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
16
+
17
+ // ─── Remote ────────────────────────────────────────────────────────────────
18
+
19
+ async function execSql(projectId, sql) {
20
+ const args = projectId ? { project_id: projectId, sql } : { sql }
21
+ const result = await proxyToolCall("execute_sql", args)
22
+ if (result?.error) throw new Error(`SQL error: ${result.error}`)
23
+ if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
24
+ return result.rows
25
+ }
26
+
27
+ function parseMaybeJson(v) {
28
+ if (v == null || typeof v !== "string") return v
29
+ try { return JSON.parse(v) } catch { return v }
30
+ }
31
+
32
+ function hydrateRow(row) {
33
+ return {
34
+ ...row,
35
+ workflow_code: parseMaybeJson(row.workflow_code) || {},
36
+ input: parseMaybeJson(row.input),
37
+ output: parseMaybeJson(row.output),
38
+ allowed_roles: parseMaybeJson(row.allowed_roles) || row.allowed_roles || [],
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Load .dypai/state.json if it exists. Used for conflict detection on push.
44
+ */
45
+ export async function readLocalStateSnapshot(rootDir) {
46
+ try {
47
+ const raw = await readFile(join(rootDir, ".dypai", "state.json"), "utf8")
48
+ return JSON.parse(raw)
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load dypai.config.yaml if it exists. This is the committed identity of the
56
+ * project (project_id, project_name). Used to auto-resolve project_id and to
57
+ * guard against pushing to the wrong project by accident.
58
+ */
59
+ export async function readLocalConfig(rootDir) {
60
+ try {
61
+ const raw = await readFile(join(rootDir, "dypai.config.yaml"), "utf8")
62
+ return YAML.parse(raw) || null
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Load dypai/realtime.yaml if it exists. Returns { tables: {...}, channels: {...} }
70
+ * flattened into a list of { target_type, target_name, ...policy } rows so the
71
+ * push/diff paths can treat them uniformly.
72
+ */
73
+ export async function readLocalRealtime(rootDir) {
74
+ try {
75
+ const raw = await readFile(join(rootDir, "realtime.yaml"), "utf8")
76
+ const doc = YAML.parse(raw) || {}
77
+ const rows = []
78
+ for (const [name, p] of Object.entries(doc.tables || {})) {
79
+ rows.push(normalizePolicy("table", name, p))
80
+ }
81
+ for (const [name, p] of Object.entries(doc.channels || {})) {
82
+ rows.push(normalizePolicy("channel", name, p))
83
+ }
84
+ return { rows, doc }
85
+ } catch {
86
+ return null
87
+ }
88
+ }
89
+
90
+ function normalizePolicy(target_type, target_name, p) {
91
+ return {
92
+ target_type,
93
+ target_name,
94
+ subscribe_filter: p?.subscribe_filter ?? null,
95
+ events: Array.isArray(p?.events) ? p.events : null,
96
+ required_role: p?.required_role ?? null,
97
+ auth_required: p?.auth_required !== false, // default true
98
+ }
99
+ }
100
+
101
+ /** Fetch remote realtime policies from the DB. Tolerates the table not existing. */
102
+ export async function fetchRemoteRealtime(projectId) {
103
+ try {
104
+ return await execSql(projectId, `
105
+ SELECT target_type, target_name, subscribe_filter,
106
+ events, required_role, auth_required
107
+ FROM system.realtime_policies
108
+ ORDER BY target_type, target_name
109
+ `)
110
+ } catch {
111
+ return null // engine doesn't have the table yet
112
+ }
113
+ }
114
+
115
+ export async function fetchRemoteState(projectId) {
116
+ const [endpoints, credentials, groups] = await Promise.all([
117
+ execSql(projectId, `
118
+ SELECT id, name, method, description, workflow_code, input, output,
119
+ allowed_roles, is_tool, tool_description, group_id, updated_at
120
+ FROM system.endpoints
121
+ WHERE is_active = true
122
+ ORDER BY name
123
+ `),
124
+ execSql(projectId, "SELECT id, name, type FROM system.credentials"),
125
+ execSql(projectId, "SELECT id, name FROM system.endpoints_group"),
126
+ ])
127
+
128
+ const mapsCtx = {
129
+ credIdToName: Object.fromEntries(credentials.map(c => [c.id, c.name])),
130
+ groupIdToName: Object.fromEntries(groups.map(g => [g.id, g.name])),
131
+ endpointIdToName: Object.fromEntries(endpoints.map(e => [e.id, e.name])),
132
+ credNameToId: Object.fromEntries(credentials.map(c => [c.name, c.id])),
133
+ groupNameToId: Object.fromEntries(groups.map(g => [g.name, g.id])),
134
+ endpointNameToId: Object.fromEntries(endpoints.map(e => [e.name, e.id])),
135
+ }
136
+
137
+ const byName = {}
138
+ for (const raw of endpoints) {
139
+ byName[raw.name] = hydrateRow(raw)
140
+ }
141
+ return { byName, mapsCtx }
142
+ }
143
+
144
+ // ─── Local ─────────────────────────────────────────────────────────────────
145
+
146
+ /**
147
+ * Walk a doc finding all `*_file` string fields. Used to pre-read contents.
148
+ */
149
+ function collectFileRefs(doc) {
150
+ const refs = new Set()
151
+ const walk = (node) => {
152
+ if (!node || typeof node !== "object") return
153
+ if (Array.isArray(node)) return node.forEach(walk)
154
+ for (const [k, v] of Object.entries(node)) {
155
+ if (typeof v === "string" && k.endsWith("_file")) refs.add(v)
156
+ else walk(v)
157
+ }
158
+ }
159
+ walk(doc)
160
+ return Array.from(refs)
161
+ }
162
+
163
+ /**
164
+ * Walk endpoints/ recursively. The first-level subfolder name (if any) encodes
165
+ * the endpoint's group. Files directly under endpoints/ have no group.
166
+ *
167
+ * endpoints/foo.yaml → no group
168
+ * endpoints/Admin/foo.yaml → group "Admin"
169
+ * endpoints/Admin/Users/foo.yaml → group "Admin" (deeper nesting ignored for now)
170
+ */
171
+ async function findEndpointYamls(endpointsDir) {
172
+ const out = [] // { relFromEndpoints, group }
173
+ async function walk(dir, relFromRoot, group) {
174
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
175
+ for (const e of entries) {
176
+ if (e.isDirectory()) {
177
+ // Only the first-level folder is the group; deeper is flattened into it
178
+ const sub = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
179
+ await walk(join(dir, e.name), sub, group || e.name)
180
+ } else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
181
+ const rel = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
182
+ out.push({ rel, group })
183
+ }
184
+ }
185
+ }
186
+ await walk(endpointsDir, "", null)
187
+ return out
188
+ }
189
+
190
+ export async function readLocalState(rootDir) {
191
+ const endpointsDir = join(rootDir, "endpoints")
192
+ const yamls = await findEndpointYamls(endpointsDir)
193
+
194
+ const byName = {}
195
+ const errors = []
196
+
197
+ for (const { rel, group } of yamls) {
198
+ try {
199
+ const raw = await readFile(join(endpointsDir, rel), "utf8")
200
+ const doc = YAML.parse(raw)
201
+ if (!doc?.name) {
202
+ errors.push({ file: rel, error: "missing 'name' field" })
203
+ continue
204
+ }
205
+
206
+ // Inject group from folder if present and not already set in the YAML
207
+ if (group && !doc.group) doc.group = group
208
+
209
+ // Pre-read all *_file references (paths are relative to dypai/ root)
210
+ const refs = collectFileRefs(doc)
211
+ const contents = await Promise.all(
212
+ refs.map(p => readFile(join(rootDir, p), "utf8").catch(() => {
213
+ errors.push({ file: rel, error: `referenced file not found: ${p}` })
214
+ return ""
215
+ }))
216
+ )
217
+ const fileMap = Object.fromEntries(refs.map((p, i) => [p, contents[i]]))
218
+
219
+ byName[doc.name] = { doc, fileMap }
220
+ } catch (e) {
221
+ errors.push({ file: rel, error: e.message })
222
+ }
223
+ }
224
+
225
+ return { byName, errors }
226
+ }
227
+
228
+ // ─── Normalization + diff ──────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Produce a canonical (fully-hydrated, DB-shaped) row from a local {doc, fileMap}.
232
+ * This is what we'd send to update_endpoint.
233
+ */
234
+ export function localToCanonical(entry, mapsCtx) {
235
+ const { doc, fileMap } = entry
236
+ const ctx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
237
+ return deserializeEndpoint(doc, ctx)
238
+ }
239
+
240
+ /**
241
+ * Inline *_file refs back into a doc using a path→content map. Mutates doc.
242
+ */
243
+ function inlineFileRefs(node, fileMap) {
244
+ if (!node || typeof node !== "object") return
245
+ if (Array.isArray(node)) {
246
+ node.forEach(n => inlineFileRefs(n, fileMap))
247
+ return
248
+ }
249
+ const inlines = []
250
+ for (const [k, v] of Object.entries(node)) {
251
+ if (typeof v === "string" && k.endsWith("_file") && fileMap[v] !== undefined) {
252
+ inlines.push(k)
253
+ } else {
254
+ inlineFileRefs(v, fileMap)
255
+ }
256
+ }
257
+ for (const k of inlines) {
258
+ const inlineKey = k.replace(/_file$/, "")
259
+ node[inlineKey] = fileMap[node[k]]
260
+ delete node[k]
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Canonical form for diff: doc with file contents inlined back in. Both sides
266
+ * are brought to this shape so file contents are part of the comparison
267
+ * (otherwise identical file paths would mask content changes).
268
+ */
269
+ function remoteToComparable(row, mapsCtx) {
270
+ const { doc, extractedFiles } = serializeEndpoint(row, mapsCtx)
271
+ const fileMap = Object.fromEntries(extractedFiles.map(f => [f.path, f.content]))
272
+ inlineFileRefs(doc, fileMap)
273
+ return doc
274
+ }
275
+
276
+ function localToComparable(entry, mapsCtx) {
277
+ const row = localToCanonical(entry, mapsCtx)
278
+ return remoteToComparable(row, mapsCtx)
279
+ }
280
+
281
+ function deepEqual(a, b) {
282
+ if (a === b) return true
283
+ if (typeof a !== typeof b) return false
284
+ if (a === null || b === null) return a === b
285
+ if (Array.isArray(a) !== Array.isArray(b)) return false
286
+ if (Array.isArray(a)) {
287
+ if (a.length !== b.length) return false
288
+ return a.every((x, i) => deepEqual(x, b[i]))
289
+ }
290
+ if (typeof a === "object") {
291
+ const ka = Object.keys(a).sort()
292
+ const kb = Object.keys(b).sort()
293
+ if (ka.join(",") !== kb.join(",")) return false
294
+ return ka.every(k => deepEqual(a[k], b[k]))
295
+ }
296
+ return false
297
+ }
298
+
299
+ /**
300
+ * Return a list of top-level field paths that differ between two docs.
301
+ * Used for human-readable diff summaries.
302
+ */
303
+ function summarizeFieldDiffs(a, b, prefix = "") {
304
+ const diffs = []
305
+ const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})])
306
+ for (const k of keys) {
307
+ const va = a?.[k]
308
+ const vb = b?.[k]
309
+ if (deepEqual(va, vb)) continue
310
+ const path = prefix ? `${prefix}.${k}` : k
311
+ if (va && vb && typeof va === "object" && typeof vb === "object" && !Array.isArray(va) && !Array.isArray(vb)) {
312
+ diffs.push(...summarizeFieldDiffs(va, vb, path))
313
+ } else {
314
+ diffs.push(path)
315
+ }
316
+ }
317
+ return diffs
318
+ }
319
+
320
+ /**
321
+ * Find credential references in a doc and return a list of any that don't
322
+ * resolve against the remote credNameToId map.
323
+ */
324
+ function findMissingCredentials(doc, credNameToId) {
325
+ const missing = new Set()
326
+ const walk = (node) => {
327
+ if (!node || typeof node !== "object") return
328
+ if (Array.isArray(node)) return node.forEach(walk)
329
+ for (const [k, v] of Object.entries(node)) {
330
+ if (k === "credential" && typeof v === "string" && !credNameToId[v]) {
331
+ missing.add(v)
332
+ } else {
333
+ walk(v)
334
+ }
335
+ }
336
+ }
337
+ walk(doc)
338
+ return Array.from(missing)
339
+ }
340
+
341
+ /**
342
+ * Detect conflicts using a prior snapshot (.dypai/state.json). If an endpoint
343
+ * was pulled at time T and its remote updated_at is now > T, someone modified
344
+ * it out-of-band since the pull. Report these so the user can pull first.
345
+ */
346
+ function detectConflicts(stateSnapshot, remoteByName) {
347
+ if (!stateSnapshot?.endpoints) return []
348
+ const conflicts = []
349
+ for (const [name, snap] of Object.entries(stateSnapshot.endpoints)) {
350
+ const remote = remoteByName[name]
351
+ if (!remote) continue
352
+ const snapT = snap.updated_at ? new Date(snap.updated_at).getTime() : 0
353
+ const remoteT = remote.updated_at ? new Date(remote.updated_at).getTime() : 0
354
+ if (remoteT > snapT + 1000) { // 1s tolerance for clock skew
355
+ conflicts.push({
356
+ endpoint: name,
357
+ snapshot_at: snap.updated_at,
358
+ remote_updated_at: remote.updated_at,
359
+ })
360
+ }
361
+ }
362
+ return conflicts
363
+ }
364
+
365
+ /**
366
+ * Compute the plan to reconcile remote state with local.
367
+ */
368
+ export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, stateSnapshot = null } = {}) {
369
+ const plan = { create: [], update: [], delete: [], unchanged: [] }
370
+ const warnings = []
371
+
372
+ for (const name of Object.keys(local.byName)) {
373
+ const entry = local.byName[name]
374
+ const localCanonical = localToComparable(entry, mapsCtx)
375
+ const remoteRow = remote.byName[name]
376
+
377
+ // Credentials check: warn if a YAML references a credential name missing remotely
378
+ const missing = findMissingCredentials(entry.doc, mapsCtx.credNameToId || {})
379
+ for (const credName of missing) {
380
+ warnings.push({
381
+ type: "missing_credential",
382
+ endpoint: name,
383
+ credential: credName,
384
+ hint: `Create it in the dashboard (same name) before pushing, or push will fail at runtime.`,
385
+ })
386
+ }
387
+
388
+ if (!remoteRow) {
389
+ plan.create.push({ name, fields: Object.keys(localCanonical) })
390
+ continue
391
+ }
392
+
393
+ const remoteCanonical = remoteToComparable(remoteRow, mapsCtx)
394
+ if (deepEqual(localCanonical, remoteCanonical)) {
395
+ plan.unchanged.push(name)
396
+ } else {
397
+ const changedFields = summarizeFieldDiffs(localCanonical, remoteCanonical)
398
+ plan.update.push({ name, changedFields })
399
+ }
400
+ }
401
+
402
+ if (deleteOrphans) {
403
+ for (const name of Object.keys(remote.byName)) {
404
+ if (!local.byName[name]) plan.delete.push({ name })
405
+ }
406
+ } else {
407
+ plan.orphansIgnored = Object.keys(remote.byName).filter(n => !local.byName[n])
408
+ }
409
+
410
+ // Conflict detection (remote changed since last pull)
411
+ const conflicts = detectConflicts(stateSnapshot, remote.byName)
412
+ if (conflicts.length) {
413
+ for (const c of conflicts) {
414
+ warnings.push({
415
+ type: "remote_changed_since_pull",
416
+ endpoint: c.endpoint,
417
+ snapshot_at: c.snapshot_at,
418
+ remote_updated_at: c.remote_updated_at,
419
+ hint: "Run dypai_pull to refresh local state before pushing (otherwise you may overwrite remote changes).",
420
+ })
421
+ }
422
+ }
423
+
424
+ if (warnings.length) plan.warnings = warnings
425
+ return plan
426
+ }