@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/index.js CHANGED
@@ -5226,8 +5226,13 @@ function defineService(service) {
5226
5226
  const pm2 = {
5227
5227
  appName: service.name,
5228
5228
  args: "",
5229
+ stopAllowance: 60,
5229
5230
  ...service.pm2
5230
5231
  };
5232
+ if (pm2.killTimeout == null) {
5233
+ const stopSec = Number(pm2.stopAllowance);
5234
+ pm2.killTimeout = Number.isFinite(stopSec) && stopSec > 0 ? Math.floor(stopSec * 1e3) + 5e3 : 65e3;
5235
+ }
5231
5236
  const nginx = service.nginx ? { siteName: service.name, ...service.nginx } : null;
5232
5237
  return {
5233
5238
  repoDirName: deriveRepoDirName(service.repoUrl),
@@ -5308,6 +5313,30 @@ function run(cmd, args, options = {}) {
5308
5313
  function runShell(command, options = {}) {
5309
5314
  return run("bash", ["-lc", command], options);
5310
5315
  }
5316
+ function runCapture(cmd, args, options = {}) {
5317
+ const { cwd, env, logger, allowFail = false } = options;
5318
+ return new Promise((resolve3, reject) => {
5319
+ logger?.info?.(`$ ${cmd} ${args.join(" ")}${cwd ? ` (cwd=${cwd})` : ""}`);
5320
+ const child = spawn(cmd, args, {
5321
+ cwd,
5322
+ env: env ?? process.env,
5323
+ stdio: ["ignore", "pipe", "pipe"]
5324
+ });
5325
+ let stdout = "";
5326
+ let stderr = "";
5327
+ child.stdout?.on("data", (chunk) => {
5328
+ stdout += chunk.toString();
5329
+ });
5330
+ child.stderr?.on("data", (chunk) => {
5331
+ stderr += chunk.toString();
5332
+ });
5333
+ child.on("error", reject);
5334
+ child.on("close", (code) => {
5335
+ if (code === 0 || allowFail) resolve3({ stdout, stderr, code });
5336
+ else reject(new Error(`${cmd} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ""}`));
5337
+ });
5338
+ });
5339
+ }
5311
5340
 
5312
5341
  // src/deploy/log.js
5313
5342
  import { appendFile, mkdir } from "fs/promises";
@@ -5525,7 +5554,7 @@ async function runReleaseTests(service, releasePath, paths, options = {}) {
5525
5554
  // src/deploy/prune.js
5526
5555
  import { rm as rm2, readlink as readlink2 } from "fs/promises";
5527
5556
  async function pruneReleases(service, paths, options = {}) {
5528
- const { dryRun = false, logger = console } = options;
5557
+ const { dryRun = false, logger = console, protect = [] } = options;
5529
5558
  const keep = service.keepReleases ?? 3;
5530
5559
  const releases = await listReleases(paths);
5531
5560
  let activeName = null;
@@ -5539,6 +5568,9 @@ async function pruneReleases(service, paths, options = {}) {
5539
5568
  if (keepSet.size < keep) keepSet.add(rel.name);
5540
5569
  }
5541
5570
  if (activeName) keepSet.add(activeName);
5571
+ for (const name of protect) {
5572
+ if (name) keepSet.add(name);
5573
+ }
5542
5574
  const toRemove = releases.filter((rel) => !keepSet.has(rel.name));
5543
5575
  for (const rel of toRemove) {
5544
5576
  if (dryRun) {
@@ -5552,14 +5584,139 @@ async function pruneReleases(service, paths, options = {}) {
5552
5584
  }
5553
5585
 
5554
5586
  // src/deploy/pm2.js
5587
+ function sleep(ms) {
5588
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
5589
+ }
5590
+ function isPidAlive(pid) {
5591
+ const n = Number(pid);
5592
+ if (!Number.isFinite(n) || n <= 0) return false;
5593
+ try {
5594
+ process.kill(n, 0);
5595
+ return true;
5596
+ } catch {
5597
+ return false;
5598
+ }
5599
+ }
5600
+ async function getPm2Process(appName, options = {}) {
5601
+ const { logger } = options;
5602
+ const { stdout } = await runCapture("bash", ["-lc", "pm2 jlist"], { logger });
5603
+ let list;
5604
+ try {
5605
+ list = JSON.parse(stdout || "[]");
5606
+ } catch (err) {
5607
+ throw new Error(`pm2 jlist: invalid JSON (${err?.message ?? err})`);
5608
+ }
5609
+ if (!Array.isArray(list)) return null;
5610
+ return list.find((p) => p?.name === appName) ?? null;
5611
+ }
5612
+ async function waitPm2(appName, predicate, options = {}) {
5613
+ const {
5614
+ timeoutMs = 65e3,
5615
+ pollMs = 500,
5616
+ dryRun = false,
5617
+ logger = console,
5618
+ label = "condition"
5619
+ } = options;
5620
+ if (dryRun) {
5621
+ logger.info?.(`[dryRun] would wait pm2 ${appName} for ${label}`);
5622
+ return null;
5623
+ }
5624
+ const deadline = Date.now() + timeoutMs;
5625
+ let lastStatus = "(unknown)";
5626
+ while (Date.now() < deadline) {
5627
+ const proc = await getPm2Process(appName, { logger });
5628
+ lastStatus = proc?.pm2_env?.status ?? "(missing)";
5629
+ if (predicate(proc)) return proc;
5630
+ await sleep(pollMs);
5631
+ }
5632
+ throw new Error(
5633
+ `pm2 wait timed out after ${timeoutMs}ms for ${appName} (${label}); last status=${lastStatus}`
5634
+ );
5635
+ }
5636
+ function isFreshOnline(proc, oldPid) {
5637
+ if (proc?.pm2_env?.status !== "online") return false;
5638
+ const pid = Number(proc.pid);
5639
+ if (!Number.isFinite(pid) || pid <= 0) return false;
5640
+ if (oldPid != null) {
5641
+ if (pid === oldPid) return false;
5642
+ if (isPidAlive(oldPid)) return false;
5643
+ }
5644
+ return true;
5645
+ }
5555
5646
  async function reloadPm2(paths, options = {}) {
5556
- const { dryRun = false, logger = console } = options;
5647
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
5557
5648
  if (dryRun) {
5558
5649
  logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
5559
5650
  return;
5560
5651
  }
5652
+ let oldPid = null;
5653
+ if (appName) {
5654
+ const before = await getPm2Process(appName, { logger });
5655
+ const n = Number(before?.pid);
5656
+ if (Number.isFinite(n) && n > 0 && before?.pm2_env?.status === "online") {
5657
+ oldPid = n;
5658
+ logger.info(`pm2 ${appName}: reloading (old pid=${oldPid})`);
5659
+ }
5660
+ }
5561
5661
  await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
5562
5662
  logger.info("pm2 reloaded");
5663
+ if (appName) {
5664
+ const proc = await waitPm2(
5665
+ appName,
5666
+ (p) => isFreshOnline(p, oldPid),
5667
+ {
5668
+ timeoutMs: waitTimeoutMs,
5669
+ logger,
5670
+ label: oldPid ? `online on new pid (old ${oldPid} gone)` : "online after reload"
5671
+ }
5672
+ );
5673
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
5674
+ }
5675
+ }
5676
+ async function stopPm2(appName, options = {}) {
5677
+ const { dryRun = false, logger = console, waitTimeoutMs = 65e3 } = options;
5678
+ if (dryRun) {
5679
+ logger.info(`[dryRun] would pm2 stop ${appName}`);
5680
+ return;
5681
+ }
5682
+ const before = await getPm2Process(appName, { logger });
5683
+ if (!before) {
5684
+ logger.info(`pm2 ${appName}: not present (already stopped)`);
5685
+ return;
5686
+ }
5687
+ if (before.pm2_env?.status === "stopped") {
5688
+ logger.info(`pm2 ${appName}: already stopped`);
5689
+ return;
5690
+ }
5691
+ const oldPid = Number(before.pid);
5692
+ await runShell(`pm2 stop "${appName}"`, { logger });
5693
+ await waitPm2(
5694
+ appName,
5695
+ (proc) => {
5696
+ if (proc && proc.pm2_env?.status !== "stopped") return false;
5697
+ if (Number.isFinite(oldPid) && oldPid > 0 && isPidAlive(oldPid)) return false;
5698
+ return true;
5699
+ },
5700
+ { timeoutMs: waitTimeoutMs, logger, label: "stopped" }
5701
+ );
5702
+ logger.info(`pm2 ${appName} stopped`);
5703
+ }
5704
+ async function startPm2(paths, options = {}) {
5705
+ const { dryRun = false, logger = console, appName = null, waitTimeoutMs = 65e3 } = options;
5706
+ if (dryRun) {
5707
+ logger.info(`[dryRun] would pm2 start ${paths.ecosystem} --update-env`);
5708
+ return;
5709
+ }
5710
+ await runShell(`pm2 start "${paths.ecosystem}" --update-env`, { logger });
5711
+ logger.info("pm2 started");
5712
+ if (appName) {
5713
+ const proc = await waitPm2(
5714
+ appName,
5715
+ (p) => isFreshOnline(p, null),
5716
+ { timeoutMs: waitTimeoutMs, logger, label: "online after start" }
5717
+ );
5718
+ logger.info(`pm2 ${appName} online pid=${proc?.pid ?? "?"}`);
5719
+ }
5563
5720
  }
5564
5721
 
5565
5722
  // src/deploy/nginx.js
@@ -5788,10 +5945,26 @@ async function requireDeployRoot(parentDir, serviceRoot) {
5788
5945
  This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
5789
5946
  );
5790
5947
  }
5948
+ function resolveKillTimeoutMs(pm2) {
5949
+ const explicit = Number(pm2?.killTimeout);
5950
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
5951
+ const stopSec = Number(pm2?.stopAllowance);
5952
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
5953
+ return 65e3;
5954
+ }
5955
+ function resolvePm2Args(pm2) {
5956
+ const base = String(pm2?.args ?? "").trim();
5957
+ const stopSec = Number(pm2?.stopAllowance);
5958
+ if (!Number.isFinite(stopSec) || stopSec <= 0) return base;
5959
+ if (/(?:^|\s)--stopAllowance=/.test(base)) return base;
5960
+ return `${base}${base ? " " : ""}--stopAllowance=${Math.floor(stopSec)}`.trim();
5961
+ }
5791
5962
  function buildEcosystemConfig(service, paths) {
5792
5963
  const { pm2 } = service;
5793
5964
  const outLog = join9(paths.logs, `${pm2.appName}.out.log`);
5794
5965
  const errLog = join9(paths.logs, `${pm2.appName}.err.log`);
5966
+ const killTimeoutMs = resolveKillTimeoutMs(pm2);
5967
+ const args = resolvePm2Args(pm2);
5795
5968
  return `/**
5796
5969
  * pm2 ecosystem for ${service.name} \u2014 written by cli-toolkit deploy from the
5797
5970
  * service manifest (init/deploy). Do not hand-edit; change pm2.* in the
@@ -5803,7 +5976,7 @@ module.exports = {
5803
5976
  name: "${pm2.appName}",
5804
5977
  script: "${pm2.script}",
5805
5978
  cwd: "${paths.current}",
5806
- args: "${pm2.args ?? ""}",
5979
+ args: ${JSON.stringify(args)},
5807
5980
  instances: 1,
5808
5981
  exec_mode: "fork",
5809
5982
  autorestart: true,
@@ -5811,6 +5984,8 @@ module.exports = {
5811
5984
  max_restarts: 10,
5812
5985
  restart_delay: 2000,
5813
5986
  max_memory_restart: "1500M",
5987
+ // Grace window after SIGINT/SIGTERM before SIGKILL (ms). Align with --stopAllowance.
5988
+ kill_timeout: ${killTimeoutMs},
5814
5989
  out_file: "${outLog}",
5815
5990
  error_file: "${errLog}",
5816
5991
  merge_logs: true,
@@ -6011,15 +6186,27 @@ async function bootstrapHost(service, options = {}) {
6011
6186
  }
6012
6187
 
6013
6188
  // src/deploy/deploy-service.js
6189
+ import { readlink as readlink3 } from "fs/promises";
6190
+ function resolveKillTimeoutMs2(service) {
6191
+ const explicit = Number(service.pm2?.killTimeout);
6192
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
6193
+ const stopSec = Number(service.pm2?.stopAllowance);
6194
+ if (Number.isFinite(stopSec) && stopSec > 0) return Math.floor(stopSec * 1e3) + 5e3;
6195
+ return 65e3;
6196
+ }
6014
6197
  async function deployService(service, options = {}) {
6015
6198
  const {
6016
6199
  dryRun = false,
6017
6200
  skipPull = false,
6018
6201
  skipTests = false,
6019
6202
  skipNginx = false,
6203
+ stopFirst = false,
6020
6204
  logger = console
6021
6205
  } = options;
6022
6206
  const paths = servicePaths(service);
6207
+ const appName = service.pm2.appName;
6208
+ const killTimeoutMs = resolveKillTimeoutMs2(service);
6209
+ const waitTimeoutMs = killTimeoutMs + 1e4;
6023
6210
  await initServiceStructure(service, { dryRun, logger });
6024
6211
  if (!skipPull) await pullRepo(service, { dryRun, logger });
6025
6212
  await syncEnv(service, { dryRun, logger });
@@ -6027,14 +6214,31 @@ async function deployService(service, options = {}) {
6027
6214
  await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
6028
6215
  await installDeps(service, releasePath, paths, { dryRun, logger });
6029
6216
  if (!skipTests) await runReleaseTests(service, releasePath, paths, { dryRun, logger });
6030
- await activateRelease(releasePath, paths, { dryRun, logger });
6031
- await pruneReleases(service, paths, { dryRun, logger });
6032
- await reloadPm2(paths, { dryRun, logger });
6217
+ let previousReleaseName = null;
6218
+ try {
6219
+ const prev = await readlink3(paths.current);
6220
+ previousReleaseName = prev.split("/").pop() || null;
6221
+ } catch {
6222
+ }
6223
+ if (stopFirst) {
6224
+ logger.info(`deploy mode=stopFirst app=${appName} killTimeoutMs=${killTimeoutMs}`);
6225
+ await stopPm2(appName, { dryRun, logger, waitTimeoutMs });
6226
+ await activateRelease(releasePath, paths, { dryRun, logger });
6227
+ await pruneReleases(service, paths, { dryRun, logger });
6228
+ await startPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
6229
+ } else {
6230
+ logger.info(
6231
+ `deploy mode=rolling app=${appName} killTimeoutMs=${killTimeoutMs}` + (previousReleaseName ? ` previous=${previousReleaseName}` : "")
6232
+ );
6233
+ await activateRelease(releasePath, paths, { dryRun, logger });
6234
+ await reloadPm2(paths, { dryRun, logger, appName, waitTimeoutMs });
6235
+ await pruneReleases(service, paths, { dryRun, logger });
6236
+ }
6033
6237
  if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
6034
- const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
6238
+ const summary = `deploy complete stamp=${stamp} mode=${stopFirst ? "stopFirst" : "rolling"} dryRun=${dryRun}`;
6035
6239
  logger.info(summary);
6036
6240
  if (!dryRun) await appendDeployLog(paths.deployLog, summary);
6037
- return { stamp, releasePath };
6241
+ return { stamp, releasePath, mode: stopFirst ? "stopFirst" : "rolling" };
6038
6242
  }
6039
6243
 
6040
6244
  // src/deploy/provision-service.js
@@ -6075,7 +6279,7 @@ async function provisionService(service, options = {}) {
6075
6279
  }
6076
6280
 
6077
6281
  // src/deploy/rollback-service.js
6078
- import { readlink as readlink3 } from "fs/promises";
6282
+ import { readlink as readlink4 } from "fs/promises";
6079
6283
  async function rollbackService(service, options = {}) {
6080
6284
  const { release: targetName, dryRun = false, logger = console } = options;
6081
6285
  const paths = servicePaths(service);
@@ -6083,7 +6287,7 @@ async function rollbackService(service, options = {}) {
6083
6287
  if (releases.length === 0) throw new Error("No releases to roll back to");
6084
6288
  let activeName = null;
6085
6289
  try {
6086
- const target = await readlink3(paths.current);
6290
+ const target = await readlink4(paths.current);
6087
6291
  activeName = target.split("/").pop();
6088
6292
  } catch {
6089
6293
  throw new Error("No active release (current symlink missing)");
@@ -7706,7 +7910,7 @@ var TaskStopRunner = class extends AbstractTask {
7706
7910
  */
7707
7911
  static async resolveCustomParams(context, overrides = {}) {
7708
7912
  const merged = AbstractTask._mergeTypedParams(context, "task-stop", {
7709
- allowanceMs: "number default 5000"
7913
+ allowanceMs: "number default 60000"
7710
7914
  }, overrides);
7711
7915
  const allowanceMs = Number(merged.allowanceMs);
7712
7916
  if (!Number.isFinite(allowanceMs) || allowanceMs < 0) {
@@ -7720,7 +7924,7 @@ var TaskStopRunner = class extends AbstractTask {
7720
7924
  * @returns {Promise<{ success: true, results: { stopRunner: true, allowanceMs: number, message: string } }>}
7721
7925
  */
7722
7926
  async run() {
7723
- const allowanceMs = Number(this.task?.params?.allowanceMs ?? 5e3);
7927
+ const allowanceMs = Number(this.task?.params?.allowanceMs ?? 6e4);
7724
7928
  this.context.logger.warn?.(`[TaskStopRunner] stop requested (allowanceMs=${allowanceMs})`);
7725
7929
  return {
7726
7930
  success: true,
@@ -8405,7 +8609,7 @@ function normalizeRegistry(registry) {
8405
8609
  if (registry instanceof TasksRegistry) return registry;
8406
8610
  return new TasksRegistry().addMany(registry);
8407
8611
  }
8408
- async function enqueueStopTask(context, serviceGroup, queueName = "tasks", allowanceMs = 5e3) {
8612
+ async function enqueueStopTask(context, serviceGroup, queueName = "tasks", allowanceMs = 6e4) {
8409
8613
  return enqueueTask(context, {
8410
8614
  queueName,
8411
8615
  name: "stopRunner",
@@ -8544,7 +8748,7 @@ async function executeClaimedTask(context, tasksTable, historyTable, row, regist
8544
8748
  await db(tasksTable).where({ id: row.id }).delete();
8545
8749
  }
8546
8750
  const stopRunnerRequested = !!(results && typeof results === "object" && results.stopRunner === true);
8547
- const stopAllowanceMs = stopRunnerRequested ? Number(results.allowanceMs ?? 5e3) : 0;
8751
+ const stopAllowanceMs = stopRunnerRequested ? Number(results.allowanceMs ?? 6e4) : 0;
8548
8752
  return { stopRunnerRequested, stopAllowanceMs };
8549
8753
  }
8550
8754
  function shuffleTaskRowsInPlace(rows) {
@@ -8634,7 +8838,7 @@ async function runTasksLoop(context, options) {
8634
8838
  const runningTaskInstances = /* @__PURE__ */ new Map();
8635
8839
  let runningControlPromise = null;
8636
8840
  let stopRequested = false;
8637
- let stopAllowanceMs = 5e3;
8841
+ let stopAllowanceMs = Number(context.stopAllowanceMs) > 0 ? Number(context.stopAllowanceMs) : 6e4;
8638
8842
  context.tasksRunnerStop = false;
8639
8843
  let registryReg = null;
8640
8844
  let registryInterval = null;
@@ -8701,7 +8905,7 @@ async function runTasksLoop(context, options) {
8701
8905
  ).then(async (outcome) => {
8702
8906
  if (outcome.stopRunnerRequested && !stopRequested) {
8703
8907
  stopRequested = true;
8704
- stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
8908
+ stopAllowanceMs = outcome.stopAllowanceMs || stopAllowanceMs;
8705
8909
  context.tasksRunnerStop = true;
8706
8910
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
8707
8911
  }
@@ -8727,7 +8931,7 @@ async function runTasksLoop(context, options) {
8727
8931
  const p = executeClaimedTask(context, tasksTable, historyTable, claimed, registry, runningTaskInstances).then(async (outcome) => {
8728
8932
  if (outcome.stopRunnerRequested && !stopRequested) {
8729
8933
  stopRequested = true;
8730
- stopAllowanceMs = outcome.stopAllowanceMs || 5e3;
8934
+ stopAllowanceMs = outcome.stopAllowanceMs || stopAllowanceMs;
8731
8935
  context.tasksRunnerStop = true;
8732
8936
  await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
8733
8937
  }
@@ -8748,7 +8952,9 @@ async function runTasksLoop(context, options) {
8748
8952
  }
8749
8953
  }
8750
8954
  if (context.isStop() && !stopRequested) {
8751
- await signalRunningTasksStop(context, runningTaskInstances, 5e3);
8955
+ stopRequested = true;
8956
+ stopAllowanceMs = Number(context.stopAllowanceMs) > 0 ? Number(context.stopAllowanceMs) : stopAllowanceMs;
8957
+ await signalRunningTasksStop(context, runningTaskInstances, stopAllowanceMs);
8752
8958
  }
8753
8959
  if (runningPromises.size > 0) {
8754
8960
  if (stopRequested) {
@@ -9032,11 +9238,14 @@ export {
9032
9238
  ensureTasksRuntime,
9033
9239
  flushTaskIpcLogs,
9034
9240
  getArgsInstance,
9241
+ getPm2Process,
9035
9242
  createElement2 as h,
9036
9243
  initServiceStructure,
9037
9244
  installDeps,
9038
9245
  installOperatorShell,
9039
9246
  ipcFileLogsTableNameForSourceResource,
9247
+ isFreshOnline,
9248
+ isPidAlive,
9040
9249
  joiEdateType,
9041
9250
  joiStringArrayType,
9042
9251
  listServicesRegistry as listAliveRunnerHeartbeats,
@@ -9078,6 +9287,7 @@ export {
9078
9287
  resolveSteps,
9079
9288
  rollbackService,
9080
9289
  run,
9290
+ runCapture,
9081
9291
  runNodeTaskScript,
9082
9292
  runReleaseTests,
9083
9293
  runRemoteCli,
@@ -9095,6 +9305,8 @@ export {
9095
9305
  showScreen,
9096
9306
  showWordGridScreen,
9097
9307
  sshRun,
9308
+ startPm2,
9309
+ stopPm2,
9098
9310
  syncEnv,
9099
9311
  taskHistoryInsertFromQueueRow,
9100
9312
  timeMatcher,
@@ -9112,6 +9324,7 @@ export {
9112
9324
  useRef2 as useRef,
9113
9325
  useState3 as useState,
9114
9326
  waitForTaskResult,
9327
+ waitPm2,
9115
9328
  writeReleaseBuildInfo
9116
9329
  };
9117
9330
  //# sourceMappingURL=index.js.map