@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.
- package/package.json +1 -1
- package/src/api.js +20 -4
- package/src/auto-update.js +44 -1
- package/src/index.js +185 -17
- package/src/tools/deploy.js +172 -127
- package/src/tools/frontend.js +65 -7
- package/src/tools/scaffold.js +6 -2
- package/src/tools/sync/diff.js +88 -7
- package/src/tools/sync/pull.js +75 -8
- package/src/tools/sync/push.js +129 -96
- package/src/tools/sync/test-endpoint.js +217 -73
- package/src/tools/sync/validate.js +415 -48
- package/src/tools/sync.js +103 -20
- package/src/tools/status.js +0 -94
package/src/tools/deploy.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* deploy_frontend —
|
|
2
|
+
* deploy_frontend — full-project deploy with "skip if identical" optimization.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
// ───
|
|
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
|
-
//
|
|
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
|
|
130
|
+
function lastDeployPath(sourceDirectory) {
|
|
120
131
|
const dypaiDir = join(sourceDirectory, "dypai", ".dypai")
|
|
121
|
-
if (existsSync(join(sourceDirectory, "dypai"))) return join(dypaiDir, "deploy
|
|
122
|
-
return join(sourceDirectory, ".dypai", "deploy
|
|
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
|
|
126
|
-
const path =
|
|
136
|
+
function readLastDeployHash(sourceDirectory) {
|
|
137
|
+
const path = lastDeployPath(sourceDirectory)
|
|
127
138
|
if (!existsSync(path)) return null
|
|
128
|
-
try {
|
|
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
|
|
132
|
-
const path =
|
|
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(
|
|
149
|
+
writeFileSync(path, JSON.stringify({ hash, at: new Date().toISOString() }, null, 2) + "\n")
|
|
136
150
|
}
|
|
137
151
|
|
|
138
|
-
|
|
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,
|
|
287
|
+
return { allFiles, total, skipped, textByPath }
|
|
266
288
|
}
|
|
267
289
|
|
|
268
|
-
// ───
|
|
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,
|
|
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
|
-
// ──
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
if (!
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
// ──
|
|
477
|
-
|
|
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
|
|
484
|
-
|
|
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 — ${
|
|
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${
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/tools/frontend.js
CHANGED
|
@@ -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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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 }
|
package/src/tools/scaffold.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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
|