openxiangda 1.0.21 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +28 -10
  2. package/lib/cli.js +723 -11
  3. package/lib/workspace-init.js +13 -0
  4. package/openxiangda-skills/SKILL.md +26 -10
  5. package/openxiangda-skills/references/architecture-patterns.md +44 -22
  6. package/openxiangda-skills/references/automation-v3.md +2 -0
  7. package/openxiangda-skills/references/best-practices.md +163 -0
  8. package/openxiangda-skills/references/connector-resources.md +3 -0
  9. package/openxiangda-skills/references/notifications.md +80 -0
  10. package/openxiangda-skills/references/openxiangda-api.md +45 -0
  11. package/openxiangda-skills/references/pages/page-sdk.md +1 -0
  12. package/openxiangda-skills/references/pages/workspace-structure.md +5 -3
  13. package/openxiangda-skills/references/workspace-state.md +6 -0
  14. package/openxiangda-skills/skills/openxiangda-app/SKILL.md +11 -7
  15. package/openxiangda-skills/skills/openxiangda-core/SKILL.md +22 -4
  16. package/openxiangda-skills/skills/openxiangda-form/SKILL.md +6 -1
  17. package/openxiangda-skills/skills/openxiangda-page/SKILL.md +9 -1
  18. package/openxiangda-skills/skills/openxiangda-permission-settings/SKILL.md +3 -0
  19. package/openxiangda-skills/skills/openxiangda-workflow-automation/SKILL.md +9 -0
  20. package/package.json +1 -1
  21. package/packages/sdk/dist/runtime/index.cjs +34 -2
  22. package/packages/sdk/dist/runtime/index.cjs.map +1 -1
  23. package/packages/sdk/dist/runtime/index.d.mts +66 -1
  24. package/packages/sdk/dist/runtime/index.d.ts +66 -1
  25. package/packages/sdk/dist/runtime/index.mjs +34 -2
  26. package/packages/sdk/dist/runtime/index.mjs.map +1 -1
  27. package/templates/sy-lowcode-app-workspace/examples/best-practices/README.md +32 -0
  28. package/templates/sy-lowcode-app-workspace/examples/best-practices/catalog.json +61 -0
  29. package/templates/sy-lowcode-app-workspace/examples/best-practices/decision-guide.md +44 -0
  30. package/templates/sy-lowcode-app-workspace/examples/best-practices/design-style.md +30 -0
  31. package/templates/sy-lowcode-app-workspace/examples/best-practices/module-structure.md +48 -0
  32. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/index.ts +2 -0
  33. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.test.ts +35 -0
  34. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/permissions.ts +24 -0
  35. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/role-governance/types.ts +17 -0
  36. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/index.ts +4 -0
  37. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.test.ts +42 -0
  38. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/permissions.ts +23 -0
  39. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.test.ts +63 -0
  40. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/state-machine.ts +73 -0
  41. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.test.ts +34 -0
  42. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/ticket-query.ts +73 -0
  43. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/domain/service-ticket/types.ts +64 -0
  44. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/page.tsx +1 -0
  45. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/app-role/schema.ts +57 -0
  46. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/customer-profile/page.tsx +1 -0
  47. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/customer-profile/schema.ts +83 -0
  48. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/service-ticket/page.tsx +1 -0
  49. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/service-ticket/schema.ts +97 -0
  50. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/ticket-action-log/page.tsx +1 -0
  51. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/forms/ticket-action-log/schema.ts +65 -0
  52. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/js-code-nodes/daily_ticket_digest/index.ts +44 -0
  53. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/js-code-nodes/sync_roles_to_platform/index.ts +33 -0
  54. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/App.tsx +7 -0
  55. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/WorkbenchPage.tsx +36 -0
  56. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/components/ConfigPanel.tsx +34 -0
  57. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/components/PreviewPanel.tsx +17 -0
  58. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/index.tsx +10 -0
  59. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/page.config.ts +9 -0
  60. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/reducer.ts +29 -0
  61. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/interactive-workbench/styles.css +24 -0
  62. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/App.tsx +7 -0
  63. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/MobilePortalShell.tsx +31 -0
  64. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/index.tsx +10 -0
  65. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/modules/MobileHome.tsx +13 -0
  66. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/page.config.ts +14 -0
  67. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/routes.ts +13 -0
  68. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/mobile-portal-shell/styles.css +11 -0
  69. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/App.tsx +7 -0
  70. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/PcPortalShell.tsx +35 -0
  71. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/components/PortalMetric.tsx +11 -0
  72. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/index.tsx +10 -0
  73. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/modules/HomeModule.tsx +25 -0
  74. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/modules/TicketsModule.tsx +14 -0
  75. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/page.config.ts +14 -0
  76. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/routes.ts +19 -0
  77. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/pc-portal-shell/styles.css +35 -0
  78. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/App.tsx +7 -0
  79. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/TicketOpsPage.tsx +105 -0
  80. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketActionTimeline.tsx +22 -0
  81. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketDetailDrawer.tsx +41 -0
  82. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/components/TicketTableActions.tsx +55 -0
  83. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/index.tsx +10 -0
  84. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/page.config.ts +9 -0
  85. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/pages/service-ticket-ops/styles.css +35 -0
  86. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/automations/daily-ticket-digest/automation.json +25 -0
  87. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/automations/daily-ticket-digest/trigger.json +9 -0
  88. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/notifications/daily-ticket-digest.json +24 -0
  89. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/form-groups/service-ticket-college.json +21 -0
  90. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/permissions/roles.json +17 -0
  91. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/resources/workflows/expense-approval-workflow.json +48 -0
  92. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/ConfirmAction.tsx +22 -0
  93. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/QueryState.tsx +37 -0
  94. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/components/StatusTag.tsx +20 -0
  95. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/hooks/useTicketOps.ts +96 -0
  96. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/services/role-governance.ts +48 -0
  97. package/templates/sy-lowcode-app-workspace/examples/best-practices/src/shared/services/service-ticket.ts +113 -0
  98. package/templates/sy-lowcode-app-workspace/package.json +1 -0
  99. package/templates/sy-lowcode-app-workspace/src/dev/App.tsx +11 -1
  100. package/templates/sy-lowcode-app-workspace/tsconfig.examples.json +24 -0
package/lib/cli.js CHANGED
@@ -14,7 +14,7 @@ const {
14
14
  } = require('./config');
15
15
  const { requestJson } = require('./http');
16
16
  const { getSkillStatusReport, installSkills } = require('./skills');
17
- const { initWorkspace } = require('./workspace-init');
17
+ const { assertCanInitializeWorkspace, initWorkspace } = require('./workspace-init');
18
18
  const {
19
19
  fail,
20
20
  openBrowser,
@@ -24,6 +24,10 @@ const {
24
24
  warn,
25
25
  writeJson,
26
26
  } = require('./utils');
27
+ const { version: CURRENT_VERSION } = require('../package.json');
28
+
29
+ const NPM_PACKAGE_NAME = 'openxiangda';
30
+ const OFFICIAL_NPM_REGISTRY = 'https://registry.npmjs.org';
27
31
 
28
32
  async function main(argv) {
29
33
  const [command, ...rest] = argv;
@@ -33,6 +37,7 @@ async function main(argv) {
33
37
  }
34
38
 
35
39
  if (command === 'login') return login(rest);
40
+ if (command === 'update') return update(rest);
36
41
  if (command === 'env') return env(rest);
37
42
  if (command === 'auth') return auth(rest);
38
43
  if (command === 'platform') return platform(rest);
@@ -58,12 +63,14 @@ function printHelp() {
58
63
 
59
64
  Usage:
60
65
  openxiangda login <platform-url> [--profile name]
66
+ openxiangda update check|install [--json] [--registry https://registry.npmjs.org]
61
67
  openxiangda platform add <name> <platform-url>
62
68
  openxiangda platform list
63
69
  openxiangda platform use <name>
64
70
  openxiangda auth status|refresh|logout [--profile name]
65
71
  openxiangda env [--profile name]
66
72
  openxiangda workspace init [dir] [--name package-name] [--install] [--profile name --app-type APP_XXX]
73
+ openxiangda workspace init [dir] --profile <name> --app-name <app-name> [--install]
67
74
  openxiangda workspace bind --profile <name> --app-type <APP_XXX>
68
75
  openxiangda workspace publish --profile <name> [--prune]
69
76
  openxiangda app list [--profile name] [--json]
@@ -102,6 +109,199 @@ OpenXiangda 使用普通用户登录 token,不需要 AK/SK。
102
109
  JS_CODE V2 使用 trusted_node;AI 源码必须写在 src/js-code-nodes/<scriptCode>/index.ts,definition-json 中的 sourceFile.localPath 会在 validate/create 时先 TS 校验、再构建并上传为快照。`);
103
110
  }
104
111
 
112
+ async function update(args) {
113
+ const requestedSubcommand = args[0] && !args[0].startsWith('--') ? args[0] : 'check';
114
+ const parsedArgs = requestedSubcommand === args[0] ? args.slice(1) : args;
115
+ const { flags } = parseArgs(parsedArgs);
116
+ const registry = normalizeNpmRegistry(flags.registry || OFFICIAL_NPM_REGISTRY);
117
+
118
+ if (requestedSubcommand === 'check') {
119
+ const result = checkOpenXiangdaUpdate(registry);
120
+ if (flags.json) return writeJson(result);
121
+ printUpdateCheck(result);
122
+ return;
123
+ }
124
+
125
+ if (requestedSubcommand === 'install') {
126
+ const result = installOpenXiangdaUpdate(registry, {
127
+ json: Boolean(flags.json),
128
+ skipSkills: Boolean(flags['no-skills']),
129
+ });
130
+ if (flags.json) return writeJson(result);
131
+ print('OpenXiangda 已更新。');
132
+ if (result.skillInstall?.attempted && result.skillInstall.status !== 0) {
133
+ warn(`skill install 未完成: ${result.skillInstall.error}`);
134
+ }
135
+ return;
136
+ }
137
+
138
+ fail('用法: openxiangda update check|install [--json] [--registry https://registry.npmjs.org] [--no-skills]');
139
+ }
140
+
141
+ function normalizeNpmRegistry(value) {
142
+ const registry = String(value || OFFICIAL_NPM_REGISTRY).trim();
143
+ return registry.replace(/\/+$/, '') || OFFICIAL_NPM_REGISTRY;
144
+ }
145
+
146
+ function checkOpenXiangdaUpdate(registry) {
147
+ const latestVersion = fetchLatestOpenXiangdaVersion(registry);
148
+ const versionComparison = compareVersions(latestVersion, CURRENT_VERSION);
149
+ const updateAvailable = versionComparison > 0;
150
+ return {
151
+ packageName: NPM_PACKAGE_NAME,
152
+ currentVersion: CURRENT_VERSION,
153
+ latestVersion,
154
+ registry,
155
+ updateAvailable,
156
+ status: updateAvailable
157
+ ? 'update_available'
158
+ : versionComparison < 0
159
+ ? 'local_newer_than_registry'
160
+ : 'latest',
161
+ installCommand: `npm install -g ${NPM_PACKAGE_NAME}@latest --registry=${registry}`,
162
+ skillInstallCommand: 'openxiangda skill install --force',
163
+ compatibilityCheckCommand: 'openxiangda commands --json',
164
+ checkedAt: new Date().toISOString(),
165
+ notes: [
166
+ 'Use the official npm registry for OpenXiangda updates; domestic mirrors may lag.',
167
+ 'After updating the package, refresh OpenXiangda skills so AI guidance matches the CLI version.',
168
+ ],
169
+ };
170
+ }
171
+
172
+ function fetchLatestOpenXiangdaVersion(registry) {
173
+ const result = spawnSync(
174
+ 'npm',
175
+ ['view', `${NPM_PACKAGE_NAME}@latest`, 'version', `--registry=${registry}`, '--json'],
176
+ {
177
+ encoding: 'utf8',
178
+ env: { ...process.env, npm_config_registry: registry },
179
+ }
180
+ );
181
+ if (result.error) {
182
+ fail(`无法执行 npm: ${result.error.message}`);
183
+ }
184
+ if (result.status !== 0) {
185
+ fail(`检查 OpenXiangda 更新失败: ${formatSpawnOutput(result)}`);
186
+ }
187
+ const raw = String(result.stdout || '').trim();
188
+ if (!raw) fail('检查 OpenXiangda 更新失败: npm 未返回版本号');
189
+ try {
190
+ const parsed = JSON.parse(raw);
191
+ return String(parsed).trim();
192
+ } catch (_error) {
193
+ return raw.replace(/^"|"$/g, '').trim();
194
+ }
195
+ }
196
+
197
+ function installOpenXiangdaUpdate(registry, options = {}) {
198
+ const npmArgs = [
199
+ 'install',
200
+ '-g',
201
+ `${NPM_PACKAGE_NAME}@latest`,
202
+ `--registry=${registry}`,
203
+ ];
204
+ const quiet = Boolean(options.json);
205
+ if (!quiet) {
206
+ print(`执行: npm ${npmArgs.join(' ')}`);
207
+ }
208
+ const installResult = spawnSync('npm', npmArgs, {
209
+ encoding: 'utf8',
210
+ stdio: quiet ? 'pipe' : 'inherit',
211
+ env: { ...process.env, npm_config_registry: registry },
212
+ });
213
+ if (installResult.error) {
214
+ fail(`无法执行 npm: ${installResult.error.message}`);
215
+ }
216
+ if (installResult.status !== 0) {
217
+ fail(`OpenXiangda 更新失败: ${formatSpawnOutput(installResult)}`);
218
+ }
219
+
220
+ const result = {
221
+ packageName: NPM_PACKAGE_NAME,
222
+ registry,
223
+ installCommand: `npm ${npmArgs.join(' ')}`,
224
+ installed: true,
225
+ skillInstall: {
226
+ attempted: false,
227
+ status: null,
228
+ error: null,
229
+ },
230
+ };
231
+
232
+ if (!options.skipSkills) {
233
+ if (!quiet) {
234
+ print('刷新 OpenXiangda skills: openxiangda skill install --force');
235
+ }
236
+ const skillResult = spawnSync('openxiangda', ['skill', 'install', '--force'], {
237
+ encoding: 'utf8',
238
+ stdio: quiet ? 'pipe' : 'inherit',
239
+ env: process.env,
240
+ });
241
+ result.skillInstall.attempted = true;
242
+ result.skillInstall.status = skillResult.status;
243
+ if (skillResult.error || skillResult.status !== 0) {
244
+ result.skillInstall.error = skillResult.error?.message || formatSpawnOutput(skillResult);
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ function printUpdateCheck(result) {
252
+ const lines = [
253
+ 'OpenXiangda update check',
254
+ `current: ${result.currentVersion}`,
255
+ `latest: ${result.latestVersion}`,
256
+ `registry: ${result.registry}`,
257
+ ];
258
+ if (result.updateAvailable) {
259
+ lines.push('status: update available');
260
+ lines.push('recommended:');
261
+ lines.push(` ${result.installCommand}`);
262
+ lines.push(` ${result.skillInstallCommand}`);
263
+ } else if (result.status === 'local_newer_than_registry') {
264
+ lines.push('status: local version is newer than registry');
265
+ } else {
266
+ lines.push('status: already latest');
267
+ }
268
+ lines.push(`compatibility: ${result.compatibilityCheckCommand}`);
269
+ print(lines.join('\n'));
270
+ }
271
+
272
+ function formatSpawnOutput(result) {
273
+ return (
274
+ String(result.stderr || '').trim() ||
275
+ String(result.stdout || '').trim() ||
276
+ `exit status ${result.status}`
277
+ );
278
+ }
279
+
280
+ function compareVersions(a, b) {
281
+ const left = parseSemver(a);
282
+ const right = parseSemver(b);
283
+ for (let index = 0; index < 3; index += 1) {
284
+ if (left.numbers[index] !== right.numbers[index]) {
285
+ return left.numbers[index] > right.numbers[index] ? 1 : -1;
286
+ }
287
+ }
288
+ if (left.prerelease === right.prerelease) return 0;
289
+ if (!left.prerelease) return 1;
290
+ if (!right.prerelease) return -1;
291
+ return left.prerelease > right.prerelease ? 1 : -1;
292
+ }
293
+
294
+ function parseSemver(value) {
295
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(value || ''));
296
+ if (!match) {
297
+ return { numbers: [0, 0, 0], prerelease: String(value || '') };
298
+ }
299
+ return {
300
+ numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
301
+ prerelease: match[4] || '',
302
+ };
303
+ }
304
+
105
305
  async function platform(args) {
106
306
  const [subcommand, ...rest] = args;
107
307
  const { flags, positional } = parseArgs(rest);
@@ -378,14 +578,49 @@ async function workspace(args) {
378
578
  const config = loadConfig();
379
579
 
380
580
  if (subcommand === 'init') {
581
+ let appType = flags['app-type'];
582
+ let profileName = flags.profile || null;
583
+ let createdApp = null;
584
+
585
+ if (flags['app-name']) {
586
+ if (appType) {
587
+ fail('workspace init 不能同时使用 --app-name 和 --app-type');
588
+ }
589
+ profileName = profileName || config.currentProfile;
590
+ if (!profileName) {
591
+ fail('workspace init --app-name 需要先选择 profile,或显式传 --profile <name>');
592
+ }
593
+ assertCanInitializeWorkspace({
594
+ dir: positional[0],
595
+ force: flags.force,
596
+ });
597
+ const data = await createPlatformApp(config, profileName, {
598
+ name: flags['app-name'],
599
+ description: flags.description || '',
600
+ iconfontCss: flags['iconfont-css'] || '',
601
+ });
602
+ appType = extractCreatedAppType(data);
603
+ if (!appType) {
604
+ fail('应用已创建,但平台响应缺少 appType,无法写入 workspace 绑定');
605
+ }
606
+ createdApp = {
607
+ name: flags['app-name'],
608
+ appType,
609
+ data,
610
+ };
611
+ }
612
+
381
613
  const result = initWorkspace({
382
614
  dir: positional[0],
383
615
  name: flags.name,
384
616
  install: flags.install,
385
617
  force: flags.force,
386
- profile: flags.profile,
387
- appType: flags['app-type'],
618
+ profile: profileName,
619
+ appType,
388
620
  });
621
+ if (createdApp) {
622
+ result.createdApp = createdApp;
623
+ }
389
624
  if (flags.json) return writeJson(result);
390
625
  printWorkspaceInitReport(result);
391
626
  return;
@@ -418,6 +653,7 @@ async function workspace(args) {
418
653
  },
419
654
  updatedAt: new Date().toISOString(),
420
655
  };
656
+ ensureResourceBuckets(state.profiles[profileName]);
421
657
  saveProjectState(state);
422
658
  print(`当前工作区已绑定 ${profileName}: ${appType}`);
423
659
  return;
@@ -463,13 +699,10 @@ async function app(args) {
463
699
  if (subcommand === 'create') {
464
700
  const name = flags.name || positional.join(' ');
465
701
  if (!name) fail('用法: openxiangda app create <name> [--profile name]');
466
- const data = await requestWithAuth(config, profileName, '/openxiangda-api/v1/apps/', {
467
- method: 'POST',
468
- body: {
469
- name,
470
- description: flags.description || '',
471
- iconfontCss: flags['iconfont-css'] || '',
472
- },
702
+ const data = await createPlatformApp(config, profileName, {
703
+ name,
704
+ description: flags.description || '',
705
+ iconfontCss: flags['iconfont-css'] || '',
473
706
  });
474
707
  if (flags.json) return writeJson(data);
475
708
  print(JSON.stringify(data, null, 2));
@@ -490,6 +723,29 @@ async function app(args) {
490
723
  print(JSON.stringify(data, null, 2));
491
724
  }
492
725
 
726
+ async function createPlatformApp(config, profileName, payload) {
727
+ return requestWithAuth(config, profileName, '/openxiangda-api/v1/apps/', {
728
+ method: 'POST',
729
+ body: {
730
+ name: payload.name,
731
+ description: payload.description || '',
732
+ iconfontCss: payload.iconfontCss || '',
733
+ },
734
+ });
735
+ }
736
+
737
+ function extractCreatedAppType(data) {
738
+ const candidates = [
739
+ data?.appType,
740
+ data?.app?.appType,
741
+ data?.result?.appType,
742
+ data?.application?.appType,
743
+ data?.type,
744
+ ];
745
+ const value = candidates.find(item => typeof item === 'string' && item.trim());
746
+ return value ? value.trim() : null;
747
+ }
748
+
493
749
  async function form(args) {
494
750
  const [subcommand, ...rest] = args;
495
751
  const { flags, positional } = parseArgs(rest);
@@ -1800,10 +2056,11 @@ async function commands(args) {
1800
2056
  name: 'openxiangda',
1801
2057
  commands: [
1802
2058
  'login <platform-url> [--profile name]',
2059
+ 'update check|install',
1803
2060
  'platform add|list|use|remove',
1804
2061
  'auth status|refresh|logout',
1805
2062
  'env',
1806
- 'workspace init|bind|publish [--prune]',
2063
+ 'workspace init|bind|publish [--app-name] [--prune]',
1807
2064
  'app list|create|snapshot',
1808
2065
  'form list|create|bind|pull|publish',
1809
2066
  'page list|publish|bind|releases|activate',
@@ -1828,6 +2085,9 @@ function printWorkspaceInitReport(result) {
1828
2085
  `已创建 OpenXiangda workspace: ${result.targetDir}`,
1829
2086
  `package: ${result.packageName}`,
1830
2087
  ];
2088
+ if (result.createdApp) {
2089
+ lines.push(`已创建平台应用: ${result.createdApp.name} (${result.createdApp.appType})`);
2090
+ }
1831
2091
  if (result.bound) {
1832
2092
  lines.push(`已绑定 ${result.bound.profile}: ${result.bound.appType}`);
1833
2093
  }
@@ -1952,6 +2212,9 @@ function ensureResourceBuckets(bound) {
1952
2212
  bound.resources.menus = bound.resources.menus || {};
1953
2213
  bound.resources.roles = bound.resources.roles || {};
1954
2214
  bound.resources.connectors = bound.resources.connectors || {};
2215
+ bound.resources.notifications = bound.resources.notifications || {};
2216
+ bound.resources.notifications.templates = bound.resources.notifications.templates || {};
2217
+ bound.resources.notifications.typeConfigs = bound.resources.notifications.typeConfigs || {};
1955
2218
  bound.resources.pagePermissionGroups = bound.resources.pagePermissionGroups || {};
1956
2219
  bound.resources.formPermissionGroups = bound.resources.formPermissionGroups || {};
1957
2220
  bound.resources.formSettings = bound.resources.formSettings || {};
@@ -2156,6 +2419,46 @@ function saveConnectorResource(target, connectorCode, connectorId, extra = {}) {
2156
2419
  }, ['connectorId', 'apis']);
2157
2420
  }
2158
2421
 
2422
+ function saveNotificationTemplateResource(target, templateCode, templateId, extra = {}) {
2423
+ const nextBound = prepareStateResourceWrite(target);
2424
+ nextBound.resources.notifications.templates[templateCode] = {
2425
+ templateId,
2426
+ ...pickStateFields(extra, ['level', 'formUuid']),
2427
+ updatedAt: new Date().toISOString(),
2428
+ };
2429
+ saveProjectState(target.state);
2430
+ }
2431
+
2432
+ function saveNotificationTypeConfigResource(target, configCode, configId, extra = {}) {
2433
+ const nextBound = prepareStateResourceWrite(target);
2434
+ nextBound.resources.notifications.typeConfigs[configCode] = {
2435
+ configId,
2436
+ ...pickStateFields(extra, [
2437
+ 'notificationType',
2438
+ 'level',
2439
+ 'formUuid',
2440
+ 'templateId',
2441
+ 'templateCode',
2442
+ ]),
2443
+ updatedAt: new Date().toISOString(),
2444
+ };
2445
+ saveProjectState(target.state);
2446
+ }
2447
+
2448
+ function prepareStateResourceWrite(target) {
2449
+ target.state.profiles = target.state.profiles || {};
2450
+ target.state.profiles[target.profileName] = {
2451
+ ...target.bound,
2452
+ baseUrl: target.profile.baseUrl,
2453
+ appType: target.appType,
2454
+ updatedAt: new Date().toISOString(),
2455
+ };
2456
+ const nextBound = target.state.profiles[target.profileName];
2457
+ ensureResourceBuckets(nextBound);
2458
+ target.bound = nextBound;
2459
+ return nextBound;
2460
+ }
2461
+
2159
2462
  function savePagePermissionGroupResource(target, groupCode, groupId) {
2160
2463
  saveStateResource(target, 'pagePermissionGroups', groupCode, { groupId }, ['groupId']);
2161
2464
  }
@@ -2202,6 +2505,7 @@ function pickStateFields(value, keys) {
2202
2505
  const RESOURCE_SPECS = [
2203
2506
  { key: 'roles', dir: 'roles', topFiles: ['roles.json'], pluralKeys: ['roles'] },
2204
2507
  { key: 'connectors', dir: 'connectors', topFiles: ['connectors.json'], pluralKeys: ['connectors'] },
2508
+ { key: 'notifications', dir: 'notifications', topFiles: ['notifications.json'], pluralKeys: ['notifications'] },
2205
2509
  { key: 'menus', dir: 'menus', topFiles: ['menus.json'], pluralKeys: ['menus'] },
2206
2510
  { key: 'workflows', dir: 'workflows', topFiles: ['workflows.json'], pluralKeys: ['workflows'] },
2207
2511
  { key: 'automations', dir: 'automations', topFiles: ['automations.json'], pluralKeys: ['automations'] },
@@ -2280,6 +2584,7 @@ function readResourceItemsFromFile(filePath, spec) {
2280
2584
  item.code ||
2281
2585
  item.resourceCode ||
2282
2586
  item.methodName ||
2587
+ (spec.key === 'notifications' ? inferNotificationResourceCode(item) : undefined) ||
2283
2588
  item.formCode ||
2284
2589
  (values.length === 1 ? defaultCode : undefined);
2285
2590
  return {
@@ -2292,7 +2597,20 @@ function readResourceItemsFromFile(filePath, spec) {
2292
2597
  });
2293
2598
  }
2294
2599
 
2600
+ function inferNotificationResourceCode(item) {
2601
+ const resourceType = normalizeNotificationResourceType(item);
2602
+ if (resourceType === 'template') return item.code || item.templateCode;
2603
+ if (resourceType !== 'typeConfig' || !item.notificationType) {
2604
+ return item.notificationType || item.templateCode;
2605
+ }
2606
+ const level = item.level || (item.formCode || item.formUuid ? 'form' : 'app');
2607
+ if (level === 'app') return item.notificationType;
2608
+ const formKey = item.formCode || item.formUuid || 'form';
2609
+ return `${level}:${formKey}:${item.notificationType}`;
2610
+ }
2611
+
2295
2612
  function extractResourceValues(value, spec) {
2613
+ if (spec.key === 'notifications') return extractNotificationResourceValues(value);
2296
2614
  if (Array.isArray(value)) return value;
2297
2615
  if (!value || typeof value !== 'object') return [value];
2298
2616
  for (const key of spec.pluralKeys || []) {
@@ -2302,6 +2620,29 @@ function extractResourceValues(value, spec) {
2302
2620
  return [value];
2303
2621
  }
2304
2622
 
2623
+ function extractNotificationResourceValues(value) {
2624
+ if (Array.isArray(value)) return value;
2625
+ if (!value || typeof value !== 'object') return [value];
2626
+ const result = [];
2627
+ for (const template of value.templates || []) {
2628
+ result.push({ ...template, resourceType: 'template' });
2629
+ }
2630
+ for (const config of value.typeConfigs || value.notificationTypeConfigs || []) {
2631
+ result.push({ ...config, resourceType: 'typeConfig' });
2632
+ }
2633
+ if (Array.isArray(value.notifications)) {
2634
+ result.push(...value.notifications);
2635
+ }
2636
+ if (result.length > 0) return result;
2637
+ if (value.template || value.typeConfig) {
2638
+ return [
2639
+ value.template ? { ...value.template, resourceType: 'template' } : null,
2640
+ value.typeConfig ? { ...value.typeConfig, resourceType: 'typeConfig' } : null,
2641
+ ].filter(Boolean);
2642
+ }
2643
+ return [value];
2644
+ }
2645
+
2305
2646
  function listJsonFiles(dirPath) {
2306
2647
  if (!fs.existsSync(dirPath)) return [];
2307
2648
  return fs
@@ -2325,6 +2666,7 @@ function validateWorkspaceResources(manifest) {
2325
2666
  validateResourceItem(spec.key, item, errors, warnings);
2326
2667
  }
2327
2668
  }
2669
+ validateNotificationReferences(manifest.notifications || [], errors);
2328
2670
  return {
2329
2671
  valid: errors.length === 0,
2330
2672
  errors,
@@ -2357,6 +2699,11 @@ function validateResourceItem(kind, item, errors, warnings) {
2357
2699
  return;
2358
2700
  }
2359
2701
 
2702
+ if (kind === 'notifications') {
2703
+ validateNotificationResourceItem(item, errors, warnings);
2704
+ return;
2705
+ }
2706
+
2360
2707
  if (kind === 'roles' && !item.name) errors.push(`${label}: 缺少 name`);
2361
2708
  if (kind === 'menus' && !item.name) errors.push(`${label}: 缺少 name`);
2362
2709
  if (kind === 'workflows') {
@@ -2392,6 +2739,99 @@ function validateResourceItem(kind, item, errors, warnings) {
2392
2739
  }
2393
2740
  }
2394
2741
 
2742
+ const NOTIFICATION_CHANNELS = new Set([
2743
+ 'inapp',
2744
+ 'email',
2745
+ 'dingding',
2746
+ 'wechat',
2747
+ 'thirdparty_todo',
2748
+ ]);
2749
+
2750
+ function validateNotificationResourceItem(item, errors, warnings) {
2751
+ const label = resourceLabel('notifications', item);
2752
+ const resourceType = normalizeNotificationResourceType(item);
2753
+ if (!resourceType) {
2754
+ errors.push(`${label}: resourceType 必须是 template 或 typeConfig`);
2755
+ return;
2756
+ }
2757
+
2758
+ if (resourceType === 'template') {
2759
+ if (!item.code) errors.push(`${label}: 通知模板缺少 code`);
2760
+ if (!item.name) errors.push(`${label}: 通知模板缺少 name`);
2761
+ if (item.level && !['app', 'form'].includes(item.level)) {
2762
+ errors.push(`${label}: OpenXiangda 通知模板 level 只能是 app 或 form`);
2763
+ }
2764
+ if ((item.level === 'form' || item.formCode || item.formUuid) && !item.formCode && !item.formUuid) {
2765
+ errors.push(`${label}: 表单级通知模板缺少 formCode 或 formUuid`);
2766
+ }
2767
+ if (!item.content && !item.channelsConfig) {
2768
+ warnings.push(`${label}: 未声明 content 或 channelsConfig`);
2769
+ }
2770
+ validateNotificationChannels(label, item.channelsConfig, errors);
2771
+ return;
2772
+ }
2773
+
2774
+ if (!item.notificationType) errors.push(`${label}: 通知类型配置缺少 notificationType`);
2775
+ if (!item.templateCode && !item.templateId) {
2776
+ errors.push(`${label}: 通知类型配置缺少 templateCode 或 templateId`);
2777
+ }
2778
+ if (item.level && !['app', 'form'].includes(item.level)) {
2779
+ errors.push(`${label}: OpenXiangda 通知类型配置 level 只能是 app 或 form`);
2780
+ }
2781
+ if ((item.level === 'form' || item.formCode || item.formUuid) && !item.formCode && !item.formUuid) {
2782
+ errors.push(`${label}: 表单级通知类型配置缺少 formCode 或 formUuid`);
2783
+ }
2784
+ }
2785
+
2786
+ function normalizeNotificationResourceType(item) {
2787
+ const value = item.resourceType || item.kind || item.type;
2788
+ if (value === 'template' || value === 'notificationTemplate') return 'template';
2789
+ if (value === 'typeConfig' || value === 'notificationTypeConfig') return 'typeConfig';
2790
+ if (item.notificationType || item.templateCode || item.templateId) return 'typeConfig';
2791
+ if (item.channelsConfig || item.content || item.variables) return 'template';
2792
+ return undefined;
2793
+ }
2794
+
2795
+ function validateNotificationReferences(items, errors) {
2796
+ const templates = new Set(
2797
+ (items || [])
2798
+ .filter(item => normalizeNotificationResourceType(item) === 'template')
2799
+ .map(item => item.code)
2800
+ .filter(Boolean)
2801
+ );
2802
+ for (const item of items || []) {
2803
+ if (normalizeNotificationResourceType(item) !== 'typeConfig') continue;
2804
+ if (item.templateCode && !templates.has(item.templateCode) && !item.templateId) {
2805
+ errors.push(
2806
+ `${resourceLabel('notifications', item)}: templateCode ${item.templateCode} 未在 src/resources/notifications 中声明`
2807
+ );
2808
+ }
2809
+ }
2810
+ }
2811
+
2812
+ function validateNotificationChannels(label, channelsConfig, errors) {
2813
+ if (!channelsConfig || typeof channelsConfig !== 'object' || Array.isArray(channelsConfig)) {
2814
+ return;
2815
+ }
2816
+ for (const [channel, config] of Object.entries(channelsConfig)) {
2817
+ if (!NOTIFICATION_CHANNELS.has(channel)) {
2818
+ errors.push(`${label}: 不支持的通知渠道 ${channel}`);
2819
+ }
2820
+ validateNoSensitiveNotificationConfig(`${label}.channelsConfig.${channel}`, config, errors);
2821
+ }
2822
+ }
2823
+
2824
+ function validateNoSensitiveNotificationConfig(label, value, errors) {
2825
+ if (!value || typeof value !== 'object') return;
2826
+ for (const [key, item] of Object.entries(value)) {
2827
+ if (/token|secret|password|authorization|auth|credential|headers/i.test(key)) {
2828
+ errors.push(`${label}.${key}: 通知资源不允许配置通道密钥或鉴权字段`);
2829
+ continue;
2830
+ }
2831
+ validateNoSensitiveNotificationConfig(`${label}.${key}`, item, errors);
2832
+ }
2833
+ }
2834
+
2395
2835
  function resourceLabel(kind, item) {
2396
2836
  return `${kind}:${item.code || item.__source || item.__index || '?'}`;
2397
2837
  }
@@ -2412,6 +2852,7 @@ async function buildResourcePlan(config, target, manifest) {
2412
2852
  addPlanActions(actions, 'role', manifest.roles, existing.roles, roleEquals);
2413
2853
  addPlanActions(actions, 'menu', manifest.menus, existing.menus, (item, current) => menuEquals(target.bound, item, current));
2414
2854
  addPlanActions(actions, 'connector', manifest.connectors, existing.connectors, connectorEquals);
2855
+ addNotificationPlanActions(target, actions, manifest.notifications || [], existing);
2415
2856
  await addWorkflowPlanActions(config, target, actions, manifest.workflows, existing.workflows);
2416
2857
  await addAutomationPlanActions(config, target, actions, manifest.automations, existing.automations);
2417
2858
  addPlanActions(actions, 'pagePermissionGroup', manifest.pagePermissionGroups, existing.pagePermissionGroups, (item, current) => pagePermissionGroupEquals(target.bound, item, current));
@@ -2444,6 +2885,7 @@ async function publishResourceManifest(config, target, manifest, options = {}) {
2444
2885
  await publishFormSettingsResources(config, target, manifest.formSettings || [], result);
2445
2886
  await publishMenuResources(config, target, manifest.menus || [], result);
2446
2887
  await publishConnectorResources(config, target, manifest.connectors || [], result);
2888
+ await publishNotificationResources(config, target, manifest.notifications || [], result);
2447
2889
  await publishWorkflowResources(config, target, manifest.workflows || [], result);
2448
2890
  await publishAutomationResources(config, target, manifest.automations || [], result);
2449
2891
  await publishPagePermissionGroupResources(config, target, manifest.pagePermissionGroups || [], result);
@@ -2463,6 +2905,8 @@ async function fetchExistingResourceMaps(config, target, manifest) {
2463
2905
  roles: new Map(),
2464
2906
  menus: new Map(),
2465
2907
  connectors: new Map(),
2908
+ notificationTemplates: new Map(),
2909
+ notificationTypeConfigs: new Map(),
2466
2910
  workflows: new Map(),
2467
2911
  automations: new Map(),
2468
2912
  pagePermissionGroups: new Map(),
@@ -2496,6 +2940,31 @@ async function fetchExistingResourceMaps(config, target, manifest) {
2496
2940
  );
2497
2941
  indexByCode(maps.connectors, normalizeItems(data), item => item.code || item.methodName);
2498
2942
  }
2943
+ if ((manifest.notifications || []).length > 0) {
2944
+ const templates = await requestWithAuth(
2945
+ config,
2946
+ target.profileName,
2947
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates`, {
2948
+ page: 1,
2949
+ limit: 1000,
2950
+ })
2951
+ );
2952
+ indexByCode(maps.notificationTemplates, normalizeItems(templates), item => item.code);
2953
+
2954
+ const typeConfigs = await requestWithAuth(
2955
+ config,
2956
+ target.profileName,
2957
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs`, {
2958
+ page: 1,
2959
+ limit: 1000,
2960
+ })
2961
+ );
2962
+ indexByCode(
2963
+ maps.notificationTypeConfigs,
2964
+ normalizeItems(typeConfigs),
2965
+ item => notificationTypeConfigExistingKey(item)
2966
+ );
2967
+ }
2499
2968
  if ((manifest.workflows || []).length > 0) {
2500
2969
  const data = await requestWithAuth(
2501
2970
  config,
@@ -2558,6 +3027,37 @@ function addPlanActions(actions, kind, desiredItems = [], existingMap, equals) {
2558
3027
  }
2559
3028
  }
2560
3029
 
3030
+ function addNotificationPlanActions(target, actions, notificationItems = [], existing) {
3031
+ const { templates, typeConfigs } = splitNotificationResources(notificationItems);
3032
+ for (const template of templates) {
3033
+ const existingTemplate = existing.notificationTemplates.get(template.code);
3034
+ actions.push({
3035
+ kind: 'notificationTemplate',
3036
+ code: template.code,
3037
+ action: existingTemplate
3038
+ ? notificationTemplateEquals(target.bound, template, existingTemplate)
3039
+ ? 'noop'
3040
+ : 'update'
3041
+ : 'create',
3042
+ platformId: existingTemplate?.id,
3043
+ });
3044
+ }
3045
+ for (const config of typeConfigs) {
3046
+ const key = notificationTypeConfigDesiredKey(target.bound, config);
3047
+ const existingConfig = existing.notificationTypeConfigs.get(key);
3048
+ actions.push({
3049
+ kind: 'notificationTypeConfig',
3050
+ code: config.code || config.notificationType,
3051
+ action: existingConfig
3052
+ ? notificationTypeConfigEquals(target.bound, config, existingConfig)
3053
+ ? 'noop'
3054
+ : 'update'
3055
+ : 'create',
3056
+ platformId: existingConfig?.id,
3057
+ });
3058
+ }
3059
+ }
3060
+
2561
3061
  async function addWorkflowPlanActions(config, target, actions, desiredItems = [], existingMap) {
2562
3062
  for (const item of desiredItems) {
2563
3063
  const code = item.code || item.resourceCode;
@@ -2842,6 +3342,57 @@ async function publishConnectorResources(config, target, connectors, result) {
2842
3342
  }
2843
3343
  }
2844
3344
 
3345
+ async function publishNotificationResources(config, target, notifications, result) {
3346
+ const { templates, typeConfigs } = splitNotificationResources(notifications);
3347
+ for (const template of templates) {
3348
+ const body = normalizeNotificationTemplateManifest(target.bound, template);
3349
+ const data = await requestWithAuth(
3350
+ config,
3351
+ target.profileName,
3352
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates/${encodeURIComponent(template.code)}`,
3353
+ { method: 'PUT', body }
3354
+ );
3355
+ if (data?.id) {
3356
+ saveNotificationTemplateResource(target, template.code, data.id, {
3357
+ level: data.level || body.level,
3358
+ formUuid: data.formUuid || body.formUuid,
3359
+ });
3360
+ }
3361
+ result.published.push({
3362
+ kind: 'notificationTemplate',
3363
+ code: template.code,
3364
+ action: data?.created ? 'create' : 'update',
3365
+ id: data?.id,
3366
+ });
3367
+ }
3368
+
3369
+ for (const configItem of typeConfigs) {
3370
+ const body = normalizeNotificationTypeConfigManifest(target.bound, configItem);
3371
+ const data = await requestWithAuth(
3372
+ config,
3373
+ target.profileName,
3374
+ `/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs/${encodeURIComponent(configItem.notificationType)}`,
3375
+ { method: 'PUT', body }
3376
+ );
3377
+ const stateCode = configItem.code || notificationTypeConfigDesiredKey(target.bound, configItem);
3378
+ if (data?.id) {
3379
+ saveNotificationTypeConfigResource(target, stateCode, data.id, {
3380
+ notificationType: data.notificationType || configItem.notificationType,
3381
+ level: data.level || body.level,
3382
+ formUuid: data.formUuid || body.formUuid,
3383
+ templateId: data.templateId,
3384
+ templateCode: data.template?.code || configItem.templateCode,
3385
+ });
3386
+ }
3387
+ result.published.push({
3388
+ kind: 'notificationTypeConfig',
3389
+ code: configItem.code || configItem.notificationType,
3390
+ action: data?.created ? 'create' : 'update',
3391
+ id: data?.id,
3392
+ });
3393
+ }
3394
+ }
3395
+
2845
3396
  async function publishWorkflowResources(config, target, workflows, result) {
2846
3397
  for (const workflowItem of workflows) {
2847
3398
  const existing = await findExistingWorkflow(config, target, workflowItem.code);
@@ -3379,6 +3930,40 @@ async function pullResources(config, target) {
3379
3930
  written.push(path.relative(process.cwd(), filePath));
3380
3931
  }
3381
3932
 
3933
+ const notificationTemplates = await requestWithAuth(
3934
+ config,
3935
+ target.profileName,
3936
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/templates`, {
3937
+ page: 1,
3938
+ limit: 1000,
3939
+ })
3940
+ );
3941
+ for (const template of normalizeItems(notificationTemplates)) {
3942
+ const code = template.code || template.id;
3943
+ const filePath = path.join(baseDir, 'notifications', 'templates', `${code}.json`);
3944
+ writeResourceJsonFile(filePath, {
3945
+ templates: [toPulledNotificationTemplate(template, pullLookups)],
3946
+ });
3947
+ written.push(path.relative(process.cwd(), filePath));
3948
+ }
3949
+
3950
+ const notificationTypeConfigs = await requestWithAuth(
3951
+ config,
3952
+ target.profileName,
3953
+ apiPathWithQuery(`/openxiangda-api/v1/apps/${encodeURIComponent(target.appType)}/notifications/type-configs`, {
3954
+ page: 1,
3955
+ limit: 1000,
3956
+ })
3957
+ );
3958
+ for (const configItem of normalizeItems(notificationTypeConfigs)) {
3959
+ const code = notificationTypeConfigExistingKey(configItem).replace(/[^a-zA-Z0-9_.-]+/g, '_');
3960
+ const filePath = path.join(baseDir, 'notifications', 'type-configs', `${code}.json`);
3961
+ writeResourceJsonFile(filePath, {
3962
+ typeConfigs: [toPulledNotificationTypeConfig(configItem, pullLookups)],
3963
+ });
3964
+ written.push(path.relative(process.cwd(), filePath));
3965
+ }
3966
+
3382
3967
  const menus = await requestWithAuth(
3383
3968
  config,
3384
3969
  target.profileName,
@@ -3598,6 +4183,47 @@ function splitPagePermissionTargetsForManifest(values = [], lookups) {
3598
4183
  };
3599
4184
  }
3600
4185
 
4186
+ function toPulledNotificationTemplate(template, lookups) {
4187
+ const formCode = lookups.formCodeByUuid.get(template.formUuid);
4188
+ return stripUndefinedValues({
4189
+ code: template.code,
4190
+ name: template.name,
4191
+ content: template.content || '',
4192
+ description: template.description || '',
4193
+ level: template.level === 'form' ? 'form' : 'app',
4194
+ ...(formCode ? { formCode } : template.formUuid ? { formUuid: template.formUuid } : {}),
4195
+ priority: template.priority,
4196
+ enabled: template.enabled,
4197
+ variables: template.variables || undefined,
4198
+ channelsConfig: stripNotificationSecrets(template.channelsConfig),
4199
+ });
4200
+ }
4201
+
4202
+ function toPulledNotificationTypeConfig(configItem, lookups) {
4203
+ const formCode = lookups.formCodeByUuid.get(configItem.formUuid);
4204
+ return stripUndefinedValues({
4205
+ code: notificationTypeConfigExistingKey(configItem),
4206
+ notificationType: configItem.notificationType,
4207
+ level: configItem.level === 'form' ? 'form' : 'app',
4208
+ ...(formCode ? { formCode } : configItem.formUuid ? { formUuid: configItem.formUuid } : {}),
4209
+ templateCode: configItem.template?.code,
4210
+ enabled: configItem.enabled,
4211
+ priority: configItem.priority,
4212
+ description: configItem.description || '',
4213
+ });
4214
+ }
4215
+
4216
+ function stripNotificationSecrets(value) {
4217
+ if (!value || typeof value !== 'object') return value;
4218
+ if (Array.isArray(value)) return value.map(stripNotificationSecrets);
4219
+ const next = {};
4220
+ for (const [key, item] of Object.entries(value)) {
4221
+ if (/token|secret|password|authorization|auth|credential|headers/i.test(key)) continue;
4222
+ next[key] = stripNotificationSecrets(item);
4223
+ }
4224
+ return next;
4225
+ }
4226
+
3601
4227
  function writeResourceJsonFile(filePath, value) {
3602
4228
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
3603
4229
  fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
@@ -3671,6 +4297,58 @@ function parseConnectorUrl(connector) {
3671
4297
  }
3672
4298
  }
3673
4299
 
4300
+ function splitNotificationResources(items = []) {
4301
+ const templates = [];
4302
+ const typeConfigs = [];
4303
+ for (const item of items || []) {
4304
+ const resourceType = normalizeNotificationResourceType(item);
4305
+ if (resourceType === 'template') templates.push(item);
4306
+ if (resourceType === 'typeConfig') typeConfigs.push(item);
4307
+ }
4308
+ return { templates, typeConfigs };
4309
+ }
4310
+
4311
+ function normalizeNotificationTemplateManifest(bound, template) {
4312
+ const level = template.level || (template.formCode || template.formUuid ? 'form' : 'app');
4313
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, template) : undefined;
4314
+ return stripUndefinedValues({
4315
+ name: template.name || template.code,
4316
+ content: template.content || '',
4317
+ description: template.description || '',
4318
+ channelsConfig: template.channelsConfig,
4319
+ level,
4320
+ formUuid,
4321
+ priority: template.priority,
4322
+ enabled: template.enabled,
4323
+ variables: template.variables,
4324
+ });
4325
+ }
4326
+
4327
+ function normalizeNotificationTypeConfigManifest(bound, config) {
4328
+ const level = config.level || (config.formCode || config.formUuid ? 'form' : 'app');
4329
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, config) : undefined;
4330
+ return stripUndefinedValues({
4331
+ level,
4332
+ formUuid,
4333
+ templateId: config.templateId,
4334
+ templateCode: config.templateCode,
4335
+ enabled: config.enabled,
4336
+ priority: config.priority,
4337
+ description: config.description || '',
4338
+ });
4339
+ }
4340
+
4341
+ function notificationTypeConfigDesiredKey(bound, config) {
4342
+ const level = config.level || (config.formCode || config.formUuid ? 'form' : 'app');
4343
+ const formUuid = level === 'form' ? resolveManifestFormUuid(bound, config) : '';
4344
+ return `${level}:${formUuid || ''}:${config.notificationType}`;
4345
+ }
4346
+
4347
+ function notificationTypeConfigExistingKey(config) {
4348
+ const level = config.level || 'app';
4349
+ return `${level}:${config.formUuid || ''}:${config.notificationType}`;
4350
+ }
4351
+
3674
4352
  async function resolveManifestJson(config, profileName, item, objectKey, fileKey, optional = false) {
3675
4353
  let value = item[objectKey];
3676
4354
  if (value === undefined && item[fileKey]) {
@@ -3778,6 +4456,40 @@ function connectorEquals(desired, existing) {
3778
4456
  return JSON.stringify(normalizedExisting) === JSON.stringify(normalizedDesired);
3779
4457
  }
3780
4458
 
4459
+ function notificationTemplateEquals(bound, desired, existing) {
4460
+ if (!existing) return false;
4461
+ const expected = normalizeNotificationTemplateManifest(bound, desired);
4462
+ return (
4463
+ String(existing.code || '') === String(desired.code || '') &&
4464
+ String(existing.name || '') === String(expected.name || '') &&
4465
+ String(existing.content || '') === String(expected.content || '') &&
4466
+ String(existing.description || '') === String(expected.description || '') &&
4467
+ String(existing.level || 'app') === String(expected.level || 'app') &&
4468
+ String(existing.formUuid || '') === String(expected.formUuid || '') &&
4469
+ optionalScalarEquals(existing.priority, expected.priority, expected.priority !== undefined) &&
4470
+ optionalScalarEquals(Boolean(existing.enabled), Boolean(expected.enabled), expected.enabled !== undefined) &&
4471
+ jsonEqualsForPlan(existing.variables || [], expected.variables || [], desired.__dir) &&
4472
+ jsonEqualsForPlan(existing.channelsConfig || {}, expected.channelsConfig || {}, desired.__dir)
4473
+ );
4474
+ }
4475
+
4476
+ function notificationTypeConfigEquals(bound, desired, existing) {
4477
+ if (!existing) return false;
4478
+ const expected = normalizeNotificationTypeConfigManifest(bound, desired);
4479
+ const existingTemplateCode = existing.template?.code;
4480
+ return (
4481
+ String(existing.notificationType || '') === String(desired.notificationType || '') &&
4482
+ String(existing.level || 'app') === String(expected.level || 'app') &&
4483
+ String(existing.formUuid || '') === String(expected.formUuid || '') &&
4484
+ (expected.templateId
4485
+ ? String(existing.templateId || '') === String(expected.templateId)
4486
+ : String(existingTemplateCode || '') === String(expected.templateCode || '')) &&
4487
+ optionalScalarEquals(Boolean(existing.enabled), Boolean(expected.enabled), expected.enabled !== undefined) &&
4488
+ optionalScalarEquals(existing.priority, expected.priority, expected.priority !== undefined) &&
4489
+ String(existing.description || '') === String(expected.description || '')
4490
+ );
4491
+ }
4492
+
3781
4493
  function pagePermissionGroupEquals(bound, desired, existing) {
3782
4494
  if (!existing) return false;
3783
4495
  const desiredTargets = resolvePagePermissionGroupTargets(bound, desired);