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.
- package/lib/auth/oauth-loopback.js +366 -13
- package/lib/process/preview-process.js +20 -8
- package/package.json +2 -1
- package/scripts/check-release-risks.js +254 -0
- package/scripts/validate-ci.sh +15 -11
- package/scripts/validate-structure.js +15 -0
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const http = require('http');
|
|
5
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
|
|
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-
|
|
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
|
+
};
|
package/scripts/validate-ci.sh
CHANGED
|
@@ -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:
|
|
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
|
|
28
|
+
echo "=== Step 5: Validate command manifest ==="
|
|
25
29
|
npm run check:commands
|
|
26
30
|
|
|
27
31
|
echo ""
|
|
28
|
-
echo "=== Step
|
|
32
|
+
echo "=== Step 6: Validate generated command docs ==="
|
|
29
33
|
npm run check:docs
|
|
30
34
|
|
|
31
35
|
echo ""
|
|
32
|
-
echo "=== Step
|
|
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:
|
|
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
|
|
56
|
+
echo "=== Step 12: Validate npm package size budget ==="
|
|
53
57
|
npm run check:package-size
|
|
54
58
|
|
|
55
59
|
echo ""
|
|
56
|
-
echo "=== Step
|
|
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) {
|