@dypai-ai/mcp 1.4.2 → 1.4.5

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,22 +1,28 @@
1
1
  /**
2
- * deploy_frontend — delta deploy engine.
2
+ * deploy_frontend — full-project deploy with "skip if identical" optimization.
3
3
  *
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.
4
+ * Architecture (deliberately simple, InsForge-style):
5
+ * 1. Walk the project, hash every file.
6
+ * 2. Compute a single project-wide hash (SHA-256 of sorted path+hash pairs).
7
+ * 3. If this hash matches the last-deploy hash on disk → skip entirely.
8
+ * 4. Otherwise send ALL files to the API (full deploy, replace_all=True).
9
+ * 5. Poll build-status to confirm GitHub commit happened.
10
+ * 6. On confirmation, persist the new project hash locally.
7
11
  *
8
- * Manifest lives at `dypai/.dypai/deploy-manifest.json`:
9
- * { "src/app/page.tsx": "a1b2c3...", "public/logo.png": "d4e5f6..." }
12
+ * Why not delta? A previous delta-based implementation (incremental commits,
13
+ * per-file manifest, deleted_paths list) generated too many edge cases:
14
+ * - Manifest drift after walk-rule changes (ghost "deletions")
15
+ * - Optimistic manifest updates on HTTP 202 where async commit failed
16
+ * - Migration triggers on deletion-only deploys
17
+ * Each required a new defensive hack. The failure mode was always the same:
18
+ * client-side state drifted from remote truth. Full deploys make drift
19
+ * impossible: every deploy is a self-contained snapshot of project state.
20
+ * GitHub's blob dedup handles unchanged-content efficiency at the server
21
+ * side — we don't need to reinvent it client-side.
10
22
  *
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
23
+ * Trade-off: ~10s extra per deploy when only one file changed (because we
24
+ * send the whole project over gzip-compressed wire instead of just that
25
+ * file). For production reliability, that trade is the right one.
20
26
  */
21
27
 
22
28
  import { createHash } from "crypto"
@@ -33,10 +39,16 @@ const MAX_BUNDLED_FILE = 25 * 1024 * 1024
33
39
 
34
40
  const IGNORE_DIRS = new Set([
35
41
  "node_modules", ".git",
42
+ // Build outputs
36
43
  "dist", "build", "out", ".output", ".vercel", ".netlify",
44
+ // Framework caches
37
45
  ".next", ".nuxt", ".svelte-kit", ".astro", ".angular", ".docusaurus",
38
46
  ".cache", ".turbo", ".vite", ".parcel-cache", ".wrangler",
47
+ // Test / misc
39
48
  "coverage", "storybook-static", "__pycache__", ".idea", ".vscode",
49
+ // DYPAI backend metadata — handled by dypai_pull/push, not shipped to the
50
+ // frontend build.
51
+ "dypai",
40
52
  ])
41
53
 
42
54
  // ─── Accepted file extensions ───────────────────────────────────────────────
@@ -109,33 +121,53 @@ function mediaCategory(ext) {
109
121
  return "other"
110
122
  }
111
123
 
112
- // ─── Deploy manifest ────────────────────────────────────────────────────────
113
- //
114
- // Tracks SHA-256 of each file at last successful deploy.
115
- // Path: <sourceDirectory>/dypai/.dypai/deploy-manifest.json
124
+ // ─── Last-deploy hash (single value, no per-file tracking) ──────────────────
116
125
  //
117
- // Format: { "src/app/page.tsx": "a1b2c3...", ... }
126
+ // Replaces the old per-file manifest. Stores ONE hash representing the entire
127
+ // project state at last successful deploy. Drift is impossible: either the
128
+ // recomputed project hash matches (skip) or it doesn't (full deploy).
118
129
 
119
- function manifestPath(sourceDirectory) {
130
+ function lastDeployPath(sourceDirectory) {
120
131
  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")
132
+ if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "last-deploy.json")
133
+ return join(sourceDirectory, ".dypai", "last-deploy.json")
123
134
  }
124
135
 
125
- function readDeployManifest(sourceDirectory) {
126
- const path = manifestPath(sourceDirectory)
136
+ function readLastDeployHash(sourceDirectory) {
137
+ const path = lastDeployPath(sourceDirectory)
127
138
  if (!existsSync(path)) return null
128
- try { return JSON.parse(readFileSync(path, "utf-8")) } catch { return null }
139
+ try {
140
+ const raw = JSON.parse(readFileSync(path, "utf-8"))
141
+ return raw && typeof raw.hash === "string" ? raw.hash : null
142
+ } catch { return null }
129
143
  }
130
144
 
131
- function writeDeployManifest(sourceDirectory, manifest) {
132
- const path = manifestPath(sourceDirectory)
145
+ function writeLastDeployHash(sourceDirectory, hash) {
146
+ const path = lastDeployPath(sourceDirectory)
133
147
  const dir = dirname(path)
134
148
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
135
- writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n")
149
+ writeFileSync(path, JSON.stringify({ hash, at: new Date().toISOString() }, null, 2) + "\n")
136
150
  }
137
151
 
138
- // ─── Media manifest (for bucket-uploaded files) ─────────────────────────────
152
+ /**
153
+ * Produce a single SHA-256 fingerprint that uniquely identifies the set of
154
+ * files about to be deployed. Files are sorted by path for determinism;
155
+ * path and per-file hash are joined with a NUL byte (which can't appear in
156
+ * either) to avoid any ambiguity between e.g. "foo"+"bar" and "foob"+"ar".
157
+ */
158
+ function computeProjectHash(files) {
159
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path))
160
+ const h = createHash("sha256")
161
+ for (const f of sorted) {
162
+ h.update(f.path)
163
+ h.update("\0")
164
+ h.update(f.hash)
165
+ h.update("\0")
166
+ }
167
+ return h.digest("hex")
168
+ }
169
+
170
+ // ─── Media manifest (kept — tracks bucket uploads for the upload_file tool) ─
139
171
 
140
172
  function mediaManifestPath(sourceDirectory) {
141
173
  const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
@@ -188,18 +220,9 @@ function classifySkip(path, ext, size) {
188
220
  return null
189
221
  }
190
222
 
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
- */
199
223
  function collectSource(dir) {
200
224
  const allFiles = []
201
225
  const skipped = []
202
- const hashMap = {}
203
226
  const textByPath = new Map()
204
227
  let total = 0
205
228
 
@@ -251,7 +274,6 @@ function collectSource(dir) {
251
274
 
252
275
  total += content.length
253
276
  const hash = sha256(content)
254
- hashMap[path] = hash
255
277
  allFiles.push({ path, content: content.toString("base64"), hash })
256
278
 
257
279
  if (CODE_SEARCH_EXTS.has(ext)) {
@@ -262,36 +284,10 @@ function collectSource(dir) {
262
284
  }
263
285
 
264
286
  walk(dir, "")
265
- return { allFiles, total, skipped, hashMap, textByPath }
287
+ return { allFiles, total, skipped, textByPath }
266
288
  }
267
289
 
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 ─────────────────────────────────────────────────────────
290
+ // ─── Reference grep for assets_requiring_action ─────────────────────────────
295
291
 
296
292
  function grepReferences(skipped, textByPath) {
297
293
  if (skipped.length === 0) return skipped
@@ -403,9 +399,36 @@ function detectFramework(dir) {
403
399
  } catch { return null }
404
400
  }
405
401
 
402
+ // ─── Post-deploy confirmation ───────────────────────────────────────────────
403
+ //
404
+ // Polls /build-status after the API accepts the deploy. The API returns 202
405
+ // queued immediately but the GitHub commit happens in a background task; we
406
+ // only persist the new "last deploy" hash once the commit has clearly
407
+ // happened (status moved past "queued"). If the background task fails, we
408
+ // leave the hash untouched — the next deploy will re-send everything.
409
+
410
+ async function confirmDeploySucceeded(project_id) {
411
+ const MAX_POLLS = 6
412
+ const INTERVAL_MS = 2000
413
+
414
+ for (let i = 0; i < MAX_POLLS; i++) {
415
+ await new Promise(r => setTimeout(r, INTERVAL_MS))
416
+ try {
417
+ const res = await api.get(`/api/engine/${project_id}/frontend/build-status`)
418
+ const status = res?.status || res?.build_status || null
419
+ if (status === "failed") return { confirmed: false, status, reason: "build_failed" }
420
+ if (status === "building" || status === "success") return { confirmed: true, status, reason: null }
421
+ } catch {
422
+ // Transient — keep polling
423
+ }
424
+ }
425
+ // Never left "queued" — treat as unconfirmed (safer to re-deploy next time)
426
+ return { confirmed: false, status: "queued", reason: "confirmation_timeout" }
427
+ }
428
+
406
429
  // ─── Public entrypoint ──────────────────────────────────────────────────────
407
430
 
408
- export async function deployFromSource({ sourceDirectory, project_id }) {
431
+ export async function deployFromSource({ sourceDirectory, project_id, force = false }) {
409
432
  if (!existsSync(sourceDirectory)) {
410
433
  return { error: `Directory not found: ${sourceDirectory}` }
411
434
  }
@@ -416,81 +439,69 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
416
439
  _lastSourceDirectory = resolve(sourceDirectory)
417
440
 
418
441
  const framework = detectFramework(sourceDirectory)
419
- const { allFiles, total, skipped, hashMap, textByPath } = collectSource(sourceDirectory)
442
+ const { allFiles, total, skipped, textByPath } = collectSource(sourceDirectory)
420
443
 
421
444
  if (!allFiles.length) {
422
445
  return { error: "No source files found." }
423
446
  }
424
447
 
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.`,
448
+ // ── Skip-if-identical check ─────────────────────────────────────────────
449
+ // One hash represents the whole project state. Same hash → nothing to do.
450
+ // Different hash (or no previous hash) → full deploy.
451
+ const projectHash = computeProjectHash(allFiles)
452
+
453
+ if (!force) {
454
+ const lastHash = readLastDeployHash(sourceDirectory)
455
+ if (lastHash && lastHash === projectHash) {
456
+ return {
457
+ success: true,
458
+ deployed: false,
459
+ skipped_reason: "no_changes",
460
+ files_total: allFiles.length,
461
+ project_hash: projectHash,
462
+ message: `No changes since last deploy (${allFiles.length} files match). Pass force:true to re-deploy anyway.`,
463
+ }
437
464
  }
438
465
  }
439
466
 
440
- // ── Build payload (delta or full) ───────────────────────────────────────
441
- const isIncremental = !delta.isFirstDeploy && delta.unchanged.length > 0
442
- let filesToSend, payloadSize
443
-
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
-
467
+ // ── Build payload (always full) ────────────────────────────────────────
454
468
  grepReferences(skipped, textByPath)
455
469
  const mediaManifest = readMediaManifest(sourceDirectory)
456
470
 
457
471
  try {
458
472
  const body = {
459
- files: filesToSend.map(f => ({ path: f.path, content: f.content })),
473
+ files: allFiles.map(f => ({ path: f.path, content: f.content })),
460
474
  framework: framework?.id ?? null,
461
475
  }
462
476
 
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
468
- }
469
- }
470
-
471
477
  const result = await api.post(
472
478
  `/api/engine/${project_id}/frontend/deploy/source`,
473
479
  body,
474
480
  )
475
481
 
476
- // ── Update manifest on success ──────────────────────────────────────
477
- writeDeployManifest(sourceDirectory, hashMap)
482
+ // ── Confirm the background task actually committed ──────────────────
483
+ const confirmation = await confirmDeploySucceeded(project_id)
484
+ if (confirmation.confirmed) {
485
+ writeLastDeployHash(sourceDirectory, projectHash)
486
+ }
478
487
 
479
488
  const label = framework?.label ?? "Project"
480
489
  const assetsAction = buildAssetsRequiringAction(skipped, mediaManifest, project_id)
481
490
  const hasUnresolvedAssets = assetsAction && assetsAction.count > 0
482
491
 
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)})`
492
+ const quota = result.build_quota || null
493
+ const quotaWarning = buildQuotaWarning(quota)
486
494
 
487
495
  const baseMessage =
488
- `Deploy accepted — ${deltaInfo}. ` +
496
+ `Deploy accepted — ${allFiles.length} files (${formatBytes(total)}). ` +
489
497
  `Build running (${label}, ~20-60s). Poll manage_frontend({operation:"build_status"}) every ~5s.`
490
498
 
491
499
  let message = baseMessage
500
+ if (quotaWarning) {
501
+ message = `${quotaWarning}\n\n${message}`
502
+ }
492
503
  if (hasUnresolvedAssets) {
493
- message = `${assetsAction.message}\n\n${baseMessage}`
504
+ message = `${assetsAction.message}\n\n${message}`
494
505
  }
495
506
 
496
507
  return {
@@ -498,31 +509,25 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
498
509
  deployed: false,
499
510
  url: result.url,
500
511
  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
- },
512
+ build_status: confirmation.status || "queued",
513
+ files_total: allFiles.length,
514
+ bytes_total: total,
515
+ project_hash: projectHash,
516
+ last_deploy_hash_updated: confirmation.confirmed,
517
+ ...(confirmation.reason ? { confirmation_warning: confirmation.reason } : {}),
515
518
 
516
519
  ...(hasUnresolvedAssets ? { assets_requiring_action: assetsAction } : {}),
517
520
  ...(assetsAction?.all_synced ? { assets_synced: assetsAction.already_in_bucket } : {}),
518
521
 
522
+ ...(quota ? { build_quota: quota } : {}),
523
+
519
524
  next_step: hasUnresolvedAssets
520
525
  ? {
521
526
  action: "resolve_pending_assets_then_poll_build",
522
527
  priority_order: [
523
528
  "1. Execute each suggested_action in assets_requiring_action.files.",
524
529
  "2. Edit source files to use the returned URL.",
525
- "3. Re-deploy. Delta deploy will only send the edited files.",
530
+ "3. Re-deploy.",
526
531
  "4. Poll build_status until terminal.",
527
532
  ],
528
533
  terminal_statuses: ["success", "failure"],
@@ -539,6 +544,46 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
539
544
  message,
540
545
  }
541
546
  } catch (e) {
547
+ if (e.statusCode === 429 && e.detail && e.detail.error === "build_quota_exceeded") {
548
+ return {
549
+ success: false,
550
+ error: e.detail.message || "Monthly build minutes limit reached.",
551
+ error_code: "build_quota_exceeded",
552
+ build_quota: {
553
+ minutes_used: e.detail.minutes_used,
554
+ minutes_limit: e.detail.minutes_limit,
555
+ resets_at: e.detail.resets_at,
556
+ },
557
+ upgrade_url: e.detail.upgrade_url,
558
+ advice: "Do not retry this deploy. Inform the user that the monthly build minute quota is exhausted and suggest upgrading the project plan.",
559
+ }
560
+ }
561
+ if (e.statusCode === 429 && e.detail && e.detail.error === "concurrent_builds_limit") {
562
+ return {
563
+ success: false,
564
+ error: e.detail.message || "Concurrent build limit reached.",
565
+ error_code: "concurrent_builds_limit",
566
+ concurrent_active: e.detail.concurrent_active,
567
+ concurrent_limit: e.detail.concurrent_limit,
568
+ advice: "Wait for the active build to finish (poll manage_frontend({operation:'build_status'})) before re-deploying.",
569
+ }
570
+ }
542
571
  return { error: `Deploy failed: ${e.message}` }
543
572
  }
544
573
  }
574
+
575
+ /**
576
+ * Human-readable warning when the project is near its monthly build quota.
577
+ * Returns null when plan is unlimited or usage is below 80%.
578
+ */
579
+ function buildQuotaWarning(quota) {
580
+ if (!quota) return null
581
+ const { minutes_used, minutes_limit, minutes_remaining } = quota
582
+ if (minutes_limit == null) return null
583
+ const pct = minutes_limit > 0 ? (minutes_used / minutes_limit) : 0
584
+ if (pct < 0.8) return null
585
+ if (pct >= 1) {
586
+ return `⚠️ Monthly build minutes exhausted (${minutes_used}/${minutes_limit} min). This deploy used your last minutes — upgrade your plan to deploy again.`
587
+ }
588
+ return `⚠️ Build quota at ${Math.round(pct * 100)}% (${minutes_used}/${minutes_limit} min, ${minutes_remaining} remaining). Consider upgrading soon.`
589
+ }
@@ -14,11 +14,13 @@
14
14
  import { api } from "../api.js"
15
15
  import { deployFromSource } from "./deploy.js"
16
16
  import { syncFromRemote } from "./sync.js"
17
+ import { proxyToolCall } from "./proxy.js"
17
18
 
18
19
  export const manageFrontendTool = {
19
20
  name: "manage_frontend",
20
21
  description:
21
- "Manage the project's frontend: download the source code to disk AND the deploy lifecycle.\n\n" +
22
+ "FRONTEND ONLY — manages the project's static frontend (HTML/CSS/JS bundle). For BACKEND endpoints/workflows use dypai_push + manage_drafts; never use this tool for backend work.\n\n" +
23
+ "Two phases: download source to local disk (`sync`), then ship changes back (`deploy`).\n\n" +
22
24
  "Use `sync` FIRST whenever you start working on a project whose frontend code isn't already on this machine — " +
23
25
  "without it you have no React/Vite source to read or edit. Call `deploy` to ship your changes.\n\n" +
24
26
  "Operations:\n" +
@@ -28,19 +30,25 @@ export const manageFrontendTool = {
28
30
  "Writes only; does NOT delete local files that were removed upstream — you may have stale files after sync (call them out to the user). " +
29
31
  "By default refuses to overwrite a directory that already has a package.json — pass overwrite:true to allow it (local-only files like .env, node_modules, .vscode are always preserved). " +
30
32
  "AFTER SYNC: .env is gitignored so it's NOT included in the download. If the target directory has no .env, the response sets `env_file_missing: true` and adds a `next_steps` line with the exact VITE_DYPAI_URL / NEXT_PUBLIC_DYPAI_URL value to write. Follow it — without .env the SDK can't reach the engine.\n" +
31
- " - deploy: Upload source files from a local directory and queue a build. Returns immediately with build_status=\"queued\" poll with `build_status` until \"success\" or \"failure\".\n" +
33
+ " - deploy: Upload source files from a local directory and queue a build. **DESTRUCTIVE: replaces the LIVE site immediately, no draft stage, no rollback button.** " +
34
+ "Requires `confirm: true` — without it the tool returns a confirmation_required hint instead of deploying. " +
35
+ "If backend drafts are pending, the hint includes a warning to publish backend FIRST (otherwise the new frontend may call endpoints that don't exist yet). " +
36
+ "Returns immediately with build_status=\"queued\" — poll with `build_status` until \"success\" or \"failure\". " +
37
+ "The response includes `build_quota` with remaining monthly build minutes. If minutes_remaining is low, tell the user. If 0, DO NOT retry — suggest upgrading the plan.\n" +
32
38
  " - status: Current live deploy info (URL, last deploy time, size).\n" +
33
39
  " - build_status: Progress of the current/latest build (queued/building/success/failure + stage + %).\n" +
40
+ " - usage: Full frontend usage snapshot including build_quota (minutes used/limit/remaining, deploy counts, resets_at). Call BEFORE deploy if unsure how much quota is left.\n" +
34
41
  " - list_deployments: Recent deploy history (status, commit, duration, URL).\n" +
35
42
  " - logs: Build logs for a specific deployment (needs deployment_id from list_deployments).\n\n" +
36
- "Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.",
43
+ "Related: `dypai_pull` brings BACKEND state (YAML endpoints, SQL, prompts). The two are independent — run both when starting fresh on a full-stack project.\n\n" +
44
+ "Order rule when both backend AND frontend changed: 1) dypai_push (saves backend as draft) → 2) manage_drafts(publish, confirm:true) → 3) manage_frontend(deploy, confirm:true). Inverting steps 2 and 3 may serve a frontend that calls non-existent endpoints.",
37
45
 
38
46
  inputSchema: {
39
47
  type: "object",
40
48
  properties: {
41
49
  operation: {
42
50
  type: "string",
43
- enum: ["deploy", "sync", "status", "build_status", "list_deployments", "logs"],
51
+ enum: ["deploy", "sync", "status", "build_status", "usage", "list_deployments", "logs"],
44
52
  description: "Which action to run.",
45
53
  },
46
54
  project_id: {
@@ -60,6 +68,16 @@ export const manageFrontendTool = {
60
68
  description: "sync only. When true, allows writing into a directory that already has a project. Local-only files (.env, node_modules, etc.) are NOT touched. Default: false.",
61
69
  default: false,
62
70
  },
71
+ force: {
72
+ type: "boolean",
73
+ description: "deploy only. Bypass the delta manifest and re-send ALL files (full deploy). Use when the previous deploy's remote build FAILED — the manifest says 'synced' but the remote never built, so a normal delta incorrectly reports no_changes. Default: false.",
74
+ default: false,
75
+ },
76
+ confirm: {
77
+ type: "boolean",
78
+ description: "Required `true` for `deploy`. Without it the tool returns a confirmation_required hint (with a ready-to-call next_call) instead of replacing the live site. The agent MUST get explicit user approval before passing confirm:true.",
79
+ default: false,
80
+ },
63
81
  deployment_id: {
64
82
  type: "string",
65
83
  description: "logs only. Deployment UUID obtained from list_deployments.",
@@ -72,7 +90,7 @@ export const manageFrontendTool = {
72
90
  required: ["operation"],
73
91
  },
74
92
 
75
- async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, deployment_id, limit } = {}) {
93
+ async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, force, confirm, deployment_id, limit } = {}) {
76
94
  if (!operation) {
77
95
  return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
78
96
  }
@@ -86,7 +104,44 @@ export const manageFrontendTool = {
86
104
  if (!sourceDirectory) {
87
105
  return { success: false, error: "operation 'deploy' requires 'sourceDirectory' (absolute path to your frontend project root)." }
88
106
  }
89
- return await deployFromSource({ sourceDirectory, project_id })
107
+ // Defense-in-depth gate: deploy replaces the live site immediately
108
+ // with no rollback. Without explicit confirm we return a structured
109
+ // hint (with ready-to-execute next_call). We also surface any
110
+ // pending backend drafts as warnings — the agent should ALWAYS
111
+ // publish backend drafts before deploying frontend, otherwise the
112
+ // new frontend may call endpoints that don't exist yet on live.
113
+ if (confirm !== true) {
114
+ const warnings = []
115
+ try {
116
+ const draftsResult = await proxyToolCall("manage_drafts", { project_id, operation: "list" })
117
+ const draftsTotal = draftsResult?.total || 0
118
+ if (draftsTotal > 0) {
119
+ warnings.push(
120
+ `${draftsTotal} backend draft(s) pending. Publish them BEFORE deploying the frontend with manage_drafts(operation:'publish', confirm:true) — otherwise the new frontend may call endpoints that don't exist on live yet.`,
121
+ )
122
+ }
123
+ } catch {
124
+ // Soft-fail — drafts check is advisory, not gating.
125
+ }
126
+ return {
127
+ confirmation_required: true,
128
+ summary: `About to replace the LIVE frontend at this project's public URL with the contents of '${sourceDirectory}'. This is IMMEDIATE and there is NO automatic rollback.`,
129
+ warnings: warnings.length > 0 ? warnings : undefined,
130
+ next_call: {
131
+ tool: "manage_frontend",
132
+ operation: "deploy",
133
+ project_id,
134
+ sourceDirectory,
135
+ ...(force ? { force: true } : {}),
136
+ confirm: true,
137
+ },
138
+ hint:
139
+ "Summarize the change to the user (what visual/functional changes are about to go live, " +
140
+ "and any pending backend drafts) and wait for explicit user approval. Then re-call this " +
141
+ "tool with confirm:true.",
142
+ }
143
+ }
144
+ return await deployFromSource({ sourceDirectory, project_id, force: !!force })
90
145
 
91
146
  case "sync":
92
147
  if (!targetDirectory) {
@@ -100,6 +155,9 @@ export const manageFrontendTool = {
100
155
  case "build_status":
101
156
  return await api.get(`/api/engine/${project_id}/frontend/build-status`)
102
157
 
158
+ case "usage":
159
+ return await api.get(`/api/engine/${project_id}/frontend/usage`)
160
+
103
161
  case "list_deployments":
104
162
  return await api.get(`/api/engine/${project_id}/frontend/deployments?limit=${limit || 10}`)
105
163
 
@@ -110,7 +168,7 @@ export const manageFrontendTool = {
110
168
  return await api.get(`/api/engine/${project_id}/frontend/deployments/${deployment_id}/logs`)
111
169
 
112
170
  default:
113
- return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | list_deployments | logs.` }
171
+ return { success: false, error: `Unknown operation '${operation}'. Use deploy | sync | status | build_status | usage | list_deployments | logs.` }
114
172
  }
115
173
  } catch (e) {
116
174
  return { success: false, error: e.message, operation }
@@ -47,9 +47,13 @@ Or use "blank" for an empty starter project.`,
47
47
  return { error: `Directory already has a package.json. Pick an empty directory or a new name.` }
48
48
  }
49
49
 
50
- // Production engine URL: https://<project-id>.dypai.app (NOT .dypai.dev that's the dev tier).
50
+ // Engine base URL — `<project_id>.dypai.dev` serves LIVE traffic; the
51
+ // local-development URL `dev-<project_id>.dypai.dev` (Layer 2.5 draft
52
+ // overlay) is written into `.env.local` separately by `sync` once the
53
+ // user has the frontend on disk. Scaffold writes the production URL
54
+ // because at scaffold-time we expect the user to deploy soon.
51
55
  // Override with DYPAI_ENGINE_BASE for self-hosted / staging setups.
52
- const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.app"
56
+ const engineBase = process.env.DYPAI_ENGINE_BASE || "dypai.dev"
53
57
  const engineUrl = `https://${project_id}.${engineBase}`
54
58
 
55
59
  // Try to download template from API