openxiangda 1.0.178 → 1.0.180
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 +18 -1
- package/lib/application-environments.js +282 -0
- package/lib/cli.js +1122 -49
- package/lib/developer-center.js +157 -0
- package/lib/policy.js +16 -0
- package/openxiangda-skills/SKILL.md +13 -2
- package/openxiangda-skills/references/openxiangda-api.md +40 -0
- package/openxiangda-skills/references/workspace-state.md +37 -0
- package/openxiangda-skills/skills/openxiangda-core/SKILL.md +12 -1
- package/package.json +2 -1
- package/policy/openxiangda-policy.json +7 -2
- package/templates/openxiangda-react-spa/.cursor/rules/openxiangda.mdc +2 -1
- package/templates/openxiangda-react-spa/.qoder/rules/openxiangda.md +2 -1
- package/templates/openxiangda-react-spa/AGENTS.md +14 -2
- package/templates/openxiangda-react-spa/scripts/deploy.mjs +3 -1
- package/templates/sy-lowcode-app-workspace/.cursor/rules/openxiangda.mdc +2 -0
- package/templates/sy-lowcode-app-workspace/.qoder/rules/openxiangda.md +2 -0
- package/templates/sy-lowcode-app-workspace/AGENTS.md +4 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- OpenXiangda-Policy-Version:
|
|
1
|
+
<!-- OpenXiangda-Policy-Version: 4 -->
|
|
2
2
|
# OpenXiangda
|
|
3
3
|
|
|
4
4
|
OpenXiangda is a lightweight CLI and skill package for private low-code platforms.
|
|
@@ -48,6 +48,17 @@ openxiangda sdd bundle mainline-release --changes add-customer-page,fix-customer
|
|
|
48
48
|
openxiangda release publish --change mainline-release --profile dev
|
|
49
49
|
openxiangda release status --change mainline-release --watch
|
|
50
50
|
openxiangda release explain --change mainline-release --profile dev
|
|
51
|
+
# 环境托管应用一次性登记(已有应用通常作为 preproduction)
|
|
52
|
+
openxiangda environment init --logical-app example-app --name "示例应用" --kind preproduction --app-type APP_PRE --profile dev
|
|
53
|
+
openxiangda environment bind production --app-type APP_PROD --profile dev
|
|
54
|
+
openxiangda environment attach --logical-app example-app --preproduction-target example-pre --production-target example-prod --profile dev
|
|
55
|
+
openxiangda environment status --json
|
|
56
|
+
openxiangda studio
|
|
57
|
+
# 环境托管应用日常发布:冻结候选 → 预发 → 测试证据 → 同候选晋级正式
|
|
58
|
+
openxiangda release candidate --change mainline-release --environment preproduction
|
|
59
|
+
openxiangda release deploy --candidate <candidate-id> --environment preproduction
|
|
60
|
+
openxiangda release test --deployment <deployment-id> --environment preproduction --evidence-json ./test-evidence.json
|
|
61
|
+
openxiangda release promote --candidate <candidate-id> --environment production --confirm-production
|
|
51
62
|
openxiangda policy check
|
|
52
63
|
openxiangda resource explain public-access --json
|
|
53
64
|
openxiangda inspect app --profile dev --json
|
|
@@ -56,6 +67,12 @@ openxiangda app snapshot APP_XXXX --profile dev --json
|
|
|
56
67
|
|
|
57
68
|
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.
|
|
58
69
|
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
`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.
|
|
75
|
+
|
|
59
76
|
`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.
|
|
60
77
|
|
|
61
78
|
For Function/Automation plans, `formFieldContracts` checks statically declared
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const ENVIRONMENT_KINDS = new Set(['preproduction', 'production']);
|
|
6
|
+
|
|
7
|
+
function canonicalJson(value) {
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
return `[${value.map(item => canonicalJson(item)).join(',')}]`;
|
|
10
|
+
}
|
|
11
|
+
if (value && typeof value === 'object') {
|
|
12
|
+
return `{${Object.keys(value)
|
|
13
|
+
.sort()
|
|
14
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
15
|
+
.join(',')}}`;
|
|
16
|
+
}
|
|
17
|
+
return JSON.stringify(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256Canonical(value) {
|
|
21
|
+
return crypto.createHash('sha256').update(canonicalJson(value)).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeEnvironmentKind(value, label = 'environment') {
|
|
25
|
+
const kind = String(value || '').trim().toLowerCase();
|
|
26
|
+
if (!ENVIRONMENT_KINDS.has(kind)) {
|
|
27
|
+
throw new Error(`${label} 必须是 preproduction 或 production`);
|
|
28
|
+
}
|
|
29
|
+
return kind;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeTargetName(value, kind) {
|
|
33
|
+
const targetName = String(value || kind || '').trim();
|
|
34
|
+
if (!/^[a-z][a-z0-9-]*$/.test(targetName)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
'target 名称必须以小写字母开头,且只包含小写字母、数字和连字符'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return targetName;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cloneJsonValue(value) {
|
|
43
|
+
return value === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: JSON.parse(JSON.stringify(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasStateResourceMappings(value) {
|
|
49
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
50
|
+
if (!value || typeof value !== 'object') {
|
|
51
|
+
return value !== undefined && value !== null && value !== '';
|
|
52
|
+
}
|
|
53
|
+
return Object.values(value).some(item => hasStateResourceMappings(item));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveEnvironmentTarget(state, selector) {
|
|
57
|
+
const targets = state?.targets || {};
|
|
58
|
+
const requested = String(selector || state?.currentTarget || '').trim();
|
|
59
|
+
if (!requested) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'当前工作区已启用应用环境,请显式使用 --environment <target|preproduction|production>'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (targets[requested]) {
|
|
65
|
+
return { targetName: requested, binding: targets[requested] };
|
|
66
|
+
}
|
|
67
|
+
const matches = Object.entries(targets).filter(
|
|
68
|
+
([, binding]) => binding?.kind === requested
|
|
69
|
+
);
|
|
70
|
+
if (matches.length !== 1) {
|
|
71
|
+
throw new Error(`应用环境 target 不存在或不唯一: ${requested}`);
|
|
72
|
+
}
|
|
73
|
+
return { targetName: matches[0][0], binding: matches[0][1] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bindEnvironmentTarget(state, input) {
|
|
77
|
+
const kind = normalizeEnvironmentKind(input.kind);
|
|
78
|
+
const targetName = normalizeTargetName(input.targetName, kind);
|
|
79
|
+
const appType = String(input.appType || '').trim();
|
|
80
|
+
const profile = String(input.profile || '').trim();
|
|
81
|
+
const environmentId = String(input.environmentId || '').trim();
|
|
82
|
+
if (!appType || !profile || !environmentId) {
|
|
83
|
+
throw new Error('环境 target 缺少 appType、profile 或 environmentId');
|
|
84
|
+
}
|
|
85
|
+
state.targets = state.targets || {};
|
|
86
|
+
const previous = state.targets[targetName] || {};
|
|
87
|
+
const previousResources = previous.resources || {};
|
|
88
|
+
const resources = hasStateResourceMappings(previousResources)
|
|
89
|
+
? previousResources
|
|
90
|
+
: cloneJsonValue(input.seedResources || previousResources) || {};
|
|
91
|
+
state.targets[targetName] = {
|
|
92
|
+
...previous,
|
|
93
|
+
targetName,
|
|
94
|
+
profile,
|
|
95
|
+
environmentId,
|
|
96
|
+
kind,
|
|
97
|
+
appType,
|
|
98
|
+
displayName: input.displayName || previous.displayName || kind,
|
|
99
|
+
publicOrigin:
|
|
100
|
+
input.publicOrigin === undefined
|
|
101
|
+
? previous.publicOrigin || null
|
|
102
|
+
: input.publicOrigin,
|
|
103
|
+
sideEffectPolicy:
|
|
104
|
+
input.sideEffectPolicy === undefined
|
|
105
|
+
? previous.sideEffectPolicy || {}
|
|
106
|
+
: input.sideEffectPolicy,
|
|
107
|
+
resources,
|
|
108
|
+
updatedAt: new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
if (!state.currentTarget || kind === 'preproduction') {
|
|
111
|
+
state.currentTarget = targetName;
|
|
112
|
+
}
|
|
113
|
+
return state.targets[targetName];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveManagedStateBinding(target) {
|
|
117
|
+
if (!target?.targetName) return null;
|
|
118
|
+
const binding = target.state?.targets?.[target.targetName];
|
|
119
|
+
if (!binding) {
|
|
120
|
+
throw new Error(`环境 target 状态不存在: ${target.targetName}`);
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
binding.appType !== target.appType ||
|
|
124
|
+
(target.environmentId &&
|
|
125
|
+
binding.environmentId !== target.environmentId)
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`环境 target 状态与当前写入目标不一致: ${target.targetName}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return binding;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function candidateDirectory(cwd = process.cwd()) {
|
|
135
|
+
return path.join(cwd, '.openxiangda', 'candidates');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function candidateFile(candidateId, cwd = process.cwd()) {
|
|
139
|
+
const normalized = String(candidateId || '').trim();
|
|
140
|
+
if (!/^[0-9a-f-]{36}$/i.test(normalized)) {
|
|
141
|
+
throw new Error('candidateId 必须是 UUID');
|
|
142
|
+
}
|
|
143
|
+
return path.join(candidateDirectory(cwd), `${normalized}.json`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writePrivateJsonAtomic(file, value) {
|
|
147
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
148
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
149
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
150
|
+
mode: 0o600,
|
|
151
|
+
});
|
|
152
|
+
fs.renameSync(temp, file);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function saveCandidate(candidate, cwd = process.cwd()) {
|
|
156
|
+
if (!candidate?.id) throw new Error('候选版本缺少 id');
|
|
157
|
+
const file = candidateFile(candidate.id, cwd);
|
|
158
|
+
writePrivateJsonAtomic(file, candidate);
|
|
159
|
+
return file;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readCandidate(candidateId, cwd = process.cwd()) {
|
|
163
|
+
const file = candidateFile(candidateId, cwd);
|
|
164
|
+
if (!fs.existsSync(file)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`本地候选版本不存在: ${path.relative(cwd, file)};请先执行 release candidate`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error(`候选版本文件无效: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readJsonInput(value, label, cwd = process.cwd()) {
|
|
177
|
+
const input = String(value || '').trim();
|
|
178
|
+
if (!input) throw new Error(`${label} 不能为空`);
|
|
179
|
+
const possibleFile = path.resolve(cwd, input);
|
|
180
|
+
const text = fs.existsSync(possibleFile)
|
|
181
|
+
? fs.readFileSync(possibleFile, 'utf8')
|
|
182
|
+
: input;
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(text);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new Error(`${label} 必须是 JSON 或 JSON 文件: ${error.message}`);
|
|
188
|
+
}
|
|
189
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
190
|
+
throw new Error(`${label} 必须是 JSON 对象`);
|
|
191
|
+
}
|
|
192
|
+
return parsed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function hashWorkspaceFile(cwd, relativePath) {
|
|
196
|
+
const normalized = String(relativePath || '').replace(/\\/g, '/');
|
|
197
|
+
const absolute = path.resolve(cwd, normalized);
|
|
198
|
+
const relative = path.relative(cwd, absolute);
|
|
199
|
+
if (
|
|
200
|
+
!normalized ||
|
|
201
|
+
relative.startsWith('..') ||
|
|
202
|
+
path.isAbsolute(relative) ||
|
|
203
|
+
!fs.existsSync(absolute) ||
|
|
204
|
+
!fs.statSync(absolute).isFile()
|
|
205
|
+
) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const buffer = fs.readFileSync(absolute);
|
|
209
|
+
return {
|
|
210
|
+
path: normalized,
|
|
211
|
+
size: buffer.length,
|
|
212
|
+
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildCandidateBundle(input) {
|
|
217
|
+
const cwd = path.resolve(input.cwd || process.cwd());
|
|
218
|
+
const files = Array.from(new Set(input.files || []))
|
|
219
|
+
.sort()
|
|
220
|
+
.map(file => hashWorkspaceFile(cwd, file))
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
const testPlan = input.testPlan || {
|
|
223
|
+
schemaVersion: 1,
|
|
224
|
+
requiredGates: [
|
|
225
|
+
'static',
|
|
226
|
+
'schema',
|
|
227
|
+
'appFunctions',
|
|
228
|
+
'roles',
|
|
229
|
+
'browser',
|
|
230
|
+
'lifecycle',
|
|
231
|
+
'cleanup',
|
|
232
|
+
],
|
|
233
|
+
maximumEvidenceAgeHours: 24,
|
|
234
|
+
};
|
|
235
|
+
const sourceBundleManifest = {
|
|
236
|
+
schemaVersion: 1,
|
|
237
|
+
logicalAppCode: input.logicalAppCode,
|
|
238
|
+
changeId: input.changeId,
|
|
239
|
+
sourceRevision: input.sourceRevision,
|
|
240
|
+
runtimeMode: input.runtimeMode,
|
|
241
|
+
targets: input.targets,
|
|
242
|
+
files,
|
|
243
|
+
testPlanHash: sha256Canonical(testPlan),
|
|
244
|
+
openxiangdaVersion: input.openxiangdaVersion,
|
|
245
|
+
};
|
|
246
|
+
return {
|
|
247
|
+
sourceBundleManifest,
|
|
248
|
+
candidateHash: sha256Canonical(sourceBundleManifest),
|
|
249
|
+
testPlan,
|
|
250
|
+
testPlanHash: sourceBundleManifest.testPlanHash,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function releaseExecutionPath(changeId, deploymentId, cwd = process.cwd()) {
|
|
255
|
+
const base = path.join(
|
|
256
|
+
cwd,
|
|
257
|
+
'.openxiangda',
|
|
258
|
+
'releases',
|
|
259
|
+
String(changeId || '').trim()
|
|
260
|
+
);
|
|
261
|
+
const deployment = String(deploymentId || '').trim();
|
|
262
|
+
return deployment
|
|
263
|
+
? path.join(base, deployment, 'execution.json')
|
|
264
|
+
: path.join(base, 'execution.json');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
bindEnvironmentTarget,
|
|
269
|
+
buildCandidateBundle,
|
|
270
|
+
canonicalJson,
|
|
271
|
+
normalizeEnvironmentKind,
|
|
272
|
+
normalizeTargetName,
|
|
273
|
+
hasStateResourceMappings,
|
|
274
|
+
readCandidate,
|
|
275
|
+
readJsonInput,
|
|
276
|
+
releaseExecutionPath,
|
|
277
|
+
resolveManagedStateBinding,
|
|
278
|
+
resolveEnvironmentTarget,
|
|
279
|
+
saveCandidate,
|
|
280
|
+
sha256Canonical,
|
|
281
|
+
writePrivateJsonAtomic,
|
|
282
|
+
};
|