@agent8/deploy 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/bin/agent8.js +131 -53
  2. package/package.json +5 -3
  3. package/.env +0 -0
package/bin/agent8.js CHANGED
@@ -148,12 +148,18 @@ if (fs.existsSync(crossrampFilePath)) {
148
148
  const filesToUpload = [];
149
149
  const newHashes = {};
150
150
 
151
- // Calculate file hash
152
- const calculateFileHash = (filePath) => {
153
- const fileContent = fs.readFileSync(filePath);
154
- return crypto.createHash("md5").update(fileContent).digest("hex");
155
- };
151
+ // Calculate file hash by streaming — a large asset must not be buffered
152
+ // whole just to be hashed (RSS high-water was dominated by this).
153
+ const calculateFileHash = (filePath) =>
154
+ new Promise((resolve, reject) => {
155
+ const hash = crypto.createHash("md5");
156
+ fs.createReadStream(filePath)
157
+ .on("data", (chunk) => hash.update(chunk))
158
+ .on("end", () => resolve(hash.digest("hex")))
159
+ .on("error", reject);
160
+ });
156
161
 
162
+ const main = async () => {
157
163
  // Check for server.js (priority: ./server.js, fallback: ./server/dist/server.js)
158
164
  let serverFilePath = path.resolve(process.cwd(), "server.js");
159
165
  if (!fs.existsSync(serverFilePath)) {
@@ -171,7 +177,7 @@ if (fs.existsSync(serverFilePath)) {
171
177
  const serverContent = fs.readFileSync(serverFilePath, "utf8");
172
178
  eval(`(function() { ${serverContent}; })`)();
173
179
 
174
- const hash = calculateFileHash(serverFilePath);
180
+ const hash = await calculateFileHash(serverFilePath);
175
181
  newHashes["server.js"] = hash;
176
182
 
177
183
  if (prodMode || hash !== deployedHashes["server.js"]) {
@@ -179,6 +185,7 @@ if (fs.existsSync(serverFilePath)) {
179
185
  filePath: serverFilePath,
180
186
  uploadPath: "",
181
187
  fileName: "server.js",
188
+ deployKey: "server.js",
182
189
  });
183
190
  }
184
191
  } catch (error) {
@@ -191,7 +198,10 @@ if (!previewMode) {
191
198
  // Check for dist directory
192
199
  const distDirPath = path.resolve(process.cwd(), "dist");
193
200
  if (fs.existsSync(distDirPath) && fs.statSync(distDirPath).isDirectory()) {
194
- const processDistFiles = (dirPath, basePath = "") => {
201
+ // Collect paths synchronously, then hash sequentially via streams so at
202
+ // most one file's read window is in memory at a time.
203
+ const distEntries = [];
204
+ const collectDistFiles = (dirPath, basePath = "") => {
195
205
  const files = fs.readdirSync(dirPath);
196
206
 
197
207
  files.forEach((file) => {
@@ -203,23 +213,28 @@ if (!previewMode) {
203
213
  .replace(/\\/g, "/");
204
214
 
205
215
  if (fs.statSync(fullPath).isDirectory()) {
206
- processDistFiles(fullPath, relativePath);
216
+ collectDistFiles(fullPath, relativePath);
207
217
  } else {
208
- const hash = calculateFileHash(fullPath);
209
- newHashes[deployKey] = hash;
210
-
211
- if (prodMode || hash !== deployedHashes[deployKey]) {
212
- filesToUpload.push({
213
- filePath: fullPath,
214
- uploadPath: uploadPath === "." ? "" : uploadPath,
215
- fileName: path.basename(file),
216
- });
217
- }
218
+ distEntries.push({ fullPath, uploadPath, deployKey });
218
219
  }
219
220
  });
220
221
  };
221
222
 
222
- processDistFiles(distDirPath);
223
+ collectDistFiles(distDirPath);
224
+
225
+ for (const entry of distEntries) {
226
+ const hash = await calculateFileHash(entry.fullPath);
227
+ newHashes[entry.deployKey] = hash;
228
+
229
+ if (prodMode || hash !== deployedHashes[entry.deployKey]) {
230
+ filesToUpload.push({
231
+ filePath: entry.fullPath,
232
+ uploadPath: entry.uploadPath === "." ? "" : entry.uploadPath,
233
+ fileName: path.basename(entry.fullPath),
234
+ deployKey: entry.deployKey,
235
+ });
236
+ }
237
+ }
223
238
  }
224
239
  }
225
240
 
@@ -229,32 +244,51 @@ if (filesToUpload.length === 0) {
229
244
  "No file changes detected. Setting up CrossRamp configuration..."
230
245
  );
231
246
 
232
- setupCrossRamp(targetVerse, crossrampConfig).then((success) => {
233
- if (success) {
234
- console.log("CrossRamp setup completed.");
235
- }
236
- process.exit(success ? 0 : 1);
237
- });
247
+ const success = await setupCrossRamp(targetVerse, crossrampConfig);
248
+ if (success) {
249
+ console.log("CrossRamp setup completed.");
250
+ }
251
+ process.exit(success ? 0 : 1);
238
252
  } else {
239
253
  console.log("No changes detected. Nothing to deploy.");
240
254
  process.exit(0);
241
255
  }
242
- return;
243
256
  }
244
257
 
245
258
  console.log(`Found ${filesToUpload.length} files to upload...`);
246
259
 
247
- // Upload files in parallel
248
- const uploadPromises = filesToUpload.map((fileInfo) => {
260
+ // Upload files with bounded concurrency, streaming each file from disk.
261
+ // The previous implementation buffered every file with readFileSync and
262
+ // started all uploads at once, so peak memory was roughly the whole dist
263
+ // size times 2-3 (raw buffer + multipart framing + axios serialization) —
264
+ // measured 2GB+ for asset-heavy games, inside 4GB containers. Streaming
265
+ // with knownLength keeps the request identical on the wire (same multipart
266
+ // framing, exact Content-Length) while holding only a few in-flight
267
+ // buffers in memory.
268
+ const UPLOAD_CONCURRENCY = 5;
269
+
270
+ const uploadFile = (fileInfo) => {
249
271
  const { filePath, uploadPath, fileName } = fileInfo;
250
272
  const form = new FormData();
251
- const fileContent = fs.readFileSync(filePath);
252
- form.append("file", fileContent, fileName);
273
+ form.append("file", fs.createReadStream(filePath), {
274
+ filename: fileName,
275
+ knownLength: fs.statSync(filePath).size,
276
+ });
253
277
  form.append("path", uploadPath || "/");
254
278
 
255
279
  return axios
256
280
  .post(`${endpoint}/verses/${targetVerse}/files`, form, {
257
- headers: getAuthHeaders(form.getHeaders()),
281
+ headers: {
282
+ ...getAuthHeaders(form.getHeaders()),
283
+ "Content-Length": form.getLengthSync(),
284
+ },
285
+ // maxRedirects: 0 forces the plain http adapter. With redirects
286
+ // enabled, follow-redirects buffers the ENTIRE request body in memory
287
+ // (to replay it after a redirect), which silently defeats streaming.
288
+ // The upload endpoint never redirects POSTs, so nothing is lost.
289
+ maxRedirects: 0,
290
+ maxBodyLength: Infinity,
291
+ maxContentLength: Infinity,
258
292
  })
259
293
  .then(() => {
260
294
  console.log(`Uploaded: ${uploadPath}/${fileName}`);
@@ -267,30 +301,74 @@ const uploadPromises = filesToUpload.map((fileInfo) => {
267
301
  );
268
302
  return false;
269
303
  });
304
+ };
305
+
306
+ // Minimal worker pool: at most `concurrency` uploads in flight, results in
307
+ // input order, never rejects (uploadFile already maps errors to false).
308
+ const uploadAll = async (files, concurrency) => {
309
+ const results = new Array(files.length);
310
+ let next = 0;
311
+ const workers = Array.from(
312
+ { length: Math.min(concurrency, files.length) },
313
+ async () => {
314
+ while (next < files.length) {
315
+ const i = next++;
316
+ results[i] = await uploadFile(files[i]);
317
+ }
318
+ }
319
+ );
320
+ await Promise.all(workers);
321
+ return results;
322
+ };
323
+
324
+ const results = await uploadAll(filesToUpload, UPLOAD_CONCURRENCY);
325
+ const successCount = results.filter(Boolean).length;
326
+ console.log(
327
+ `Deployment complete: ${successCount}/${filesToUpload.length} files uploaded successfully.`
328
+ );
329
+
330
+ // A failed upload must not be recorded as deployed — otherwise the next
331
+ // incremental run sees a matching hash, skips the file, and the verse stays
332
+ // broken until a --prod redeploy. Revert each failed file's hash to its last
333
+ // deployed value (the server still holds that content), or drop it entirely
334
+ // if it was never deployed, so the next run retries it.
335
+ const failedKeys = [];
336
+ results.forEach((ok, i) => {
337
+ if (ok) return;
338
+ const { deployKey } = filesToUpload[i];
339
+ failedKeys.push(deployKey);
340
+ if (deployKey in deployedHashes) {
341
+ newHashes[deployKey] = deployedHashes[deployKey];
342
+ } else {
343
+ delete newHashes[deployKey];
344
+ }
270
345
  });
271
346
 
272
- Promise.all(uploadPromises)
273
- .then(async (results) => {
274
- const successCount = results.filter(Boolean).length;
275
- console.log(
276
- `Deployment complete: ${successCount}/${filesToUpload.length} files uploaded successfully.`
347
+ // CrossRamp setup if config exists and all files uploaded successfully
348
+ if (crossrampConfig && successCount === filesToUpload.length) {
349
+ console.log("Setting up CrossRamp configuration...");
350
+ const success = await setupCrossRamp(targetVerse, crossrampConfig);
351
+ if (!success) {
352
+ console.warn(
353
+ "Warning: CrossRamp setup failed, but files were deployed"
277
354
  );
355
+ }
356
+ }
278
357
 
279
- // CrossRamp setup if config exists and all files uploaded successfully
280
- if (crossrampConfig && successCount === filesToUpload.length) {
281
- console.log("Setting up CrossRamp configuration...");
282
- const success = await setupCrossRamp(targetVerse, crossrampConfig);
283
- if (!success) {
284
- console.warn(
285
- "Warning: CrossRamp setup failed, but files were deployed"
286
- );
287
- }
288
- }
358
+ // Save new hashes to .deployed file
359
+ fs.writeFileSync(deployedFilePath, JSON.stringify(newHashes, null, 2));
360
+ console.log("Updated .deployed file with new file hashes");
289
361
 
290
- // Save new hashes to .deployed file
291
- fs.writeFileSync(deployedFilePath, JSON.stringify(newHashes, null, 2));
292
- console.log("Updated .deployed file with new file hashes");
293
- })
294
- .catch((error) => {
295
- console.error("Deployment failed:", error.message);
296
- });
362
+ if (failedKeys.length > 0) {
363
+ console.error(
364
+ `Deployment incomplete: ${failedKeys.length} file(s) failed to upload and will be retried on the next run:`
365
+ );
366
+ failedKeys.forEach((key) => console.error(` - ${key}`));
367
+ process.exit(1);
368
+ }
369
+ };
370
+
371
+ main().catch((error) => {
372
+ console.error("Deployment failed:", error.message);
373
+ process.exit(1);
374
+ });
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@agent8/deploy",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "A CLI tool for running agent8 commands",
5
- "main": "index.js",
6
5
  "bin": {
7
6
  "agent8": "./bin/agent8.js"
8
7
  },
@@ -13,5 +12,8 @@
13
12
  "axios": "^1.7.9",
14
13
  "dotenv": "^16.4.7",
15
14
  "form-data": "^4.0.1"
16
- }
15
+ },
16
+ "files": [
17
+ "bin"
18
+ ]
17
19
  }
package/.env DELETED
File without changes