@nmakarov/cli-toolkit 0.50.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.cjs CHANGED
@@ -34,6 +34,7 @@ __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,
@@ -55,6 +56,7 @@ __export(deploy_exports, {
55
56
  resolveServiceFrom: () => resolveServiceFrom,
56
57
  rollbackService: () => rollbackService,
57
58
  run: () => run,
59
+ runCapture: () => runCapture,
58
60
  runReleaseTests: () => runReleaseTests,
59
61
  runRemoteCli: () => runRemoteCli,
60
62
  runRemoteStatus: () => runRemoteStatus,
@@ -63,7 +65,10 @@ __export(deploy_exports, {
63
65
  servicePaths: () => servicePaths,
64
66
  shellQuote: () => shellQuote,
65
67
  sshRun: () => sshRun,
68
+ startPm2: () => startPm2,
69
+ stopPm2: () => stopPm2,
66
70
  syncEnv: () => syncEnv,
71
+ waitPm2: () => waitPm2,
67
72
  writeReleaseBuildInfo: () => writeReleaseBuildInfo
68
73
  });
69
74
  module.exports = __toCommonJS(deploy_exports);
@@ -81,8 +86,13 @@ function defineService(service) {
81
86
  const pm2 = {
82
87
  appName: service.name,
83
88
  args: "",
89
+ stopAllowance: 60,
84
90
  ...service.pm2
85
91
  };
92
+ if (pm2.killTimeout == null) {
93
+ const stopSec = Number(pm2.stopAllowance);
94
+ pm2.killTimeout = Number.isFinite(stopSec) && stopSec > 0 ? Math.floor(stopSec * 1e3) + 5e3 : 65e3;
95
+ }
86
96
  const nginx = service.nginx ? { siteName: service.name, ...service.nginx } : null;
87
97
  return {
88
98
  repoDirName: deriveRepoDirName(service.repoUrl),
@@ -163,6 +173,30 @@ function run(cmd, args, options = {}) {
163
173
  function runShell(command, options = {}) {
164
174
  return run("bash", ["-lc", command], options);
165
175
  }
176
+ function runCapture(cmd, args, options = {}) {
177
+ const { cwd, env, logger, allowFail = false } = options;
178
+ return new Promise((resolve2, reject) => {
179
+ logger?.info?.(`$ ${cmd} ${args.join(" ")}${cwd ? ` (cwd=${cwd})` : ""}`);
180
+ const child = (0, import_node_child_process.spawn)(cmd, args, {
181
+ cwd,
182
+ env: env ?? process.env,
183
+ stdio: ["ignore", "pipe", "pipe"]
184
+ });
185
+ let stdout = "";
186
+ let stderr = "";
187
+ child.stdout?.on("data", (chunk) => {
188
+ stdout += chunk.toString();
189
+ });
190
+ child.stderr?.on("data", (chunk) => {
191
+ stderr += chunk.toString();
192
+ });
193
+ child.on("error", reject);
194
+ child.on("close", (code) => {
195
+ if (code === 0 || allowFail) resolve2({ stdout, stderr, code });
196
+ else reject(new Error(`${cmd} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
197
+ });
198
+ });
199
+ }
166
200
 
167
201
  // src/deploy/log.js
168
202
  var import_promises = require("fs/promises");
@@ -380,7 +414,7 @@ async function runReleaseTests(service, releasePath, paths, options = {}) {
380
414
  // src/deploy/prune.js
381
415
  var import_promises7 = require("fs/promises");
382
416
  async function pruneReleases(service, paths, options = {}) {
383
- const { dryRun = false, logger = console } = options;
417
+ const { dryRun = false, logger = console, protect = [] } = options;
384
418
  const keep = service.keepReleases ?? 3;
385
419
  const releases = await listReleases(paths);
386
420
  let activeName = null;
@@ -394,6 +428,9 @@ async function pruneReleases(service, paths, options = {}) {
394
428
  if (keepSet.size < keep) keepSet.add(rel.name);
395
429
  }
396
430
  if (activeName) keepSet.add(activeName);
431
+ for (const name of protect) {
432
+ if (name) keepSet.add(name);
433
+ }
397
434
  const toRemove = releases.filter((rel) => !keepSet.has(rel.name));
398
435
  for (const rel of toRemove) {
399
436
  if (dryRun) {
@@ -407,14 +444,101 @@ async function pruneReleases(service, paths, options = {}) {
407
444
  }
408
445
 
409
446
  // src/deploy/pm2.js
447
+ function sleep(ms) {
448
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
449
+ }
450
+ async function getPm2Process(appName, options = {}) {
451
+ const { logger } = options;
452
+ const { stdout } = await runCapture("bash", ["-lc", "pm2 jlist"], { logger });
453
+ let list;
454
+ try {
455
+ list = JSON.parse(stdout || "[]");
456
+ } catch (err) {
457
+ throw new Error(`pm2 jlist: invalid JSON (${err?.message ?? err})`);
458
+ }
459
+ if (!Array.isArray(list)) return null;
460
+ return list.find((p) => p?.name === appName) ?? null;
461
+ }
462
+ async function waitPm2(appName, predicate, options = {}) {
463
+ const {
464
+ timeoutMs = 65e3,
465
+ pollMs = 500,
466
+ dryRun = false,
467
+ logger = console,
468
+ label = "condition"
469
+ } = options;
470
+ if (dryRun) {
471
+ logger.info?.(`[dryRun] would wait pm2 ${appName} for ${label}`);
472
+ return null;
473
+ }
474
+ const deadline = Date.now() + timeoutMs;
475
+ let lastStatus = "(unknown)";
476
+ while (Date.now() < deadline) {
477
+ const proc = await getPm2Process(appName, { logger });
478
+ lastStatus = proc?.pm2_env?.status ?? "(missing)";
479
+ if (predicate(proc)) return proc;
480
+ await sleep(pollMs);
481
+ }
482
+ throw new Error(
483
+ `pm2 wait timed out after ${timeoutMs}ms for ${appName} (${label}); last status=${lastStatus}`
484
+ );
485
+ }
410
486
  async function reloadPm2(paths, options = {}) {
411
- const { dryRun = false, logger = console } = options;
487
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
412
488
  if (dryRun) {
413
489
  logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
414
490
  return;
415
491
  }
416
492
  await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
417
493
  logger.info("pm2 reloaded");
494
+ if (appName) {
495
+ await waitPm2(
496
+ appName,
497
+ (proc) => proc?.pm2_env?.status === "online",
498
+ { timeoutMs: waitTimeoutMs, logger, label: "online after reload" }
499
+ );
500
+ logger.info(`pm2 ${appName} online`);
501
+ }
502
+ }
503
+ async function stopPm2(appName, options = {}) {
504
+ const { dryRun = false, logger = console, waitTimeoutMs = 65e3 } = options;
505
+ if (dryRun) {
506
+ logger.info(`[dryRun] would pm2 stop ${appName}`);
507
+ return;
508
+ }
509
+ const before = await getPm2Process(appName, { logger });
510
+ if (!before) {
511
+ logger.info(`pm2 ${appName}: not present (already stopped)`);
512
+ return;
513
+ }
514
+ if (before.pm2_env?.status === "stopped") {
515
+ logger.info(`pm2 ${appName}: already stopped`);
516
+ return;
517
+ }
518
+ await runShell(`pm2 stop "${appName}"`, { logger });
519
+ await waitPm2(
520
+ appName,
521
+ (proc) => !proc || proc.pm2_env?.status === "stopped",
522
+ { timeoutMs: waitTimeoutMs, logger, label: "stopped" }
523
+ );
524
+ logger.info(`pm2 ${appName} stopped`);
525
+ }
526
+ async function startPm2(paths, options = {}) {
527
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
528
+ if (dryRun) {
529
+ logger.info(`[dryRun] would pm2 start ${paths.ecosystem} --update-env`);
530
+ return;
531
+ }
532
+ await runShell(`pm2 start "${paths.ecosystem}" --update-env`, { logger });
533
+ logger.info("pm2 started");
534
+ if (appName) {
535
+ await waitPm2(
536
+ appName,
537
+ (proc) => proc?.pm2_env?.status === "online",
538
+ { timeoutMs: waitTimeoutMs, logger, label: "online after start" }
539
+ );
540
+ logger.info(`pm2 ${appName} online`);
541
+ }
418
542
  }
419
543
 
420
544
  // src/deploy/nginx.js
@@ -643,10 +767,26 @@ async function requireDeployRoot(parentDir, serviceRoot) {
643
767
  This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
644
768
  );
645
769
  }
770
+ function resolveKillTimeoutMs(pm2) {
771
+ const explicit = Number(pm2?.killTimeout);
772
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
773
+ const stopSec = Number(pm2?.stopAllowance);
774
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
775
+ return 65e3;
776
+ }
777
+ function resolvePm2Args(pm2) {
778
+ const base = String(pm2?.args ?? "").trim();
779
+ const stopSec = Number(pm2?.stopAllowance);
780
+ if (!Number.isFinite(stopSec) || stopSec <= 0) return base;
781
+ if (/(?:^|\s)--stopAllowance=/.test(base)) return base;
782
+ return `${base}${base ? " " : ""}--stopAllowance=${Math.floor(stopSec)}`.trim();
783
+ }
646
784
  function buildEcosystemConfig(service, paths) {
647
785
  const { pm2 } = service;
648
786
  const outLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.out.log`);
649
787
  const errLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.err.log`);
788
+ const killTimeoutMs = resolveKillTimeoutMs(pm2);
789
+ const args = resolvePm2Args(pm2);
650
790
  return `/**
651
791
  * pm2 ecosystem for ${service.name} \u2014 written by cli-toolkit deploy from the
652
792
  * service manifest (init/deploy). Do not hand-edit; change pm2.* in the
@@ -658,7 +798,7 @@ module.exports = {
658
798
  name: "${pm2.appName}",
659
799
  script: "${pm2.script}",
660
800
  cwd: "${paths.current}",
661
- args: "${pm2.args ?? ""}",
801
+ args: ${JSON.stringify(args)},
662
802
  instances: 1,
663
803
  exec_mode: "fork",
664
804
  autorestart: true,
@@ -666,6 +806,8 @@ module.exports = {
666
806
  max_restarts: 10,
667
807
  restart_delay: 2000,
668
808
  max_memory_restart: "1500M",
809
+ // Grace window after SIGINT/SIGTERM before SIGKILL (ms). Align with --stopAllowance.
810
+ kill_timeout: ${killTimeoutMs},
669
811
  out_file: "${outLog}",
670
812
  error_file: "${errLog}",
671
813
  merge_logs: true,
@@ -866,15 +1008,27 @@ async function bootstrapHost(service, options = {}) {
866
1008
  }
867
1009
 
868
1010
  // src/deploy/deploy-service.js
1011
+ var import_promises13 = require("fs/promises");
1012
+ function resolveKillTimeoutMs2(service) {
1013
+ const explicit = Number(service.pm2?.killTimeout);
1014
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
1015
+ const stopSec = Number(service.pm2?.stopAllowance);
1016
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
1017
+ return 65e3;
1018
+ }
869
1019
  async function deployService(service, options = {}) {
870
1020
  const {
871
1021
  dryRun = false,
872
1022
  skipPull = false,
873
1023
  skipTests = false,
874
1024
  skipNginx = false,
1025
+ stopFirst = false,
875
1026
  logger = console
876
1027
  } = options;
877
1028
  const paths = servicePaths(service);
1029
+ const appName = service.pm2.appName;
1030
+ const killTimeoutMs = resolveKillTimeoutMs2(service);
1031
+ const waitTimeoutMs = killTimeoutMs + 1e4;
878
1032
  await initServiceStructure(service, { dryRun, logger });
879
1033
  if (!skipPull) await pullRepo(service, { dryRun, logger });
880
1034
  await syncEnv(service, { dryRun, logger });
@@ -882,21 +1036,38 @@ async function deployService(service, options = {}) {
882
1036
  await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
883
1037
  await installDeps(service, releasePath, paths, { dryRun, logger });
884
1038
  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 });
1039
+ let previousReleaseName = null;
1040
+ try {
1041
+ const prev = await (0, import_promises13.readlink)(paths.current);
1042
+ previousReleaseName = prev.split("/").pop() || null;
1043
+ } catch {
1044
+ }
1045
+ if (stopFirst) {
1046
+ logger.info(`deploy mode=stopFirst app=${appName} killTimeoutMs=${killTimeoutMs}`);
1047
+ await stopPm2(appName, { dryRun, logger, waitTimeoutMs });
1048
+ await activateRelease(releasePath, paths, { dryRun, logger });
1049
+ await pruneReleases(service, paths, { dryRun, logger });
1050
+ await startPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1051
+ } else {
1052
+ logger.info(
1053
+ `deploy mode=rolling app=${appName} killTimeoutMs=${killTimeoutMs}` + (previousReleaseName ? ` previous=${previousReleaseName}` : "")
1054
+ );
1055
+ await activateRelease(releasePath, paths, { dryRun, logger });
1056
+ await reloadPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
1057
+ await pruneReleases(service, paths, { dryRun, logger });
1058
+ }
888
1059
  if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
889
- const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
1060
+ const summary = `deploy complete stamp=${stamp} mode=${stopFirst ? "stopFirst" : "rolling"} dryRun=${dryRun}`;
890
1061
  logger.info(summary);
891
1062
  if (!dryRun) await appendDeployLog(paths.deployLog, summary);
892
- return { stamp, releasePath };
1063
+ return { stamp, releasePath, mode: stopFirst ? "stopFirst" : "rolling" };
893
1064
  }
894
1065
 
895
1066
  // src/deploy/provision-service.js
896
- var import_promises13 = require("fs/promises");
1067
+ var import_promises14 = require("fs/promises");
897
1068
  async function pathExists6(path) {
898
1069
  try {
899
- await (0, import_promises13.access)(path);
1070
+ await (0, import_promises14.access)(path);
900
1071
  return true;
901
1072
  } catch {
902
1073
  return false;
@@ -930,7 +1101,7 @@ async function provisionService(service, options = {}) {
930
1101
  }
931
1102
 
932
1103
  // src/deploy/rollback-service.js
933
- var import_promises14 = require("fs/promises");
1104
+ var import_promises15 = require("fs/promises");
934
1105
  async function rollbackService(service, options = {}) {
935
1106
  const { release: targetName, dryRun = false, logger = console } = options;
936
1107
  const paths = servicePaths(service);
@@ -938,7 +1109,7 @@ async function rollbackService(service, options = {}) {
938
1109
  if (releases.length === 0) throw new Error("No releases to roll back to");
939
1110
  let activeName = null;
940
1111
  try {
941
- const target = await (0, import_promises14.readlink)(paths.current);
1112
+ const target = await (0, import_promises15.readlink)(paths.current);
942
1113
  activeName = target.split("/").pop();
943
1114
  } catch {
944
1115
  throw new Error("No active release (current symlink missing)");
@@ -965,14 +1136,14 @@ async function rollbackService(service, options = {}) {
965
1136
  }
966
1137
 
967
1138
  // src/deploy/ssh-remote.js
968
- var import_promises15 = require("fs/promises");
1139
+ var import_promises16 = require("fs/promises");
969
1140
  var import_node_os3 = require("os");
970
1141
  var import_node_path10 = require("path");
971
1142
  var import_node_child_process4 = require("child_process");
972
1143
  var REMOTE_CLI_REL = "node_modules/@nmakarov/cli-toolkit/scripts/deploy/cli.js";
973
1144
  async function pathExists7(path) {
974
1145
  try {
975
- await (0, import_promises15.access)(path);
1146
+ await (0, import_promises16.access)(path);
976
1147
  return true;
977
1148
  } catch {
978
1149
  return false;
@@ -1064,9 +1235,9 @@ async function ensureEnvOnRemote(host, service, options = {}) {
1064
1235
  if (!await pathExists7(localPath)) return false;
1065
1236
  logger.info(`copying .env ${localPath} \u2192 ${host}:${paths.repoEnv}`);
1066
1237
  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 ?? []);
1238
+ const scrubbed = scrubEnvContent(await (0, import_promises16.readFile)(localPath, "utf8"), service.envScrubPatterns ?? []);
1068
1239
  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 });
1240
+ await (0, import_promises16.writeFile)(tmp, scrubbed, { mode: 384 });
1070
1241
  await scp(tmp, `${host}:${paths.repoEnv}`, { logger });
1071
1242
  await sshRun(host, `chmod 600 ${shellQuote(paths.repoEnv)}`, { logger });
1072
1243
  return true;
@@ -1177,6 +1348,7 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1177
1348
  ensureEnvOnRemote,
1178
1349
  ensureRemoteRepo,
1179
1350
  ensureRepoDependencies,
1351
+ getPm2Process,
1180
1352
  initServiceStructure,
1181
1353
  installDeps,
1182
1354
  installOperatorShell,
@@ -1198,6 +1370,7 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1198
1370
  resolveServiceFrom,
1199
1371
  rollbackService,
1200
1372
  run,
1373
+ runCapture,
1201
1374
  runReleaseTests,
1202
1375
  runRemoteCli,
1203
1376
  runRemoteStatus,
@@ -1206,7 +1379,10 @@ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1206
1379
  servicePaths,
1207
1380
  shellQuote,
1208
1381
  sshRun,
1382
+ startPm2,
1383
+ stopPm2,
1209
1384
  syncEnv,
1385
+ waitPm2,
1210
1386
  writeReleaseBuildInfo
1211
1387
  });
1212
1388
  //# sourceMappingURL=deploy.cjs.map