underpost 3.1.3 → 3.2.2

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.
Files changed (92) hide show
  1. package/.env.example +0 -2
  2. package/.github/workflows/ghpkg.ci.yml +4 -4
  3. package/.github/workflows/npmpkg.ci.yml +28 -11
  4. package/.github/workflows/publish.ci.yml +6 -0
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -5
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
  7. package/.github/workflows/release.cd.yml +13 -8
  8. package/CHANGELOG.md +396 -1
  9. package/CLI-HELP.md +53 -6
  10. package/Dockerfile +4 -2
  11. package/README.md +3 -2
  12. package/bin/build.js +18 -12
  13. package/bin/deploy.js +177 -124
  14. package/bin/file.js +3 -0
  15. package/conf.js +3 -2
  16. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -2
  17. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +5 -2
  18. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  19. package/manifests/deployment/dd-test-development/deployment.yaml +88 -74
  20. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  21. package/manifests/deployment/playwright/deployment.yaml +1 -1
  22. package/nodemon.json +1 -1
  23. package/package.json +22 -15
  24. package/scripts/rhel-grpc-setup.sh +56 -0
  25. package/src/api/file/file.ref.json +18 -0
  26. package/src/api/user/user.service.js +8 -7
  27. package/src/cli/cluster.js +7 -7
  28. package/src/cli/db.js +726 -825
  29. package/src/cli/deploy.js +151 -93
  30. package/src/cli/env.js +19 -0
  31. package/src/cli/fs.js +5 -2
  32. package/src/cli/index.js +45 -2
  33. package/src/cli/kubectl.js +211 -0
  34. package/src/cli/release.js +284 -0
  35. package/src/cli/repository.js +434 -75
  36. package/src/cli/run.js +189 -34
  37. package/src/cli/secrets.js +73 -0
  38. package/src/cli/test.js +3 -3
  39. package/src/client/Default.index.js +3 -4
  40. package/src/client/components/core/AppStore.js +69 -0
  41. package/src/client/components/core/CalendarCore.js +2 -2
  42. package/src/client/components/core/DropDown.js +137 -17
  43. package/src/client/components/core/Keyboard.js +2 -2
  44. package/src/client/components/core/LogIn.js +2 -2
  45. package/src/client/components/core/LogOut.js +2 -2
  46. package/src/client/components/core/Modal.js +0 -1
  47. package/src/client/components/core/Panel.js +0 -1
  48. package/src/client/components/core/PanelForm.js +19 -19
  49. package/src/client/components/core/SocketIo.js +82 -29
  50. package/src/client/components/core/SocketIoHandler.js +75 -0
  51. package/src/client/components/core/Stream.js +143 -95
  52. package/src/client/components/core/Webhook.js +40 -7
  53. package/src/client/components/default/AppStoreDefault.js +5 -0
  54. package/src/client/components/default/LogInDefault.js +3 -3
  55. package/src/client/components/default/LogOutDefault.js +2 -2
  56. package/src/client/components/default/MenuDefault.js +5 -5
  57. package/src/client/components/default/SocketIoDefault.js +3 -51
  58. package/src/client/services/core/core.service.js +20 -8
  59. package/src/client/services/user/user.management.js +2 -2
  60. package/src/index.js +24 -1
  61. package/src/runtime/express/Dockerfile +4 -0
  62. package/src/runtime/express/Express.js +18 -1
  63. package/src/runtime/lampp/Dockerfile +13 -2
  64. package/src/runtime/lampp/Lampp.js +27 -4
  65. package/src/runtime/wp/Dockerfile +68 -0
  66. package/src/runtime/wp/Wp.js +639 -0
  67. package/src/server/auth.js +24 -1
  68. package/src/server/backup.js +57 -23
  69. package/src/server/client-build-docs.js +9 -2
  70. package/src/server/client-build.js +31 -31
  71. package/src/server/client-formatted.js +109 -57
  72. package/src/server/cron.js +23 -18
  73. package/src/server/ipfs-client.js +24 -1
  74. package/src/server/peer.js +8 -0
  75. package/src/server/runtime.js +25 -1
  76. package/src/server/start.js +3 -2
  77. package/src/ws/IoInterface.js +1 -10
  78. package/src/ws/IoServer.js +14 -33
  79. package/src/ws/core/channels/core.ws.chat.js +65 -20
  80. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  81. package/src/ws/core/channels/core.ws.stream.js +90 -31
  82. package/src/ws/core/core.ws.connection.js +12 -33
  83. package/src/ws/core/core.ws.emit.js +10 -26
  84. package/src/ws/core/core.ws.server.js +25 -58
  85. package/src/ws/default/channels/default.ws.main.js +53 -12
  86. package/src/ws/default/default.ws.connection.js +26 -13
  87. package/src/ws/default/default.ws.server.js +30 -12
  88. package/src/client/components/default/ElementsDefault.js +0 -38
  89. package/src/ws/core/management/core.ws.chat.js +0 -8
  90. package/src/ws/core/management/core.ws.mailer.js +0 -16
  91. package/src/ws/core/management/core.ws.stream.js +0 -8
  92. package/src/ws/default/management/default.ws.main.js +0 -8
@@ -97,6 +97,7 @@ class UnderpostRepository {
97
97
  * @param {boolean} [options.cached=false] - If true, commits only staged changes.
98
98
  * @param {number} [options.log=0] - If greater than 0, shows the last N commits with diffs.
99
99
  * @param {boolean} [options.lastMsg=0] - If greater than 0, copies or show the last last single n commit message to clipboard.
100
+ * @param {boolean} [options.unpush=false] - If true with --log, automatically detects unpushed commits ahead of remote and uses that count.
100
101
  * @param {string} [options.deployId=''] - An optional deploy ID to include in the commit message.
101
102
  * @param {string} [options.hashes=''] - If provided with diff option, shows the diff between two hashes.
102
103
  * @param {string} [options.extension=''] - If provided with diff option, filters the diff by this file extension.
@@ -127,11 +128,51 @@ class UnderpostRepository {
127
128
  changelogBuild: false,
128
129
  changelogMinVersion: '',
129
130
  changelogNoHash: false,
131
+ unpush: false,
130
132
  b: false,
133
+ p: undefined,
134
+ bc: '',
135
+ isRemoteRepo: '',
131
136
  },
132
137
  ) {
133
138
  if (!repoPath) repoPath = '.';
134
139
 
140
+ if (options.isRemoteRepo) {
141
+ const accessible = Underpost.repo.isRemoteRepo(options.isRemoteRepo);
142
+ console.log(accessible);
143
+ return;
144
+ }
145
+
146
+ if (options.bc) {
147
+ console.log(
148
+ shellExec(`cd ${repoPath} && git for-each-ref --contains ${options.bc} --format='%(refname:short)'`, {
149
+ stdout: true,
150
+ silent: true,
151
+ disableLog: true,
152
+ }).trim(),
153
+ );
154
+ return;
155
+ }
156
+
157
+ if (options.p !== undefined) {
158
+ const branch =
159
+ options.p === true
160
+ ? shellExec(`cd ${repoPath} && git branch --show-current`, {
161
+ stdout: true,
162
+ silent: true,
163
+ disableLog: true,
164
+ }).trim()
165
+ : options.p;
166
+ console.log(
167
+ shellExec(`cd ${repoPath} && git --no-pager reflog show refs/heads/${branch}`, {
168
+ stdout: true,
169
+ silent: true,
170
+ disableLog: true,
171
+ }).trim(),
172
+ );
173
+ return;
174
+ }
175
+
135
176
  if (options.b) {
136
177
  const currentBranch = shellExec(`cd ${repoPath} && git branch --show-current`, {
137
178
  stdout: true,
@@ -304,17 +345,25 @@ class UnderpostRepository {
304
345
  else console.log('Diff command:', _diffCmd);
305
346
  return;
306
347
  }
307
- if (options.log) {
308
- const history = Underpost.repo.getHistory(options.log);
348
+ if (options.log || options.unpush) {
349
+ if (options.unpush) {
350
+ const { count, hasUnpushed } = Underpost.repo.getUnpushedCount(repoPath);
351
+ if (!hasUnpushed) {
352
+ logger.warn('No unpushed commits found');
353
+ return;
354
+ }
355
+ options.log = count;
356
+ }
357
+ const history = Underpost.repo.getHistory(options.log, repoPath);
309
358
  const chainCmd = history
310
359
  .reverse()
311
- .map((commitData, i) => `${i === 0 ? '' : ' && '}git ${diffCmd} ${commitData.hash}`)
360
+ .map((commitData, i) => `${i === 0 ? '' : ' && '}git -C ${repoPath} ${diffCmd} ${commitData.hash}`)
312
361
  .join('');
313
362
  if (history[0]) {
314
363
  let index = history.length;
315
364
  for (const commit of history) {
316
365
  console.log(
317
- shellExec(`git show -s --format=%ci ${commit.hash}`, {
366
+ shellExec(`cd ${repoPath} && git show -s --format=%ci ${commit.hash}`, {
318
367
  stdout: true,
319
368
  silent: true,
320
369
  disableLog: true,
@@ -323,7 +372,7 @@ class UnderpostRepository {
323
372
  console.log(`${index}`.magenta, commit.hash.yellow, commit.message);
324
373
  index--;
325
374
  console.log(
326
- shellExec(`git show --name-status --pretty="" ${commit.hash}`, {
375
+ shellExec(`cd ${repoPath} && git show --name-status --pretty="" ${commit.hash}`, {
327
376
  stdout: true,
328
377
  silent: true,
329
378
  disableLog: true,
@@ -547,14 +596,9 @@ class UnderpostRepository {
547
596
  logger.info(`Created repository directory: ${repo.path}`);
548
597
  }
549
598
 
550
- // Initialize git repository
551
- shellExec(`cd ${repo.path} && git init`, { disableLog: false });
552
- logger.info(`Initialized git repository in: ${repo.path}`);
553
-
554
- // Add remote origin
555
599
  const remoteUrl = `https://${token ? `${token}@` : ''}github.com/${username}/${repo.name}.git`;
556
- shellExec(`cd ${repo.path} && git remote add origin ${remoteUrl}`, { disableLog: false });
557
- logger.info(`Added remote origin for: ${repo.name}`);
600
+ UnderpostRepository.API.initLocalRepo({ path: repo.path, origin: remoteUrl });
601
+ logger.info(`Initialized git repository with remote: ${repo.name}`);
558
602
  }
559
603
  }
560
604
  return resolve(true);
@@ -573,7 +617,8 @@ class UnderpostRepository {
573
617
  fs.readFileSync(`${underpostRoot}/.dockerignore`, 'utf8'),
574
618
  'utf8',
575
619
  );
576
- shellExec(`cd ${destFolder} && git init && git add . && git commit -m "Base template implementation"`);
620
+ UnderpostRepository.API.initLocalRepo({ path: destFolder });
621
+ shellExec(`cd ${destFolder} && git add . && git commit -m "Base template implementation"`);
577
622
  }
578
623
  shellExec(`cd ${destFolder} && npm run build`);
579
624
  shellExec(`cd ${destFolder} && npm run dev`);
@@ -617,65 +662,7 @@ class UnderpostRepository {
617
662
  ) {
618
663
  return new Promise(async (resolve, reject) => {
619
664
  try {
620
- // Handle syncEnvPort operation
621
- if (options.syncEnvPort) {
622
- const dataDeploy = await getDataDeploy({ disableSyncEnvPort: true });
623
- const dataEnv = [
624
- { env: 'production', port: 3000 },
625
- { env: 'development', port: 4000 },
626
- { env: 'test', port: 5000 },
627
- ];
628
- let portOffset = 0;
629
- const singleReplicaPortOffsets = {};
630
- for (const deployIdObj of dataDeploy) {
631
- const { deployId } = deployIdObj;
632
- const baseConfPath = fs.existsSync(`./engine-private/replica/${deployId}`)
633
- ? `./engine-private/replica`
634
- : `./engine-private/conf`;
635
-
636
- const effectivePortOffset =
637
- singleReplicaPortOffsets[deployId] !== undefined ? singleReplicaPortOffsets[deployId] : portOffset;
638
-
639
- for (const envInstanceObj of dataEnv) {
640
- const envPath = `${baseConfPath}/${deployId}/.env.${envInstanceObj.env}`;
641
- const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
642
- envObj.PORT = `${envInstanceObj.port + effectivePortOffset}`;
643
- writeEnv(envPath, envObj);
644
- }
645
-
646
- if (singleReplicaPortOffsets[deployId] !== undefined) continue;
647
-
648
- const serverConf = loadReplicas(
649
- deployId,
650
- loadConfServerJson(`${baseConfPath}/${deployId}/conf.server.json`),
651
- );
652
- for (const host of Object.keys(serverConf)) {
653
- let deferredSingleReplicaSlots = [];
654
- for (const path of Object.keys(serverConf[host])) {
655
- if (serverConf[host][path].singleReplica && serverConf[host][path].replicas) {
656
- deferredSingleReplicaSlots.push({
657
- replicas: serverConf[host][path].replicas,
658
- peer: !!serverConf[host][path].peer,
659
- });
660
- continue;
661
- }
662
- portOffset++;
663
- if (serverConf[host][path].peer) portOffset++;
664
- }
665
- for (const slot of deferredSingleReplicaSlots) {
666
- for (const replica of slot.replicas) {
667
- const replicaDeployId = buildReplicaId({ deployId, replica });
668
- singleReplicaPortOffsets[replicaDeployId] = portOffset;
669
- portOffset++;
670
- if (slot.peer) portOffset++;
671
- }
672
- }
673
- }
674
- }
675
- return resolve(true);
676
- }
677
-
678
- // Handle singleReplica operation
665
+ // Handle singleReplica operation (must run before syncEnvPort to ensure replica dirs exist)
679
666
  if (options.singleReplica) {
680
667
  const replicaPath = path;
681
668
  if (!deployId || !host || !replicaPath) {
@@ -741,6 +728,71 @@ class UnderpostRepository {
741
728
  }
742
729
  }
743
730
  }
731
+ if (!options.syncEnvPort) return resolve(true);
732
+ }
733
+
734
+ // Handle syncEnvPort operation
735
+ if (options.syncEnvPort) {
736
+ const dataDeploy = await getDataDeploy({ disableSyncEnvPort: true });
737
+ const dataEnv = [
738
+ { env: 'production', port: 3000 },
739
+ { env: 'development', port: 4000 },
740
+ { env: 'test', port: 5000 },
741
+ ];
742
+ let portOffset = 0;
743
+ const singleReplicaPortOffsets = {};
744
+ for (const deployIdObj of dataDeploy) {
745
+ const { deployId } = deployIdObj;
746
+ const baseConfPath = fs.existsSync(`./engine-private/replica/${deployId}`)
747
+ ? `./engine-private/replica`
748
+ : `./engine-private/conf`;
749
+
750
+ const effectivePortOffset =
751
+ singleReplicaPortOffsets[deployId] !== undefined ? singleReplicaPortOffsets[deployId] : portOffset;
752
+
753
+ let skipDeploy = false;
754
+ for (const envInstanceObj of dataEnv) {
755
+ const envPath = `${baseConfPath}/${deployId}/.env.${envInstanceObj.env}`;
756
+ if (!fs.existsSync(envPath)) {
757
+ logger.warn(`Skipping ${deployId}: ${envPath} not found`);
758
+ skipDeploy = true;
759
+ break;
760
+ }
761
+ const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
762
+ envObj.PORT = `${envInstanceObj.port + effectivePortOffset}`;
763
+ writeEnv(envPath, envObj);
764
+ }
765
+
766
+ if (skipDeploy) continue;
767
+ if (singleReplicaPortOffsets[deployId] !== undefined) continue;
768
+
769
+ const serverConf = loadReplicas(
770
+ deployId,
771
+ loadConfServerJson(`${baseConfPath}/${deployId}/conf.server.json`),
772
+ );
773
+ for (const host of Object.keys(serverConf)) {
774
+ let deferredSingleReplicaSlots = [];
775
+ for (const path of Object.keys(serverConf[host])) {
776
+ if (serverConf[host][path].singleReplica && serverConf[host][path].replicas) {
777
+ deferredSingleReplicaSlots.push({
778
+ replicas: serverConf[host][path].replicas,
779
+ peer: !!serverConf[host][path].peer,
780
+ });
781
+ continue;
782
+ }
783
+ portOffset++;
784
+ if (serverConf[host][path].peer) portOffset++;
785
+ }
786
+ for (const slot of deferredSingleReplicaSlots) {
787
+ for (const replica of slot.replicas) {
788
+ const replicaDeployId = buildReplicaId({ deployId, replica });
789
+ singleReplicaPortOffsets[replicaDeployId] = portOffset;
790
+ portOffset++;
791
+ if (slot.peer) portOffset++;
792
+ }
793
+ }
794
+ }
795
+ }
744
796
  return resolve(true);
745
797
  }
746
798
 
@@ -868,8 +920,8 @@ Prevent build private config repo.`,
868
920
  * @returns {Array<{hash: string, message: string, files: string}>} An array of commit objects with hash, message, and files.
869
921
  * @memberof UnderpostRepository
870
922
  */
871
- getHistory(sinceCommit = 1) {
872
- return shellExec(`git log -1 --pretty=format:"%h %s" -n ${sinceCommit}`, {
923
+ getHistory(sinceCommit = 1, repoPath = '.') {
924
+ return shellExec(`cd ${repoPath} && git log -1 --pretty=format:"%h %s" -n ${sinceCommit}`, {
873
925
  stdout: true,
874
926
  silent: true,
875
927
  disableLog: true,
@@ -884,7 +936,7 @@ Prevent build private config repo.`,
884
936
  })
885
937
  .filter((line) => line.hash)
886
938
  .map((line) => {
887
- line.files = shellExec(`git show --name-status --pretty="" ${line.hash}`, {
939
+ line.files = shellExec(`cd ${repoPath} && git show --name-status --pretty="" ${line.hash}`, {
888
940
  stdout: true,
889
941
  silent: true,
890
942
  disableLog: true,
@@ -1185,6 +1237,68 @@ Prevent build private config repo.`,
1185
1237
  logger.info('Dispatched workflow', `${repo} -> ${workflowFile}`, inputs.job ? `(job: ${inputs.job})` : '');
1186
1238
  },
1187
1239
 
1240
+ /**
1241
+ * Returns metadata about unpushed commits in a git repository.
1242
+ * Fetches from origin, then counts commits ahead of the remote branch.
1243
+ * @param {string} [repoPath='.'] - Path to the git repository.
1244
+ * @param {number} [fallback=1] - Value to return as `count` when no unpushed commits are detected.
1245
+ * @returns {{ count: number, branch: string, hasUnpushed: boolean }} Unpush metadata.
1246
+ * @memberof UnderpostRepository
1247
+ */
1248
+ /**
1249
+ * Checks whether a remote Git repository URL is reachable.
1250
+ * Uses `git ls-remote` with `|| true` so the process always exits 0.
1251
+ * Injects `GITHUB_TOKEN` into GitHub HTTPS URLs when available.
1252
+ * @param {string} url - Full HTTPS clone URL to test (e.g. "https://github.com/org/repo.git").
1253
+ * @returns {boolean} `true` when the remote responded with at least one ref hash.
1254
+ * @memberof UnderpostRepository
1255
+ */
1256
+ resolveAuthUrl(url) {
1257
+ if (!url) return url;
1258
+ // Normalize short form "owner/repo" → full GitHub HTTPS URL
1259
+ let normalized = url;
1260
+ if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
1261
+ normalized = `https://github.com/${url}`;
1262
+ }
1263
+ if (process.env.GITHUB_TOKEN && normalized.startsWith('https://github.com/')) {
1264
+ return normalized.replace(
1265
+ 'https://github.com/',
1266
+ `https://x-access-token:${process.env.GITHUB_TOKEN}@github.com/`,
1267
+ );
1268
+ }
1269
+ return normalized;
1270
+ },
1271
+
1272
+ isRemoteRepo(url) {
1273
+ if (!url) return false;
1274
+ const authUrl = Underpost.repo.resolveAuthUrl(url);
1275
+ // GIT_TERMINAL_PROMPT=0 prevents git from hanging on credential prompts inside containers.
1276
+ const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git ls-remote "${authUrl}" HEAD 2>&1 || true`, {
1277
+ stdout: true,
1278
+ silent: true,
1279
+ disableLog: true,
1280
+ });
1281
+ logger.info('isRemoteRepo', { url, raw: (raw || '').slice(0, 120) });
1282
+ return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
1283
+ },
1284
+
1285
+ getUnpushedCount(repoPath = '.', fallback = 1) {
1286
+ const branch = shellExec(`cd ${repoPath} && git branch --show-current`, {
1287
+ stdout: true,
1288
+ silent: true,
1289
+ disableLog: true,
1290
+ }).trim();
1291
+ shellExec(`cd ${repoPath} && git fetch origin 2>/dev/null`, { silent: true, disableLog: true });
1292
+ const raw = shellExec(`cd ${repoPath} && git rev-list --count origin/${branch}..HEAD 2>/dev/null`, {
1293
+ stdout: true,
1294
+ silent: true,
1295
+ disableLog: true,
1296
+ }).trim();
1297
+ const count = parseInt(raw);
1298
+ const hasUnpushed = !isNaN(count) && count > 0;
1299
+ return { count: hasUnpushed ? count : fallback, branch, hasUnpushed };
1300
+ },
1301
+
1188
1302
  /**
1189
1303
  * Sanitizes a markdown changelog string into a compact message format.
1190
1304
  * Strips date headers, converts section tags to `[tag]` prefixes, removes bullet markers and special characters.
@@ -1207,6 +1321,251 @@ Prevent build private config repo.`,
1207
1321
  .trim()
1208
1322
  .replaceAll('] - ', '] ');
1209
1323
  },
1324
+
1325
+ /**
1326
+ * Manages a cron-backup Git repository: clone, pull, commit, or push.
1327
+ * Resolves the repository path as `../<repoName>` relative to the CWD.
1328
+ * Requires the `GITHUB_USERNAME` environment variable to be set.
1329
+ * @param {object} params
1330
+ * @param {string} params.repoName - Repository name (e.g. `engine-cyberia-cron-backups`).
1331
+ * @param {'clone'|'pull'|'commit'|'push'} params.operation - Git operation to perform.
1332
+ * @param {string} [params.message=''] - Commit message (used by the `commit` operation).
1333
+ * @param {boolean} [params.forceClone=false] - Remove existing clone before re-cloning.
1334
+ * @returns {boolean} `true` on success, `false` if GITHUB_USERNAME is unset or on error.
1335
+ * @memberof UnderpostRepository
1336
+ */
1337
+ /**
1338
+ * Initializes a git repository at the given path and configures user identity
1339
+ * from environment variables (`GITHUB_USERNAME` / `GITHUB_EMAIL`).
1340
+ * Safe to call on an already-initialized repo — only runs `git init` when
1341
+ * `.git` is absent and always ensures user.name / user.email are set.
1342
+ * @param {object} opts
1343
+ * @param {string} opts.path - Absolute or relative path to the repository.
1344
+ * @param {string} [opts.origin] - If provided, sets or updates git remote `origin`.
1345
+ * @memberof UnderpostRepository
1346
+ */
1347
+ initLocalRepo({ path: repoPath, origin }) {
1348
+ const gitUsername = process.env.GITHUB_USERNAME || 'underpostnet';
1349
+ const gitEmail = process.env.GITHUB_EMAIL || `development@underpost.net`;
1350
+
1351
+ if (!fs.existsSync(`${repoPath}/.git`)) {
1352
+ shellExec(`cd "${repoPath}" && git init`);
1353
+ }
1354
+ shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
1355
+ shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
1356
+
1357
+ if (origin) {
1358
+ const currentRemote = shellExec(`cd "${repoPath}" && git remote get-url origin 2>/dev/null || true`, {
1359
+ stdout: true,
1360
+ silent: true,
1361
+ }).trim();
1362
+ if (!currentRemote) {
1363
+ shellExec(`cd "${repoPath}" && git remote add origin "${origin}"`);
1364
+ } else if (currentRemote !== origin) {
1365
+ shellExec(`cd "${repoPath}" && git remote set-url origin "${origin}"`);
1366
+ }
1367
+ }
1368
+ },
1369
+
1370
+ manageBackupRepo({ repoName, operation, message = '', forceClone = false }) {
1371
+ try {
1372
+ const username = process.env.GITHUB_USERNAME;
1373
+ if (!username) {
1374
+ logger.error('GITHUB_USERNAME environment variable not set');
1375
+ return false;
1376
+ }
1377
+
1378
+ const repoPath = `../${repoName}`;
1379
+
1380
+ switch (operation) {
1381
+ case 'clone':
1382
+ if (forceClone && fs.existsSync(repoPath)) {
1383
+ logger.info(`Force clone: removing existing repository: ${repoName}`);
1384
+ fs.removeSync(repoPath);
1385
+ }
1386
+ if (!fs.existsSync(repoPath)) {
1387
+ shellExec(`cd .. && underpost clone ${username}/${repoName}`);
1388
+ logger.info(`Cloned repository: ${repoName}`);
1389
+ }
1390
+ break;
1391
+
1392
+ case 'pull':
1393
+ if (fs.existsSync(repoPath)) {
1394
+ shellExec(`cd ${repoPath} && git checkout . && git clean -f -d`);
1395
+ shellExec(`cd ${repoPath} && underpost pull . ${username}/${repoName}`, { silent: true });
1396
+ logger.info(`Pulled repository: ${repoName}`);
1397
+ }
1398
+ break;
1399
+
1400
+ case 'commit':
1401
+ if (fs.existsSync(repoPath)) {
1402
+ shellExec(`cd ${repoPath} && git add .`);
1403
+ shellExec(`underpost cmt ${repoPath} backup '' '${message}'`);
1404
+ logger.info(`Committed to repository: ${repoName}`, { message });
1405
+ }
1406
+ break;
1407
+
1408
+ case 'push':
1409
+ if (fs.existsSync(repoPath)) {
1410
+ shellExec(`cd ${repoPath} && underpost push . ${username}/${repoName}`, { silent: true });
1411
+ logger.info(`Pushed repository: ${repoName}`);
1412
+ }
1413
+ break;
1414
+
1415
+ default:
1416
+ logger.warn(`Unknown git operation: ${operation}`);
1417
+ return false;
1418
+ }
1419
+
1420
+ return true;
1421
+ } catch (error) {
1422
+ logger.error(`Git operation failed`, { repoName, operation, error: error.message });
1423
+ return false;
1424
+ }
1425
+ },
1426
+
1427
+ /**
1428
+ * Runtime-type → in-pod site-root directory resolver.
1429
+ * Maps each known runtime to the filesystem path where its repository lives inside the container.
1430
+ * @param {string} runtime - The runtime identifier (e.g. 'wp').
1431
+ * @param {string} host - The virtual-host name.
1432
+ * @returns {string|null} Absolute path inside the pod, or null if the runtime has no known mapping.
1433
+ * @memberof UnderpostRepository
1434
+ */
1435
+ runtimeSiteRoot(runtime, host) {
1436
+ const runtimePaths = {
1437
+ wp: `/opt/lampp/htdocs/wp/${host}`,
1438
+ };
1439
+ return runtimePaths[runtime] || null;
1440
+ },
1441
+
1442
+ /**
1443
+ * Backs up all repositories defined in a deployment's conf.server.json by executing
1444
+ * git commit+push inside the running deployment pod via `kubectl exec`.
1445
+ *
1446
+ * Scans every `server[host][path]` entry for a `repository` field. For each match
1447
+ * the runtime-specific site root is resolved and a git backup script is executed
1448
+ * inside the pod. GITHUB_TOKEN and GITHUB_USERNAME are injected as ephemeral
1449
+ * environment variables in the exec command — never persisted to the pod filesystem.
1450
+ *
1451
+ * @param {object} opts
1452
+ * @param {string} opts.deployId - Deployment ID (used to read conf.server.json and find pods).
1453
+ * @param {string} [opts.namespace='default'] - Kubernetes namespace.
1454
+ * @param {string} [opts.env='production'] - Deployment environment.
1455
+ * @returns {void}
1456
+ * @memberof UnderpostRepository
1457
+ */
1458
+ backupPodRepositories({ deployId, namespace = 'default', env = 'production' }) {
1459
+ const confServer = readConfJson(deployId, 'server', { resolve: true });
1460
+ const githubToken = process.env.GITHUB_TOKEN || '';
1461
+ const githubUsername = process.env.GITHUB_USERNAME || 'underpostnet';
1462
+
1463
+ if (!githubToken) {
1464
+ logger.warn('backupPodRepositories: GITHUB_TOKEN not available — git push will fail');
1465
+ }
1466
+
1467
+ // Resolve the active blue/green traffic colour so we target the correct pod
1468
+ const traffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace });
1469
+ if (!traffic) {
1470
+ logger.warn(`backupPodRepositories: could not resolve current traffic for ${deployId} — skipping`);
1471
+ return;
1472
+ }
1473
+
1474
+ // Find a running pod that matches the active traffic colour
1475
+ const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
1476
+ const runningPod = pods.find((p) => p.STATUS === 'Running');
1477
+ if (!runningPod) {
1478
+ logger.warn(`backupPodRepositories: no running ${traffic} pod found for ${deployId} in namespace ${namespace}`);
1479
+ return;
1480
+ }
1481
+ const podName = runningPod.NAME;
1482
+
1483
+ for (const host of Object.keys(confServer)) {
1484
+ for (const routePath of Object.keys(confServer[host])) {
1485
+ const entry = confServer[host][routePath];
1486
+ if (!entry.repository) continue;
1487
+
1488
+ const siteRoot = Underpost.repo.runtimeSiteRoot(entry.runtime, host);
1489
+ if (!siteRoot) {
1490
+ logger.warn(`backupPodRepositories: no site-root mapping for runtime '${entry.runtime}' (${host})`);
1491
+ continue;
1492
+ }
1493
+
1494
+ const repoName = entry.repository.split('/').pop().split('.')[0];
1495
+
1496
+ // Build the backup script — secrets are injected as env vars in the exec,
1497
+ // never written to filesystem. The shell process inherits them ephemerally.
1498
+ const backupScript = [
1499
+ `export GITHUB_TOKEN='${githubToken.replace(/'/g, "'\\''")}'`,
1500
+ `export GITHUB_USERNAME='${githubUsername.replace(/'/g, "'\\''")}'`,
1501
+ `git config --global --add safe.directory '${siteRoot}' 2>/dev/null || true`,
1502
+ `cd '${siteRoot}' && git add -A && git commit -m 'backup $(date -u +%Y-%m-%dT%H:%M:%SZ)' || true`,
1503
+ `cd '${siteRoot}' && underpost push . ${githubUsername}/${repoName}`,
1504
+ ].join(' && ');
1505
+
1506
+ try {
1507
+ logger.info(`backupPodRepositories: backing up ${host} (${entry.runtime}) in pod ${podName}`);
1508
+ Underpost.kubectl.exec({ podName, namespace, command: backupScript });
1509
+ logger.info(`backupPodRepositories: git push done for ${host}`);
1510
+ } catch (err) {
1511
+ logger.error(`backupPodRepositories: backup failed for ${host}`, err.message);
1512
+ }
1513
+ }
1514
+ }
1515
+ },
1516
+
1517
+ /**
1518
+ * Clones the deploy-specific private repository into `./engine-private`
1519
+ * when it does not already exist on disk. Returns `{ ephemeral: true }`
1520
+ * when a fresh clone was performed so the caller can remove it after use,
1521
+ * or `{ ephemeral: false }` when the directory was already present (host,
1522
+ * cron hostPath mount, or prior build step).
1523
+ *
1524
+ * @param {string} [deployId] - Deploy ID (e.g. `dd-core`) used to derive
1525
+ * the repo name `engine-{component}-private`. Falls back to
1526
+ * `process.env.DEFAULT_DEPLOY_ID`.
1527
+ * @returns {{ ephemeral: boolean }}
1528
+ * @memberof UnderpostRepository
1529
+ */
1530
+ privateEngineRepoFactory(deployId) {
1531
+ if (fs.existsSync('./engine-private')) return { ephemeral: false };
1532
+
1533
+ const effectiveDeployId = deployId || process.env.DEFAULT_DEPLOY_ID;
1534
+ if (!effectiveDeployId) {
1535
+ throw new Error('privateEngineRepoFactory: no deployId provided and DEFAULT_DEPLOY_ID not set');
1536
+ }
1537
+ const username = process.env.GITHUB_USERNAME;
1538
+ if (!username) {
1539
+ throw new Error('privateEngineRepoFactory: GITHUB_USERNAME not set');
1540
+ }
1541
+
1542
+ const component = effectiveDeployId.split('-')[1];
1543
+ const repoName = `engine-${component}-private`;
1544
+ logger.info(`engine-private missing — cloning ${username}/${repoName} (ephemeral)`);
1545
+ shellExec(`underpost clone ${username}/${repoName}`);
1546
+ if (!fs.existsSync(`./${repoName}`)) {
1547
+ throw new Error(`privateEngineRepoFactory: clone failed for ${username}/${repoName}`);
1548
+ }
1549
+ shellExec(`mv ./${repoName} ./engine-private`);
1550
+
1551
+ return { ephemeral: true };
1552
+ },
1553
+
1554
+ /**
1555
+ * Removes the ephemeral `engine-private/` clone created by
1556
+ * `privateEngineRepoFactory()`. No-op if the directory does not exist.
1557
+ * @memberof UnderpostRepository
1558
+ */
1559
+ cleanupPrivateEngineRepo() {
1560
+ if (fs.existsSync('./engine-private')) {
1561
+ fs.removeSync('./engine-private');
1562
+ logger.info('engine-private ephemeral clone removed');
1563
+ }
1564
+ if (fs.existsSync('/home/dd/engine-private')) {
1565
+ fs.removeSync('/home/dd/engine-private');
1566
+ logger.info('engine-private in /home/dd removed');
1567
+ }
1568
+ },
1210
1569
  };
1211
1570
  }
1212
1571