@nmakarov/cli-toolkit 0.51.0 → 0.55.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.cjs CHANGED
@@ -34,9 +34,12 @@ __export(deploy_exports, {
34
34
  ensureEnvOnRemote: () => ensureEnvOnRemote,
35
35
  ensureRemoteRepo: () => ensureRemoteRepo,
36
36
  ensureRepoDependencies: () => ensureRepoDependencies,
37
+ getPm2Process: () => getPm2Process,
37
38
  initServiceStructure: () => initServiceStructure,
38
39
  installDeps: () => installDeps,
39
40
  installOperatorShell: () => installOperatorShell,
41
+ isFreshOnline: () => isFreshOnline,
42
+ isPidAlive: () => isPidAlive,
40
43
  listReleases: () => listReleases,
41
44
  loadServices: () => loadServices,
42
45
  npmEnv: () => npmEnv,
@@ -55,6 +58,7 @@ __export(deploy_exports, {
55
58
  resolveServiceFrom: () => resolveServiceFrom,
56
59
  rollbackService: () => rollbackService,
57
60
  run: () => run,
61
+ runCapture: () => runCapture,
58
62
  runReleaseTests: () => runReleaseTests,
59
63
  runRemoteCli: () => runRemoteCli,
60
64
  runRemoteStatus: () => runRemoteStatus,
@@ -63,7 +67,10 @@ __export(deploy_exports, {
63
67
  servicePaths: () => servicePaths,
64
68
  shellQuote: () => shellQuote,
65
69
  sshRun: () => sshRun,
70
+ startPm2: () => startPm2,
71
+ stopPm2: () => stopPm2,
66
72
  syncEnv: () => syncEnv,
73
+ waitPm2: () => waitPm2,
67
74
  writeReleaseBuildInfo: () => writeReleaseBuildInfo
68
75
  });
69
76
  module.exports = __toCommonJS(deploy_exports);
@@ -81,8 +88,13 @@ function defineService(service) {
81
88
  const pm2 = {
82
89
  appName: service.name,
83
90
  args: "",
91
+ stopAllowance: 60,
84
92
  ...service.pm2
85
93
  };
94
+ if (pm2.killTimeout == null) {
95
+ const stopSec = Number(pm2.stopAllowance);
96
+ pm2.killTimeout = Number.isFinite(stopSec) && stopSec > 0 ? Math.floor(stopSec * 1e3) + 5e3 : 65e3;
97
+ }
86
98
  const nginx = service.nginx ? { siteName: service.name, ...service.nginx } : null;
87
99
  return {
88
100
  repoDirName: deriveRepoDirName(service.repoUrl),
@@ -163,6 +175,30 @@ function run(cmd, args, options = {}) {
163
175
  function runShell(command, options = {}) {
164
176
  return run("bash", ["-lc", command], options);
165
177
  }
178
+ function runCapture(cmd, args, options = {}) {
179
+ const { cwd, env, logger, allowFail = false } = options;
180
+ return new Promise((resolve2, reject) => {
181
+ logger?.info?.(`$ ${cmd} ${args.join(" ")}${cwd ? ` (cwd=${cwd})` : ""}`);
182
+ const child = (0, import_node_child_process.spawn)(cmd, args, {
183
+ cwd,
184
+ env: env ?? process.env,
185
+ stdio: ["ignore", "pipe", "pipe"]
186
+ });
187
+ let stdout = "";
188
+ let stderr = "";
189
+ child.stdout?.on("data", (chunk) => {
190
+ stdout += chunk.toString();
191
+ });
192
+ child.stderr?.on("data", (chunk) => {
193
+ stderr += chunk.toString();
194
+ });
195
+ child.on("error", reject);
196
+ child.on("close", (code) => {
197
+ if (code === 0 || allowFail) resolve2({ stdout, stderr, code });
198
+ else reject(new Error(`${cmd} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
199
+ });
200
+ });
201
+ }
166
202
 
167
203
  // src/deploy/log.js
168
204
  var import_promises = require("fs/promises");
@@ -380,7 +416,7 @@ async function runReleaseTests(service, releasePath, paths, options = {}) {
380
416
  // src/deploy/prune.js
381
417
  var import_promises7 = require("fs/promises");
382
418
  async function pruneReleases(service, paths, options = {}) {
383
- const { dryRun = false, logger = console } = options;
419
+ const { dryRun = false, logger = console, protect = [] } = options;
384
420
  const keep = service.keepReleases ?? 3;
385
421
  const releases = await listReleases(paths);
386
422
  let activeName = null;
@@ -394,6 +430,9 @@ async function pruneReleases(service, paths, options = {}) {
394
430
  if (keepSet.size < keep) keepSet.add(rel.name);
395
431
  }
396
432
  if (activeName) keepSet.add(activeName);
433
+ for (const name of protect) {
434
+ if (name) keepSet.add(name);
435
+ }
397
436
  const toRemove = releases.filter((rel) => !keepSet.has(rel.name));
398
437
  for (const rel of toRemove) {
399
438
  if (dryRun) {
@@ -407,14 +446,139 @@ async function pruneReleases(service, paths, options = {}) {
407
446
  }
408
447
 
409
448
  // src/deploy/pm2.js
449
+ function sleep(ms) {
450
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
451
+ }
452
+ function isPidAlive(pid) {
453
+ const n = Number(pid);
454
+ if (!Number.isFinite(n) || n <= 0) return false;
455
+ try {
456
+ process.kill(n, 0);
457
+ return true;
458
+ } catch {
459
+ return false;
460
+ }
461
+ }
462
+ async function getPm2Process(appName, options = {}) {
463
+ const { logger } = options;
464
+ const { stdout } = await runCapture("bash", ["-lc", "pm2 jlist"], { logger });
465
+ let list;
466
+ try {
467
+ list = JSON.parse(stdout || "[]");
468
+ } catch (err) {
469
+ throw new Error(`pm2 jlist: invalid JSON (${err?.message ?? err})`);
470
+ }
471
+ if (!Array.isArray(list)) return null;
472
+ return list.find((p) => p?.name === appName) ?? null;
473
+ }
474
+ async function waitPm2(appName, predicate, options = {}) {
475
+ const {
476
+ timeoutMs = 65e3,
477
+ pollMs = 500,
478
+ dryRun = false,
479
+ logger = console,
480
+ label = "condition"
481
+ } = options;
482
+ if (dryRun) {
483
+ logger.info?.(`[dryRun] would wait pm2 ${appName} for ${label}`);
484
+ return null;
485
+ }
486
+ const deadline = Date.now() + timeoutMs;
487
+ let lastStatus = "(unknown)";
488
+ while (Date.now() < deadline) {
489
+ const proc = await getPm2Process(appName, { logger });
490
+ lastStatus = proc?.pm2_env?.status ?? "(missing)";
491
+ if (predicate(proc)) return proc;
492
+ await sleep(pollMs);
493
+ }
494
+ throw new Error(
495
+ `pm2 wait timed out after ${timeoutMs}ms for ${appName} (${label}); last status=${lastStatus}`
496
+ );
497
+ }
498
+ function isFreshOnline(proc, oldPid) {
499
+ if (proc?.pm2_env?.status !== "online") return false;
500
+ const pid = Number(proc.pid);
501
+ if (!Number.isFinite(pid) || pid <= 0) return false;
502
+ if (oldPid != null) {
503
+ if (pid === oldPid) return false;
504
+ if (isPidAlive(oldPid)) return false;
505
+ }
506
+ return true;
507
+ }
410
508
  async function reloadPm2(paths, options = {}) {
411
- const { dryRun = false, logger = console } = options;
509
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
412
510
  if (dryRun) {
413
511
  logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
414
512
  return;
415
513
  }
514
+ let oldPid = null;
515
+ if (appName) {
516
+ const before = await getPm2Process(appName, { logger });
517
+ const n = Number(before?.pid);
518
+ if (Number.isFinite(n) && n > 0 && before?.pm2_env?.status === "online") {
519
+ oldPid = n;
520
+ logger.info(`pm2 ${appName}: reloading (old pid=${oldPid})`);
521
+ }
522
+ }
416
523
  await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
417
524
  logger.info("pm2 reloaded");
525
+ if (appName) {
526
+ const proc = await waitPm2(
527
+ appName,
528
+ (p) => isFreshOnline(p, oldPid),
529
+ {
530
+ timeoutMs: waitTimeoutMs,
531
+ logger,
532
+ label: oldPid ? `online on new pid (old ${oldPid} gone)` : "online after reload"
533
+ }
534
+ );
535
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
536
+ }
537
+ }
538
+ async function stopPm2(appName, options = {}) {
539
+ const { dryRun = false, logger = console, waitTimeoutMs = 65e3 } = options;
540
+ if (dryRun) {
541
+ logger.info(`[dryRun] would pm2 stop ${appName}`);
542
+ return;
543
+ }
544
+ const before = await getPm2Process(appName, { logger });
545
+ if (!before) {
546
+ logger.info(`pm2 ${appName}: not present (already stopped)`);
547
+ return;
548
+ }
549
+ if (before.pm2_env?.status === "stopped") {
550
+ logger.info(`pm2 ${appName}: already stopped`);
551
+ return;
552
+ }
553
+ const oldPid = Number(before.pid);
554
+ await runShell(`pm2 stop "${appName}"`, { logger });
555
+ await waitPm2(
556
+ appName,
557
+ (proc) => {
558
+ if (proc && proc.pm2_env?.status !== "stopped") return false;
559
+ if (Number.isFinite(oldPid) && oldPid > 0 && isPidAlive(oldPid)) return false;
560
+ return true;
561
+ },
562
+ { timeoutMs: waitTimeoutMs, logger, label: "stopped" }
563
+ );
564
+ logger.info(`pm2 ${appName} stopped`);
565
+ }
566
+ async function startPm2(paths, options = {}) {
567
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
568
+ if (dryRun) {
569
+ logger.info(`[dryRun] would pm2 start ${paths.ecosystem} --update-env`);
570
+ return;
571
+ }
572
+ await runShell(`pm2 start "${paths.ecosystem}" --update-env`, { logger });
573
+ logger.info("pm2 started");
574
+ if (appName) {
575
+ const proc = await waitPm2(
576
+ appName,
577
+ (p) => isFreshOnline(p, null),
578
+ { timeoutMs: waitTimeoutMs, logger, label: "online after start" }
579
+ );
580
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
581
+ }
418
582
  }
419
583
 
420
584
  // src/deploy/nginx.js
@@ -643,10 +807,26 @@ async function requireDeployRoot(parentDir, serviceRoot) {
643
807
  This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
644
808
  );
645
809
  }
810
+ function resolveKillTimeoutMs(pm2) {
811
+ const explicit = Number(pm2?.killTimeout);
812
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
813
+ const stopSec = Number(pm2?.stopAllowance);
814
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
815
+ return 65e3;
816
+ }
817
+ function resolvePm2Args(pm2) {
818
+ const base = String(pm2?.args ?? "").trim();
819
+ const stopSec = Number(pm2?.stopAllowance);
820
+ if (!Number.isFinite(stopSec) || stopSec <= 0) return base;
821
+ if (/(?:^|\s)--stopAllowance=/.test(base)) return base;
822
+ return `${base}${base ? " " : ""}--stopAllowance=${Math.floor(stopSec)}`.trim();
823
+ }
646
824
  function buildEcosystemConfig(service, paths) {
647
825
  const { pm2 } = service;
648
826
  const outLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.out.log`);
649
827
  const errLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.err.log`);
828
+ const killTimeoutMs = resolveKillTimeoutMs(pm2);
829
+ const args = resolvePm2Args(pm2);
650
830
  return `/**
651
831
  * pm2 ecosystem for ${service.name} \u2014 written by cli-toolkit deploy from the
652
832
  * service manifest (init/deploy). Do not hand-edit; change pm2.* in the
@@ -658,7 +838,7 @@ module.exports = {
658
838
  name: "${pm2.appName}",
659
839
  script: "${pm2.script}",
660
840
  cwd: "${paths.current}",
661
- args: "${pm2.args ?? ""}",
841
+ args: ${JSON.stringify(args)},
662
842
  instances: 1,
663
843
  exec_mode: "fork",
664
844
  autorestart: true,
@@ -666,6 +846,8 @@ module.exports = {
666
846
  max_restarts: 10,
667
847
  restart_delay: 2000,
668
848
  max_memory_restart: "1500M",
849
+ // Grace window after SIGINT/SIGTERM before SIGKILL (ms). Align with --stopAllowance.
850
+ kill_timeout: ${killTimeoutMs},
669
851
  out_file: "${outLog}",
670
852
  error_file: "${errLog}",
671
853
  merge_logs: true,
@@ -866,15 +1048,27 @@ async function bootstrapHost(service, options = {}) {
866
1048
  }
867
1049
 
868
1050
  // src/deploy/deploy-service.js
1051
+ var import_promises13 = require("fs/promises");
1052
+ function resolveKillTimeoutMs2(service) {
1053
+ const explicit = Number(service.pm2?.killTimeout);
1054
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
1055
+ const stopSec = Number(service.pm2?.stopAllowance);
1056
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
1057
+ return 65e3;
1058
+ }
869
1059
  async function deployService(service, options = {}) {
870
1060
  const {
871
1061
  dryRun = false,
872
1062
  skipPull = false,
873
1063
  skipTests = false,
874
1064
  skipNginx = false,
1065
+ stopFirst = false,
875
1066
  logger = console
876
1067
  } = options;
877
1068
  const paths = servicePaths(service);
1069
+ const appName = service.pm2.appName;
1070
+ const killTimeoutMs = resolveKillTimeoutMs2(service);
1071
+ const waitTimeoutMs = killTimeoutMs + 1e4;
878
1072
  await initServiceStructure(service, { dryRun, logger });
879
1073
  if (!skipPull) await pullRepo(service, { dryRun, logger });
880
1074
  await syncEnv(service, { dryRun, logger });
@@ -882,21 +1076,38 @@ async function deployService(service, options = {}) {
882
1076
  await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
883
1077
  await installDeps(service, releasePath, paths, { dryRun, logger });
884
1078
  if (!skipTests) await runReleaseTests(service, releasePath, paths, { dryRun, logger });
885
- await activateRelease(releasePath, paths, { dryRun, logger });
886
- await pruneReleases(service, paths, { dryRun, logger });
887
- await reloadPm2(paths, { dryRun, logger });
1079
+ let previousReleaseName = null;
1080
+ try {
1081
+ const prev = await (0, import_promises13.readlink)(paths.current);
1082
+ previousReleaseName = prev.split("/").pop() || null;
1083
+ } catch {
1084
+ }
1085
+ if (stopFirst) {
1086
+ logger.info(`deploy mode=stopFirst app=${appName} killTimeoutMs=${killTimeoutMs}`);
1087
+ await stopPm2(appName, { dryRun, logger, waitTimeoutMs });
1088
+ await activateRelease(releasePath, paths, { dryRun, logger });
1089
+ await pruneReleases(service, paths, { dryRun, logger });
1090
+ await startPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1091
+ } else {
1092
+ logger.info(
1093
+ `deploy mode=rolling app=${appName} killTimeoutMs=${killTimeoutMs}` + (previousReleaseName ? ` previous=${previousReleaseName}` : "")
1094
+ );
1095
+ await activateRelease(releasePath, paths, { dryRun, logger });
1096
+ await reloadPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1097
+ await pruneReleases(service, paths, { dryRun, logger });
1098
+ }
888
1099
  if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
889
- const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
1100
+ const summary = `deploy complete stamp=${stamp} mode=${stopFirst ? "stopFirst" : "rolling"} dryRun=${dryRun}`;
890
1101
  logger.info(summary);
891
1102
  if (!dryRun) await appendDeployLog(paths.deployLog, summary);
892
- return { stamp, releasePath };
1103
+ return { stamp, releasePath, mode: stopFirst ? "stopFirst" : "rolling" };
893
1104
  }
894
1105
 
895
1106
  // src/deploy/provision-service.js
896
- var import_promises13 = require("fs/promises");
1107
+ var import_promises14 = require("fs/promises");
897
1108
  async function pathExists6(path) {
898
1109
  try {
899
- await (0, import_promises13.access)(path);
1110
+ await (0, import_promises14.access)(path);
900
1111
  return true;
901
1112
  } catch {
902
1113
  return false;
@@ -930,7 +1141,7 @@ async function provisionService(service, options = {}) {
930
1141
  }
931
1142
 
932
1143
  // src/deploy/rollback-service.js
933
- var import_promises14 = require("fs/promises");
1144
+ var import_promises15 = require("fs/promises");
934
1145
  async function rollbackService(service, options = {}) {
935
1146
  const { release: targetName, dryRun = false, logger = console } = options;
936
1147
  const paths = servicePaths(service);
@@ -938,7 +1149,7 @@ async function rollbackService(service, options = {}) {
938
1149
  if (releases.length === 0) throw new Error("No releases to roll back to");
939
1150
  let activeName = null;
940
1151
  try {
941
- const target = await (0, import_promises14.readlink)(paths.current);
1152
+ const target = await (0, import_promises15.readlink)(paths.current);
942
1153
  activeName = target.split("/").pop();
943
1154
  } catch {
944
1155
  throw new Error("No active release (current symlink missing)");
@@ -965,14 +1176,14 @@ async function rollbackService(service, options = {}) {
965
1176
  }
966
1177
 
967
1178
  // src/deploy/ssh-remote.js
968
- var import_promises15 = require("fs/promises");
1179
+ var import_promises16 = require("fs/promises");
969
1180
  var import_node_os3 = require("os");
970
1181
  var import_node_path10 = require("path");
971
1182
  var import_node_child_process4 = require("child_process");
972
1183
  var REMOTE_CLI_REL = "node_modules/@nmakarov/cli-toolkit/scripts/deploy/cli.js";
973
1184
  async function pathExists7(path) {
974
1185
  try {
975
- await (0, import_promises15.access)(path);
1186
+ await (0, import_promises16.access)(path);
976
1187
  return true;
977
1188
  } catch {
978
1189
  return false;
@@ -1064,9 +1275,9 @@ async function ensureEnvOnRemote(host, service, options = {}) {
1064
1275
  if (!await pathExists7(localPath)) return false;
1065
1276
  logger.info(`copying .env ${localPath} \u2192 ${host}:${paths.repoEnv}`);
1066
1277
  await sshRun(host, `mkdir -p ${shellQuote((0, import_node_path10.dirname)(paths.repoEnv))}`, { logger });
1067
- const scrubbed = scrubEnvContent(await (0, import_promises15.readFile)(localPath, "utf8"), service.envScrubPatterns ?? []);
1278
+ const scrubbed = scrubEnvContent(await (0, import_promises16.readFile)(localPath, "utf8"), service.envScrubPatterns ?? []);
1068
1279
  const tmp = (0, import_node_path10.join)((0, import_node_os3.tmpdir)(), `deploy-env-${Date.now()}`);
1069
- await (0, import_promises15.writeFile)(tmp, scrubbed, { mode: 384 });
1280
+ await (0, import_promises16.writeFile)(tmp, scrubbed, { mode: 384 });
1070
1281
  await scp(tmp, `${host}:${paths.repoEnv}`, { logger });
1071
1282
  await sshRun(host, `chmod 600 ${shellQuote(paths.repoEnv)}`, { logger });
1072
1283
  return true;
@@ -1177,9 +1388,12 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1177
1388
  ensureEnvOnRemote,
1178
1389
  ensureRemoteRepo,
1179
1390
  ensureRepoDependencies,
1391
+ getPm2Process,
1180
1392
  initServiceStructure,
1181
1393
  installDeps,
1182
1394
  installOperatorShell,
1395
+ isFreshOnline,
1396
+ isPidAlive,
1183
1397
  listReleases,
1184
1398
  loadServices,
1185
1399
  npmEnv,
@@ -1198,6 +1412,7 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1198
1412
  resolveServiceFrom,
1199
1413
  rollbackService,
1200
1414
  run,
1415
+ runCapture,
1201
1416
  runReleaseTests,
1202
1417
  runRemoteCli,
1203
1418
  runRemoteStatus,
@@ -1206,7 +1421,10 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1206
1421
  servicePaths,
1207
1422
  shellQuote,
1208
1423
  sshRun,
1424
+ startPm2,
1425
+ stopPm2,
1209
1426
  syncEnv,
1427
+ waitPm2,
1210
1428
  writeReleaseBuildInfo
1211
1429
  });
1212
1430
  //# sourceMappingURL=deploy.cjs.map