@dypai-ai/mcp 1.3.1 → 1.4.1
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/api.js +21 -3
- package/src/index.js +401 -346
- package/src/tools/deploy.js +450 -103
- package/src/tools/storage.js +289 -0
- package/src/tools/sync/transforms.js +32 -20
- package/src/tools/sync/validate.js +6 -1
- package/src/tools/sync.js +23 -1
package/src/tools/deploy.js
CHANGED
|
@@ -1,109 +1,383 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* deploy_frontend —
|
|
2
|
+
* deploy_frontend — delta deploy engine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Like InsForge/Vercel, uses SHA-256 hashing + a local manifest to send only
|
|
5
|
+
* the files that actually changed since the last deploy. Typical second deploy
|
|
6
|
+
* goes from ~30 MB to a few KB.
|
|
7
|
+
*
|
|
8
|
+
* Manifest lives at `dypai/.dypai/deploy-manifest.json`:
|
|
9
|
+
* { "src/app/page.tsx": "a1b2c3...", "public/logo.png": "d4e5f6..." }
|
|
10
|
+
*
|
|
11
|
+
* Flow:
|
|
12
|
+
* 1. Walk directory, hash each accepted file (SHA-256)
|
|
13
|
+
* 2. Diff against manifest → new, changed, unchanged, deleted
|
|
14
|
+
* 3. If nothing changed → skip deploy ("No changes detected")
|
|
15
|
+
* 4. Send only new+changed files + deleted_paths to API (incremental mode)
|
|
16
|
+
* 5. API commits via GitHub Trees API with base_tree (preserves unchanged)
|
|
17
|
+
* 6. Update local manifest
|
|
18
|
+
*
|
|
19
|
+
* Files > 25 MB → surfaced in `assets_requiring_action` with upload_file cmd
|
|
7
20
|
*/
|
|
8
21
|
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
22
|
+
import { createHash } from "crypto"
|
|
23
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from "fs"
|
|
24
|
+
import { join, basename, dirname, resolve } from "path"
|
|
11
25
|
import { api } from "../api.js"
|
|
12
26
|
|
|
27
|
+
// ─── Limits ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
13
29
|
const MAX_SOURCE_SIZE = 50 * 1024 * 1024
|
|
30
|
+
const MAX_BUNDLED_FILE = 25 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
// ─── Directories to skip ────────────────────────────────────────────────────
|
|
14
33
|
|
|
15
|
-
// Directories to skip — build outputs, caches, package managers
|
|
16
34
|
const IGNORE_DIRS = new Set([
|
|
17
35
|
"node_modules", ".git",
|
|
18
|
-
// Build outputs
|
|
19
36
|
"dist", "build", "out", ".output", ".vercel", ".netlify",
|
|
20
|
-
// Framework caches
|
|
21
37
|
".next", ".nuxt", ".svelte-kit", ".astro", ".angular", ".docusaurus",
|
|
22
38
|
".cache", ".turbo", ".vite", ".parcel-cache", ".wrangler",
|
|
23
|
-
// Test / misc
|
|
24
39
|
"coverage", "storybook-static", "__pycache__", ".idea", ".vscode",
|
|
25
40
|
])
|
|
26
41
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
42
|
+
// ─── Accepted file extensions ───────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const CODE_EXTS = new Set([
|
|
30
45
|
".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
|
|
31
|
-
// Styles
|
|
32
46
|
".css", ".scss", ".less", ".sass", ".styl",
|
|
33
|
-
// Markup / templates
|
|
34
47
|
".html", ".htm",
|
|
35
|
-
// Framework-specific SFCs
|
|
36
48
|
".vue", ".svelte", ".astro",
|
|
37
|
-
// Data / config
|
|
38
49
|
".json", ".toml", ".yaml", ".yml",
|
|
39
|
-
// Content
|
|
40
50
|
".md", ".mdx", ".txt",
|
|
41
|
-
// Assets metadata
|
|
42
|
-
".svg", ".ico", ".webmanifest",
|
|
43
|
-
// Schema / query
|
|
44
51
|
".graphql", ".gql", ".prisma",
|
|
45
|
-
// Misc
|
|
46
52
|
".xml", ".csv",
|
|
47
53
|
])
|
|
48
54
|
|
|
49
|
-
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".svg"])
|
|
50
|
-
const
|
|
51
|
-
const
|
|
55
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".svg", ".bmp", ".tiff"])
|
|
56
|
+
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".mkv", ".avi"])
|
|
57
|
+
const AUDIO_EXTS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac"])
|
|
58
|
+
const DOC_EXTS = new Set([".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"])
|
|
59
|
+
const FONT_EXTS = new Set([".woff", ".woff2", ".ttf", ".otf", ".eot"])
|
|
60
|
+
const ARCHIVE_EXTS = new Set([".zip", ".gz", ".tar", ".rar", ".7z"])
|
|
61
|
+
const MISC_STATIC_EXTS = new Set([".webmanifest", ".lottie"])
|
|
62
|
+
|
|
63
|
+
const ALL_ACCEPTED_EXTS = new Set([
|
|
64
|
+
...CODE_EXTS, ...IMAGE_EXTS, ...VIDEO_EXTS, ...AUDIO_EXTS,
|
|
65
|
+
...DOC_EXTS, ...FONT_EXTS, ...ARCHIVE_EXTS, ...MISC_STATIC_EXTS,
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
const CODE_SEARCH_EXTS = new Set([
|
|
69
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cjs", ".cts",
|
|
70
|
+
".vue", ".svelte", ".astro",
|
|
71
|
+
".html", ".htm", ".css", ".scss", ".less",
|
|
72
|
+
".json", ".md", ".mdx",
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const BLOCKED = new Set([
|
|
76
|
+
".env", ".env.local", ".env.production", ".env.development",
|
|
77
|
+
".DS_Store", "Thumbs.db",
|
|
78
|
+
])
|
|
52
79
|
|
|
53
|
-
// Root-level config files to always include (regex)
|
|
54
80
|
const ROOT_CONFIG_RE = /^(package\.json|package-lock\.json|pnpm-lock\.yaml|bun\.lockb|yarn\.lock|vite\.config|vitest\.config|tsconfig|tailwind\.config|postcss\.config|next\.config|vinext\.config|astro\.config|nuxt\.config|svelte\.config|remix\.config|gatsby-config|angular\.json|docusaurus\.config|wrangler\.toml|wrangler\.json|vercel\.json|netlify\.toml|turbo\.json|components\.json|uno\.config|eslint\.config|prettier\.config|jest\.config|playwright\.config|\.prettierrc|\.eslintrc|\.browserslistrc|\.nvmrc|\.node-version|index\.html)/
|
|
55
81
|
|
|
82
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function extOf(entry) {
|
|
85
|
+
return entry.includes(".") ? `.${entry.split(".").pop().toLowerCase()}` : ""
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatBytes(n) {
|
|
89
|
+
if (n < 1024) return `${n} B`
|
|
90
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
91
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatMb(bytes) {
|
|
95
|
+
return +(bytes / (1024 * 1024)).toFixed(1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sha256(buf) {
|
|
99
|
+
return createHash("sha256").update(buf).digest("hex")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mediaCategory(ext) {
|
|
103
|
+
if (IMAGE_EXTS.has(ext)) return "image"
|
|
104
|
+
if (VIDEO_EXTS.has(ext)) return "video"
|
|
105
|
+
if (AUDIO_EXTS.has(ext)) return "audio"
|
|
106
|
+
if (DOC_EXTS.has(ext)) return "document"
|
|
107
|
+
if (FONT_EXTS.has(ext)) return "font"
|
|
108
|
+
if (ARCHIVE_EXTS.has(ext)) return "archive"
|
|
109
|
+
return "other"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Deploy manifest ────────────────────────────────────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// Tracks SHA-256 of each file at last successful deploy.
|
|
115
|
+
// Path: <sourceDirectory>/dypai/.dypai/deploy-manifest.json
|
|
116
|
+
//
|
|
117
|
+
// Format: { "src/app/page.tsx": "a1b2c3...", ... }
|
|
118
|
+
|
|
119
|
+
function manifestPath(sourceDirectory) {
|
|
120
|
+
const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
|
|
121
|
+
if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "deploy-manifest.json")
|
|
122
|
+
return join(sourceDirectory, ".dypai", "deploy-manifest.json")
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readDeployManifest(sourceDirectory) {
|
|
126
|
+
const path = manifestPath(sourceDirectory)
|
|
127
|
+
if (!existsSync(path)) return null
|
|
128
|
+
try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeDeployManifest(sourceDirectory, manifest) {
|
|
132
|
+
const path = manifestPath(sourceDirectory)
|
|
133
|
+
const dir = dirname(path)
|
|
134
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
135
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Media manifest (for bucket-uploaded files) ─────────────────────────────
|
|
139
|
+
|
|
140
|
+
function mediaManifestPath(sourceDirectory) {
|
|
141
|
+
const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
|
|
142
|
+
if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "media-manifest.json")
|
|
143
|
+
return join(sourceDirectory, ".dypai", "media-manifest.json")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readMediaManifest(sourceDirectory) {
|
|
147
|
+
const path = mediaManifestPath(sourceDirectory)
|
|
148
|
+
if (!existsSync(path)) return {}
|
|
149
|
+
try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function updateMediaManifest(sourceDirectory, localPath, entry) {
|
|
153
|
+
const path = mediaManifestPath(sourceDirectory)
|
|
154
|
+
const dir = dirname(path)
|
|
155
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
156
|
+
const manifest = existsSync(path)
|
|
157
|
+
? (() => { try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return {} } })()
|
|
158
|
+
: {}
|
|
159
|
+
manifest[localPath] = entry
|
|
160
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function removeFromMediaManifest(sourceDirectory, localPath) {
|
|
164
|
+
const path = mediaManifestPath(sourceDirectory)
|
|
165
|
+
if (!existsSync(path)) return
|
|
166
|
+
try {
|
|
167
|
+
const manifest = JSON.parse(readFileSync(path, "utf-8"))
|
|
168
|
+
delete manifest[localPath]
|
|
169
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n")
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Collection ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function classifySkip(path, ext, size) {
|
|
176
|
+
if (ALL_ACCEPTED_EXTS.has(ext) && size > MAX_BUNDLED_FILE) {
|
|
177
|
+
return {
|
|
178
|
+
reason_code: "file_too_large",
|
|
179
|
+
reason_human: `${mediaCategory(ext)} file is ${formatMb(size)} MB (max ${formatMb(MAX_BUNDLED_FILE)} MB per file). Upload to the 'public' storage bucket instead.`,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (ext && ![".lock", ".log"].includes(ext) && !ALL_ACCEPTED_EXTS.has(ext)) {
|
|
183
|
+
return {
|
|
184
|
+
reason_code: "unsupported_file_type",
|
|
185
|
+
reason_human: `Files of type ${ext} aren't recognized. Upload to the 'public' storage bucket if needed.`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Walk directory. Returns:
|
|
193
|
+
* allFiles: [{ path, content (base64), hash (sha256) }]
|
|
194
|
+
* skipped: [{ local_path, ext, size_bytes, ... }]
|
|
195
|
+
* total: bytes of accepted files
|
|
196
|
+
* hashMap: { path: sha256 }
|
|
197
|
+
* textByPath: Map<path, string>
|
|
198
|
+
*/
|
|
56
199
|
function collectSource(dir) {
|
|
57
|
-
const
|
|
200
|
+
const allFiles = []
|
|
58
201
|
const skipped = []
|
|
202
|
+
const hashMap = {}
|
|
203
|
+
const textByPath = new Map()
|
|
59
204
|
let total = 0
|
|
60
205
|
|
|
61
206
|
function walk(d, rel) {
|
|
62
207
|
if (total > MAX_SOURCE_SIZE) return
|
|
63
|
-
|
|
208
|
+
let entries
|
|
209
|
+
try { entries = readdirSync(d) } catch { return }
|
|
210
|
+
for (const entry of entries) {
|
|
64
211
|
if (IGNORE_DIRS.has(entry)) continue
|
|
65
|
-
if (entry.startsWith(".") && statSync(join(d, entry)).isDirectory()) continue
|
|
66
|
-
if (BLOCKED.has(entry)) continue
|
|
67
212
|
const full = join(d, entry)
|
|
68
|
-
const path = rel ? `${rel}/${entry}` : entry
|
|
69
213
|
try {
|
|
70
214
|
const stat = statSync(full)
|
|
71
|
-
if (stat.isDirectory()) {
|
|
215
|
+
if (stat.isDirectory()) {
|
|
216
|
+
if (entry.startsWith(".")) continue
|
|
217
|
+
walk(full, rel ? `${rel}/${entry}` : entry)
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
72
220
|
if (!stat.isFile()) continue
|
|
73
|
-
|
|
74
|
-
|
|
221
|
+
} catch { continue }
|
|
222
|
+
if (BLOCKED.has(entry)) continue
|
|
223
|
+
|
|
224
|
+
const path = rel ? `${rel}/${entry}` : entry
|
|
225
|
+
try {
|
|
226
|
+
const stat = statSync(full)
|
|
227
|
+
const ext = extOf(entry)
|
|
228
|
+
|
|
229
|
+
let ok = false
|
|
75
230
|
if (!rel && ROOT_CONFIG_RE.test(entry)) ok = true
|
|
76
|
-
if (
|
|
231
|
+
else if (CODE_EXTS.has(ext)) ok = true
|
|
232
|
+
else if (ALL_ACCEPTED_EXTS.has(ext) && stat.size <= MAX_BUNDLED_FILE) ok = true
|
|
77
233
|
|
|
78
|
-
// Track skipped files so the user knows what was excluded
|
|
79
234
|
if (!ok) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
skipped.push({ path, reason: `Unsupported file type (${ext})` })
|
|
235
|
+
const classified = classifySkip(path, ext, stat.size)
|
|
236
|
+
if (classified) {
|
|
237
|
+
skipped.push({ local_path: path, ext, size_bytes: stat.size, size_mb: formatMb(stat.size), ...classified })
|
|
84
238
|
}
|
|
85
239
|
continue
|
|
86
240
|
}
|
|
87
241
|
|
|
88
242
|
const content = readFileSync(full)
|
|
89
243
|
if (total + content.length > MAX_SOURCE_SIZE) {
|
|
90
|
-
skipped.push({
|
|
244
|
+
skipped.push({
|
|
245
|
+
local_path: path, ext, size_bytes: stat.size, size_mb: formatMb(stat.size),
|
|
246
|
+
reason_code: "bundle_size_limit_reached",
|
|
247
|
+
reason_human: `Total upload size limit (${formatMb(MAX_SOURCE_SIZE)} MB) reached.`,
|
|
248
|
+
})
|
|
91
249
|
return
|
|
92
250
|
}
|
|
251
|
+
|
|
93
252
|
total += content.length
|
|
94
|
-
|
|
253
|
+
const hash = sha256(content)
|
|
254
|
+
hashMap[path] = hash
|
|
255
|
+
allFiles.push({ path, content: content.toString("base64"), hash })
|
|
256
|
+
|
|
257
|
+
if (CODE_SEARCH_EXTS.has(ext)) {
|
|
258
|
+
textByPath.set(path, content.toString("utf-8"))
|
|
259
|
+
}
|
|
95
260
|
} catch {}
|
|
96
261
|
}
|
|
97
262
|
}
|
|
98
263
|
|
|
99
264
|
walk(dir, "")
|
|
100
|
-
return {
|
|
265
|
+
return { allFiles, total, skipped, hashMap, textByPath }
|
|
101
266
|
}
|
|
102
267
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
268
|
+
// ─── Delta computation ──────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function computeDelta(hashMap, prevManifest) {
|
|
271
|
+
if (!prevManifest) return { isFirstDeploy: true, changed: Object.keys(hashMap), deleted: [], unchanged: [] }
|
|
272
|
+
|
|
273
|
+
const changed = []
|
|
274
|
+
const unchanged = []
|
|
275
|
+
const deleted = []
|
|
276
|
+
|
|
277
|
+
for (const [path, hash] of Object.entries(hashMap)) {
|
|
278
|
+
if (prevManifest[path] === hash) {
|
|
279
|
+
unchanged.push(path)
|
|
280
|
+
} else {
|
|
281
|
+
changed.push(path)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const path of Object.keys(prevManifest)) {
|
|
286
|
+
if (!(path in hashMap)) {
|
|
287
|
+
deleted.push(path)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { isFirstDeploy: false, changed, deleted, unchanged }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Reference grep ─────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function grepReferences(skipped, textByPath) {
|
|
297
|
+
if (skipped.length === 0) return skipped
|
|
298
|
+
|
|
299
|
+
const needles = skipped.map(s => {
|
|
300
|
+
const name = basename(s.local_path)
|
|
301
|
+
const variants = new Set([name, `/${name}`, s.local_path])
|
|
302
|
+
if (s.local_path.startsWith("public/")) {
|
|
303
|
+
const stripped = s.local_path.slice("public/".length)
|
|
304
|
+
variants.add(`/${stripped}`)
|
|
305
|
+
variants.add(stripped)
|
|
306
|
+
}
|
|
307
|
+
return { entry: s, variants: [...variants] }
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
for (const [path, text] of textByPath) {
|
|
311
|
+
const lines = text.split("\n")
|
|
312
|
+
for (const { entry, variants } of needles) {
|
|
313
|
+
if ((entry.referenced_in_code?.length || 0) >= 5) continue
|
|
314
|
+
for (let i = 0; i < lines.length; i++) {
|
|
315
|
+
if (variants.some(v => lines[i].includes(v))) {
|
|
316
|
+
entry.referenced_in_code = entry.referenced_in_code || []
|
|
317
|
+
entry.referenced_in_code.push({ file: path, line: i + 1, snippet: lines[i].trim().slice(0, 160) })
|
|
318
|
+
if (entry.referenced_in_code.length >= 5) break
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const s of skipped) { if (!s.referenced_in_code) s.referenced_in_code = [] }
|
|
324
|
+
return skipped
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Assets requiring action ────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
let _lastSourceDirectory = null
|
|
330
|
+
|
|
331
|
+
function buildAssetsRequiringAction(skipped, mediaManifest, project_id) {
|
|
332
|
+
const actionable = skipped.filter(s => s.reason_code === "file_too_large" || s.reason_code === "unsupported_file_type")
|
|
333
|
+
if (actionable.length === 0) return null
|
|
334
|
+
|
|
335
|
+
const needsUpload = []
|
|
336
|
+
const alreadyUploaded = []
|
|
337
|
+
|
|
338
|
+
for (const s of actionable) {
|
|
339
|
+
const prev = mediaManifest[s.local_path]
|
|
340
|
+
if (prev && prev.size === s.size_bytes) {
|
|
341
|
+
alreadyUploaded.push({ local_path: s.local_path, bucket: prev.bucket, object_name: prev.object_name, status: "already_uploaded" })
|
|
342
|
+
} else {
|
|
343
|
+
needsUpload.push(s)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (needsUpload.length === 0) {
|
|
348
|
+
return { count: 0, all_synced: true, already_in_bucket: alreadyUploaded,
|
|
349
|
+
message: `All ${alreadyUploaded.length} large asset(s) already in storage bucket.` }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const files = needsUpload.map(s => {
|
|
353
|
+
const objectName = basename(s.local_path)
|
|
354
|
+
return {
|
|
355
|
+
local_path: s.local_path, size_mb: s.size_mb, category: mediaCategory(s.ext),
|
|
356
|
+
reason_code: s.reason_code, reason_human: s.reason_human, referenced_in_code: s.referenced_in_code,
|
|
357
|
+
suggested_action: {
|
|
358
|
+
tool: "manage_storage",
|
|
359
|
+
args: {
|
|
360
|
+
operation: "upload_file", project_id, bucket: "public",
|
|
361
|
+
local_path: s.local_path, object_name: objectName,
|
|
362
|
+
ensure_bucket: true, bucket_public: true, source_directory: _lastSourceDirectory,
|
|
363
|
+
},
|
|
364
|
+
after_upload: s.referenced_in_code.length > 0
|
|
365
|
+
? `Replace references in: ${s.referenced_in_code.map(r => `${r.file}:${r.line}`).join(", ")}`
|
|
366
|
+
: `No code references found for '${objectName}'.`,
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
count: files.length,
|
|
373
|
+
message: `⚠️ ${files.length} asset(s) too large to bundle (>${formatMb(MAX_BUNDLED_FILE)} MB). Upload to 'public' bucket.`,
|
|
374
|
+
files,
|
|
375
|
+
...(alreadyUploaded.length > 0 ? { already_in_bucket: alreadyUploaded } : {}),
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── Framework detection ────────────────────────────────────────────────────
|
|
380
|
+
|
|
107
381
|
function detectFramework(dir) {
|
|
108
382
|
const pkgPath = join(dir, "package.json")
|
|
109
383
|
if (!existsSync(pkgPath)) return null
|
|
@@ -129,69 +403,142 @@ function detectFramework(dir) {
|
|
|
129
403
|
} catch { return null }
|
|
130
404
|
}
|
|
131
405
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* Reads the source directory, uploads to the API, returns a structured
|
|
135
|
-
* response the caller can hand back to the agent.
|
|
136
|
-
*/
|
|
406
|
+
// ─── Public entrypoint ──────────────────────────────────────────────────────
|
|
407
|
+
|
|
137
408
|
export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
409
|
+
if (!existsSync(sourceDirectory)) {
|
|
410
|
+
return { error: `Directory not found: ${sourceDirectory}` }
|
|
411
|
+
}
|
|
412
|
+
if (!existsSync(join(sourceDirectory, "package.json"))) {
|
|
413
|
+
return { error: `No package.json found in ${sourceDirectory}. Is this a frontend project?` }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_lastSourceDirectory = resolve(sourceDirectory)
|
|
417
|
+
|
|
418
|
+
const framework = detectFramework(sourceDirectory)
|
|
419
|
+
const { allFiles, total, skipped, hashMap, textByPath } = collectSource(sourceDirectory)
|
|
141
420
|
|
|
142
|
-
|
|
143
|
-
|
|
421
|
+
if (!allFiles.length) {
|
|
422
|
+
return { error: "No source files found." }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Delta computation ───────────────────────────────────────────────────
|
|
426
|
+
const prevManifest = readDeployManifest(sourceDirectory)
|
|
427
|
+
const delta = computeDelta(hashMap, prevManifest)
|
|
428
|
+
|
|
429
|
+
// Nothing changed at all → skip deploy entirely
|
|
430
|
+
if (!delta.isFirstDeploy && delta.changed.length === 0 && delta.deleted.length === 0) {
|
|
431
|
+
return {
|
|
432
|
+
success: true,
|
|
433
|
+
deployed: false,
|
|
434
|
+
skipped_reason: "no_changes",
|
|
435
|
+
files_total: allFiles.length,
|
|
436
|
+
message: `No changes detected since last deploy (${allFiles.length} files, all hashes match). Deploy skipped.`,
|
|
144
437
|
}
|
|
438
|
+
}
|
|
145
439
|
|
|
146
|
-
|
|
147
|
-
|
|
440
|
+
// ── Build payload (delta or full) ───────────────────────────────────────
|
|
441
|
+
const isIncremental = !delta.isFirstDeploy && delta.unchanged.length > 0
|
|
442
|
+
let filesToSend, payloadSize
|
|
148
443
|
|
|
149
|
-
|
|
150
|
-
|
|
444
|
+
if (isIncremental) {
|
|
445
|
+
// Only send new + changed files
|
|
446
|
+
const changedSet = new Set(delta.changed)
|
|
447
|
+
filesToSend = allFiles.filter(f => changedSet.has(f.path))
|
|
448
|
+
payloadSize = filesToSend.reduce((sum, f) => sum + Buffer.byteLength(f.content, "base64") * 0.75, 0)
|
|
449
|
+
} else {
|
|
450
|
+
filesToSend = allFiles
|
|
451
|
+
payloadSize = total
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
grepReferences(skipped, textByPath)
|
|
455
|
+
const mediaManifest = readMediaManifest(sourceDirectory)
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const body = {
|
|
459
|
+
files: filesToSend.map(f => ({ path: f.path, content: f.content })),
|
|
460
|
+
framework: framework?.id ?? null,
|
|
151
461
|
}
|
|
152
462
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const label = framework?.label ?? "Project"
|
|
160
|
-
|
|
161
|
-
// Fire-and-forget: return immediately with "queued" status. The agent
|
|
162
|
-
// is expected to call `get_build_status` to poll progress until the
|
|
163
|
-
// terminal "success" / "failure" is reached. Blocking here would stall
|
|
164
|
-
// the agent for up to 2 minutes and prevent it from doing anything
|
|
165
|
-
// else in parallel.
|
|
166
|
-
const skippedMsg = skipped.length > 0
|
|
167
|
-
? `\n\nSkipped ${skipped.length} file(s):\n${skipped.map(s => ` - ${s.path}: ${s.reason}`).join("\n")}`
|
|
168
|
-
: ""
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
success: true,
|
|
172
|
-
deployed: false, // explicit: not live yet, still building
|
|
173
|
-
url: result.url, // final URL once success
|
|
174
|
-
files_pushed: files.length,
|
|
175
|
-
files_skipped: skipped.length,
|
|
176
|
-
skipped_files: skipped.length > 0 ? skipped : undefined,
|
|
177
|
-
size_bytes: total,
|
|
178
|
-
framework: label,
|
|
179
|
-
build_status: "queued",
|
|
180
|
-
next_step: {
|
|
181
|
-
action: "poll_build_status",
|
|
182
|
-
tool: "manage_frontend",
|
|
183
|
-
arg: { operation: "build_status", project_id },
|
|
184
|
-
interval_seconds: 5,
|
|
185
|
-
expected_total_seconds: 60,
|
|
186
|
-
terminal_statuses: ["success", "failure"],
|
|
187
|
-
},
|
|
188
|
-
message:
|
|
189
|
-
`Deploy accepted — ${files.length} files (${Math.round(total / 1024)} KB) queued. ` +
|
|
190
|
-
`The build is running in the cloud (${label}, typically 20–60s). ` +
|
|
191
|
-
`Call manage_frontend({operation:"build_status", project_id:"${project_id}"}) every ~5s until status is "success" or "failure" ` +
|
|
192
|
-
`before reporting completion to the user. The site is NOT live yet.${skippedMsg}`,
|
|
463
|
+
// Signal incremental mode to the API
|
|
464
|
+
if (isIncremental) {
|
|
465
|
+
body.incremental = true
|
|
466
|
+
if (delta.deleted.length > 0) {
|
|
467
|
+
body.deleted_paths = delta.deleted
|
|
193
468
|
}
|
|
194
|
-
} catch (e) {
|
|
195
|
-
return { error: `Deploy failed: ${e.message}` }
|
|
196
469
|
}
|
|
470
|
+
|
|
471
|
+
const result = await api.post(
|
|
472
|
+
`/api/engine/${project_id}/frontend/deploy/source`,
|
|
473
|
+
body,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
// ── Update manifest on success ──────────────────────────────────────
|
|
477
|
+
writeDeployManifest(sourceDirectory, hashMap)
|
|
478
|
+
|
|
479
|
+
const label = framework?.label ?? "Project"
|
|
480
|
+
const assetsAction = buildAssetsRequiringAction(skipped, mediaManifest, project_id)
|
|
481
|
+
const hasUnresolvedAssets = assetsAction && assetsAction.count > 0
|
|
482
|
+
|
|
483
|
+
const deltaInfo = isIncremental
|
|
484
|
+
? `Delta deploy: ${delta.changed.length} changed, ${delta.deleted.length} deleted, ${delta.unchanged.length} unchanged (sent ${formatBytes(payloadSize)} instead of ${formatBytes(total)})`
|
|
485
|
+
: `Full deploy: ${allFiles.length} files (${formatBytes(total)})`
|
|
486
|
+
|
|
487
|
+
const baseMessage =
|
|
488
|
+
`Deploy accepted — ${deltaInfo}. ` +
|
|
489
|
+
`Build running (${label}, ~20-60s). Poll manage_frontend({operation:"build_status"}) every ~5s.`
|
|
490
|
+
|
|
491
|
+
let message = baseMessage
|
|
492
|
+
if (hasUnresolvedAssets) {
|
|
493
|
+
message = `${assetsAction.message}\n\n${baseMessage}`
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
success: true,
|
|
498
|
+
deployed: false,
|
|
499
|
+
url: result.url,
|
|
500
|
+
framework: label,
|
|
501
|
+
build_status: "queued",
|
|
502
|
+
|
|
503
|
+
// Delta stats — shows the agent (and user) how much we saved
|
|
504
|
+
delta: {
|
|
505
|
+
mode: isIncremental ? "incremental" : "full",
|
|
506
|
+
files_sent: filesToSend.length,
|
|
507
|
+
files_total: allFiles.length,
|
|
508
|
+
files_changed: delta.changed.length,
|
|
509
|
+
files_deleted: delta.deleted.length,
|
|
510
|
+
files_unchanged: delta.unchanged.length,
|
|
511
|
+
bytes_sent: Math.round(payloadSize),
|
|
512
|
+
bytes_total: total,
|
|
513
|
+
savings: isIncremental ? `${Math.round((1 - payloadSize / total) * 100)}%` : "0% (first deploy)",
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
...(hasUnresolvedAssets ? { assets_requiring_action: assetsAction } : {}),
|
|
517
|
+
...(assetsAction?.all_synced ? { assets_synced: assetsAction.already_in_bucket } : {}),
|
|
518
|
+
|
|
519
|
+
next_step: hasUnresolvedAssets
|
|
520
|
+
? {
|
|
521
|
+
action: "resolve_pending_assets_then_poll_build",
|
|
522
|
+
priority_order: [
|
|
523
|
+
"1. Execute each suggested_action in assets_requiring_action.files.",
|
|
524
|
+
"2. Edit source files to use the returned URL.",
|
|
525
|
+
"3. Re-deploy. Delta deploy will only send the edited files.",
|
|
526
|
+
"4. Poll build_status until terminal.",
|
|
527
|
+
],
|
|
528
|
+
terminal_statuses: ["success", "failure"],
|
|
529
|
+
}
|
|
530
|
+
: {
|
|
531
|
+
action: "poll_build_status",
|
|
532
|
+
tool: "manage_frontend",
|
|
533
|
+
arg: { operation: "build_status", project_id },
|
|
534
|
+
interval_seconds: 5,
|
|
535
|
+
expected_total_seconds: 60,
|
|
536
|
+
terminal_statuses: ["success", "failure"],
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
message,
|
|
540
|
+
}
|
|
541
|
+
} catch (e) {
|
|
542
|
+
return { error: `Deploy failed: ${e.message}` }
|
|
543
|
+
}
|
|
197
544
|
}
|