@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
@@ -1,6 +1,6 @@
1
1
  import type { IndexRow } from "../types";
2
2
  import { Alert, Modal, Radio, Space, Spin, Typography } from "antd";
3
- import React, { useEffect, useMemo, useState } from "react";
3
+ import React, { useMemo, useState } from "react";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import { useMarketHistory } from "../hooks/useMarket";
6
6
  import { compareVersions } from "../utils/semver";
@@ -34,9 +34,13 @@ export function RollbackDialog({ row, open, onClose, onRollback }: RollbackDialo
34
34
  return [...versions].sort((a, b) => compareVersions(b, a)); // 较新的旧版在前
35
35
  }, [row, status, data]);
36
36
 
37
- useEffect(() => {
37
+ // 候选集变化(历史加载完成/切换方案)时默认选中最新旧版;
38
+ // 渲染期同步派生重置,替代 effect 内同步 set(react/set-state-in-effect)
39
+ const [prevCandidates, setPrevCandidates] = useState(candidates);
40
+ if (prevCandidates !== candidates) {
41
+ setPrevCandidates(candidates);
38
42
  setSelected(candidates.length ? candidates[0] : null);
39
- }, [candidates]);
43
+ }
40
44
 
41
45
  const loading = status === "loading";
42
46
 
@@ -19,22 +19,33 @@ export function UninstallConfirm({ row, open, onClose, onConfirmed }: UninstallC
19
19
  const { t } = useTranslation();
20
20
  const client = useMarketClient();
21
21
  const [impact, setImpact] = useState<UninstallImpact | null>(null);
22
- const [loading, setLoading] = useState(false);
23
22
  const [error, setError] = useState<string | null>(null);
24
23
 
24
+ // 打开(或切换方案)即清场上一轮结果;渲染期同步派生重置,替代 effect 内同步 set
25
+ const rowKey = open && row ? row.name : null;
26
+ const [prevRowKey, setPrevRowKey] = useState(rowKey);
27
+ if (prevRowKey !== rowKey) {
28
+ setPrevRowKey(rowKey);
29
+ setImpact(null);
30
+ setError(null);
31
+ }
32
+
25
33
  useEffect(() => {
26
34
  if (!open || !row)
27
35
  return;
28
- setLoading(true);
29
- setError(null);
30
- setImpact(null);
36
+ let active = true;
31
37
  client
32
38
  .uninstallImpact(row.name)
33
- .then(setImpact)
34
- .catch(e => setError(e instanceof Error ? e.message : String(e)))
35
- .finally(() => setLoading(false));
39
+ .then(i => active && setImpact(i))
40
+ .catch(e => active && setError(e instanceof Error ? e.message : String(e)));
41
+ return () => {
42
+ active = false;
43
+ };
36
44
  }, [open, row, client]);
37
45
 
46
+ // 加载态派生:已打开且尚无结果/错误即视为预检中(替代原 effect 同步 setLoading)
47
+ const loading = rowKey !== null && impact === null && error === null;
48
+
38
49
  return (
39
50
  <Modal
40
51
  title={t("oj-market:confirm:uninstallTitle")}
@@ -12,10 +12,12 @@ export type LoadState<T> = { status: "loading", data?: undefined } | { status: "
12
12
  export function useMarketIndex(): LoadState<IndexRow[]> & { retry: () => void } {
13
13
  const client = useMarketClient();
14
14
  const [state, setState] = useState<LoadState<IndexRow[]>>({ status: "loading" });
15
+ // 重试不复跑「先同步 set loading 再拉取」的加载函数(effect 内同步 set 触发
16
+ // react/set-state-in-effect),改为事件里置 loading + 递增 nonce 驱动 effect 重跑。
17
+ const [nonce, setNonce] = useState(0);
15
18
 
16
- const load = useCallback(() => {
19
+ useEffect(() => {
17
20
  let active = true;
18
- setState({ status: "loading" });
19
21
  client
20
22
  .index()
21
23
  .then(data => active && setState({ status: "ok", data }))
@@ -23,11 +25,14 @@ export function useMarketIndex(): LoadState<IndexRow[]> & { retry: () => void }
23
25
  return () => {
24
26
  active = false;
25
27
  };
26
- }, [client]);
28
+ }, [client, nonce]);
27
29
 
28
- useEffect(() => load(), [load]);
30
+ const retry = useCallback(() => {
31
+ setState({ status: "loading" });
32
+ setNonce(n => n + 1);
33
+ }, []);
29
34
 
30
- return { ...state, retry: load };
35
+ return { ...state, retry };
31
36
  }
32
37
 
33
38
  /** 任务轮询:给定 task_id,800ms 轮询至终态(done/failed/partial);超时不抛,返回当前值 */
@@ -39,17 +44,20 @@ export function useInstallTask(taskId: string | null): {
39
44
  const [record, setRecord] = useState<TaskRecord | null>(null);
40
45
  const [done, setDone] = useState(false);
41
46
 
47
+ // 无任务 id / 切换任务 = 尚无任务:清空上一次任务的记录,避免「重新安装/信任后重试」时
48
+ // 旧终态(可能是 needs_trust)残留在弹窗里被误读成本次结果。
49
+ // (渲染期同步派生重置,替代 effect 内同步 set,规避 react/set-state-in-effect)
50
+ const [prevTaskId, setPrevTaskId] = useState(taskId);
51
+ if (prevTaskId !== taskId) {
52
+ setPrevTaskId(taskId);
53
+ setRecord(null);
54
+ setDone(false);
55
+ }
56
+
42
57
  useEffect(() => {
43
- // 无任务 id = 尚无任务:清空上一次任务的记录,避免「重新安装/信任后重试」时
44
- // 旧终态(可能是 needs_trust)残留在弹窗里被误读成本次结果。
45
- if (!taskId) {
46
- setRecord(null);
47
- setDone(false);
58
+ if (!taskId)
48
59
  return;
49
- }
50
60
  let active = true;
51
- setRecord(null);
52
- setDone(false);
53
61
  poll(
54
62
  () => client.task(taskId),
55
63
  r => r.status !== "running",
@@ -80,10 +88,11 @@ export function useInstallTask(taskId: string | null): {
80
88
  export function useMarketHistory(): LoadState<MarketHistory[]> & { retry: () => void } {
81
89
  const client = useMarketClient();
82
90
  const [state, setState] = useState<LoadState<MarketHistory[]>>({ status: "loading" });
91
+ // 同 useMarketIndex:重试走 nonce 驱动,不在 effect 链路里同步 set
92
+ const [nonce, setNonce] = useState(0);
83
93
 
84
- const load = useCallback(() => {
94
+ useEffect(() => {
85
95
  let active = true;
86
- setState({ status: "loading" });
87
96
  client
88
97
  .history()
89
98
  .then(data => active && setState({ status: "ok", data }))
@@ -91,9 +100,12 @@ export function useMarketHistory(): LoadState<MarketHistory[]> & { retry: () =>
91
100
  return () => {
92
101
  active = false;
93
102
  };
94
- }, [client]);
103
+ }, [client, nonce]);
95
104
 
96
- useEffect(() => load(), [load]);
105
+ const retry = useCallback(() => {
106
+ setState({ status: "loading" });
107
+ setNonce(n => n + 1);
108
+ }, []);
97
109
 
98
- return { ...state, retry: load };
110
+ return { ...state, retry };
99
111
  }
@@ -30,7 +30,7 @@
30
30
  "displayName": "Display Name",
31
31
  "email": "Email",
32
32
  "roles": "Roles",
33
- "status": "Status",
33
+ "status": "Account status (forbidden / password change)",
34
34
  "actions": "Actions"
35
35
  }
36
36
  },
@@ -30,7 +30,7 @@
30
30
  "displayName": "显示名",
31
31
  "email": "邮箱",
32
32
  "roles": "角色",
33
- "status": "状态",
33
+ "status": "账号状态(禁用/首登改密)",
34
34
  "actions": "操作"
35
35
  }
36
36
  },
@@ -11,8 +11,8 @@
11
11
  // ③ notification:join ON 值条件 + UNION ALL + 字面量列 {lit,as} 的真渲染(场景 41 同款)。
12
12
  //
13
13
  // 登录账号:fixtures 的 l1tester(src/_platform/fixtures/l1-test-user.sql,仅 oj test 灌入)。
14
- // 不用 seed 的 admin:其 must_change=1(判权端点恒 403)且初始口令 123456 会随用户
15
- // 首登改密漂移——L1 绑 admin 曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
14
+ // 不用 seed 的 admin 等开发账号:初始口令 123456 会随用户在 dev 库改密漂移——
15
+ // L1 绑 seed 账号曾实测 401。l1tester 为 additive 专用账号(must_change=0)。
16
16
  //
17
17
  // 运行:cd packages/cli/templates/api && ~/.local/share/oj/oj test -c config.yaml -d src -t tests
18
18
 
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- // BDD:seed 契约——框架内置角色 + seed 超管绑定 + must_change 首登改密(D12/AC1-27)。
4
+ // BDD:seed 契约——框架内置角色 + 内置角色开发账号(账号名=角色名,must_change=0,
5
+ // 2026-10-22 口径,替代原「admin 绑 superadmin + must_change=1」的 D12 旧钉)。
5
6
  // 夹具重放真实 migrations + seed.sql 文件,本 spec 即「seed 与文件不漂移」的钉子。
6
7
  import { beforeAll, describe, expect, it } from "vitest";
7
8
 
@@ -38,17 +39,18 @@ describe("框架内置角色(ROLES 常量单一真相,PRD §4.2)", () => {
38
39
  });
39
40
  });
40
41
 
41
- describe("seed 超管(D12)", () => {
42
- // AUTOINCREMENT 不复用 id(sqlite_sequence 语义),统一按 username 动态解析,不钉死 id
43
- const adminId = () => (dbo.prepare("SELECT id FROM users WHERE username = 'admin'").get() as { id: number }).id;
44
-
45
- it("admin 账号绑定 superadmin 角色(sys_user_role 权威源)", () => {
46
- const row = dbo.prepare("SELECT * FROM sys_user_role WHERE user_id = ? AND role_name = 'superadmin'").get(adminId());
42
+ describe("seed 内置角色开发账号(账号名=角色名,2026-10-22 口径)", () => {
43
+ it.each(["superadmin", "admin", "editor", "viewer"])("账号 %s 绑定同名角色且 must_change=0", (name) => {
44
+ const uid = (dbo.prepare("SELECT id FROM users WHERE username = ?").get(name) as { id: number }).id;
45
+ const row = dbo.prepare("SELECT * FROM sys_user_role WHERE user_id = ? AND role_name = ?").get(uid, name);
47
46
  expect(row).toBeDefined();
47
+ const mc = dbo.prepare("SELECT must_change FROM users WHERE id = ?").get(uid) as { must_change: number };
48
+ expect(Number(mc.must_change)).toBe(0);
48
49
  });
49
50
 
50
- it("must_change=1:首登强制改密(AC1-27 前提)", () => {
51
- const row = dbo.prepare("SELECT must_change FROM users WHERE id = ?").get(adminId()) as { must_change: number };
52
- expect(Number(row.must_change)).toBe(1);
51
+ it("admin 不再绑 superadmin(旧 D12 绑定已被幂等 DELETE 收敛)", () => {
52
+ const uid = (dbo.prepare("SELECT id FROM users WHERE username = 'admin'").get() as { id: number }).id;
53
+ const row = dbo.prepare("SELECT * FROM sys_user_role WHERE user_id = ? AND role_name = 'superadmin'").get(uid);
54
+ expect(row).toBeUndefined();
53
55
  });
54
56
  });
@@ -0,0 +1,87 @@
1
+ // BDD:脚手架 seed 内置角色开发账号——账号名=角色名一一对应(superadmin/admin/editor/viewer),
2
+ // 口令均 123456,must_change=0 免首登改密(纯开发便利,生产部署须改密/删除)。
3
+ // 旧库收敛:历史 seed 把 admin 绑在 superadmin 且 must_change=1,seed 幂等重放即收敛
4
+ // (DELETE 旧绑定 + UPDATE must_change=0),无需手工迁移。
5
+ // 直接重放 _platform 迁移 + seed.sql 到内存 sqlite(installDb 不 replay seed,故自给自足)。
6
+ // 方言对齐:seed.mysql.sql / seed.pg.sql 与基线同构,人工对齐不受本 spec 钉守——
7
+ // 首条用例对三份文件做 INSERT 语法冒烟,防「INSERT IGNORE 错位」类事故回归。
8
+ import type { DatabaseSync } from "node:sqlite";
9
+
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { beforeAll, describe, expect, it } from "vitest";
15
+
16
+ const req = createRequire(import.meta.url);
17
+ const { DatabaseSync: Db } = req("node:sqlite") as { DatabaseSync: typeof DatabaseSync };
18
+
19
+ const PLATFORM_API = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "_platform", "api");
20
+
21
+ let dbo: InstanceType<typeof DatabaseSync>;
22
+
23
+ /** 重放迁移 + seed(seed 按英文分号朴素切分,同 oj 运行时口径) */
24
+ function replaySeed(): void {
25
+ for (const stmt of readFileSync(join(PLATFORM_API, "seed.sql"), "utf8").split(";")) {
26
+ const sql = stmt.trim();
27
+ if (sql)
28
+ dbo.exec(sql);
29
+ }
30
+ }
31
+
32
+ beforeAll(() => {
33
+ dbo = new Db(":memory:");
34
+ for (const f of readdirSync(join(PLATFORM_API, "migrations")).filter(f => f.endsWith(".sql") && !/\.(?:mysql|pg)\.sql$/.test(f)).sort())
35
+ dbo.exec(readFileSync(join(PLATFORM_API, "migrations", f), "utf8"));
36
+ replaySeed();
37
+ });
38
+
39
+ describe("seed 内置角色开发账号", () => {
40
+ it("方言文件 INSERT 语法冒烟:mysql 为 INSERT IGNORE 前缀、pg 为 ON CONFLICT 后缀", () => {
41
+ const mysql = readFileSync(join(PLATFORM_API, "seed.mysql.sql"), "utf8");
42
+ const pg = readFileSync(join(PLATFORM_API, "seed.pg.sql"), "utf8");
43
+ expect(mysql).not.toMatch(/INSERT INTO .*INSERT IGNORE/);
44
+ expect(mysql.match(/INSERT IGNORE INTO/g)!.length).toBeGreaterThanOrEqual(12);
45
+ expect(pg).not.toMatch(/INSERT IGNORE/);
46
+ expect(pg.match(/ON CONFLICT DO NOTHING/g)!.length).toBeGreaterThanOrEqual(12);
47
+ });
48
+
49
+ it.each(["superadmin", "admin", "editor", "viewer"])("账号 %s:must_change=0 且只绑定同名角色", (username) => {
50
+ const row = dbo.prepare("SELECT must_change FROM users WHERE username = ?").get(username) as { must_change: number };
51
+ expect(row).toBeDefined();
52
+ expect(Number(row.must_change)).toBe(0);
53
+ const roles = dbo.prepare("SELECT role_name FROM sys_user_role WHERE user_id = (SELECT id FROM users WHERE username = ?)").all(username) as Array<{ role_name: string }>;
54
+ expect(roles.map(r => r.role_name)).toEqual([username]);
55
+ });
56
+
57
+ it("四个角色账号口令一致(123456 的 bcrypt,复用 seed 既有哈希)", () => {
58
+ const hashes = dbo.prepare("SELECT username, password_hash h FROM users WHERE username IN ('superadmin','admin','editor','viewer')").all() as Array<{ username: string, h: string }>;
59
+ expect(new Set(hashes.map(r => r.h)).size).toBe(1);
60
+ });
61
+
62
+ it("旧库收敛:legacy admin(superadmin 绑定 + must_change=1) 经重放收敛为账号名=角色名", () => {
63
+ // 构造历史库状态:admin 绑 superadmin 且 must_change=1(旧 seed 口径),无 superadmin 账号
64
+ dbo.exec("DELETE FROM sys_user_role");
65
+ dbo.exec("DELETE FROM users");
66
+ dbo.prepare("INSERT INTO users (username, password_hash, must_change) VALUES ('admin', 'x', 1)").run();
67
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) SELECT id, 'superadmin' FROM users WHERE username = 'admin'").run();
68
+
69
+ replaySeed();
70
+
71
+ expect(Number((dbo.prepare("SELECT must_change m FROM users WHERE username = 'admin'").get() as { m: number }).m)).toBe(0);
72
+ const adminRoles = (dbo.prepare("SELECT role_name r FROM sys_user_role WHERE user_id = (SELECT id FROM users WHERE username = 'admin')").all() as Array<{ r: string }>).map(x => x.r);
73
+ expect(adminRoles).toEqual(["admin"]);
74
+ const superRoles = (dbo.prepare("SELECT role_name r FROM sys_user_role WHERE user_id = (SELECT id FROM users WHERE username = 'superadmin')").all() as Array<{ r: string }>).map(x => x.r);
75
+ expect(superRoles).toEqual(["superadmin"]);
76
+ });
77
+
78
+ it("幂等:重放两遍结果不变", () => {
79
+ const snap = () => JSON.stringify([
80
+ dbo.prepare("SELECT username, must_change FROM users ORDER BY username").all(),
81
+ dbo.prepare("SELECT user_id, role_name FROM sys_user_role ORDER BY user_id, role_name").all(),
82
+ ]);
83
+ const before = snap();
84
+ replaySeed();
85
+ expect(snap()).toBe(before);
86
+ });
87
+ });
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.20",
12
- "@oj-module/runtime/contract": "0.2.20",
13
- "@oj-module/runtime/contract/errors": "0.2.20",
11
+ "@oj-module/runtime": "0.2.23",
12
+ "@oj-module/runtime/contract": "0.2.23",
13
+ "@oj-module/runtime/contract/errors": "0.2.23",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",