openxiangda 1.0.162 → 1.0.163

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
@@ -119,6 +119,8 @@ openxiangda resource publish function --code customer_get --change <change> --pr
119
119
  --replace-manifest --reason "reviewed manifest is the complete desired definition"
120
120
  ```
121
121
 
122
+ Exact `--only` / `--code` selectors are applied before unrelated manifests, source dependencies, and JS_CODE targets are read or built. Shared/transitive dependencies of the selected targets remain in scope; omitting a selector intentionally preserves full-workspace validation and planning. Resource commands use the packaged canonical scoped builder for standard workspaces, so an older checked-in `scripts/build-js-code.mjs` does not need to be upgraded before the installed CLI gains this optimization; refresh the workspace template only when developers also need the same behavior from a manual `pnpm build-js-code` command.
123
+
122
124
  For source-only Function/Automation changes, the final command does not reconstruct whole definitions with client-side GET+PUT. `release begin` captures the Git/change baseline, one preflight covers every selected code, and Backend Release performs one `prepare -> verify -> activate` sequence for all eligible updates. Inspect history with `openxiangda release backend-head|backend-list|backend-detail`, compare it with `backend-diff`, or create an audited immutable rollback with `backend-rollback <releaseId> --change <change> --reason "..."`. Always run `release end` when promotion finishes or is abandoned.
123
125
 
124
126
  Page repair publishing is staged by default. It first freezes `pages/snapshot`, sends the active Page Release parent plus every page revision, and uses revision `0` only for a genuinely new page. Review with `openxiangda page head|releases|detail|diff`; activate an immutable complete release explicitly with `page activate <releaseId> --change <change>`. Historical activation requires `page rollback <releaseId> --rollback --change <change> --reason "..."`. Parent or revision conflicts are never refreshed or retried automatically.
package/lib/cli.js CHANGED
@@ -6931,7 +6931,7 @@ async function resource(args) {
6931
6931
  ' - publish --dry-run 等价于发布前计划,不会写平台资源。',
6932
6932
  ' - 在线 publish 必须用 --only/--code 精确选择;真正全量发布必须显式 --all --reason,SDD bypass 不能绕过此范围门。',
6933
6933
  ' - 源码依赖触发的 Function/Automation 更新默认只替换 source snapshot,保留线上 bindings/schema/metadata;显式替换 manifest 必须加 --replace-manifest --reason。',
6934
- ' - --only/--code 在读取 manifest 后立即按逻辑资源 code 过滤;也支持 function:foo 这类带类型 selector。',
6934
+ ' - --only/--code 会在读取/分析 manifest 与构建源码前按逻辑资源 code 早期收窄;也支持 function:foo 这类带类型 selector。',
6935
6935
  ' - type 可选: workflow, notification, route, menu, role, permission, data-view, function, connector 等。',
6936
6936
  ' - plan/publish 会在 stderr 输出阶段进度,超过 15 秒持续心跳;JSON 结果包含 timings,不污染 stdout。',
6937
6937
  ' - plan/publish --dry-run 严格只允许 GET/HEAD;READ_ONLY_AUTH_REQUIRED 时先执行 auth refresh 或重新登录。',
@@ -9539,6 +9539,12 @@ function resourceCodeSelectorMatches(selector, resourceKey, item) {
9539
9539
  return getResourceItemCode(item) === selector.code;
9540
9540
  }
9541
9541
 
9542
+ function resourceCodeSelectorAppliesToKey(selector, resourceKey) {
9543
+ if (selector.key) return selector.key === resourceKey;
9544
+ if (selector.keys) return selector.keys.includes(resourceKey);
9545
+ return true;
9546
+ }
9547
+
9542
9548
  function normalizeResourceTypeAlias(value) {
9543
9549
  return String(value || '')
9544
9550
  .trim()
@@ -9569,21 +9575,40 @@ function loadWorkspaceResources(options = {}) {
9569
9575
  }
9570
9576
  : {}),
9571
9577
  };
9578
+ if (codeFilters) {
9579
+ Object.defineProperty(manifest, '__resourceCodeSelectors', {
9580
+ value: codeFilters,
9581
+ configurable: true,
9582
+ enumerable: false,
9583
+ writable: false,
9584
+ });
9585
+ }
9572
9586
  for (const spec of RESOURCE_SPECS) {
9573
- const items = !typeFilters || typeFilters.has(spec.key)
9574
- ? readResourceItems(baseDir, spec)
9575
- : [];
9576
- manifest[spec.key] = codeFilters
9577
- ? items.filter(item => {
9578
- let matched = false;
9579
- for (let index = 0; index < codeFilters.length; index += 1) {
9580
- if (!resourceCodeSelectorMatches(codeFilters[index], spec.key, item)) continue;
9581
- matchedCodeFilters.add(index);
9582
- matched = true;
9583
- }
9584
- return matched;
9585
- })
9586
- : items;
9587
+ if (typeFilters && !typeFilters.has(spec.key)) {
9588
+ manifest[spec.key] = [];
9589
+ continue;
9590
+ }
9591
+ if (!codeFilters) {
9592
+ manifest[spec.key] = readResourceItems(baseDir, spec);
9593
+ continue;
9594
+ }
9595
+
9596
+ const selectorEntries = codeFilters
9597
+ .map((selector, index) => ({ selector, index }))
9598
+ .filter(({ selector }) => resourceCodeSelectorAppliesToKey(selector, spec.key));
9599
+ if (selectorEntries.length === 0) {
9600
+ manifest[spec.key] = [];
9601
+ continue;
9602
+ }
9603
+ manifest[spec.key] = readResourceItems(baseDir, spec, {
9604
+ selectorEntries,
9605
+ matchedCodeFilters,
9606
+ // Composite menu/notification files may expose several logical codes
9607
+ // whose children cannot be located from the filename alone. They still
9608
+ // filter before validation/source analysis; exact Function/Automation
9609
+ // manifests use the fail-safe early file path below.
9610
+ selectorFileScope: !['menus', 'notifications'].includes(spec.key),
9611
+ });
9587
9612
  }
9588
9613
  if (codeFilters) {
9589
9614
  manifest.unmatchedResourceCodeFilters = codeFilters
@@ -9593,19 +9618,63 @@ function loadWorkspaceResources(options = {}) {
9593
9618
  return manifest;
9594
9619
  }
9595
9620
 
9596
- function readResourceItems(baseDir, spec) {
9621
+ function readResourceItems(baseDir, spec, options = {}) {
9597
9622
  const items = [];
9623
+ const selectorEntries = options.selectorEntries || null;
9624
+ const matchedSelectorIndexes = new Set();
9625
+ const visitedFiles = new Set();
9626
+ const appendFile = filePath => {
9627
+ if (visitedFiles.has(filePath)) return;
9628
+ visitedFiles.add(filePath);
9629
+ const fileItems = readResourceItemsFromFile(filePath, spec);
9630
+ if (!selectorEntries) {
9631
+ items.push(...fileItems);
9632
+ return;
9633
+ }
9634
+ for (const item of fileItems) {
9635
+ let matched = false;
9636
+ for (const { selector, index } of selectorEntries) {
9637
+ if (!resourceCodeSelectorMatches(selector, spec.key, item)) continue;
9638
+ options.matchedCodeFilters?.add(index);
9639
+ matchedSelectorIndexes.add(index);
9640
+ matched = true;
9641
+ }
9642
+ if (matched) items.push(item);
9643
+ }
9644
+ };
9598
9645
  for (const relativeFile of spec.topFiles || []) {
9599
9646
  const filePath = path.join(baseDir, relativeFile);
9600
9647
  if (fs.existsSync(filePath)) {
9601
- items.push(...readResourceItemsFromFile(filePath, spec));
9648
+ appendFile(filePath);
9602
9649
  }
9603
9650
  }
9604
9651
 
9605
9652
  const dirPath = path.join(baseDir, spec.dir);
9606
9653
  if (fs.existsSync(dirPath)) {
9607
- for (const filePath of listResourceJsonFiles(dirPath, spec)) {
9608
- items.push(...readResourceItemsFromFile(filePath, spec));
9654
+ if (selectorEntries && options.selectorFileScope !== false) {
9655
+ const remaining = () => selectorEntries.filter(
9656
+ ({ index }) => !matchedSelectorIndexes.has(index)
9657
+ );
9658
+ for (const filePath of listSelectedResourceJsonFiles(
9659
+ dirPath,
9660
+ spec,
9661
+ remaining().map(({ selector }) => selector.code)
9662
+ )) {
9663
+ appendFile(filePath);
9664
+ }
9665
+ if (remaining().length > 0) {
9666
+ // A direct path is only a hint. The file may declare another explicit
9667
+ // code, while the requested logical resource lives in an arbitrarily
9668
+ // named legacy bundle. Fall back only when no selected item actually
9669
+ // matched, preserving the standard-layout fast path.
9670
+ for (const filePath of listResourceJsonFiles(dirPath, spec)) {
9671
+ appendFile(filePath);
9672
+ }
9673
+ }
9674
+ } else {
9675
+ for (const filePath of listResourceJsonFiles(dirPath, spec)) {
9676
+ appendFile(filePath);
9677
+ }
9609
9678
  }
9610
9679
  }
9611
9680
  return items;
@@ -9754,6 +9823,36 @@ function listResourceJsonFiles(dirPath, spec) {
9754
9823
  );
9755
9824
  }
9756
9825
 
9826
+ function listSelectedResourceJsonFiles(dirPath, spec, selectedCodes = []) {
9827
+ const codeSet = new Set(selectedCodes.map(code => String(code || '').trim()).filter(Boolean));
9828
+ if (codeSet.size === 0) return [];
9829
+
9830
+ const directCandidates = new Set();
9831
+ for (const code of codeSet) {
9832
+ if (
9833
+ code === '.' ||
9834
+ code === '..' ||
9835
+ code.includes('/') ||
9836
+ code.includes('\\')
9837
+ ) {
9838
+ continue;
9839
+ }
9840
+ const directFile = path.join(dirPath, `${code}.json`);
9841
+ if (fs.existsSync(directFile) && fs.statSync(directFile).isFile()) {
9842
+ directCandidates.add(directFile);
9843
+ }
9844
+ const directDir = path.join(dirPath, code);
9845
+ if (fs.existsSync(directDir) && fs.statSync(directDir).isDirectory()) {
9846
+ for (const filePath of listJsonFiles(directDir)) {
9847
+ if (shouldReadResourceJsonFile(filePath, dirPath, spec)) {
9848
+ directCandidates.add(filePath);
9849
+ }
9850
+ }
9851
+ }
9852
+ }
9853
+ return Array.from(directCandidates).sort();
9854
+ }
9855
+
9757
9856
  function shouldReadResourceJsonFile(filePath, dirPath, spec) {
9758
9857
  if (spec.key !== 'workflows') return true;
9759
9858
  const relativeParts = path.relative(dirPath, filePath).split(path.sep);
@@ -10987,7 +11086,7 @@ async function publishResourcesForWorkspace(config, profileName, options = {}) {
10987
11086
 
10988
11087
  async function buildResourcePlan(config, target, manifest) {
10989
11088
  return await withReadOnlyHttpGuard('resource plan', async () => {
10990
- prepareManifestJsCodeBundlesForPlan(manifest);
11089
+ await prepareManifestJsCodeBundlesForPlan(manifest);
10991
11090
  const existing = await fetchExistingResourceMaps(config, target, manifest);
10992
11091
  const actions = [];
10993
11092
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
@@ -11024,7 +11123,7 @@ async function buildResourcePlan(config, target, manifest) {
11024
11123
  });
11025
11124
  }
11026
11125
 
11027
- function prepareManifestJsCodeBundlesForPlan(manifest) {
11126
+ async function prepareManifestJsCodeBundlesForPlan(manifest) {
11028
11127
  const targetsByWorkspace = new Map();
11029
11128
  const seenObjects = new Set();
11030
11129
 
@@ -11079,21 +11178,45 @@ function prepareManifestJsCodeBundlesForPlan(manifest) {
11079
11178
  };
11080
11179
 
11081
11180
  for (const spec of RESOURCE_SPECS) {
11082
- visit(manifest[spec.key] || [], manifest.baseDir);
11181
+ const selectors = manifest.__resourceCodeSelectors || null;
11182
+ const items = selectors
11183
+ ? (manifest[spec.key] || []).filter(item =>
11184
+ selectors.some(selector =>
11185
+ resourceCodeSelectorMatches(selector, spec.key, item)
11186
+ )
11187
+ )
11188
+ : manifest[spec.key] || [];
11189
+ visit(items, manifest.baseDir);
11083
11190
  }
11084
11191
 
11085
11192
  for (const [workspaceRoot, sourceMap] of targetsByWorkspace.entries()) {
11086
11193
  const sourceInfos = Array.from(sourceMap.values());
11087
11194
  if (sourceInfos.length === 0) continue;
11088
- const result = runWorkspaceJsCodeBuildBatch(
11195
+ let result = runWorkspaceJsCodeBuildBatch(
11089
11196
  workspaceRoot,
11090
11197
  sourceInfos.map(item => ({
11091
11198
  sourceKind: item.sourceKind,
11092
11199
  scriptCode: item.scriptCode,
11093
11200
  }))
11094
11201
  );
11095
- if (result.stdout) process.stderr.write(result.stdout);
11096
- if (result.stderr) process.stderr.write(result.stderr);
11202
+ if (result.status !== 0) {
11203
+ const legacyFallback = result.openxiangdaBuildMode === 'workspace-script'
11204
+ ? await tryBuildFunctionSourcesWithLegacyWorkspaceBuilder(
11205
+ workspaceRoot,
11206
+ sourceInfos
11207
+ )
11208
+ : { ok: false, result: null };
11209
+ if (legacyFallback.ok) {
11210
+ result = { status: 0, stdout: '', stderr: '' };
11211
+ } else {
11212
+ const failure = legacyFallback.result || result;
11213
+ if (failure.stdout) process.stderr.write(failure.stdout);
11214
+ if (failure.stderr) process.stderr.write(failure.stderr);
11215
+ }
11216
+ } else {
11217
+ if (result.stdout) process.stderr.write(result.stdout);
11218
+ if (result.stderr) process.stderr.write(result.stderr);
11219
+ }
11097
11220
  if (result.status !== 0) {
11098
11221
  fail(
11099
11222
  `JS_CODE batch bundle构建失败,无法生成资源计划: ${sourceInfos
@@ -11112,6 +11235,48 @@ function prepareManifestJsCodeBundlesForPlan(manifest) {
11112
11235
  }
11113
11236
  }
11114
11237
 
11238
+ async function tryBuildFunctionSourcesWithLegacyWorkspaceBuilder(
11239
+ workspaceRoot,
11240
+ sourceInfos
11241
+ ) {
11242
+ if (
11243
+ sourceInfos.length === 0 ||
11244
+ sourceInfos.some(item => item.sourceKind !== 'functions')
11245
+ ) {
11246
+ return { ok: false, result: null };
11247
+ }
11248
+
11249
+ let usedLegacyBuilder = false;
11250
+ for (const sourceInfo of sourceInfos) {
11251
+ const result = runWorkspaceJsCodeBuild(
11252
+ workspaceRoot,
11253
+ sourceInfo.scriptCode,
11254
+ sourceInfo.sourceKind
11255
+ );
11256
+ if (result.status === 0) {
11257
+ usedLegacyBuilder = true;
11258
+ if (result.stdout) process.stderr.write(result.stdout);
11259
+ if (result.stderr) process.stderr.write(result.stderr);
11260
+ continue;
11261
+ }
11262
+ if (!isUnsupportedFunctionsBuildScript(result)) {
11263
+ return { ok: false, result };
11264
+ }
11265
+ warn(
11266
+ `当前工作区 scripts/build-js-code.mjs 不支持 App Function source=functions,已使用 openxiangda 内置构建器兼容构建 ${sourceInfo.scriptCode}`
11267
+ );
11268
+ await buildFunctionSourceWithBundledEsbuild(
11269
+ sourceInfo.entryPath,
11270
+ workspaceRoot,
11271
+ sourceInfo.scriptCode
11272
+ );
11273
+ }
11274
+ if (usedLegacyBuilder) {
11275
+ warn('当前工作区 build-js-code 不支持批量 --scripts,已兼容使用逐 Function 构建');
11276
+ }
11277
+ return { ok: true, result: null };
11278
+ }
11279
+
11115
11280
  function buildResourcePlanActionIndex(actions = []) {
11116
11281
  const index = new Map();
11117
11282
  for (const action of actions || []) {
@@ -17034,6 +17199,7 @@ function getJsCodeSourceInfoForPlan(localPath) {
17034
17199
  workspaceRoot,
17035
17200
  sourceKind,
17036
17201
  scriptCode,
17202
+ entryPath: localPath,
17037
17203
  bundlePath: path.join(workspaceRoot, 'dist', sourceKind, scriptCode, 'index.cjs'),
17038
17204
  };
17039
17205
  }
@@ -1,6 +1,16 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
1
3
  const { spawnSync } = require('child_process');
2
4
 
3
5
  const JS_CODE_SOURCE_KINDS = new Set(['js-code-nodes', 'automations', 'functions']);
6
+ const CANONICAL_BUILD_SCRIPT = path.join(
7
+ __dirname,
8
+ '..',
9
+ 'templates',
10
+ 'openxiangda-react-spa',
11
+ 'scripts',
12
+ 'build-js-code.mjs'
13
+ );
4
14
 
5
15
  function normalizeJsCodeBuildTargets(targets = []) {
6
16
  const normalized = [];
@@ -42,20 +52,122 @@ function buildJsCodeBatchArgs(targets, options = {}) {
42
52
  ];
43
53
  }
44
54
 
55
+ function readWorkspacePackage(workspaceRoot) {
56
+ try {
57
+ return JSON.parse(
58
+ fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')
59
+ );
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function isStandardWorkspaceJsCodeBuilder(workspaceRoot) {
66
+ const command = String(
67
+ readWorkspacePackage(workspaceRoot)?.scripts?.['build-js-code'] || ''
68
+ ).trim().replace(/\\/g, '/');
69
+ if (!command) return true;
70
+ // Only bypass the exact template-shaped command. Extra flags, command
71
+ // chaining, or wrappers may be meaningful custom preprocessing and must keep
72
+ // using the workspace builder.
73
+ const matched = command.match(
74
+ /^(?:node\s+)?((?:\.\/)?scripts\/build-js-code\.(?:mjs|cjs|js))$/
75
+ );
76
+ if (!matched) return false;
77
+ try {
78
+ const source = fs.readFileSync(path.resolve(workspaceRoot, matched[1]), 'utf8');
79
+ return [
80
+ 'tsconfig.js-code-nodes.json',
81
+ 'sourceKinds',
82
+ 'buildScript',
83
+ ].every(marker => source.includes(marker)) &&
84
+ (source.includes('resolveBuildTargets') ||
85
+ source.includes('resolveBuildSelection'));
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function findWorkspaceTargetEntry(workspaceRoot, target) {
92
+ const base = path.join(
93
+ workspaceRoot,
94
+ 'src',
95
+ target.sourceKind,
96
+ target.scriptCode,
97
+ 'index'
98
+ );
99
+ return ['.ts', '.tsx']
100
+ .map(extension => `${base}${extension}`)
101
+ .find(filePath => fs.existsSync(filePath));
102
+ }
103
+
104
+ function canUseCanonicalJsCodeBuilder(workspaceRoot, targets, options = {}) {
105
+ if (
106
+ options.preferWorkspaceBuilder ||
107
+ process.env.OPENXIANGDA_USE_WORKSPACE_JS_CODE_BUILDER === '1'
108
+ ) {
109
+ return false;
110
+ }
111
+ if (
112
+ !fs.existsSync(CANONICAL_BUILD_SCRIPT) ||
113
+ !fs.existsSync(path.join(workspaceRoot, 'tsconfig.js-code-nodes.json')) ||
114
+ !isStandardWorkspaceJsCodeBuilder(workspaceRoot)
115
+ ) {
116
+ return false;
117
+ }
118
+ return targets.every(target => findWorkspaceTargetEntry(workspaceRoot, target));
119
+ }
120
+
121
+ function resolveWorkspaceJsCodeBuildCommand(workspaceRoot, targets, options = {}) {
122
+ const normalized = normalizeJsCodeBuildTargets(targets);
123
+ const packageArgs = buildJsCodeBatchArgs(normalized, options);
124
+ if (canUseCanonicalJsCodeBuilder(workspaceRoot, normalized, options)) {
125
+ return {
126
+ mode: 'canonical-scoped',
127
+ command: options.nodeBinary || process.execPath,
128
+ args: [CANONICAL_BUILD_SCRIPT, ...packageArgs.slice(1)],
129
+ env: {
130
+ OPENXIANGDA_WORKSPACE_ROOT: path.resolve(workspaceRoot),
131
+ },
132
+ };
133
+ }
134
+ return {
135
+ mode: 'workspace-script',
136
+ command: options.packageManager || 'pnpm',
137
+ args: packageArgs,
138
+ env: {},
139
+ };
140
+ }
141
+
45
142
  function runWorkspaceJsCodeBuildBatch(workspaceRoot, targets, options = {}) {
46
- return spawnSync(
47
- options.packageManager || 'pnpm',
48
- buildJsCodeBatchArgs(targets, options),
49
- {
50
- cwd: workspaceRoot,
51
- encoding: 'utf8',
52
- ...options.spawnOptions,
53
- }
143
+ const resolved = resolveWorkspaceJsCodeBuildCommand(
144
+ workspaceRoot,
145
+ targets,
146
+ options
54
147
  );
148
+ const spawnOptions = {
149
+ cwd: workspaceRoot,
150
+ encoding: 'utf8',
151
+ ...options.spawnOptions,
152
+ };
153
+ if (resolved.mode === 'canonical-scoped') {
154
+ spawnOptions.env = {
155
+ ...process.env,
156
+ ...(options.spawnOptions?.env || {}),
157
+ ...resolved.env,
158
+ };
159
+ }
160
+ const result = spawnSync(resolved.command, resolved.args, spawnOptions);
161
+ Object.defineProperty(result, 'openxiangdaBuildMode', {
162
+ value: resolved.mode,
163
+ enumerable: false,
164
+ });
165
+ return result;
55
166
  }
56
167
 
57
168
  module.exports = {
58
169
  buildJsCodeBatchArgs,
59
170
  normalizeJsCodeBuildTargets,
171
+ resolveWorkspaceJsCodeBuildCommand,
60
172
  runWorkspaceJsCodeBuildBatch,
61
173
  };
@@ -31,6 +31,8 @@ This file is a router and safety card. Read only the one or two subskills select
31
31
  4. Plan and publish exact resource codes with `--only <codes>` or `--code <code>`; a type-only or full-resource publish is allowed only when the dependency closure intentionally contains the whole type/application.
32
32
  5. A React SPA page change rebuilds one application runtime. Commit the complete build input first, upload/preview with `runtime deploy --no-activate`, and promote only from a clean merged release head that descends from the online Runtime source revision.
33
33
 
34
+ An exact Function/Automation selector is an early scope boundary: unrelated manifests, source dependency graphs, and JS_CODE targets must not be read, typechecked, or built. Transitive/shared/ambient dependencies of the selected entry remain included. No selector intentionally keeps full-workspace behavior.
35
+
34
36
  Never infer a release scope from a globally dirty `git status` when multiple changes are present. Never publish a temporary source tree that differs from the reviewed patch.
35
37
 
36
38
  ## Risk and approval
@@ -287,7 +287,7 @@ Use JS_CODE V2 when the script is local to one automation graph node.
287
287
  }
288
288
  ```
289
289
 
290
- Author source in `src/js-code-nodes/<scriptCode>/index.ts`. AI-authored source must be TypeScript. Build with `pnpm build-js-code --script <scriptCode>`; the command runs TypeScript validation before bundling. During validate/create, the CLI uploads the generated bundle, replaces `sourceFile.localPath` with `{ bucketName, objectName, sha256, ... }`, and the backend verifies sha256 before execution.
290
+ Author source in `src/js-code-nodes/<scriptCode>/index.ts`. AI-authored source must be TypeScript. Build with `pnpm build-js-code --script <scriptCode>`; the explicit selector typechecks only that entry plus its transitive/shared/ambient dependencies before bundling, while an unscoped build validates the whole workspace. During validate/create, the CLI uploads the generated bundle, replaces `sourceFile.localPath` with `{ bucketName, objectName, sha256, ... }`, and the backend verifies sha256 before execution.
291
291
 
292
292
  The backend runs the snapshot in the trusted Node runtime, applies the node timeout (`30000` ms by default), stores execution logs, and writes the returned value to the node output and `variables.node_<nodeId>`. Scripts may use `export default async function (ctx) {}`, `module.exports = async (ctx) => {}`, `require`, `process`, `Buffer`, arbitrary HTTP, and `platform.api` for `/openxiangda-api/v1`.
293
293
 
@@ -754,7 +754,7 @@ export default async function (ctx) {
754
754
  }
755
755
  ```
756
756
 
757
- 构建:`pnpm build-js-code --script sync_customer`(先 `tsc` 再打包到 `dist/js-code-nodes/<code>/index.cjs`)。CLI validate/create/publish 时会上传快照、用 `{ bucketName, objectName, sha256 }` 替换 `sourceFile.localPath`。
757
+ 构建:`pnpm build-js-code --script sync_customer`(只校验该入口及其传递/shared/ambient 依赖,再打包到 `dist/js-code-nodes/<code>/index.cjs`;仅无 selector 时全量校验)。CLI validate/create/publish 时会上传快照、用 `{ bucketName, objectName, sha256 }` 替换 `sourceFile.localPath`。
758
758
 
759
759
  `ctx.methods.*` 仍可用于兼容旧脚本;新脚本优先使用 `ctx.resources`、`ctx.form.queryOne/queryMany/getById/createOne/updateOne/updateById`、`ctx.dataView`、`ctx.connector`、`ctx.notification`、`ctx.platform.roles` 和 `ctx.platform.api`。
760
760
 
@@ -371,7 +371,7 @@ File snapshot:
371
371
  }
372
372
  ```
373
373
 
374
- AI-authored JS_CODE source must be TypeScript under `src/js-code-nodes/<scriptCode>/index.ts`. When validating or creating, the CLI runs `pnpm build-js-code --script <scriptCode>`, which runs TypeScript validation first, bundles to `dist/js-code-nodes/<scriptCode>/index.cjs`, uploads the bundle, and replaces `sourceFile.localPath` with immutable snapshot metadata. The backend verifies snapshot `sha256`, runs it in the trusted Node runtime, applies the node timeout (`30000` ms by default), stores execution logs, and writes the returned value to the node output and `variables.node_<nodeId>`.
374
+ AI-authored JS_CODE source must be TypeScript under `src/js-code-nodes/<scriptCode>/index.ts`. When validating or creating, the CLI runs `pnpm build-js-code --script <scriptCode>`, which typechecks only the selected entry plus its transitive/shared/ambient dependencies, bundles to `dist/js-code-nodes/<scriptCode>/index.cjs`, uploads the bundle, and replaces `sourceFile.localPath` with immutable snapshot metadata. An intentionally unscoped build retains full-workspace TypeScript validation. The backend verifies snapshot `sha256`, runs it in the trusted Node runtime, applies the node timeout (`30000` ms by default), stores console/runtime logs in the execution record, and writes the returned value to the node output and `variables.node_<nodeId>`.
375
375
 
376
376
  For reusable backend logic that should be shared by pages, automations, and workflows, prefer an App Function under `src/functions/<functionCode>/index.ts` plus `src/resources/functions/<functionCode>.json`. Workflow graphs that support App Function nodes can call it with:
377
377
 
@@ -53,6 +53,8 @@ openxiangda resource publish function --only function_a,function_b --profile <na
53
53
 
54
54
  `--code <code>` is the single-resource equivalent of `--only <codes>`. Type-only planning is appropriate for an intentional shared dependency closure. Unscoped full-resource planning/publishing is app-wide and requires an app-wide approved change.
55
55
 
56
+ Selectors apply before manifest parsing, source dependency analysis, and JS_CODE typecheck/build. A scoped Function/Automation plan must touch only the selected targets plus their transitive/shared/ambient dependencies; an unscoped plan intentionally retains full-workspace behavior. Resource commands use the canonical scoped builder packaged with the installed CLI for standard workspaces, so old checked-in builders still receive the optimization; refresh/bootstrap the workspace script only for equivalent manual `pnpm build-js-code` behavior. Nonstandard custom builders remain an explicit compatibility fallback.
57
+
56
58
  Source-triggered Function/Automation targets use one immutable Backend Release by default. The CLI uploads every eligible existing source-only target, freezes the current Backend Release parent plus Git/change baseline, then runs `prepare -> verify -> activate`; activation first CAS-checks the entire set and applies all updates in one transaction. Never fall back to sequential PATCH after prepare/verify/activate has started. Compatibility fallback is allowed only when the Backend Release `head` feature probe is an explicit HTTP 404, and must warn. Existing online bindings, contracts, metadata, trigger/view configuration, and enabled/published state remain unchanged; noops do not advance resource versions/timestamps. A deliberate whole-definition replacement requires exact `--only/--code` and `--replace-manifest --reason "<why>"`; SDD bypass does not imply replacement authority.
57
59
 
58
60
  Use `openxiangda release backend-head|backend-list|backend-detail|backend-diff` to inspect immutable history. `backend-rollback <releaseId> --change <change> --reason "..."` prepares, verifies, and activates a new release from the historical snapshot; it never mutates or directly reactivates the old row. `backend-abort` stops prepared/verified work and `backend-retry` retries only recorded post-commit side effects. Do not fetch newer revisions to replay an older payload.
@@ -29,7 +29,7 @@ Create a workflow only when the scenario has **real approval semantics**: approv
29
29
  - ✅ Delayed approval start: save the process-form record first with `StandardFormPage submitBehavior="save-draft"` or `sdk.form.create({ formUuid, data, saveAsDraft: true, startProcess: false })`, then start approval in place with `sdk.process.startFromExistingInstance({ formUuid, formInstId })` or `StandardFormPage submitBehavior="start-existing-process"`.
30
30
  - ✅ Return-to-initiator flows: use `returnPolicy: { scopeType: "initiator", resubmitMode: "resume_current" | "replay" }` or `flow.action.returnToInitiator()`. The runtime capabilities protocol exposes `resubmit` only for pending `originator_return` tasks; return to a previous approval node continues through the normal `approve` action.
31
31
  - ✅ Reusable backend business logic: prefer **App Function** (`src/functions/<functionCode>/index.ts` + `src/resources/functions/<functionCode>.json`), then call it from page `sdk.function.invoke` or automation/workflow `function_call`.
32
- - ✅ JS_CODE V2 trusted_node: source in TypeScript under `src/js-code-nodes/<scriptCode>/index.ts`, `src/automations/<resourceCode>/index.ts`, or `src/functions/<functionCode>/index.ts`. Run `pnpm build-js-code --script <code>` (validates with `tsc` first).
32
+ - ✅ JS_CODE V2 trusted_node: source in TypeScript under `src/js-code-nodes/<scriptCode>/index.ts`, `src/automations/<resourceCode>/index.ts`, or `src/functions/<functionCode>/index.ts`. Run `pnpm build-js-code --script <code>`; an explicit selector validates/builds only that entry plus its transitive/shared/ambient dependencies, while an unscoped command validates the whole workspace.
33
33
  - ✅ Use `trigger_v2` for new automation triggers; CLI fills root `appType` / `formUuid` from active profile when `--form-code` is provided.
34
34
  - ✅ Use logical `workflowCode` / `automationCode` locally; live IDs are profile-isolated under `.openxiangda/state.json`.
35
35
  - ✅ `ctx.logger.debug/info/warn/error(message, data?)` at every important step — inspect via `automation executions` / `automation logs` / `automation diagnose`.
@@ -112,7 +112,7 @@ For new AI-authored workflows where users do not need canvas editing, prefer com
112
112
  Do not use JS_CODE for simple UI interactions, ordinary form validation, display-only page behavior, or logic that belongs in a normal React code page. For non-trivial backend logic, prefer JS_CODE V2 trusted Node scripts over large inline snippets. AI-authored JS_CODE source must be TypeScript:
113
113
 
114
114
  1. Put source in `src/js-code-nodes/<scriptCode>/index.ts`.
115
- 2. Run `pnpm build-js-code --script <scriptCode>`. This command runs TypeScript validation first and only bundles after `tsc` passes.
115
+ 2. Run `pnpm build-js-code --script <scriptCode>`. In a refreshed workspace this command typechecks only the selected entry and its transitive/shared/ambient dependencies, then bundles after validation passes; omit the selector only for an intentional full-workspace validation. Installed CLI resource commands independently use their packaged canonical scoped builder, including against standard older workspaces.
116
116
  3. In workflow or automation JSON, use:
117
117
  ```json
118
118
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openxiangda",
3
- "version": "1.0.162",
3
+ "version": "1.0.163",
4
4
  "description": "OpenXiangda CLI, workspace build tools, runtime SDK, and form components.",
5
5
  "private": false,
6
6
  "bin": {
@@ -24,6 +24,7 @@ openxiangda resource typegen --profile <name>
24
24
  - Guest access to forms, dataViews, functions, and connectors requires explicit policy `grants`.
25
25
  - Connector secrets and third-party credentials belong in the platform backend, never in manifests or page source.
26
26
  - Formal changes should keep Git as the source of truth: edit manifests, validate, plan, then publish.
27
+ - Exact selectors apply before manifest/source analysis and JS_CODE build: touch only selected targets plus transitive/shared/ambient dependencies; omit selectors only for intentional full-workspace work.
27
28
  - `resource plan` and publish dry-runs are GET/HEAD-only. On `READ_ONLY_AUTH_REQUIRED`, run `openxiangda auth refresh --profile <name>` or log in again before retrying; never refresh inside the plan.
28
29
  - Source-triggered Function/Automation publishing patches only source fields on the server and preserves online bindings/contracts/metadata/state. Whole-manifest replacement requires `--replace-manifest --reason "..."`.
29
30
  - Formal promotion runs `release begin --change` (freeze Git + remote baseline, preflight the complete set) through `release end`. Reconcile `SOURCE_BASE_DIVERGED` / `RESOURCE_FIELD_CONFLICT`; never retry an old full payload with a fresh revision.
@@ -24,6 +24,7 @@ openxiangda resource typegen --profile <name>
24
24
  - Guest access to forms, dataViews, functions, and connectors requires explicit policy `grants`.
25
25
  - Connector secrets and third-party credentials belong in the platform backend, never in manifests or page source.
26
26
  - Formal changes should keep Git as the source of truth: edit manifests, validate, plan, then publish.
27
+ - Exact selectors apply before manifest/source analysis and JS_CODE build: touch only selected targets plus transitive/shared/ambient dependencies; omit selectors only for intentional full-workspace work.
27
28
  - `resource plan` and publish dry-runs are GET/HEAD-only. On `READ_ONLY_AUTH_REQUIRED`, run `openxiangda auth refresh --profile <name>` or log in again before retrying; never refresh inside the plan.
28
29
  - Source-triggered Function/Automation publishing patches only source fields on the server and preserves online bindings/contracts/metadata/state. Whole-manifest replacement requires `--replace-manifest --reason "..."`.
29
30
  - Formal promotion runs `release begin --change` (freeze Git + remote baseline, preflight the complete set) through `release end`. Reconcile `SOURCE_BASE_DIVERGED` / `RESOURCE_FIELD_CONFLICT`; never retry an old full payload with a fresh revision.
@@ -64,7 +64,7 @@ openxiangda runtime activate <reviewedReleaseId> --change <change> --profile <na
64
64
  openxiangda release end --profile <name>
65
65
  ```
66
66
 
67
- `pnpm build-js-code` 会检查并打包 `src/js-code-nodes/<code>/index.ts`、`src/automations/<code>/index.ts`、`src/functions/<code>/index.ts`,供 JS_CODE V2、代码自动化和 App Function 资源发布使用。批量目标使用 `pnpm build-js-code --scripts functions:a,functions:b,automations:c`,也兼容重复的 `--script a --script b --source functions`;一批目标只运行一次完整 TypeScript 校验。构建缓存写入 `.openxiangda/build-cache.json`,按源码、实际共享依赖、构建配置、Node/Vite/TypeScript 版本及产物校验和判断命中;需要强制重建时追加 `--force`。
67
+ `pnpm build-js-code` 会检查并打包 `src/js-code-nodes/<code>/index.ts`、`src/automations/<code>/index.ts`、`src/functions/<code>/index.ts`,供 JS_CODE V2、代码自动化和 App Function 资源发布使用。批量目标使用 `pnpm build-js-code --scripts functions:a,functions:b,automations:c`,也兼容重复的 `--script a --script b --source functions`。显式 selector 只校验和构建选中入口及其传递/shared/ambient 依赖;不带 selector 才执行全工作区 TypeScript 校验。构建缓存写入 `.openxiangda/build-cache.json` 并按 scoped target、实际共享依赖、构建配置、Node/Vite/TypeScript 版本及产物校验和判断命中;需要强制重建时追加 `--force`。
68
68
 
69
69
  `openxiangda runtime deploy --no-activate` 会构建并上传不可变预览版本;发布前先提交所有可能进入构建的源码/配置。所有 Runtime deploy(包括 `--no-activate`)都会先获取应用发布 lease,并在任何构建和上传前冻结 clean `HEAD` 与当前 active Runtime 父血缘;旧分支返回 `RUNTIME_SOURCE_BASE_DIVERGED`,不能先上传旧 preview 再激活。`openspec/` SDD 证据和生成/状态目录不算源码 dirty。仅审批的回退可使用 `--allow-runtime-rollback --reason "至少 8 个字符"`;`--no-build` 不会跳过守卫。不要手工修改 `dist/index.html`。
70
70
 
@@ -19,13 +19,29 @@ import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { build } from "vite";
21
21
 
22
- const rootDir = fileURLToPath(new URL("..", import.meta.url));
23
22
  const scriptFile = fileURLToPath(import.meta.url);
23
+ const defaultRootDir = fileURLToPath(new URL("..", import.meta.url));
24
+ const rootDir = path.resolve(process.env.OPENXIANGDA_WORKSPACE_ROOT || defaultRootDir);
24
25
  const args = process.argv.slice(2);
25
26
  const require = createRequire(import.meta.url);
26
- const CACHE_VERSION = 2;
27
- const CACHE_FILE = path.join(rootDir, ".openxiangda", "build-cache.json");
27
+ const ts = require("typescript");
28
+ const CACHE_VERSION = 3;
29
+ const builderFingerprint = crypto
30
+ .createHash("sha256")
31
+ .update(readFileSync(scriptFile))
32
+ .digest("hex");
33
+ const workspaceBuilderFile = path.join(rootDir, "scripts", "build-js-code.mjs");
34
+ const workspaceBuilderMatches =
35
+ existsSync(workspaceBuilderFile) &&
36
+ crypto.createHash("sha256").update(readFileSync(workspaceBuilderFile)).digest("hex") ===
37
+ builderFingerprint;
38
+ const cacheFileName =
39
+ path.resolve(defaultRootDir) !== rootDir && !workspaceBuilderMatches
40
+ ? "build-cache.cli-v3.json"
41
+ : "build-cache.json";
42
+ const CACHE_FILE = path.join(rootDir, ".openxiangda", cacheFileName);
28
43
  const CACHE_LOCK_FILE = `${CACHE_FILE}.lock`;
44
+ const TSCONFIG_FILE = path.join(rootDir, "tsconfig.js-code-nodes.json");
29
45
  const forceBuild = args.includes("--force") || args.includes("--no-cache");
30
46
 
31
47
  const sourceKinds = {
@@ -84,11 +100,24 @@ function assertScriptCode(scriptCode) {
84
100
  function listScriptCodes(kind) {
85
101
  if (!existsSync(kind.sourceRoot)) return [];
86
102
  return readdirSync(kind.sourceRoot, { withFileTypes: true })
87
- .filter((entry) => entry.isDirectory() && existsSync(path.join(kind.sourceRoot, entry.name, "index.ts")))
103
+ .filter(
104
+ (entry) =>
105
+ entry.isDirectory() &&
106
+ ["index.ts", "index.tsx"].some((name) =>
107
+ existsSync(path.join(kind.sourceRoot, entry.name, name)),
108
+ ),
109
+ )
88
110
  .map((entry) => entry.name)
89
111
  .sort();
90
112
  }
91
113
 
114
+ function targetEntryFile(target) {
115
+ const base = path.join(target.kind.sourceRoot, target.scriptCode);
116
+ return ["index.ts", "index.tsx"]
117
+ .map((name) => path.join(base, name))
118
+ .find((file) => existsSync(file)) || path.join(base, "index.ts");
119
+ }
120
+
92
121
  function parseScriptSpec(rawSpec, defaultKind) {
93
122
  const prefixed = String(rawSpec).match(/^(js-code-nodes|automations|functions)[:/](.+)$/);
94
123
  const requestedKind = prefixed?.[1] || defaultKind?.name;
@@ -101,7 +130,9 @@ function parseScriptSpec(rawSpec, defaultKind) {
101
130
  if (requestedKind) return { kind: sourceKinds[requestedKind], scriptCode };
102
131
 
103
132
  const matches = Object.values(sourceKinds).filter((kind) =>
104
- existsSync(path.join(kind.sourceRoot, scriptCode, "index.ts")),
133
+ ["index.ts", "index.tsx"].some((name) =>
134
+ existsSync(path.join(kind.sourceRoot, scriptCode, name)),
135
+ ),
105
136
  );
106
137
  if (matches.length > 1) {
107
138
  console.warn(
@@ -111,7 +142,7 @@ function parseScriptSpec(rawSpec, defaultKind) {
111
142
  return { kind: matches[0] || sourceKinds["js-code-nodes"], scriptCode };
112
143
  }
113
144
 
114
- function resolveBuildTargets() {
145
+ function resolveBuildSelection() {
115
146
  const sourceArgs = readArgs("source");
116
147
  const sourceNames = [...new Set(sourceArgs)];
117
148
  if (sourceNames.length > 1) throw new Error("--source may only select one source kind");
@@ -128,7 +159,10 @@ function resolveBuildTargets() {
128
159
  : (selectedKind ? [selectedKind] : Object.values(sourceKinds)).flatMap((kind) =>
129
160
  listScriptCodes(kind).map((scriptCode) => ({ kind, scriptCode })),
130
161
  );
131
- return [...new Map(targets.map((target) => [`${target.kind.name}/${target.scriptCode}`, target])).values()];
162
+ return {
163
+ scoped: specs.length > 0,
164
+ targets: [...new Map(targets.map((target) => [`${target.kind.name}/${target.scriptCode}`, target])).values()],
165
+ };
132
166
  }
133
167
 
134
168
  function packageVersion(name) {
@@ -144,6 +178,7 @@ const toolFingerprint = crypto
144
178
  .update(
145
179
  JSON.stringify({
146
180
  cacheVersion: CACHE_VERSION,
181
+ builder: builderFingerprint,
147
182
  node: process.version,
148
183
  platform: process.platform,
149
184
  arch: process.arch,
@@ -157,14 +192,25 @@ const toolFingerprint = crypto
157
192
  .digest("hex");
158
193
 
159
194
  function emptyCache() {
160
- return { version: CACHE_VERSION, toolFingerprint, typecheck: null, targets: {} };
195
+ return {
196
+ version: CACHE_VERSION,
197
+ toolFingerprint,
198
+ typecheck: null,
199
+ typechecks: {},
200
+ targets: {},
201
+ };
161
202
  }
162
203
 
163
204
  function readCache(file = CACHE_FILE) {
164
205
  try {
165
206
  const cache = JSON.parse(readFileSync(file, "utf8"));
166
207
  if (cache.version !== CACHE_VERSION) return emptyCache();
167
- return { ...emptyCache(), ...cache, targets: cache.targets || {} };
208
+ return {
209
+ ...emptyCache(),
210
+ ...cache,
211
+ typechecks: cache.typechecks || {},
212
+ targets: cache.targets || {},
213
+ };
168
214
  } catch {
169
215
  return emptyCache();
170
216
  }
@@ -231,7 +277,7 @@ function typecheckInputHash() {
231
277
  }
232
278
 
233
279
  function targetInputHash(target, dependencies) {
234
- const entry = relativeWorkspaceFile(path.join(target.kind.sourceRoot, target.scriptCode, "index.ts"));
280
+ const entry = relativeWorkspaceFile(targetEntryFile(target));
235
281
  return hashFileSet(
236
282
  [...configInputFiles(), entry, ...(dependencies || [])].filter(Boolean),
237
283
  `target:${target.kind.name}/${target.scriptCode}`,
@@ -258,11 +304,94 @@ function isTargetCacheHit(cache, target) {
258
304
  );
259
305
  }
260
306
 
261
- function typecheckScripts(cache) {
307
+ function formatTypeScriptDiagnostics(diagnostics) {
308
+ return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
309
+ getCanonicalFileName: (file) => file,
310
+ getCurrentDirectory: () => rootDir,
311
+ getNewLine: () => "\n",
312
+ });
313
+ }
314
+
315
+ function scopedTypecheckKey(targets) {
316
+ return targets
317
+ .map((target) => `${target.kind.name}/${target.scriptCode}`)
318
+ .sort()
319
+ .join(",");
320
+ }
321
+
322
+ function scopedAmbientDeclarationFiles() {
323
+ return walkFiles(path.join(rootDir, "src"))
324
+ .filter((file) => file.endsWith(".d.ts"))
325
+ .map(relativeWorkspaceFile)
326
+ .filter(Boolean);
327
+ }
328
+
329
+ function createScopedTypecheckProgram(targets) {
330
+ const loaded = ts.readConfigFile(TSCONFIG_FILE, ts.sys.readFile);
331
+ if (loaded.error) {
332
+ process.stderr.write(formatTypeScriptDiagnostics([loaded.error]));
333
+ throw new Error("JS_CODE scoped TypeScript config validation failed");
334
+ }
335
+ const files = [
336
+ ...targets.map((target) =>
337
+ relativeWorkspaceFile(targetEntryFile(target)),
338
+ ),
339
+ ...scopedAmbientDeclarationFiles(),
340
+ ].filter(Boolean);
341
+ const parsed = ts.parseJsonConfigFileContent(
342
+ { ...loaded.config, files, include: [] },
343
+ ts.sys,
344
+ rootDir,
345
+ { noEmit: true },
346
+ TSCONFIG_FILE,
347
+ );
348
+ if (parsed.errors.length > 0) {
349
+ process.stderr.write(formatTypeScriptDiagnostics(parsed.errors));
350
+ throw new Error("JS_CODE scoped TypeScript config validation failed");
351
+ }
352
+ return ts.createProgram({
353
+ rootNames: parsed.fileNames,
354
+ options: parsed.options,
355
+ projectReferences: parsed.projectReferences,
356
+ });
357
+ }
358
+
359
+ function scopedTypecheckInputHash(program, cacheKey) {
360
+ const sourceFiles = program
361
+ .getSourceFiles()
362
+ .map((sourceFile) => relativeWorkspaceFile(sourceFile.fileName))
363
+ .filter(Boolean);
364
+ return hashFileSet([...configInputFiles(), ...sourceFiles], `typecheck:${cacheKey}`);
365
+ }
366
+
367
+ function typecheckSelectedScripts(cache, targets) {
368
+ const cacheKey = scopedTypecheckKey(targets);
369
+ const program = createScopedTypecheckProgram(targets);
370
+ const inputHash = scopedTypecheckInputHash(program, cacheKey);
371
+ if (
372
+ !forceBuild &&
373
+ cache.toolFingerprint === toolFingerprint &&
374
+ cache.typechecks?.[cacheKey]?.inputHash === inputHash
375
+ ) {
376
+ console.log(`[build-js-code] scoped TypeScript validation cache hit (${targets.length} selected)`);
377
+ return { cacheHit: true, cacheKey, inputHash, mode: "scoped" };
378
+ }
379
+
380
+ console.log(`[build-js-code] validating TypeScript for ${targets.length} selected target(s)...`);
381
+ const diagnostics = ts.getPreEmitDiagnostics(program);
382
+ if (diagnostics.length > 0) {
383
+ process.stderr.write(formatTypeScriptDiagnostics(diagnostics));
384
+ throw new Error("JS_CODE scoped TypeScript validation failed");
385
+ }
386
+ console.log("[build-js-code] scoped TypeScript validation passed");
387
+ return { cacheHit: false, cacheKey, inputHash, mode: "scoped" };
388
+ }
389
+
390
+ function typecheckAllScripts(cache) {
262
391
  const inputHash = typecheckInputHash();
263
392
  if (!forceBuild && cache.toolFingerprint === toolFingerprint && cache.typecheck?.inputHash === inputHash) {
264
393
  console.log("[build-js-code] TypeScript validation cache hit");
265
- return { cacheHit: true, inputHash };
394
+ return { cacheHit: true, inputHash, mode: "full" };
266
395
  }
267
396
 
268
397
  console.log("[build-js-code] validating TypeScript once for this batch...");
@@ -275,7 +404,13 @@ function typecheckScripts(cache) {
275
404
  console.log("[build-js-code] TypeScript validation passed");
276
405
  // pnpm may materialize/update a lockfile on the first invocation. Record the
277
406
  // post-validation inputs so the next process can reuse this successful tsc.
278
- return { cacheHit: false, inputHash: typecheckInputHash() };
407
+ return { cacheHit: false, inputHash: typecheckInputHash(), mode: "full" };
408
+ }
409
+
410
+ function typecheckScripts(cache, targets, scoped) {
411
+ return scoped
412
+ ? typecheckSelectedScripts(cache, targets)
413
+ : typecheckAllScripts(cache);
279
414
  }
280
415
 
281
416
  function collectBuildDependencies(buildResult, entry) {
@@ -296,7 +431,7 @@ function collectBuildDependencies(buildResult, entry) {
296
431
 
297
432
  async function buildScript(target) {
298
433
  const { kind, scriptCode } = target;
299
- const entry = path.join(kind.sourceRoot, scriptCode, "index.ts");
434
+ const entry = targetEntryFile(target);
300
435
  if (!existsSync(entry)) throw new Error(`${kind.label} script not found: ${entry}`);
301
436
  const outDir = path.join(kind.outputRoot, scriptCode);
302
437
  const external = Array.from(
@@ -361,14 +496,31 @@ function saveCache(typecheck, updatedTargets) {
361
496
  if (typecheck.cacheHit && updatedTargets.size === 0) return;
362
497
  const lockFd = acquireCacheLock();
363
498
  try {
364
- const latest = readCache();
499
+ const stored = readCache();
500
+ // A scoped build updates only part of the cache. Never carry metadata from
501
+ // a different compiler/builder fingerprint into the new cache, otherwise
502
+ // untouched targets could become false hits after this process stamps the
503
+ // cache with the current fingerprint.
504
+ const latest = stored.toolFingerprint === toolFingerprint
505
+ ? stored
506
+ : emptyCache();
507
+ const validatedAt = new Date().toISOString();
508
+ const mergedTypechecks = { ...latest.typechecks };
509
+ if (!typecheck.cacheHit && typecheck.mode === "scoped") {
510
+ mergedTypechecks[typecheck.cacheKey] = {
511
+ inputHash: typecheck.inputHash,
512
+ validatedAt,
513
+ };
514
+ }
365
515
  const merged = {
366
516
  ...latest,
367
517
  version: CACHE_VERSION,
368
518
  toolFingerprint,
369
- typecheck: typecheck.cacheHit
370
- ? latest.typecheck
371
- : { inputHash: typecheck.inputHash, validatedAt: new Date().toISOString() },
519
+ typecheck:
520
+ !typecheck.cacheHit && typecheck.mode === "full"
521
+ ? { inputHash: typecheck.inputHash, validatedAt }
522
+ : latest.typecheck,
523
+ typechecks: mergedTypechecks,
372
524
  targets: { ...latest.targets, ...Object.fromEntries(updatedTargets) },
373
525
  };
374
526
  const tempFile = `${CACHE_FILE}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
@@ -398,7 +550,7 @@ function saveCache(typecheck, updatedTargets) {
398
550
  }
399
551
  }
400
552
 
401
- const targets = resolveBuildTargets();
553
+ const { scoped, targets } = resolveBuildSelection();
402
554
  if (targets.length === 0) {
403
555
  console.log(
404
556
  "no JS_CODE scripts found under src/js-code-nodes/<scriptCode>/index.ts, src/automations/<scriptCode>/index.ts, or src/functions/<functionCode>/index.ts",
@@ -407,7 +559,7 @@ if (targets.length === 0) {
407
559
  }
408
560
 
409
561
  const cache = readCache();
410
- const typecheck = typecheckScripts(cache);
562
+ const typecheck = typecheckScripts(cache, targets, scoped);
411
563
  const updatedTargets = new Map();
412
564
  let builtCount = 0;
413
565
  let cachedCount = 0;
@@ -27,6 +27,8 @@ Commands: `openxiangda resource validate|plan|publish <type> --only <codes> --pr
27
27
 
28
28
  `resource plan` and publish dry-runs are strictly GET/HEAD-only. On `READ_ONLY_AUTH_REQUIRED`, run `openxiangda auth refresh --profile <name>` or log in again before retrying; never refresh inside the plan.
29
29
 
30
+ Exact `--only/--code` selectors apply before manifest/source analysis and JS_CODE build. Touch only selected targets plus transitive/shared/ambient dependencies; omit selectors only for intentional full-workspace work.
31
+
30
32
  When a Function/Automation enters scope only through source changes, publishing uses a server-side source-field PATCH and preserves online bindings, contracts, metadata, trigger/view configuration, and enabled/published state. Whole-manifest replacement requires `--replace-manifest --reason "..."`. Formal promotion runs `release begin --change` (Git + remote baseline, whole-set preflight) through `release end`; `SOURCE_BASE_DIVERGED` and `RESOURCE_FIELD_CONFLICT` require reconciliation and a new plan.
31
33
 
32
34
  Before editing `roles`, `permissions/page-groups`, or `permissions/form-groups` for account/role/data-scope/RBAC/query-param authorization work, run `openxiangda design gates --topic permissions --json`, choose the permission mode, and write the permission matrix.
@@ -45,6 +45,8 @@ Function/Automation 仅因源码变化进入 scope 时,默认通过服务端
45
45
 
46
46
  `resource plan` 与 publish dry-run 严格只允许 GET/HEAD。遇到 `READ_ONLY_AUTH_REQUIRED` 时,先执行 `openxiangda auth refresh --profile <name>` 或重新登录再重试;不得在 plan 内自动 POST 刷新 token。
47
47
 
48
+ 精确 `--only/--code` 会在 manifest/source 分析与 JS_CODE 构建前收窄,只触碰目标及其传递/shared/ambient 依赖;只有明确的全工作区任务才省略 selector。
49
+
48
50
  ## 严禁
49
51
 
50
52
  - ❌ 把 `formUuid` / `pageId` / `workflowId` 等平台 ID 直接写进 manifest(CLI 解析逻辑 code)。
@@ -86,7 +86,7 @@
86
86
 
87
87
  ## JS_CODE 批量构建
88
88
 
89
- `pnpm build-js-code --scripts functions:a,functions:b,automations:c` 可在一次进程中构建多个 App Function / Automation / JS_CODE 目标;也兼容 `--script a --script b --source functions`,完整 TypeScript 校验每批只运行一次。`.openxiangda/build-cache.json` 按源码、实际共享依赖、构建配置、Node/Vite/TypeScript 版本及产物校验和决定是否跳过,不能只因 `dist/**/index.cjs` 存在就视为有效;强制重建使用 `--force`。
89
+ `pnpm build-js-code --scripts functions:a,functions:b,automations:c` 可在一次进程中构建多个 App Function / Automation / JS_CODE 目标;也兼容 `--script a --script b --source functions`。显式 selector 只校验和构建选中入口及其传递/shared/ambient 依赖;不带 selector 才保持全工作区 TypeScript 校验。`.openxiangda/build-cache.json` 分 scoped target 记录源码、实际共享依赖、构建配置、Node/Vite/TypeScript 版本及产物校验和,不能只因 `dist/**/index.cjs` 存在就视为有效;强制重建使用 `--force`。
90
90
 
91
91
  ## 工作区结构速查
92
92
 
@@ -19,13 +19,29 @@ import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { build } from "vite";
21
21
 
22
- const rootDir = fileURLToPath(new URL("..", import.meta.url));
23
22
  const scriptFile = fileURLToPath(import.meta.url);
23
+ const defaultRootDir = fileURLToPath(new URL("..", import.meta.url));
24
+ const rootDir = path.resolve(process.env.OPENXIANGDA_WORKSPACE_ROOT || defaultRootDir);
24
25
  const args = process.argv.slice(2);
25
26
  const require = createRequire(import.meta.url);
26
- const CACHE_VERSION = 2;
27
- const CACHE_FILE = path.join(rootDir, ".openxiangda", "build-cache.json");
27
+ const ts = require("typescript");
28
+ const CACHE_VERSION = 3;
29
+ const builderFingerprint = crypto
30
+ .createHash("sha256")
31
+ .update(readFileSync(scriptFile))
32
+ .digest("hex");
33
+ const workspaceBuilderFile = path.join(rootDir, "scripts", "build-js-code.mjs");
34
+ const workspaceBuilderMatches =
35
+ existsSync(workspaceBuilderFile) &&
36
+ crypto.createHash("sha256").update(readFileSync(workspaceBuilderFile)).digest("hex") ===
37
+ builderFingerprint;
38
+ const cacheFileName =
39
+ path.resolve(defaultRootDir) !== rootDir && !workspaceBuilderMatches
40
+ ? "build-cache.cli-v3.json"
41
+ : "build-cache.json";
42
+ const CACHE_FILE = path.join(rootDir, ".openxiangda", cacheFileName);
28
43
  const CACHE_LOCK_FILE = `${CACHE_FILE}.lock`;
44
+ const TSCONFIG_FILE = path.join(rootDir, "tsconfig.js-code-nodes.json");
29
45
  const forceBuild = args.includes("--force") || args.includes("--no-cache");
30
46
 
31
47
  const sourceKinds = {
@@ -84,11 +100,24 @@ function assertScriptCode(scriptCode) {
84
100
  function listScriptCodes(kind) {
85
101
  if (!existsSync(kind.sourceRoot)) return [];
86
102
  return readdirSync(kind.sourceRoot, { withFileTypes: true })
87
- .filter((entry) => entry.isDirectory() && existsSync(path.join(kind.sourceRoot, entry.name, "index.ts")))
103
+ .filter(
104
+ (entry) =>
105
+ entry.isDirectory() &&
106
+ ["index.ts", "index.tsx"].some((name) =>
107
+ existsSync(path.join(kind.sourceRoot, entry.name, name)),
108
+ ),
109
+ )
88
110
  .map((entry) => entry.name)
89
111
  .sort();
90
112
  }
91
113
 
114
+ function targetEntryFile(target) {
115
+ const base = path.join(target.kind.sourceRoot, target.scriptCode);
116
+ return ["index.ts", "index.tsx"]
117
+ .map((name) => path.join(base, name))
118
+ .find((file) => existsSync(file)) || path.join(base, "index.ts");
119
+ }
120
+
92
121
  function parseScriptSpec(rawSpec, defaultKind) {
93
122
  const prefixed = String(rawSpec).match(/^(js-code-nodes|automations|functions)[:/](.+)$/);
94
123
  const requestedKind = prefixed?.[1] || defaultKind?.name;
@@ -101,7 +130,9 @@ function parseScriptSpec(rawSpec, defaultKind) {
101
130
  if (requestedKind) return { kind: sourceKinds[requestedKind], scriptCode };
102
131
 
103
132
  const matches = Object.values(sourceKinds).filter((kind) =>
104
- existsSync(path.join(kind.sourceRoot, scriptCode, "index.ts")),
133
+ ["index.ts", "index.tsx"].some((name) =>
134
+ existsSync(path.join(kind.sourceRoot, scriptCode, name)),
135
+ ),
105
136
  );
106
137
  if (matches.length > 1) {
107
138
  console.warn(
@@ -111,7 +142,7 @@ function parseScriptSpec(rawSpec, defaultKind) {
111
142
  return { kind: matches[0] || sourceKinds["js-code-nodes"], scriptCode };
112
143
  }
113
144
 
114
- function resolveBuildTargets() {
145
+ function resolveBuildSelection() {
115
146
  const sourceArgs = readArgs("source");
116
147
  const sourceNames = [...new Set(sourceArgs)];
117
148
  if (sourceNames.length > 1) throw new Error("--source may only select one source kind");
@@ -128,7 +159,10 @@ function resolveBuildTargets() {
128
159
  : (selectedKind ? [selectedKind] : Object.values(sourceKinds)).flatMap((kind) =>
129
160
  listScriptCodes(kind).map((scriptCode) => ({ kind, scriptCode })),
130
161
  );
131
- return [...new Map(targets.map((target) => [`${target.kind.name}/${target.scriptCode}`, target])).values()];
162
+ return {
163
+ scoped: specs.length > 0,
164
+ targets: [...new Map(targets.map((target) => [`${target.kind.name}/${target.scriptCode}`, target])).values()],
165
+ };
132
166
  }
133
167
 
134
168
  function packageVersion(name) {
@@ -144,6 +178,7 @@ const toolFingerprint = crypto
144
178
  .update(
145
179
  JSON.stringify({
146
180
  cacheVersion: CACHE_VERSION,
181
+ builder: builderFingerprint,
147
182
  node: process.version,
148
183
  platform: process.platform,
149
184
  arch: process.arch,
@@ -157,14 +192,25 @@ const toolFingerprint = crypto
157
192
  .digest("hex");
158
193
 
159
194
  function emptyCache() {
160
- return { version: CACHE_VERSION, toolFingerprint, typecheck: null, targets: {} };
195
+ return {
196
+ version: CACHE_VERSION,
197
+ toolFingerprint,
198
+ typecheck: null,
199
+ typechecks: {},
200
+ targets: {},
201
+ };
161
202
  }
162
203
 
163
204
  function readCache(file = CACHE_FILE) {
164
205
  try {
165
206
  const cache = JSON.parse(readFileSync(file, "utf8"));
166
207
  if (cache.version !== CACHE_VERSION) return emptyCache();
167
- return { ...emptyCache(), ...cache, targets: cache.targets || {} };
208
+ return {
209
+ ...emptyCache(),
210
+ ...cache,
211
+ typechecks: cache.typechecks || {},
212
+ targets: cache.targets || {},
213
+ };
168
214
  } catch {
169
215
  return emptyCache();
170
216
  }
@@ -231,7 +277,7 @@ function typecheckInputHash() {
231
277
  }
232
278
 
233
279
  function targetInputHash(target, dependencies) {
234
- const entry = relativeWorkspaceFile(path.join(target.kind.sourceRoot, target.scriptCode, "index.ts"));
280
+ const entry = relativeWorkspaceFile(targetEntryFile(target));
235
281
  return hashFileSet(
236
282
  [...configInputFiles(), entry, ...(dependencies || [])].filter(Boolean),
237
283
  `target:${target.kind.name}/${target.scriptCode}`,
@@ -258,11 +304,94 @@ function isTargetCacheHit(cache, target) {
258
304
  );
259
305
  }
260
306
 
261
- function typecheckScripts(cache) {
307
+ function formatTypeScriptDiagnostics(diagnostics) {
308
+ return ts.formatDiagnosticsWithColorAndContext(diagnostics, {
309
+ getCanonicalFileName: (file) => file,
310
+ getCurrentDirectory: () => rootDir,
311
+ getNewLine: () => "\n",
312
+ });
313
+ }
314
+
315
+ function scopedTypecheckKey(targets) {
316
+ return targets
317
+ .map((target) => `${target.kind.name}/${target.scriptCode}`)
318
+ .sort()
319
+ .join(",");
320
+ }
321
+
322
+ function scopedAmbientDeclarationFiles() {
323
+ return walkFiles(path.join(rootDir, "src"))
324
+ .filter((file) => file.endsWith(".d.ts"))
325
+ .map(relativeWorkspaceFile)
326
+ .filter(Boolean);
327
+ }
328
+
329
+ function createScopedTypecheckProgram(targets) {
330
+ const loaded = ts.readConfigFile(TSCONFIG_FILE, ts.sys.readFile);
331
+ if (loaded.error) {
332
+ process.stderr.write(formatTypeScriptDiagnostics([loaded.error]));
333
+ throw new Error("JS_CODE scoped TypeScript config validation failed");
334
+ }
335
+ const files = [
336
+ ...targets.map((target) =>
337
+ relativeWorkspaceFile(targetEntryFile(target)),
338
+ ),
339
+ ...scopedAmbientDeclarationFiles(),
340
+ ].filter(Boolean);
341
+ const parsed = ts.parseJsonConfigFileContent(
342
+ { ...loaded.config, files, include: [] },
343
+ ts.sys,
344
+ rootDir,
345
+ { noEmit: true },
346
+ TSCONFIG_FILE,
347
+ );
348
+ if (parsed.errors.length > 0) {
349
+ process.stderr.write(formatTypeScriptDiagnostics(parsed.errors));
350
+ throw new Error("JS_CODE scoped TypeScript config validation failed");
351
+ }
352
+ return ts.createProgram({
353
+ rootNames: parsed.fileNames,
354
+ options: parsed.options,
355
+ projectReferences: parsed.projectReferences,
356
+ });
357
+ }
358
+
359
+ function scopedTypecheckInputHash(program, cacheKey) {
360
+ const sourceFiles = program
361
+ .getSourceFiles()
362
+ .map((sourceFile) => relativeWorkspaceFile(sourceFile.fileName))
363
+ .filter(Boolean);
364
+ return hashFileSet([...configInputFiles(), ...sourceFiles], `typecheck:${cacheKey}`);
365
+ }
366
+
367
+ function typecheckSelectedScripts(cache, targets) {
368
+ const cacheKey = scopedTypecheckKey(targets);
369
+ const program = createScopedTypecheckProgram(targets);
370
+ const inputHash = scopedTypecheckInputHash(program, cacheKey);
371
+ if (
372
+ !forceBuild &&
373
+ cache.toolFingerprint === toolFingerprint &&
374
+ cache.typechecks?.[cacheKey]?.inputHash === inputHash
375
+ ) {
376
+ console.log(`[build-js-code] scoped TypeScript validation cache hit (${targets.length} selected)`);
377
+ return { cacheHit: true, cacheKey, inputHash, mode: "scoped" };
378
+ }
379
+
380
+ console.log(`[build-js-code] validating TypeScript for ${targets.length} selected target(s)...`);
381
+ const diagnostics = ts.getPreEmitDiagnostics(program);
382
+ if (diagnostics.length > 0) {
383
+ process.stderr.write(formatTypeScriptDiagnostics(diagnostics));
384
+ throw new Error("JS_CODE scoped TypeScript validation failed");
385
+ }
386
+ console.log("[build-js-code] scoped TypeScript validation passed");
387
+ return { cacheHit: false, cacheKey, inputHash, mode: "scoped" };
388
+ }
389
+
390
+ function typecheckAllScripts(cache) {
262
391
  const inputHash = typecheckInputHash();
263
392
  if (!forceBuild && cache.toolFingerprint === toolFingerprint && cache.typecheck?.inputHash === inputHash) {
264
393
  console.log("[build-js-code] TypeScript validation cache hit");
265
- return { cacheHit: true, inputHash };
394
+ return { cacheHit: true, inputHash, mode: "full" };
266
395
  }
267
396
 
268
397
  console.log("[build-js-code] validating TypeScript once for this batch...");
@@ -275,7 +404,13 @@ function typecheckScripts(cache) {
275
404
  console.log("[build-js-code] TypeScript validation passed");
276
405
  // pnpm may materialize/update a lockfile on the first invocation. Record the
277
406
  // post-validation inputs so the next process can reuse this successful tsc.
278
- return { cacheHit: false, inputHash: typecheckInputHash() };
407
+ return { cacheHit: false, inputHash: typecheckInputHash(), mode: "full" };
408
+ }
409
+
410
+ function typecheckScripts(cache, targets, scoped) {
411
+ return scoped
412
+ ? typecheckSelectedScripts(cache, targets)
413
+ : typecheckAllScripts(cache);
279
414
  }
280
415
 
281
416
  function collectBuildDependencies(buildResult, entry) {
@@ -296,7 +431,7 @@ function collectBuildDependencies(buildResult, entry) {
296
431
 
297
432
  async function buildScript(target) {
298
433
  const { kind, scriptCode } = target;
299
- const entry = path.join(kind.sourceRoot, scriptCode, "index.ts");
434
+ const entry = targetEntryFile(target);
300
435
  if (!existsSync(entry)) throw new Error(`${kind.label} script not found: ${entry}`);
301
436
  const outDir = path.join(kind.outputRoot, scriptCode);
302
437
  const external = Array.from(
@@ -361,14 +496,31 @@ function saveCache(typecheck, updatedTargets) {
361
496
  if (typecheck.cacheHit && updatedTargets.size === 0) return;
362
497
  const lockFd = acquireCacheLock();
363
498
  try {
364
- const latest = readCache();
499
+ const stored = readCache();
500
+ // A scoped build updates only part of the cache. Never carry metadata from
501
+ // a different compiler/builder fingerprint into the new cache, otherwise
502
+ // untouched targets could become false hits after this process stamps the
503
+ // cache with the current fingerprint.
504
+ const latest = stored.toolFingerprint === toolFingerprint
505
+ ? stored
506
+ : emptyCache();
507
+ const validatedAt = new Date().toISOString();
508
+ const mergedTypechecks = { ...latest.typechecks };
509
+ if (!typecheck.cacheHit && typecheck.mode === "scoped") {
510
+ mergedTypechecks[typecheck.cacheKey] = {
511
+ inputHash: typecheck.inputHash,
512
+ validatedAt,
513
+ };
514
+ }
365
515
  const merged = {
366
516
  ...latest,
367
517
  version: CACHE_VERSION,
368
518
  toolFingerprint,
369
- typecheck: typecheck.cacheHit
370
- ? latest.typecheck
371
- : { inputHash: typecheck.inputHash, validatedAt: new Date().toISOString() },
519
+ typecheck:
520
+ !typecheck.cacheHit && typecheck.mode === "full"
521
+ ? { inputHash: typecheck.inputHash, validatedAt }
522
+ : latest.typecheck,
523
+ typechecks: mergedTypechecks,
372
524
  targets: { ...latest.targets, ...Object.fromEntries(updatedTargets) },
373
525
  };
374
526
  const tempFile = `${CACHE_FILE}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
@@ -398,7 +550,7 @@ function saveCache(typecheck, updatedTargets) {
398
550
  }
399
551
  }
400
552
 
401
- const targets = resolveBuildTargets();
553
+ const { scoped, targets } = resolveBuildSelection();
402
554
  if (targets.length === 0) {
403
555
  console.log(
404
556
  "no JS_CODE scripts found under src/js-code-nodes/<scriptCode>/index.ts, src/automations/<scriptCode>/index.ts, or src/functions/<functionCode>/index.ts",
@@ -407,7 +559,7 @@ if (targets.length === 0) {
407
559
  }
408
560
 
409
561
  const cache = readCache();
410
- const typecheck = typecheckScripts(cache);
562
+ const typecheck = typecheckScripts(cache, targets, scoped);
411
563
  const updatedTargets = new Map();
412
564
  let builtCount = 0;
413
565
  let cachedCount = 0;