openxiangda 1.0.83 → 1.0.84

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/README.md CHANGED
@@ -34,12 +34,15 @@ openxiangda data-view status ticket_with_customer --profile dev
34
34
  openxiangda resource validate --profile dev
35
35
  openxiangda resource plan --profile dev
36
36
  openxiangda resource publish --profile dev
37
+ openxiangda runtime deploy --profile dev
37
38
  openxiangda inspect app --profile dev --json
38
39
  openxiangda app snapshot APP_XXXX --profile dev --json
39
40
  ```
40
41
 
41
42
  User tokens are stored in `~/.openxiangda/profiles.json` with `0600` permissions. Shared workspace environment values, including `APP_OSS_*`, can live in `~/.openxiangda/.env` and are inherited by new workspaces. Project `.env` files still work and override the global defaults. Project state is stored in `.openxiangda/state.json` and contains only profile-specific resource IDs.
42
43
 
44
+ React SPA workspaces publish their frontend with `openxiangda runtime deploy`. The default upload mode is staged multipart: each `dist/` file is uploaded separately, then the CLI submits a release manifest and activates it unless `--no-activate` is passed. Upload progress, timeout, and `traceId` are written to stderr; `--json` stdout remains the final JSON result. Use `--upload-timeout-ms <ms>` or `OPENXIANGDA_RUNTIME_UPLOAD_TIMEOUT_MS` to tune runtime upload/finalize timeouts. Use `--upload-mode legacy-json` only as a compatibility fallback for older platforms.
45
+
43
46
  Feedback can be sent to a DingTalk custom robot from the CLI. AI agents should proactively report platform defects, missing capabilities, unclear design rules, repeated workarounds, implementation uncertainty, and user-visible UX gaps during development. Store the robot settings in `~/.openxiangda/.env`, not in project files:
44
47
 
45
48
  ```env
package/lib/cli.js CHANGED
@@ -35,6 +35,8 @@ const { version: CURRENT_VERSION } = require('../package.json');
35
35
 
36
36
  const NPM_PACKAGE_NAME = 'openxiangda';
37
37
  const OFFICIAL_NPM_REGISTRY = 'https://registry.npmjs.org';
38
+ const RUNTIME_UPLOAD_DEFAULT_TIMEOUT_MS = 120000;
39
+ const RUNTIME_UPLOAD_CONCURRENCY = 3;
38
40
 
39
41
  async function main(argv) {
40
42
  const [command, ...rest] = argv;
@@ -114,7 +116,7 @@ Usage:
114
116
  openxiangda permission form-group-list|form-group-create|form-group-bind
115
117
  openxiangda settings get|save|indexes|indexes-save|data-management|data-management-save|public-access
116
118
  openxiangda resource validate|plan|publish|pull [--profile name] [--json]
117
- openxiangda runtime deploy [--profile name] [--dist dist] [--build-id id] [--no-build] [--no-activate] [--json]
119
+ openxiangda runtime deploy [--profile name] [--dist dist] [--build-id id] [--upload-mode staged|legacy-json] [--upload-timeout-ms ms] [--no-build] [--no-activate] [--json]
118
120
  openxiangda runtime releases [--profile name] [--json]
119
121
  openxiangda runtime activate <releaseId> [--profile name] [--json]
120
122
  openxiangda inspect app|form|workflow|automation|permissions
@@ -2766,30 +2768,57 @@ async function runtime(args) {
2766
2768
  const buildId = normalizeRuntimeBuildId(flags['build-id'] || createRuntimeBuildId());
2767
2769
  const distDir = path.resolve(process.cwd(), flags.dist || 'dist');
2768
2770
  const assetBaseUrl = buildRuntimeAssetBaseUrl(target.appType, buildId);
2771
+ const uploadMode = normalizeRuntimeUploadMode(flags['upload-mode']);
2772
+ const uploadTimeoutMs = normalizeRuntimeUploadTimeoutMs(flags['upload-timeout-ms']);
2773
+ const traceId = createRuntimeTraceId();
2769
2774
  if (!flags['no-build']) {
2770
2775
  runRuntimeBuild({
2771
2776
  buildId,
2772
2777
  appType: target.appType,
2773
2778
  assetBaseUrl,
2774
2779
  command: flags['build-command'],
2780
+ jsonOutput: Boolean(flags.json),
2775
2781
  });
2776
2782
  }
2777
2783
  const files = collectRuntimeDistFiles(distDir, {
2778
2784
  includeSourceMaps: Boolean(flags['include-sourcemaps']),
2779
2785
  });
2780
2786
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
2787
+ printRuntimeProgress(
2788
+ `runtime release upload: mode=${uploadMode} traceId=${traceId} files=${files.length} size=${formatBytes(totalBytes)} timeout=${uploadTimeoutMs}ms`
2789
+ );
2790
+ const releaseFiles =
2791
+ uploadMode === 'legacy-json'
2792
+ ? files.map(file => ({
2793
+ path: file.path,
2794
+ size: file.size,
2795
+ sha256: file.sha256,
2796
+ contentType: file.contentType,
2797
+ contentBase64: file.buffer.toString('base64'),
2798
+ }))
2799
+ : await uploadRuntimeDistFilesStaged({
2800
+ config,
2801
+ profileName: target.profileName,
2802
+ appType: target.appType,
2803
+ buildId,
2804
+ files,
2805
+ traceId,
2806
+ timeoutMs: uploadTimeoutMs,
2807
+ });
2781
2808
  const data = await requestWithAuth(
2782
2809
  config,
2783
2810
  target.profileName,
2784
2811
  `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/runtime/releases`,
2785
2812
  {
2786
2813
  method: 'POST',
2814
+ timeoutMs: uploadTimeoutMs,
2815
+ headers: { 'x-openxiangda-trace-id': traceId },
2787
2816
  body: {
2788
2817
  buildId,
2789
2818
  version: flags.version || readWorkspaceVersion(),
2790
2819
  releaseNotes: flags.notes || flags['release-notes'] || '',
2791
2820
  activate: !flags['no-activate'],
2792
- files,
2821
+ files: releaseFiles,
2793
2822
  },
2794
2823
  }
2795
2824
  );
@@ -2803,6 +2832,8 @@ async function runtime(args) {
2803
2832
  totalBytes,
2804
2833
  assetBaseUrl,
2805
2834
  activated: !flags['no-activate'],
2835
+ uploadMode,
2836
+ traceId,
2806
2837
  };
2807
2838
  if (flags.json) return writeJson(result);
2808
2839
  print(
@@ -2810,6 +2841,8 @@ async function runtime(args) {
2810
2841
  `runtime release 已上传: ${target.appType}`,
2811
2842
  `build: ${buildId}`,
2812
2843
  `files: ${files.length} (${formatBytes(totalBytes)})`,
2844
+ `upload: ${uploadMode}`,
2845
+ `traceId: ${traceId}`,
2813
2846
  `assetBase: ${assetBaseUrl}`,
2814
2847
  `active: ${result.activated ? 'yes' : 'no'}`,
2815
2848
  ].join('\n')
@@ -2822,11 +2855,16 @@ async function runtime(args) {
2822
2855
 
2823
2856
  function runRuntimeBuild(options) {
2824
2857
  const command = options.command || defaultRuntimeBuildCommand();
2825
- print(`构建 React SPA runtime: ${command}`);
2858
+ if (options.jsonOutput) {
2859
+ printRuntimeProgress(`构建 React SPA runtime: ${command}`);
2860
+ } else {
2861
+ print(`构建 React SPA runtime: ${command}`);
2862
+ }
2826
2863
  const result = spawnSync(command, [], {
2827
2864
  cwd: process.cwd(),
2828
2865
  shell: true,
2829
- stdio: 'inherit',
2866
+ stdio: options.jsonOutput ? 'pipe' : 'inherit',
2867
+ encoding: options.jsonOutput ? 'utf8' : undefined,
2830
2868
  env: {
2831
2869
  ...process.env,
2832
2870
  OPENXIANGDA_APP_TYPE: options.appType,
@@ -2835,6 +2873,10 @@ function runRuntimeBuild(options) {
2835
2873
  OPENXIANGDA_RUNTIME_ASSET_BASE: options.assetBaseUrl,
2836
2874
  },
2837
2875
  });
2876
+ if (options.jsonOutput) {
2877
+ if (result.stdout) process.stderr.write(maskText(result.stdout));
2878
+ if (result.stderr) process.stderr.write(maskText(result.stderr));
2879
+ }
2838
2880
  if (result.error) fail(`runtime build 无法启动: ${result.error.message}`);
2839
2881
  if (result.status !== 0) fail(`runtime build 失败: exit ${result.status}`);
2840
2882
  }
@@ -2856,7 +2898,7 @@ function collectRuntimeDistFiles(distDir, options = {}) {
2856
2898
  }
2857
2899
  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
2858
2900
  if (totalBytes > 25 * 1024 * 1024) {
2859
- fail(`runtime dist 过大: ${formatBytes(totalBytes)},当前 JSON 发布通道上限为 25MB`);
2901
+ fail(`runtime dist 过大: ${formatBytes(totalBytes)},当前 runtime 发布通道上限为 25MB`);
2860
2902
  }
2861
2903
  return files;
2862
2904
  }
@@ -2878,11 +2920,112 @@ function walkRuntimeDist(rootDir, currentDir, files, options) {
2878
2920
  size: buffer.length,
2879
2921
  sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
2880
2922
  contentType: inferRuntimeContentType(relative),
2881
- contentBase64: buffer.toString('base64'),
2923
+ buffer,
2882
2924
  });
2883
2925
  }
2884
2926
  }
2885
2927
 
2928
+ async function uploadRuntimeDistFilesStaged(options) {
2929
+ const files = options.files || [];
2930
+ let completed = 0;
2931
+ const results = await runWithConcurrency(
2932
+ files,
2933
+ RUNTIME_UPLOAD_CONCURRENCY,
2934
+ async file => {
2935
+ const uploaded = await uploadRuntimeDistFile(options, file);
2936
+ completed += 1;
2937
+ printRuntimeProgress(
2938
+ `runtime file uploaded [${completed}/${files.length}] ${file.path} ${formatBytes(file.size)} traceId=${options.traceId}`
2939
+ );
2940
+ return uploaded;
2941
+ }
2942
+ );
2943
+ return results.map(file => ({
2944
+ path: file.path,
2945
+ size: file.size,
2946
+ sha256: file.sha256,
2947
+ contentType: file.contentType,
2948
+ }));
2949
+ }
2950
+
2951
+ async function uploadRuntimeDistFile(options, file) {
2952
+ const apiPath = apiPathWithQuery(
2953
+ `/openxiangda-api/v1/apps/${encodeURIComponent(options.appType)}/runtime/releases/files`,
2954
+ {
2955
+ buildId: options.buildId,
2956
+ path: file.path,
2957
+ size: file.size,
2958
+ sha256: file.sha256,
2959
+ contentType: file.contentType,
2960
+ }
2961
+ );
2962
+ return await requestFormWithAuth(
2963
+ options.config,
2964
+ options.profileName,
2965
+ apiPath,
2966
+ () => {
2967
+ const form = new FormData();
2968
+ form.append(
2969
+ 'file',
2970
+ new Blob([file.buffer], { type: file.contentType }),
2971
+ path.basename(file.path)
2972
+ );
2973
+ return form;
2974
+ },
2975
+ {
2976
+ timeoutMs: options.timeoutMs,
2977
+ headers: { 'x-openxiangda-trace-id': options.traceId },
2978
+ }
2979
+ );
2980
+ }
2981
+
2982
+ async function runWithConcurrency(items, concurrency, worker) {
2983
+ const results = new Array(items.length);
2984
+ let nextIndex = 0;
2985
+ let firstError = null;
2986
+ const workers = Array.from(
2987
+ { length: Math.min(concurrency, items.length) },
2988
+ async () => {
2989
+ while (nextIndex < items.length && !firstError) {
2990
+ const index = nextIndex;
2991
+ nextIndex += 1;
2992
+ try {
2993
+ results[index] = await worker(items[index], index);
2994
+ } catch (error) {
2995
+ firstError = error;
2996
+ }
2997
+ }
2998
+ }
2999
+ );
3000
+ await Promise.all(workers);
3001
+ if (firstError) throw firstError;
3002
+ return results;
3003
+ }
3004
+
3005
+ function normalizeRuntimeUploadMode(value) {
3006
+ const normalized = String(value || 'staged').trim().toLowerCase();
3007
+ if (normalized === 'staged' || normalized === 'legacy-json') return normalized;
3008
+ fail(`--upload-mode 只支持 staged 或 legacy-json,当前: ${value}`);
3009
+ }
3010
+
3011
+ function normalizeRuntimeUploadTimeoutMs(value) {
3012
+ const candidate =
3013
+ value === undefined || value === null || value === ''
3014
+ ? process.env.OPENXIANGDA_RUNTIME_UPLOAD_TIMEOUT_MS
3015
+ : value;
3016
+ const parsed = Number(candidate);
3017
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
3018
+ return RUNTIME_UPLOAD_DEFAULT_TIMEOUT_MS;
3019
+ }
3020
+
3021
+ function createRuntimeTraceId() {
3022
+ return `oxd-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
3023
+ }
3024
+
3025
+ function printRuntimeProgress(message) {
3026
+ process.stderr.write(`${maskText(message)}\n`);
3027
+ }
3028
+
2886
3029
  function buildRuntimeAssetBaseUrl(appType, buildId) {
2887
3030
  return `/service/openxiangda-api/v1/apps/${encodeURIComponent(appType)}/runtime/releases/by-build/${encodeURIComponent(buildId)}/files/`;
2888
3031
  }
@@ -7087,7 +7230,7 @@ async function requestWithAuth(config, profileName, apiPath, options = {}) {
7087
7230
  }
7088
7231
  }
7089
7232
 
7090
- async function requestFormWithAuth(config, profileName, apiPath, formDataFactory) {
7233
+ async function requestFormWithAuth(config, profileName, apiPath, formDataFactory, options = {}) {
7091
7234
  const resolved = getProfile(config, profileName);
7092
7235
  const profile = resolved.profile;
7093
7236
  if (!profile.token?.accessToken) {
@@ -7099,7 +7242,8 @@ async function requestFormWithAuth(config, profileName, apiPath, formDataFactory
7099
7242
  profile.baseUrl,
7100
7243
  apiPath,
7101
7244
  formDataFactory(),
7102
- profile.token.accessToken
7245
+ profile.token.accessToken,
7246
+ options
7103
7247
  );
7104
7248
  return unwrapApi(payload);
7105
7249
  } catch (error) {
@@ -7111,25 +7255,41 @@ async function requestFormWithAuth(config, profileName, apiPath, formDataFactory
7111
7255
  profile.baseUrl,
7112
7256
  apiPath,
7113
7257
  formDataFactory(),
7114
- profile.token.accessToken
7258
+ profile.token.accessToken,
7259
+ options
7115
7260
  );
7116
7261
  return unwrapApi(payload);
7117
7262
  }
7118
7263
  }
7119
7264
 
7120
- async function requestForm(baseUrl, apiPath, formData, accessToken) {
7265
+ async function requestForm(baseUrl, apiPath, formData, accessToken, options = {}) {
7121
7266
  if (typeof fetch !== 'function') {
7122
7267
  throw new Error('当前 Node.js 版本不支持 fetch,请使用 Node.js 18 或更高版本');
7123
7268
  }
7124
7269
  const url = `${baseUrl.replace(/\/+$/, '')}${apiPath}`;
7125
- const response = await fetch(url, {
7126
- method: 'POST',
7127
- headers: {
7128
- accept: 'application/json',
7129
- authorization: `Bearer ${accessToken}`,
7130
- },
7131
- body: formData,
7132
- });
7270
+ const timeoutMs = normalizeRuntimeUploadTimeoutMs(options.timeoutMs);
7271
+ const controller = new AbortController();
7272
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7273
+ let response;
7274
+ try {
7275
+ response = await fetch(url, {
7276
+ method: 'POST',
7277
+ headers: {
7278
+ accept: 'application/json',
7279
+ authorization: `Bearer ${accessToken}`,
7280
+ ...(options.headers || {}),
7281
+ },
7282
+ body: formData,
7283
+ signal: controller.signal,
7284
+ });
7285
+ } catch (error) {
7286
+ if (isAbortError(error)) {
7287
+ throw new Error(maskText(`HTTP form request timed out after ${timeoutMs}ms: ${apiPath}`));
7288
+ }
7289
+ throw error;
7290
+ } finally {
7291
+ clearTimeout(timer);
7292
+ }
7133
7293
  const text = await response.text();
7134
7294
  let payload = null;
7135
7295
  if (text) {
@@ -7146,6 +7306,13 @@ async function requestForm(baseUrl, apiPath, formData, accessToken) {
7146
7306
  return payload;
7147
7307
  }
7148
7308
 
7309
+ function isAbortError(error) {
7310
+ return (
7311
+ error?.name === 'AbortError' ||
7312
+ String(error?.message || '').toLowerCase().includes('aborted')
7313
+ );
7314
+ }
7315
+
7149
7316
  async function refreshProfile(config, profileName) {
7150
7317
  const { profile } = getProfile(config, profileName);
7151
7318
  if (!profile.token?.refreshToken) {
package/lib/http.js CHANGED
@@ -1,11 +1,16 @@
1
1
  const { maskText } = require('./utils');
2
2
 
3
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
4
+
3
5
  async function requestJson(baseUrl, apiPath, options = {}) {
4
6
  if (typeof fetch !== 'function') {
5
7
  throw new Error('当前 Node.js 版本不支持 fetch,请使用 Node.js 18 或更高版本');
6
8
  }
7
9
 
8
10
  const url = `${baseUrl.replace(/\/+$/, '')}${apiPath}`;
11
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
9
14
  const headers = {
10
15
  accept: 'application/json',
11
16
  ...(options.headers || {}),
@@ -17,12 +22,23 @@ async function requestJson(baseUrl, apiPath, options = {}) {
17
22
  headers.authorization = `Bearer ${options.accessToken}`;
18
23
  }
19
24
 
20
- const response = await fetch(url, {
21
- method: options.method || 'GET',
22
- headers,
23
- body:
24
- options.body === undefined ? undefined : JSON.stringify(options.body),
25
- });
25
+ let response;
26
+ try {
27
+ response = await fetch(url, {
28
+ method: options.method || 'GET',
29
+ headers,
30
+ body:
31
+ options.body === undefined ? undefined : JSON.stringify(options.body),
32
+ signal: controller.signal,
33
+ });
34
+ } catch (error) {
35
+ if (isAbortError(error)) {
36
+ throw new Error(maskText(`HTTP request timed out after ${timeoutMs}ms: ${apiPath}`));
37
+ }
38
+ throw error;
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
26
42
 
27
43
  const text = await response.text();
28
44
  let payload = null;
@@ -42,6 +58,19 @@ async function requestJson(baseUrl, apiPath, options = {}) {
42
58
  return payload;
43
59
  }
44
60
 
61
+ function normalizeTimeoutMs(value, fallback) {
62
+ const parsed = Number(value);
63
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
64
+ return fallback;
65
+ }
66
+
67
+ function isAbortError(error) {
68
+ return (
69
+ error?.name === 'AbortError' ||
70
+ String(error?.message || '').toLowerCase().includes('aborted')
71
+ );
72
+ }
73
+
45
74
  module.exports = {
46
75
  requestJson,
47
76
  };
@@ -37,6 +37,7 @@ OpenXiangda supports two workspace modes. Classic `sy-lowcode-app-workspace` pub
37
37
  ### Hard rules — always
38
38
 
39
39
  - ✅ Publish classic workspaces through `openxiangda workspace publish --profile <name>`; publish React SPA workspaces through `openxiangda resource publish` + `openxiangda runtime deploy`.
40
+ - ✅ `runtime deploy` defaults to staged multipart `dist/` uploads plus a final manifest. Use `--upload-mode legacy-json` only for old platform servers.
40
41
  - ✅ User token lives in `~/.openxiangda/profiles.json`; project state in `.openxiangda/state.json` (IDs only).
41
42
  - ✅ Each profile (dev / prod / ...) has its own `appType` and resource IDs; never copy a `formUuid` / `pageId` / `workflowId` / `automationId` across profiles.
42
43
  - ✅ Run `openxiangda update check --json` at the start of substantial work; if `updateAvailable`, run `openxiangda update install` and `openxiangda skill install --force`.
@@ -64,7 +64,7 @@ openxiangda resource publish --profile <name>
64
64
  openxiangda runtime deploy --profile <name>
65
65
  ```
66
66
 
67
- `runtime deploy` builds and activates the app-level SPA release for `/view/:appType/*`.
67
+ `runtime deploy` builds and activates the app-level SPA release for `/view/:appType/*`. It uploads `dist/` through staged multipart file uploads by default; use `--upload-mode legacy-json` only as an older-server fallback.
68
68
  Publish React SPA forms separately with `openxiangda workspace publish --form <formCode>`;
69
69
  with `runtimeMode: "react-spa"` this is schema-only and does not require OSS.
70
70
 
@@ -43,7 +43,7 @@ openxiangda resource publish --profile <name>
43
43
  openxiangda runtime deploy --profile <name>
44
44
  ```
45
45
 
46
- `runtime deploy` builds the Vite app with a release-specific asset base, uploads `dist/` to the platform runtime release store, and activates the release unless `--no-activate` is passed.
46
+ `runtime deploy` builds the Vite app with a release-specific asset base, uploads `dist/` to the platform runtime release store, and activates the release unless `--no-activate` is passed. The default upload mode is staged multipart file upload plus a final release manifest; use `--upload-mode legacy-json` only for older platform servers that have not been upgraded.
47
47
 
48
48
  For React SPA forms, still use targeted form publish to create/bind the form and
49
49
  sync schema:
@@ -282,8 +282,11 @@ openxiangda runtime deploy --profile prod
282
282
  It is responsible for:
283
283
 
284
284
  1. Running the workspace build command with `OPENXIANGDA_APP_TYPE`, `OPENXIANGDA_BUILD_ID`, and `OPENXIANGDA_RUNTIME_ASSET_BASE`.
285
- 2. Uploading `dist/` to `/openxiangda-api/v1/apps/:appType/runtime/releases`.
286
- 3. Activating the new runtime release so `/view/:appType/*` enters the React app.
285
+ 2. Uploading each `dist/` file to `/openxiangda-api/v1/apps/:appType/runtime/releases/files` with a shared trace id.
286
+ 3. Submitting the final release manifest to `/openxiangda-api/v1/apps/:appType/runtime/releases` without inline base64 content.
287
+ 4. Activating the new runtime release so `/view/:appType/*` enters the React app.
288
+
289
+ Runtime upload/finalize timeout defaults to 120s. Override with `--upload-timeout-ms <ms>` or `OPENXIANGDA_RUNTIME_UPLOAD_TIMEOUT_MS`; progress and `traceId` are written to stderr, while `--json` keeps stdout as final JSON only.
287
290
 
288
291
  ## References
289
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.83",
3
+ "version": "1.0.84",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {