openxiangda 1.0.197 → 1.0.199

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
@@ -55,12 +55,20 @@ openxiangda environment attach --logical-app example-app --preproduction-target
55
55
  openxiangda environment status --json
56
56
  # 仅限投产前环境角色重分类:原子交换两个既有应用的角色,应用数据和 Release Head 不移动
57
57
  openxiangda environment swap --reason "投产前将现有业务应用调整为正式应用" --confirm-production --profile dev
58
+ # 单独调整一个环境的副作用策略:先只读预览,再按当前 revision 审计式更新;不会交换环境角色
59
+ openxiangda environment policy update preproduction \
60
+ --side-effect-policy-json '{"organizationWrites":"explicit_capability_only"}' \
61
+ --reason "为预发真实 UAT 开放受权限保护的组织写入" --dry-run --profile dev
62
+ openxiangda environment policy update preproduction \
63
+ --side-effect-policy-json '{"organizationWrites":"explicit_capability_only"}' \
64
+ --reason "为预发真实 UAT 开放受权限保护的组织写入" --profile dev
58
65
  openxiangda studio
59
- # 环境托管应用日常发布:冻结候选 → 预发 → 测试证据 → 同候选晋级正式
60
- openxiangda release candidate --change mainline-release --environment preproduction
61
- openxiangda release deploy --candidate <candidate-id> --environment preproduction
62
- openxiangda release test --deployment <deployment-id> --environment preproduction --evidence-json ./test-evidence.json
63
- openxiangda release promote --candidate <candidate-id> --environment production --confirm-production
66
+ # 环境托管应用日常发布:首次命令只部署预发并停止
67
+ openxiangda release ship --change mainline-release --profile dev
68
+
69
+ # 确认预发结果后,再以同一 candidate 晋级正式;验收备注可选
70
+ openxiangda release ship --change mainline-release --profile dev \
71
+ --confirm-production
64
72
  openxiangda policy check
65
73
  openxiangda resource explain public-access --json
66
74
  openxiangda inspect app --profile dev --json
@@ -69,12 +77,16 @@ openxiangda app snapshot APP_XXXX --profile dev --json
69
77
 
70
78
  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; CLI writes use a workspace lock plus atomic merge/rename so concurrent processes do not truncate another profile's state.
71
79
 
72
- An environment-managed workspace keeps one logical application with independent `preproduction` and `production` targets. Each target owns its own `appType`, resource IDs, release heads, data, and side-effect policy; IDs must never be copied across targets. `release candidate` seals the clean authoritative Git revision, exact changed files, resource targets, SDD change, and test plan into an immutable candidate. `release deploy` can only deploy it to preproduction. `release test` attaches deployment-bound, hash-verified evidence. `release promote` can only promote that exact candidate to production after the platform verifies fresh passing preproduction evidence and zero candidate drift. Direct `release publish` is retained only for legacy, unmanaged workspaces and fails closed for either target of an environment-managed application.
80
+ An environment-managed workspace keeps one logical application with independent `preproduction` and `production` targets. Each target owns its own `appType`, resource IDs, release heads, data, and side-effect policy; IDs must never be copied across targets. `release ship` is deliberately two-phase: the first invocation seals the clean authoritative Git revision and deploys one immutable candidate only to preproduction; it always stops at `awaiting_production_confirmation`. A later invocation includes `--confirm-production` and promotes that exact candidate to production without rebuilding it. Real human acceptance is the recommended default; an optional `--acceptance-note` records it in the deployment evidence, but the platform does not force every low-risk or emergency release through a rigid approval gate. It is impossible to prepare and promote in one invocation. The lower-level `candidate/deploy/test/promote` commands remain available for recovery and diagnostics. Direct `release publish` is retained only for legacy, unmanaged workspaces and fails closed for either target of an environment-managed application.
81
+
82
+ Exact, non-destructive configuration selectors such as Data Views and permission groups are now sequenced automatically inside the same ship journal instead of requiring separate SDD changes. New forms are idempotently ensured per environment before their immutable FormRelease is staged. Unscoped resources and destructive configuration deletes remain fail-closed.
73
83
 
74
84
  Existing workspaces connect to a server-side environment set with `environment attach`. If the legacy `profiles.<profile>` binding has the same `appType` as one environment, its resource mappings are copied only into that matching target (normally preproduction). Production starts with an empty mapping, and later writes update only the selected target even when both targets reuse one login profile.
75
85
 
76
86
  `environment swap` is a commissioning/reclassification operation, not a release shortcut. It requires `--confirm-production` and a reason, atomically exchanges the two existing environment roles, preserves each application's data and Release Heads, and remaps local resource IDs by appType. Existing side-effect policies remain restricted unless explicit replacement policy JSON is supplied.
77
87
 
88
+ Use `environment policy update <kind|id>` when only one environment's side-effect policy must change. The CLI reads the current revision and shows the exact before/after diff; `--dry-run` performs GET only. The write uses CAS, increments only that environment revision, writes a durable before/after audit, and preserves unspecified fields unless `--full-replace` is explicit. Production writes additionally require `--confirm-production`. `organizationWrites=explicit_capability_only` removes the environment-level deny but still requires `app:organization:manage`; there is no unrestricted bypass mode.
89
+
78
90
  `openxiangda studio` starts a loopback-only local Developer Center with a random session token. It shows target bindings, candidate/deployment status, Git state, test evidence, drift, and the next safe action. Its buttons invoke only registered OpenXiangda operations; it does not expose arbitrary shell execution. Production promotion and rollback still require an explicit production confirmation.
79
91
 
80
92
  `resource plan` and `resource publish --dry-run` run behind a strict GET/HEAD-only HTTP guard. If a read receives HTTP 401, the command fails with `READ_ONLY_AUTH_REQUIRED` and never calls the token refresh POST from inside the plan. Run `openxiangda auth refresh --profile <name>` (or log in again) before retrying; a plan must not mutate auth state or platform resources.
@@ -3,6 +3,20 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
 
5
5
  const ENVIRONMENT_KINDS = new Set(['preproduction', 'production']);
6
+ const SIDE_EFFECT_POLICY_ENUMS = Object.freeze({
7
+ notifications: ['disabled', 'tester_allowlist', 'enabled', 'real'],
8
+ organizationWrites: ['deny', 'explicit_capability_only'],
9
+ scheduledAutomations: ['disabled', 'enabled'],
10
+ externalWrites: ['deny', 'allowlist', 'configured', 'sandbox'],
11
+ payments: ['deny', 'configured', 'sandbox'],
12
+ publicIndexing: ['deny', 'configured', 'enabled'],
13
+ });
14
+ const SIDE_EFFECT_POLICY_FIELDS = new Set([
15
+ ...Object.keys(SIDE_EFFECT_POLICY_ENUMS),
16
+ 'notificationAllowlist',
17
+ 'environmentBanner',
18
+ 'externalDingTalkDepartmentRootId',
19
+ ]);
6
20
 
7
21
  function canonicalJson(value) {
8
22
  if (Array.isArray(value)) {
@@ -45,6 +59,114 @@ function cloneJsonValue(value) {
45
59
  : JSON.parse(JSON.stringify(value));
46
60
  }
47
61
 
62
+ function normalizeSideEffectPolicyInput(value, label = 'sideEffectPolicy') {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
64
+ throw new Error(`${label} 必须是对象`);
65
+ }
66
+ for (const key of Object.keys(value)) {
67
+ if (!SIDE_EFFECT_POLICY_FIELDS.has(key)) {
68
+ throw new Error(`${label}.${key} 不是允许的副作用策略字段`);
69
+ }
70
+ }
71
+ const result = {};
72
+ for (const [key, allowed] of Object.entries(SIDE_EFFECT_POLICY_ENUMS)) {
73
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
74
+ const normalized = String(value[key] || '').trim();
75
+ if (!allowed.includes(normalized)) {
76
+ throw new Error(`${label}.${key} 必须是 ${allowed.join('、')} 之一`);
77
+ }
78
+ result[key] = normalized;
79
+ }
80
+ if (Object.prototype.hasOwnProperty.call(value, 'notificationAllowlist')) {
81
+ if (!Array.isArray(value.notificationAllowlist)) {
82
+ throw new Error(`${label}.notificationAllowlist 必须是数组`);
83
+ }
84
+ if (value.notificationAllowlist.length > 500) {
85
+ throw new Error(`${label}.notificationAllowlist 最多包含 500 项`);
86
+ }
87
+ result.notificationAllowlist = Array.from(
88
+ new Set(
89
+ value.notificationAllowlist.map((item, index) => {
90
+ const normalized = String(item ?? '').trim();
91
+ if (!normalized || normalized.length > 255) {
92
+ throw new Error(
93
+ `${label}.notificationAllowlist[${index}] 必须是 1-255 字符的字符串`,
94
+ );
95
+ }
96
+ return normalized;
97
+ }),
98
+ ),
99
+ );
100
+ }
101
+ if (Object.prototype.hasOwnProperty.call(value, 'environmentBanner')) {
102
+ if (typeof value.environmentBanner !== 'boolean') {
103
+ throw new Error(`${label}.environmentBanner 必须是布尔值`);
104
+ }
105
+ result.environmentBanner = value.environmentBanner;
106
+ }
107
+ if (
108
+ Object.prototype.hasOwnProperty.call(
109
+ value,
110
+ 'externalDingTalkDepartmentRootId',
111
+ )
112
+ ) {
113
+ const raw = value.externalDingTalkDepartmentRootId;
114
+ if (
115
+ !['string', 'number'].includes(typeof raw) ||
116
+ (typeof raw === 'number' && !Number.isSafeInteger(raw))
117
+ ) {
118
+ throw new Error(
119
+ `${label}.externalDingTalkDepartmentRootId 必须是字符串或安全整数`,
120
+ );
121
+ }
122
+ const normalized = String(raw).trim();
123
+ if (!/^\d{1,64}$/.test(normalized)) {
124
+ throw new Error(
125
+ `${label}.externalDingTalkDepartmentRootId 必须是数字型钉钉部门 ID`,
126
+ );
127
+ }
128
+ result.externalDingTalkDepartmentRootId = normalized;
129
+ }
130
+ return result;
131
+ }
132
+
133
+ function buildSideEffectPolicyDiff(beforeInput, updateInput, options = {}) {
134
+ const before = normalizeSideEffectPolicyInput(
135
+ beforeInput || {},
136
+ 'currentSideEffectPolicy',
137
+ );
138
+ const update = normalizeSideEffectPolicyInput(
139
+ updateInput,
140
+ 'sideEffectPolicy',
141
+ );
142
+ const fullReplace = options.fullReplace === true;
143
+ const after = normalizeSideEffectPolicyInput(
144
+ fullReplace ? update : { ...before, ...update },
145
+ 'nextSideEffectPolicy',
146
+ );
147
+ const fields = Array.from(
148
+ new Set([...Object.keys(before), ...Object.keys(after)]),
149
+ ).sort();
150
+ const changes = fields
151
+ .filter(key => canonicalJson(before[key]) !== canonicalJson(after[key]))
152
+ .map(key => ({
153
+ field: key,
154
+ before: Object.prototype.hasOwnProperty.call(before, key)
155
+ ? cloneJsonValue(before[key])
156
+ : null,
157
+ after: Object.prototype.hasOwnProperty.call(after, key)
158
+ ? cloneJsonValue(after[key])
159
+ : null,
160
+ }));
161
+ return {
162
+ fullReplace,
163
+ changed: changes.length > 0,
164
+ before,
165
+ after,
166
+ changes,
167
+ };
168
+ }
169
+
48
170
  function normalizeIdentityList(values) {
49
171
  return Array.from(
50
172
  new Set(
@@ -203,6 +325,10 @@ function bindEnvironmentTarget(state, input) {
203
325
  input.sideEffectPolicy === undefined
204
326
  ? previous.sideEffectPolicy || {}
205
327
  : input.sideEffectPolicy,
328
+ revision:
329
+ input.revision === undefined
330
+ ? previous.revision || null
331
+ : Number(input.revision),
206
332
  resources,
207
333
  updatedAt: new Date().toISOString(),
208
334
  };
@@ -359,6 +485,8 @@ function buildCandidateBundle(input) {
359
485
  targets: input.targets,
360
486
  files,
361
487
  testPlanHash: sha256Canonical(testPlan),
488
+ prepublishVerificationHash:
489
+ input.prepublishVerificationHash || null,
362
490
  openxiangdaVersion: input.openxiangdaVersion,
363
491
  };
364
492
  return {
@@ -447,12 +575,14 @@ function isRecoverablePostActivationDeploymentError(error, deployment) {
447
575
 
448
576
  module.exports = {
449
577
  bindEnvironmentTarget,
578
+ buildSideEffectPolicyDiff,
450
579
  buildCandidateBundle,
451
580
  canonicalJson,
452
581
  isRecoverablePostActivationDeploymentError,
453
582
  normalizeEnvironmentKind,
454
583
  normalizeManagedChangeSourceBase,
455
584
  normalizeManagedReleaseSourceRevision,
585
+ normalizeSideEffectPolicyInput,
456
586
  normalizeTargetName,
457
587
  hasStateResourceMappings,
458
588
  readCandidate,