@dypai-ai/mcp 1.4.2 → 1.4.3
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 +6 -2
- package/src/tools/deploy.js +124 -127
- package/src/tools/frontend.js +7 -2
- package/src/tools/sync.js +18 -7
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -18,11 +18,15 @@ function getConfig() {
|
|
|
18
18
|
return {
|
|
19
19
|
token: process.env.DYPAI_TOKEN || "",
|
|
20
20
|
apiUrl: process.env.DYPAI_API_URL || DEFAULT_API_URL,
|
|
21
|
+
// Escape hatch for local dev against an API that doesn't have the
|
|
22
|
+
// gzip decompression middleware yet. Set DYPAI_NO_GZIP=1 to send
|
|
23
|
+
// bodies as plain JSON regardless of size.
|
|
24
|
+
noGzip: process.env.DYPAI_NO_GZIP === "1" || process.env.DYPAI_NO_GZIP === "true",
|
|
21
25
|
}
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export function request(method, path, body) {
|
|
25
|
-
const { token, apiUrl } = getConfig()
|
|
29
|
+
const { token, apiUrl, noGzip } = getConfig()
|
|
26
30
|
const url = `${apiUrl}${path}`
|
|
27
31
|
|
|
28
32
|
return new Promise((resolve, reject) => {
|
|
@@ -33,7 +37,7 @@ export function request(method, path, body) {
|
|
|
33
37
|
let payload = jsonStr ? Buffer.from(jsonStr) : null
|
|
34
38
|
let useGzip = false
|
|
35
39
|
|
|
36
|
-
if (payload && payload.length > GZIP_THRESHOLD) {
|
|
40
|
+
if (payload && payload.length > GZIP_THRESHOLD && !noGzip) {
|
|
37
41
|
payload = gzipSync(payload)
|
|
38
42
|
useGzip = true
|
|
39
43
|
}
|
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")
|
|
150
|
+
}
|
|
151
|
+
|
|
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")
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
// ─── Media manifest (
|
|
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,
|
|
266
|
-
}
|
|
267
|
-
|
|
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 }
|
|
287
|
+
return { allFiles, total, skipped, textByPath }
|
|
292
288
|
}
|
|
293
289
|
|
|
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,76 +439,58 @@ 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 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
492
|
const baseMessage =
|
|
488
|
-
`Deploy accepted — ${
|
|
493
|
+
`Deploy accepted — ${allFiles.length} files (${formatBytes(total)}). ` +
|
|
489
494
|
`Build running (${label}, ~20-60s). Poll manage_frontend({operation:"build_status"}) every ~5s.`
|
|
490
495
|
|
|
491
496
|
let message = baseMessage
|
|
@@ -498,20 +503,12 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
|
498
503
|
deployed: false,
|
|
499
504
|
url: result.url,
|
|
500
505
|
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
|
-
},
|
|
506
|
+
build_status: confirmation.status || "queued",
|
|
507
|
+
files_total: allFiles.length,
|
|
508
|
+
bytes_total: total,
|
|
509
|
+
project_hash: projectHash,
|
|
510
|
+
last_deploy_hash_updated: confirmation.confirmed,
|
|
511
|
+
...(confirmation.reason ? { confirmation_warning: confirmation.reason } : {}),
|
|
515
512
|
|
|
516
513
|
...(hasUnresolvedAssets ? { assets_requiring_action: assetsAction } : {}),
|
|
517
514
|
...(assetsAction?.all_synced ? { assets_synced: assetsAction.already_in_bucket } : {}),
|
|
@@ -522,7 +519,7 @@ export async function deployFromSource({ sourceDirectory, project_id }) {
|
|
|
522
519
|
priority_order: [
|
|
523
520
|
"1. Execute each suggested_action in assets_requiring_action.files.",
|
|
524
521
|
"2. Edit source files to use the returned URL.",
|
|
525
|
-
"3. Re-deploy.
|
|
522
|
+
"3. Re-deploy.",
|
|
526
523
|
"4. Poll build_status until terminal.",
|
|
527
524
|
],
|
|
528
525
|
terminal_statuses: ["success", "failure"],
|
package/src/tools/frontend.js
CHANGED
|
@@ -60,6 +60,11 @@ export const manageFrontendTool = {
|
|
|
60
60
|
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
61
|
default: false,
|
|
62
62
|
},
|
|
63
|
+
force: {
|
|
64
|
+
type: "boolean",
|
|
65
|
+
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.",
|
|
66
|
+
default: false,
|
|
67
|
+
},
|
|
63
68
|
deployment_id: {
|
|
64
69
|
type: "string",
|
|
65
70
|
description: "logs only. Deployment UUID obtained from list_deployments.",
|
|
@@ -72,7 +77,7 @@ export const manageFrontendTool = {
|
|
|
72
77
|
required: ["operation"],
|
|
73
78
|
},
|
|
74
79
|
|
|
75
|
-
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, deployment_id, limit } = {}) {
|
|
80
|
+
async execute({ operation, project_id, sourceDirectory, targetDirectory, overwrite, force, deployment_id, limit } = {}) {
|
|
76
81
|
if (!operation) {
|
|
77
82
|
return { success: false, error: "operation is required (deploy | sync | status | build_status | list_deployments | logs)." }
|
|
78
83
|
}
|
|
@@ -86,7 +91,7 @@ export const manageFrontendTool = {
|
|
|
86
91
|
if (!sourceDirectory) {
|
|
87
92
|
return { success: false, error: "operation 'deploy' requires 'sourceDirectory' (absolute path to your frontend project root)." }
|
|
88
93
|
}
|
|
89
|
-
return await deployFromSource({ sourceDirectory, project_id })
|
|
94
|
+
return await deployFromSource({ sourceDirectory, project_id, force: !!force })
|
|
90
95
|
|
|
91
96
|
case "sync":
|
|
92
97
|
if (!targetDirectory) {
|
package/src/tools/sync.js
CHANGED
|
@@ -92,22 +92,33 @@ export async function syncFromRemote({ project_id, targetDirectory, overwrite =
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Seed the deploy
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// after a sync
|
|
95
|
+
// Seed the last-deploy hash so the next deploy can skip if nothing
|
|
96
|
+
// changed. We compute the same project-wide hash deploy.js produces
|
|
97
|
+
// (sorted path+file-hash pairs). If the user runs `manage_frontend(deploy)`
|
|
98
|
+
// right after a sync and hasn't edited anything, it returns "no_changes"
|
|
99
|
+
// instantly without re-uploading 30 MB over the wire.
|
|
99
100
|
try {
|
|
100
101
|
const dypaiBase = existsSync(join(targetDirectory, "dypai"))
|
|
101
102
|
? join(targetDirectory, "dypai", ".dypai")
|
|
102
103
|
: join(targetDirectory, ".dypai")
|
|
103
104
|
mkdirSync(dypaiBase, { recursive: true })
|
|
105
|
+
|
|
106
|
+
// Same algorithm as deploy.js:computeProjectHash — sort by path, join
|
|
107
|
+
// path + hash with NUL, SHA-256 the concatenation. Keep in sync.
|
|
108
|
+
const entries = Object.entries(hashMap).sort(([a], [b]) => a.localeCompare(b))
|
|
109
|
+
const h = createHash("sha256")
|
|
110
|
+
for (const [path, fileHash] of entries) {
|
|
111
|
+
h.update(path); h.update("\0"); h.update(fileHash); h.update("\0")
|
|
112
|
+
}
|
|
113
|
+
const projectHash = h.digest("hex")
|
|
114
|
+
|
|
104
115
|
writeFileSync(
|
|
105
|
-
join(dypaiBase, "deploy
|
|
106
|
-
JSON.stringify(
|
|
116
|
+
join(dypaiBase, "last-deploy.json"),
|
|
117
|
+
JSON.stringify({ hash: projectHash, at: new Date().toISOString() }, null, 2) + "\n",
|
|
107
118
|
)
|
|
108
119
|
} catch (e) {
|
|
109
120
|
// Non-fatal: the first deploy will just be a full deploy.
|
|
110
|
-
failures.push({ path: ".dypai/deploy
|
|
121
|
+
failures.push({ path: ".dypai/last-deploy.json", reason: `Hash seed failed: ${e.message}` })
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
// Structured next_steps so the agent can act without re-prompting the LLM.
|