@dypai-ai/mcp 1.6.15 → 1.6.17
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/generated/serverInstructions.js +3 -3
- package/src/index.js +18 -7
- package/src/lib/backendSnapshot.js +8 -0
- package/src/lib/effective-workflows-runner.js +6 -3
- package/src/toolProfiles.js +19 -4
- package/src/tools/deploy.js +2 -4
- package/src/tools/frontend.js +53 -23
- package/src/tools/project-deploy-production.js +210 -0
- package/src/tools/project-push.js +221 -0
- package/src/tools/sql-guard.js +1 -1
- package/src/tools/sync/diff.js +28 -130
- package/src/tools/sync/planner.js +20 -1
- package/src/tools/sync/pull.js +38 -15
- package/src/tools/sync/push.js +49 -13
- package/src/tools/sync/source-diff.js +217 -0
- package/src/tools/sync/test-endpoint.js +4 -4
- package/src/tools/sync.js +2 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs"
|
|
2
|
+
import { basename, dirname, join, resolve as resolvePath } from "path"
|
|
3
|
+
import YAML from "yaml"
|
|
4
|
+
import { deployFromSource } from "./deploy.js"
|
|
5
|
+
import { proxyToolCall } from "./proxy.js"
|
|
6
|
+
import { resolveOutDir } from "./sync/path-resolver.js"
|
|
7
|
+
|
|
8
|
+
function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
|
|
9
|
+
if (workspace_root) {
|
|
10
|
+
const workspaceRoot = resolvePath(workspace_root)
|
|
11
|
+
return { workspaceRoot, rootDir: resolvePath(workspaceRoot, "dypai"), source: "workspace_root" }
|
|
12
|
+
}
|
|
13
|
+
if (sourceDirectory) {
|
|
14
|
+
const workspaceRoot = resolvePath(sourceDirectory)
|
|
15
|
+
return { workspaceRoot, rootDir: resolvePath(workspaceRoot, "dypai"), source: "sourceDirectory" }
|
|
16
|
+
}
|
|
17
|
+
const resolved = resolveOutDir(root_dir || "./dypai")
|
|
18
|
+
const rootDir = resolved.path
|
|
19
|
+
const workspaceRoot = basename(rootDir) === "dypai" ? dirname(rootDir) : rootDir
|
|
20
|
+
return { workspaceRoot, rootDir, source: `root_dir:${resolved.source}` }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readProjectIdFromConfig(rootDir) {
|
|
24
|
+
const configPath = join(rootDir, "dypai.config.yaml")
|
|
25
|
+
if (!existsSync(configPath)) return null
|
|
26
|
+
try {
|
|
27
|
+
const doc = YAML.parse(readFileSync(configPath, "utf8"))
|
|
28
|
+
return typeof doc?.project_id === "string" && doc.project_id.trim()
|
|
29
|
+
? doc.project_id.trim()
|
|
30
|
+
: null
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function draftCount(result) {
|
|
37
|
+
if (!result || result.error) return null
|
|
38
|
+
if (Number.isFinite(result.total)) return result.total
|
|
39
|
+
if (Array.isArray(result.drafts)) return result.drafts.length
|
|
40
|
+
if (Array.isArray(result.items)) return result.items.length
|
|
41
|
+
if (Array.isArray(result.resources)) return result.resources.length
|
|
42
|
+
return 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isSuccessful(result) {
|
|
46
|
+
return result && result.success !== false && !result.error
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const dypaiDeployProductionTool = {
|
|
50
|
+
name: "dypai_deploy_production",
|
|
51
|
+
description:
|
|
52
|
+
"PUBLISH PRODUCTION — make the saved DYPAI project live. Requires confirm:true. " +
|
|
53
|
+
"By default publishes/merges pending backend drafts first, then deploys the frontend to production. " +
|
|
54
|
+
"Use target:'backend' for backend-only Flow/Automation releases; use target:'frontend' only when backend drafts must stay pending. " +
|
|
55
|
+
"Use only after the user explicitly approves going live. For saving work without production, use dypai_push.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
project_id: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
|
|
62
|
+
},
|
|
63
|
+
workspace_root: {
|
|
64
|
+
type: "string",
|
|
65
|
+
description: "Absolute project root containing package.json and dypai/. Optional when cwd/env can resolve it.",
|
|
66
|
+
},
|
|
67
|
+
sourceDirectory: {
|
|
68
|
+
type: "string",
|
|
69
|
+
description: "Alias for workspace_root. Temporary: production frontend deploy still uses local source until the API supports deploying directly from Studio HEAD.",
|
|
70
|
+
},
|
|
71
|
+
root_dir: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Backend dypai/ folder used to infer workspace/project. Default ./dypai.",
|
|
74
|
+
default: "./dypai",
|
|
75
|
+
},
|
|
76
|
+
force: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
description: "Frontend only. Re-send all files even if the local manifest says nothing changed.",
|
|
79
|
+
default: false,
|
|
80
|
+
},
|
|
81
|
+
target: {
|
|
82
|
+
type: "string",
|
|
83
|
+
enum: ["all", "backend", "frontend"],
|
|
84
|
+
description: "What to publish. all = backend drafts + frontend production. backend = publish only pending backend drafts. frontend = deploy only frontend source.",
|
|
85
|
+
default: "all",
|
|
86
|
+
},
|
|
87
|
+
confirm: {
|
|
88
|
+
type: "boolean",
|
|
89
|
+
description: "Required true. This publishes production/live.",
|
|
90
|
+
default: false,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ["confirm"],
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async execute({ project_id, workspace_root, sourceDirectory, root_dir = "./dypai", force = false, target = "all", confirm = false } = {}) {
|
|
97
|
+
const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
|
|
98
|
+
const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
|
|
99
|
+
const publishTarget = ["all", "backend", "frontend"].includes(target) ? target : "all"
|
|
100
|
+
const publishBackend = publishTarget === "all" || publishTarget === "backend"
|
|
101
|
+
const deployFrontend = publishTarget === "all" || publishTarget === "frontend"
|
|
102
|
+
|
|
103
|
+
if (confirm !== true) {
|
|
104
|
+
return {
|
|
105
|
+
confirmation_required: true,
|
|
106
|
+
live_changed: false,
|
|
107
|
+
summary:
|
|
108
|
+
publishTarget === "backend"
|
|
109
|
+
? "This will publish pending backend drafts to PRODUCTION without deploying frontend. Get explicit user approval, then call again with confirm:true."
|
|
110
|
+
: publishTarget === "frontend"
|
|
111
|
+
? "This will deploy the frontend to PRODUCTION without publishing pending backend drafts. Get explicit user approval, then call again with confirm:true."
|
|
112
|
+
: "This will publish pending backend drafts and deploy the frontend to PRODUCTION. Get explicit user approval, then call again with confirm:true.",
|
|
113
|
+
next_call: {
|
|
114
|
+
tool: "dypai_deploy_production",
|
|
115
|
+
...(targetProjectId ? { project_id: targetProjectId } : {}),
|
|
116
|
+
workspace_root: workspaceRoot,
|
|
117
|
+
...(force ? { force: true } : {}),
|
|
118
|
+
...(publishTarget !== "all" ? { target: publishTarget } : {}),
|
|
119
|
+
confirm: true,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!targetProjectId) {
|
|
125
|
+
return {
|
|
126
|
+
success: false,
|
|
127
|
+
error: "project_id is required. Pass project_id or run from a workspace containing dypai/dypai.config.yaml.",
|
|
128
|
+
workspace_root: workspaceRoot,
|
|
129
|
+
root_dir: rootDir,
|
|
130
|
+
resolved_via: source,
|
|
131
|
+
live_changed: false,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let pendingDrafts = 0
|
|
136
|
+
let backend = {
|
|
137
|
+
skipped: true,
|
|
138
|
+
reason: publishBackend ? "no_pending_drafts" : "target_frontend",
|
|
139
|
+
pending_drafts: 0,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (publishBackend) {
|
|
143
|
+
const draftsList = await proxyToolCall("manage_drafts", {
|
|
144
|
+
project_id: targetProjectId,
|
|
145
|
+
operation: "list",
|
|
146
|
+
})
|
|
147
|
+
pendingDrafts = draftCount(draftsList)
|
|
148
|
+
if (pendingDrafts == null) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
phase: "list_backend_drafts",
|
|
152
|
+
target: publishTarget,
|
|
153
|
+
backend: draftsList,
|
|
154
|
+
frontend: { skipped: true, reason: "could_not_list_backend_drafts" },
|
|
155
|
+
live_changed: false,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (pendingDrafts > 0) {
|
|
160
|
+
backend = await proxyToolCall("manage_drafts", {
|
|
161
|
+
project_id: targetProjectId,
|
|
162
|
+
operation: "publish",
|
|
163
|
+
confirm: true,
|
|
164
|
+
})
|
|
165
|
+
if (!isSuccessful(backend)) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
phase: "publish_backend_drafts",
|
|
169
|
+
target: publishTarget,
|
|
170
|
+
backend,
|
|
171
|
+
frontend: { skipped: true, reason: "backend_publish_failed" },
|
|
172
|
+
live_changed: false,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let frontend = {
|
|
179
|
+
skipped: true,
|
|
180
|
+
reason: deployFrontend ? "no_local_package_json" : "target_backend",
|
|
181
|
+
note: deployFrontend
|
|
182
|
+
? "Temporary limitation: frontend production deploy currently needs local sourceDirectory/workspace_root. API deploy-from-Studio-HEAD is the next backend change."
|
|
183
|
+
: undefined,
|
|
184
|
+
}
|
|
185
|
+
if (deployFrontend && existsSync(join(workspaceRoot, "package.json"))) {
|
|
186
|
+
frontend = await deployFromSource({
|
|
187
|
+
sourceDirectory: workspaceRoot,
|
|
188
|
+
project_id: targetProjectId,
|
|
189
|
+
force: !!force,
|
|
190
|
+
target: "both",
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const frontendOk = frontend.skipped === true || isSuccessful(frontend)
|
|
195
|
+
return {
|
|
196
|
+
success: frontendOk,
|
|
197
|
+
project_id: targetProjectId,
|
|
198
|
+
workspace_root: workspaceRoot,
|
|
199
|
+
target: publishTarget,
|
|
200
|
+
backend,
|
|
201
|
+
frontend,
|
|
202
|
+
backend_published: pendingDrafts > 0 && isSuccessful(backend),
|
|
203
|
+
frontend_deploy_queued: frontendOk && frontend.skipped !== true,
|
|
204
|
+
live_changed: frontendOk && (pendingDrafts > 0 || frontend.skipped !== true),
|
|
205
|
+
next_step: frontendOk && frontend.skipped !== true
|
|
206
|
+
? "Production deploy was queued. Check the production URL/build status until the build reaches success or failure."
|
|
207
|
+
: undefined,
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs"
|
|
2
|
+
import { basename, dirname, join, resolve as resolvePath } from "path"
|
|
3
|
+
import YAML from "yaml"
|
|
4
|
+
import { deployFromSource } from "./deploy.js"
|
|
5
|
+
import { dypaiPushTool as backendPushTool } from "./sync/push.js"
|
|
6
|
+
import { resolveOutDir } from "./sync/path-resolver.js"
|
|
7
|
+
|
|
8
|
+
function resolveWorkspace({ workspace_root, sourceDirectory, root_dir } = {}) {
|
|
9
|
+
if (workspace_root) {
|
|
10
|
+
const workspaceRoot = resolvePath(workspace_root)
|
|
11
|
+
return {
|
|
12
|
+
workspaceRoot,
|
|
13
|
+
rootDir: resolvePath(workspaceRoot, "dypai"),
|
|
14
|
+
source: "workspace_root",
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (sourceDirectory) {
|
|
19
|
+
const workspaceRoot = resolvePath(sourceDirectory)
|
|
20
|
+
return {
|
|
21
|
+
workspaceRoot,
|
|
22
|
+
rootDir: resolvePath(workspaceRoot, "dypai"),
|
|
23
|
+
source: "sourceDirectory",
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resolved = resolveOutDir(root_dir || "./dypai")
|
|
28
|
+
const rootDir = resolved.path
|
|
29
|
+
const workspaceRoot = basename(rootDir) === "dypai" ? dirname(rootDir) : rootDir
|
|
30
|
+
return { workspaceRoot, rootDir, source: `root_dir:${resolved.source}` }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isSuccessful(result) {
|
|
34
|
+
return result && result.success !== false && !result.error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizeLegacyShipText(value) {
|
|
38
|
+
if (typeof value === "string") {
|
|
39
|
+
return value
|
|
40
|
+
.replace(/\bmanage_frontend\b/g, "dypai_push")
|
|
41
|
+
.replace(/Frontend code is not affected[^.]*\./g, "Frontend source is saved by the project-level dypai_push wrapper.")
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) return value.map(sanitizeLegacyShipText)
|
|
44
|
+
if (value && typeof value === "object") {
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
Object.entries(value).map(([key, nested]) => [key, sanitizeLegacyShipText(nested)]),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readProjectIdFromConfig(rootDir) {
|
|
53
|
+
const configPath = join(rootDir, "dypai.config.yaml")
|
|
54
|
+
if (!existsSync(configPath)) return null
|
|
55
|
+
try {
|
|
56
|
+
const doc = YAML.parse(readFileSync(configPath, "utf8"))
|
|
57
|
+
return typeof doc?.project_id === "string" && doc.project_id.trim()
|
|
58
|
+
? doc.project_id.trim()
|
|
59
|
+
: null
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const dypaiPushProjectTool = {
|
|
66
|
+
name: "dypai_push",
|
|
67
|
+
description:
|
|
68
|
+
"SAVE CHANGES — push all local DYPAI project changes to Studio/DYPAI without publishing production. " +
|
|
69
|
+
"Backend files under dypai/ are staged as drafts. Frontend files under src/, public/, package.json, and versionable dypai/ source are saved to the Studio branch. " +
|
|
70
|
+
"Production is never changed by this tool. Use dypai_deploy_production(confirm:true) after explicit user approval to publish live.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
project_id: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Project UUID. Auto-injected from dypai.config.yaml when omitted.",
|
|
77
|
+
},
|
|
78
|
+
workspace_root: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Absolute project root containing package.json and/or dypai/. Optional; inferred from root_dir/cwd when omitted.",
|
|
81
|
+
},
|
|
82
|
+
sourceDirectory: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "Alias for workspace_root, kept for compatibility with older frontend calls.",
|
|
85
|
+
},
|
|
86
|
+
root_dir: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "Backend dypai/ folder. Default ./dypai. Prefer workspace_root for new calls.",
|
|
89
|
+
default: "./dypai",
|
|
90
|
+
},
|
|
91
|
+
delete_orphans: {
|
|
92
|
+
type: "boolean",
|
|
93
|
+
description: "Backend only. If true, endpoints in remote but missing locally get deleted as drafts. Default: false.",
|
|
94
|
+
default: false,
|
|
95
|
+
},
|
|
96
|
+
dry_run: {
|
|
97
|
+
type: "boolean",
|
|
98
|
+
description: "If true, computes backend plan and does not write backend or frontend.",
|
|
99
|
+
default: false,
|
|
100
|
+
},
|
|
101
|
+
force: {
|
|
102
|
+
type: "boolean",
|
|
103
|
+
description: "Backend conflict override and frontend full save. Use only when needed. Default: false.",
|
|
104
|
+
default: false,
|
|
105
|
+
},
|
|
106
|
+
skip_validation: {
|
|
107
|
+
type: "boolean",
|
|
108
|
+
description: "Backend only. Skip dypai_validate pre-flight. Default: false.",
|
|
109
|
+
default: false,
|
|
110
|
+
},
|
|
111
|
+
save_frontend: {
|
|
112
|
+
type: "boolean",
|
|
113
|
+
description: "If false, only pushes backend drafts. Default: true.",
|
|
114
|
+
default: true,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
required: [],
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async execute({
|
|
121
|
+
project_id,
|
|
122
|
+
workspace_root,
|
|
123
|
+
sourceDirectory,
|
|
124
|
+
root_dir = "./dypai",
|
|
125
|
+
delete_orphans = false,
|
|
126
|
+
dry_run = false,
|
|
127
|
+
force = false,
|
|
128
|
+
skip_validation = false,
|
|
129
|
+
save_frontend = true,
|
|
130
|
+
} = {}) {
|
|
131
|
+
const { workspaceRoot, rootDir, source } = resolveWorkspace({ workspace_root, sourceDirectory, root_dir })
|
|
132
|
+
const targetProjectId = project_id || readProjectIdFromConfig(rootDir)
|
|
133
|
+
const hasBackend = existsSync(rootDir)
|
|
134
|
+
const hasFrontend = existsSync(join(workspaceRoot, "package.json"))
|
|
135
|
+
|
|
136
|
+
if (!hasBackend && !hasFrontend) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
error: "No DYPAI project source found. Expected package.json and/or dypai/.",
|
|
140
|
+
workspace_root: workspaceRoot,
|
|
141
|
+
root_dir: rootDir,
|
|
142
|
+
resolved_via: source,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!targetProjectId) {
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: "project_id is required. Pass project_id or run from a workspace containing dypai/dypai.config.yaml.",
|
|
149
|
+
workspace_root: workspaceRoot,
|
|
150
|
+
root_dir: rootDir,
|
|
151
|
+
resolved_via: source,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let backend = {
|
|
156
|
+
skipped: true,
|
|
157
|
+
reason: hasBackend ? "dry_run_or_disabled" : "no_dypai_folder",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hasBackend) {
|
|
161
|
+
backend = sanitizeLegacyShipText(await backendPushTool.execute({
|
|
162
|
+
project_id: targetProjectId,
|
|
163
|
+
root_dir: rootDir,
|
|
164
|
+
delete_orphans,
|
|
165
|
+
dry_run,
|
|
166
|
+
force,
|
|
167
|
+
skip_validation,
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!isSuccessful(backend)) {
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
phase: "backend_push",
|
|
175
|
+
workspace_root: workspaceRoot,
|
|
176
|
+
root_dir: rootDir,
|
|
177
|
+
backend,
|
|
178
|
+
frontend: {
|
|
179
|
+
skipped: true,
|
|
180
|
+
reason: "backend_push_failed",
|
|
181
|
+
},
|
|
182
|
+
live_changed: false,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let frontend = {
|
|
187
|
+
skipped: true,
|
|
188
|
+
reason: dry_run
|
|
189
|
+
? "dry_run"
|
|
190
|
+
: !save_frontend
|
|
191
|
+
? "save_frontend_false"
|
|
192
|
+
: hasFrontend
|
|
193
|
+
? "not_run"
|
|
194
|
+
: "no_package_json",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!dry_run && save_frontend && hasFrontend) {
|
|
198
|
+
frontend = await deployFromSource({
|
|
199
|
+
sourceDirectory: workspaceRoot,
|
|
200
|
+
project_id: targetProjectId,
|
|
201
|
+
force: !!force,
|
|
202
|
+
target: "studio",
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const frontendOk = frontend.skipped === true || isSuccessful(frontend)
|
|
207
|
+
return {
|
|
208
|
+
success: frontendOk,
|
|
209
|
+
workspace_root: workspaceRoot,
|
|
210
|
+
root_dir: rootDir,
|
|
211
|
+
backend,
|
|
212
|
+
frontend,
|
|
213
|
+
backend_saved: hasBackend && isSuccessful(backend),
|
|
214
|
+
frontend_saved: hasFrontend && frontendOk && frontend.skipped !== true,
|
|
215
|
+
live_changed: false,
|
|
216
|
+
next_step: frontendOk
|
|
217
|
+
? "Changes are saved to Studio/DYPAI. Test the preview. To publish live, use dypai_deploy_production(confirm:true) after explicit user approval."
|
|
218
|
+
: "Frontend save failed. Fix the frontend/source issue and run dypai_push again.",
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
}
|
package/src/tools/sql-guard.js
CHANGED
|
@@ -129,7 +129,7 @@ export function validateSql(sql, opts = {}) {
|
|
|
129
129
|
: schema === "storage"
|
|
130
130
|
? "To manage files, use manage_storage. Read-only SELECTs against storage.* are allowed."
|
|
131
131
|
: schema === "system"
|
|
132
|
-
? "To manage endpoints, use dypai_push /
|
|
132
|
+
? "To manage endpoints, use dypai_push / dypai_deploy_production / manage_users / manage_roles. Read-only SELECTs against system.* are allowed."
|
|
133
133
|
: "Read-only SELECTs against this schema are allowed.",
|
|
134
134
|
}
|
|
135
135
|
}
|
package/src/tools/sync/diff.js
CHANGED
|
@@ -1,26 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dypai_diff — preview what dypai_push would change. Read-only, safe.
|
|
3
|
-
*
|
|
4
|
-
* The picture has THREE layers, not two:
|
|
5
|
-
* - LOCAL : what's on disk in dypai/
|
|
6
|
-
* - DRAFT : what's already been staged via prior pushes (system.config_drafts)
|
|
7
|
-
* - LIVE : what the engine actually serves at <project_id>.dypai.dev
|
|
8
|
-
*
|
|
9
|
-
* The diff body reports LOCAL → EFFECTIVE_REMOTE (live + pending drafts) —
|
|
10
|
-
* what `dypai_push` will actually queue. We additionally surface the DRAFT
|
|
11
|
-
* layer metadata so the agent can spot overlap and publish/discard decisions.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
1
|
import { resolve as resolvePath } from "path"
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
2
|
+
import { readLocalConfig } from "./planner.js"
|
|
3
|
+
import { diffBackendSource } from "./source-diff.js"
|
|
17
4
|
|
|
18
5
|
export const dypaiDiffTool = {
|
|
19
6
|
name: "dypai_diff",
|
|
20
7
|
description:
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
8
|
+
"Fast source diff for the local ./dypai/ folder. " +
|
|
9
|
+
"It does not compile, call remote services, or stage changes. " +
|
|
10
|
+
"Use dypai_push to compile, validate, save backend drafts, and regenerate types.",
|
|
24
11
|
inputSchema: {
|
|
25
12
|
type: "object",
|
|
26
13
|
properties: {
|
|
@@ -35,138 +22,49 @@ export const dypaiDiffTool = {
|
|
|
35
22
|
},
|
|
36
23
|
delete_orphans: {
|
|
37
24
|
type: "boolean",
|
|
38
|
-
description: "
|
|
25
|
+
description: "Ignored by dypai_diff. Deletions are only applied by dypai_push when explicitly requested.",
|
|
39
26
|
default: false,
|
|
40
27
|
},
|
|
41
28
|
},
|
|
42
29
|
},
|
|
43
30
|
|
|
44
|
-
async execute({ project_id, root_dir = "./dypai"
|
|
31
|
+
async execute({ project_id, root_dir = "./dypai" } = {}) {
|
|
45
32
|
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
46
33
|
|
|
47
34
|
const config = await readLocalConfig(rootDir)
|
|
48
35
|
const targetProjectId = project_id || config?.project_id || null
|
|
36
|
+
const diff = await diffBackendSource(rootDir)
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
readLocalEffectiveState(rootDir, targetProjectId),
|
|
52
|
-
fetchRemoteState(targetProjectId),
|
|
53
|
-
readLocalStateSnapshot(rootDir),
|
|
54
|
-
// Pending drafts. Cheap on dev (always 0); on prod surfaces what's
|
|
55
|
-
// already staged so the agent can reason about overlap with the local
|
|
56
|
-
// change set. Old engines without the drafts API silently fall back.
|
|
57
|
-
proxyToolCall("manage_drafts", targetProjectId
|
|
58
|
-
? { project_id: targetProjectId, operation: "list" }
|
|
59
|
-
: { operation: "list" }
|
|
60
|
-
).catch(() => ({ total: 0, drafts: [], _unavailable: true })),
|
|
61
|
-
])
|
|
62
|
-
|
|
63
|
-
if (local.errors.length) {
|
|
64
|
-
return {
|
|
65
|
-
success: false,
|
|
66
|
-
phase: "read_local",
|
|
67
|
-
errors: local.errors,
|
|
68
|
-
hint: "Fix the YAML/file errors before running diff again.",
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (!remote || !remote.mapsCtx) {
|
|
38
|
+
if (!diff.ok) {
|
|
73
39
|
return {
|
|
74
40
|
success: false,
|
|
75
|
-
phase: "
|
|
76
|
-
|
|
41
|
+
phase: "source_diff",
|
|
42
|
+
reason: diff.reason,
|
|
43
|
+
error: diff.error,
|
|
44
|
+
hint: "Fix the local dypai/.dypai/backend-baseline.json or run dypai_pull/sync again.",
|
|
77
45
|
}
|
|
78
46
|
}
|
|
79
47
|
|
|
80
|
-
const
|
|
81
|
-
const plan = computePlan(local, effectiveRemote, remote.mapsCtx, {
|
|
82
|
-
deleteOrphans: delete_orphans,
|
|
83
|
-
stateSnapshot,
|
|
84
|
-
liveRemote: remote,
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
const groupChanges = (plan.groups?.create?.length || 0) + (plan.groups?.delete?.length || 0)
|
|
88
|
-
const totalChanges = plan.create.length + plan.update.length + plan.delete.length + groupChanges
|
|
89
|
-
const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
|
|
90
|
-
|
|
91
|
-
// ─── Drafts overlay ───────────────────────────────────────────────────
|
|
92
|
-
// Build a `pending_drafts` view that tells the agent what's already
|
|
93
|
-
// staged AND highlights the overlap with this diff's local-change set.
|
|
94
|
-
// The overlap is the agent-relevant signal: same endpoint touched on
|
|
95
|
-
// BOTH sides means pushing now will overwrite the existing draft.
|
|
96
|
-
const draftItems = Array.isArray(draftsResult?.drafts) ? draftsResult.drafts : []
|
|
97
|
-
const draftCount = draftsResult?.total || 0
|
|
98
|
-
|
|
99
|
-
const localChangeNames = new Set([
|
|
100
|
-
...plan.create.map(p => p.name),
|
|
101
|
-
...plan.update.map(p => p.name),
|
|
102
|
-
...plan.delete.map(p => p.name),
|
|
103
|
-
])
|
|
104
|
-
|
|
105
|
-
const overlap = draftItems
|
|
106
|
-
.filter(d => d.resource_type === "endpoint" && localChangeNames.has(d.resource_name))
|
|
107
|
-
.map(d => ({
|
|
108
|
-
endpoint: d.resource_name,
|
|
109
|
-
existing_draft_op: d.op,
|
|
110
|
-
local_change_op: plan.create.find(p => p.name === d.resource_name) ? "create"
|
|
111
|
-
: plan.update.find(p => p.name === d.resource_name) ? "update"
|
|
112
|
-
: "delete",
|
|
113
|
-
}))
|
|
114
|
-
|
|
115
|
-
const pendingDrafts = draftCount > 0 ? {
|
|
116
|
-
total: draftCount,
|
|
117
|
-
counts_by_type: draftsResult?.counts_by_type || {},
|
|
118
|
-
items: draftItems
|
|
119
|
-
.map(d => ({
|
|
120
|
-
resource_type: d.resource_type,
|
|
121
|
-
resource_name: d.resource_name,
|
|
122
|
-
op: d.op,
|
|
123
|
-
}))
|
|
124
|
-
.sort((a, b) => (a.resource_name || "").localeCompare(b.resource_name || "")),
|
|
125
|
-
// Endpoints that are BOTH staged AND modified locally — pushing again
|
|
126
|
-
// replaces the existing draft. Empty array = no overlap (clean signal).
|
|
127
|
-
overlap_with_local: overlap,
|
|
128
|
-
} : null
|
|
129
|
-
|
|
130
|
-
// ─── Hint priority ────────────────────────────────────────────────────
|
|
131
|
-
// 1. Conflicts and missing creds always win (block push).
|
|
132
|
-
// 2. Overlap is next: explicitly tell the agent that re-push overwrites.
|
|
133
|
-
// 3. Pending drafts (no overlap): suggest publishing or discarding before
|
|
134
|
-
// stacking more on top.
|
|
135
|
-
const hint = hasConflicts
|
|
136
|
-
? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
|
|
137
|
-
: (plan.warnings || []).some(w => w.type === "missing_credential")
|
|
138
|
-
? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
|
|
139
|
-
: overlap.length > 0
|
|
140
|
-
? `${overlap.length} endpoint(s) have BOTH a pending draft AND a local change — dypai_push will REPLACE the existing draft with the new version. Review pending_drafts.overlap_with_local before pushing.`
|
|
141
|
-
: totalChanges === 0 && draftCount > 0
|
|
142
|
-
? `${draftCount} pending draft(s) — local matches what's already staged. Test with dypai_test_endpoint(mode:'draft'), then manage_drafts(operation:'publish', confirm:true) when ready.`
|
|
143
|
-
: draftCount > 0 && totalChanges > 0
|
|
144
|
-
? `${draftCount} draft(s) already pending; this diff would queue ${totalChanges} more. Consider manage_drafts(operation:'publish'|'discard', confirm:true) first to keep the staged set focused.`
|
|
145
|
-
: draftCount > 0
|
|
146
|
-
? `${draftCount} pending draft(s) — no local changes to push. Run manage_drafts(operation:'list') to review, then 'publish' (confirm:true) or 'discard' (confirm:true).`
|
|
147
|
-
: undefined
|
|
48
|
+
const totalChanges = diff.summary.create + diff.summary.update + diff.summary.delete
|
|
148
49
|
|
|
149
50
|
return {
|
|
150
51
|
success: true,
|
|
52
|
+
mode: "source",
|
|
53
|
+
project_id: targetProjectId,
|
|
54
|
+
baseline: diff.baseline,
|
|
151
55
|
summary: {
|
|
152
|
-
create:
|
|
153
|
-
update:
|
|
154
|
-
delete:
|
|
155
|
-
unchanged:
|
|
156
|
-
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
157
|
-
groups_create: plan.groups?.create?.length || 0,
|
|
158
|
-
groups_delete: plan.groups?.delete?.length || 0,
|
|
159
|
-
groups_unchanged: plan.groups?.unchanged?.length || 0,
|
|
160
|
-
warnings: plan.warnings?.length || 0,
|
|
161
|
-
pending_drafts: draftCount,
|
|
162
|
-
drafts_overlap_with_local: overlap.length,
|
|
56
|
+
create: diff.summary.create,
|
|
57
|
+
update: diff.summary.update,
|
|
58
|
+
delete: diff.summary.delete,
|
|
59
|
+
unchanged: diff.summary.unchanged,
|
|
163
60
|
},
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
61
|
+
source_changes: totalChanges > 0 ? diff.files : undefined,
|
|
62
|
+
requires_push: totalChanges > 0,
|
|
63
|
+
requires_compile: totalChanges > 0,
|
|
64
|
+
pending_drafts: null,
|
|
65
|
+
hint: totalChanges > 0
|
|
66
|
+
? "Local backend source changed. Run dypai_push to compile, validate, save preview drafts, and regenerate types."
|
|
67
|
+
: "No local backend source changes. Nothing to push.",
|
|
170
68
|
}
|
|
171
69
|
},
|
|
172
70
|
}
|
|
@@ -40,6 +40,15 @@ function parseMaybeJson(v) {
|
|
|
40
40
|
try { return JSON.parse(v) } catch { return v }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function isBackendSourcePath(path) {
|
|
44
|
+
if (typeof path !== "string") return false
|
|
45
|
+
return (
|
|
46
|
+
(path.startsWith("dypai/flows/") && /\.flow\.ts$/i.test(path)) ||
|
|
47
|
+
(path.startsWith("dypai/automations/") && /\.automation\.ts$/i.test(path)) ||
|
|
48
|
+
(path.startsWith("dypai/endpoints/") && /\.(ya?ml)$/i.test(path))
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
function hydrateRow(row) {
|
|
44
53
|
return {
|
|
45
54
|
...row,
|
|
@@ -453,6 +462,7 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
|
|
|
453
462
|
|
|
454
463
|
const payloads = Array.isArray(bulkResult.data?.payloads) ? bulkResult.data.payloads : []
|
|
455
464
|
const diagnostics = Array.isArray(bulkResult.data?.diagnostics) ? bulkResult.data.diagnostics : []
|
|
465
|
+
const rejected = Array.isArray(bulkResult.data?.rejected) ? bulkResult.data.rejected : []
|
|
456
466
|
const byName = {}
|
|
457
467
|
const shadowed = []
|
|
458
468
|
const flowNames = new Set()
|
|
@@ -484,12 +494,21 @@ export async function readLocalEffectiveState(rootDir, projectId = null, deps =
|
|
|
484
494
|
})
|
|
485
495
|
}
|
|
486
496
|
|
|
497
|
+
for (const item of rejected) {
|
|
498
|
+
if (!isBackendSourcePath(item?.path)) continue
|
|
499
|
+
yamlState.errors.push({
|
|
500
|
+
file: item.path,
|
|
501
|
+
error: `Backend compiler rejected source file: ${item.reason || "rejected"}`,
|
|
502
|
+
rule: "backend_compiler_rejected_source",
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
487
506
|
for (const [name, entry] of Object.entries(yamlState.byName)) {
|
|
488
507
|
if (flowNames.has(name)) continue
|
|
489
508
|
byName[name] = { ...entry, source: "legacy-yaml" }
|
|
490
509
|
}
|
|
491
510
|
|
|
492
|
-
return { byName, errors: yamlState.errors, shadowed, runnerWarning: null }
|
|
511
|
+
return { byName, errors: yamlState.errors, shadowed, runnerWarning: null, automations: bulkResult.data?.automations ?? null }
|
|
493
512
|
}
|
|
494
513
|
|
|
495
514
|
// ─── Normalization + diff ──────────────────────────────────────────────────
|