@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/cli-runner.cjs +23 -12
- package/dist/cli-runner.cjs.map +1 -1
- package/dist/cli-runner.js +23 -12
- package/dist/cli-runner.js.map +1 -1
- package/dist/deploy.cjs +234 -16
- package/dist/deploy.cjs.map +1 -1
- package/dist/deploy.js +221 -10
- package/dist/deploy.js.map +1 -1
- package/dist/index.cjs +246 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +231 -18
- package/dist/index.js.map +1 -1
- package/dist/init.cjs +14 -5
- package/dist/init.cjs.map +1 -1
- package/dist/init.js +14 -5
- package/dist/init.js.map +1 -1
- package/dist/tasks.cjs +10 -8
- package/dist/tasks.cjs.map +1 -1
- package/dist/tasks.js +10 -8
- package/dist/tasks.js.map +1 -1
- package/package.json +1 -1
- package/scripts/deploy/cli.js +5 -1
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:
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
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
|
|
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
|