sy-lowcode-workspace-tools 0.1.0

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.
@@ -0,0 +1,141 @@
1
+ // @ts-nocheck
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ assertFormReadyForBundle,
6
+ assertSchemaSyncResult,
7
+ transformToApiFormat,
8
+ } from "./schema-transform.mjs";
9
+
10
+ describe("schema-transform", () => {
11
+ it("builds form component nodes with stable field metadata", () => {
12
+ const payload = transformToApiFormat(
13
+ {
14
+ formMeta: {
15
+ formUuid: "FORM_1",
16
+ appType: "APP_1",
17
+ title: "客户信息登记",
18
+ },
19
+ fields: [
20
+ {
21
+ fieldId: "customer_name",
22
+ componentName: "TextField",
23
+ label: "客户名称",
24
+ },
25
+ {
26
+ fieldId: "remark",
27
+ componentName: "TextAreaField",
28
+ label: "备注",
29
+ },
30
+ ],
31
+ },
32
+ "customer-info",
33
+ );
34
+
35
+ const schema = JSON.parse(payload.schema);
36
+ const nodes = schema.componentsTree[0].children;
37
+
38
+ expect(payload.fieldCount).toBe(2);
39
+ expect(nodes[0].props).toMatchObject({
40
+ fieldId: "customer_name",
41
+ componentName: "TextField",
42
+ isFormComponent: true,
43
+ });
44
+ expect(nodes[1].props).toMatchObject({
45
+ fieldId: "remark",
46
+ componentName: "TextareaField",
47
+ isFormComponent: true,
48
+ });
49
+ });
50
+
51
+ it("rejects duplicate field ids and empty fields", () => {
52
+ expect(() =>
53
+ transformToApiFormat(
54
+ {
55
+ formMeta: { formUuid: "FORM_1", appType: "APP_1", title: "表单" },
56
+ fields: [],
57
+ },
58
+ "empty-form",
59
+ ),
60
+ ).toThrow("schema.fields 不能为空");
61
+
62
+ expect(() =>
63
+ transformToApiFormat(
64
+ {
65
+ formMeta: { formUuid: "FORM_1", appType: "APP_1", title: "表单" },
66
+ fields: [
67
+ { fieldId: "name", componentName: "TextField", label: "姓名" },
68
+ { fieldId: "name", componentName: "TextField", label: "姓名2" },
69
+ ],
70
+ },
71
+ "duplicate-form",
72
+ ),
73
+ ).toThrow("fieldId 重复: name");
74
+ });
75
+
76
+ it("validates schema sync response before treating publish as ready", () => {
77
+ expect(() =>
78
+ assertSchemaSyncResult(
79
+ {
80
+ code: 200,
81
+ data: {
82
+ tableName: "form_app_1",
83
+ formFields: {
84
+ customer_name: {},
85
+ remark: {},
86
+ },
87
+ },
88
+ },
89
+ 2,
90
+ ),
91
+ ).not.toThrow();
92
+
93
+ expect(() =>
94
+ assertSchemaSyncResult(
95
+ {
96
+ code: 200,
97
+ data: {
98
+ tableName: "null",
99
+ formFields: { customer_name: {} },
100
+ },
101
+ },
102
+ 1,
103
+ ),
104
+ ).toThrow("有效 tableName");
105
+
106
+ expect(() =>
107
+ assertSchemaSyncResult(
108
+ {
109
+ code: 200,
110
+ data: {
111
+ tableName: "form_app_1",
112
+ formFields: { customer_name: {} },
113
+ },
114
+ },
115
+ 2,
116
+ ),
117
+ ).toThrow("formFields 数量不匹配");
118
+ });
119
+
120
+ it("rejects bundle registration before backend schema is initialized", () => {
121
+ expect(() =>
122
+ assertFormReadyForBundle(
123
+ {
124
+ tableName: "",
125
+ formFields: { customer_name: {} },
126
+ },
127
+ "customer-info",
128
+ ),
129
+ ).toThrow("缺少 tableName");
130
+
131
+ expect(() =>
132
+ assertFormReadyForBundle(
133
+ {
134
+ tableName: "form_app_1",
135
+ formFields: {},
136
+ },
137
+ "customer-info",
138
+ ),
139
+ ).toThrow("缺少 formFields");
140
+ });
141
+ });
package/src/cli.mjs ADDED
@@ -0,0 +1,382 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createRequire } from "node:module";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const managedScriptDir = join(packageRoot, "scripts");
10
+ const managedCommands = new Map([
11
+ ["build", "build-workspace.mjs"],
12
+ ["build-forms", "build-forms.mjs"],
13
+ ["build-pages", "build-pages.mjs"],
14
+ ["sync-schema", "sync-schema.mjs"],
15
+ ["publish-oss", "publish-oss.mjs"],
16
+ ["register", "register.mjs"],
17
+ ["publish-all", "publish-all.mjs"],
18
+ ]);
19
+ const wrapperScripts = new Map([
20
+ ["build-workspace.mjs", "build"],
21
+ ["build-forms.mjs", "build-forms"],
22
+ ["build-pages.mjs", "build-pages"],
23
+ ["sync-schema.mjs", "sync-schema"],
24
+ ["publish-oss.mjs", "publish-oss"],
25
+ ["register.mjs", "register"],
26
+ ["publish-all.mjs", "publish-all"],
27
+ ]);
28
+ const textExtensions = new Set([
29
+ ".js",
30
+ ".jsx",
31
+ ".ts",
32
+ ".tsx",
33
+ ".mjs",
34
+ ".cjs",
35
+ ".json",
36
+ ".md",
37
+ ".css",
38
+ ".scss",
39
+ ".yml",
40
+ ".yaml",
41
+ ]);
42
+ const ignoredDirs = new Set([".git", "node_modules", "dist", "build", "coverage", ".vite"]);
43
+
44
+ function usage() {
45
+ return `
46
+ lowcode-workspace <command> [options]
47
+
48
+ Commands:
49
+ build | build-forms | build-pages | sync-schema | publish-oss | register | publish-all
50
+ update Update workspace runtime dependencies and managed wrappers
51
+ migrate Convert old local SDK workspace to npm package mode
52
+
53
+ Update options:
54
+ --workspace <path> Workspace root, defaults to cwd
55
+ --channel <tag> npm dist-tag, defaults to latest
56
+ --check Validate only; do not mutate
57
+ --commit Commit update changes
58
+ --push Push after commit
59
+ --no-commit Do not commit, even if changes exist
60
+ --allow-dirty Allow updates with existing worktree changes
61
+ --skip-install Do not run pnpm install/update
62
+ --skip-gate Do not run typecheck smoke gate
63
+ `;
64
+ }
65
+
66
+ function parseArgs(argv) {
67
+ const result = { _: [] };
68
+ for (let index = 0; index < argv.length; index += 1) {
69
+ const arg = argv[index];
70
+ if (!arg.startsWith("--")) {
71
+ result._.push(arg);
72
+ continue;
73
+ }
74
+ const key = arg.slice(2);
75
+ if (["workspace", "channel"].includes(key)) {
76
+ result[key] = argv[index + 1];
77
+ index += 1;
78
+ } else {
79
+ result[key] = true;
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function run(command, args, options = {}) {
86
+ const result = spawnSync(command, args, {
87
+ cwd: options.cwd,
88
+ env: options.env || process.env,
89
+ stdio: options.capture ? "pipe" : "inherit",
90
+ encoding: "utf-8",
91
+ });
92
+ if (result.status !== 0) {
93
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
94
+ throw new Error(
95
+ `command failed: ${[command, ...args].join(" ")}${output ? `\n${output}` : ""}`,
96
+ );
97
+ }
98
+ return result;
99
+ }
100
+
101
+ function runManagedScript(command, args, workspaceRoot) {
102
+ const scriptName = managedCommands.get(command);
103
+ if (!scriptName) throw new Error(`unknown managed command: ${command}`);
104
+ const scriptPath = join(managedScriptDir, scriptName);
105
+ const tsxCli = require.resolve("tsx/cli");
106
+ run(process.execPath, [tsxCli, scriptPath, ...args], {
107
+ cwd: workspaceRoot,
108
+ env: {
109
+ ...process.env,
110
+ LOWCODE_WORKSPACE_ROOT: workspaceRoot,
111
+ },
112
+ });
113
+ }
114
+
115
+ function readJson(path) {
116
+ return JSON.parse(readFileSync(path, "utf-8"));
117
+ }
118
+
119
+ function writeJson(path, value) {
120
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
121
+ }
122
+
123
+ function gitStatus(workspaceRoot) {
124
+ const result = run("git", ["status", "--porcelain"], {
125
+ cwd: workspaceRoot,
126
+ capture: true,
127
+ });
128
+ return String(result.stdout || "").trim();
129
+ }
130
+
131
+ function hasGit(workspaceRoot) {
132
+ return existsSync(join(workspaceRoot, ".git")) || existsSync(join(workspaceRoot, "..", ".git"));
133
+ }
134
+
135
+ function updatePackageJson(workspaceRoot, channel) {
136
+ const packagePath = join(workspaceRoot, "package.json");
137
+ if (!existsSync(packagePath)) throw new Error(`package.json not found: ${packagePath}`);
138
+ const pkg = readJson(packagePath);
139
+ pkg.scripts = {
140
+ ...(pkg.scripts || {}),
141
+ build: "lowcode-workspace build",
142
+ "build:forms": "lowcode-workspace build-forms",
143
+ "build:pages": "lowcode-workspace build-pages",
144
+ "sync-schema": "lowcode-workspace sync-schema",
145
+ "publish:oss": "lowcode-workspace publish-oss",
146
+ register: "lowcode-workspace register",
147
+ "register-bundle": "lowcode-workspace register",
148
+ "publish:all": "lowcode-workspace publish-all",
149
+ "ai:update": `pnpm dlx sy-lowcode-workspace-tools@${channel} update --channel ${channel}`,
150
+ "ai:migrate": `pnpm dlx sy-lowcode-workspace-tools@${channel} migrate`,
151
+ };
152
+ pkg.dependencies = pkg.dependencies || {};
153
+ delete pkg.dependencies["@sy/page-sdk"];
154
+ pkg.dependencies["sy-form-components"] = channel;
155
+ pkg.dependencies["sy-page-sdk"] = channel;
156
+ pkg.dependencies["sy-lowcode-workspace-tools"] = channel;
157
+ writeJson(packagePath, pkg);
158
+ }
159
+
160
+ function wrapperContent(command) {
161
+ return `#!/usr/bin/env node
162
+ import { main } from "sy-lowcode-workspace-tools";
163
+
164
+ await main([${JSON.stringify(command)}, ...process.argv.slice(2)]);
165
+ `;
166
+ }
167
+
168
+ function ensureWrapperScripts(workspaceRoot) {
169
+ const scriptsDir = join(workspaceRoot, "scripts");
170
+ mkdirSync(scriptsDir, { recursive: true });
171
+ for (const [fileName, command] of wrapperScripts) {
172
+ writeFileSync(join(scriptsDir, fileName), wrapperContent(command), "utf-8");
173
+ }
174
+ }
175
+
176
+ function replaceInFile(path, replacer) {
177
+ const before = readFileSync(path, "utf-8");
178
+ const after = replacer(before);
179
+ if (after !== before) writeFileSync(path, after, "utf-8");
180
+ }
181
+
182
+ function rewriteImports(workspaceRoot) {
183
+ walkFiles(workspaceRoot, (filePath) => {
184
+ if (!textExtensions.has(filePath.slice(filePath.lastIndexOf(".")))) return;
185
+ replaceInFile(filePath, (content) =>
186
+ content.replaceAll("@sy/page-sdk", "sy-page-sdk"),
187
+ );
188
+ });
189
+ }
190
+
191
+ function rewriteLocalSdkConfig(workspaceRoot) {
192
+ const viteConfig = join(workspaceRoot, "vite.config.ts");
193
+ if (existsSync(viteConfig)) {
194
+ replaceInFile(viteConfig, (content) =>
195
+ content
196
+ .replace(/\s*\{[^{}]*packages\/page-sdk[^{}]*\},?/g, "")
197
+ .replace(/\s*\{\s*find:\s*["']sy-page-sdk\/react["'][\s\S]*?\n\s*\},\n/g, "")
198
+ .replace(/\s*\{\s*find:\s*["']sy-page-sdk["'][\s\S]*?\n\s*\},\n/g, ""),
199
+ );
200
+ }
201
+ const vitestConfig = join(workspaceRoot, "vitest.config.ts");
202
+ if (existsSync(vitestConfig)) {
203
+ replaceInFile(vitestConfig, (content) =>
204
+ content
205
+ .replace(/\s*\{[^{}]*packages\/page-sdk[^{}]*\},?/g, "")
206
+ .replace(/\s*\{\s*find:\s*["']sy-page-sdk\/react["'][\s\S]*?\n\s*\},\n/g, "")
207
+ .replace(/\s*\{\s*find:\s*["']sy-page-sdk["'][\s\S]*?\n\s*\},\n/g, "")
208
+ .replace(/,\s*["']sy-page-sdk["']/g, ""),
209
+ );
210
+ }
211
+ const tsconfig = join(workspaceRoot, "tsconfig.app.json");
212
+ if (existsSync(tsconfig)) {
213
+ try {
214
+ const config = readJson(tsconfig);
215
+ if (config.compilerOptions?.paths) {
216
+ delete config.compilerOptions.paths["sy-page-sdk"];
217
+ delete config.compilerOptions.paths["sy-page-sdk/react"];
218
+ }
219
+ if (Array.isArray(config.include)) {
220
+ config.include = config.include.filter((item) => !String(item).includes("packages/page-sdk"));
221
+ }
222
+ writeJson(tsconfig, config);
223
+ } catch {
224
+ replaceInFile(tsconfig, (content) =>
225
+ content
226
+ .replace(/\s*["']sy-page-sdk["']:\s*\[[^\n]*\],?\n/g, "")
227
+ .replace(/\s*["']sy-page-sdk\/react["']:\s*\[[^\n]*\],?\n/g, "")
228
+ .replace(/,\s*["']packages\/page-sdk\/src["']/g, "")
229
+ .replace(/["']packages\/page-sdk\/src["'],?\s*/g, ""),
230
+ );
231
+ }
232
+ }
233
+ const tailwindConfig = join(workspaceRoot, "tailwind.config.cjs");
234
+ if (existsSync(tailwindConfig)) {
235
+ replaceInFile(tailwindConfig, (content) =>
236
+ content
237
+ .replace(/,\s*["'][^"']*packages\/page-sdk[^"']*["']/g, "")
238
+ .replace(/["'][^"']*packages\/page-sdk[^"']*["']\s*,?/g, ""),
239
+ );
240
+ }
241
+ }
242
+
243
+ function walkFiles(root, onFile) {
244
+ for (const entry of readdirSync(root)) {
245
+ if (ignoredDirs.has(entry)) continue;
246
+ const path = join(root, entry);
247
+ const stat = statSync(path);
248
+ if (stat.isDirectory()) {
249
+ walkFiles(path, onFile);
250
+ } else if (stat.isFile()) {
251
+ onFile(path);
252
+ }
253
+ }
254
+ }
255
+
256
+ function removeLocalSdk(workspaceRoot) {
257
+ const localSdkPath = join(workspaceRoot, "packages", "page-sdk");
258
+ if (existsSync(localSdkPath)) {
259
+ rmSync(localSdkPath, { recursive: true, force: true });
260
+ }
261
+ }
262
+
263
+ function validateWorkspace(workspaceRoot, channel = "latest") {
264
+ const errors = [];
265
+ const pkg = readJson(join(workspaceRoot, "package.json"));
266
+ const deps = pkg.dependencies || {};
267
+ if (deps["@sy/page-sdk"]) errors.push("package.json still depends on @sy/page-sdk");
268
+ for (const name of ["sy-form-components", "sy-page-sdk", "sy-lowcode-workspace-tools"]) {
269
+ if (deps[name] !== channel) errors.push(`package.json dependency ${name} must be ${channel}`);
270
+ }
271
+ if (existsSync(join(workspaceRoot, "packages", "page-sdk"))) {
272
+ errors.push("local packages/page-sdk must be removed from application workspaces");
273
+ }
274
+ walkFiles(workspaceRoot, (filePath) => {
275
+ if (!textExtensions.has(filePath.slice(filePath.lastIndexOf(".")))) return;
276
+ const content = readFileSync(filePath, "utf-8");
277
+ if (content.includes("@sy/page-sdk")) {
278
+ errors.push(`${filePath} still references @sy/page-sdk`);
279
+ }
280
+ if (content.includes("packages/page-sdk")) {
281
+ errors.push(`${filePath} still references packages/page-sdk`);
282
+ }
283
+ });
284
+ if (errors.length) {
285
+ throw new Error(`workspace update check failed:\n- ${errors.join("\n- ")}`);
286
+ }
287
+ }
288
+
289
+ function runUpdateInstall(workspaceRoot, channel) {
290
+ run("pnpm", ["install"], { cwd: workspaceRoot });
291
+ run("pnpm", [
292
+ "update",
293
+ "--latest",
294
+ "sy-form-components",
295
+ "sy-page-sdk",
296
+ "sy-lowcode-workspace-tools",
297
+ ], { cwd: workspaceRoot });
298
+ updatePackageJson(workspaceRoot, channel);
299
+ run("pnpm", ["install"], { cwd: workspaceRoot });
300
+ }
301
+
302
+ function runQuickGate(workspaceRoot) {
303
+ const pkg = readJson(join(workspaceRoot, "package.json"));
304
+ if (pkg.scripts?.typecheck) {
305
+ run("pnpm", ["typecheck"], { cwd: workspaceRoot });
306
+ }
307
+ runManagedScript("build-pages", ["--help"], workspaceRoot);
308
+ }
309
+
310
+ function commitAndMaybePush(workspaceRoot, push) {
311
+ if (!hasGit(workspaceRoot)) return;
312
+ run("git", ["add", "-A"], { cwd: workspaceRoot });
313
+ const status = gitStatus(workspaceRoot);
314
+ if (!status) return;
315
+ run("git", ["commit", "-m", "chore: update lowcode workspace runtime dependencies"], {
316
+ cwd: workspaceRoot,
317
+ });
318
+ if (push) {
319
+ run("git", ["push", "origin", "HEAD"], { cwd: workspaceRoot });
320
+ }
321
+ }
322
+
323
+ async function updateWorkspace(argv, { migrate = false } = {}) {
324
+ const args = parseArgs(argv);
325
+ const workspaceRoot = resolve(args.workspace || process.cwd());
326
+ const channel = String(args.channel || process.env.APP_WORKSPACE_UPDATE_CHANNEL || "latest");
327
+ const checkOnly = Boolean(args.check);
328
+ const allowDirty = Boolean(args["allow-dirty"]);
329
+ const shouldCommit = Boolean(args.commit) && !args["no-commit"];
330
+ const shouldPush = Boolean(args.push);
331
+
332
+ if (checkOnly) {
333
+ validateWorkspace(workspaceRoot, channel);
334
+ console.log("[lowcode-workspace] update check passed");
335
+ return;
336
+ }
337
+
338
+ if (hasGit(workspaceRoot) && !allowDirty) {
339
+ const before = gitStatus(workspaceRoot);
340
+ if (before) {
341
+ throw new Error(
342
+ "workspace has uncommitted changes; commit/stash them first or pass --allow-dirty",
343
+ );
344
+ }
345
+ }
346
+
347
+ updatePackageJson(workspaceRoot, channel);
348
+ ensureWrapperScripts(workspaceRoot);
349
+ rewriteImports(workspaceRoot);
350
+ rewriteLocalSdkConfig(workspaceRoot);
351
+ if (migrate || existsSync(join(workspaceRoot, "packages", "page-sdk"))) {
352
+ removeLocalSdk(workspaceRoot);
353
+ }
354
+
355
+ if (!args["skip-install"]) runUpdateInstall(workspaceRoot, channel);
356
+ validateWorkspace(workspaceRoot, channel);
357
+ if (!args["skip-gate"]) runQuickGate(workspaceRoot);
358
+ if (shouldCommit) commitAndMaybePush(workspaceRoot, shouldPush);
359
+ console.log("[lowcode-workspace] workspace update completed");
360
+ }
361
+
362
+ export async function main(argv = process.argv.slice(2)) {
363
+ const [command, ...rest] = argv;
364
+ if (!command || command === "--help" || command === "-h") {
365
+ console.log(usage());
366
+ return;
367
+ }
368
+ if (command === "update") {
369
+ await updateWorkspace(rest);
370
+ return;
371
+ }
372
+ if (command === "migrate") {
373
+ await updateWorkspace(rest, { migrate: true });
374
+ return;
375
+ }
376
+ if (managedCommands.has(command)) {
377
+ const args = parseArgs(rest);
378
+ runManagedScript(command, rest, resolve(args.workspace || process.cwd()));
379
+ return;
380
+ }
381
+ throw new Error(`unknown command: ${command}`);
382
+ }