openyida 2026.10.30-beta.1 → 2026.10.31

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 (51) hide show
  1. package/README.md +3 -16
  2. package/lib/app/app-theme-state.js +80 -0
  3. package/lib/app/create-app.js +31 -0
  4. package/lib/app/custom-theme.js +21 -1
  5. package/lib/app/services/canvas-page-schema-builder.js +4 -0
  6. package/lib/app/update-app.js +97 -65
  7. package/lib/auth/qwenwork-desktop.js +2 -1
  8. package/lib/auth/qwenwork-login.js +40 -5
  9. package/lib/core/agent-capabilities.js +50 -10
  10. package/lib/core/browser-handoff.js +18 -4
  11. package/lib/core/command-manifest.js +12 -6
  12. package/lib/core/form-edit-permissions.js +210 -0
  13. package/lib/core/locales/en.js +9 -1
  14. package/lib/core/locales/zh.js +9 -1
  15. package/lib/core/query-data.js +12 -1
  16. package/lib/core/qwenwork-login-guidance.js +5 -2
  17. package/lib/core/utils.js +15 -10
  18. package/lib/design-plan/visual-policy.js +1 -1
  19. package/lib/permission/permission-scope.js +27 -8
  20. package/lib/permission/save-permission.js +6 -4
  21. package/package.json +1 -1
  22. package/yida-skills/SKILL.md +9 -3
  23. package/yida-skills/references/execution-rules.md +28 -0
  24. package/yida-skills/references/qwenwork-cloud-login.md +2 -0
  25. package/yida-skills/references/qwenwork-desktop-login.md +2 -0
  26. package/yida-skills/references/setup-and-env.md +26 -8
  27. package/yida-skills/references/user-facing-summary.md +26 -6
  28. package/yida-skills/skills/yida-app/SKILL.md +6 -0
  29. package/yida-skills/skills/yida-app/workflow/step-3-create-or-reuse-app.md +4 -4
  30. package/yida-skills/skills/yida-app/workflow/step-8-publish-navigation.md +2 -0
  31. package/yida-skills/skills/yida-app/workflow/step-9-output-finish.md +13 -11
  32. package/yida-skills/skills/yida-canvas-custom-page/SKILL.md +1 -1
  33. package/yida-skills/skills/yida-canvas-custom-page/references/data-bridge-guide.md +25 -22
  34. package/yida-skills/skills/yida-canvas-custom-page/references/waf-safe-authoring.md +14 -37
  35. package/yida-skills/skills/yida-canvas-data-binding/SKILL.md +3 -17
  36. package/yida-skills/skills/yida-create-app/SKILL.md +14 -6
  37. package/yida-skills/skills/yida-data-management/SKILL.md +4 -1
  38. package/yida-skills/skills/yida-data-management/references/api-matrix.md +4 -0
  39. package/yida-skills/skills/yida-design/SKILL.md +1 -1
  40. package/yida-skills/skills/yida-design/references/application-style-library.md +7 -3
  41. package/yida-skills/skills/yida-design/references/application-theme-consistency.md +4 -4
  42. package/yida-skills/skills/yida-design/references/theme-selection.md +7 -6
  43. package/yida-skills/skills/yida-design/templates/design-themes/dark-rail-fine-lines/app_theme.css +10 -9
  44. package/yida-skills/skills/yida-design/templates/design-themes/dark-rail-fine-lines/design.md +6 -3
  45. package/yida-skills/skills/yida-design/workflow/output-design.md +2 -2
  46. package/yida-skills/skills/yida-design/workflow/step-2-theme-system.md +4 -4
  47. package/yida-skills/skills/yida-dingtalk-openapi/references/connector-mapping.md +7 -3
  48. package/yida-skills/skills/yida-form-permission/SKILL.md +3 -22
  49. package/yida-skills/skills/yida-login/SKILL.md +13 -6
  50. package/yida-skills/skills/yida-publish-page/SKILL.md +4 -2
  51. package/yida-skills/skills/yida-requirement-analysis/workflow/prepare-brief.md +2 -0
package/README.md CHANGED
@@ -143,9 +143,9 @@ openyida login --check-only --json
143
143
  openyida auth status
144
144
  ```
145
145
 
146
- In QwenWork web, `openyida login --qwenwork --json` (or ordinary `login`) sends the sandbox Agent Token and gateway origin directly to the selected YiDA authentication endpoint. YiDA validates the identity through the trusted gateway’s fixed `/api/agent-gw/yida/user_info` endpoint, then issues its own short-lived session. This login flow no longer calls the legacy exchange or `/user/info` endpoints. Login preparation stays silent; failures stop without browser OAuth. Desktop and other hosts retain their existing authentication paths.
146
+ In QwenWork web, `openyida login --qwenwork --json` (or ordinary `login`) reuses the sandbox identity. If cloud authentication fails, the same command falls back to OAuth; follow its authorization URL and wait for completion. Desktop and other hosts keep their existing login paths.
147
147
 
148
- QwenWork login supports DingTalk organizations, QwenWork organizations and personal ODS identities. Local ODS tenant IDs retain their `yida` prefix. When the server supplies a refresh token, the CLI stores it for the standard refresh flow. Optional server fields `identity_type` (`PERSONAL` / `ORGANIZATION`) and `organization_type` (`DINGTALK` / `QWENWORK` / `OTHER`, organizations only) are preserved in the session and `auth status`; historical sessions with both fields absent, null or empty default to `ORGANIZATION` / `DINGTALK`. Authentication failures expose bounded `status`, `stage`, `exchange_id`, HTTP `status_code` and local validation `reason` in JSON diagnostics, without response bodies or credentials.
148
+ Login supports DingTalk and QwenWork organizations, ODS and personal spaces. Sessions preserve server-provided identity classification, business domain and refresh credentials. Historical sessions default to DingTalk organizations. JSON errors include bounded diagnostics without credentials or response bodies.
149
149
 
150
150
  For QwenWork cloud identity diagnostics, run inside the actual cloud execution environment:
151
151
 
@@ -153,20 +153,7 @@ For QwenWork cloud identity diagnostics, run inside the actual cloud execution e
153
153
  openyida auth qwenwork user-info --json
154
154
  ```
155
155
 
156
- This diagnostic command uses the legacy user-token protocol; it does not validate the current web Agent Token login flow. Use `openyida login --check-only --json` to check the YiDA session.
157
-
158
- This command exchanges the injected `QWENWORK_AGENT_TOKEN` (legacy: `MULE_AGENT_TOKEN` / `AGENT_TOKEN`) at `QWENWORK_GATEWAY_URL` (legacy: `MULE_GATEWAY_URL`), then queries `/user/info` on the same origin. It reports identity and scope metadata without printing or persisting either credential. It does not create an OpenYida login session, and QwenWork organization IDs are not YiDA `corpId` values. Existing OAuth/profile behavior is unchanged.
159
-
160
- Programmatic callers can use `exchangeQwenWorkAgentToken`, `getQwenWorkUserInfo` and `getQwenWorkIdentity` from `lib/auth/qwenwork-client.js`. The exchange function returns an in-memory `access_token` credential for the identity query or a future server-side login exchange; callers must not log or persist it.
161
-
162
- ```js
163
- const { exchangeQwenWorkAgentToken, getQwenWorkUserInfo } = require('openyida/lib/auth/qwenwork-client');
164
- async function readCloudUser() {
165
- const credential = await exchangeQwenWorkAgentToken();
166
- // The result contains QwenWork identifiers, not YiDA IDs.
167
- return getQwenWorkUserInfo(credential);
168
- }
169
- ```
156
+ This legacy diagnostic reads the injected host credentials and reports identity and scope without printing or saving tokens. It does not create or validate a YiDA session; QwenWork organization IDs are not YiDA `corpId` values. Check the YiDA session with `openyida login --check-only --json`. See [QwenWork login guidance](yida-skills/references/qwenwork-cloud-login.md) for authentication behavior.
170
157
 
171
158
  To reuse an existing shared login profile for the current project, switch first instead of starting a new OAuth login:
172
159
 
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const { httpGet, requestWithAutoLogin } = require('../core/utils');
4
+ const { throwCommandError } = require('../core/command-errors');
5
+ const { t } = require('../core/i18n');
6
+ const { isAgentAppType } = require('./agent-app-type');
7
+
8
+ function themeContextFailure(reason) {
9
+ throwCommandError(t('upgrade_app_theme.context_failed'), {
10
+ code: 'APP_THEME_CONTEXT_UNVERIFIED', details: { reason },
11
+ });
12
+ }
13
+
14
+ // The application API exposes persisted mode/provenance. These establish the
15
+ // configured theme, not proof that a browser loaded the stylesheet.
16
+ function resolveAppThemeState(app = {}) {
17
+ const config = app.config && typeof app.config === 'object' && !Array.isArray(app.config) ? app.config : {};
18
+ const mode = app.appThemeMode ?? config.APP_THEME_MODE;
19
+ const enabled = app.appThemeEnable;
20
+ const createdModern = config.CREATED_WITH_MODERN_THEME;
21
+ if (enabled === 'n' || mode === 'legacy' || createdModern === 'n') {
22
+ return { mode: 'legacy', source: 'server_settings' };
23
+ }
24
+ if (enabled === 'y' && mode === 'modern') {
25
+ return { mode: 'modern', source: 'server_theme_state' };
26
+ }
27
+ if (enabled !== undefined && enabled !== null && enabled !== 'y') {
28
+ return { mode: 'unknown', source: 'server_settings' };
29
+ }
30
+ if (createdModern === 'y' &&
31
+ (mode === 'modern' || ((mode === undefined || mode === null || mode === '') && isAgentAppType(app.agentAppType)))) {
32
+ return { mode: 'modern', source: 'server_creation_config' };
33
+ }
34
+ return { mode: 'unknown', source: 'server_settings' };
35
+ }
36
+
37
+ async function fetchCurrentApp(appType, authRef, verifyIdentity = false) {
38
+ const target = { baseUrl: authRef.baseUrl, corpId: authRef.corpId, userId: authRef.userId };
39
+ if (verifyIdentity && (typeof appType !== 'string' || !/^[A-Za-z0-9_-]+$/.test(appType) ||
40
+ typeof target.corpId !== 'string' || !target.corpId || !target.baseUrl)) {
41
+ themeContextFailure('invalid_target');
42
+ }
43
+ const assertIdentity = auth => {
44
+ if (verifyIdentity && Object.keys(target).some(key => auth[key] !== target[key])) {
45
+ themeContextFailure('identity_changed');
46
+ }
47
+ };
48
+ const response = await requestWithAutoLogin((auth) => {
49
+ assertIdentity(auth);
50
+ return httpGet(
51
+ auth.baseUrl,
52
+ `/${encodeURIComponent(appType)}/query/app/getAppIncludingAecpInfo.json`,
53
+ {
54
+ _api: 'nattyFetch',
55
+ _mock: 'false',
56
+ appKey: appType,
57
+ _csrf_token: auth.csrfToken,
58
+ _stamp: Date.now(),
59
+ }
60
+ );
61
+ }, authRef);
62
+ assertIdentity(authRef);
63
+
64
+ if (verifyIdentity) {
65
+ const app = response?.content;
66
+ if (response?.success !== true || !app || typeof app !== 'object' || Array.isArray(app)) {
67
+ themeContextFailure('app_query_failed');
68
+ }
69
+ if (app.appType !== appType || app.corpId !== target.corpId) {
70
+ themeContextFailure('app_identity_mismatch');
71
+ }
72
+ }
73
+
74
+ if (!response || !response.success) {
75
+ throw new Error(response ? response.errorMsg || '查询应用详情失败' : '查询应用详情失败');
76
+ }
77
+ return response.content || {};
78
+ }
79
+
80
+ module.exports = { fetchCurrentApp, resolveAppThemeState };
@@ -22,6 +22,7 @@ const { parseOpenOption, withBrowserHandoff } = require('../core/browser-handoff
22
22
  const { throwCommandError } = require('../core/command-errors');
23
23
  const { isPermissionGuidanceError } = require('../core/permission-guidance');
24
24
  const { resolveBuilderAiSource } = require('./builder-ai-source');
25
+ const { fetchCurrentApp, resolveAppThemeState } = require('./app-theme-state');
25
26
 
26
27
  const DEFAULT_APP_OPTIONS = {
27
28
  iconColor: '#0089FF',
@@ -376,6 +377,35 @@ async function run(args) {
376
377
  const appUrl = entryUrls.appUrl;
377
378
  const corpId = authRef.corpId || (authRef.authData && authRef.authData.corp_id) || '';
378
379
 
380
+ let themeVerification;
381
+ try {
382
+ const app = await fetchCurrentApp(appType, authRef, true);
383
+ const theme = resolveAppThemeState(app);
384
+ if (theme.mode !== 'modern') {
385
+ throwCommandError(t('create_app.modern_theme_unverified'), {
386
+ code: 'APP_THEME_NOT_MODERN', details: { themeMode: theme.mode },
387
+ });
388
+ }
389
+ themeVerification = { verified: true, appThemeMode: 'modern', source: theme.source,
390
+ scope: 'configuration_readback', runtimeVerified: false };
391
+ } catch (error) {
392
+ const created = { appCreated: true, appType, corpId, url: appUrl, retryCreate: false,
393
+ partial: true, residual: { appType, corpId }, retrySafe: false, mutationPerformed: true,
394
+ sideEffectState: 'applied', readbackAllowed: true };
395
+ if (isPermissionGuidanceError(error)) {
396
+ error.details = { ...error.details, ...created };
397
+ throw error;
398
+ }
399
+ throwCommandError(t('create_app.modern_theme_unverified'), {
400
+ code: 'APP_CREATED_THEME_UNVERIFIED', details: {
401
+ ...created,
402
+ causeCode: error.code || 'APP_THEME_READ_FAILED',
403
+ ...(error.details?.themeMode ? { themeMode: error.details.themeMode } : {}),
404
+ ...(error.details?.reason ? { reason: error.details.reason } : {}),
405
+ },
406
+ });
407
+ }
408
+
379
409
  chalkResult(true, t('create_app.success'), [
380
410
  ['appType', appType],
381
411
  ['Corp ID', corpId || t('common.unknown_error')],
@@ -391,6 +421,7 @@ async function run(args) {
391
421
  appType,
392
422
  appName,
393
423
  corpId,
424
+ themeVerification,
394
425
  url: appUrl,
395
426
  ...entryUrls,
396
427
  },
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { createHash } = require('crypto');
5
6
  const { resolveBearerAuthHeaders } = require('../core/utils');
6
7
  const { readManagedContext, managedError } = require('../agent/managed-context');
7
8
  const { CliError } = require('../core/cli-error');
@@ -306,7 +307,26 @@ async function uploadCustomThemeFile(appType, filePath, authRef, options = {}) {
306
307
  if (!response.ok) {
307
308
  throw new Error((payload && (payload.errorMsg || payload.message)) || `主题文件上传失败: HTTP ${response.status}`);
308
309
  }
309
- return payload;
310
+ // Hash multipart bytes, not reread files or server-supplied receipt fields.
311
+ if (payload?.success === false) {
312
+ return { ...payload, themeUpload: { requested: true, status: 'rejected', uploadedThisRun: false, appType } };
313
+ }
314
+ const upload = unwrapUploadResponse(payload);
315
+ return { ...payload, themeUpload: {
316
+ requested: true,
317
+ status: 'uploaded',
318
+ uploadedThisRun: true,
319
+ appType,
320
+ baseUrl: new URL(authRef.baseUrl).origin,
321
+ corpId: authRef.corpId || null,
322
+ sourceFileName: file.name,
323
+ sourceBytes: file.content.length,
324
+ sourceSha256: createHash('sha256').update(file.content).digest('hex'),
325
+ themeColor: file.themeColor,
326
+ cssUrl: upload.downloadUrl || upload.url,
327
+ cssFileName: upload.name || '',
328
+ resourceContentVerified: false,
329
+ } };
310
330
  }
311
331
 
312
332
  module.exports = {
@@ -81,6 +81,10 @@ var OPENYIDA_UTIL_API_METHODS = [
81
81
  function openyidaYidaApiBridgeTargets() {
82
82
  var targets = [];
83
83
  function pushTarget(target) {
84
+ if (target !== window) {
85
+ var origin = window.location.origin;
86
+ if (!origin || origin === 'null' || target.location.origin !== origin) { return; }
87
+ }
84
88
  if (target && targets.indexOf(target) === -1) {
85
89
  targets.push(target);
86
90
  }
@@ -7,8 +7,8 @@
7
7
  'use strict';
8
8
 
9
9
  const querystring = require('querystring');
10
+ const { fetchCurrentApp, resolveAppThemeState } = require('./app-theme-state');
10
11
  const {
11
- httpGet,
12
12
  httpPost,
13
13
  requestWithAutoLogin,
14
14
  } = require('../core/utils');
@@ -376,35 +376,24 @@ function preserveBasicSettings(payload, app) {
376
376
  }
377
377
  }
378
378
 
379
- function requireAppStyleConfirmation(params, context) {
380
- const modern = pickAppField(context, 'appThemeEnable', 'APP_THEME_ENABLE') === 'y' &&
381
- pickAppField(context, 'appThemeMode', 'APP_THEME_MODE') === 'modern';
382
- if (!modern && params.confirmLegacyAppStyle !== true) {
379
+ function requireAppStyleConfirmation(params, app) {
380
+ const theme = resolveAppThemeState(app);
381
+ if (theme.mode !== 'modern' && params.confirmLegacyAppStyle !== true) {
382
+ const storedMode = pickAppField(app, 'appThemeMode', 'APP_THEME_MODE');
383
383
  throwCommandError(t('update_app.legacy_style_confirm_required'), {
384
384
  code: 'LEGACY_APP_CUSTOM_THEME_CONFIRMATION_REQUIRED',
385
- details: { appType: params.appType, applicationSettingsWritten: false, nextAction: 'ask_user_for_application_style_injection' },
385
+ details: { appType: params.appType, applicationSettingsWritten: false,
386
+ themeCapability: theme.mode, storedThemeMode: typeof storedMode === 'string' ? storedMode : null,
387
+ nextAction: 'ask_user_for_application_style_injection' },
386
388
  });
387
389
  }
388
390
  }
389
391
 
390
- // Runtime capability is resolved by the server. Stored APP_THEME_MODE alone is insufficient.
391
392
  async function checkAppStyleInjection(params, authRef) {
392
- const html = await requestWithAutoLogin(auth => httpGet(auth.baseUrl,
393
- `/${encodeURIComponent(params.appType)}/admin`, { _stamp: Date.now() }, { responseType: 'text' }), authRef);
394
- const { parseThemeContext } = require('./upgrade-app-theme');
395
- // Even human approval cannot waive target application and organization identity.
396
- const identity = parseThemeContext(html, ['appType', 'corpId']);
397
- if (identity.appType !== params.appType || !authRef.corpId || identity.corpId !== authRef.corpId) {
398
- throwCommandError(t('upgrade_app_theme.context_failed'), { code: 'APP_THEME_CONTEXT_UNVERIFIED' });
399
- }
400
- let context;
401
- try {
402
- context = parseThemeContext(html, ['appType', 'corpId', 'appThemeEnable', 'appThemeMode']);
403
- } catch (error) {
404
- if (error.code !== 'APP_THEME_CONTEXT_UNVERIFIED') {throw error;}
405
- return requireAppStyleConfirmation(params, {});
406
- }
407
- requireAppStyleConfirmation(params, context);
393
+ // AppRpc#getAppIncludingAecpInfo returns the application's own identity.
394
+ // Never open /admin or follow a browser login redirect to validate an API write.
395
+ const app = await fetchCurrentApp(params.appType, authRef, true);
396
+ requireAppStyleConfirmation(params, app);
408
397
  }
409
398
 
410
399
  function buildUpdateAppPostData(params, currentApp = {}, authRef) {
@@ -520,25 +509,6 @@ function buildUpdateAppPostData(params, currentApp = {}, authRef) {
520
509
  return postDataObj;
521
510
  }
522
511
 
523
- async function fetchCurrentApp(appType, authRef) {
524
- const response = await requestWithAutoLogin((auth) => httpGet(
525
- auth.baseUrl,
526
- `/${appType}/query/app/getAppIncludingAecpInfo.json`,
527
- {
528
- _api: 'nattyFetch',
529
- _mock: 'false',
530
- appKey: appType,
531
- _csrf_token: auth.csrfToken,
532
- _stamp: Date.now(),
533
- }
534
- ), authRef);
535
-
536
- if (!response || !response.success) {
537
- throw new Error(response ? response.errorMsg || '查询应用详情失败' : '查询应用详情失败');
538
- }
539
- return response.content || {};
540
- }
541
-
542
512
  function getThemeSettings(app) {
543
513
  const rawStyle = pickAppField(app, 'customThemeStyle', 'CUSTOM_THEME_STYLE');
544
514
  let customThemeStyle = rawStyle || null;
@@ -566,6 +536,51 @@ function themeSettingsMatch(expected, actual) {
566
536
  ['cssUrl', 'cssFileName'].every((key) => (expectedStyle[key] || '') === (actualStyle[key] || ''));
567
537
  }
568
538
 
539
+ function themeReadbackEvidence(params, details) {
540
+ const operation = params.disableCustomTheme ? 'disable'
541
+ : params.customThemeStyle ? 'bind_resource'
542
+ : params.colour || params.themeColor ? 'update_theme' : 'preserve';
543
+ return { ...details, scope: 'configuration_readback', operation, runtimeVerified: false };
544
+ }
545
+
546
+ /** Configuration completion is separate from rendering. */
547
+ function getThemeCompletion(params, currentApp, app, themeVerified, navigationVerified) {
548
+ const before = resolveAppThemeState(currentApp);
549
+ const theme = resolveAppThemeState(app);
550
+ const operation = params.customThemeStyle ? 'application_css'
551
+ : params.disableCustomTheme ? 'disable'
552
+ : params.colour && params.colour !== 'custom' ? 'preset' : 'partial_update';
553
+ const actual = getThemeSettings(app);
554
+ const checks = {
555
+ themeModeVerified: theme.mode !== 'unknown' && theme.mode === before.mode,
556
+ settingsVerified: themeVerified === true,
557
+ navigationVerified: navigationVerified === true,
558
+ };
559
+ if (operation === 'application_css') {
560
+ checks.resourceBound = actual.colour === 'custom' && !!normalizeCssColorToHex(actual.themeColor) &&
561
+ actual.customThemeStyle?.enabled === true &&
562
+ typeof actual.customThemeStyle?.cssUrl === 'string' && !!actual.customThemeStyle.cssUrl.trim();
563
+ // Legacy overlays require scope approval.
564
+ if (theme.mode === 'legacy') {checks.legacyScopeConfirmed = params.confirmLegacyAppStyle === true;}
565
+ }
566
+ const complete = operation !== 'partial_update' && Object.values(checks).every(value => value === true);
567
+ return { scope: 'configuration_readback', operation, mode: theme.mode, modeSource: theme.source,
568
+ complete, status: complete ? 'complete' : operation === 'partial_update' ? 'partial' : 'incomplete',
569
+ checks, missing: Object.keys(checks).filter(key => !checks[key]), runtimeVerified: false };
570
+ }
571
+
572
+ function noThemeUpload(appType) {
573
+ return { requested: false, status: 'not_requested', uploadedThisRun: false, appType };
574
+ }
575
+
576
+ function themeFailureEvidence(themeUpload) {
577
+ const uncertain = themeUpload.uploadedThisRun === true || themeUpload.status === 'unknown';
578
+ return { themeUpload, runtimeVerification: { verified: false, status: 'not_checked' },
579
+ ...(uncertain ? { target: { appType: themeUpload.appType }, retrySafe: false,
580
+ sideEffectState: 'unknown', readbackAllowed: true } : {}),
581
+ };
582
+ }
583
+
569
584
  function getRequestedNavigationSettings(params, payload) {
570
585
  return Object.fromEntries(['navTheme', 'layoutDirection', 'hideAppNav', 'logoSource']
571
586
  .filter((name) => params[name] !== undefined && params[name] !== null && params[name] !== '')
@@ -614,6 +629,7 @@ async function saveAppSettings(params, authRef) {
614
629
  const navigationFields = Object.keys(expectedNavigation);
615
630
  if (!verifyTheme && !navigationFields.length) {return response;}
616
631
  let actual;
632
+ let actualApp = {};
617
633
  let actualNavigation;
618
634
  let readbackError;
619
635
  let themeVerified = !verifyTheme;
@@ -623,15 +639,17 @@ async function saveAppSettings(params, authRef) {
623
639
  if (attempt) { await new Promise((resolve) => setTimeout(resolve, attempt * 250)); }
624
640
  try {
625
641
  const app = await fetchCurrentApp(params.appType, authRef);
642
+ actualApp = app;
626
643
  actual = getThemeSettings(app);
627
644
  actualNavigation = getNavigationSettings(app, navigationFields, currentApp, params.disableCustomTheme);
628
645
  readbackError = undefined;
629
646
  themeVerified = !verifyTheme || themeSettingsMatch(expected, actual);
630
647
  navigationVerified = navigationFields.every((name) => expectedNavigation[name] === actualNavigation[name]);
631
- if (themeVerified && navigationVerified) {
632
- return { ...response,
648
+ const themeCompletion = getThemeCompletion(params, currentApp, app, themeVerified, navigationVerified);
649
+ if (themeVerified && navigationVerified && (!params.customThemeStyle || themeCompletion.complete)) {
650
+ return { ...response, themeCompletion,
633
651
  ...(submittedFields ? { submittedFields } : {}),
634
- ...(verifyTheme ? { themeVerification: { verified: true, ...actual } } : {}),
652
+ ...(verifyTheme ? { themeVerification: themeReadbackEvidence(params, { verified: true, ...actual }) } : {}),
635
653
  ...(navigationFields.length ? { navigationVerification: { verified: true, ...actualNavigation } } : {}),
636
654
  };
637
655
  }
@@ -643,11 +661,14 @@ async function saveAppSettings(params, authRef) {
643
661
  }
644
662
  return {
645
663
  success: false,
664
+ themeCompletion: getThemeCompletion(params, currentApp, actualApp, themeVerified, navigationVerified),
646
665
  ...(submittedFields ? { submittedFields } : {}),
647
- errorCode: themeVerified ? 'APP_NAVIGATION_NOT_PERSISTED' : 'APP_THEME_NOT_PERSISTED',
648
- errorMsg: t(themeVerified ? 'update_app.navigation_not_persisted' : 'update_app.theme_not_persisted'),
649
- ...(verifyTheme ? { themeVerification: themeVerified
650
- ? { verified: true, ...actual } : { verified: false, expected, actual, readbackError } } : {}),
666
+ errorCode: !themeVerified ? 'APP_THEME_NOT_PERSISTED' : !navigationVerified
667
+ ? 'APP_NAVIGATION_NOT_PERSISTED' : 'APP_THEME_CONFIGURATION_INCOMPLETE',
668
+ errorMsg: t(!themeVerified ? 'update_app.theme_not_persisted' : !navigationVerified
669
+ ? 'update_app.navigation_not_persisted' : 'update_app.theme_configuration_incomplete'),
670
+ ...(verifyTheme ? { themeVerification: themeReadbackEvidence(params, themeVerified
671
+ ? { verified: true, ...actual } : { verified: false, expected, actual, readbackError }) } : {}),
651
672
  ...(navigationFields.length ? { navigationVerification: navigationVerified
652
673
  ? { verified: true, ...actualNavigation }
653
674
  : { verified: false, expected: expectedNavigation, actual: actualNavigation, readbackError } } : {}),
@@ -663,22 +684,25 @@ async function applyCustomThemeUpdate(appType, themeParams, authRef) {
663
684
  assertNavTheme(params.navTheme);
664
685
  assertLogoSource(params.logoSource);
665
686
 
666
- assertCustomThemeOptions(params);
667
- await checkAppStyleInjection(params, authRef);
668
- const themeFile = readThemeCssFile(params.themeFile);
669
- params.themeColor = themeFile.themeColor;
670
- const uploadResponse = await requestWithAutoLogin(
671
- (auth) => uploadCustomThemeFile(appType, params.themeFile, auth),
672
- authRef
673
- );
674
- params.customThemeStyle = buildCustomThemeStyle(uploadResponse);
675
-
687
+ let themeUpload = { requested: true, status: 'not_started', uploadedThisRun: false, appType };
676
688
  try {
677
- return await saveAppSettings(params, authRef);
689
+ assertCustomThemeOptions(params);
690
+ await checkAppStyleInjection(params, authRef);
691
+ readThemeCssFile(params.themeFile);
692
+ // Lost responses leave upload state unknown.
693
+ themeUpload = { ...themeUpload, status: 'unknown', uploadedThisRun: null };
694
+ const uploadResponse = await requestWithAutoLogin(
695
+ (auth) => uploadCustomThemeFile(appType, params.themeFile, auth),
696
+ authRef
697
+ );
698
+ if (uploadResponse.themeUpload) {themeUpload = uploadResponse.themeUpload;}
699
+ params.customThemeStyle = buildCustomThemeStyle(uploadResponse);
700
+ params.themeColor = themeUpload.themeColor;
701
+ return { ...await saveAppSettings(params, authRef), themeUpload };
678
702
  } catch (error) {
679
- error.details = { ...error.details, appType, uploadedResource: params.customThemeStyle,
680
- applicationSettingsVerified: false };
681
- throw error;
703
+ throwCommandError(error.message, { code: error.code || 'APP_THEME_UPDATE_FAILED', exitCode: error.exitCode,
704
+ details: { ...error.details, appType, uploadedResource: params.customThemeStyle,
705
+ ...themeFailureEvidence(themeUpload), applicationSettingsVerified: false } });
682
706
  }
683
707
  }
684
708
 
@@ -767,6 +791,8 @@ async function run(args) {
767
791
  postData
768
792
  );
769
793
  }, authRef);
794
+ const themeUpload = params.themeFile ? response?.themeUpload : noThemeUpload(params.appType);
795
+ if (themeUpload?.uploadedThisRun) {params.themeColor = themeUpload.themeColor;}
770
796
 
771
797
  // Verify the persisted resource before exposing success or a mutation receipt.
772
798
  let nameVerification;
@@ -789,9 +815,11 @@ async function run(args) {
789
815
  success: true,
790
816
  appType: params.appType,
791
817
  themeVerification: response.themeVerification,
818
+ themeCompletion: response.themeCompletion,
819
+ themeUpload,
792
820
  nameVerification,
793
821
  navigationVerification: response.navigationVerification,
794
- ...(params.disableCustomTheme ? { runtimeVerification: { verified: false } } : {}),
822
+ runtimeVerification: { verified: false, status: 'not_checked' },
795
823
  updatedFields: {
796
824
  name: params.name || undefined,
797
825
  desc: params.desc || undefined,
@@ -810,11 +838,15 @@ async function run(args) {
810
838
  const errorMsg = response ? response.errorMsg || t('common.unknown_error') : t('common.request_failed');
811
839
  chalkResult(false, t('update_app.failed', errorMsg));
812
840
  console.log(JSON.stringify({ success: false, appType: params.appType, error: errorMsg,
841
+ themeUpload,
842
+ runtimeVerification: { verified: false, status: 'not_checked' },
813
843
  submittedFields: response && response.submittedFields,
844
+ themeCompletion: response && response.themeCompletion,
814
845
  errorCode: response && response.errorCode, themeVerification: response && response.themeVerification,
815
846
  navigationVerification: response && response.navigationVerification }));
816
847
  throwCommandError(errorMsg, { code: response && response.errorCode,
817
- details: response && (response.errorCode === 'APP_NAVIGATION_NOT_PERSISTED' ? response.navigationVerification : response.themeVerification) });
848
+ details: response && { ...(response.errorCode === 'APP_NAVIGATION_NOT_PERSISTED' ? response.navigationVerification : response.themeVerification),
849
+ themeCompletion: response.themeCompletion, ...themeFailureEvidence(themeUpload) } });
818
850
  }
819
851
  }
820
852
 
@@ -17,7 +17,8 @@ const MAX_RESPONSE_BYTES = 65536;
17
17
  function fail(code) {
18
18
  return new CliError(t('qwenwork.desktop_login_failed'), {
19
19
  code: `QWENWORK_DESKTOP_${code}`,
20
- details: { invocation: buildQwenWorkLoginInvocation() },
20
+ details: { stage: 'authentication', owner: 'openyida', retryable: false,
21
+ nextAction: 'retry_after_host_state_changes', output_visibility: 'internal', invocation: buildQwenWorkLoginInvocation() },
21
22
  });
22
23
  }
23
24
 
@@ -9,7 +9,13 @@ const { exchangeQwenWorkCloudSession } = require('./qwenwork-cloud');
9
9
  const { tokenLogin, tokenRefresh, isRefreshAuthRequired } = require('./token-auth');
10
10
 
11
11
  function failure(code) {
12
- return new CliError(t('qwenwork.login_check_failed'), { code });
12
+ return new CliError(t('qwenwork.login_check_failed'), { code, details: {
13
+ stage: 'authentication', owner: 'openyida',
14
+ retryable: false,
15
+ nextAction: code === 'QWENWORK_LOGIN_IDENTITY_MISMATCH'
16
+ ? 'select_target_organization_in_host_then_retry' : 'retry_after_host_state_changes',
17
+ output_visibility: 'internal',
18
+ } });
13
19
  }
14
20
  function summary(ok, session = {}) { return { ok, status: ok ? 'ok' : 'not_logged_in', can_auto_use: ok, ...(ok ? sessionIdentity(session) : {}) }; }
15
21
 
@@ -72,7 +78,8 @@ async function verify(session, options = {}) {
72
78
  throw failure('QWENWORK_LOGIN_CHECK_FAILED');
73
79
  }
74
80
  if (value.corp_id !== session.corp_id || value.user_id !== session.user_id ||
75
- (options.corpId && value.corp_id !== options.corpId)) { throw failure('QWENWORK_LOGIN_IDENTITY_MISMATCH'); }
81
+ (options.corpId && value.corp_id !== options.corpId) ||
82
+ (options.userId && value.user_id !== options.userId)) { throw failure('QWENWORK_LOGIN_IDENTITY_MISMATCH'); }
76
83
  return true;
77
84
  } catch (error) {
78
85
  if (error instanceof CliError) { throw error; }
@@ -81,7 +88,7 @@ async function verify(session, options = {}) {
81
88
  }
82
89
 
83
90
  /** 网页态优先消费宿主凭据,认证失败回退普通 OAuth;其他宿主保持原流程。 */
84
- async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
91
+ async function performQwenWorkLogin(options = {}, dependencies = {}) {
85
92
  const env = options.env || process.env;
86
93
  const runtime = options.runtime || detectRuntimeCapabilities({ env });
87
94
  if (isQwenWorkWebRuntime(runtime) && !isEnvTokenAuthMode(env) && !hasEnvTokenCredential(env)) {
@@ -110,12 +117,26 @@ async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
110
117
  return summary(true, session);
111
118
  }
112
119
  const resolution = resolveTokenSession(options);
113
- if (resolution.status === 'profile_required') { throw failure('AUTH_PROFILE_REQUIRED'); }
120
+ if (resolution.status === 'profile_required') {
121
+ // 受管桌面的身份由客户端绑定;历史 profile 歧义不能让状态检查跳过宿主签票。
122
+ // 只返回未就绪,不选择凭据、发起 OAuth 或放宽显式身份约束。
123
+ const hostCheck = options.checkOnly && runtime.tool === 'qwenwork' && runtime.subtype === 'qwenwork_desktop' &&
124
+ /^[a-f0-9]{64}$/.test(env.QWENWORK_CLIENT_OPENYIDA_IDENTITY || '') &&
125
+ ![options.authProfile, options.profile, options.corpId, options.userId,
126
+ env.OPENYIDA_AUTH_PROFILE, env.OPENYIDA_AUTH_CORP_ID, env.OPENYIDA_AUTH_USER_ID].some(Boolean);
127
+ if (hostCheck) { return summary(false); }
128
+ throw failure('AUTH_PROFILE_REQUIRED');
129
+ }
130
+ if (runtime.tool === 'qwenwork' && resolution.status === 'profile_not_found' &&
131
+ (options.corpId || options.userId || env.OPENYIDA_AUTH_CORP_ID || env.OPENYIDA_AUTH_USER_ID)) {
132
+ throw failure('QWENWORK_LOGIN_IDENTITY_MISMATCH');
133
+ }
114
134
  let session = loadTokenSession(options);
115
135
  if (session && options.endpoint && new URL(session.base_url).origin !== new URL(options.endpoint).origin) {
116
136
  session = null;
117
137
  }
118
138
  if (await verify(session, options)) { return summary(true, session); }
139
+ if (options.checkOnly) { return summary(false); }
119
140
  if (session?.refresh_token) {
120
141
  try {
121
142
  const refreshed = await (dependencies.refresh || tokenRefresh)(options);
@@ -129,7 +150,6 @@ async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
129
150
  }
130
151
  }
131
152
  }
132
- if (options.checkOnly) { return summary(false); }
133
153
  if (isEnvTokenAuthMode(env) || isQwenWorkWebRuntime(runtime)) { throw failure('QWENWORK_LOGIN_FAILED'); }
134
154
  const result = await (dependencies.login || tokenLogin)(options);
135
155
  if (!result || result.ok === false || !result.access_token || !await verify(result, options)) {
@@ -138,6 +158,21 @@ async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
138
158
  return summary(true, result);
139
159
  }
140
160
 
161
+ async function ensureQwenWorkLogin(options = {}, dependencies = {}) {
162
+ try {
163
+ const result = await performQwenWorkLogin(options, dependencies);
164
+ return result.ok && (options.corpId || options.userId) ? { ...result, target_match: 'matched' } : result;
165
+ } catch (error) {
166
+ const terminal = failure(error instanceof CliError ? error.code : 'QWENWORK_LOGIN_FAILED');
167
+ if (error instanceof CliError) {
168
+ error.details = { ...terminal.details, ...error.details,
169
+ retryable: false, nextAction: terminal.details.nextAction, output_visibility: 'internal' };
170
+ throw error;
171
+ }
172
+ throw terminal;
173
+ }
174
+ }
175
+
141
176
  async function run(args, options = {}) {
142
177
  const result = await ensureQwenWorkLogin({ ...options, checkOnly: args.includes('--check-only') });
143
178
  if (args.includes('--json') && !args.includes('--quiet') && process.env.YIDA_QUIET !== '1') {