@agent8/deploy 1.3.0 → 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 +142 -55
  2. package/package.json +5 -3
  3. package/.env +0 -0
package/bin/agent8.js CHANGED
@@ -148,21 +148,36 @@ 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
+ });
161
+
162
+ const main = async () => {
163
+ // Check for server.js (priority: ./server.js, fallback: ./server/dist/server.js)
164
+ let serverFilePath = path.resolve(process.cwd(), "server.js");
165
+ if (!fs.existsSync(serverFilePath)) {
166
+ // Fallback to ./server/dist/server.js if ./server.js doesn't exist
167
+ const fallbackPath = path.resolve(process.cwd(), "server/dist/server.js");
168
+ if (fs.existsSync(fallbackPath)) {
169
+ serverFilePath = fallbackPath;
170
+ console.log("Found server file: ./server/dist/server.js");
171
+ }
172
+ }
156
173
 
157
- // Check for server.js
158
- const serverFilePath = path.resolve(process.cwd(), "server.js");
159
174
  if (fs.existsSync(serverFilePath)) {
160
175
  // Validate server.js syntax before deployment
161
176
  try {
162
177
  const serverContent = fs.readFileSync(serverFilePath, "utf8");
163
178
  eval(`(function() { ${serverContent}; })`)();
164
179
 
165
- const hash = calculateFileHash(serverFilePath);
180
+ const hash = await calculateFileHash(serverFilePath);
166
181
  newHashes["server.js"] = hash;
167
182
 
168
183
  if (prodMode || hash !== deployedHashes["server.js"]) {
@@ -170,6 +185,7 @@ if (fs.existsSync(serverFilePath)) {
170
185
  filePath: serverFilePath,
171
186
  uploadPath: "",
172
187
  fileName: "server.js",
188
+ deployKey: "server.js",
173
189
  });
174
190
  }
175
191
  } catch (error) {
@@ -182,7 +198,10 @@ if (!previewMode) {
182
198
  // Check for dist directory
183
199
  const distDirPath = path.resolve(process.cwd(), "dist");
184
200
  if (fs.existsSync(distDirPath) && fs.statSync(distDirPath).isDirectory()) {
185
- 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 = "") => {
186
205
  const files = fs.readdirSync(dirPath);
187
206
 
188
207
  files.forEach((file) => {
@@ -194,23 +213,28 @@ if (!previewMode) {
194
213
  .replace(/\\/g, "/");
195
214
 
196
215
  if (fs.statSync(fullPath).isDirectory()) {
197
- processDistFiles(fullPath, relativePath);
216
+ collectDistFiles(fullPath, relativePath);
198
217
  } else {
199
- const hash = calculateFileHash(fullPath);
200
- newHashes[deployKey] = hash;
201
-
202
- if (prodMode || hash !== deployedHashes[deployKey]) {
203
- filesToUpload.push({
204
- filePath: fullPath,
205
- uploadPath: uploadPath === "." ? "" : uploadPath,
206
- fileName: path.basename(file),
207
- });
208
- }
218
+ distEntries.push({ fullPath, uploadPath, deployKey });
209
219
  }
210
220
  });
211
221
  };
212
222
 
213
- 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
+ }
214
238
  }
215
239
  }
216
240
 
@@ -220,32 +244,51 @@ if (filesToUpload.length === 0) {
220
244
  "No file changes detected. Setting up CrossRamp configuration..."
221
245
  );
222
246
 
223
- setupCrossRamp(targetVerse, crossrampConfig).then((success) => {
224
- if (success) {
225
- console.log("CrossRamp setup completed.");
226
- }
227
- process.exit(success ? 0 : 1);
228
- });
247
+ const success = await setupCrossRamp(targetVerse, crossrampConfig);
248
+ if (success) {
249
+ console.log("CrossRamp setup completed.");
250
+ }
251
+ process.exit(success ? 0 : 1);
229
252
  } else {
230
253
  console.log("No changes detected. Nothing to deploy.");
231
254
  process.exit(0);
232
255
  }
233
- return;
234
256
  }
235
257
 
236
258
  console.log(`Found ${filesToUpload.length} files to upload...`);
237
259
 
238
- // Upload files in parallel
239
- 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) => {
240
271
  const { filePath, uploadPath, fileName } = fileInfo;
241
272
  const form = new FormData();
242
- const fileContent = fs.readFileSync(filePath);
243
- form.append("file", fileContent, fileName);
273
+ form.append("file", fs.createReadStream(filePath), {
274
+ filename: fileName,
275
+ knownLength: fs.statSync(filePath).size,
276
+ });
244
277
  form.append("path", uploadPath || "/");
245
278
 
246
279
  return axios
247
280
  .post(`${endpoint}/verses/${targetVerse}/files`, form, {
248
- 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,
249
292
  })
250
293
  .then(() => {
251
294
  console.log(`Uploaded: ${uploadPath}/${fileName}`);
@@ -258,30 +301,74 @@ const uploadPromises = filesToUpload.map((fileInfo) => {
258
301
  );
259
302
  return false;
260
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
+ }
261
345
  });
262
346
 
263
- Promise.all(uploadPromises)
264
- .then(async (results) => {
265
- const successCount = results.filter(Boolean).length;
266
- console.log(
267
- `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"
268
354
  );
355
+ }
356
+ }
269
357
 
270
- // CrossRamp setup if config exists and all files uploaded successfully
271
- if (crossrampConfig && successCount === filesToUpload.length) {
272
- console.log("Setting up CrossRamp configuration...");
273
- const success = await setupCrossRamp(targetVerse, crossrampConfig);
274
- if (!success) {
275
- console.warn(
276
- "Warning: CrossRamp setup failed, but files were deployed"
277
- );
278
- }
279
- }
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");
280
361
 
281
- // Save new hashes to .deployed file
282
- fs.writeFileSync(deployedFilePath, JSON.stringify(newHashes, null, 2));
283
- console.log("Updated .deployed file with new file hashes");
284
- })
285
- .catch((error) => {
286
- console.error("Deployment failed:", error.message);
287
- });
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.0",
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