@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.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,139 @@ 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
+ function isPidAlive(pid) {
376
+ const n = Number(pid);
377
+ if (!Number.isFinite(n) || n <= 0) return false;
378
+ try {
379
+ process.kill(n, 0);
380
+ return true;
381
+ } catch {
382
+ return false;
383
+ }
384
+ }
385
+ async function getPm2Process(appName, options = {}) {
386
+ const { logger } = options;
387
+ const { stdout } = await runCapture("bash", ["-lc", "pm2 jlist"], { logger });
388
+ let list;
389
+ try {
390
+ list = JSON.parse(stdout || "[]");
391
+ } catch (err) {
392
+ throw new Error(`pm2 jlist: invalid JSON (${err?.message ?? err})`);
393
+ }
394
+ if (!Array.isArray(list)) return null;
395
+ return list.find((p) => p?.name === appName) ?? null;
396
+ }
397
+ async function waitPm2(appName, predicate, options = {}) {
398
+ const {
399
+ timeoutMs = 65e3,
400
+ pollMs = 500,
401
+ dryRun = false,
402
+ logger = console,
403
+ label = "condition"
404
+ } = options;
405
+ if (dryRun) {
406
+ logger.info?.(`[dryRun] would wait pm2 ${appName} for ${label}`);
407
+ return null;
408
+ }
409
+ const deadline = Date.now() + timeoutMs;
410
+ let lastStatus = "(unknown)";
411
+ while (Date.now() < deadline) {
412
+ const proc = await getPm2Process(appName, { logger });
413
+ lastStatus = proc?.pm2_env?.status ?? "(missing)";
414
+ if (predicate(proc)) return proc;
415
+ await sleep(pollMs);
416
+ }
417
+ throw new Error(
418
+ `pm2 wait timed out after ${timeoutMs}ms for ${appName} (${label}); last status=${lastStatus}`
419
+ );
420
+ }
421
+ function isFreshOnline(proc, oldPid) {
422
+ if (proc?.pm2_env?.status !== "online") return false;
423
+ const pid = Number(proc.pid);
424
+ if (!Number.isFinite(pid) || pid <= 0) return false;
425
+ if (oldPid != null) {
426
+ if (pid === oldPid) return false;
427
+ if (isPidAlive(oldPid)) return false;
428
+ }
429
+ return true;
430
+ }
340
431
  async function reloadPm2(paths, options = {}) {
341
- const { dryRun = false, logger = console } = options;
432
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
342
433
  if (dryRun) {
343
434
  logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
344
435
  return;
345
436
  }
437
+ let oldPid = null;
438
+ if (appName) {
439
+ const before = await getPm2Process(appName, { logger });
440
+ const n = Number(before?.pid);
441
+ if (Number.isFinite(n) && n > 0 && before?.pm2_env?.status === "online") {
442
+ oldPid = n;
443
+ logger.info(`pm2 ${appName}: reloading (old pid=${oldPid})`);
444
+ }
445
+ }
346
446
  await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
347
447
  logger.info("pm2 reloaded");
448
+ if (appName) {
449
+ const proc = await waitPm2(
450
+ appName,
451
+ (p) => isFreshOnline(p, oldPid),
452
+ {
453
+ timeoutMs: waitTimeoutMs,
454
+ logger,
455
+ label: oldPid ? `online on new pid (old ${oldPid} gone)` : "online after reload"
456
+ }
457
+ );
458
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
459
+ }
460
+ }
461
+ async function stopPm2(appName, options = {}) {
462
+ const { dryRun = false, logger = console, waitTimeoutMs = 65e3 } = options;
463
+ if (dryRun) {
464
+ logger.info(`[dryRun] would pm2 stop ${appName}`);
465
+ return;
466
+ }
467
+ const before = await getPm2Process(appName, { logger });
468
+ if (!before) {
469
+ logger.info(`pm2 ${appName}: not present (already stopped)`);
470
+ return;
471
+ }
472
+ if (before.pm2_env?.status === "stopped") {
473
+ logger.info(`pm2 ${appName}: already stopped`);
474
+ return;
475
+ }
476
+ const oldPid = Number(before.pid);
477
+ await runShell(`pm2 stop "${appName}"`, { logger });
478
+ await waitPm2(
479
+ appName,
480
+ (proc) => {
481
+ if (proc && proc.pm2_env?.status !== "stopped") return false;
482
+ if (Number.isFinite(oldPid) && oldPid > 0 && isPidAlive(oldPid)) return false;
483
+ return true;
484
+ },
485
+ { timeoutMs: waitTimeoutMs, logger, label: "stopped" }
486
+ );
487
+ logger.info(`pm2 ${appName} stopped`);
488
+ }
489
+ async function startPm2(paths, options = {}) {
490
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
491
+ if (dryRun) {
492
+ logger.info(`[dryRun] would pm2 start ${paths.ecosystem} --update-env`);
493
+ return;
494
+ }
495
+ await runShell(`pm2 start "${paths.ecosystem}" --update-env`, { logger });
496
+ logger.info("pm2 started");
497
+ if (appName) {
498
+ const proc = await waitPm2(
499
+ appName,
500
+ (p) => isFreshOnline(p, null),
501
+ { timeoutMs: waitTimeoutMs, logger, label: "online after start" }
502
+ );
503
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
504
+ }
348
505
  }
349
506
 
350
507
  // src/deploy/nginx.js
@@ -573,10 +730,26 @@ async function requireDeployRoot(parentDir, serviceRoot) {
573
730
  This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
574
731
  );
575
732
  }
733
+ function resolveKillTimeoutMs(pm2) {
734
+ const explicit = Number(pm2?.killTimeout);
735
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
736
+ const stopSec = Number(pm2?.stopAllowance);
737
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
738
+ return 65e3;
739
+ }
740
+ function resolvePm2Args(pm2) {
741
+ const base = String(pm2?.args ?? "").trim();
742
+ const stopSec = Number(pm2?.stopAllowance);
743
+ if (!Number.isFinite(stopSec) || stopSec <= 0) return base;
744
+ if (/(?:^|\s)--stopAllowance=/.test(base)) return base;
745
+ return `${base}${base ? " " : ""}--stopAllowance=${Math.floor(stopSec)}`.trim();
746
+ }
576
747
  function buildEcosystemConfig(service, paths) {
577
748
  const { pm2 } = service;
578
749
  const outLog = join8(paths.logs, `${pm2.appName}.out.log`);
579
750
  const errLog = join8(paths.logs, `${pm2.appName}.err.log`);
751
+ const killTimeoutMs = resolveKillTimeoutMs(pm2);
752
+ const args = resolvePm2Args(pm2);
580
753
  return `/**
581
754
  * pm2 ecosystem for ${service.name} \u2014 written by cli-toolkit deploy from the
582
755
  * service manifest (init/deploy). Do not hand-edit; change pm2.* in the
@@ -588,7 +761,7 @@ module.exports = {
588
761
  name: "${pm2.appName}",
589
762
  script: "${pm2.script}",
590
763
  cwd: "${paths.current}",
591
- args: "${pm2.args ?? ""}",
764
+ args: ${JSON.stringify(args)},
592
765
  instances: 1,
593
766
  exec_mode: "fork",
594
767
  autorestart: true,
@@ -596,6 +769,8 @@ module.exports = {
596
769
  max_restarts: 10,
597
770
  restart_delay: 2000,
598
771
  max_memory_restart: "1500M",
772
+ // Grace window after SIGINT/SIGTERM before SIGKILL (ms). Align with --stopAllowance.
773
+ kill_timeout: ${killTimeoutMs},
599
774
  out_file: "${outLog}",
600
775
  error_file: "${errLog}",
601
776
  merge_logs: true,
@@ -796,15 +971,27 @@ async function bootstrapHost(service, options = {}) {
796
971
  }
797
972
 
798
973
  // src/deploy/deploy-service.js
974
+ import { readlink as readlink3 } from "fs/promises";
975
+ function resolveKillTimeoutMs2(service) {
976
+ const explicit = Number(service.pm2?.killTimeout);
977
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
978
+ const stopSec = Number(service.pm2?.stopAllowance);
979
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
980
+ return 65e3;
981
+ }
799
982
  async function deployService(service, options = {}) {
800
983
  const {
801
984
  dryRun = false,
802
985
  skipPull = false,
803
986
  skipTests = false,
804
987
  skipNginx = false,
988
+ stopFirst = false,
805
989
  logger = console
806
990
  } = options;
807
991
  const paths = servicePaths(service);
992
+ const appName = service.pm2.appName;
993
+ const killTimeoutMs = resolveKillTimeoutMs2(service);
994
+ const waitTimeoutMs = killTimeoutMs + 1e4;
808
995
  await initServiceStructure(service, { dryRun, logger });
809
996
  if (!skipPull) await pullRepo(service, { dryRun, logger });
810
997
  await syncEnv(service, { dryRun, logger });
@@ -812,14 +999,31 @@ async function deployService(service, options = {}) {
812
999
  await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
813
1000
  await installDeps(service, releasePath, paths, { dryRun, logger });
814
1001
  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 });
1002
+ let previousReleaseName = null;
1003
+ try {
1004
+ const prev = await readlink3(paths.current);
1005
+ previousReleaseName = prev.split("/").pop() || null;
1006
+ } catch {
1007
+ }
1008
+ if (stopFirst) {
1009
+ logger.info(`deploy mode=stopFirst app=${appName} killTimeoutMs=${killTimeoutMs}`);
1010
+ await stopPm2(appName, { dryRun, logger, waitTimeoutMs });
1011
+ await activateRelease(releasePath, paths, { dryRun, logger });
1012
+ await pruneReleases(service, paths, { dryRun, logger });
1013
+ await startPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1014
+ } else {
1015
+ logger.info(
1016
+ `deploy mode=rolling app=${appName} killTimeoutMs=${killTimeoutMs}` + (previousReleaseName ? ` previous=${previousReleaseName}` : "")
1017
+ );
1018
+ await activateRelease(releasePath, paths, { dryRun, logger });
1019
+ await reloadPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1020
+ await pruneReleases(service, paths, { dryRun, logger });
1021
+ }
818
1022
  if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
819
- const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
1023
+ const summary = `deploy complete stamp=${stamp} mode=${stopFirst ? "stopFirst" : "rolling"} dryRun=${dryRun}`;
820
1024
  logger.info(summary);
821
1025
  if (!dryRun) await appendDeployLog(paths.deployLog, summary);
822
- return { stamp, releasePath };
1026
+ return { stamp, releasePath, mode: stopFirst ? "stopFirst" : "rolling" };
823
1027
  }
824
1028
 
825
1029
  // src/deploy/provision-service.js
@@ -860,7 +1064,7 @@ async function provisionService(service, options = {}) {
860
1064
  }
861
1065
 
862
1066
  // src/deploy/rollback-service.js
863
- import { readlink as readlink3 } from "fs/promises";
1067
+ import { readlink as readlink4 } from "fs/promises";
864
1068
  async function rollbackService(service, options = {}) {
865
1069
  const { release: targetName, dryRun = false, logger = console } = options;
866
1070
  const paths = servicePaths(service);
@@ -868,7 +1072,7 @@ async function rollbackService(service, options = {}) {
868
1072
  if (releases.length === 0) throw new Error("No releases to roll back to");
869
1073
  let activeName = null;
870
1074
  try {
871
- const target = await readlink3(paths.current);
1075
+ const target = await readlink4(paths.current);
872
1076
  activeName = target.split("/").pop();
873
1077
  } catch {
874
1078
  throw new Error("No active release (current symlink missing)");
@@ -1106,9 +1310,12 @@ export {
1106
1310
  ensureEnvOnRemote,
1107
1311
  ensureRemoteRepo,
1108
1312
  ensureRepoDependencies,
1313
+ getPm2Process,
1109
1314
  initServiceStructure,
1110
1315
  installDeps,
1111
1316
  installOperatorShell,
1317
+ isFreshOnline,
1318
+ isPidAlive,
1112
1319
  listReleases,
1113
1320
  loadServices,
1114
1321
  npmEnv,
@@ -1127,6 +1334,7 @@ export {
1127
1334
  resolveServiceFrom,
1128
1335
  rollbackService,
1129
1336
  run,
1337
+ runCapture,
1130
1338
  runReleaseTests,
1131
1339
  runRemoteCli,
1132
1340
  runRemoteStatus,
@@ -1135,7 +1343,10 @@ export {
1135
1343
  servicePaths,
1136
1344
  shellQuote,
1137
1345
  sshRun,
1346
+ startPm2,
1347
+ stopPm2,
1138
1348
  syncEnv,
1349
+ waitPm2,
1139
1350
  writeReleaseBuildInfo
1140
1351
  };
1141
1352
  //# sourceMappingURL=deploy.js.map