birc-generator 0.5.5

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 (128) hide show
  1. package/.bircrc.example.json +14 -0
  2. package/AGENTS.md +20 -0
  3. package/PROJECT.md +83 -0
  4. package/README.md +300 -0
  5. package/bin/birc.js +645 -0
  6. package/bin/cli-util.js +428 -0
  7. package/bin/home-install.js +346 -0
  8. package/bin/update-check.js +153 -0
  9. package/implement.md +76 -0
  10. package/install.ps1 +156 -0
  11. package/install.sh +201 -0
  12. package/package.json +68 -0
  13. package/plopfile.js +1358 -0
  14. package/project-docs/AGENTS.md.hbs +12 -0
  15. package/project-docs/PROJECT.md.hbs +128 -0
  16. package/project-docs/implement.hbs +54 -0
  17. package/project-docs/test.hbs +31 -0
  18. package/scripts/install-bin.sh +72 -0
  19. package/scripts/update.sh +11 -0
  20. package/templates/aop/OperationLog.hbs +18 -0
  21. package/templates/aop/OperationLogAspect.hbs +95 -0
  22. package/templates/aop/build-gradle-dep.hbs +1 -0
  23. package/templates/base/Application.hbs +11 -0
  24. package/templates/base/ApplicationTests.java.hbs +11 -0
  25. package/templates/base/BaseService.hbs +15 -0
  26. package/templates/base/BaseServiceImpl.hbs +53 -0
  27. package/templates/base/EntityMapper.hbs +12 -0
  28. package/templates/base/README.md.hbs +56 -0
  29. package/templates/base/Result.hbs +48 -0
  30. package/templates/base/application-test.yml.hbs +27 -0
  31. package/templates/base/application.yml.hbs +18 -0
  32. package/templates/base/bircrc.hbs +14 -0
  33. package/templates/base/build.gradle.hbs +33 -0
  34. package/templates/base/gitignore.hbs +15 -0
  35. package/templates/client/ExternalApiClient.hbs +28 -0
  36. package/templates/client/ExternalApiClientConfig.hbs +26 -0
  37. package/templates/client/ExternalApiProperties.hbs +30 -0
  38. package/templates/client/application-yml-block.hbs +7 -0
  39. package/templates/controller.hbs +55 -0
  40. package/templates/dao.hbs +11 -0
  41. package/templates/docker/Dockerfile.hbs +29 -0
  42. package/templates/docker/docker-compose.prod.yml.hbs +65 -0
  43. package/templates/docker/docker-compose.yml.hbs +52 -0
  44. package/templates/docker/dockerignore.hbs +6 -0
  45. package/templates/docker/env.example.hbs +24 -0
  46. package/templates/dto-request.hbs +19 -0
  47. package/templates/dto-response.hbs +13 -0
  48. package/templates/email/EmailAsyncConfig.hbs +28 -0
  49. package/templates/email/EmailService.hbs +26 -0
  50. package/templates/email/EmailServiceImpl.hbs +49 -0
  51. package/templates/email/application-yml-block.hbs +19 -0
  52. package/templates/email/build-gradle-dep.hbs +2 -0
  53. package/templates/email/sample-template.hbs +16 -0
  54. package/templates/entity.hbs +46 -0
  55. package/templates/exception/ExceptionHandleController.hbs +16 -0
  56. package/templates/exception/NotFoundException.hbs +28 -0
  57. package/templates/exception/ProjectException.hbs +20 -0
  58. package/templates/exception.hbs +30 -0
  59. package/templates/file-upload/EmptyFileException.hbs +25 -0
  60. package/templates/file-upload/FileExtensionIllegalException.hbs +21 -0
  61. package/templates/file-upload/FileExtensionUtils.hbs +20 -0
  62. package/templates/file-upload/FileStorageProperties.hbs +37 -0
  63. package/templates/file-upload/FileStorageService.hbs +15 -0
  64. package/templates/file-upload/FileStorageServiceImpl.hbs +78 -0
  65. package/templates/file-upload/FileTooLargeException.hbs +21 -0
  66. package/templates/file-upload/FileUploadController.hbs +57 -0
  67. package/templates/file-upload/FileUtils.hbs +39 -0
  68. package/templates/file-upload/InvalidStoredFileException.hbs +21 -0
  69. package/templates/file-upload/application-yml-block.hbs +12 -0
  70. package/templates/gitlab-ci/gitlab-ci.hbs +158 -0
  71. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar +0 -0
  72. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties +8 -0
  73. package/templates/gradle-wrapper/gradlew +248 -0
  74. package/templates/gradle-wrapper/gradlew.bat +93 -0
  75. package/templates/log4j2/build-gradle-dep.hbs +6 -0
  76. package/templates/log4j2/log4j2-spring.xml.hbs +34 -0
  77. package/templates/mapper-gradle-dep.hbs +3 -0
  78. package/templates/mapper.hbs +11 -0
  79. package/templates/migration/add-column.hbs +4 -0
  80. package/templates/migration/create-table.hbs +9 -0
  81. package/templates/migration/custom.hbs +2 -0
  82. package/templates/migration/remove-column.hbs +4 -0
  83. package/templates/migration/seed.hbs +7 -0
  84. package/templates/multi-module/build.gradle.hbs +86 -0
  85. package/templates/multi-module/config/ApplicationConfig.java.hbs +33 -0
  86. package/templates/multi-module/config/Config.java.hbs +19 -0
  87. package/templates/multi-module/config/SecurityConfig.java.hbs +48 -0
  88. package/templates/multi-module/config/build.gradle.hbs +10 -0
  89. package/templates/multi-module/database-config/BaseDAO.java.hbs +31 -0
  90. package/templates/multi-module/database-config/BaseViewDAO.java.hbs +48 -0
  91. package/templates/multi-module/database-config/Config.java.hbs +14 -0
  92. package/templates/multi-module/database-config/JpaConfig.java.hbs +9 -0
  93. package/templates/multi-module/database-config/build.gradle.hbs +17 -0
  94. package/templates/multi-module/settings.gradle.hbs +9 -0
  95. package/templates/oauth/OAuth2LoginSuccessHandler.hbs +31 -0
  96. package/templates/oauth/application-yml-block.hbs +11 -0
  97. package/templates/oauth/build-gradle-dep.hbs +1 -0
  98. package/templates/openapi/OpenApiConfig.hbs +23 -0
  99. package/templates/openapi/build-gradle-dep.hbs +1 -0
  100. package/templates/pagination/GenericSpecification.hbs +76 -0
  101. package/templates/pagination/PageInfo.hbs +20 -0
  102. package/templates/pagination/PageRequest.hbs +27 -0
  103. package/templates/pagination/PageResponse.hbs +24 -0
  104. package/templates/pagination/Pager.hbs +37 -0
  105. package/templates/pagination/SearchCriteria.hbs +26 -0
  106. package/templates/pagination/SpecificationSupport.hbs +34 -0
  107. package/templates/permission/PermissionAspect.hbs +41 -0
  108. package/templates/permission/RequirePermission.hbs +18 -0
  109. package/templates/permission/build-gradle-dep.hbs +2 -0
  110. package/templates/scheduling/SampleSchedule.hbs +22 -0
  111. package/templates/scheduling/SchedulingConfig.hbs +13 -0
  112. package/templates/scheduling/application-yml-block.hbs +5 -0
  113. package/templates/sentry/SentryConfig.hbs +74 -0
  114. package/templates/sentry/application-yml-block.hbs +15 -0
  115. package/templates/sentry/build-gradle-dep.hbs +1 -0
  116. package/templates/service.hbs +9 -0
  117. package/templates/serviceImpl.hbs +23 -0
  118. package/templates/spotless/build-gradle-allprojects.hbs +16 -0
  119. package/templates/spotless/build-gradle-plugin.hbs +1 -0
  120. package/templates/spotless/spotless_formatter.xml +83 -0
  121. package/templates/sso/SsoAutoConfiguration.hbs +17 -0
  122. package/templates/sso/SsoProperties.hbs +27 -0
  123. package/templates/sso/application-yml-block.hbs +7 -0
  124. package/templates/valid-group/ValidGroup.hbs +20 -0
  125. package/test.md +56 -0
  126. package/uninstall.ps1 +60 -0
  127. package/uninstall.sh +76 -0
  128. package/versions.js +10 -0
@@ -0,0 +1,346 @@
1
+ const fs = require('fs');
2
+ const https = require('https');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const DEFAULT_BASE_URL = 'https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator';
8
+
9
+ function npmCmd(platform = process.platform) {
10
+ return platform === 'win32' ? 'npm.cmd' : 'npm';
11
+ }
12
+
13
+ function defaultInstallDir(homedir = os.homedir()) {
14
+ return path.join(homedir, '.birc-generator');
15
+ }
16
+
17
+ function defaultBaseUrl() {
18
+ return (process.env.BIRC_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
19
+ }
20
+
21
+ function canWrite(dir) {
22
+ try {
23
+ fs.mkdirSync(dir, { recursive: true });
24
+ fs.accessSync(dir, fs.constants.W_OK);
25
+ return true;
26
+ } catch (_) {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function readNpmPrefix(platform = process.platform) {
32
+ const result = spawnSync(npmCmd(platform), ['config', 'get', 'prefix'], {
33
+ encoding: 'utf8',
34
+ timeout: 8000,
35
+ shell: platform === 'win32'
36
+ });
37
+ if (result.status !== 0) {
38
+ return '';
39
+ }
40
+ return String(result.stdout || '').trim();
41
+ }
42
+
43
+ function npmBinDir(prefix, platform = process.platform, exists = fs.existsSync) {
44
+ if (!prefix) {
45
+ return '';
46
+ }
47
+ if (platform === 'win32') {
48
+ return prefix;
49
+ }
50
+ const nested = path.join(prefix, 'bin');
51
+ return exists(nested) ? nested : prefix;
52
+ }
53
+
54
+ function detectBinDir({
55
+ platform = process.platform,
56
+ homedir = os.homedir(),
57
+ prefix,
58
+ exists = fs.existsSync,
59
+ writable = canWrite
60
+ } = {}) {
61
+ if (prefix === undefined) {
62
+ prefix = readNpmPrefix(platform);
63
+ }
64
+ const localBin = path.join(homedir, '.local', 'bin');
65
+ const candidates = platform === 'win32'
66
+ ? [npmBinDir(prefix, platform, exists), localBin]
67
+ : ['/usr/local/bin', npmBinDir(prefix, platform, exists), localBin];
68
+
69
+ for (const dir of candidates) {
70
+ if (dir && writable(dir)) {
71
+ return dir;
72
+ }
73
+ }
74
+ throw new Error('找不到可寫入的 bin 目錄。請先確認 npm 可用,或自行把 ~/.local/bin 加進 PATH。');
75
+ }
76
+
77
+ function installCommand(jsPath, {
78
+ platform = process.platform,
79
+ binDir,
80
+ homedir = os.homedir()
81
+ } = {}) {
82
+ const resolved = path.resolve(jsPath);
83
+ const dir = binDir || detectBinDir({ platform, homedir });
84
+ fs.mkdirSync(dir, { recursive: true });
85
+
86
+ if (platform === 'win32') {
87
+ fs.writeFileSync(path.join(dir, 'birc.cmd'), `@echo off\r\nnode "${resolved}" %*\r\n`);
88
+ fs.writeFileSync(
89
+ path.join(dir, 'birc.ps1'),
90
+ `& node "${resolved.replace(/\\/g, '/')}" @args\n`
91
+ );
92
+ return dir;
93
+ }
94
+
95
+ const escaped = resolved.replace(/'/g, `'\\''`);
96
+ const shim = path.join(dir, 'birc');
97
+ fs.writeFileSync(
98
+ shim,
99
+ `#!/usr/bin/env bash
100
+ _birc_js='${escaped}'
101
+ if command -v cygpath >/dev/null 2>&1; then
102
+ exec node "$(cygpath -w "$_birc_js")" "$@"
103
+ fi
104
+ exec node "$_birc_js" "$@"
105
+ `
106
+ );
107
+ fs.chmodSync(shim, 0o755);
108
+ return dir;
109
+ }
110
+
111
+ function spawnOrThrow(command, args, options) {
112
+ const result = spawnSync(command, args, {
113
+ encoding: 'utf8',
114
+ ...options
115
+ });
116
+ if (result.error) {
117
+ throw result.error;
118
+ }
119
+ if (result.status !== 0) {
120
+ const detail = String(result.stderr || result.stdout || '').trim();
121
+ throw new Error(detail || `${command} 結束狀態 ${result.status}`);
122
+ }
123
+ return result;
124
+ }
125
+
126
+ function downloadFile(url, dest, redirects = 0) {
127
+ return new Promise((resolve, reject) => {
128
+ const req = https.get(
129
+ url,
130
+ { headers: { 'User-Agent': 'birc-generator' } },
131
+ (res) => {
132
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirects < 5) {
133
+ res.resume();
134
+ const next = new URL(res.headers.location, url).toString();
135
+ downloadFile(next, dest, redirects + 1).then(resolve, reject);
136
+ return;
137
+ }
138
+ if (res.statusCode !== 200) {
139
+ res.resume();
140
+ reject(new Error(`下載失敗 HTTP ${res.statusCode}`));
141
+ return;
142
+ }
143
+ const out = fs.createWriteStream(dest);
144
+ res.pipe(out);
145
+ out.on('finish', () => out.close((err) => (err ? reject(err) : resolve(dest))));
146
+ out.on('error', reject);
147
+ }
148
+ );
149
+ req.on('error', reject);
150
+ });
151
+ }
152
+
153
+ function moveDir(from, to) {
154
+ try {
155
+ fs.renameSync(from, to);
156
+ } catch (err) {
157
+ if (err.code !== 'EXDEV') {
158
+ throw err;
159
+ }
160
+ fs.cpSync(from, to, { recursive: true });
161
+ fs.rmSync(from, { recursive: true, force: true });
162
+ }
163
+ }
164
+
165
+ function extractTarball(archive, destDir) {
166
+ fs.mkdirSync(destDir, { recursive: true });
167
+ spawnOrThrow('tar', ['-xzf', archive, '-C', destDir, '--strip-components=1'], {
168
+ timeout: 60_000
169
+ });
170
+ }
171
+
172
+ // 解壓前先驗壓縮檔清單,擋 tar slip。
173
+ function assertSafeTarEntries(archive, listTar = listTarEntries) {
174
+ const entries = listTar(archive);
175
+ const unsafe = entries.filter((name) => isUnsafeEntryName(name));
176
+ if (unsafe.length > 0) {
177
+ throw new Error(`壓縮檔含不安全的路徑(可能為 tar slip 攻擊),拒絕解壓:\n${unsafe.join('\n')}`);
178
+ }
179
+ }
180
+
181
+ function listTarEntries(archive) {
182
+ const result = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8', timeout: 60_000 });
183
+ if (result.error || result.status !== 0) {
184
+ throw new Error('無法讀取下載的壓縮檔內容。');
185
+ }
186
+ return String(result.stdout || '')
187
+ .split('\n')
188
+ .map((line) => line.trim())
189
+ .filter(Boolean);
190
+ }
191
+
192
+ // 任何一段是 .. 都拒絕:第一層若就是 ..,會被 --strip-components=1 剝掉而漏判。
193
+ // strip 只用來抓「剝完變絕對路徑」的名字(如 //x)。
194
+ function isUnsafeEntryName(name) {
195
+ if (name.split('/').includes('..')) {
196
+ return true;
197
+ }
198
+ if (name.startsWith('/')) {
199
+ return true;
200
+ }
201
+ return name.replace(/^[^/]+\//, '').startsWith('/');
202
+ }
203
+
204
+ // 符號/硬連結可能指向解壓目錄外;官方 tarball 不該出現,直接拒絕。
205
+ function assertNoLinkEntries(archive) {
206
+ const result = spawnSync('tar', ['-tvzf', archive], { encoding: 'utf8', timeout: 60_000 });
207
+ if (result.error || result.status !== 0) {
208
+ throw new Error('無法讀取下載的壓縮檔內容。');
209
+ }
210
+ const links = String(result.stdout || '')
211
+ .split('\n')
212
+ .filter((line) => /^[lh]/.test(line.trim()))
213
+ .join('\n');
214
+ if (links) {
215
+ throw new Error(`壓縮檔內含符號或硬連結項目,拒絕解壓:\n${links}`);
216
+ }
217
+ }
218
+
219
+ async function confirmCustomBase(baseUrl) {
220
+ if (baseUrl === DEFAULT_BASE_URL) {
221
+ return true;
222
+ }
223
+ process.stdout.write(`警告:偵測到自訂下載來源 ${baseUrl}\n`);
224
+ process.stdout.write('會從這個來源下載並執行程式碼,有資安風險。\n');
225
+ if (process.env.BIRC_ALLOW_CUSTOM_BASE === '1') {
226
+ process.stdout.write('BIRC_ALLOW_CUSTOM_BASE=1,繼續使用自訂來源\n');
227
+ return true;
228
+ }
229
+ if (!process.stdin.isTTY) {
230
+ process.stderr.write('非互動環境不接受自訂來源。要強制使用請設 BIRC_ALLOW_CUSTOM_BASE=1。\n');
231
+ return false;
232
+ }
233
+ const readline = require('readline/promises');
234
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
235
+ try {
236
+ const answer = await rl.question('確定要從自訂來源更新嗎?(y/N): ');
237
+ return /^y(es)?$/i.test(String(answer || '').trim());
238
+ } finally {
239
+ rl.close();
240
+ }
241
+ }
242
+
243
+ function npmInstall(cwd, platform = process.platform) {
244
+ spawnOrThrow(npmCmd(platform), ['install'], {
245
+ cwd,
246
+ stdio: 'inherit',
247
+ shell: platform === 'win32',
248
+ timeout: 180_000
249
+ });
250
+ }
251
+
252
+ async function updateViaTarball({ installDir, baseUrl = defaultBaseUrl(), platform = process.platform }) {
253
+ if (!(await confirmCustomBase(baseUrl))) {
254
+ throw new Error('已取消更新。');
255
+ }
256
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'birc-update-'));
257
+ const archive = path.join(staging, 'birc-generator-main.tar.gz');
258
+ const unpacked = path.join(staging, 'src');
259
+ process.stdout.write('下載最新版...\n');
260
+ await downloadFile(`${baseUrl}/-/archive/main/birc-generator-main.tar.gz`, archive);
261
+ assertNoLinkEntries(archive);
262
+ assertSafeTarEntries(archive);
263
+ extractTarball(archive, unpacked);
264
+ process.stdout.write('安裝依賴...\n');
265
+ npmInstall(unpacked, platform);
266
+
267
+ const backup = `${installDir}.bak`;
268
+ fs.rmSync(backup, { recursive: true, force: true });
269
+ if (fs.existsSync(installDir)) {
270
+ moveDir(installDir, backup);
271
+ }
272
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
273
+ moveDir(unpacked, installDir);
274
+ fs.rmSync(staging, { recursive: true, force: true });
275
+ fs.rmSync(backup, { recursive: true, force: true });
276
+ return installDir;
277
+ }
278
+
279
+ async function runUpdate({
280
+ currentRoot,
281
+ installDir = defaultInstallDir(),
282
+ baseUrl = defaultBaseUrl(),
283
+ platform = process.platform
284
+ } = {}) {
285
+ const root = currentRoot || installDir;
286
+ process.stdout.write('更新 birc 到最新版(main)\n');
287
+ if (fs.existsSync(path.join(root, '.git'))) {
288
+ process.stdout.write(`用 git pull 更新 ${root}\n`);
289
+ spawnOrThrow('git', ['-C', root, 'pull', '--ff-only'], { stdio: 'inherit' });
290
+ npmInstall(root, platform);
291
+ const binDir = installCommand(path.join(root, 'bin', 'birc.js'), { platform });
292
+ process.stdout.write(`已安裝指令: ${path.join(binDir, platform === 'win32' ? 'birc.cmd' : 'birc')}\n`);
293
+ return 0;
294
+ }
295
+
296
+ const dest = await updateViaTarball({ installDir, baseUrl, platform });
297
+ const binDir = installCommand(path.join(dest, 'bin', 'birc.js'), { platform });
298
+ process.stdout.write(`已安裝指令: ${path.join(binDir, platform === 'win32' ? 'birc.cmd' : 'birc')}\n`);
299
+ return 0;
300
+ }
301
+
302
+ async function main(argv = process.argv.slice(2)) {
303
+ const command = argv[0];
304
+ if (command === 'install-command') {
305
+ const jsPath = argv[1];
306
+ if (!jsPath) {
307
+ throw new Error('用法: node bin/home-install.js install-command <birc.js 路徑>');
308
+ }
309
+ const binDir = installCommand(jsPath);
310
+ process.stdout.write(`已安裝指令: ${path.join(binDir, process.platform === 'win32' ? 'birc.cmd' : 'birc')}\n`);
311
+ return 0;
312
+ }
313
+ if (command === 'update') {
314
+ return runUpdate({
315
+ currentRoot: process.env.BIRC_CURRENT_ROOT,
316
+ installDir: process.env.BIRC_INSTALL_DIR || defaultInstallDir(),
317
+ baseUrl: defaultBaseUrl()
318
+ });
319
+ }
320
+ throw new Error('用法: node bin/home-install.js <install-command|update>');
321
+ }
322
+
323
+ if (require.main === module) {
324
+ Promise.resolve()
325
+ .then(() => main())
326
+ .then((status) => process.exit(status || 0))
327
+ .catch((err) => {
328
+ process.stderr.write(`${err.message || err}\n`);
329
+ process.exit(1);
330
+ });
331
+ }
332
+
333
+ module.exports = {
334
+ DEFAULT_BASE_URL,
335
+ defaultInstallDir,
336
+ defaultBaseUrl,
337
+ detectBinDir,
338
+ npmBinDir,
339
+ installCommand,
340
+ confirmCustomBase,
341
+ assertSafeTarEntries,
342
+ assertNoLinkEntries,
343
+ isUnsafeEntryName,
344
+ runUpdate,
345
+ main
346
+ };
@@ -0,0 +1,153 @@
1
+ const fs = require('fs');
2
+ const https = require('https');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
7
+
8
+ const NPM_LATEST = `https://registry.npmjs.org/${pkg.name}/latest`;
9
+ const GITLAB_PACKAGE =
10
+ 'https://gitlab.ntubimdbirc.tw/lucashsu95/birc-generator/-/raw/main/package.json';
11
+ const SNOOZE_FILE = path.join(os.homedir(), '.birc-update-check.json');
12
+ const FETCH_TIMEOUT_MS = 2500;
13
+ const MAX_REDIRECTS = 5;
14
+ const SNOOZE_MS = 24 * 60 * 60 * 1000;
15
+
16
+ // 只跟 https 的 redirect;location 相對於原 URL 解析,解析失敗或降級成
17
+ // http/file 等一律回 null(呼叫端放棄本次版本檢查)。
18
+ function resolveRedirectTarget(base, location) {
19
+ try {
20
+ const next = new URL(String(location), base);
21
+ return next.protocol === 'https:' ? next.toString() : null;
22
+ } catch (_) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function compareSemver(a, b) {
28
+ const left = String(a || '').split('.').map((part) => parseInt(part, 10) || 0);
29
+ const right = String(b || '').split('.').map((part) => parseInt(part, 10) || 0);
30
+ const len = Math.max(left.length, right.length, 3);
31
+ for (let i = 0; i < len; i += 1) {
32
+ const l = left[i] || 0;
33
+ const r = right[i] || 0;
34
+ if (l > r) return 1;
35
+ if (l < r) return -1;
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ function detectInstallKind(generatorRoot, homedir = os.homedir()) {
41
+ const root = path.resolve(generatorRoot);
42
+ if (root.split(path.sep).includes('node_modules')) {
43
+ return 'npm';
44
+ }
45
+ if (root === path.resolve(homedir, '.birc-generator')) {
46
+ return 'home';
47
+ }
48
+ return 'git';
49
+ }
50
+
51
+ function resolveUpdateMethod(kind) {
52
+ return kind === 'npm' ? 'npm' : 'home';
53
+ }
54
+
55
+ function shouldSkipPrompt({ env, tty, command, latest, snooze, now = Date.now() }) {
56
+ if (env && env.BIRC_SKIP_UPDATE_CHECK === '1') return true;
57
+ if (!tty) return true;
58
+ if (command === 'update') return true;
59
+ if (snooze && latest && snooze.latest === latest && Number(snooze.skippedUntil) > now) {
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function readSnooze() {
66
+ try {
67
+ return JSON.parse(fs.readFileSync(SNOOZE_FILE, 'utf8'));
68
+ } catch (_) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function writeSnooze(latest) {
74
+ fs.writeFileSync(
75
+ SNOOZE_FILE,
76
+ `${JSON.stringify({ latest, skippedUntil: Date.now() + SNOOZE_MS })}\n`
77
+ );
78
+ }
79
+
80
+ function fetchJson(url, timeoutMs = FETCH_TIMEOUT_MS, redirects = 0) {
81
+ return new Promise((resolve) => {
82
+ let settled = false;
83
+ const finish = (value) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ resolve(value);
87
+ };
88
+
89
+ let req;
90
+ try {
91
+ req = https.get(
92
+ url,
93
+ { timeout: timeoutMs, headers: { 'User-Agent': pkg.name } },
94
+ (res) => {
95
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
96
+ res.resume();
97
+ // 無上限的 redirect 會被拿來打內部網段或把 process 打掛。
98
+ const next =
99
+ redirects < MAX_REDIRECTS ? resolveRedirectTarget(url, res.headers.location) : null;
100
+ if (!next) {
101
+ finish(null);
102
+ return;
103
+ }
104
+ fetchJson(next, timeoutMs, redirects + 1).then(finish);
105
+ return;
106
+ }
107
+ if (res.statusCode !== 200) {
108
+ res.resume();
109
+ finish(null);
110
+ return;
111
+ }
112
+ const chunks = [];
113
+ res.on('data', (chunk) => chunks.push(chunk));
114
+ res.on('end', () => {
115
+ try {
116
+ finish(JSON.parse(Buffer.concat(chunks).toString('utf8')));
117
+ } catch (_) {
118
+ finish(null);
119
+ }
120
+ });
121
+ }
122
+ );
123
+ } catch (_) {
124
+ finish(null);
125
+ return;
126
+ }
127
+ req.on('error', () => finish(null));
128
+ req.on('timeout', () => {
129
+ req.destroy();
130
+ finish(null);
131
+ });
132
+ });
133
+ }
134
+
135
+ async function fetchLatestVersion(kind) {
136
+ const url = kind === 'npm' ? NPM_LATEST : GITLAB_PACKAGE;
137
+ const body = await fetchJson(url);
138
+ const version = body && body.version;
139
+ return typeof version === 'string' && version ? version : null;
140
+ }
141
+
142
+ module.exports = {
143
+ compareSemver,
144
+ detectInstallKind,
145
+ resolveUpdateMethod,
146
+ shouldSkipPrompt,
147
+ readSnooze,
148
+ writeSnooze,
149
+ resolveRedirectTarget,
150
+ fetchLatestVersion,
151
+ FETCH_TIMEOUT_MS,
152
+ MAX_REDIRECTS
153
+ };
package/implement.md ADDED
@@ -0,0 +1,76 @@
1
+ # implement
2
+
3
+ 把**已經決定**的工作做成可合併的 diff。不重開計畫、不另提做法。
4
+
5
+ 對齊 [aihero `/implement`](https://www.aihero.dev/skills-implement):讀 ticket → 在已約定 seam 做 TDD → 跑完整測試 → 人要求時再 commit。一次一張 ticket。
6
+
7
+ ## 跟其它文件
8
+
9
+ | 先讀 | 再做 |
10
+ | --- | --- |
11
+ | [ponytail](https://github.com/DietrichGebert/ponytail/blob/main/skills/ponytail/SKILL.md) | 決定這次寫哪些檔、寫多短。手感在這裡,不在這份。 |
12
+ | [PROJECT.md](PROJECT.md) | 對齊 seam 與慣例。 |
13
+ | [test.md](test.md) | 步驟走到「寫測試」時打開。怎麼圍、什麼算假綠燈。 |
14
+
15
+ 梯子說「這次不做」的東西,不要為了湊測試硬做。
16
+
17
+ ## 何時用
18
+
19
+ 人叫「照 implement 做」,或把已定案的 ticket / spec 丟過來。計畫還不存在就先問,不要用這份開寫。
20
+
21
+ ## 步驟
22
+
23
+ ### 1. 讀輸入,點名 seam
24
+
25
+ 讀已定案的行為。對照 [PROJECT.md](PROJECT.md) 的 seam 表,寫下要觀察的公開邊界。
26
+
27
+ 完成條件:列得出 seam,沒有順便重做相鄰層。
28
+
29
+ ### 2. 用 ponytail 收斂 diff
30
+
31
+ 能登記 `FEATURES` 就不要新 generator;能接 anchor 就不要重寫整份設定。產出專案慣例改 `project-docs/`,不要抄回這個 repo。
32
+
33
+ 完成條件:預期改動的檔列得完,沒有「以後會用到」的模組。
34
+
35
+ ### 3. 寫測試再寫碼
36
+
37
+ 打法見 [test.md](test.md)。一條紅 → 剛好夠綠 → 下一條。
38
+
39
+ 完成條件:每條新關鍵行為都先紅過再綠。
40
+
41
+ ### 4. 跑完整測試再停
42
+
43
+ 過程只跑正在寫的那一個 `node --test --test-name-pattern=...`。最後跑 `npm run test:coverage`。改到 `bin/cli-util.js` 時再跑 `npm run test:mutation`。
44
+
45
+ 完成條件:完整套件綠、覆蓋率達標。不重開範圍。
46
+
47
+ ### 5. Commit
48
+
49
+ 人要求 commit、或這次明確說「做完並提交」才做。沒說就停在回報。
50
+
51
+ 格式用 Conventional Commits,標題與本文一律中文:
52
+
53
+ - 類型:`feat:` / `fix:` / `refactor:` / `chore:` / `test:` / `docs:`
54
+ - 標題寫為什麼,一行。本文用編號列做了什麼。
55
+ - 不要加 `Ultraworked by AI`、`Co-authored-by:` 或其它 AI 後綴。
56
+
57
+ ```text
58
+ feat: 新增使用者驗證功能
59
+
60
+ 1. 使用 JWT 實作 Token 管理
61
+ 2. 加入密碼強度驗證 (Model Validator)
62
+ 3. 補充 Unit Tests,覆蓋率達 85%
63
+ ```
64
+
65
+ 完成條件:一筆 commit、類型正確、全文中文、沒有 AI 後綴。
66
+
67
+ ## 回報
68
+
69
+ ```text
70
+ seam: …
71
+ 測試檔: …
72
+ 圍住: …
73
+ 人工突變: 改了 X,Y 測試轉紅
74
+ 仍需人看: …
75
+ 跳過: …(ponytail 判定不做)
76
+ ```