@jira-deploy/core 1.0.16 → 1.0.18

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.
@@ -57,6 +57,7 @@ const DEFAULT_DEPLOY_CONFIG = {
57
57
  clusterList: 'customfield_cluster_list',
58
58
  grayReleaseNotes: 'customfield_grayrelease_notes',
59
59
  deployResult: 'customfield_deploy_result',
60
+ extraVars: 'customfield_extra_vars',
60
61
  },
61
62
  },
62
63
  issueTypes: {
@@ -136,6 +137,7 @@ const DEFAULT_DEPLOY_CONFIG = {
136
137
  };
137
138
 
138
139
  let cachedConfig;
140
+ let runtimeConfigEnv = EMPTY_OBJECT;
139
141
 
140
142
  function parseJsonConfig(value, source) {
141
143
  try {
@@ -169,16 +171,32 @@ function expandHomePath(filePath) {
169
171
  }
170
172
 
171
173
  function loadExternalConfig() {
172
- if (process.env.JIRA_DEPLOY_CONFIG_PATH) {
173
- const configPath = expandHomePath(process.env.JIRA_DEPLOY_CONFIG_PATH);
174
+ const deployConfigPath = getRuntimeConfigValue('JIRA_DEPLOY_CONFIG_PATH');
175
+ if (deployConfigPath) {
176
+ const configPath = expandHomePath(deployConfigPath);
174
177
  return parseJsonConfig(
175
178
  readFileSync(configPath, 'utf8'),
176
- `JIRA_DEPLOY_CONFIG_PATH (${process.env.JIRA_DEPLOY_CONFIG_PATH})`,
179
+ `JIRA_DEPLOY_CONFIG_PATH (${deployConfigPath})`,
177
180
  );
178
181
  }
179
182
  return EMPTY_OBJECT;
180
183
  }
181
184
 
185
+ export function configureRuntimeConfig({env = EMPTY_OBJECT} = {}) {
186
+ runtimeConfigEnv = Object.freeze({...env});
187
+ cachedConfig = undefined;
188
+ }
189
+
190
+ export function getRuntimeConfigValue(key) {
191
+ return runtimeConfigEnv[key] ?? process.env[key];
192
+ }
193
+
194
+ export function getRuntimeConfigNumber(key, fallback) {
195
+ const value = getRuntimeConfigValue(key);
196
+ const parsed = Number.parseInt(value ?? '', 10);
197
+ return Number.isFinite(parsed) ? parsed : fallback;
198
+ }
199
+
182
200
  export function getDeployConfig() {
183
201
  if (!cachedConfig) {
184
202
  cachedConfig = deepMerge(DEFAULT_DEPLOY_CONFIG, loadExternalConfig());
@@ -187,7 +205,7 @@ export function getDeployConfig() {
187
205
  }
188
206
 
189
207
  export function getReleaseProjectKey() {
190
- return process.env.JIRA_RELEASE_PROJECT_KEY ?? getDeployConfig().jira.releaseProjectKey;
208
+ return getRuntimeConfigValue('JIRA_RELEASE_PROJECT_KEY') ?? getDeployConfig().jira.releaseProjectKey;
191
209
  }
192
210
 
193
211
  export function resetDeployConfigForTests() {
@@ -1,4 +1,4 @@
1
- import {getDeployConfig} from './config.js';
1
+ import {getDeployConfig, getRuntimeConfigValue} from './config.js';
2
2
 
3
3
  const config = getDeployConfig();
4
4
 
@@ -8,4 +8,4 @@ export const ISSUE_TYPE_IDS = config.issueTypes.ids;
8
8
 
9
9
  export const JIRA_PROJECT_ID_NUMERIC = config.jira.projectIdNumeric;
10
10
 
11
- export const JIRA_PROJECT_ID = process.env.JIRA_PROJECT_KEY ?? config.jira.projectKey;
11
+ export const JIRA_PROJECT_ID = getRuntimeConfigValue('JIRA_PROJECT_KEY') ?? config.jira.projectKey;
package/jira-client.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import axios from 'axios';
2
2
  import https from 'https';
3
+ import {getRuntimeConfigValue} from './constants/config.js';
3
4
 
4
5
  const httpsAgent = new https.Agent({ rejectUnauthorized: false });
5
6
 
6
- const DRY_RUN = process.env.DRY_RUN === 'true';
7
+ function isDryRun() {
8
+ return getRuntimeConfigValue('DRY_RUN') === 'true';
9
+ }
7
10
 
8
11
  export class JiraClient {
9
12
  constructor() {
10
- const { JIRA_BASE_URL, JIRA_API_TOKEN } = process.env;
13
+ const JIRA_BASE_URL = getRuntimeConfigValue('JIRA_BASE_URL');
14
+ const JIRA_API_TOKEN = getRuntimeConfigValue('JIRA_API_TOKEN');
11
15
  if (!JIRA_BASE_URL || !JIRA_API_TOKEN) {
12
16
  throw new Error('Missing required Jira env vars: JIRA_BASE_URL, JIRA_API_TOKEN');
13
17
  }
@@ -20,7 +24,7 @@ export class JiraClient {
20
24
  Authorization: `Bearer ${JIRA_API_TOKEN}`,
21
25
  },
22
26
  });
23
- this.dryRun = DRY_RUN;
27
+ this.dryRun = isDryRun();
24
28
  this.fieldIdByName = new Map();
25
29
  }
26
30
 
@@ -255,7 +259,7 @@ export class JiraClient {
255
259
 
256
260
  // 取得 Bitbucket repo 的原始檔案內容(Bitbucket REST API 1.0)
257
261
  async getBitbucketFileContent(project, repo, filePath, branch) {
258
- const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
262
+ const BB_BASE = getRuntimeConfigValue('BITBUCKET_URL') ?? getRuntimeConfigValue('BITBUCKET_BASE_URL');
259
263
  if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
260
264
  const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/raw/${filePath}`;
261
265
  const res = await axios
@@ -263,7 +267,7 @@ export class JiraClient {
263
267
  httpsAgent,
264
268
  headers: {
265
269
  Accept: 'text/plain',
266
- Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
270
+ Authorization: `Bearer ${getRuntimeConfigValue('BITBUCKET_API_TOKEN')}`,
267
271
  },
268
272
  params: { at: branch },
269
273
  responseType: 'text',
@@ -281,7 +285,7 @@ export class JiraClient {
281
285
  repo,
282
286
  { filterValue = '', orderBy = 'MODIFICATION', limit = 1 } = {},
283
287
  ) {
284
- const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
288
+ const BB_BASE = getRuntimeConfigValue('BITBUCKET_URL') ?? getRuntimeConfigValue('BITBUCKET_BASE_URL');
285
289
  if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
286
290
  const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/tags`;
287
291
  const res = await axios
@@ -290,7 +294,7 @@ export class JiraClient {
290
294
  headers: {
291
295
  'Content-Type': 'application/json',
292
296
  Accept: 'application/json',
293
- Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
297
+ Authorization: `Bearer ${getRuntimeConfigValue('BITBUCKET_API_TOKEN')}`,
294
298
  },
295
299
  params: { filterValue, orderBy, limit },
296
300
  })
@@ -307,7 +311,7 @@ export class JiraClient {
307
311
  repo,
308
312
  { filterValue = '', orderBy = 'MODIFICATION', limit = 1 } = {},
309
313
  ) {
310
- const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
314
+ const BB_BASE = getRuntimeConfigValue('BITBUCKET_URL') ?? getRuntimeConfigValue('BITBUCKET_BASE_URL');
311
315
  if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
312
316
  const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/branches`;
313
317
  const res = await axios
@@ -316,7 +320,7 @@ export class JiraClient {
316
320
  headers: {
317
321
  'Content-Type': 'application/json',
318
322
  Accept: 'application/json',
319
- Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
323
+ Authorization: `Bearer ${getRuntimeConfigValue('BITBUCKET_API_TOKEN')}`,
320
324
  },
321
325
  params: { filterValue, orderBy, limit },
322
326
  })
@@ -333,7 +337,7 @@ export class JiraClient {
333
337
  repo,
334
338
  { branch = '', state = 'OPEN', limit = 100 } = {},
335
339
  ) {
336
- const BB_BASE = process.env.BITBUCKET_URL ?? process.env.BITBUCKET_BASE_URL;
340
+ const BB_BASE = getRuntimeConfigValue('BITBUCKET_URL') ?? getRuntimeConfigValue('BITBUCKET_BASE_URL');
337
341
  if (!BB_BASE) throw new Error('Missing required Bitbucket env var: BITBUCKET_URL');
338
342
  const url = `${BB_BASE}/rest/api/1.0/projects/${project}/repos/${repo}/pull-requests`;
339
343
  const res = await axios
@@ -342,7 +346,7 @@ export class JiraClient {
342
346
  headers: {
343
347
  'Content-Type': 'application/json',
344
348
  Accept: 'application/json',
345
- Authorization: `Bearer ${process.env.BITBUCKET_API_TOKEN}`,
349
+ Authorization: `Bearer ${getRuntimeConfigValue('BITBUCKET_API_TOKEN')}`,
346
350
  },
347
351
  params: {
348
352
  state,
package/notifier.js CHANGED
@@ -5,12 +5,13 @@
5
5
  * 擴充 Slack:填入 .env 的 SLACK_BOT_TOKEN + SLACK_CHANNEL_ID 後,
6
6
  * 把下方 notifySlack 的 TODO 實作即可,其他地方不用動。
7
7
  */
8
+ import {getRuntimeConfigValue} from './constants/config.js';
8
9
 
9
10
  export class Notifier {
10
11
  constructor(jiraClient) {
11
12
  this.jira = jiraClient;
12
- this.slackEnabled = !!process.env.SLACK_BOT_TOKEN && !!process.env.SLACK_CHANNEL_ID;
13
- this.dryRun = process.env.DRY_RUN === 'true';
13
+ this.slackEnabled = !!getRuntimeConfigValue('SLACK_BOT_TOKEN') && !!getRuntimeConfigValue('SLACK_CHANNEL_ID');
14
+ this.dryRun = getRuntimeConfigValue('DRY_RUN') === 'true';
14
15
  }
15
16
 
16
17
  async notify(issueKey, message) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jira-deploy/core",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "repository": {
@@ -11,6 +11,7 @@
11
11
  "exports": {
12
12
  ".": "./index.js",
13
13
  "./constants": "./constants/index.js",
14
+ "./runtime-config": "./constants/config.js",
14
15
  "./jira-client": "./jira-client.js",
15
16
  "./notifier": "./notifier.js",
16
17
  "./poller": "./poller.js",
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import {SYSTEM_CODES, GRAY_RELEASE_MODULE_IDS} from './constants/index.js';
9
+ import {getRuntimeConfigValue} from './constants/config.js';
9
10
  import {getServerList} from './tools/helpers.js';
10
11
 
11
12
  /**
@@ -27,7 +28,7 @@ export function getPlatformConfig(platformName, environment = 'stg') {
27
28
  if (!systemCode) {
28
29
  console.warn(`Unknown platform: ${platformName}, using defaults`);
29
30
  return {
30
- projectKey: process.env.JIRA_PROJECT_KEY ?? 'OPS',
31
+ projectKey: getRuntimeConfigValue('JIRA_PROJECT_KEY') ?? 'OPS',
31
32
  systemCode: undefined,
32
33
  clusters: [],
33
34
  environment,
package/poller.js CHANGED
@@ -1,3 +1,5 @@
1
+ import {getRuntimeConfigNumber} from './constants/config.js';
2
+
1
3
  /**
2
4
  * Poller — 輪詢等待 Jira issue 達到目標狀態
3
5
  *
@@ -19,8 +21,8 @@ export class Poller {
19
21
  * @returns {{ issueKey, status, elapsedMs, attempts }}
20
22
  */
21
23
  async waitForStatus(issueKey, targetStatus, options = {}) {
22
- const intervalMs = options.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
23
- const timeoutMs = options.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
24
+ const intervalMs = options.intervalMs ?? getRuntimeConfigNumber('POLL_INTERVAL_MS', 30000);
25
+ const timeoutMs = options.timeoutMs ?? getRuntimeConfigNumber('POLL_TIMEOUT_MS', 3600000);
24
26
  const progress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
25
27
 
26
28
  const startTime = Date.now();
package/tools/cd.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  SYSTEM_MODULES,
26
26
  SYSTEM_TO_DEPT_MAP,
27
27
  } from '../constants/index.js';
28
+ import {getRuntimeConfigValue} from '../constants/config.js';
28
29
 
29
30
  // ── Flow Definition ──────────────────────────────────────────────
30
31
 
@@ -291,7 +292,7 @@ export async function handleCreateCDTicket(args, {jira, notifier, progress: repo
291
292
  return ok({
292
293
  issueKey: issue.key,
293
294
  issueId: issue.id,
294
- url: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
295
+ url: getIssueUrl(issue.key),
295
296
  type: 'CD Deploy',
296
297
  system: normalizedArgs.systemCode,
297
298
  environment: normalizedArgs.environment,
@@ -615,12 +616,12 @@ function resolveRequiredAccountId(alias, label) {
615
616
  }
616
617
 
617
618
  function getJabberJid(accountId) {
618
- const domain = process.env.JABBER_DOMAIN ?? getDeployConfig().jabber?.domain;
619
+ const domain = getRuntimeConfigValue('JABBER_DOMAIN') ?? getDeployConfig().jabber?.domain;
619
620
  return domain ? `${accountId}@${domain}` : accountId;
620
621
  }
621
622
 
622
623
  function getIssueUrl(issueKey) {
623
- return `${process.env.JIRA_BASE_URL}/browse/${issueKey}`;
624
+ return `${getRuntimeConfigValue('JIRA_BASE_URL')}/browse/${issueKey}`;
624
625
  }
625
626
 
626
627
  function isPrdLikeEnv(env) {
@@ -971,7 +972,7 @@ async function generateReleaseNotes(jira, ciKey, env, systemCode) {
971
972
  targetBranch = await findLatestReleaseBranchWithTag(jira, repoMeta, repoName);
972
973
  if (!targetBranch) continue;
973
974
  }
974
- const baseUrl = process.env.BITBUCKET_URL || 'https://bitbucket.example.com';
975
+ const baseUrl = getRuntimeConfigValue('BITBUCKET_URL') || 'https://bitbucket.example.com';
975
976
  const compareUrl = `${baseUrl}/projects/${repoMeta.project}/repos/${repoName}/compare/diff?sourceBranch=${sourceBranch.replace(/\//g, '%2F')}&targetBranch=${targetBranch.replace(/\//g, '%2F')}&targetRepoId=${repoMeta.repoId}`;
976
977
  notes.push(`${label}: ${compareUrl}`);
977
978
  } catch (_) {
@@ -1056,7 +1057,7 @@ async function handleWeblink(jira, issue, notifier, args) {
1056
1057
  });
1057
1058
 
1058
1059
  if (versionId) {
1059
- const versionUrl = `${process.env.JIRA_BASE_URL}/projects/LBPRJ/versions/${versionId}`;
1060
+ const versionUrl = `${getRuntimeConfigValue('JIRA_BASE_URL')}/projects/LBPRJ/versions/${versionId}`;
1060
1061
  await jira.addRemoteLink(issue.key, versionUrl, moduleName);
1061
1062
  await notifier.notify(issue.key, `已附上版本 Web Link:${moduleName}(${libKey})`);
1062
1063
  } else {
package/tools/ci.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  SYSTEM_TO_CI_REPO_MAP,
23
23
  SYSTEM_TO_DEPT_MAP,
24
24
  } from '../constants/index.js';
25
+ import {getRuntimeConfigValue} from '../constants/config.js';
25
26
  import { assertNoOpenPRBeforeCreate } from './branch-prs.js';
26
27
 
27
28
  // ── Flow Definition ──────────────────────────────────────────────
@@ -481,7 +482,7 @@ export async function handleCreateCITicket(args, { jira, notifier }) {
481
482
  return ok({
482
483
  issueKey: issue.key,
483
484
  issueId: issue.id,
484
- url: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
485
+ url: `${getRuntimeConfigValue('JIRA_BASE_URL')}/browse/${issue.key}`,
485
486
  type: 'CI Release',
486
487
  system: systemCode,
487
488
  goldenImageVersion,
@@ -40,6 +40,7 @@ import { assertNoOpenPRBeforeCreate } from './branch-prs.js';
40
40
  import { Poller } from '../poller.js';
41
41
  import { handleGetReleaseManager, handleWaitForComment } from './release.js';
42
42
  import { handleSendJabberMessage } from './jabber.js';
43
+ import {getRuntimeConfigValue} from '../constants/config.js';
43
44
  import {
44
45
  needSwitchExecutionNode as shouldSwitchExecutionNode,
45
46
  waitForSwitchExecutionNode as waitForSharedSwitchExecutionNode,
@@ -122,10 +123,14 @@ function progress(ctx, event) {
122
123
  }
123
124
 
124
125
  function getJabberJid(accountId) {
125
- const domain = process.env.JABBER_DOMAIN ?? getDeployConfig().jabber?.domain;
126
+ const domain = getRuntimeConfigValue('JABBER_DOMAIN') ?? getDeployConfig().jabber?.domain;
126
127
  return domain ? `${accountId}@${domain}` : accountId;
127
128
  }
128
129
 
130
+ function getJiraIssueUrl(issueKey) {
131
+ return `${getRuntimeConfigValue('JIRA_BASE_URL')}/browse/${issueKey}`;
132
+ }
133
+
129
134
  function getGrayReleaseUatApprovers() {
130
135
  const config = getDeployConfig().release.grayReleaseUatApprovers ?? {};
131
136
  return {
@@ -251,6 +256,12 @@ export function getGrayReleaseToolDefinitions() {
251
256
  type: 'string',
252
257
  description: 'Jenkins branch(選填,預設 master)',
253
258
  },
259
+ fortifyScan: {
260
+ type: 'boolean',
261
+ default: false,
262
+ description:
263
+ '(選填) 是否掃描 Fortify。預設 false;若為 true,會在 GrayRelease 專屬 extraVars 加入 {"fortifyScan":"True"}',
264
+ },
254
265
  dryRun: {
255
266
  type: 'boolean',
256
267
  description: '(選填) 預覽模式,不實際建立 Jira 單,回傳會送出的 payload',
@@ -507,6 +518,11 @@ export async function handleCreateGrayReleaseTicket(args, { jira, notifier }) {
507
518
  // gray release notes
508
519
  fields[GRAY_RELEASE_FIELD_IDS.grayReleaseNotes] = NOTES_TEMPLATES.grayRelease;
509
520
 
521
+ // fortify scan (GrayRelease 專屬 extraVars)
522
+ if (normalizedArgs.fortifyScan === true && GRAY_RELEASE_FIELD_IDS.extraVars) {
523
+ fields[GRAY_RELEASE_FIELD_IDS.extraVars] = JSON.stringify({ fortifyScan: 'True' });
524
+ }
525
+
510
526
  await assertNoOpenPRBeforeCreate({
511
527
  ticketType: 'grayrelease',
512
528
  systemCode: normalizedArgs.systemCode,
@@ -527,7 +543,7 @@ export async function handleCreateGrayReleaseTicket(args, { jira, notifier }) {
527
543
  const result = {
528
544
  issueKey: issue.key,
529
545
  issueId: issue.id,
530
- url: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
546
+ url: getJiraIssueUrl(issue.key),
531
547
  type: 'GrayRelease',
532
548
  system: normalizedArgs.systemCode,
533
549
  module: normalizedArgs.module,
@@ -1311,7 +1327,7 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
1311
1327
  await handleSendJabberMessage(
1312
1328
  {
1313
1329
  to: jabberTo,
1314
- message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核。環境: STG,系統: ${systemCode}\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
1330
+ message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核。環境: STG,系統: ${systemCode}\n${getJiraIssueUrl(issueKey)}`,
1315
1331
  },
1316
1332
  {},
1317
1333
  );
@@ -1354,7 +1370,7 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
1354
1370
  await handleSendJabberMessage(
1355
1371
  {
1356
1372
  to: commentReviewerJabber,
1357
- message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核並留言確認。環境: UAT,系統: ${systemCode}\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
1373
+ message: `[GrayRelease 簽核通知] ${issueKey} 需要您的簽核並留言確認。環境: UAT,系統: ${systemCode}\n${getJiraIssueUrl(issueKey)}`,
1358
1374
  },
1359
1375
  {},
1360
1376
  );
@@ -1401,7 +1417,7 @@ async function handleGrayReleaseApproval(issueKey, environment, systemCode, ctx)
1401
1417
  await handleSendJabberMessage(
1402
1418
  {
1403
1419
  to: finalApproverJabber,
1404
- message: `[GrayRelease 簽核通知] ${issueKey} 已由 ${commentReviewerAlias} 確認,需要您的最終簽核。環境: UAT,系統: ${systemCode}\n${process.env.JIRA_BASE_URL}/browse/${issueKey}`,
1420
+ message: `[GrayRelease 簽核通知] ${issueKey} 已由 ${commentReviewerAlias} 確認,需要您的最終簽核。環境: UAT,系統: ${systemCode}\n${getJiraIssueUrl(issueKey)}`,
1405
1421
  },
1406
1422
  {},
1407
1423
  );
package/tools/helpers.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {getDeployConfig, META_TEST_NODES, SERVER_MODULE_MAP, SERVERS} from '../constants/index.js';
2
+ import {getRuntimeConfigNumber} from '../constants/config.js';
2
3
 
3
4
  export function ok(data) {
4
5
  return {content: [{type: 'text', text: JSON.stringify(data, null, 2)}]};
@@ -66,11 +67,11 @@ export function getModuleName(systemCode, module, versionName) {
66
67
  }
67
68
 
68
69
  export function getPollIntervalMs() {
69
- return parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
70
+ return getRuntimeConfigNumber('POLL_INTERVAL_MS', 30000);
70
71
  }
71
72
 
72
73
  export function getPollTimeoutMs() {
73
- return parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
74
+ return getRuntimeConfigNumber('POLL_TIMEOUT_MS', 3600000);
74
75
  }
75
76
 
76
77
  export function isPassingResult(value) {
@@ -83,4 +84,4 @@ export function isFailingResult(value) {
83
84
  return ['fail', 'failed', 'failure', 'error'].includes(
84
85
  String(value ?? '').trim().toLowerCase(),
85
86
  );
86
- }
87
+ }
package/tools/jabber.js CHANGED
@@ -7,6 +7,7 @@ import fs from 'fs';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
  import {error, ok} from './helpers.js';
10
+ import {getRuntimeConfigValue} from '../constants/config.js';
10
11
 
11
12
  const packageScriptPath = path.resolve(
12
13
  path.dirname(fileURLToPath(import.meta.url)),
@@ -32,8 +33,9 @@ export function resolveJabberNotifyScriptPath({
32
33
  execPath = process.execPath,
33
34
  cwd = process.cwd(),
34
35
  } = {}) {
35
- if (env.JABBER_NOTIFY_SCRIPT) {
36
- return env.JABBER_NOTIFY_SCRIPT;
36
+ const configuredScript = getRuntimeConfigValue('JABBER_NOTIFY_SCRIPT') ?? env.JABBER_NOTIFY_SCRIPT;
37
+ if (configuredScript) {
38
+ return configuredScript;
37
39
  }
38
40
 
39
41
  const resolvedExecPath = realpathOrNull(execPath);
@@ -91,14 +93,20 @@ export async function handleSendJabberMessage(args, _ctx) {
91
93
  sent: false,
92
94
  dryRun: true,
93
95
  message: args.message,
94
- to: args.to ?? process.env.JABBER_TO ?? '(JABBER_TO not set)',
95
- room: args.room ?? process.env.JABBER_ROOM,
96
- preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ?? process.env.JABBER_TO ?? '?'}: ${args.message}`,
96
+ to: args.to ?? getRuntimeConfigValue('JABBER_TO') ?? '(JABBER_TO not set)',
97
+ room: args.room ?? getRuntimeConfigValue('JABBER_ROOM'),
98
+ preview: `[DRY RUN] 將發送 Jabber 給 ${args.to ?? getRuntimeConfigValue('JABBER_TO') ?? '?'}: ${args.message}`,
97
99
  });
98
100
  }
99
101
 
100
102
  // 允許透過 tool 參數覆蓋 JABBER_TO / JABBER_ROOM
101
103
  const env = {...process.env};
104
+ for (const key of ['JABBER_SERVER', 'JABBER_USER', 'JABBER_DOMAIN', 'JABBER_KEYCHAIN_SERVICE', 'JABBER_KEYCHAIN_ACCOUNT', 'JABBER_TO', 'JABBER_ROOM', 'JABBER_PORT', 'JABBER_RESOURCE', 'JABBER_NICK']) {
105
+ const value = getRuntimeConfigValue(key);
106
+ if (value !== undefined) {
107
+ env[key] = value;
108
+ }
109
+ }
102
110
  if (args.to) env.JABBER_TO = args.to;
103
111
  if (args.room) env.JABBER_ROOM = args.room;
104
112
 
package/tools/library.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  SYSTEM_CODES,
20
20
  SYSTEM_TO_DEPT_MAP,
21
21
  } from '../constants/index.js';
22
+ import {getRuntimeConfigValue} from '../constants/config.js';
22
23
  import { error, getPollIntervalMs, getPollTimeoutMs, ok, today } from './helpers.js';
23
24
  import { assertNoOpenPRBeforeCreate } from './branch-prs.js';
24
25
  import {
@@ -267,7 +268,7 @@ export async function handleCreateLibraryTicket(args, { jira, notifier }) {
267
268
  return ok({
268
269
  issueKey: issue.key,
269
270
  issueId: issue.id,
270
- url: `${process.env.JIRA_BASE_URL}/browse/${issue.key}`,
271
+ url: `${getRuntimeConfigValue('JIRA_BASE_URL')}/browse/${issue.key}`,
271
272
  type: 'Library Release',
272
273
  system: normalizedArgs.systemCode,
273
274
  module: normalizedArgs.module,
package/tools/release.js CHANGED
@@ -9,6 +9,7 @@ import https from 'https';
9
9
  import http from 'http';
10
10
  import {error, getModuleName, ok} from './helpers.js';
11
11
  import {getDeployConfig, getReleaseProjectKey, SYSTEM_CODES, SYSTEM_MODULES} from '../constants/index.js';
12
+ import {getRuntimeConfigNumber, getRuntimeConfigValue} from '../constants/config.js';
12
13
 
13
14
  // ── Schema definitions ───────────────────────────────────────────
14
15
  export function getReleaseToolDefinitions() {
@@ -177,13 +178,13 @@ export async function handleGetReleaseManager(args = {}) {
177
178
  });
178
179
  }
179
180
 
180
- const CONF_BASE_URL = process.env.CONF_BASE_URL;
181
- const CONF_TOKEN = process.env.CONF_TOKEN;
181
+ const CONF_BASE_URL = getRuntimeConfigValue('CONF_BASE_URL');
182
+ const CONF_TOKEN = getRuntimeConfigValue('CONF_TOKEN');
182
183
  if (!CONF_BASE_URL || !CONF_TOKEN) {
183
184
  return error('缺少環境變數 CONF_BASE_URL 或 CONF_TOKEN');
184
185
  }
185
186
 
186
- const SUB_CALENDAR_ID = process.env.CONF_RELEASE_MANAGER_SUB_CALENDAR_ID
187
+ const SUB_CALENDAR_ID = getRuntimeConfigValue('CONF_RELEASE_MANAGER_SUB_CALENDAR_ID')
187
188
  ?? releaseConfig.managerSubCalendarId;
188
189
  if (!SUB_CALENDAR_ID) {
189
190
  return error('缺少環境變數 CONF_RELEASE_MANAGER_SUB_CALENDAR_ID 或 release.managerSubCalendarId config');
@@ -265,8 +266,8 @@ export async function handleWaitForComment(args, {
265
266
  });
266
267
  }
267
268
 
268
- const intervalMs = args.intervalMs ?? parseInt(process.env.POLL_INTERVAL_MS ?? '30000');
269
- const timeoutMs = args.timeoutMs ?? parseInt(process.env.POLL_TIMEOUT_MS ?? '3600000');
269
+ const intervalMs = args.intervalMs ?? getRuntimeConfigNumber('POLL_INTERVAL_MS', 30000);
270
+ const timeoutMs = args.timeoutMs ?? getRuntimeConfigNumber('POLL_TIMEOUT_MS', 3600000);
270
271
  const startTime = Date.now();
271
272
  let attempts = 0;
272
273