@nmakarov/cli-toolkit 0.29.0 → 0.33.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.
@@ -0,0 +1,1166 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/deploy/index.js
20
+ var deploy_exports = {};
21
+ __export(deploy_exports, {
22
+ REMOTE_CLI_REL: () => REMOTE_CLI_REL,
23
+ activateRelease: () => activateRelease,
24
+ appendDeployLog: () => appendDeployLog,
25
+ bootstrapHost: () => bootstrapHost,
26
+ bumpPatchVersion: () => bumpPatchVersion,
27
+ cloneRepo: () => cloneRepo,
28
+ createRelease: () => createRelease,
29
+ defineService: () => defineService,
30
+ deployService: () => deployService,
31
+ deriveRepoDirName: () => deriveRepoDirName,
32
+ enableNginxUpstream: () => enableNginxUpstream,
33
+ ensureDeployKeyOnRemote: () => ensureDeployKeyOnRemote,
34
+ ensureEnvOnRemote: () => ensureEnvOnRemote,
35
+ ensureRemoteRepo: () => ensureRemoteRepo,
36
+ ensureRepoDependencies: () => ensureRepoDependencies,
37
+ initServiceStructure: () => initServiceStructure,
38
+ installDeps: () => installDeps,
39
+ listReleases: () => listReleases,
40
+ loadServices: () => loadServices,
41
+ npmEnv: () => npmEnv,
42
+ npmInstallEnv: () => npmInstallEnv,
43
+ parseGitHost: () => parseGitHost,
44
+ prepareGitHost: () => prepareGitHost,
45
+ provisionService: () => provisionService,
46
+ pruneReleases: () => pruneReleases,
47
+ pullRepo: () => pullRepo,
48
+ readCurrentRelease: () => readCurrentRelease,
49
+ readReleaseBuildInfo: () => readReleaseBuildInfo,
50
+ releaseDir: () => releaseDir,
51
+ releaseStamp: () => releaseStamp,
52
+ reloadPm2: () => reloadPm2,
53
+ resolveNextVersion: () => resolveNextVersion,
54
+ resolveServiceFrom: () => resolveServiceFrom,
55
+ rollbackService: () => rollbackService,
56
+ run: () => run,
57
+ runReleaseTests: () => runReleaseTests,
58
+ runRemoteCli: () => runRemoteCli,
59
+ runRemoteStatus: () => runRemoteStatus,
60
+ runShell: () => runShell,
61
+ scrubEnvContent: () => scrubEnvContent,
62
+ servicePaths: () => servicePaths,
63
+ shellQuote: () => shellQuote,
64
+ sshRun: () => sshRun,
65
+ syncEnv: () => syncEnv,
66
+ writeReleaseBuildInfo: () => writeReleaseBuildInfo
67
+ });
68
+ module.exports = __toCommonJS(deploy_exports);
69
+
70
+ // src/deploy/service.js
71
+ function deriveRepoDirName(repoUrl) {
72
+ const tail = String(repoUrl ?? "").split("/").pop() ?? "repo";
73
+ return tail.replace(/\.git$/, "") || "repo";
74
+ }
75
+ function defineService(service) {
76
+ if (!service?.name) throw new Error("service manifest needs a `name`");
77
+ if (!service.appsRoot) throw new Error(`service "${service.name}" needs an appsRoot`);
78
+ if (!service.repoUrl) throw new Error(`service "${service.name}" needs a repoUrl`);
79
+ if (!service.pm2?.script) throw new Error(`service "${service.name}" needs pm2.script`);
80
+ const pm2 = {
81
+ appName: service.name,
82
+ args: "",
83
+ ...service.pm2
84
+ };
85
+ const nginx = service.nginx ? { siteName: service.name, ...service.nginx } : null;
86
+ return {
87
+ repoDirName: deriveRepoDirName(service.repoUrl),
88
+ repoSubdir: "",
89
+ keepReleases: 3,
90
+ testCommand: null,
91
+ envScrubPatterns: [],
92
+ legacyRepoEnv: null,
93
+ buildInfoPath: "build-info.json",
94
+ deployKey: null,
95
+ requireEnv: false,
96
+ ...service,
97
+ pm2,
98
+ nginx
99
+ };
100
+ }
101
+
102
+ // src/deploy/paths.js
103
+ var import_node_path = require("path");
104
+ function servicePaths(service) {
105
+ const root = service.appsRoot;
106
+ const repoDirName = service.repoDirName ?? "repo";
107
+ const repoSubdir = service.repoSubdir ?? "";
108
+ const repo = (0, import_node_path.join)(root, repoDirName);
109
+ const repoRun = repoSubdir ? (0, import_node_path.join)(repo, repoSubdir) : repo;
110
+ return {
111
+ root,
112
+ repo,
113
+ repoRun,
114
+ repoEnv: (0, import_node_path.join)(repoRun, ".env"),
115
+ releases: (0, import_node_path.join)(root, "releases"),
116
+ shared: (0, import_node_path.join)(root, "shared"),
117
+ logs: (0, import_node_path.join)(root, "logs"),
118
+ current: (0, import_node_path.join)(root, "current"),
119
+ sharedEnv: (0, import_node_path.join)(root, "shared", ".env"),
120
+ ecosystem: (0, import_node_path.join)(root, "shared", "ecosystem.config.cjs"),
121
+ deployLog: (0, import_node_path.join)(root, "logs", "deploy.log"),
122
+ lockHashFile: (0, import_node_path.join)(root, "shared", ".package-lock.sha256")
123
+ };
124
+ }
125
+ function releaseStamp(date = /* @__PURE__ */ new Date()) {
126
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
127
+ }
128
+ function releaseDir(releasesRoot, stamp) {
129
+ return (0, import_node_path.join)(releasesRoot, stamp);
130
+ }
131
+
132
+ // src/deploy/run.js
133
+ var import_node_child_process = require("child_process");
134
+ function npmEnv(baseEnv = process.env) {
135
+ const env = { ...baseEnv };
136
+ delete env.NODE_ENV;
137
+ return env;
138
+ }
139
+ function npmInstallEnv(baseEnv = process.env) {
140
+ return {
141
+ ...npmEnv(baseEnv),
142
+ NPM_CONFIG_PRODUCTION: "false",
143
+ npm_config_production: "false"
144
+ };
145
+ }
146
+ function run(cmd, args, options = {}) {
147
+ const { cwd, env, logger } = options;
148
+ return new Promise((resolve2, reject) => {
149
+ logger?.info?.(`$ ${cmd} ${args.join(" ")}${cwd ? ` (cwd=${cwd})` : ""}`);
150
+ const child = (0, import_node_child_process.spawn)(cmd, args, {
151
+ cwd,
152
+ env: env ?? process.env,
153
+ stdio: "inherit"
154
+ });
155
+ child.on("error", reject);
156
+ child.on("close", (code) => {
157
+ if (code === 0) resolve2();
158
+ else reject(new Error(`${cmd} exited with code ${code}`));
159
+ });
160
+ });
161
+ }
162
+ function runShell(command, options = {}) {
163
+ return run("bash", ["-lc", command], options);
164
+ }
165
+
166
+ // src/deploy/log.js
167
+ var import_promises = require("fs/promises");
168
+ async function appendDeployLog(deployLogPath, message) {
169
+ await (0, import_promises.mkdir)(deployLogPath.replace(/\/[^/]+$/, ""), { recursive: true });
170
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
171
+ `;
172
+ await (0, import_promises.appendFile)(deployLogPath, line);
173
+ }
174
+
175
+ // src/deploy/git.js
176
+ var import_promises2 = require("fs/promises");
177
+ async function pathExists(path) {
178
+ try {
179
+ await (0, import_promises2.access)(path);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+ async function cloneRepo(service, options = {}) {
186
+ const { dryRun = false, logger = console } = options;
187
+ const paths = servicePaths(service);
188
+ if (await pathExists(paths.repo)) {
189
+ throw new Error(`Repo already exists at ${paths.repo} \u2014 use git pull instead`);
190
+ }
191
+ if (dryRun) {
192
+ logger.info(`[dryRun] would git clone ${service.repoUrl} ${paths.repo}`);
193
+ return;
194
+ }
195
+ await run("git", ["clone", service.repoUrl, paths.repo], { logger });
196
+ logger.info(`cloned ${service.repoUrl} \u2192 ${paths.repo}`);
197
+ }
198
+ async function pullRepo(service, options = {}) {
199
+ const { dryRun = false, logger = console } = options;
200
+ const paths = servicePaths(service);
201
+ if (!await pathExists(paths.repo)) {
202
+ throw new Error(`Repo missing at ${paths.repo} \u2014 run provision first`);
203
+ }
204
+ if (dryRun) {
205
+ logger.info(`[dryRun] would git -C ${paths.repo} pull --ff-only`);
206
+ return;
207
+ }
208
+ await run("git", ["-C", paths.repo, "pull", "--ff-only"], { logger });
209
+ logger.info(`pulled ${paths.repo}`);
210
+ }
211
+
212
+ // src/deploy/release.js
213
+ var import_promises3 = require("fs/promises");
214
+ var import_node_path2 = require("path");
215
+ var SKIP_TOP = /* @__PURE__ */ new Set(["node_modules", ".git"]);
216
+ function shouldCopyEntry(srcPath) {
217
+ const parts = srcPath.split(import_node_path2.sep);
218
+ if (parts.some((p) => p === "node_modules" || p === ".git")) return false;
219
+ const top = parts.at(-1);
220
+ if (parts.length === 1 && SKIP_TOP.has(top)) return false;
221
+ return true;
222
+ }
223
+ async function createRelease(service, options = {}) {
224
+ const { stamp = releaseStamp(), dryRun = false, logger = console } = options;
225
+ const paths = servicePaths(service);
226
+ const dest = releaseDir(paths.releases, stamp);
227
+ if (dryRun) {
228
+ logger.info(`[dryRun] would copy ${paths.repoRun} \u2192 ${dest}`);
229
+ return { stamp, path: dest };
230
+ }
231
+ await (0, import_promises3.mkdir)(paths.releases, { recursive: true });
232
+ await (0, import_promises3.cp)(paths.repoRun, dest, {
233
+ recursive: true,
234
+ filter: (src) => shouldCopyEntry(src)
235
+ });
236
+ logger.info(`release ${stamp} created at ${dest}`);
237
+ return { stamp, path: dest };
238
+ }
239
+ async function readCurrentRelease(paths) {
240
+ try {
241
+ const target = await (0, import_promises3.readlink)(paths.current);
242
+ return target.startsWith("/") ? target : (0, import_node_path2.join)((0, import_node_path2.dirname)(paths.current), target);
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ async function listReleases(paths) {
248
+ let names;
249
+ try {
250
+ names = await (0, import_promises3.readdir)(paths.releases);
251
+ } catch {
252
+ return [];
253
+ }
254
+ const entries = [];
255
+ for (const name of names) {
256
+ const full = releaseDir(paths.releases, name);
257
+ const s = await (0, import_promises3.stat)(full);
258
+ if (s.isDirectory()) entries.push({ name, path: full, mtime: s.mtime });
259
+ }
260
+ entries.sort((a, b) => b.name.localeCompare(a.name));
261
+ return entries;
262
+ }
263
+
264
+ // src/deploy/activate.js
265
+ var import_promises4 = require("fs/promises");
266
+ var import_node_path3 = require("path");
267
+ async function activateRelease(releasePath, paths, options = {}) {
268
+ const { dryRun = false, logger = console } = options;
269
+ if (dryRun) {
270
+ logger.info(`[dryRun] would activate ${releasePath} \u2192 ${paths.current}`);
271
+ return;
272
+ }
273
+ try {
274
+ await (0, import_promises4.unlink)(paths.current);
275
+ } catch {
276
+ }
277
+ await (0, import_promises4.symlink)(releasePath, paths.current);
278
+ const envLink = (0, import_node_path3.join)(releasePath, ".env");
279
+ try {
280
+ await (0, import_promises4.unlink)(envLink);
281
+ } catch {
282
+ }
283
+ await (0, import_promises4.symlink)(paths.sharedEnv, envLink);
284
+ logger.info(`active release: ${releasePath}`);
285
+ }
286
+
287
+ // src/deploy/deps.js
288
+ var import_node_crypto = require("crypto");
289
+ var import_promises5 = require("fs/promises");
290
+ var import_node_path4 = require("path");
291
+ async function pathExists2(path) {
292
+ try {
293
+ await (0, import_promises5.access)(path);
294
+ return true;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+ async function lockfileHash(releasePath) {
300
+ const lockPath = (0, import_node_path4.join)(releasePath, "package-lock.json");
301
+ try {
302
+ const buf = await (0, import_promises5.readFile)(lockPath);
303
+ return (0, import_node_crypto.createHash)("sha256").update(buf).digest("hex");
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+ async function readHashFile(path) {
309
+ try {
310
+ return (await (0, import_promises5.readFile)(path, "utf8")).trim();
311
+ } catch {
312
+ return null;
313
+ }
314
+ }
315
+ async function npmInstall(releasePath, hasLock, logger) {
316
+ await (0, import_promises5.rm)((0, import_node_path4.join)(releasePath, "node_modules"), { recursive: true, force: true });
317
+ const cmd = hasLock ? ["ci", "--include=dev"] : ["install", "--include=dev", "--no-audit", "--no-fund"];
318
+ logger.info(`running npm ${cmd.join(" ")} in ${releasePath}`);
319
+ await run("npm", cmd, { cwd: releasePath, logger, env: npmInstallEnv() });
320
+ }
321
+ async function installDeps(service, releasePath, paths, options = {}) {
322
+ const { dryRun = false, logger = console } = options;
323
+ if (dryRun) {
324
+ logger.info(`[dryRun] would install deps in ${releasePath}`);
325
+ return { hash: null, copied: false };
326
+ }
327
+ const hash = await lockfileHash(releasePath);
328
+ const hasLock = hash !== null;
329
+ const hashMarker = (0, import_node_path4.join)(releasePath, ".deploy-package-lock.sha256");
330
+ const currentPath = await readCurrentRelease(paths);
331
+ let copied = false;
332
+ if (hasLock && currentPath && await pathExists2((0, import_node_path4.join)(currentPath, "node_modules"))) {
333
+ const currentHash = await readHashFile((0, import_node_path4.join)(currentPath, ".deploy-package-lock.sha256"));
334
+ if (currentHash === hash) {
335
+ logger.info("lockfile unchanged \u2014 copying node_modules from current release");
336
+ await (0, import_promises5.cp)((0, import_node_path4.join)(currentPath, "node_modules"), (0, import_node_path4.join)(releasePath, "node_modules"), {
337
+ recursive: true,
338
+ force: true
339
+ });
340
+ copied = true;
341
+ }
342
+ }
343
+ if (!copied) {
344
+ await npmInstall(releasePath, hasLock, logger);
345
+ }
346
+ if (hasLock) {
347
+ await (0, import_promises5.writeFile)(hashMarker, `${hash}
348
+ `, "utf8");
349
+ await (0, import_promises5.mkdir)(paths.shared, { recursive: true });
350
+ await (0, import_promises5.writeFile)(paths.lockHashFile, `${hash}
351
+ `, "utf8");
352
+ }
353
+ return { hash, copied };
354
+ }
355
+
356
+ // src/deploy/test.js
357
+ var import_promises6 = require("fs/promises");
358
+ var import_node_path5 = require("path");
359
+ async function runReleaseTests(service, releasePath, paths, options = {}) {
360
+ const { dryRun = false, logger = console } = options;
361
+ if (!service.testCommand) {
362
+ logger.info("no testCommand configured \u2014 skipping tests");
363
+ return;
364
+ }
365
+ if (dryRun) {
366
+ logger.info(`[dryRun] would run "${service.testCommand}" in ${releasePath}`);
367
+ return;
368
+ }
369
+ const envLink = (0, import_node_path5.join)(releasePath, ".env");
370
+ try {
371
+ await (0, import_promises6.unlink)(envLink);
372
+ } catch {
373
+ }
374
+ await (0, import_promises6.symlink)(paths.sharedEnv, envLink);
375
+ logger.info(`running "${service.testCommand}" in ${releasePath}`);
376
+ await runShell(service.testCommand, { cwd: releasePath, logger, env: npmInstallEnv() });
377
+ }
378
+
379
+ // src/deploy/prune.js
380
+ var import_promises7 = require("fs/promises");
381
+ async function pruneReleases(service, paths, options = {}) {
382
+ const { dryRun = false, logger = console } = options;
383
+ const keep = service.keepReleases ?? 3;
384
+ const releases = await listReleases(paths);
385
+ let activeName = null;
386
+ try {
387
+ const target = await (0, import_promises7.readlink)(paths.current);
388
+ activeName = target.split("/").pop();
389
+ } catch {
390
+ }
391
+ const keepSet = /* @__PURE__ */ new Set();
392
+ for (const rel of releases) {
393
+ if (keepSet.size < keep) keepSet.add(rel.name);
394
+ }
395
+ if (activeName) keepSet.add(activeName);
396
+ const toRemove = releases.filter((rel) => !keepSet.has(rel.name));
397
+ for (const rel of toRemove) {
398
+ if (dryRun) {
399
+ logger.info(`[dryRun] would rm -rf ${rel.path}`);
400
+ } else {
401
+ await (0, import_promises7.rm)(rel.path, { recursive: true, force: true });
402
+ logger.info(`pruned ${rel.path}`);
403
+ }
404
+ }
405
+ return { removed: toRemove.map((r) => r.name) };
406
+ }
407
+
408
+ // src/deploy/pm2.js
409
+ async function reloadPm2(paths, options = {}) {
410
+ const { dryRun = false, logger = console } = options;
411
+ if (dryRun) {
412
+ logger.info(`[dryRun] would pm2 startOrReload ${paths.ecosystem} --update-env`);
413
+ return;
414
+ }
415
+ await runShell(`pm2 startOrReload "${paths.ecosystem}" --update-env`, { logger });
416
+ logger.info("pm2 reloaded");
417
+ }
418
+
419
+ // src/deploy/nginx.js
420
+ var import_promises8 = require("fs/promises");
421
+ var import_node_os = require("os");
422
+ var import_node_path6 = require("path");
423
+ async function hasTlsCert(certPath) {
424
+ try {
425
+ await runShell(`sudo test -f '${certPath}'`, {});
426
+ return true;
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+ function proxyBlock(port) {
432
+ return ` location / {
433
+ proxy_pass http://127.0.0.1:${port};
434
+ proxy_http_version 1.1;
435
+ proxy_set_header Host $host;
436
+ proxy_set_header X-Real-IP $remote_addr;
437
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
438
+ proxy_set_header X-Forwarded-Proto $scheme;
439
+ proxy_read_timeout 120s;
440
+ }`;
441
+ }
442
+ function buildNginxConfig(service) {
443
+ const { nginx, pm2 } = service;
444
+ const certDir = `/etc/letsencrypt/live/${nginx.fqdn}`;
445
+ return `# ${nginx.siteName} \u2014 managed by cli-toolkit deploy (proxy mode)
446
+ # ${nginx.fqdn} \u2192 127.0.0.1:${pm2.port}
447
+
448
+ server {
449
+ listen 80;
450
+ listen [::]:80;
451
+ server_name ${nginx.fqdn};
452
+ location / { return 301 https://$host$request_uri; }
453
+ }
454
+
455
+ server {
456
+ listen 443 ssl;
457
+ listen [::]:443 ssl;
458
+ server_name ${nginx.fqdn};
459
+
460
+ ssl_certificate ${certDir}/fullchain.pem;
461
+ ssl_certificate_key ${certDir}/privkey.pem;
462
+ include /etc/letsencrypt/options-ssl-nginx.conf;
463
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
464
+
465
+ client_max_body_size 25m;
466
+
467
+ ${proxyBlock(pm2.port)}
468
+ }
469
+ `;
470
+ }
471
+ function buildNginxConfigHttpOnly(service) {
472
+ const { nginx, pm2 } = service;
473
+ return `# ${nginx.siteName} \u2014 managed by cli-toolkit deploy (HTTP proxy, no TLS cert yet)
474
+
475
+ server {
476
+ listen 80;
477
+ listen [::]:80;
478
+ server_name ${nginx.fqdn};
479
+
480
+ client_max_body_size 25m;
481
+
482
+ ${proxyBlock(pm2.port)}
483
+ }
484
+ `;
485
+ }
486
+ async function enableNginxUpstream(service, options = {}) {
487
+ const { dryRun = false, logger = console } = options;
488
+ const { nginx } = service;
489
+ if (!nginx) {
490
+ logger.info("no nginx config on service \u2014 skipping nginx step");
491
+ return { skipped: true };
492
+ }
493
+ const siteAvailable = `/etc/nginx/sites-available/${nginx.siteName}`;
494
+ const siteEnabled = `/etc/nginx/sites-enabled/${nginx.siteName}`;
495
+ const cert = `/etc/letsencrypt/live/${nginx.fqdn}/fullchain.pem`;
496
+ if (dryRun) {
497
+ logger.info(`[dryRun] would write ${siteAvailable} (proxy \u2192 127.0.0.1:${service.pm2.port}) and reload nginx`);
498
+ return { hasCert: null };
499
+ }
500
+ const hasCert = await hasTlsCert(cert);
501
+ const config = hasCert ? buildNginxConfig(service) : buildNginxConfigHttpOnly(service);
502
+ const tmp = (0, import_node_path6.join)((0, import_node_os.tmpdir)(), `${service.name}-nginx.conf`);
503
+ await (0, import_promises8.writeFile)(tmp, config);
504
+ await runShell(
505
+ `sudo cp '${tmp}' '${siteAvailable}' && sudo ln -sf '${siteAvailable}' '${siteEnabled}' && sudo nginx -t && sudo systemctl reload nginx`,
506
+ { logger }
507
+ );
508
+ logger.info(`nginx upstream enabled for ${nginx.fqdn} \u2192 127.0.0.1:${service.pm2.port} (tls=${hasCert})`);
509
+ return { hasCert };
510
+ }
511
+
512
+ // src/deploy/sync-env.js
513
+ var import_promises9 = require("fs/promises");
514
+ function scrubEnvContent(content, patterns = []) {
515
+ const regexes = patterns.map((p) => p instanceof RegExp ? p : new RegExp(p));
516
+ if (regexes.length === 0) return content;
517
+ return content.split("\n").filter((line) => !regexes.some((re) => re.test(line.trim()))).join("\n");
518
+ }
519
+ async function pathExists3(path) {
520
+ try {
521
+ await (0, import_promises9.access)(path);
522
+ return true;
523
+ } catch {
524
+ return false;
525
+ }
526
+ }
527
+ async function syncEnv(service, options = {}) {
528
+ const { dryRun = false, logger = console } = options;
529
+ const paths = servicePaths(service);
530
+ const patterns = service.envScrubPatterns ?? [];
531
+ if (dryRun) {
532
+ logger.info(`[dryRun] would sync ${paths.repoEnv} \u2192 ${paths.sharedEnv}`);
533
+ return { source: paths.repoEnv, dest: paths.sharedEnv };
534
+ }
535
+ let source = paths.repoEnv;
536
+ if (!await pathExists3(source) && service.legacyRepoEnv && await pathExists3(service.legacyRepoEnv)) {
537
+ logger.info(`using legacy env: ${service.legacyRepoEnv}`);
538
+ source = service.legacyRepoEnv;
539
+ }
540
+ await (0, import_promises9.mkdir)(paths.shared, { recursive: true });
541
+ if (!await pathExists3(source)) {
542
+ if (service.requireEnv) {
543
+ throw new Error(
544
+ `No .env found at ${paths.repoEnv}` + (service.legacyRepoEnv ? ` or ${service.legacyRepoEnv}` : "") + " \u2014 place .env on the host or run the remote deploy from a laptop with a local .env (auto-scp)"
545
+ );
546
+ }
547
+ if (!await pathExists3(paths.sharedEnv)) {
548
+ await (0, import_promises9.writeFile)(paths.sharedEnv, "", { mode: 384 });
549
+ }
550
+ logger.warn(`no .env found (source ${source}) \u2014 using empty ${paths.sharedEnv}`);
551
+ return { source: null, dest: paths.sharedEnv };
552
+ }
553
+ const raw = await (0, import_promises9.readFile)(source, "utf8");
554
+ await (0, import_promises9.writeFile)(paths.sharedEnv, scrubEnvContent(raw, patterns), { mode: 384 });
555
+ logger.info(`synced ${source} \u2192 ${paths.sharedEnv}`);
556
+ return { source, dest: paths.sharedEnv };
557
+ }
558
+
559
+ // src/deploy/build-info.js
560
+ var import_promises10 = require("fs/promises");
561
+ var import_node_path7 = require("path");
562
+ var import_node_child_process2 = require("child_process");
563
+ var import_node_util = require("util");
564
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
565
+ function bumpPatchVersion(version) {
566
+ const parts = String(version).trim().split(".");
567
+ const major = Number.parseInt(parts[0], 10) || 0;
568
+ const minor = Number.parseInt(parts[1], 10) || 0;
569
+ const patch = Number.parseInt(parts[2], 10) || 0;
570
+ return `${major}.${minor}.${patch + 1}`;
571
+ }
572
+ async function readJson(path) {
573
+ try {
574
+ return JSON.parse(await (0, import_promises10.readFile)(path, "utf8"));
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+ async function gitShortCommit(repoPath) {
580
+ try {
581
+ const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-parse", "--short", "HEAD"], {
582
+ encoding: "utf8"
583
+ });
584
+ return stdout.trim() || null;
585
+ } catch {
586
+ return null;
587
+ }
588
+ }
589
+ async function resolveNextVersion(service, paths, pkgPath) {
590
+ const pkg = await readJson(pkgPath) ?? {};
591
+ const baseVersion = pkg.version || "0.1.0";
592
+ const currentPath = await readCurrentRelease(paths);
593
+ if (currentPath) {
594
+ const active = await readJson((0, import_node_path7.join)(currentPath, service.buildInfoPath));
595
+ if (active?.version) return bumpPatchVersion(active.version);
596
+ }
597
+ return baseVersion;
598
+ }
599
+ async function readReleaseBuildInfo(service, releasePath) {
600
+ return readJson((0, import_node_path7.join)(releasePath, service.buildInfoPath));
601
+ }
602
+ async function writeReleaseBuildInfo(service, releasePath, { stamp, dryRun = false, logger = console }) {
603
+ const paths = servicePaths(service);
604
+ const pkgPath = (0, import_node_path7.join)(paths.repoRun, "package.json");
605
+ const version = await resolveNextVersion(service, paths, pkgPath);
606
+ const gitCommit = await gitShortCommit(paths.repo);
607
+ const buildInfo = {
608
+ version,
609
+ release: stamp,
610
+ deployedAt: (/* @__PURE__ */ new Date()).toISOString(),
611
+ gitCommit,
612
+ service: service.name
613
+ };
614
+ const dest = (0, import_node_path7.join)(releasePath, service.buildInfoPath);
615
+ if (dryRun) {
616
+ logger.info(`[dryRun] would write ${dest} (${JSON.stringify(buildInfo)})`);
617
+ return buildInfo;
618
+ }
619
+ await (0, import_promises10.mkdir)((0, import_node_path7.dirname)(dest), { recursive: true });
620
+ await (0, import_promises10.writeFile)(dest, `${JSON.stringify(buildInfo, null, 2)}
621
+ `, { mode: 420 });
622
+ const gitSuffix = gitCommit ? ` git=${gitCommit}` : "";
623
+ logger.info(`build info: v${version} release=${stamp}${gitSuffix}`);
624
+ return buildInfo;
625
+ }
626
+
627
+ // src/deploy/init-structure.js
628
+ var import_promises11 = require("fs/promises");
629
+ var import_node_path8 = require("path");
630
+ async function pathExists4(path) {
631
+ try {
632
+ await (0, import_promises11.access)(path);
633
+ return true;
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+ async function requireDeployRoot(parentDir, serviceRoot) {
639
+ if (await pathExists4(parentDir)) return;
640
+ throw new Error(
641
+ `Cannot create ${serviceRoot}: parent directory ${parentDir} does not exist.
642
+ This is meant to run on the target host (where ${parentDir} exists). For a local dry run use --appsRoot=/tmp/<service>.`
643
+ );
644
+ }
645
+ function buildEcosystemConfig(service, paths) {
646
+ const { pm2 } = service;
647
+ const outLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.out.log`);
648
+ const errLog = (0, import_node_path8.join)(paths.logs, `${pm2.appName}.err.log`);
649
+ return `/**
650
+ * pm2 ecosystem for ${service.name} \u2014 seeded by cli-toolkit deploy (init).
651
+ *
652
+ * cwd points at the \`current\` symlink (created on first deploy).
653
+ * Tweak \`args\` here, then: pm2 reload ${paths.ecosystem} --update-env
654
+ */
655
+ module.exports = {
656
+ apps: [
657
+ {
658
+ name: "${pm2.appName}",
659
+ script: "${pm2.script}",
660
+ cwd: "${paths.current}",
661
+ args: "${pm2.args}",
662
+ instances: 1,
663
+ exec_mode: "fork",
664
+ autorestart: true,
665
+ min_uptime: "10s",
666
+ max_restarts: 10,
667
+ restart_delay: 2000,
668
+ max_memory_restart: "1500M",
669
+ out_file: "${outLog}",
670
+ error_file: "${errLog}",
671
+ merge_logs: true,
672
+ time: true,
673
+ env: {
674
+ NODE_ENV: "production",
675
+ },
676
+ },
677
+ ],
678
+ };
679
+ `;
680
+ }
681
+ async function initServiceStructure(service, options = {}) {
682
+ const { dryRun = false, logger = console } = options;
683
+ const paths = servicePaths(service);
684
+ const dirs = [paths.releases, paths.shared, paths.logs];
685
+ const created = [];
686
+ const skipped = [];
687
+ if (!dryRun) {
688
+ await requireDeployRoot((0, import_node_path8.dirname)(paths.root), paths.root);
689
+ }
690
+ for (const dir of dirs) {
691
+ if (await pathExists4(dir)) {
692
+ skipped.push(dir);
693
+ continue;
694
+ }
695
+ if (!dryRun) await (0, import_promises11.mkdir)(dir, { recursive: true });
696
+ created.push(dir);
697
+ }
698
+ let ecosystemCreated = false;
699
+ if (await pathExists4(paths.ecosystem)) {
700
+ skipped.push(paths.ecosystem);
701
+ } else {
702
+ if (!dryRun) {
703
+ await (0, import_promises11.writeFile)(paths.ecosystem, buildEcosystemConfig(service, paths), { mode: 420 });
704
+ }
705
+ ecosystemCreated = true;
706
+ created.push(paths.ecosystem);
707
+ }
708
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] init-structure service=${service.name} dryRun=${dryRun} created=${created.length} skipped=${skipped.length}
709
+ `;
710
+ if (!dryRun) {
711
+ await (0, import_promises11.mkdir)(paths.logs, { recursive: true });
712
+ await (0, import_promises11.appendFile)(paths.deployLog, line);
713
+ }
714
+ logger.info(`service=${service.name} appsRoot=${paths.root}`);
715
+ logger.info(`created: ${created.length ? created.join(", ") : "(none)"}`);
716
+ logger.info(`already present: ${skipped.length ? skipped.join(", ") : "(none)"}`);
717
+ if (ecosystemCreated) logger.info(`seeded ${paths.ecosystem}`);
718
+ return { paths, created, skipped, ecosystemCreated };
719
+ }
720
+
721
+ // src/deploy/bootstrap-host.js
722
+ var import_node_child_process3 = require("child_process");
723
+ var import_promises12 = require("fs/promises");
724
+ var import_node_os2 = require("os");
725
+ var import_node_path9 = require("path");
726
+ async function pathExists5(path) {
727
+ try {
728
+ await (0, import_promises12.access)(path);
729
+ return true;
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+ function expandHome(path) {
735
+ return path.startsWith("~/") ? (0, import_node_path9.join)((0, import_node_os2.homedir)(), path.slice(2)) : path;
736
+ }
737
+ async function ensurePm2Startup(options = {}) {
738
+ const { user = "ubuntu", dryRun = false, logger = console } = options;
739
+ if (dryRun) {
740
+ logger.info("[dryRun] would configure pm2 startup systemd");
741
+ return;
742
+ }
743
+ try {
744
+ (0, import_node_child_process3.execSync)("pm2 ping", { stdio: "ignore" });
745
+ } catch {
746
+ }
747
+ try {
748
+ const out = (0, import_node_child_process3.execSync)(`pm2 startup systemd -u ${user} --hp /home/${user}`, { encoding: "utf8" });
749
+ const sudoLine = out.split("\n").find((l) => l.trim().startsWith("sudo"));
750
+ if (sudoLine) {
751
+ (0, import_node_child_process3.execSync)(sudoLine.trim(), { stdio: "inherit" });
752
+ logger.info("pm2 startup systemd configured");
753
+ }
754
+ } catch (err) {
755
+ logger.warn(`pm2 startup skipped or already configured: ${err.message}`);
756
+ }
757
+ }
758
+ async function installDeployKey(deployKeyPath, options = {}) {
759
+ const { dryRun = false, logger = console } = options;
760
+ const keyPath = expandHome(deployKeyPath);
761
+ if (!await pathExists5(keyPath)) {
762
+ logger.warn(`deploy key not found at ${keyPath} \u2014 skipping git ssh setup`);
763
+ return;
764
+ }
765
+ const keyBase = (0, import_node_path9.basename)(keyPath);
766
+ const sshDir = (0, import_node_path9.join)((0, import_node_os2.homedir)(), ".ssh");
767
+ const destKey = (0, import_node_path9.join)(sshDir, keyBase);
768
+ const configPath = (0, import_node_path9.join)(sshDir, "config");
769
+ const block = `
770
+ Host github.com
771
+ HostName github.com
772
+ User git
773
+ IdentityFile ${destKey}
774
+ IdentitiesOnly yes
775
+ `;
776
+ if (dryRun) {
777
+ logger.info(`[dryRun] would install deploy key ${keyPath} \u2192 ${destKey}`);
778
+ return;
779
+ }
780
+ await (0, import_promises12.mkdir)(sshDir, { recursive: true, mode: 448 });
781
+ await (0, import_promises12.copyFile)(keyPath, destKey);
782
+ await (0, import_promises12.chmod)(destKey, 384);
783
+ let config = "";
784
+ if (await pathExists5(configPath)) config = await (0, import_promises12.readFile)(configPath, "utf8");
785
+ if (!config.includes("Host github.com")) {
786
+ await (0, import_promises12.writeFile)(configPath, `${config.trimEnd()}
787
+ ${block}
788
+ `, { mode: 384 });
789
+ logger.info("updated ~/.ssh/config for github.com");
790
+ }
791
+ logger.info(`deploy key installed at ${destKey}`);
792
+ }
793
+ async function installLogrotate(service, options = {}) {
794
+ const { dryRun = false, logger = console } = options;
795
+ const conf = `/etc/logrotate.d/${service.name}`;
796
+ const body = `${service.appsRoot}/logs/*.log {
797
+ daily
798
+ rotate 14
799
+ compress
800
+ delaycompress
801
+ missingok
802
+ notifempty
803
+ copytruncate
804
+ }
805
+ `;
806
+ if (dryRun) {
807
+ logger.info(`[dryRun] would write ${conf}`);
808
+ return;
809
+ }
810
+ const tmp = (0, import_node_path9.join)("/tmp", `${service.name}-logrotate.conf`);
811
+ await (0, import_promises12.writeFile)(tmp, body);
812
+ await runShell(`sudo cp '${tmp}' '${conf}'`, { logger });
813
+ logger.info(`logrotate config written: ${conf}`);
814
+ }
815
+ async function bootstrapHost(service, options = {}) {
816
+ const { deployKey = service.deployKey, user = "ubuntu", dryRun = false, logger = console } = options;
817
+ await ensurePm2Startup({ user, dryRun, logger });
818
+ if (deployKey) await installDeployKey(deployKey, { dryRun, logger });
819
+ await installLogrotate(service, { dryRun, logger });
820
+ logger.info("bootstrap-host complete");
821
+ }
822
+
823
+ // src/deploy/deploy-service.js
824
+ async function deployService(service, options = {}) {
825
+ const {
826
+ dryRun = false,
827
+ skipPull = false,
828
+ skipTests = false,
829
+ skipNginx = false,
830
+ logger = console
831
+ } = options;
832
+ const paths = servicePaths(service);
833
+ await initServiceStructure(service, { dryRun, logger });
834
+ if (!skipPull) await pullRepo(service, { dryRun, logger });
835
+ await syncEnv(service, { dryRun, logger });
836
+ const { stamp, path: releasePath } = await createRelease(service, { dryRun, logger });
837
+ await writeReleaseBuildInfo(service, releasePath, { stamp, dryRun, logger });
838
+ await installDeps(service, releasePath, paths, { dryRun, logger });
839
+ if (!skipTests) await runReleaseTests(service, releasePath, paths, { dryRun, logger });
840
+ await activateRelease(releasePath, paths, { dryRun, logger });
841
+ await pruneReleases(service, paths, { dryRun, logger });
842
+ await reloadPm2(paths, { dryRun, logger });
843
+ if (!skipNginx) await enableNginxUpstream(service, { dryRun, logger });
844
+ const summary = `deploy complete stamp=${stamp} dryRun=${dryRun}`;
845
+ logger.info(summary);
846
+ if (!dryRun) await appendDeployLog(paths.deployLog, summary);
847
+ return { stamp, releasePath };
848
+ }
849
+
850
+ // src/deploy/provision-service.js
851
+ var import_promises13 = require("fs/promises");
852
+ async function pathExists6(path) {
853
+ try {
854
+ await (0, import_promises13.access)(path);
855
+ return true;
856
+ } catch {
857
+ return false;
858
+ }
859
+ }
860
+ async function provisionService(service, options = {}) {
861
+ const {
862
+ dryRun = false,
863
+ deploy = true,
864
+ skipBootstrap = true,
865
+ deployKey,
866
+ logger = console
867
+ } = options;
868
+ if (!skipBootstrap) {
869
+ await bootstrapHost(service, { deployKey, dryRun, logger });
870
+ }
871
+ await initServiceStructure(service, { dryRun, logger });
872
+ const paths = servicePaths(service);
873
+ if (await pathExists6(paths.repo)) {
874
+ logger.info(`repo exists at ${paths.repo} \u2014 pulling`);
875
+ await pullRepo(service, { dryRun, logger });
876
+ } else {
877
+ await cloneRepo(service, { dryRun, logger });
878
+ }
879
+ await syncEnv(service, { dryRun, logger });
880
+ if (deploy) {
881
+ await deployService(service, { dryRun, logger, skipPull: true });
882
+ } else {
883
+ logger.info("provision complete (deploy skipped)");
884
+ }
885
+ }
886
+
887
+ // src/deploy/rollback-service.js
888
+ var import_promises14 = require("fs/promises");
889
+ async function rollbackService(service, options = {}) {
890
+ const { release: targetName, dryRun = false, logger = console } = options;
891
+ const paths = servicePaths(service);
892
+ const releases = await listReleases(paths);
893
+ if (releases.length === 0) throw new Error("No releases to roll back to");
894
+ let activeName = null;
895
+ try {
896
+ const target = await (0, import_promises14.readlink)(paths.current);
897
+ activeName = target.split("/").pop();
898
+ } catch {
899
+ throw new Error("No active release (current symlink missing)");
900
+ }
901
+ let rollbackTarget;
902
+ if (targetName) {
903
+ rollbackTarget = releases.find((r) => r.name === targetName);
904
+ if (!rollbackTarget) throw new Error(`Release not found: ${targetName}`);
905
+ } else {
906
+ rollbackTarget = releases.find((r) => r.name !== activeName);
907
+ if (!rollbackTarget) throw new Error("No previous release to roll back to");
908
+ }
909
+ if (rollbackTarget.name === activeName) throw new Error(`Already on release ${activeName}`);
910
+ logger.info(`rollback ${activeName} \u2192 ${rollbackTarget.name}`);
911
+ const buildInfo = await readReleaseBuildInfo(service, rollbackTarget.path);
912
+ if (buildInfo?.version) {
913
+ logger.info(`rollback target: v${buildInfo.version} release=${buildInfo.release ?? rollbackTarget.name}`);
914
+ }
915
+ await activateRelease(rollbackTarget.path, paths, { dryRun, logger });
916
+ await reloadPm2(paths, { dryRun, logger });
917
+ const summary = `rollback ${activeName} \u2192 ${rollbackTarget.name} dryRun=${dryRun}`;
918
+ if (!dryRun) await appendDeployLog(paths.deployLog, summary);
919
+ return { from: activeName, to: rollbackTarget.name, path: rollbackTarget.path };
920
+ }
921
+
922
+ // src/deploy/ssh-remote.js
923
+ var import_promises15 = require("fs/promises");
924
+ var import_node_os3 = require("os");
925
+ var import_node_path10 = require("path");
926
+ var import_node_child_process4 = require("child_process");
927
+ var REMOTE_CLI_REL = "node_modules/@nmakarov/cli-toolkit/scripts/deploy/cli.js";
928
+ async function pathExists7(path) {
929
+ try {
930
+ await (0, import_promises15.access)(path);
931
+ return true;
932
+ } catch {
933
+ return false;
934
+ }
935
+ }
936
+ function expandHome2(path) {
937
+ return path.startsWith("~/") ? (0, import_node_path10.join)((0, import_node_os3.homedir)(), path.slice(2)) : path;
938
+ }
939
+ function resolveLocalEnvPath(envFile) {
940
+ if (envFile) {
941
+ const expanded = expandHome2(envFile);
942
+ return expanded.startsWith("/") ? expanded : (0, import_node_path10.join)(process.cwd(), expanded);
943
+ }
944
+ return (0, import_node_path10.join)(process.cwd(), ".env");
945
+ }
946
+ function parseGitHost(repoUrl) {
947
+ const u = String(repoUrl ?? "");
948
+ let m = u.match(/^[^@]+@([^:]+):/);
949
+ if (m) return m[1];
950
+ m = u.match(/^ssh:\/\/[^@]+@([^/:]+)/);
951
+ if (m) return m[1];
952
+ return null;
953
+ }
954
+ function shellQuote(value) {
955
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
956
+ }
957
+ function sshRun(host, remoteCommand, options = {}) {
958
+ const { logger } = options;
959
+ return new Promise((resolve2, reject) => {
960
+ logger?.info?.(`ssh ${host} ${remoteCommand.slice(0, 120)}${remoteCommand.length > 120 ? "\u2026" : ""}`);
961
+ const child = (0, import_node_child_process4.spawn)("ssh", [host, remoteCommand], { stdio: "inherit" });
962
+ child.on("error", reject);
963
+ child.on("close", (code) => code === 0 ? resolve2() : reject(new Error(`ssh ${host} exited with code ${code}`)));
964
+ });
965
+ }
966
+ function scp(localPath, remoteSpec) {
967
+ return new Promise((resolve2, reject) => {
968
+ const child = (0, import_node_child_process4.spawn)("scp", [localPath, remoteSpec], { stdio: "inherit" });
969
+ child.on("error", reject);
970
+ child.on("close", (code) => code === 0 ? resolve2() : reject(new Error(`scp exited with code ${code}`)));
971
+ });
972
+ }
973
+ async function ensureDeployKeyOnRemote(host, deployKeyPath, options = {}) {
974
+ const { logger = console } = options;
975
+ if (!deployKeyPath) return false;
976
+ const localPath = expandHome2(deployKeyPath);
977
+ if (!await pathExists7(localPath)) return false;
978
+ const keyBase = (0, import_node_path10.basename)(localPath);
979
+ logger.info(`copying deploy key ${localPath} \u2192 ${host}:~/.ssh/${keyBase}`);
980
+ await sshRun(host, "mkdir -p ~/.ssh && chmod 700 ~/.ssh", { logger });
981
+ await scp(localPath, `${host}:.ssh/${keyBase}`, { logger });
982
+ await sshRun(host, `chmod 600 ~/.ssh/${keyBase}`, { logger });
983
+ return true;
984
+ }
985
+ async function prepareGitHost(host, options = {}) {
986
+ const { logger = console, gitHost, keyBasename } = options;
987
+ if (!gitHost) return;
988
+ const configBlock = keyBasename ? `if ! grep -q 'Host ${gitHost}' ~/.ssh/config 2>/dev/null; then
989
+ printf '%s\\n' '' 'Host ${gitHost}' ' HostName ${gitHost}' ' User git' ' IdentityFile ~/.ssh/${keyBasename}' ' IdentitiesOnly yes' >> ~/.ssh/config
990
+ chmod 600 ~/.ssh/config
991
+ echo "configured ~/.ssh/config for ${gitHost}"
992
+ fi` : `:`;
993
+ const script = `
994
+ set -euo pipefail
995
+ mkdir -p ~/.ssh
996
+ chmod 700 ~/.ssh
997
+ if ! grep -q '^${gitHost}' ~/.ssh/known_hosts 2>/dev/null; then
998
+ ssh-keyscan -t ed25519,rsa ${gitHost} >> ~/.ssh/known_hosts 2>/dev/null
999
+ echo "added ${gitHost} to known_hosts"
1000
+ fi
1001
+ ${configBlock}
1002
+ `.trim();
1003
+ await sshRun(host, script, { logger });
1004
+ }
1005
+ async function ensureRepoDependencies(host, service, options = {}) {
1006
+ const { logger = console } = options;
1007
+ const paths = servicePaths(service);
1008
+ const run2 = shellQuote(paths.repoRun);
1009
+ await sshRun(
1010
+ host,
1011
+ `cd ${run2} && if [ ! -d node_modules/@nmakarov/cli-toolkit ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then npm ci; fi`,
1012
+ { logger }
1013
+ );
1014
+ }
1015
+ async function ensureEnvOnRemote(host, service, options = {}) {
1016
+ const { logger = console, envFile } = options;
1017
+ const paths = servicePaths(service);
1018
+ const localPath = resolveLocalEnvPath(envFile);
1019
+ if (!await pathExists7(localPath)) return false;
1020
+ logger.info(`copying .env ${localPath} \u2192 ${host}:${paths.repoEnv}`);
1021
+ await sshRun(host, `mkdir -p ${shellQuote((0, import_node_path10.dirname)(paths.repoEnv))}`, { logger });
1022
+ const scrubbed = scrubEnvContent(await (0, import_promises15.readFile)(localPath, "utf8"), service.envScrubPatterns ?? []);
1023
+ const tmp = (0, import_node_path10.join)((0, import_node_os3.tmpdir)(), `deploy-env-${Date.now()}`);
1024
+ await (0, import_promises15.writeFile)(tmp, scrubbed, { mode: 384 });
1025
+ await scp(tmp, `${host}:${paths.repoEnv}`, { logger });
1026
+ await sshRun(host, `chmod 600 ${shellQuote(paths.repoEnv)}`, { logger });
1027
+ return true;
1028
+ }
1029
+ async function ensureRemoteRepo(host, service, options = {}) {
1030
+ const { logger = console, deployKey = service.deployKey, envFile } = options;
1031
+ const paths = servicePaths(service);
1032
+ const repo = shellQuote(paths.repo);
1033
+ const repoUrl = shellQuote(service.repoUrl);
1034
+ const root = shellQuote(paths.root);
1035
+ const localKeyBase = deployKey ? (0, import_node_path10.basename)(expandHome2(deployKey)) : null;
1036
+ await ensureDeployKeyOnRemote(host, deployKey, { logger });
1037
+ await prepareGitHost(host, { logger, gitHost: parseGitHost(service.repoUrl), keyBasename: localKeyBase });
1038
+ await sshRun(
1039
+ host,
1040
+ `mkdir -p ${root} && if [ -d ${repo}/.git ]; then git -C ${repo} pull --ff-only; else git clone ${repoUrl} ${repo}; fi`,
1041
+ { logger }
1042
+ );
1043
+ await ensureRepoDependencies(host, service, { logger });
1044
+ await ensureEnvOnRemote(host, service, { logger, envFile });
1045
+ }
1046
+ async function runRemoteCli(host, service, command, args = [], options = {}) {
1047
+ const { logger = console, manifests, skipPull = false, deployKey = service.deployKey, envFile } = options;
1048
+ const paths = servicePaths(service);
1049
+ const run2 = shellQuote(paths.repoRun);
1050
+ const cli = shellQuote(REMOTE_CLI_REL);
1051
+ if (!skipPull) {
1052
+ await ensureRemoteRepo(host, service, { logger, deployKey, envFile });
1053
+ } else {
1054
+ await ensureRepoDependencies(host, service, { logger });
1055
+ await ensureEnvOnRemote(host, service, { logger, envFile });
1056
+ }
1057
+ const passthrough = [
1058
+ `--service=${service.name}`,
1059
+ ...manifests ? [`--manifests=${manifests}`] : [],
1060
+ ...args
1061
+ ].map(shellQuote).join(" ");
1062
+ await sshRun(host, `cd ${run2} && node ${cli} ${shellQuote(command)} ${passthrough}`.trim(), { logger });
1063
+ }
1064
+ async function runRemoteStatus(host, service, options = {}) {
1065
+ const { logger = console } = options;
1066
+ const paths = servicePaths(service);
1067
+ const port = service.pm2.port;
1068
+ const app = service.pm2.appName;
1069
+ const script = `
1070
+ echo "=== apps root ==="
1071
+ ls -la ${shellQuote(paths.root)} 2>/dev/null || echo "(missing)"
1072
+ echo ""
1073
+ echo "=== current ==="
1074
+ readlink ${shellQuote(paths.current)} 2>/dev/null || echo "(not set)"
1075
+ echo ""
1076
+ echo "=== releases ==="
1077
+ ls -1 ${shellQuote(paths.releases)} 2>/dev/null || echo "(none)"
1078
+ echo ""
1079
+ echo "=== pm2 ==="
1080
+ pm2 describe ${app} 2>/dev/null | head -20 || pm2 status ${app} 2>/dev/null || echo "(not running)"
1081
+ ${port ? `echo ""
1082
+ echo "=== app health (localhost) ==="
1083
+ curl -sf http://127.0.0.1:${port}/healthz 2>/dev/null || echo "(no /healthz on :${port})"` : ""}
1084
+ `.trim();
1085
+ await sshRun(host, script, { logger });
1086
+ }
1087
+
1088
+ // src/deploy/manifests.js
1089
+ var import_node_path11 = require("path");
1090
+ var import_node_url = require("url");
1091
+ async function loadServices({ manifests = "deploy/services.js", cwd = process.cwd() } = {}) {
1092
+ const abs = (0, import_node_path11.isAbsolute)(manifests) ? manifests : (0, import_node_path11.resolve)(cwd, manifests);
1093
+ let mod;
1094
+ try {
1095
+ mod = await import((0, import_node_url.pathToFileURL)(abs).href);
1096
+ } catch (err) {
1097
+ throw new Error(`Could not load deploy manifests from ${abs}: ${err.message}`);
1098
+ }
1099
+ const raw = mod.services ?? mod.default;
1100
+ if (!raw) {
1101
+ throw new Error(`Manifests module ${abs} must export \`services\` (or default): a map or array of service manifests`);
1102
+ }
1103
+ const list = Array.isArray(raw) ? raw : Object.values(raw);
1104
+ const out = {};
1105
+ for (const entry of list) {
1106
+ const svc = defineService(entry);
1107
+ out[svc.name] = svc;
1108
+ }
1109
+ return out;
1110
+ }
1111
+ function resolveServiceFrom(serviceMap, name, { appsRoot } = {}) {
1112
+ const svc = serviceMap[name];
1113
+ if (!svc) {
1114
+ throw new Error(`Unknown service "${name}". Known: ${Object.keys(serviceMap).join(", ") || "(none)"}`);
1115
+ }
1116
+ return appsRoot ? { ...svc, appsRoot } : svc;
1117
+ }
1118
+ // Annotate the CommonJS export names for ESM import in node:
1119
+ 0 && (module.exports = {
1120
+ REMOTE_CLI_REL,
1121
+ activateRelease,
1122
+ appendDeployLog,
1123
+ bootstrapHost,
1124
+ bumpPatchVersion,
1125
+ cloneRepo,
1126
+ createRelease,
1127
+ defineService,
1128
+ deployService,
1129
+ deriveRepoDirName,
1130
+ enableNginxUpstream,
1131
+ ensureDeployKeyOnRemote,
1132
+ ensureEnvOnRemote,
1133
+ ensureRemoteRepo,
1134
+ ensureRepoDependencies,
1135
+ initServiceStructure,
1136
+ installDeps,
1137
+ listReleases,
1138
+ loadServices,
1139
+ npmEnv,
1140
+ npmInstallEnv,
1141
+ parseGitHost,
1142
+ prepareGitHost,
1143
+ provisionService,
1144
+ pruneReleases,
1145
+ pullRepo,
1146
+ readCurrentRelease,
1147
+ readReleaseBuildInfo,
1148
+ releaseDir,
1149
+ releaseStamp,
1150
+ reloadPm2,
1151
+ resolveNextVersion,
1152
+ resolveServiceFrom,
1153
+ rollbackService,
1154
+ run,
1155
+ runReleaseTests,
1156
+ runRemoteCli,
1157
+ runRemoteStatus,
1158
+ runShell,
1159
+ scrubEnvContent,
1160
+ servicePaths,
1161
+ shellQuote,
1162
+ sshRun,
1163
+ syncEnv,
1164
+ writeReleaseBuildInfo
1165
+ });
1166
+ //# sourceMappingURL=deploy.cjs.map