@oj-module/cli 0.2.20 → 0.2.23

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 (39) hide show
  1. package/devkit/manual.md +93 -7
  2. package/package.json +2 -2
  3. package/shell-dist/assets/runtime.js +59 -34
  4. package/shell-dist/index.html +2 -2
  5. package/shell-dist/versions.json +3 -3
  6. package/src/__tests__/init-packagejson.test.ts +29 -0
  7. package/src/args.ts +54 -4
  8. package/src/builtin.ts +93 -0
  9. package/src/bundle.ts +230 -26
  10. package/src/contract/__tests__/market-client.integration.test.ts +12 -12
  11. package/src/dev.ts +58 -2
  12. package/src/index.ts +26 -6
  13. package/src/info.ts +49 -7
  14. package/src/init.ts +23 -7
  15. package/src/lifecycle/__tests__/args.test.ts +20 -0
  16. package/src/lifecycle/__tests__/builtin.test.ts +97 -0
  17. package/src/lifecycle/__tests__/bundle.test.ts +168 -5
  18. package/src/lifecycle/__tests__/new.test.ts +129 -0
  19. package/src/lifecycle/__tests__/smoke.test.ts +2 -1
  20. package/src/manifest-contract.ts +16 -3
  21. package/src/market/publish.ts +10 -2
  22. package/src/new.ts +208 -0
  23. package/src/usage.ts +18 -6
  24. package/templates/package.json +3 -1
  25. package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
  26. package/templates/src/_platform/api/seed.mysql.sql +11 -3
  27. package/templates/src/_platform/api/seed.pg.sql +11 -3
  28. package/templates/src/_platform/api/seed.sql +16 -5
  29. package/templates/src/oj-market/web/api/context.ts +2 -2
  30. package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
  31. package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
  32. package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
  33. package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
  34. package/templates/src/system/web/locales/en-US.json +1 -1
  35. package/templates/src/system/web/locales/zh-CN.json +1 -1
  36. package/templates/tests/l1-builder.test.ts +2 -2
  37. package/templates/tests/rbac-seed.spec.ts +12 -10
  38. package/templates/tests/seed-role-accounts.spec.ts +87 -0
  39. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,129 @@
1
+ // BDD:`ojm new <name>` 方案骨架生成(docs/plans/2026-10-22-1640-multi-solution-bundle-design.md §C)。
2
+ // N0 非工程拒绝 / N1 骨架 round-trip(生成→补产物→buildBundle 直接过)/ N2 --api
3
+ // N3 非法·保留·内置·重名拒绝 / N5 空名拒绝 / N6 历史布局拒绝 / N7 失败清理
4
+ import fs, { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, writeFileSync as writeFileSyncOrig } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { buildBundle } from "../../bundle";
9
+ import { createSolution } from "../../new";
10
+
11
+ let project: string;
12
+
13
+ beforeEach(() => {
14
+ project = mkdtempSync(join(tmpdir(), "ojm-new-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(project, { recursive: true, force: true });
19
+ });
20
+
21
+ /** 最小 module-root 工程面:web.config.ts + src/(含一个自带模块 home 与内置名单) */
22
+ function makeModuleRootProject(builtin = true): void {
23
+ mkdirSync(join(project, "src", "home", "web"), { recursive: true });
24
+ writeFileSync(join(project, "src", "home", "web", "entry.ts"), "export default { name: \"home\" };\n");
25
+ writeFileSync(join(project, "web.config.ts"), "export default { baseUrl: \"\", modules: [] };\n");
26
+ if (builtin)
27
+ writeFileSync(join(project, "src", ".ojm-builtin.json"), JSON.stringify({ modules: ["home"] }));
28
+ }
29
+
30
+ /** 给已生成的方案补站点产物,模拟「ojm build 之后」的打包前置 */
31
+ function addWebArtifacts(name: string, version = "0.1.0"): void {
32
+ const webDist = join(project, "web/dist/modules", name, version);
33
+ mkdirSync(webDist, { recursive: true });
34
+ writeFileSync(join(webDist, "entry.js"), "export default {};\n");
35
+ }
36
+
37
+ describe("createSolution · 守卫", () => {
38
+ it("n0 非 ojm 工程(无 web.config.ts / src/)→ 拒绝且零写入", () => {
39
+ expect(() => createSolution(project, "crm")).toThrow(/不是 ojm 工程/);
40
+ expect(existsSync(join(project, "src"))).toBe(false);
41
+ });
42
+
43
+ it("n6 历史布局工程(web/src 树)→ 拒绝 + 迁移指引", () => {
44
+ mkdirSync(join(project, "web", "src", "old"), { recursive: true });
45
+ writeFileSync(join(project, "web.config.ts"), "export default { modules: [] };\n");
46
+ expect(() => createSolution(project, "crm")).toThrow(/module-root/);
47
+ expect(existsSync(join(project, "src", "crm"))).toBe(false);
48
+ });
49
+
50
+ it("n3 非法名(大写/数字开头/路径穿越)→ 拒绝", () => {
51
+ makeModuleRootProject();
52
+ for (const bad of ["Crm", "9crm", "../crm", "bad name"])
53
+ expect(() => createSolution(project, bad)).toThrow(/非法/);
54
+ });
55
+
56
+ it("n3 保留名(system)与内置名(home)→ 拒绝 + 指引", () => {
57
+ makeModuleRootProject();
58
+ expect(() => createSolution(project, "system")).toThrow(/保留名/);
59
+ expect(() => createSolution(project, "home")).toThrow(/框架自带/);
60
+ });
61
+
62
+ it("n3 目录已存在(非内置共享目录撞名)→ 拒绝", () => {
63
+ makeModuleRootProject();
64
+ mkdirSync(join(project, "src", "crm"), { recursive: true });
65
+ expect(() => createSolution(project, "crm")).toThrow(/已存在/);
66
+ });
67
+
68
+ it("n5 空名 → 拒绝", () => {
69
+ makeModuleRootProject();
70
+ expect(() => createSolution(project, "")).toThrow(/非法/);
71
+ });
72
+ });
73
+
74
+ describe("createSolution · 生成", () => {
75
+ it("n1 生成 web 骨架:solution.yaml/entry.ts/页面/双语 locales 齐备,layout 写进 handle", () => {
76
+ makeModuleRootProject();
77
+ const r = createSolution(project, "crm", { layout: "container" });
78
+ expect(r.files).toHaveLength(5);
79
+ const entry = readFileSync(join(project, "src/crm/web/entry.ts"), "utf-8");
80
+ expect(entry).toContain("layout: \"container\"");
81
+ expect(entry).toContain("crm:menu.main");
82
+ expect(readFileSync(join(project, "src/crm/solution.yaml"), "utf-8")).toContain("name: crm");
83
+ expect(JSON.parse(readFileSync(join(project, "src/crm/web/locales/zh-CN.json"), "utf-8")).menu.main).toBeTruthy();
84
+ expect(JSON.parse(readFileSync(join(project, "src/crm/web/locales/en-US.json"), "utf-8")).menu.main).toBeTruthy();
85
+ // --layout none
86
+ createSolution(project, "erp", { layout: "none" });
87
+ expect(readFileSync(join(project, "src/erp/web/entry.ts"), "utf-8")).toContain("layout: \"none\"");
88
+ });
89
+
90
+ it("n1 round-trip:生成的骨架补产物后 buildBundle({ name }) 直接过", async () => {
91
+ makeModuleRootProject();
92
+ createSolution(project, "crm");
93
+ addWebArtifacts("crm");
94
+ const results = await buildBundle(project, { out: join(project, "dist"), name: "crm" });
95
+ expect(results.map(r => r.name)).toEqual(["crm"]);
96
+ expect(existsSync(results[0]!.tgz)).toBe(true);
97
+ });
98
+
99
+ it("n2 --api:额外生成 api 骨架且 solution.yaml 含 api 段", () => {
100
+ makeModuleRootProject();
101
+ createSolution(project, "crm", { api: true });
102
+ expect(existsSync(join(project, "src/crm/api/manifest.yaml"))).toBe(true);
103
+ expect(readFileSync(join(project, "src/crm/api/manifest.yaml"), "utf-8")).toContain("name: crm");
104
+ expect(readFileSync(join(project, "src/crm/api/api.ts"), "utf-8")).toContain("/api/crm/ping");
105
+ const sol = readFileSync(join(project, "src/crm/solution.yaml"), "utf-8");
106
+ expect(sol).toContain("api:");
107
+ expect(sol).toContain("prefix: /api/crm");
108
+ });
109
+
110
+ it("n7 生成中途失败 → 不残留半截 src/<name>/", () => {
111
+ makeModuleRootProject();
112
+ // 让第二次写盘(entry.ts)必失败:首写(solution.yaml)成功后触发清理路径——
113
+ // 预置冲突文件会先在「目录已存在」守卫被拒,测不到 rmSync 回滚(审查发现 n7 旧用例假绿)
114
+ let calls = 0;
115
+ const spy = vi.spyOn(fs, "writeFileSync").mockImplementation(((...args: unknown[]) => {
116
+ calls++;
117
+ if (calls === 2)
118
+ throw new Error("ENOSPC: disk full");
119
+ return (writeFileSyncOrig as (...a: unknown[]) => unknown)(...args);
120
+ }) as typeof fs.writeFileSync);
121
+ try {
122
+ expect(() => createSolution(project, "crm")).toThrow(/disk full/);
123
+ }
124
+ finally {
125
+ spy.mockRestore();
126
+ }
127
+ expect(existsSync(join(project, "src", "crm"))).toBe(false);
128
+ });
129
+ });
@@ -49,7 +49,8 @@ describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJ
49
49
  mkdirSync(webDist, { recursive: true });
50
50
  writeFileSync(join(webDist, "entry.js"), "export default {};\n");
51
51
  writeFileSync(join(webDist, "demo-solution.css"), ".demo{}\n");
52
- const { tgz } = await buildBundle(project, { out: join(project, "dist") });
52
+ const [built] = await buildBundle(project, { out: join(project, "dist") });
53
+ const { tgz } = built!;
53
54
 
54
55
  const record = await installCommand(tgz, { url: SMOKE_URL ?? DEFAULT_SITE_URL, user: SMOKE_USER, pass: SMOKE_PASS });
55
56
  expect(record.status).toBe("done");
@@ -16,11 +16,24 @@ export type ParseResult = { ok: true, manifest: BundleManifest } | { ok: false,
16
16
 
17
17
  // ── 契约 §2/§4 正则与清单(与 preflight.ts 逐字对应;改动须先改契约再同步双端与夹具)──
18
18
 
19
- /** C2-01:name 白名单 */
20
- const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
19
+ /** C2-01:name 白名单(导出供 ojm new/--bundle <name> 入口校验共用,DRY) */
20
+ export const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
21
+
22
+ /**
23
+ * 方案名入口校验(`--bundle <name>` / `ojm new <name>` 共用,先校验再拼路径):
24
+ * 与契约 C2-01 同一条正则;入口先给可读错误,契约校验再做正式判罚。
25
+ * 放在这里而非 bundle.ts——new 入口不应拖入打包模块图(esbuild/deterministic-tar 等)。
26
+ */
27
+ export function assertValidSolutionName(name: string, where: string): void {
28
+ if (!NAME_RE.test(name)) {
29
+ throw new Error(
30
+ `[ojm] ${where}:「${name}」非法——须为 kebab-case(小写字母/数字/连字符,首字符为小写字母)。`,
31
+ );
32
+ }
33
+ }
21
34
 
22
35
  /** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留) */
23
- const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
36
+ export const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
24
37
 
25
38
  /** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
26
39
  const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
@@ -118,9 +118,17 @@ async function buildFixture(args: { fixture?: string, staging: string, cdnDir: s
118
118
  return { name: spec.name, version: spec.version, tgzPath, sha256, preExisted: before.exists };
119
119
  }
120
120
 
121
- /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录 */
121
+ /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录(一次只发一份:多方案工程请显式指定) */
122
122
  async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
123
- const built = await buildBundle(solutionRoot, { out: staging });
123
+ const builtList = await buildBundle(solutionRoot, { out: staging });
124
+ if (builtList.length !== 1) {
125
+ throw new Error(
126
+ `[ojm] market publish --real 一次只发布一份方案,但该工程打出了 ${builtList.length} 份用户方案。`
127
+ + "请改用显式链路:在方案工程里 `ojm build --bundle <name>`,再 `ojm sign dist/<name>-<version>.tgz --id <publisher>.<key>`,"
128
+ + "把 tgz + .sig 放进 cdn/ 后 `ojm market build-index`。",
129
+ );
130
+ }
131
+ const built = builtList[0]!;
124
132
  const tgzPath = path.join(cdnDir, `${built.name}-${built.version}.tgz`);
125
133
  const before = captureExisting(tgzPath);
126
134
 
package/src/new.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `ojm new <name>` —— 脚手架内快速生成用户方案骨架(多方案 phase 3,设计 v2 §C)。
3
+ *
4
+ * 生成 module-root 布局的 `src/<name>/{api,web}` 最小骨架:solution.yaml(方案契约,
5
+ * `ojm build --bundle` 入口)+ web(entry.ts 示例路由 / 占位页 / 双语 locales)+
6
+ * 可选 api(oj 七键 manifest + ping 示例 handler)。生成物补产物后即过打包校验
7
+ * (round-trip 承诺,N1 用例钉死)。
8
+ *
9
+ * 前置守卫(全 fail-fast,顺序即排查顺序):
10
+ * 1. N0 非 ojm 工程(无 web.config.ts / src/)→ 指向 `ojm init`;
11
+ * 2. N6 历史布局(new/flat)→ 拒绝 + 迁移指引(生成 module-root 骨架会触发
12
+ * 双源码树互斥,把 build/dev 打挂——宁可拒绝不留半套);
13
+ * 3. N3 名字四查:kebab-case 正则(与契约 C2-01 同条)→ 保留名 → 框架自带名
14
+ * (`src/.ojm-builtin.json`)→ 目录已存在;
15
+ * 4. N7 原子性:任一步失败 best-effort 删除已建 `src/<name>/`,不留半截目录
16
+ * (半截目录会触发 scanModuleRoot 的坏模块报错)。
17
+ *
18
+ * 刻意不做:web.config.ts 登记——module-root 布局以 `src/<name>/web/entry.ts`
19
+ * 扫描为真源(0.2.20 起登记只是可选排序覆盖),生成即被扫描收录,向用户 TS 源文件
20
+ * 做文本插入既脆弱又零收益(六方评审一致结论)。
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { readBuiltinModules } from "./builtin";
26
+ import { tryResolveLayout } from "./layout";
27
+ import { assertValidSolutionName, RESERVED_NAMES } from "./manifest-contract";
28
+
29
+ export interface NewSolutionOptions {
30
+ /** 示例路由布局(默认 container;none = 无外壳裸页面) */
31
+ layout?: "container" | "none"
32
+ /** 同时生成 api 骨架(oj 七键 manifest + ping 示例 handler) */
33
+ api?: boolean
34
+ }
35
+
36
+ export interface NewSolutionResult {
37
+ /** 生成的方案目录(src/<name>) */
38
+ dir: string
39
+ /** 相对工程根的文件清单 */
40
+ files: string[]
41
+ }
42
+
43
+ export function createSolution(projectRoot: string, name: string, opts: NewSolutionOptions = {}): NewSolutionResult {
44
+ // N0:工程判定(web.config.ts 与 src/ 至少要有一个——空目录跑 new 是最常见误用)
45
+ const hasConfig = fs.existsSync(path.join(projectRoot, "web.config.ts"));
46
+ const hasSrc = fs.existsSync(path.join(projectRoot, "src"));
47
+ if (!hasConfig && !hasSrc) {
48
+ throw new Error(
49
+ `[ojm] ${projectRoot} 不是 ojm 工程(没有 web.config.ts / src/)。`
50
+ + "先 `ojm init` 创建脚手架,再在里面 `ojm new <name>`。",
51
+ );
52
+ }
53
+
54
+ // N6:布局守卫。tryResolveLayout 自带双树互斥/坏目录的人话报错,直接传播;
55
+ // 历史布局(new/flat)拒绝——module-root 骨架落进去会触发互斥,把 build/dev 打挂
56
+ const layout = tryResolveLayout(projectRoot);
57
+ if (layout !== null && layout.kind !== "module-root") {
58
+ throw new Error(
59
+ `[ojm] ojm new 只支持 module-root 布局工程(src/<name>/{api,web}),当前为 ${layout.kind} 历史布局。`
60
+ + "在历史布局工程里生成骨架会触发两套源码树互斥、把 build/dev 打挂,故拒绝。"
61
+ + "请先迁移布局,或在新脚手架工程(ojm init)里使用。",
62
+ );
63
+ }
64
+
65
+ // N3:名字四查
66
+ assertValidSolutionName(name, "ojm new <name>");
67
+ if (RESERVED_NAMES.has(name)) {
68
+ throw new Error(
69
+ `[ojm] 「${name}」是保留名(框架功能命名空间,如 system/auth/market),不能用作方案名。`
70
+ + "换一个不与框架能力撞名的名字。",
71
+ );
72
+ }
73
+ if (readBuiltinModules(projectRoot).has(name)) {
74
+ throw new Error(
75
+ `[ojm] 「${name}」是框架自带方案(见 src/.ojm-builtin.json;ojm info --all 可看全名单)。`
76
+ + "想改造自带模块:`ojm new <新名>` 生成骨架后把代码复制过去(自带身份按目录名判定,复制即脱离)。",
77
+ );
78
+ }
79
+ const dir = path.join(projectRoot, "src", name);
80
+ if (fs.existsSync(dir))
81
+ throw new Error(`[ojm] src/${name}/ 已存在。换个名字,或删掉该目录后重试。`);
82
+
83
+ const shell = opts.layout ?? "container";
84
+ const files: string[] = [];
85
+ try {
86
+ put(projectRoot, files, path.join(dir, "solution.yaml"), solutionYaml(name, opts.api));
87
+ put(projectRoot, files, path.join(dir, "web", "entry.ts"), entryTs(name, shell));
88
+ put(projectRoot, files, path.join(dir, "web", "pages", "index.tsx"), pageTsx(name));
89
+ put(projectRoot, files, path.join(dir, "web", "locales", "zh-CN.json"), `${JSON.stringify({ menu: { main: `${name} 方案` } }, null, "\t")}\n`);
90
+ put(projectRoot, files, path.join(dir, "web", "locales", "en-US.json"), `${JSON.stringify({ menu: { main: name } }, null, "\t")}\n`);
91
+ if (opts.api) {
92
+ put(projectRoot, files, path.join(dir, "api", "manifest.yaml"), apiManifest(name));
93
+ put(projectRoot, files, path.join(dir, "api", "api.ts"), apiTs(name));
94
+ }
95
+ }
96
+ catch (error) {
97
+ // N7:不留半截目录(scanModuleRoot 会把「有 api/ 或 web/ 但缺标记」的目录判成坏模块)
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ throw error;
100
+ }
101
+ return { dir, files };
102
+ }
103
+
104
+ function put(projectRoot: string, files: string[], p: string, content: string): void {
105
+ fs.mkdirSync(path.dirname(p), { recursive: true });
106
+ fs.writeFileSync(p, content);
107
+ files.push(path.relative(projectRoot, p));
108
+ }
109
+
110
+ function solutionYaml(name: string, withApi: boolean | undefined): string {
111
+ const api = withApi
112
+ ? [
113
+ "api:",
114
+ ` module: ${name}`,
115
+ ` prefix: /api/${name}`,
116
+ "",
117
+ ].join("\n")
118
+ : "";
119
+ return `# 方案契约:ojm build --bundle 的入口(字段规范见 devkit 手册 §11.1;
120
+ # 优先级:本文件 > 工程根 solution.yaml > 同名 manifest.yaml 回退)。
121
+ name: ${name}
122
+ version: 0.1.0
123
+ title: ${name}
124
+ summary: ${name} 方案(ojm new 生成的骨架,替换为你的描述)
125
+ changelog:
126
+ - 0.1.0 首个版本(ojm new 生成)
127
+ ${api}web:
128
+ route_prefix: /${name}
129
+ entry: modules/${name}/0.1.0/entry.js
130
+ `;
131
+ }
132
+
133
+ function entryTs(name: string, shell: "container" | "none"): string {
134
+ return `import { defineModule } from "@oj-module/runtime";
135
+ import { lazy } from "react";
136
+
137
+ const Index = lazy(() => import("./pages/index"));
138
+
139
+ /**
140
+ * ${name} 方案:\`ojm new\` 生成的最小骨架。
141
+ * 菜单顺序由 handle.order 决定(≥0 主菜单区升序;<0 底部固定区);
142
+ * 标题键对应 locales/<lang>.json 的 menu.main。
143
+ */
144
+ export default defineModule({
145
+ name: "${name}",
146
+ description: "${name} 方案",
147
+ version: "0.1.0",
148
+ peerRuntime: ">=0.0.0",
149
+ routes: [
150
+ {
151
+ path: "/${name}",
152
+ handle: { layout: "${shell}", order: 200, title: "${name}:menu.main" },
153
+ children: [
154
+ {
155
+ index: true,
156
+ Component: Index,
157
+ handle: { title: "${name}:menu.main" },
158
+ },
159
+ ],
160
+ },
161
+ ],
162
+ i18n: {
163
+ "zh-CN": () => import("./locales/zh-CN.json"),
164
+ "en-US": () => import("./locales/en-US.json"),
165
+ },
166
+ });
167
+ `;
168
+ }
169
+
170
+ function pageTsx(name: string): string {
171
+ return `import { BasicContent } from "@oj-module/runtime";
172
+ import { Card, Typography } from "antd";
173
+
174
+ /** ${name} 方案首页:\`ojm new\` 生成的占位页,替换为你的实现。 */
175
+ export default function IndexPage() {
176
+ return (
177
+ <BasicContent>
178
+ <Card>
179
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
180
+ ${name}
181
+ </Typography.Title>
182
+ <Typography.Text type="secondary">
183
+ 由 ojm new 生成的方案骨架——从这里开始你的实现。
184
+ </Typography.Text>
185
+ </Card>
186
+ </BasicContent>
187
+ );
188
+ }
189
+ `;
190
+ }
191
+
192
+ function apiManifest(name: string): string {
193
+ return `# oj 原生七键清单:oj 按 src/<name>/api/manifest.yaml 枚举发现后端模块,
194
+ # 与方案契约(上级 solution.yaml)是两条互不干扰的枚举(devkit 手册 §1.2)。
195
+ name: ${name}
196
+ desc: ${name} 方案后端(ojm new 生成的骨架)
197
+ version: 0.1.0
198
+ tables: []
199
+ `;
200
+ }
201
+
202
+ function apiTs(name: string): string {
203
+ return `// GET /api/${name}/ping —— 健康探针示例(保存即热更;出参挂平级 .response 即入 openapi.json)。
204
+ export async function ping() {
205
+ json.ok({ pong: true, module: "${name}" });
206
+ }
207
+ `;
208
+ }
package/src/usage.ts CHANGED
@@ -16,11 +16,15 @@ export function usageText(): string {
16
16
  开发模式快捷键(仅 TTY 终端,类 Vite):
17
17
  r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开
18
18
  q 退出(后端 oj 一并终止)
19
- ojm build [--bundle] [--out <dir>]
19
+ ojm build [--bundle [name]] [--out <dir>]
20
20
  构建后端(oj build)与模块产物(含全站合并)
21
- --bundle 方案包构建:manifest.yaml 契约校验(C2,fail-fast)→ 确定性 tgz
21
+ --bundle 方案包构建:契约校验(fail-fast)→ 确定性 tgz
22
22
  → dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)
23
+ --bundle <name> 只打这一份方案(无参 = 全部用户方案,框架自带自动排除)
23
24
  --out <dir> --bundle 输出目录(缺省 dist/)
25
+ ojm new <name> [--layout container|none] [--api]
26
+ 脚手架内生成用户方案骨架(src/<name>/{api,web} + solution.yaml;
27
+ module-root 扫描自动收录,无需登记 web.config.ts)
24
28
  ojm preview [port]
25
29
  生产形态预览(migrate → oj serve 单服务:API 与静态 SPA 同进程,深链回落)
26
30
  静态由 oj web 挂载直出;ojm 不起第二个服务、不做反代
@@ -105,11 +109,18 @@ const COMMAND_HELP: Record<string, string> = {
105
109
  r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开
106
110
  q 退出(后端 oj 一并终止)
107
111
  port 缺省 5174(oj 后端端口以 config.yaml 的 server.port 为准)`,
108
- "build": `ojm build [--bundle] [--out <dir>]
112
+ "build": `ojm build [--bundle [name]] [--out <dir>]
109
113
  构建后端(oj build)与模块产物(含全站合并)
110
- --bundle 方案包构建:manifest.yaml 契约校验(fail-fast)→ 确定性 tgz
114
+ --bundle 方案包构建:契约校验(fail-fast,两段式全绿才写盘)→ 确定性 tgz
111
115
  → dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)
116
+ --bundle <name> / --bundle=<name> 只打这一份方案;无参 = 全部用户方案
117
+ (src/<name>/solution.yaml;框架自带方案自动排除并逐条日志)
112
118
  --out <dir> --bundle 输出目录(缺省 dist/)`,
119
+ "new": `ojm new <name> [--layout container|none] [--api]
120
+ 脚手架内生成用户方案骨架:src/<name>/solution.yaml(契约,build --bundle 入口)
121
+ + web(entry.ts 示例路由/占位页/双语 locales)+ 可选 api(oj 七键 manifest + ping 示例)
122
+ 守卫:非 ojm 工程 / 历史布局 / 非法名 / 保留名 / 框架自带名 / 重名一律拒绝(零写入)
123
+ module-root 布局以 src/<name>/web/entry.ts 扫描收录,无需登记 web.config.ts`,
113
124
  "preview": `ojm preview [port]
114
125
  生产形态预览(migrate → oj serve 单服务:API 与静态 SPA 同进程,深链回落)
115
126
  静态由 oj web 挂载直出;ojm 不起第二个服务、不做反代
@@ -120,8 +131,9 @@ const COMMAND_HELP: Record<string, string> = {
120
131
  --exempt <path> 指定豁免清单,覆盖默认 .ojm-api-exempt.json(工程根;历史布局在 api/ 下)
121
132
  --docs 出自包含文档站(单文件离线可看)
122
133
  dir 缺省当前目录;显式目录须先存在`,
123
- "info": `ojm info
124
- 输出版本矩阵与模块清单(报障用)`,
134
+ "info": `ojm info [--all]
135
+ 输出版本矩阵与模块清单(报障用)
136
+ --all 展开框架自带模块(默认折叠为一行计数;名单见 src/.ojm-builtin.json)`,
125
137
  "vendor": `ojm vendor [tag] [--force]
126
138
  下载/重装 oj vendor(经 npm 包 @oj-bin/oj;tag 缺省取最新,形如 v0.1.13)
127
139
  --force 即使已存在也重新下载`,
@@ -9,7 +9,9 @@
9
9
  "preview": "ojm preview",
10
10
  "info": "ojm info",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
- "typecheck:api": "tsc --noEmit -p src/tsconfig.json"
12
+ "typecheck:api": "tsc --noEmit -p src/tsconfig.json",
13
+ "bundle": "ojm build --bundle",
14
+ "new": "ojm new"
13
15
  },
14
16
  "devDependencies": {
15
17
  "@ant-design/cssinjs": "catalog:",
@@ -1,10 +1,9 @@
1
1
  -- L1 测试账号(仅 `oj test` 经 fixtures 灌入,不进 release 产物)。
2
2
  --
3
- -- 为什么不能用 seed 的 admin 跑 L1 登录:
4
- -- ① seed 的 admin 是 must_change=1 首登改密态(D12 生产语义),且初始口令 123456
5
- -- 只是开发便利——用户在 dev 库改过口令后(产品主动引导),admin 口令即漂移,
6
- -- L1 若绑 admin/123456 会随机 401(本地实测踩过:dev 库口令被改成 12345678)。
7
- -- ② 判权类端点(currentAccess/assertPermission)对 must_change=1 恒 403。
3
+ -- 为什么不能用 seed 的开发账号(admin/superadmin 等)跑 L1 登录:
4
+ -- ① seed 账号初始口令 123456 只是开发便利——用户在 dev 库改过口令后,口令即漂移,
5
+ -- L1 若绑 seed 账号会随机 401(本地实测踩过:dev 库口令被改成 12345678)。
6
+ -- ② 不用 must_change 解释:seed 账号现均为 0(2026-10-22 口径);真正的原因是 ①。
8
7
  -- 故 additive 建一个专用账号:INSERT OR IGNORE 幂等,反复重放终态一致,
9
8
  -- 不碰 admin 既有行(用户的口令/状态不受 `oj test` 影响)。
10
9
  -- 按分号朴素切分(S006):语句内不得含分号字面量。
@@ -1,8 +1,16 @@
1
1
  -- _platform 种子数据 mysql 方言覆盖(方言覆盖文件必胜;INSERT OR IGNORE → INSERT IGNORE)。
2
- -- 语句分号切分与注释纪律同基线 seed.sql。
3
- INSERT IGNORE INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 1);
2
+ -- 语句分号切分与注释纪律同基线 seed.sql;账号口径同基线(账号名=角色名,must_change=0)。
3
+ INSERT IGNORE INTO users (username, password_hash, must_change) VALUES ('superadmin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
4
+ INSERT IGNORE INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
5
+ INSERT IGNORE INTO users (username, password_hash, must_change) VALUES ('editor', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
6
+ INSERT IGNORE INTO users (username, password_hash, must_change) VALUES ('viewer', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
7
+ DELETE FROM sys_user_role WHERE role_name = 'superadmin' AND user_id = (SELECT id FROM users WHERE username = 'admin');
8
+ UPDATE users SET must_change = 0 WHERE username IN ('superadmin', 'admin', 'editor', 'viewer');
4
9
  INSERT IGNORE INTO sys_role (name, module, title, builtin) VALUES ('superadmin', 'framework', '超级管理员', 1);
5
10
  INSERT IGNORE INTO sys_role (name, module, title, builtin) VALUES ('admin', 'framework', '系统管理员', 1);
6
11
  INSERT IGNORE INTO sys_role (name, module, title, builtin) VALUES ('editor', 'framework', '编辑', 1);
7
12
  INSERT IGNORE INTO sys_role (name, module, title, builtin) VALUES ('viewer', 'framework', '访客', 1);
8
- INSERT IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'admin';
13
+ INSERT IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'superadmin';
14
+ INSERT IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'admin' FROM users WHERE username = 'admin';
15
+ INSERT IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'editor' FROM users WHERE username = 'editor';
16
+ INSERT IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'viewer' FROM users WHERE username = 'viewer';
@@ -1,8 +1,16 @@
1
1
  -- _platform 种子数据 pg 方言覆盖(方言覆盖文件必胜;INSERT OR IGNORE → ON CONFLICT DO NOTHING)。
2
- -- 语句分号切分与注释纪律同基线 seed.sql。
3
- INSERT INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 1) ON CONFLICT DO NOTHING;
2
+ -- 语句分号切分与注释纪律同基线 seed.sql;账号口径同基线(账号名=角色名,must_change=0)。
3
+ INSERT INTO users (username, password_hash, must_change) VALUES ('superadmin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0) ON CONFLICT DO NOTHING;
4
+ INSERT INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0) ON CONFLICT DO NOTHING;
5
+ INSERT INTO users (username, password_hash, must_change) VALUES ('editor', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0) ON CONFLICT DO NOTHING;
6
+ INSERT INTO users (username, password_hash, must_change) VALUES ('viewer', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0) ON CONFLICT DO NOTHING;
7
+ DELETE FROM sys_user_role WHERE role_name = 'superadmin' AND user_id = (SELECT id FROM users WHERE username = 'admin');
8
+ UPDATE users SET must_change = 0 WHERE username IN ('superadmin', 'admin', 'editor', 'viewer');
4
9
  INSERT INTO sys_role (name, module, title, builtin) VALUES ('superadmin', 'framework', '超级管理员', 1) ON CONFLICT DO NOTHING;
5
10
  INSERT INTO sys_role (name, module, title, builtin) VALUES ('admin', 'framework', '系统管理员', 1) ON CONFLICT DO NOTHING;
6
11
  INSERT INTO sys_role (name, module, title, builtin) VALUES ('editor', 'framework', '编辑', 1) ON CONFLICT DO NOTHING;
7
12
  INSERT INTO sys_role (name, module, title, builtin) VALUES ('viewer', 'framework', '访客', 1) ON CONFLICT DO NOTHING;
8
- INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'admin' ON CONFLICT DO NOTHING;
13
+ INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'superadmin' ON CONFLICT DO NOTHING;
14
+ INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'admin' FROM users WHERE username = 'admin' ON CONFLICT DO NOTHING;
15
+ INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'editor' FROM users WHERE username = 'editor' ON CONFLICT DO NOTHING;
16
+ INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'viewer' FROM users WHERE username = 'viewer' ON CONFLICT DO NOTHING;
@@ -1,16 +1,27 @@
1
1
  -- _platform 种子数据(只放数据,DDL 归 migrations/,S006 纪律)。
2
2
  -- 方言覆盖:seed.mysql.sql / seed.pg.sql(INSERT OR IGNORE 为 sqlite 专属语法,方言文件必胜,同迁移约定)。
3
3
  -- 注意:seed 按英文分号朴素切分语句,注释里也不要出现英文分号。
4
- -- 账号生命周期 D12:admin 为 seed 超管(superadmin),must_change=1 首登强制改密——
5
- -- 初始口令 123456 仅为开发便利,must_change 闭环兜底默认凭据窗口(口令不随机生成的取舍见计划 A2 阶段小结)
6
- INSERT OR IGNORE INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 1);
4
+ -- 账号生命周期 D12(2026-10-22 调整):四个内置角色各一个开发账号,**账号名=角色名**
5
+ -- (superadmin/admin/editor/viewer,避免心智负担),口令均 123456,must_change=0 免首登改密——
6
+ -- 纯开发便利(默认凭据不随机生成的取舍见计划 A2 阶段小结),生产部署须改密或删除
7
+ INSERT OR IGNORE INTO users (username, password_hash, must_change) VALUES ('superadmin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
8
+ INSERT OR IGNORE INTO users (username, password_hash, must_change) VALUES ('admin', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
9
+ INSERT OR IGNORE INTO users (username, password_hash, must_change) VALUES ('editor', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
10
+ INSERT OR IGNORE INTO users (username, password_hash, must_change) VALUES ('viewer', '$2y$10$ylE2NdjnYiuEcq54W/KxH.bhvGaQQg3rbmYI3mcEHX6LdQEAqixeC', 0);
11
+ -- 历史 seed 把 admin 绑在 superadmin 上;收敛为账号名=角色名(幂等 DELETE,每次重放生效)。
12
+ -- 已存在库中 admin 行可能残留 must_change=1,UPDATE 统一收敛为 0(四个均为 seed 自有开发账号)
13
+ DELETE FROM sys_user_role WHERE role_name = 'superadmin' AND user_id = (SELECT id FROM users WHERE username = 'admin');
14
+ UPDATE users SET must_change = 0 WHERE username IN ('superadmin', 'admin', 'editor', 'viewer');
7
15
  -- 框架内置角色(ROLES 单一真相:superadmin 安全敏感项 / admin 日常管理 / editor、viewer 页面门)
8
16
  INSERT OR IGNORE INTO sys_role (name, module, title, builtin) VALUES ('superadmin', 'framework', '超级管理员', 1);
9
17
  INSERT OR IGNORE INTO sys_role (name, module, title, builtin) VALUES ('admin', 'framework', '系统管理员', 1);
10
18
  INSERT OR IGNORE INTO sys_role (name, module, title, builtin) VALUES ('editor', 'framework', '编辑', 1);
11
19
  INSERT OR IGNORE INTO sys_role (name, module, title, builtin) VALUES ('viewer', 'framework', '访客', 1);
12
- -- seed 超管绑定(sys_user_role 唯一权威源,D10)
13
- INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'admin';
20
+ -- 角色绑定(sys_user_role 唯一权威源,D10):账号名=角色名一一对应
21
+ INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'superadmin';
22
+ INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'admin' FROM users WHERE username = 'admin';
23
+ INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'editor' FROM users WHERE username = 'editor';
24
+ INSERT OR IGNORE INTO sys_user_role (user_id, role_name) SELECT id, 'viewer' FROM users WHERE username = 'viewer';
14
25
  -- 系统设置默认值(PRD §4.3;C4 设置页 / C5 登录方式发现的数据源;framework.market_cdn_url 安全组仅 superadmin 可改)
15
26
  INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('framework.site_title', 'framework', 'general', 'string', 'React Antd Admin', 'React Antd Admin', 'system:setting.site_title', 0, 0);
16
27
  INSERT OR IGNORE INTO sys_setting (key, module, "group", type, value, default_value, label_key, secret, update_time) VALUES ('framework.market_cdn_url', 'framework', 'security', 'string', '', '', 'system:setting.market_cdn_url', 0, 0);
@@ -1,5 +1,5 @@
1
1
  import type { MarketClient } from "./client";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, use } from "react";
3
3
  import { httpMarketClient } from "./defaultClient";
4
4
 
5
5
  /** 市场客户端上下文(依赖注入位;默认 = 运行时 HTTP 实现,测试可替换为内存 fake) */
@@ -7,5 +7,5 @@ export const MarketClientContext = createContext<MarketClient>(httpMarketClient)
7
7
 
8
8
  /** 组件/hooks 取客户端(默认注入运行时实现) */
9
9
  export function useMarketClient(): MarketClient {
10
- return useContext(MarketClientContext);
10
+ return use(MarketClientContext);
11
11
  }
@@ -44,7 +44,7 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
44
44
  // onDone 对同一任务只通告一次:父级每次渲染都会换 onDone 身份,若只靠 deps 触发,
45
45
  // 终态后 onDone → index.retry() → 父级重渲染 → onDone 身份变 → 再触发,会形成
46
46
  // 无限请求循环(浏览器侧表现为 ERR_INSUFFICIENT_RESOURCES)。
47
- const notifiedTaskId = useRef<string | null>(null);
47
+ const notifiedTaskIdRef = useRef<string | null>(null);
48
48
 
49
49
  const targetVersion = version ?? row?.latest ?? null;
50
50
  const title
@@ -54,9 +54,13 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
54
54
  ? t("oj-market:card:upgrade")
55
55
  : t("oj-market:task:installing");
56
56
 
57
- // 打开即触发安装(version 缺省取 latest;已装视为升级;显式 version = 回滚降级安装)
57
+ // 打开即触发安装(version 缺省取 latest;已装视为升级;显式 version = 回滚降级安装)。
58
+ // 注:下面的同步 set 均为「弹窗打开/关闭生命周期」的有意重置与触发守卫;
59
+ // 曾尝试渲染期派生重置,但同组件 notifier effect 里的 onDone?.(record) 会触发
60
+ // eslint-react set-state-in-effect 的误判(prop 回调被解析回组件函数),故保留原结构。
58
61
  useEffect(() => {
59
62
  if (open && row && !started) {
63
+ // eslint-disable-next-line react/set-state-in-effect -- 有意:打开弹窗时置「已触发」守卫,保证安装只发起一次
60
64
  setStarted(true);
61
65
  client
62
66
  .install({ name: row.name, version: targetVersion, source: "cdn" })
@@ -64,19 +68,22 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
64
68
  .catch(e => setTaskId(`__error__${e instanceof Error ? e.message : String(e)}`));
65
69
  }
66
70
  if (!open) {
71
+ // eslint-disable-next-line react/set-state-in-effect -- 有意:关闭弹窗时清空任务,下次打开是全新一次安装
67
72
  setTaskId(null);
73
+ // eslint-disable-next-line react/set-state-in-effect -- 有意:关闭时复位触发守卫,配合上行清场
68
74
  setStarted(false);
75
+ // eslint-disable-next-line react/set-state-in-effect -- 有意:关闭时清掉信任错误,避免残留到下次打开
69
76
  setTrustError(null);
70
- notifiedTaskId.current = null;
77
+ notifiedTaskIdRef.current = null;
71
78
  }
72
79
  }, [open, row, started, client, targetVersion]);
73
80
 
74
81
  useEffect(() => {
75
82
  if (!record || !isTerminal(record) || !done)
76
83
  return;
77
- if (notifiedTaskId.current === record.id)
84
+ if (notifiedTaskIdRef.current === record.id)
78
85
  return;
79
- notifiedTaskId.current = record.id;
86
+ notifiedTaskIdRef.current = record.id;
80
87
  onDone?.(record);
81
88
  }, [record, done, onDone]);
82
89
 
@@ -88,7 +95,7 @@ export function InstallDialog({ row, open, onClose, onDone, version, mode }: Ins
88
95
  setTrustError(null);
89
96
  try {
90
97
  await client.trust(taskId);
91
- notifiedTaskId.current = null;
98
+ notifiedTaskIdRef.current = null;
92
99
  setTaskId(null);
93
100
  setStarted(false);
94
101
  }