@dypai-ai/mcp 1.3.0 → 1.4.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.
@@ -1,109 +1,383 @@
1
1
  /**
2
- * deploy_frontend — Reads source files from disk and deploys to DYPAI.
2
+ * deploy_frontend — delta deploy engine.
3
3
  *
4
- * This is the key tool that requires local filesystem access.
5
- * The AI passes sourceDirectory, this tool reads all source files,
6
- * sends them to the API, which commits to GitHub CF Pages builds.
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 { readFileSync, readdirSync, statSync, existsSync } from "fs"
10
- import { join, relative } from "path"
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
- // Source file extensions to include
28
- const SOURCE_EXTS = new Set([
29
- // JavaScript / TypeScript
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 BLOCKED = new Set([".env", ".env.local", ".env.production", ".env.development", ".DS_Store", "Thumbs.db"])
51
- const MAX_IMAGE = 2 * 1024 * 1024
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 files = []
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
- for (const entry of readdirSync(d)) {
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()) { walk(full, path); continue }
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
- const ext = entry.includes(".") ? `.${entry.split(".").pop().toLowerCase()}` : ""
74
- let ok = SOURCE_EXTS.has(ext)
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 (IMAGE_EXTS.has(ext) && stat.size <= MAX_IMAGE) ok = true
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
- if (IMAGE_EXTS.has(ext) && stat.size > MAX_IMAGE) {
81
- skipped.push({ path, reason: `Image too large (${(stat.size / (1024 * 1024)).toFixed(1)}MB, max ${MAX_IMAGE / (1024 * 1024)}MB)` })
82
- } else if (ext && ![".lock", ".log"].includes(ext)) {
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({ path, reason: "Total upload size limit reached" })
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
- files.push({ path, content: content.toString("base64") })
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 { files, total, skipped }
265
+ return { allFiles, total, skipped, hashMap, textByPath }
101
266
  }
102
267
 
103
- /**
104
- * Detect framework for display AND for API build config.
105
- * Returns { label, id } where id matches pages_service.py FRAMEWORK_PRESETS.
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
- * Pure deploy function — used by the consolidated `manage_frontend` tool.
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
- if (!existsSync(sourceDirectory)) {
139
- return { error: `Directory not found: ${sourceDirectory}` }
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
- if (!existsSync(join(sourceDirectory, "package.json"))) {
143
- return { error: `No package.json found in ${sourceDirectory}. Is this a frontend project?` }
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
- const framework = detectFramework(sourceDirectory)
147
- const { files, total, skipped } = collectSource(sourceDirectory)
440
+ // ── Build payload (delta or full) ───────────────────────────────────────
441
+ const isIncremental = !delta.isFirstDeploy && delta.unchanged.length > 0
442
+ let filesToSend, payloadSize
148
443
 
149
- if (!files.length) {
150
- return { error: "No source files found." }
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
- try {
154
- const result = await api.post(
155
- `/api/engine/${project_id}/frontend/deploy/source`,
156
- { files, framework: framework?.id ?? null }
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
  }