openyida 2026.7.18-1 → 2026.7.18-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.
@@ -2,7 +2,10 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const http = require('http');
5
- const { spawn } = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawn, spawnSync } = require('child_process');
6
9
 
7
10
  const CALLBACK_PATH = '/oauth/callback';
8
11
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
@@ -11,7 +14,266 @@ function randomToken(bytes = 24) {
11
14
  return crypto.randomBytes(bytes).toString('base64url');
12
15
  }
13
16
 
14
- function resolveBrowserLauncher(url, platform = process.platform) {
17
+ const CHROMIUM_MAC_BUNDLES = {
18
+ 'com.google.chrome': 'com.google.Chrome',
19
+ 'com.microsoft.edgemac': 'com.microsoft.edgemac',
20
+ 'com.brave.browser': 'com.brave.Browser',
21
+ 'com.operasoftware.opera': 'com.operasoftware.Opera',
22
+ 'com.vivaldi.vivaldi': 'com.vivaldi.Vivaldi',
23
+ 'org.chromium.chromium': 'org.chromium.Chromium',
24
+ };
25
+
26
+ // Only these browser families have a known reliable new-window launch path.
27
+ // Anything else (unknown/unsupported default browser) must fall back.
28
+ const SUPPORTED_BROWSER_FAMILIES = new Set(['chromium', 'firefox', 'safari']);
29
+
30
+ // Chromium accepts `--new-window`; Firefox uses the single-dash `-new-window`.
31
+ function newWindowFlag(family) {
32
+ return family === 'firefox' ? '-new-window' : '--new-window';
33
+ }
34
+
35
+ function appleScriptSafe(value) {
36
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
37
+ }
38
+
39
+ function classifyMacBundleId(bundleId) {
40
+ if (!bundleId) {
41
+ return null;
42
+ }
43
+ const id = String(bundleId).toLowerCase();
44
+ if (id === 'com.apple.safari') {
45
+ return { family: 'safari' };
46
+ }
47
+ if (id === 'org.mozilla.firefox') {
48
+ return { family: 'firefox', bundleId: 'org.mozilla.firefox' };
49
+ }
50
+ if (CHROMIUM_MAC_BUNDLES[id]) {
51
+ return { family: 'chromium', bundleId: CHROMIUM_MAC_BUNDLES[id] };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function classifyWindowsProgId(progId) {
57
+ if (!progId) {
58
+ return null;
59
+ }
60
+ const id = String(progId).toLowerCase();
61
+ if (id.includes('firefox')) {
62
+ return 'firefox';
63
+ }
64
+ if (
65
+ id.startsWith('chromehtml') ||
66
+ id.startsWith('msedge') ||
67
+ id.startsWith('bravehtml') ||
68
+ id.startsWith('opera') ||
69
+ id.includes('vivaldi') ||
70
+ id.includes('chromium')
71
+ ) {
72
+ return 'chromium';
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function classifyLinuxDesktop(name) {
78
+ if (!name) {
79
+ return null;
80
+ }
81
+ const id = String(name).toLowerCase();
82
+ if (id.includes('firefox')) {
83
+ return 'firefox';
84
+ }
85
+ if (
86
+ id.includes('chrome') ||
87
+ id.includes('chromium') ||
88
+ id.includes('brave') ||
89
+ id.includes('edge') ||
90
+ id.includes('opera') ||
91
+ id.includes('vivaldi')
92
+ ) {
93
+ return 'chromium';
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function parseMacDefaultBrowser(defaultsOutput) {
99
+ if (!defaultsOutput) {
100
+ return null;
101
+ }
102
+ // The secure plist is an array of dicts; each `}` closes one handler entry.
103
+ const blocks = String(defaultsOutput).split('}');
104
+ for (const block of blocks) {
105
+ if (/LSHandlerURLScheme\s*=\s*https\b/i.test(block)) {
106
+ const match = block.match(/LSHandlerRole(?:All|Viewer)\s*=\s*"?([\w.-]+)"?/i);
107
+ if (match) {
108
+ return match[1].toLowerCase();
109
+ }
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function parseWindowsProgId(regOutput) {
116
+ if (!regOutput) {
117
+ return null;
118
+ }
119
+ const match = String(regOutput).match(/ProgId\s+REG_\w+\s+(\S+)/i);
120
+ return match ? match[1] : null;
121
+ }
122
+
123
+ function parseWindowsCommandExec(regOutput) {
124
+ if (!regOutput) {
125
+ return null;
126
+ }
127
+ const match = String(regOutput).match(/REG_\w+\s+(.+)$/m);
128
+ if (!match) {
129
+ return null;
130
+ }
131
+ const value = match[1].trim();
132
+ if (value.startsWith('"')) {
133
+ const end = value.indexOf('"', 1);
134
+ if (end > 1) {
135
+ return value.slice(1, end);
136
+ }
137
+ }
138
+ return value.split(/\s+/)[0] || null;
139
+ }
140
+
141
+ function parseLinuxExec(desktopContent) {
142
+ if (!desktopContent) {
143
+ return null;
144
+ }
145
+ const match = String(desktopContent).match(/^Exec=(.+)$/m);
146
+ if (!match) {
147
+ return null;
148
+ }
149
+ // Drop desktop-entry field codes (%u %U %f %F ...) then take the executable.
150
+ const withoutCodes = match[1].replace(/%[a-zA-Z]/g, '').trim();
151
+ const first = withoutCodes.match(/^"([^"]+)"|^(\S+)/);
152
+ if (!first) {
153
+ return null;
154
+ }
155
+ return first[1] || first[2] || null;
156
+ }
157
+
158
+ function runCapture(command, args) {
159
+ try {
160
+ const result = spawnSync(command, args, {
161
+ encoding: 'utf8',
162
+ timeout: 2500,
163
+ windowsHide: true,
164
+ });
165
+ if (result && typeof result.stdout === 'string' && result.stdout.length > 0) {
166
+ return result.stdout;
167
+ }
168
+ } catch {
169
+ return null;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ // Detect the system default browser so we can open a real new window.
175
+ // Returns { family, bundleId? , execPath? } or null when detection fails or
176
+ // the default browser is not a supported family (unknown ProgId/desktop/bundle).
177
+ // `deps` allows injecting command runner / file reader for deterministic tests.
178
+ function detectDefaultBrowser(platform = process.platform, deps = {}) {
179
+ const run = deps.run || runCapture;
180
+ const readFile = deps.readFile || ((filePath) => fs.readFileSync(filePath, 'utf8'));
181
+ const homedir = deps.homedir || os.homedir;
182
+ try {
183
+ if (platform === 'darwin') {
184
+ const output = run('defaults', [
185
+ 'read',
186
+ 'com.apple.LaunchServices/com.apple.launchservices.secure',
187
+ ]);
188
+ return classifyMacBundleId(parseMacDefaultBrowser(output));
189
+ }
190
+ if (platform === 'win32') {
191
+ const progOutput = run('reg', [
192
+ 'query',
193
+ 'HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\https\\UserChoice',
194
+ '/v',
195
+ 'ProgId',
196
+ ]);
197
+ const progId = parseWindowsProgId(progOutput);
198
+ const family = classifyWindowsProgId(progId);
199
+ // Unknown ProgId (e.g. IE.HTTP or any browser we don't map) -> fall back.
200
+ if (!family) {
201
+ return null;
202
+ }
203
+ const commandOutput =
204
+ run('reg', ['query', `HKCU\\Software\\Classes\\${progId}\\shell\\open\\command`, '/ve']) ||
205
+ run('reg', ['query', `HKEY_CLASSES_ROOT\\${progId}\\shell\\open\\command`, '/ve']);
206
+ const execPath = parseWindowsCommandExec(commandOutput);
207
+ if (!execPath) {
208
+ return null;
209
+ }
210
+ return { family, execPath };
211
+ }
212
+ // linux and other unix-like desktops
213
+ const desktop = run('xdg-settings', ['get', 'default-web-browser']);
214
+ const name = desktop ? desktop.trim() : '';
215
+ if (!name) {
216
+ return null;
217
+ }
218
+ const family = classifyLinuxDesktop(name);
219
+ // Unknown .desktop (e.g. some non-browser or unmapped browser) -> fall back.
220
+ if (!family) {
221
+ return null;
222
+ }
223
+ const candidates = [
224
+ path.join(homedir(), '.local/share/applications', name),
225
+ path.join('/usr/share/applications', name),
226
+ path.join('/usr/local/share/applications', name),
227
+ ];
228
+ for (const candidate of candidates) {
229
+ try {
230
+ const content = readFile(candidate);
231
+ const execPath = parseLinuxExec(content);
232
+ if (execPath) {
233
+ return { family, execPath };
234
+ }
235
+ } catch {
236
+ // try the next candidate location
237
+ }
238
+ }
239
+ return null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function resolveBrowserLauncher(url, platform = process.platform, openMode = 'legacy', browser = null) {
246
+ if (openMode === 'newWindow' && browser && SUPPORTED_BROWSER_FAMILIES.has(browser.family)) {
247
+ if (platform === 'darwin') {
248
+ if (browser.family === 'safari') {
249
+ // Safari has no CLI new-window flag; AppleScript opens a new window.
250
+ const safeUrl = appleScriptSafe(url);
251
+ return {
252
+ command: 'osascript',
253
+ args: [
254
+ '-e',
255
+ `tell application "Safari" to make new document with properties {URL:"${safeUrl}"}`,
256
+ '-e',
257
+ 'tell application "Safari" to activate',
258
+ ],
259
+ };
260
+ }
261
+ if (browser.bundleId) {
262
+ // `open -b <bundle> -n --args --new-window <url>` forces a new window
263
+ // in the specific Chromium/Firefox app instead of a new tab.
264
+ return {
265
+ command: 'open',
266
+ args: ['-b', browser.bundleId, '-n', '--args', newWindowFlag(browser.family), url],
267
+ };
268
+ }
269
+ } else if (browser.execPath) {
270
+ return {
271
+ command: browser.execPath,
272
+ args: [newWindowFlag(browser.family), url],
273
+ };
274
+ }
275
+ }
276
+
15
277
  if (platform === 'darwin') {
16
278
  return { command: 'open', args: [url] };
17
279
  }
@@ -25,25 +287,65 @@ function resolveBrowserLauncher(url, platform = process.platform) {
25
287
  return { command: 'xdg-open', args: [url] };
26
288
  }
27
289
 
28
- function openBrowser(url) {
29
- if (process.env.OPENYIDA_NO_BROWSER === '1') {
30
- return false;
31
- }
32
-
33
- const { command, args } = resolveBrowserLauncher(url);
34
-
290
+ function launchBrowserLauncher(launcher, onError) {
35
291
  try {
36
- const child = spawn(command, args, {
292
+ const child = spawn(launcher.command, launcher.args, {
37
293
  detached: true,
38
294
  stdio: 'ignore',
39
295
  });
40
296
  child.unref();
297
+ if (onError) {
298
+ child.once('error', onError);
299
+ }
41
300
  return true;
42
301
  } catch {
302
+ if (onError) {
303
+ onError();
304
+ }
43
305
  return false;
44
306
  }
45
307
  }
46
308
 
309
+ function openBrowser(url) {
310
+ if (process.env.OPENYIDA_NO_BROWSER === '1') {
311
+ return false;
312
+ }
313
+
314
+ const platform = process.platform;
315
+ const legacyLauncher = resolveBrowserLauncher(url, platform, 'legacy');
316
+ const runLegacy = () => launchBrowserLauncher(legacyLauncher);
317
+
318
+ let browser = null;
319
+ try {
320
+ browser = detectDefaultBrowser(platform);
321
+ } catch {
322
+ browser = null;
323
+ }
324
+
325
+ // Default browser detected and supported → open a real new window in that
326
+ // browser, falling back to the plain default-browser open if launch errors.
327
+ if (browser && SUPPORTED_BROWSER_FAMILIES.has(browser.family)) {
328
+ const newWindowLauncher = resolveBrowserLauncher(url, platform, 'newWindow', browser);
329
+ let fallbackUsed = false;
330
+ const runFallback = () => {
331
+ if (fallbackUsed) {
332
+ return false;
333
+ }
334
+ fallbackUsed = true;
335
+ return runLegacy();
336
+ };
337
+
338
+ const launched = launchBrowserLauncher(newWindowLauncher, runFallback);
339
+ if (!launched) {
340
+ return runFallback();
341
+ }
342
+ return true;
343
+ }
344
+
345
+ // Detection failed → fall back to the system default browser (new tab).
346
+ return runLegacy();
347
+ }
348
+
47
349
  function buildDingtalkOAuthUrl(options) {
48
350
  const {
49
351
  clientId,
@@ -66,8 +368,45 @@ function buildDingtalkOAuthUrl(options) {
66
368
  return url.toString();
67
369
  }
68
370
 
69
- function responseHtml(title, body) {
70
- return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><h3>${title}</h3><p>${body}</p></body></html>`;
371
+ function responseHtml(title, body, options = {}) {
372
+ const {
373
+ autoClose = false,
374
+ autoCloseDelayMs = 1200,
375
+ staticHint = 'You can close this page and return to the terminal.',
376
+ } = options;
377
+
378
+ const closeScript = autoClose ? `
379
+ <script>
380
+ (function() {
381
+ const delay = Math.max(0, ${Number(autoCloseDelayMs)});
382
+ function forceClose() {
383
+ try {
384
+ if (window.opener && !window.opener.closed && window.opener.focus) {
385
+ window.opener.focus();
386
+ }
387
+ } catch (err) {}
388
+
389
+ try {
390
+ window.close();
391
+ } catch (err) {}
392
+
393
+ if (!window.closed) {
394
+ try {
395
+ window.open('', '_self').close();
396
+ } catch (err) {}
397
+ }
398
+ }
399
+
400
+ setTimeout(forceClose, delay);
401
+ })();
402
+ </script>
403
+ ` : '';
404
+
405
+ const closeHint = autoClose
406
+ ? ''
407
+ : `<p>${staticHint}</p>`;
408
+
409
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><h3>${title}</h3><p>${body}</p>${closeHint}${closeScript}</body></html>`;
71
410
  }
72
411
 
73
412
  function runDingtalkLoopback(options = {}) {
@@ -122,7 +461,13 @@ function runDingtalkLoopback(options = {}) {
122
461
 
123
462
  res.statusCode = 200;
124
463
  res.setHeader('content-type', 'text/html; charset=utf-8');
125
- res.end(responseHtml('OpenYida login completed', 'You can close this page and return to the terminal.'));
464
+ res.end(responseHtml(
465
+ 'OpenYida login completed',
466
+ 'Login callback received. Returning to terminal.',
467
+ {
468
+ autoClose: true,
469
+ }
470
+ ));
126
471
  finish(null, {
127
472
  code,
128
473
  authCode,
@@ -160,5 +505,13 @@ module.exports = {
160
505
  runDingtalkLoopback,
161
506
  buildDingtalkOAuthUrl,
162
507
  resolveBrowserLauncher,
508
+ detectDefaultBrowser,
509
+ classifyMacBundleId,
510
+ classifyWindowsProgId,
511
+ classifyLinuxDesktop,
512
+ parseMacDefaultBrowser,
513
+ parseWindowsProgId,
514
+ parseWindowsCommandExec,
515
+ parseLinuxExec,
163
516
  CALLBACK_PATH,
164
517
  };
@@ -21,6 +21,7 @@
21
21
 
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
+ const { spawnSync } = require('child_process');
24
25
  const {
25
26
  findProjectRoot,
26
27
  } = require('../core/utils');
@@ -859,16 +860,25 @@ async function run(args) {
859
860
  return result;
860
861
  }
861
862
 
863
+ function resolvePreviewBrowserLauncher(filePath, platform = process.platform) {
864
+ if (platform === 'darwin') {
865
+ return { command: 'open', args: [filePath] };
866
+ }
867
+ if (platform === 'win32') {
868
+ return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', filePath] };
869
+ }
870
+ return { command: 'xdg-open', args: [filePath] };
871
+ }
872
+
862
873
  function tryOpenBrowser(filePath) {
863
874
  try {
864
- const { execSync } = require('child_process');
865
- const platform = process.platform;
866
- if (platform === 'darwin') {
867
- execSync(`open "${filePath}"`, { stdio: 'ignore' });
868
- } else if (platform === 'win32') {
869
- execSync(`start "" "${filePath}"`, { stdio: 'ignore' });
870
- } else {
871
- execSync(`xdg-open "${filePath}"`, { stdio: 'ignore' });
875
+ const launcher = resolvePreviewBrowserLauncher(filePath);
876
+ const result = spawnSync(launcher.command, launcher.args, {
877
+ stdio: 'ignore',
878
+ windowsHide: true,
879
+ });
880
+ if (result && (result.error || (typeof result.status === 'number' && result.status !== 0))) {
881
+ throw result.error || new Error('open_browser_failed');
872
882
  }
873
883
  warn(`🌐 ${t('preview_process.browser_opened')}`);
874
884
  } catch {
@@ -883,4 +893,6 @@ module.exports = {
883
893
  fetchProcessInstance,
884
894
  fetchOperationRecords,
885
895
  parseProcessData,
896
+ resolvePreviewBrowserLauncher,
897
+ tryOpenBrowser,
886
898
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openyida",
3
- "version": "2026.7.18-1",
3
+ "version": "2026.7.18-2",
4
4
  "description": "OpenYida CLI - 宜搭低代码 AI 开发工具(安装即用,零配置)",
5
5
  "bin": {
6
6
  "openyida": "bin/yida.js",
@@ -53,6 +53,7 @@
53
53
  "check:i18n:audit": "node scripts/validate-i18n.js",
54
54
  "check:i18n:baseline": "node scripts/validate-i18n.js --update-baseline",
55
55
  "check:syntax": "node scripts/check-syntax.js",
56
+ "check:release-risks": "node scripts/check-release-risks.js",
56
57
  "check:package-size": "node scripts/validate-package-size.js",
57
58
  "check:package": "npm pack --dry-run",
58
59
  "check:release": "npm run check:ci",
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-release-risks.js
6
+ *
7
+ * 发布前静态风险扫描,专注「跨平台浏览器/URL 拉起」这一类在 macOS 本地
8
+ * 测试里查不出、却会在 Windows/Linux 用户侧炸掉的历史坑:
9
+ *
10
+ * - `cmd /c start <url>`:cmd.exe 把 URL 里的 `&` 当命令分隔符,截断
11
+ * OAuth/bridge URL,丢失 client_id/state 或 bridge 配对参数(见 ce7efdd)。
12
+ * - `open -n <url>`:macOS 下不会真正开新窗口,只是在默认浏览器里开新
13
+ * 标签页,属于「假装开新窗口」的反模式。
14
+ *
15
+ * HARD 反模式(error)→ 退出码 1,阻断发布。
16
+ * SOFT 提示(warning)→ 退出码 0,仅提醒发布人做人工跨端验证。
17
+ *
18
+ * 该脚本无第三方依赖,纯静态扫描 lib/ 源码;核心分析函数导出供单测使用。
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ const REPO_ROOT = path.resolve(__dirname, '..');
25
+ const SCAN_DIRS = ['lib'];
26
+
27
+ // HARD 反模式:命中即 error,阻断发布。
28
+ const HARD_RULES = [
29
+ {
30
+ id: 'cmd-c-start-url',
31
+ pattern: /cmd\s*\/c\s*start/i,
32
+ message:
33
+ 'cmd /c start 会把 URL 中的 & 当作命令分隔符截断参数(丢失 client_id/state 或 bridge 配对参数)。请改用 rundll32 url.dll,FileProtocolHandler,并复用 resolveBrowserLauncher。',
34
+ },
35
+ {
36
+ id: 'cmd-c-start-argv',
37
+ pattern: /['"]cmd['"]\s*,\s*\[\s*['"]\/c['"]\s*,\s*['"]start['"]/i,
38
+ message:
39
+ 'spawn("cmd", ["/c", "start", ...]) 与 cmd /c start 同源风险:& 处截断 URL。请改用 rundll32 或平台专用浏览器拉起命令。',
40
+ },
41
+ {
42
+ id: 'open-n-url-new-tab',
43
+ pattern: /\[\s*(['"])-n\1\s*,\s*url\s*\]/,
44
+ message:
45
+ 'open -n <url> 在 macOS 上不会开新窗口,只会在默认浏览器新开标签页(假装开窗口)。要真正开新窗口须指定浏览器 App 并传其 --new-window/-new-window,检测不到默认浏览器时回退纯 open <url>。',
46
+ },
47
+ ];
48
+
49
+ // SOFT 提示:命中不阻断,只提醒发布人补人工跨端验证。
50
+ const SOFT_LAUNCH_PRIMITIVES =
51
+ /rundll32|xdg-open|osascript|FileProtocolHandler|--new-window|-new-window|command\s*:\s*['"]open['"]|spawn(?:Sync)?\(\s*['"]open['"]/;
52
+
53
+ // 把注释内容替换成等长空白(保留换行以维持行号),避免把「解释为何避免
54
+ // cmd /c start」这类说明性注释误判成真实反模式。会跟踪字符串/模板字面量,
55
+ // 不会把字符串里的 // 或 /* 当注释。
56
+ function stripComments(content) {
57
+ const src = String(content);
58
+ let out = '';
59
+ let i = 0;
60
+ const n = src.length;
61
+ let state = 'code'; // code | line | block | sq | dq | tpl
62
+ while (i < n) {
63
+ const ch = src[i];
64
+ const next = src[i + 1];
65
+ if (state === 'code') {
66
+ if (ch === '/' && next === '/') {
67
+ state = 'line';
68
+ out += ' ';
69
+ i += 2;
70
+ continue;
71
+ }
72
+ if (ch === '/' && next === '*') {
73
+ state = 'block';
74
+ out += ' ';
75
+ i += 2;
76
+ continue;
77
+ }
78
+ if (ch === "'") {
79
+ state = 'sq';
80
+ } else if (ch === '"') {
81
+ state = 'dq';
82
+ } else if (ch === '`') {
83
+ state = 'tpl';
84
+ }
85
+ out += ch;
86
+ i += 1;
87
+ continue;
88
+ }
89
+ if (state === 'line') {
90
+ if (ch === '\n') {
91
+ state = 'code';
92
+ out += ch;
93
+ } else {
94
+ out += ' ';
95
+ }
96
+ i += 1;
97
+ continue;
98
+ }
99
+ if (state === 'block') {
100
+ if (ch === '*' && next === '/') {
101
+ state = 'code';
102
+ out += ' ';
103
+ i += 2;
104
+ } else {
105
+ out += ch === '\n' ? '\n' : ' ';
106
+ i += 1;
107
+ }
108
+ continue;
109
+ }
110
+ // string / template states: copy verbatim, honor escapes, exit on matching quote
111
+ out += ch;
112
+ if (ch === '\\') {
113
+ out += next !== undefined ? next : '';
114
+ i += 2;
115
+ continue;
116
+ }
117
+ if (
118
+ (state === 'sq' && ch === "'") ||
119
+ (state === 'dq' && ch === '"') ||
120
+ (state === 'tpl' && ch === '`')
121
+ ) {
122
+ state = 'code';
123
+ }
124
+ i += 1;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function analyzeContent(relPath, content) {
130
+ const findings = [];
131
+ const scannable = stripComments(content);
132
+ const lines = scannable.split(/\r?\n/);
133
+
134
+ for (const rule of HARD_RULES) {
135
+ lines.forEach((line, index) => {
136
+ if (rule.pattern.test(line)) {
137
+ findings.push({
138
+ file: relPath,
139
+ line: index + 1,
140
+ severity: 'error',
141
+ ruleId: rule.id,
142
+ message: rule.message,
143
+ });
144
+ }
145
+ });
146
+ }
147
+
148
+ if (SOFT_LAUNCH_PRIMITIVES.test(scannable)) {
149
+ findings.push({
150
+ file: relPath,
151
+ line: 0,
152
+ severity: 'warning',
153
+ ruleId: 'cross-platform-launch-touched',
154
+ message:
155
+ '涉及浏览器/URL 拉起代码。CI 只跑纯函数单测,不覆盖真实系统拉起——发布前请在 Windows / macOS / Linux 各自实测 login 与 bridge 页面唤起。',
156
+ });
157
+ }
158
+
159
+ return findings;
160
+ }
161
+
162
+ function analyzeFiles(files) {
163
+ const findings = [];
164
+ for (const f of files) {
165
+ findings.push(...analyzeContent(f.path, f.content));
166
+ }
167
+ const errorCount = findings.filter((x) => x.severity === 'error').length;
168
+ const warnCount = findings.filter((x) => x.severity === 'warning').length;
169
+ return { findings, errorCount, warnCount };
170
+ }
171
+
172
+ function collectSourceFiles(root = REPO_ROOT, dirs = SCAN_DIRS) {
173
+ const out = [];
174
+ const walk = (abs) => {
175
+ let entries = [];
176
+ try {
177
+ entries = fs.readdirSync(abs, { withFileTypes: true });
178
+ } catch {
179
+ return;
180
+ }
181
+ for (const entry of entries) {
182
+ const full = path.join(abs, entry.name);
183
+ if (entry.isDirectory()) {
184
+ if (entry.name === 'node_modules' || entry.name === '.git') {
185
+ continue;
186
+ }
187
+ walk(full);
188
+ } else if (entry.isFile() && entry.name.endsWith('.js')) {
189
+ out.push({
190
+ path: path.relative(root, full),
191
+ content: fs.readFileSync(full, 'utf8'),
192
+ });
193
+ }
194
+ }
195
+ };
196
+ for (const dir of dirs) {
197
+ walk(path.join(root, dir));
198
+ }
199
+ return out;
200
+ }
201
+
202
+ function formatFindings({ findings, errorCount, warnCount }) {
203
+ const lines = [];
204
+ const errors = findings.filter((x) => x.severity === 'error');
205
+ const warnings = findings.filter((x) => x.severity === 'warning');
206
+
207
+ if (errors.length) {
208
+ lines.push('发布风险检查:发现 HARD 反模式(阻断发布)');
209
+ for (const f of errors) {
210
+ lines.push(` ✗ [${f.ruleId}] ${f.file}:${f.line}`);
211
+ lines.push(` ${f.message}`);
212
+ }
213
+ }
214
+
215
+ if (warnings.length) {
216
+ lines.push('发布风险检查:跨端人工验证提醒(不阻断)');
217
+ const byFile = new Set(warnings.map((w) => w.file));
218
+ for (const file of byFile) {
219
+ lines.push(` ⚠ ${file}`);
220
+ }
221
+ lines.push(
222
+ ' 涉及浏览器/URL 拉起:请在 Windows / macOS / Linux 各自实测 openyida login 与 bridge 页面唤起。'
223
+ );
224
+ }
225
+
226
+ lines.push('');
227
+ lines.push(`发布风险检查完成:error=${errorCount}, warning=${warnCount}`);
228
+ return lines.join('\n');
229
+ }
230
+
231
+ function main() {
232
+ const result = analyzeFiles(collectSourceFiles());
233
+ process.stdout.write(`${formatFindings(result)}\n`);
234
+ if (result.errorCount > 0) {
235
+ process.stdout.write(
236
+ '\n存在阻断发布的 HARD 反模式,请修复后重试(详见 .qoder/skills 跨端发布风险技能)。\n'
237
+ );
238
+ process.exit(1);
239
+ }
240
+ process.exit(0);
241
+ }
242
+
243
+ if (require.main === module) {
244
+ main();
245
+ }
246
+
247
+ module.exports = {
248
+ HARD_RULES,
249
+ SOFT_LAUNCH_PRIMITIVES,
250
+ analyzeContent,
251
+ analyzeFiles,
252
+ collectSourceFiles,
253
+ formatFindings,
254
+ };
@@ -17,25 +17,25 @@ echo "=== Step 2: Validate project structure ==="
17
17
  npm run check:structure
18
18
 
19
19
  echo ""
20
- echo "=== Step 3: Validate skills ==="
20
+ echo "=== Step 3: Build Wukong skills package ==="
21
+ npm run build:skills
22
+
23
+ echo ""
24
+ echo "=== Step 4: Validate skills ==="
21
25
  npm run check:skills
22
26
 
23
27
  echo ""
24
- echo "=== Step 4: Validate command manifest ==="
28
+ echo "=== Step 5: Validate command manifest ==="
25
29
  npm run check:commands
26
30
 
27
31
  echo ""
28
- echo "=== Step 5: Validate generated command docs ==="
32
+ echo "=== Step 6: Validate generated command docs ==="
29
33
  npm run check:docs
30
34
 
31
35
  echo ""
32
- echo "=== Step 6: Validate i18n locale parity (ratchet) ==="
36
+ echo "=== Step 7: Validate i18n locale parity (ratchet) ==="
33
37
  npm run check:i18n
34
38
 
35
- echo ""
36
- echo "=== Step 7: Build Wukong skills package ==="
37
- npm run build:skills
38
-
39
39
  echo ""
40
40
  echo "=== Step 8: Check JavaScript syntax ==="
41
41
  npm run check:syntax
@@ -45,15 +45,19 @@ echo "=== Step 9: Run lint ==="
45
45
  npm run lint
46
46
 
47
47
  echo ""
48
- echo "=== Step 10: Run tests ==="
48
+ echo "=== Step 10: Scan cross-platform release risks ==="
49
+ npm run check:release-risks
50
+
51
+ echo ""
52
+ echo "=== Step 11: Run tests ==="
49
53
  npm run test:unit -- --runInBand
50
54
 
51
55
  echo ""
52
- echo "=== Step 11: Validate npm package size budget ==="
56
+ echo "=== Step 12: Validate npm package size budget ==="
53
57
  npm run check:package-size
54
58
 
55
59
  echo ""
56
- echo "=== Step 12: Validate npm package contents ==="
60
+ echo "=== Step 13: Validate npm package contents ==="
57
61
  npm run check:package
58
62
 
59
63
  echo ""
@@ -41,6 +41,21 @@ if (!nodeEngine) {
41
41
  }
42
42
  console.log('engines.node: ' + nodeEngine);
43
43
 
44
+ if (fs.existsSync('package-lock.json')) {
45
+ const packageLock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
46
+ const lockVersions = [
47
+ ['package-lock.json version', packageLock.version],
48
+ ['package-lock root package version', packageLock.packages && packageLock.packages[''] && packageLock.packages[''].version],
49
+ ];
50
+ for (const pair of lockVersions) {
51
+ if (pair[1] !== packageJson.version) {
52
+ console.error(`${pair[0]} (${pair[1] || 'missing'}) does not match package.json version (${packageJson.version})`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+ console.log('package-lock version: ' + packageLock.version);
57
+ }
58
+
44
59
  const skillsDir = 'yida-skills/skills';
45
60
  if (fs.existsSync(skillsDir)) {
46
61
  const skills = fs.readdirSync(skillsDir).filter(function(name) {