@nmakarov/cli-toolkit 0.51.0 → 0.53.0

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/dist/deploy.js CHANGED
@@ -11,8 +11,13 @@ function defineService(service) {
11
11
  const pm2 = {
12
12
  appName: service.name,
13
13
  args: "",
14
+ stopAllowance: 60,
14
15
  ...service.pm2
15
16
  };
17
+ if (pm2.killTimeout == null) {
18
+ const stopSec = Number(pm2.stopAllowance);
19
+ pm2.killTimeout = Number.isFinite(stopSec) && stopSec > 0 ? Math.floor(stopSec * 1e3) + 5e3 : 65e3;
20
+ }
16
21
  const nginx = service.nginx ? { siteName: service.name, ...service.nginx } : null;
17
22
  return {
18
23
  repoDirName: deriveRepoDirName(service.repoUrl),
@@ -93,6 +98,30 @@ function run(cmd, args, options = {}) {
93
98
  function runShell(command, options = {}) {
94
99
  return run("bash", ["-lc", command], options);
95
100
  }
101
+ function runCapture(cmd, args, options = {}) {
102
+ const { cwd, env, logger, allowFail = false } = options;
103
+ return new Promise((resolve2, reject) => {
104
+ logger?.info?.(`$ ${cmd} ${args.join(" ")}${cwd ? ` (cwd=${cwd})` : ""}`);
105
+ const child = spawn(cmd, args, {
106
+ cwd,
107
+ env: env ?? process.env,
108
+ stdio: ["ignore", "pipe", "pipe"]
109
+ });
110
+ let stdout = "";
111
+ let stderr = "";
112
+ child.stdout?.on("data", (chunk) => {
113
+ stdout += chunk.toString();
114
+ });
115
+ child.stderr?.on("data", (chunk) => {
116
+ stderr += chunk.toString();
117
+ });
118
+ child.on("error", reject);
119
+ child.on("close", (code) => {
120
+ if (code === 0 || allowFail) resolve2({ stdout, stderr, code });
121
+ else reject(new Error(`${cmd} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
122
+ });
123
+ });
124
+ }
96
125
 
97
126
  // src/deploy/log.js
98
127
  import { appendFile, mkdir } from "fs/promises";
@@ -310,7 +339,7 @@ async function runReleaseTests(service, releasePath, paths, options = {}) {
310
339
  // src/deploy/prune.js
311
340
  import { rm as rm2, readlink as readlink2 } from "fs/promises";
312
341
  async function pruneReleases(service, paths, options = {}) {
313
- const { dryRun = false, logger = console } = options;
342
+ const { dryRun = false, logger = console, protect = [] } = options;
314
343
  const keep = service.keepReleases ?? 3;
315
344
  const releases = await listReleases(paths);
316
345
  let activeName = null;
@@ -324,6 +353,9 @@ async function pruneReleases(service, paths, options = {}) {
324
353
  if (keepSet.size < keep) keepSet.add(rel.name);
325
354
  }
326
355
  if (activeName) keepSet.add(activeName);
356
+ for (const name of protect) {
357
+ if (name) keepSet.add(name);
358
+ }
327
359
  const toRemove = releases.filter((rel) => !keepSet.has(rel.name));
328
360
  for (const rel of toRemove) {
329
361
  if (dryRun) {
@@ -337,14 +369,101 @@ async function pruneReleases(service, paths, options = {}) {
337
369
  }
338
370
 
339
371
  // src/deploy/pm2.js
372
+ function sleep(ms) {
373
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
374
+ }
375
+ async function getPm2Process(appName, options = {}) {
376
+ const { logger } = options;
377
+ const { stdout } = await runCapture("bash", ["-lc", "pm2 jlist"], { logger });
378
+ let list;
379
+ try {
380
+ list = JSON.parse(stdout || "[]");
381
+ } catch (err) {
382
+ throw new Error(`pm2 jlist: invalid JSON (${err?.message ?? err})`);
383
+ }
384
+ if (!Array.isArray(list)) return null;
385
+ return list.find((p) => p?.name === appName) ?? null;
386
+ }
387
+ async function waitPm2(appName, predicate, options = {}) {
388
+ const {
389
+ timeoutMs = 65e3,
390
+ pollMs = 500,
391
+ dryRun = false,
392
+ logger = console,
393
+ label = "condition"
394
+ } = options;
395
+ if (dryRun) {
396
+ logger.info?.(`[dryRun] would wait pm2 ${appName} for ${label}`);
397
+ return null;
398
+ }
399
+ const deadline = Date.now() + timeoutMs;
400
+ let lastStatus = "(unknown)";
401
+ while (Date.now() < deadline) {
402
+ const proc = await getPm2Process(appName, { logger });
403
+ lastStatus = proc?.pm2_env?.status ?? "(missing)";
404
+ if (predicate(proc)) return proc;
405
+ await sleep(pollMs);
406
+ }
407
+ throw new Error(
408
+ `pm2 wait timed out after ${timeoutMs}ms for ${appName} (${label}); last status=${lastStatus}`
409
+ );
410
+ }
340
411
  async function reloadPm2(paths, options = {}) {
341
- const { dryRun = false, logger = console } = options;
412
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
342
413
  if (dryRun) {
343
414
  logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
344
415
  return;
345
416
  }
346
417
  await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
347
418
  logger.info("pm2 reloaded");
419
+ if (appName) {
420
+ await waitPm2(
421
+ appName,
422
+ (proc) => proc?.pm2_env?.status === "online",
423
+ { timeoutMs: waitTimeoutMs, logger, label: "online after reload" }
424
+ );
425
+ logger.info(`pm2 ${appName} online`);
426
+ }
427
+ }
428
+ async function stopPm2(appName, options = {}) {
429
+ const { dryRun = false, logger = console, waitTimeoutMs = 65e3 } = options;
430
+ if (dryRun) {
431
+ logger.info(`[dryRun] would pm2 stop ${appName}`);
432
+ return;
433
+ }
434
+ const before = await getPm2Process(appName, { logger });
435
+ if (!before) {
436
+ logger.info(`pm2 ${appName}: not present (already stopped)`);
437
+ return;
438
+ }
439
+ if (before.pm2_env?.status === "stopped") {
440
+ logger.info(`pm2 ${appName}: already stopped`);
441
+ return;
442
+ }
443
+ await runShell(`pm2 stop "${appName}"`, { logger });
444
+ await waitPm2(
445
+ appName,
446
+ (proc) => !proc || proc.pm2_env?.status === "stopped",
447
+ { timeoutMs: waitTimeoutMs, logger, label: "stopped" }
448
+ );
449
+ logger.info(`pm2 ${appName} stopped`);
450
+ }
451
+ async function startPm2(paths, options = {}) {
452
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
453
+ if (dryRun) {
454
+ logger.info(`[dryRun] would pm2 start ${paths.ecosystem} --update-env`);
455
+ return;
456
+ }
457
+ await runShell(`pm2 start "${paths.ecosystem}" --update-env`, { logger });
458
+ logger.info("pm2 started");
459
+ if (appName) {
460
+ await waitPm2(
461
+ appName,
462
+ (proc) => proc?.pm2_env?.status === "online",
463
+ { timeoutMs: waitTimeoutMs, logger, label: "online after start" }
464
+ );
465
+ logger.info(`pm2 ${appName} online`);
466
+ }
348
467
  }
349
468
 
350
469
  // src/deploy/nginx.js
@@ -573,10 +692,26 @@ async function requireDeployRoot(parentDir, serviceRoot) {
573
692
  This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
574
693
  );
575
694
  }
695
+ function resolveKillTimeoutMs(pm2) {
696
+ const explicit = Number(pm2?.killTimeout);
697
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
698
+ const stopSec = Number(pm2?.stopAllowance);
699
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
700
+ return 65e3;
701
+ }
702
+ function resolvePm2Args(pm2) {
703
+ const base = String(pm2?.args ?? "").trim();
704
+ const stopSec = Number(pm2?.stopAllowance);
705
+ if (!Number.isFinite(stopSec) || stopSec <= 0) return base;
706
+ if (/(?:^|\s)--stopAllowance=/.test(base)) return base;
707
+ return `${base}${base ? " " : ""}--stopAllowance=${Math.floor(stopSec)}`.trim();
708
+ }
576
709
  function buildEcosystemConfig(service, paths) {
577
710
  const { pm2 } = service;
578
711
  const outLog = join8(paths.logs, `${pm2.appName}.out.log`);
579
712
  const errLog = join8(paths.logs, `${pm2.appName}.err.log`);
713
+ const killTimeoutMs = resolveKillTimeoutMs(pm2);
714
+ const args = resolvePm2Args(pm2);
580
715
  return `/**
581
716
  * pm2 ecosystem for ${service.name} \u2014 written by cli-toolkit deploy from the
582
717
  * service manifest (init/deploy). Do not hand-edit; change pm2.* in the
@@ -588,7 +723,7 @@ module.exports = {
588
723
  name: "${pm2.appName}",
589
724
  script: "${pm2.script}",
590
725
  cwd: "${paths.current}",
591
- args: "${pm2.args ?? ""}",
726
+ args: ${JSON.stringify(args)},
592
727
  instances: 1,
593
728
  exec_mode: "fork",
594
729
  autorestart: true,
@@ -596,6 +731,8 @@ module.exports = {
596
731
  max_restarts: 10,
597
732
  restart_delay: 2000,
598
733
  max_memory_restart: "1500M",
734
+ // Grace window after SIGINT/SIGTERM before SIGKILL (ms). Align with --stopAllowance.
735
+ kill_timeout: ${killTimeoutMs},
599
736
  out_file: "${outLog}",
600
737
  error_file: "${errLog}",
601
738
  merge_logs: true,
@@ -796,15 +933,27 @@ async function bootstrapHost(service, options = {}) {
796
933
  }
797
934
 
798
935
  // src/deploy/deploy-service.js
936
+ import { readlink as readlink3 } from "fs/promises";
937
+ function resolveKillTimeoutMs2(service) {
938
+ const explicit = Number(service.pm2?.killTimeout);
939
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
940
+ const stopSec = Number(service.pm2?.stopAllowance);
941
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
942
+ return 65e3;
943
+ }
799
944
  async function deployService(service, options = {}) {
800
945
  const {
801
946
  dryRun = false,
802
947
  skipPull = false,
803
948
  skipTests = false,
804
949
  skipNginx = false,
950
+ stopFirst = false,
805
951
  logger = console
806
952
  } = options;
807
953
  const paths = servicePaths(service);
954
+ const appName = service.pm2.appName;
955
+ const killTimeoutMs = resolveKillTimeoutMs2(service);
956
+ const waitTimeoutMs = killTimeoutMs + 1e4;
808
957
  await initServiceStructure(service, { dryRun, logger });
809
958
  if (!skipPull) await pullRepo(service, { dryRun, logger });
810
959
  await syncEnv(service, { dryRun, logger });
@@ -812,14 +961,31 @@ async function deployService(service, options = {}) {
812
961
  await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
813
962
  await installDeps(service, releasePath, paths, { dryRun, logger });
814
963
  if (!skipTests) await runReleaseTests(service, releasePath, paths, { dryRun, logger });
815
- await activateRelease(releasePath, paths, { dryRun, logger });
816
- await pruneReleases(service, paths, { dryRun, logger });
817
- await reloadPm2(paths, { dryRun, logger });
964
+ let previousReleaseName = null;
965
+ try {
966
+ const prev = await readlink3(paths.current);
967
+ previousReleaseName = prev.split("/").pop() || null;
968
+ } catch {
969
+ }
970
+ if (stopFirst) {
971
+ logger.info(`deploy mode=stopFirst app=${appName} killTimeoutMs=${killTimeoutMs}`);
972
+ await stopPm2(appName, { dryRun, logger, waitTimeoutMs });
973
+ await activateRelease(releasePath, paths, { dryRun, logger });
974
+ await pruneReleases(service, paths, { dryRun, logger });
975
+ await startPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
976
+ } else {
977
+ logger.info(
978
+ `deploy mode=rolling app=${appName} killTimeoutMs=${killTimeoutMs}` + (previousReleaseName ? ` previous=${previousReleaseName}` : "")
979
+ );
980
+ await activateRelease(releasePath, paths, { dryRun, logger });
981
+ await reloadPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
982
+ await pruneReleases(service, paths, { dryRun, logger });
983
+ }
818
984
  if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
819
- const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
985
+ const summary = `deploy complete stamp=${stamp} mode=${stopFirst ? "stopFirst" : "rolling"} dryRun=${dryRun}`;
820
986
  logger.info(summary);
821
987
  if (!dryRun) await appendDeployLog(paths.deployLog, summary);
822
- return { stamp, releasePath };
988
+ return { stamp, releasePath, mode: stopFirst ? "stopFirst" : "rolling" };
823
989
  }
824
990
 
825
991
  // src/deploy/provision-service.js
@@ -860,7 +1026,7 @@ async function provisionService(service, options = {}) {
860
1026
  }
861
1027
 
862
1028
  // src/deploy/rollback-service.js
863
- import { readlink as readlink3 } from "fs/promises";
1029
+ import { readlink as readlink4 } from "fs/promises";
864
1030
  async function rollbackService(service, options = {}) {
865
1031
  const { release: targetName, dryRun = false, logger = console } = options;
866
1032
  const paths = servicePaths(service);
@@ -868,7 +1034,7 @@ async function rollbackService(service, options = {}) {
868
1034
  if (releases.length === 0) throw new Error("No releases to roll back to");
869
1035
  let activeName = null;
870
1036
  try {
871
- const target = await readlink3(paths.current);
1037
+ const target = await readlink4(paths.current);
872
1038
  activeName = target.split("/").pop();
873
1039
  } catch {
874
1040
  throw new Error("No active release (current symlink missing)");
@@ -1106,6 +1272,7 @@ export {
1106
1272
  ensureEnvOnRemote,
1107
1273
  ensureRemoteRepo,
1108
1274
  ensureRepoDependencies,
1275
+ getPm2Process,
1109
1276
  initServiceStructure,
1110
1277
  installDeps,
1111
1278
  installOperatorShell,
@@ -1127,6 +1294,7 @@ export {
1127
1294
  resolveServiceFrom,
1128
1295
  rollbackService,
1129
1296
  run,
1297
+ runCapture,
1130
1298
  runReleaseTests,
1131
1299
  runRemoteCli,
1132
1300
  runRemoteStatus,
@@ -1135,7 +1303,10 @@ export {
1135
1303
  servicePaths,
1136
1304
  shellQuote,
1137
1305
  sshRun,
1306
+ startPm2,
1307
+ stopPm2,
1138
1308
  syncEnv,
1309
+ waitPm2,
1139
1310
  writeReleaseBuildInfo
1140
1311
  };
1141
1312
  //# sourceMappingURL=deploy.js.map