underpost 3.2.28 → 3.2.70

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.
@@ -106,6 +106,9 @@ class UnderpostRepository {
106
106
  * @param {boolean} [options.changelogBuild=false] - If true, scrapes all git history and builds a full CHANGELOG.md. Commits containing 'New release v:' are used as version section titles. Only commits starting with '[<tag>]' are included as entries.
107
107
  * @param {string} [options.changelogMinVersion=''] - If set, overrides the default minimum version limit (2.85.0) for --changelog-build.
108
108
  * @param {boolean} [options.changelogNoHash=false] - If true, omits commit hashes from the changelog entries.
109
+ * @param {boolean} [options.remoteUrl=false] - If true, prints the current git remote URL (origin) in plain text and returns.
110
+ * @param {string} [options.switchRepo=''] - If set, switches the remote `origin` to this URL and force-pulls the target branch, overwriting the current working tree.
111
+ * @param {string} [options.targetBranch=''] - Target branch for `switchRepo` (defaults to `master`).
109
112
  * @memberof UnderpostRepository
110
113
  */
111
114
  commit(
@@ -135,6 +138,9 @@ class UnderpostRepository {
135
138
  bc: '',
136
139
  isRemoteRepo: '',
137
140
  hasChanges: false,
141
+ remoteUrl: false,
142
+ switchRepo: '',
143
+ targetBranch: '',
138
144
  },
139
145
  ) {
140
146
  if (!repoPath) repoPath = '.';
@@ -155,6 +161,22 @@ class UnderpostRepository {
155
161
  return;
156
162
  }
157
163
 
164
+ if (options.remoteUrl) {
165
+ const url = Underpost.repo.getRemoteUrl({ path: repoPath });
166
+ if (options.copy) pbcopy(url);
167
+ else console.log(url);
168
+ return;
169
+ }
170
+
171
+ if (options.switchRepo) {
172
+ Underpost.repo.switchRemote({
173
+ path: repoPath,
174
+ url: options.switchRepo,
175
+ branch: options.targetBranch || 'master',
176
+ });
177
+ return;
178
+ }
179
+
158
180
  if (options.bc) {
159
181
  console.log(
160
182
  shellExec(`cd ${repoPath} && git for-each-ref --contains ${options.bc} --format='%(refname:short)'`, {
@@ -196,7 +218,7 @@ class UnderpostRepository {
196
218
  return;
197
219
  }
198
220
 
199
- if (options.changelog !== undefined || options.changelogBuild) {
221
+ if (options.changelog !== undefined || options.changelogBuild || options.changelogMsg !== undefined) {
200
222
  const releaseMatch = 'New release v:';
201
223
  // Helper: parse [<tag>] commits into grouped sections
202
224
  const buildSectionChangelog = (commits) => {
@@ -233,7 +255,8 @@ class UnderpostRepository {
233
255
  stdout: true,
234
256
  silent: true,
235
257
  disableLog: true,
236
- });
258
+ silentOnError: true,
259
+ }).toString();
237
260
  return rawLog
238
261
  .split('\n')
239
262
  .map((line) => {
@@ -324,15 +347,18 @@ class UnderpostRepository {
324
347
  fs.writeFileSync(changelogPath, `# Changelog\n\n${changelog}`);
325
348
  logger.info('CHANGELOG.md built at', changelogPath);
326
349
  } else {
327
- // --changelog [latest-n]: print changelog of last N commits (default: 1)
328
- const hasExplicitCount =
329
- options.changelog !== undefined && options.changelog !== true && !isNaN(parseInt(options.changelog));
330
- const scanLimit = hasExplicitCount ? parseInt(options.changelog) : 1;
331
- const allCommits = fetchHistory(scanLimit);
332
-
333
- const sections = buildVersionSections(allCommits);
334
- let changelog = renderSections(sections);
335
- console.log(changelog || `No changelog entries found.\n`);
350
+ // --changelog / --changelog-msg: message from the last N commits, where N is --from-n-commit
351
+ // (default 1, last commit only). No auto-detection.
352
+ const n = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
353
+ const sections = buildVersionSections(fetchHistory(n));
354
+ const changelog = renderSections(sections);
355
+ if (options.changelogMsg !== undefined) {
356
+ // Sanitized, commit-ready message; empty string when there are no tagged entries so
357
+ // callers fall back to their own generic default instead of a placeholder.
358
+ console.log(Underpost.repo.sanitizeChangelogMessage(changelog));
359
+ } else {
360
+ console.log(changelog || `No changelog entries found.\n`);
361
+ }
336
362
  }
337
363
 
338
364
  return;
@@ -1235,17 +1261,40 @@ Prevent build private config repo.`,
1235
1261
  return copiedFiles;
1236
1262
  },
1237
1263
 
1264
+ /**
1265
+ * Resolves the default branch for a remote GitHub repository by querying
1266
+ * `git ls-remote --symref` and extracting the HEAD ref target.
1267
+ * Falls back to `main` when detection fails.
1268
+ * @param {string} repo - The GitHub repository (e.g., "owner/repo").
1269
+ * @returns {string} The default branch name (e.g. "main" or "master").
1270
+ * @memberof UnderpostRepository
1271
+ */
1272
+ getDefaultBranch(repo) {
1273
+ if (!repo) return 'main';
1274
+ const authUrl = Underpost.repo.resolveAuthUrl(repo);
1275
+ const raw = shellExec(
1276
+ `GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote --symref "${authUrl}" HEAD 2>&1`,
1277
+ { stdout: true, silent: true, disableLog: true, silentOnError: true },
1278
+ );
1279
+ // --symref emits a line like: ref: refs/heads/main HEAD
1280
+ const match = typeof raw === 'string' ? raw.match(/^ref:\s*refs\/heads\/(\S+)\tHEAD$/m) : null;
1281
+ const branch = match ? match[1] : 'main';
1282
+ logger.info('getDefaultBranch', { repo, branch });
1283
+ return branch;
1284
+ },
1285
+
1238
1286
  /**
1239
1287
  * Dispatches a GitHub Actions workflow using gh CLI or curl fallback.
1240
1288
  * @param {object} options - Dispatch options.
1241
1289
  * @param {string} options.repo - The GitHub repository (e.g., "owner/repo").
1242
1290
  * @param {string} options.workflowFile - The workflow file name (e.g., "engine-core.cd.yml").
1243
- * @param {string} [options.ref='master'] - The git ref to dispatch against.
1291
+ * @param {string} [options.ref] - The git ref to dispatch against. Auto-detects the remote's default branch when omitted.
1244
1292
  * @param {object} [options.inputs={}] - Key-value inputs for the workflow_dispatch event.
1245
1293
  * @memberof UnderpostRepository
1246
1294
  */
1247
- dispatchWorkflow(options = { repo: '', workflowFile: '', ref: 'master', inputs: {} }) {
1248
- const { repo, workflowFile, ref, inputs } = options;
1295
+ dispatchWorkflow(options = { repo: '', workflowFile: '', ref: '', inputs: {} }) {
1296
+ const { repo, workflowFile, inputs } = options;
1297
+ const ref = options.ref || Underpost.repo.getDefaultBranch(repo);
1249
1298
  const ghAvailable = shellExec('command -v gh 2>/dev/null', {
1250
1299
  stdout: true,
1251
1300
  silent: true,
@@ -1322,14 +1371,77 @@ Prevent build private config repo.`,
1322
1371
  if (!url) return false;
1323
1372
  const authUrl = Underpost.repo.resolveAuthUrl(url);
1324
1373
  // GIT_TERMINAL_PROMPT=0 prevents git from hanging on credential prompts inside containers.
1325
- const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git ls-remote "${authUrl}" HEAD 2>&1`, {
1374
+ // `-c credential.helper=` disables any host-configured credential helper so its stderr
1375
+ // warnings don't pollute the ls-remote output; the token is already embedded in authUrl.
1376
+ const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote "${authUrl}" HEAD 2>&1`, {
1326
1377
  stdout: true,
1327
1378
  silent: true,
1328
1379
  disableLog: true,
1329
1380
  silentOnError: true,
1330
1381
  });
1331
- logger.info('isRemoteRepo', { url, raw: (raw || '').slice(0, 120) });
1332
- return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
1382
+ const refLine = typeof raw === 'string' ? (raw.match(/^[0-9a-f]{40}\t.*$/m) || [])[0] : undefined;
1383
+ const accessible = !!refLine;
1384
+ logger.info('isRemoteRepo', { url, accessible, ref: refLine || (raw || '').trim().split('\n').pop() });
1385
+ return accessible;
1386
+ },
1387
+
1388
+ /**
1389
+ * Returns the current URL of a git remote in plain text.
1390
+ * @param {object} [opts]
1391
+ * @param {string} [opts.path='.'] - Path to the git repository.
1392
+ * @param {string} [opts.remote='origin'] - Remote name to query.
1393
+ * @returns {string} The remote URL, or '' when the remote is not configured.
1394
+ * @memberof UnderpostRepository
1395
+ */
1396
+ getRemoteUrl({ path: repoPath = '.', remote = 'origin' } = {}) {
1397
+ return shellExec(`cd "${repoPath}" && git remote get-url ${remote}`, {
1398
+ stdout: true,
1399
+ silent: true,
1400
+ disableLog: true,
1401
+ silentOnError: true,
1402
+ }).trim();
1403
+ },
1404
+
1405
+ /**
1406
+ * Switches a local repository onto a different remote and force-syncs its
1407
+ * working tree to a target branch, discarding local commits and tracked
1408
+ * changes — effectively "switch repo to <url>#<branch>".
1409
+ *
1410
+ * Sequence (idempotent, re-runnable):
1411
+ * 1. Normalize the URL (`owner/repo` → full GitHub HTTPS) and set/add the
1412
+ * remote, storing the token-free URL so no secret leaks into `.git/config`.
1413
+ * 2. Force-fetch the target branch (auth injected inline for private repos).
1414
+ * 3. Reset the working tree to the fetched tip and check out the target
1415
+ * branch, overwriting any current tracked content.
1416
+ *
1417
+ * Untracked files are intentionally left in place (no `git clean`).
1418
+ *
1419
+ * @param {object} opts
1420
+ * @param {string} opts.url - New remote URL (full URL or "owner/repo" short form).
1421
+ * @param {string} [opts.path='.'] - Path to the git repository.
1422
+ * @param {string} [opts.branch='master'] - Target branch to overwrite the current tree with.
1423
+ * @param {string} [opts.remote='origin'] - Remote name to set and fetch from.
1424
+ * @returns {void}
1425
+ * @memberof UnderpostRepository
1426
+ */
1427
+ switchRemote({ url, path: repoPath = '.', branch = 'master', remote = 'origin' }) {
1428
+ if (!url) throw new Error('switchRemote requires a target remote url');
1429
+ if (!fs.existsSync(`${repoPath}/.git`)) throw new Error(`switchRemote: not a git repository: ${repoPath}`);
1430
+ // Token-free URL for the stored remote; auth-injected URL only for the fetch.
1431
+ let normalized = url;
1432
+ if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
1433
+ normalized = `https://github.com/${url}`;
1434
+ }
1435
+ const authUrl = Underpost.repo.resolveAuthUrl(url);
1436
+ const current = Underpost.repo.getRemoteUrl({ path: repoPath, remote });
1437
+ if (!current) shellExec(`cd "${repoPath}" && git remote add ${remote} "${normalized}"`);
1438
+ else shellExec(`cd "${repoPath}" && git remote set-url ${remote} "${normalized}"`);
1439
+ logger.info('switchRemote', { path: repoPath, remote, branch, url: normalized });
1440
+ shellExec(`cd "${repoPath}" && GIT_TERMINAL_PROMPT=0 git fetch --force "${authUrl}" ${branch}`);
1441
+ // reset --hard first clears the worktree so the checkout cannot be blocked
1442
+ // by conflicting local changes; -B points the target branch at the fetched tip.
1443
+ shellExec(`cd "${repoPath}" && git reset --hard FETCH_HEAD`);
1444
+ shellExec(`cd "${repoPath}" && git checkout -B ${branch} FETCH_HEAD`);
1333
1445
  },
1334
1446
 
1335
1447
  /**
@@ -1341,17 +1453,31 @@ Prevent build private config repo.`,
1341
1453
  * @memberof UnderpostRepository
1342
1454
  */
1343
1455
  getUnpushedCount(repoPath = '.', fallback = 1) {
1456
+ // Every git call is silentOnError: a detached HEAD (CI checkout) or a missing upstream must
1457
+ // degrade to the fallback, never throw — otherwise the thrown error is logged to stdout and
1458
+ // can be captured as a commit message by callers that read this command's output.
1344
1459
  const branch = shellExec(`cd ${repoPath} && git branch --show-current`, {
1345
1460
  stdout: true,
1346
1461
  silent: true,
1347
1462
  disableLog: true,
1348
- }).trim();
1349
- shellExec(`cd ${repoPath} && git fetch origin 2>/dev/null`, { silent: true, disableLog: true });
1463
+ silentOnError: true,
1464
+ })
1465
+ .toString()
1466
+ .trim();
1467
+ if (!branch) return { count: fallback, branch: '', hasUnpushed: false };
1468
+ shellExec(`cd ${repoPath} && git fetch origin 2>/dev/null`, {
1469
+ silent: true,
1470
+ disableLog: true,
1471
+ silentOnError: true,
1472
+ });
1350
1473
  const raw = shellExec(`cd ${repoPath} && git rev-list --count origin/${branch}..HEAD 2>/dev/null`, {
1351
1474
  stdout: true,
1352
1475
  silent: true,
1353
1476
  disableLog: true,
1354
- }).trim();
1477
+ silentOnError: true,
1478
+ })
1479
+ .toString()
1480
+ .trim();
1355
1481
  const count = parseInt(raw);
1356
1482
  const hasUnpushed = !isNaN(count) && count > 0;
1357
1483
  return { count: hasUnpushed ? count : fallback, branch, hasUnpushed };
@@ -1366,7 +1492,7 @@ Prevent build private config repo.`,
1366
1492
  */
1367
1493
  sanitizeChangelogMessage(message) {
1368
1494
  if (!message) return '';
1369
- return message
1495
+ const sanitized = message
1370
1496
  .replace(/^##\s+\d{4}-\d{2}-\d{2}\s*/gm, '')
1371
1497
  .replace(/^###\s+(\S+)\s*/gm, '[$1] ')
1372
1498
  .replace(/^- /gm, '')
@@ -1378,6 +1504,9 @@ Prevent build private config repo.`,
1378
1504
  .join('\n')
1379
1505
  .trim()
1380
1506
  .replaceAll('] - ', '] ');
1507
+ // The empty-changelog placeholder must never become a commit message; return empty so
1508
+ // callers fall back to their own generic default.
1509
+ return sanitized === 'No changelog entries found.' ? '' : sanitized;
1381
1510
  },
1382
1511
  /**
1383
1512
  * Initializes a git repository at the given path and configures user identity
@@ -1702,12 +1831,17 @@ Prevent build private config repo.`,
1702
1831
  * 4. Falls back to `${GITHUB_USERNAME}/engine` when no match is found.
1703
1832
  *
1704
1833
  * @param {string} [runtime=''] - The runtime identifier to look up (e.g. `'cyberia-server'`, `'cyberia-client'`).
1834
+ * @param {boolean} [ownRuntimeRepo=false] - Whether to check for the runtime's own repository.
1705
1835
  * @returns {string} The resolved `owner/repo` string.
1706
1836
  * @memberof UnderpostRepository
1707
1837
  */
1708
- resolveInstanceRepo(runtime = '') {
1838
+ resolveInstanceRepo(runtime = '', ownRuntimeRepo = false) {
1709
1839
  const fallback = `${process.env.GITHUB_USERNAME}/engine`;
1710
1840
  if (!runtime) return fallback;
1841
+ // A `.dev` suffix selects the development image workflow
1842
+ // (docker-image.<runtime>.dev.ci.yml) but resolves to the same instance
1843
+ // repo as its production counterpart, so strip it before matching.
1844
+ runtime = runtime.replace(/\.dev$/, '');
1711
1845
  const ddRouter = './engine-private/deploy/dd.router';
1712
1846
  const deployIds = fs.existsSync(ddRouter)
1713
1847
  ? fs
@@ -1733,6 +1867,16 @@ Prevent build private config repo.`,
1733
1867
  logger.warn(`[resolveInstanceRepo] failed to parse ${confPath}: ${err.message}`);
1734
1868
  }
1735
1869
  }
1870
+ if (ownRuntimeRepo) {
1871
+ const runtimeRepo = Underpost.repo.isRemoteRepo(`${process.env.GITHUB_USERNAME}/${runtime}`);
1872
+ if (runtimeRepo) {
1873
+ logger.info(`[resolveInstanceRepo] resolved from ${process.env.GITHUB_USERNAME}/${runtime}`, {
1874
+ runtime,
1875
+ repo: `${process.env.GITHUB_USERNAME}/${runtime}`,
1876
+ });
1877
+ return `${process.env.GITHUB_USERNAME}/${runtime}`;
1878
+ }
1879
+ }
1736
1880
  return fallback;
1737
1881
  },
1738
1882