@oj-module/cli 0.2.66 → 0.2.67

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 (187) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +30 -1
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +38 -12
  7. package/devkit/publish-walkthrough.md +110 -37
  8. package/devkit/wecom-internal-app.md +7 -2
  9. package/devkit/wecom-multi-corp-links.md +163 -0
  10. package/package.json +2 -2
  11. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  12. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
  13. package/shell-dist/index.html +4 -4
  14. package/shell-dist/versions.json +3 -3
  15. package/src/__tests__/publish-keygen.test.ts +157 -0
  16. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  17. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  18. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  19. package/src/bundle.ts +8 -3
  20. package/src/cli.ts +17 -5
  21. package/src/dev.ts +15 -10
  22. package/src/install.ts +11 -4
  23. package/src/keygen.ts +54 -0
  24. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  25. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  26. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  27. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  28. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  29. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  30. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  31. package/src/manifest-contract.ts +91 -8
  32. package/src/market/bootstrap.ts +26 -2
  33. package/src/market/command.ts +1 -0
  34. package/src/market/publish.ts +5 -1
  35. package/src/market/walkthrough.ts +24 -8
  36. package/src/site.ts +2 -0
  37. package/src/usage.ts +8 -4
  38. package/src/vendor.ts +5 -1
  39. package/templates/config.yaml +4 -2
  40. package/templates/init.include.yaml +8 -3
  41. package/templates/openapi.json +460 -1
  42. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  43. package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
  44. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  45. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  46. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  47. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  48. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  49. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  50. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  51. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  52. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  53. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  54. package/templates/src/app/api/_platform/market/api.ts +235 -10
  55. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  56. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  57. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  58. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  59. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  60. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  61. package/templates/src/app/api/_platform/schema.yaml +18 -0
  62. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  63. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  64. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  65. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  66. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  67. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  68. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  69. package/templates/src/app/api/home/seed.pg.sql +71 -0
  70. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  71. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  72. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  73. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  74. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  75. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  76. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  77. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  78. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  79. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  80. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  81. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  82. package/templates/src/app/api/publish/api.ts +349 -0
  83. package/templates/src/app/api/publish/manifest.yaml +8 -0
  84. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  85. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  86. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  87. package/templates/src/app/api/publish/schema.yaml +33 -0
  88. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  89. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  90. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  91. package/templates/src/app/web/entry.ts +9 -5
  92. package/templates/src/app/web/home/routes.ts +5 -0
  93. package/templates/src/app/web/login/locales/en-US.json +7 -1
  94. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  95. package/templates/src/app/web/login/pages/login.tsx +79 -86
  96. package/templates/src/app/web/login/routes.ts +54 -1
  97. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  98. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  99. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  100. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  101. package/templates/src/app/web/system/locales/en-US.json +1 -8
  102. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  103. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  104. package/templates/src/app/web/system/routes.ts +0 -6
  105. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  106. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  107. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  108. package/templates/src/oj-market/web/api/client.ts +3 -0
  109. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  110. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  111. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  112. package/templates/src/oj-market/web/entry.ts +31 -2
  113. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  114. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  115. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  116. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  117. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  118. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  119. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  120. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  121. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  122. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  123. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  124. package/templates/src/oj-market/web/types.ts +30 -0
  125. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  126. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  127. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  128. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  129. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  130. package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
  131. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  132. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  133. package/templates/tests/publish-credential.spec.ts +354 -0
  134. package/templates/tests/publish-identity.spec.ts +44 -0
  135. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  136. package/templates/tests/wecom-landing.spec.ts +63 -1
  137. package/templates/tsconfig.paths.json +1 -0
  138. package/templates/web.config.ts +6 -4
  139. package/vendor/host-versions.json +3 -3
  140. package/templates/seed.mysql.sql +0 -6
  141. package/templates/seed.pg.sql +0 -6
  142. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  143. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  144. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  145. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  146. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  147. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  148. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  149. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  150. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  151. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  152. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  153. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  154. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  155. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  156. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  157. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  158. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  159. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  160. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  161. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  162. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  163. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  164. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  165. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  166. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  168. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  169. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  170. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  171. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  172. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  173. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  174. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  175. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  176. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  177. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  178. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  179. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  180. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  181. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  182. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  183. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  184. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  185. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  186. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  187. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -0,0 +1,540 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { parse } from "yaml";
6
+
7
+ /**
8
+ * BDD:迁移里的建表语句与 schema.yaml / manifest.yaml 三处一致。
9
+ *
10
+ * 为什么要这条:oj 的 SQL 构造器在发 SQL 前先按 schema.yaml 校验列名,多一列少一列都是
11
+ * 端点 500 或「列不存在」的假象;而 manifest.yaml 的 tables 才是模块装载时的表清单。
12
+ * 历史演进靠迁移叠加,结构漂移往往就藏在最后一条 ALTER 里,属于最难靠人眼发现的一类。
13
+ *
14
+ * 2026-11 把 _platform 的 0001–0018(45 个文件)压平成一条基线后,原先靠
15
+ * templates-schema-columns 的 `ALTER ADD COLUMN` 校验对 _platform 直接失效(基线里没有
16
+ * ALTER)——本用例补的就是这个缺口,且覆盖面从「只查新增列」扩大到「整表逐列 + 三方言」。
17
+ *
18
+ * S006 纪律:结构只进 migrations。故本用例不解析 seed.sql。
19
+ */
20
+
21
+ const templatesRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../templates");
22
+ const apiRoot = join(templatesRoot, "src", "app", "api");
23
+
24
+ /**
25
+ * 表级约束行。注意不能简单地按首词判 KEY——`key TEXT PRIMARY KEY` 里的 key 是**列名**
26
+ * (sys_setting 的历史遗留,mysql 侧写作 `key`),按首词判会把它当约束整行丢掉。
27
+ */
28
+ const CONSTRAINT_PATTERNS = [
29
+ /^UNIQUE\b/i,
30
+ /^PRIMARY\s+KEY\b/i,
31
+ /^CONSTRAINT\b/i,
32
+ /^FOREIGN\s+KEY\b/i,
33
+ /^CHECK\s*\(/i,
34
+ /^INDEX\s/i,
35
+ /^KEY\s+[`"\w]+\s*\(/i,
36
+ ];
37
+
38
+ function isConstraintLine(segment: string): boolean {
39
+ return CONSTRAINT_PATTERNS.some(re => re.test(segment));
40
+ }
41
+
42
+ /** 由 oj 自建自管的 journal,结构不归任何模块的迁移管 */
43
+ const OJ_OWNED_TABLES = new Set(["_oj_migrations"]);
44
+
45
+ interface TableDecl {
46
+ tenant?: boolean
47
+ columns?: Record<string, unknown>
48
+ }
49
+
50
+ function walk(dir: string, out: string[] = []): string[] {
51
+ if (!existsSync(dir))
52
+ return out;
53
+ for (const entry of readdirSync(dir)) {
54
+ const full = join(dir, entry);
55
+ if (statSync(full).isDirectory())
56
+ walk(full, out);
57
+ else
58
+ out.push(full);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function modulesWithApi(): string[] {
64
+ return readdirSync(apiRoot).filter((name) => {
65
+ const full = join(apiRoot, name);
66
+ return statSync(full).isDirectory() && existsSync(join(full, "manifest.yaml"));
67
+ });
68
+ }
69
+
70
+ /**
71
+ * 按「方言覆盖文件必胜、通用文件兜底」的装配规则取某方言实际执行的迁移序列。
72
+ * 覆盖是**按版本号**替换,不是追加:0002__tenant.mysql.sql 存在时,0002__tenant.sql 不执行。
73
+ */
74
+ function migrationsFor(module: string, dialect: "base" | "mysql" | "pg"): string[] {
75
+ const dir = join(apiRoot, module, "migrations");
76
+ if (!existsSync(dir))
77
+ return [];
78
+
79
+ const base = new Map<string, string>();
80
+ const override = new Map<string, string>();
81
+
82
+ for (const f of readdirSync(dir).filter(name => name.endsWith(".sql"))) {
83
+ const seq = f.slice(0, 4);
84
+ if (f.endsWith(".mysql.sql")) {
85
+ if (dialect === "mysql")
86
+ override.set(seq, join(dir, f));
87
+ }
88
+ else if (f.endsWith(".pg.sql")) {
89
+ if (dialect === "pg")
90
+ override.set(seq, join(dir, f));
91
+ }
92
+ else {
93
+ base.set(seq, join(dir, f));
94
+ }
95
+ }
96
+
97
+ return [...new Set([...base.keys(), ...override.keys()])]
98
+ .sort()
99
+ .map(seq => override.get(seq) ?? base.get(seq)!);
100
+ }
101
+
102
+ function hasDialectOverrides(module: string, dialect: "mysql" | "pg"): boolean {
103
+ const dir = join(apiRoot, module, "migrations");
104
+ return existsSync(dir) && readdirSync(dir).some(f => f.endsWith(`.${dialect}.sql`));
105
+ }
106
+
107
+ /** 只留可执行语句:`--` 行注释里的示例语法不算方言违规 */
108
+ function executableSql(sql: string): string {
109
+ return sql.split("\n").filter(line => !line.trim().startsWith("--")).join("\n");
110
+ }
111
+
112
+ /**
113
+ * 各方言必须拒绝的 SQLite 专有写法。命中任意一条,该方言在真库上必然语法错
114
+ * (2026-11-05 演练实踩:home 0001 只写 sqlite 版,MySQL 报 1064 AUTOINCREMENT)。
115
+ * `CREATE INDEX IF NOT EXISTS` 在 pg 合法,故只列 mysql。
116
+ */
117
+ const DIALECT_ONLY_SQLITE: Record<"mysql" | "pg", RegExp[]> = {
118
+ mysql: [/\bAUTOINCREMENT\b/i, /\bINSERT\s+OR\s+IGNORE\b/i, /\bCREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i],
119
+ pg: [/\bAUTOINCREMENT\b/i, /\bINSERT\s+OR\s+IGNORE\b/i],
120
+ };
121
+
122
+ /** `INSERT [OR IGNORE|IGNORE] INTO <表>` 按表计数(剥注释,避免注释里的示例语句算数) */
123
+ function insertCountsByTable(sql: string): Map<string, number> {
124
+ const out = new Map<string, number>();
125
+ const re = /\bINSERT\s+(?:OR\s+IGNORE\s+|IGNORE\s+)?INTO\s+([`"\w]+)/gi;
126
+ for (const m of executableSql(sql).matchAll(re)) {
127
+ const table = m[1].replace(/[`"]/g, "");
128
+ out.set(table, (out.get(table) ?? 0) + 1);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** 模板内所有 `seed*.sql`(跳过依赖与产物目录),返回相对模板根的路径 */
134
+ function seedSqlFiles(): string[] {
135
+ const out: string[] = [];
136
+ const skip = new Set(["node_modules", "dist", "compress-cache", "logs", ".git"]);
137
+ const visit = (dir: string) => {
138
+ for (const entry of readdirSync(dir)) {
139
+ if (skip.has(entry))
140
+ continue;
141
+ const full = join(dir, entry);
142
+ if (statSync(full).isDirectory())
143
+ visit(full);
144
+ else if (/^seed.*\.sql$/i.test(entry))
145
+ out.push(relative(templatesRoot, full));
146
+ }
147
+ };
148
+ visit(templatesRoot);
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * mysql 保留字里最容易被当成列名的一批。不求穷举——目标是把「裸 key」这类**必然 1064**
154
+ * 的写法挡在模板里,而不是当 SQL 解析器(真语法错误由真库演练兜底)。
155
+ */
156
+ const MYSQL_RESERVED = new Set([
157
+ "key",
158
+ "group",
159
+ "order",
160
+ "desc",
161
+ "index",
162
+ "rank",
163
+ "range",
164
+ "system",
165
+ "interval",
166
+ "condition",
167
+ "references",
168
+ "usage",
169
+ "values",
170
+ "table",
171
+ ]);
172
+
173
+ /** `INSERT ... (列清单)` 里裸写的保留字(反引号 / 双引号包起来的合法,跳过) */
174
+ function bareReservedColumns(sql: string): string[] {
175
+ const hits: string[] = [];
176
+ const re = /\bINSERT\s+(?:OR\s+IGNORE\s+|IGNORE\s+)?INTO\s+[`"\w]+\s*\(([^)]*)\)/gi;
177
+ for (const m of executableSql(sql).matchAll(re)) {
178
+ for (const raw of m[1].split(",")) {
179
+ const col = raw.trim();
180
+ if (!col || col.startsWith("`") || col.startsWith("\""))
181
+ continue;
182
+ const name = col.split(".").pop()!.replace(/\W/g, "").toLowerCase();
183
+ if (MYSQL_RESERVED.has(name))
184
+ hits.push(col);
185
+ }
186
+ }
187
+ return hits;
188
+ }
189
+
190
+ /** 解析单文件里的 `CREATE TABLE` → 表 → 列 → 首词类型(去掉引号与长度参数) */
191
+ function columnTypes(file: string): Map<string, Map<string, string>> {
192
+ const out = new Map<string, Map<string, string>>();
193
+ const sql = readFileSync(file, "utf-8");
194
+ for (const m of sql.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"\w]+)\s*\(([\s\S]*?)\)\s*(?:ENGINE|;)/gi)) {
195
+ const table = m[1].replace(/[`"]/g, "");
196
+ const cols = new Map<string, string>();
197
+ for (const raw of m[2].split("\n")) {
198
+ const line = raw.trim().replace(/,$/, "");
199
+ if (!line || line.startsWith("--") || isConstraintLine(line))
200
+ continue;
201
+ const [, name, type] = line.match(/^([`"\w]+)\s+([a-z]+)/i) ?? [];
202
+ if (name && type)
203
+ cols.set(name.replace(/[`"]/g, "").toLowerCase(), type.toUpperCase());
204
+ }
205
+ out.set(table, cols);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** seed 装配规则:`seed.<dialect>.sql` 存在则必胜,否则回落 `seed.sql` */function seedFor(module: string, dialect: "base" | "mysql" | "pg"): string | null {
211
+ const dir = join(apiRoot, module);
212
+ if (dialect !== "base") {
213
+ const override = join(dir, `seed.${dialect}.sql`);
214
+ if (existsSync(override))
215
+ return override;
216
+ }
217
+ const base = join(dir, "seed.sql");
218
+ return existsSync(base) ? base : null;
219
+ }
220
+
221
+ /** 取出 `CREATE TABLE <name> ( ... )` 的 (表名, 列名集合),能处理引号与嵌套括号 */
222
+ function parseCreateTables(sql: string): Map<string, Set<string>> {
223
+ const out = new Map<string, Set<string>>();
224
+ const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"\w]+)\s*\(/gi;
225
+ for (let m = re.exec(sql); m !== null; m = re.exec(sql)) {
226
+ const rawName = m[1].replace(/[`"]/g, "");
227
+ const start = re.lastIndex;
228
+ let depth = 1;
229
+ let i = start;
230
+ while (i < sql.length && depth > 0) {
231
+ const ch = sql[i];
232
+ if (ch === "(")
233
+ depth++;
234
+ else if (ch === ")")
235
+ depth--;
236
+ i++;
237
+ }
238
+ const body = sql.slice(start, i - 1);
239
+ out.set(rawName, parseColumns(body));
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** 顶层逗号切分(忽略括号内逗号),取每段的首个标识符为列名 */
245
+ function parseColumns(body: string): Set<string> {
246
+ const cols = new Set<string>();
247
+ let depth = 0;
248
+ let start = 0;
249
+ const segments: string[] = [];
250
+ for (let i = 0; i < body.length; i++) {
251
+ const ch = body[i];
252
+ if (ch === "(") {
253
+ depth++;
254
+ }
255
+ else if (ch === ")") {
256
+ depth--;
257
+ }
258
+ else if (ch === "," && depth === 0) {
259
+ segments.push(body.slice(start, i));
260
+ start = i + 1;
261
+ }
262
+ }
263
+ segments.push(body.slice(start));
264
+
265
+ for (const raw of segments) {
266
+ // 去掉行注释再取首词
267
+ const seg = raw
268
+ .split("\n")
269
+ .filter(line => !line.trim().startsWith("--"))
270
+ .join(" ")
271
+ .trim();
272
+ if (seg === "" || isConstraintLine(seg))
273
+ continue;
274
+ cols.add(seg.split(/\s+/)[0].replace(/[`"]/g, "").toLowerCase());
275
+ }
276
+ return cols;
277
+ }
278
+
279
+ const DROP_TABLE_RE = /DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([`"\w]+)\s*;/gi;
280
+ const ADD_COLUMN_RE = /ALTER\s+TABLE\s+([`"\w]+)\s+ADD\s+(?:COLUMN\s+)?([`"\w]+)/gi;
281
+
282
+ /**
283
+ * 按文件序累积出「跑完这些迁移后的表 → 列集」。
284
+ * 覆盖三种演进形态:整表 CREATE、DROP 后重建、ALTER ADD COLUMN(历史模块的常态)。
285
+ * 压平后的 _platform 只有第一种,正是本文件要守的那一类。
286
+ */
287
+ function effectiveSchema(files: string[]): Map<string, Set<string>> {
288
+ const tables = new Map<string, Set<string>>();
289
+
290
+ for (const file of files) {
291
+ const sql = readFileSync(file, "utf-8");
292
+
293
+ for (const m of sql.matchAll(DROP_TABLE_RE))
294
+ tables.delete(m[1].replace(/[`"]/g, ""));
295
+
296
+ for (const [table, cols] of parseCreateTables(sql))
297
+ tables.set(table, cols);
298
+
299
+ for (const m of sql.matchAll(ADD_COLUMN_RE)) {
300
+ const table = m[1].replace(/[`"]/g, "");
301
+ const column = m[2].replace(/[`"]/g, "").toLowerCase();
302
+ if (!tables.has(table))
303
+ tables.set(table, new Set());
304
+ tables.get(table)!.add(column);
305
+ }
306
+ }
307
+
308
+ return tables;
309
+ }
310
+
311
+ function loadSchemaTables(): Map<string, TableDecl> {
312
+ const tables = new Map<string, TableDecl>();
313
+ for (const file of walk(join(templatesRoot, "src")).filter(f => f.endsWith("schema.yaml"))) {
314
+ const doc = parse(readFileSync(file, "utf-8")) as { tables?: Record<string, TableDecl> } | null;
315
+ for (const [name, decl] of Object.entries(doc?.tables ?? {}))
316
+ tables.set(name, decl);
317
+ }
318
+ return tables;
319
+ }
320
+
321
+ function loadManifestTables(module: string): string[] {
322
+ const doc = parse(readFileSync(join(apiRoot, module, "manifest.yaml"), "utf-8")) as { tables?: string[] } | null;
323
+ return doc?.tables ?? [];
324
+ }
325
+
326
+ describe("templates 迁移 ↔ schema.yaml / manifest.yaml 结构一致", () => {
327
+ it("m-1 迁移落地后的列集与 schema.yaml 双向一致(多列 = 构造器拒查,少列 = 运行时缺列)", () => {
328
+ const declared = loadSchemaTables();
329
+ const violations: string[] = [];
330
+
331
+ for (const module of modulesWithApi()) {
332
+ const effective = effectiveSchema(migrationsFor(module, "base"));
333
+ for (const [table, cols] of effective) {
334
+ const where = `${module}/migrations`;
335
+ const decl = declared.get(table);
336
+ if (!decl) {
337
+ violations.push(`${where}: 表 ${table} 未在任何 schema.yaml 声明`);
338
+ continue;
339
+ }
340
+ const declaredCols = new Set(Object.keys(decl.columns ?? {}).map(c => c.toLowerCase()));
341
+ for (const c of cols) {
342
+ if (!declaredCols.has(c))
343
+ violations.push(`${where}: ${table}.${c} 建了列但 schema.yaml 未声明`);
344
+ }
345
+ for (const c of declaredCols) {
346
+ if (!cols.has(c))
347
+ violations.push(`${where}: ${table}.${c} 在 schema.yaml 声明但迁移未建列`);
348
+ }
349
+ }
350
+ }
351
+
352
+ expect(violations).toEqual([]);
353
+ });
354
+
355
+ it("m-2 每个 manifest.yaml 声明的表都有建表语句(_oj_migrations 由 oj 自建自有,豁免)", () => {
356
+ const violations: string[] = [];
357
+
358
+ for (const module of modulesWithApi()) {
359
+ const created = new Set(effectiveSchema(migrationsFor(module, "base")).keys());
360
+ for (const table of loadManifestTables(module)) {
361
+ if (OJ_OWNED_TABLES.has(table))
362
+ continue;
363
+ if (!created.has(table))
364
+ violations.push(`${module}: manifest.yaml 声明了 ${table},但该模块 migrations 里没有建表语句`);
365
+ }
366
+ }
367
+
368
+ expect(violations).toEqual([]);
369
+ });
370
+
371
+ it("m-3 方言覆盖不得缺表(有方言文件时,基础迁移的每张表都要有方言版)", () => {
372
+ const violations: string[] = [];
373
+
374
+ for (const module of modulesWithApi()) {
375
+ const baseTables = new Set(effectiveSchema(migrationsFor(module, "base")).keys());
376
+ if (baseTables.size === 0)
377
+ continue;
378
+
379
+ for (const dialect of ["mysql", "pg"] as const) {
380
+ const files = migrationsFor(module, dialect);
381
+ // 整个模块没有方言覆盖是合法的:oj 用基础迁移兜底(home / notification 现状)
382
+ if (!hasDialectOverrides(module, dialect))
383
+ continue;
384
+
385
+ const dialectTables = new Set(effectiveSchema(files).keys());
386
+ for (const table of baseTables) {
387
+ if (!dialectTables.has(table))
388
+ violations.push(`${module}: 表 ${table} 缺 ${dialect} 方言建表语句`);
389
+ }
390
+ for (const table of dialectTables) {
391
+ if (!baseTables.has(table))
392
+ violations.push(`${module}: ${dialect} 方言建了基础迁移里不存在的表 ${table}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ expect(violations).toEqual([]);
398
+ });
399
+
400
+ it("m-4 方言覆盖的列集与 schema.yaml 一致(类型可方言化,列名不行)", () => {
401
+ const declared = loadSchemaTables();
402
+ const violations: string[] = [];
403
+
404
+ for (const module of modulesWithApi()) {
405
+ for (const dialect of ["mysql", "pg"] as const) {
406
+ const files = migrationsFor(module, dialect);
407
+ if (!hasDialectOverrides(module, dialect))
408
+ continue;
409
+
410
+ for (const [table, cols] of effectiveSchema(files)) {
411
+ const decl = declared.get(table);
412
+ if (!decl) {
413
+ violations.push(`${module}/${dialect}: 表 ${table} 未在任何 schema.yaml 声明`);
414
+ continue;
415
+ }
416
+ const declaredCols = new Set(Object.keys(decl.columns ?? {}).map(c => c.toLowerCase()));
417
+ for (const c of cols) {
418
+ if (!declaredCols.has(c))
419
+ violations.push(`${module}/${dialect}: ${table}.${c} 建了列但 schema.yaml 未声明`);
420
+ }
421
+ for (const c of declaredCols) {
422
+ if (!cols.has(c))
423
+ violations.push(`${module}/${dialect}: ${table}.${c} 在 schema.yaml 声明但方言迁移未建列`);
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ expect(violations).toEqual([]);
430
+ });
431
+
432
+ it("m-5 任一方言实际执行的迁移与 seed 不含该方言不支持的语法(方言覆盖须整条链路可执行)", () => {
433
+ const violations: string[] = [];
434
+
435
+ for (const module of modulesWithApi()) {
436
+ for (const dialect of ["mysql", "pg"] as const) {
437
+ const files = [...migrationsFor(module, dialect)];
438
+ const seed = seedFor(module, dialect);
439
+ if (seed)
440
+ files.push(seed);
441
+
442
+ for (const file of files) {
443
+ const sql = executableSql(readFileSync(file, "utf-8"));
444
+ for (const re of DIALECT_ONLY_SQLITE[dialect]) {
445
+ if (re.test(sql))
446
+ violations.push(`${module}/${dialect}: ${relative(apiRoot, file)} 含该方言不支持的语法 ${re}`);
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ expect(violations).toEqual([]);
453
+ });
454
+
455
+ it("m-6 方言 seed 与基础 seed 覆盖同一批表、同样条数(漏段 = 部署面静默缺数据)", () => {
456
+ const violations: string[] = [];
457
+
458
+ for (const module of modulesWithApi()) {
459
+ const baseSeed = seedFor(module, "base");
460
+ if (!baseSeed)
461
+ continue;
462
+ const expected = insertCountsByTable(readFileSync(baseSeed, "utf-8"));
463
+
464
+ for (const dialect of ["mysql", "pg"] as const) {
465
+ const seed = seedFor(module, dialect);
466
+ if (!seed || seed === baseSeed)
467
+ continue;
468
+
469
+ // seed 按 `;` 朴素切分:一行出现第二个终止符会切出孤立语句(如 `; ON CONFLICT DO NOTHING`)
470
+ for (const [i, line] of readFileSync(seed, "utf-8").split("\n").entries()) {
471
+ if (line.trim().startsWith("--"))
472
+ continue;
473
+ if ((line.match(/;/g) ?? []).length > 1)
474
+ violations.push(`${module}/${dialect} seed: 第 ${i + 1} 行有多个语句终止符(拼接错会切出孤立语句)`);
475
+ }
476
+
477
+ const actual = insertCountsByTable(readFileSync(seed, "utf-8"));
478
+ for (const table of new Set([...expected.keys(), ...actual.keys()])) {
479
+ if ((expected.get(table) ?? 0) !== (actual.get(table) ?? 0)) {
480
+ violations.push(
481
+ `${module}/${dialect} seed: ${table} 基础 ${expected.get(table) ?? 0} 条 / 方言 ${actual.get(table) ?? 0} 条`,
482
+ );
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ expect(violations).toEqual([]);
489
+ });
490
+
491
+ it("m-7 _platform 基线的 BIGINT 时间列在 pg 不得收窄为 INTEGER(毫秒 epoch 超出 int32)", () => {
492
+ const dir = join(apiRoot, "_platform", "migrations");
493
+ const mysql = columnTypes(join(dir, "0001__baseline.mysql.sql"));
494
+ const pg = columnTypes(join(dir, "0001__baseline.pg.sql"));
495
+ // 只盯时间语义列:*_at / *_time / *_start / *_until(秒级与毫秒级 epoch 都要求 64 位)
496
+ const TIME_COLUMN = /(?:^|_)(?:at|time|start|until)$/;
497
+ const violations: string[] = [];
498
+
499
+ for (const [table, cols] of mysql) {
500
+ for (const [col, type] of cols) {
501
+ if (type !== "BIGINT" || !TIME_COLUMN.test(col))
502
+ continue;
503
+ const pgType = pg.get(table)?.get(col);
504
+ if (pgType && pgType !== "BIGINT")
505
+ violations.push(`${table}.${col}: mysql=BIGINT / pg=${pgType}`);
506
+ }
507
+ }
508
+
509
+ expect(violations).toEqual([]);
510
+ });
511
+
512
+ /**
513
+ * 2026-11-05 实踩:模板根曾有 `seed.mysql.sql` / `seed.pg.sql` 的旧副本——只有 4 条
514
+ * sys_setting(模块内真件 22 条),且 mysql 那份把保留字 `key` 裸写,照文档部署必
515
+ * `1064 near 'key, module, ...'`。它既不在 m-5/m-6 的扫描范围(只扫 `src/<模块>/`),
516
+ * 也没有任何执行路径天天跑它,坏了大半年没人发现。这条守卫钉两件事:
517
+ * 1) `seed*.sql` 只允许活在模块目录(根级/别处出现即违规,从源头禁掉第二份真源);
518
+ * 2) mysql 方言件的 INSERT 列名里,保留字必须反引号(裸写 = 该方言必然语法错)。
519
+ */
520
+ it("m-8 seed*.sql 只活在模块目录,且 mysql 保留字列必须反引号(根级副本会漂移)", () => {
521
+ const misplaced = seedSqlFiles().filter((rel) => {
522
+ return !rel.startsWith(join("src", "app", "api") + sep);
523
+ });
524
+ expect(misplaced).toEqual([]);
525
+
526
+ const unescaped: string[] = [];
527
+ for (const module of modulesWithApi()) {
528
+ const file = join(apiRoot, module, "seed.mysql.sql");
529
+ if (!existsSync(file))
530
+ continue;
531
+ for (const col of bareReservedColumns(readFileSync(file, "utf-8")))
532
+ unescaped.push(`${module}/seed.mysql.sql: 列 ${col} 未转义`);
533
+ }
534
+ expect(unescaped).toEqual([]);
535
+
536
+ // 反证:检测器对坏样本必须命中(否则这条守卫只是恒绿)
537
+ expect(bareReservedColumns("INSERT IGNORE INTO t (key, module) VALUES ('a','b')")).toEqual(["key"]);
538
+ expect(bareReservedColumns("INSERT IGNORE INTO t (`key`, module) VALUES ('a','b')")).toEqual([]);
539
+ });
540
+ });
@@ -88,12 +88,25 @@ describe("templates 后端 schema.yaml 列白名单", () => {
88
88
  expect(violations).toEqual([]);
89
89
  });
90
90
 
91
- it("s-2 回归锚点:0017 三方言迁移齐备,users.employee_no 已声明为 text", () => {
91
+ it("s-2 回归锚点:_platform 基线为 0001、增量 0002(发布历史)三方言齐备,且基线里确有 users.employee_no", () => {
92
92
  const migDir = join(srcRoot, "app", "api", "_platform", "migrations");
93
- for (const file of ["0017__users_employee_no.sql", "0017__users_employee_no.mysql.sql", "0017__users_employee_no.pg.sql"])
93
+ for (const file of [
94
+ "0001__baseline.sql",
95
+ "0001__baseline.mysql.sql",
96
+ "0001__baseline.pg.sql",
97
+ "0002__market_release.sql",
98
+ "0002__market_release.mysql.sql",
99
+ "0002__market_release.pg.sql",
100
+ ])
94
101
  expect(existsSync(join(migDir, file)), `缺迁移 ${file}`).toBe(true);
95
102
 
103
+ // 0001 是压平后的结构基线,已应用过的库不会重跑——新增结构只能追加新序号,
104
+ // 不得回改基线(否则老库拿不到)。序号必须从 0001 起连续、无空洞。
105
+ const seqs = [...new Set(readdirSync(migDir).map(f => f.slice(0, 4)))].sort();
106
+ expect(seqs).toEqual(["0001", "0002"]);
107
+
96
108
  const schema = parse(readFileSync(join(srcRoot, "app", "api", "_platform", "schema.yaml"), "utf-8")) as SchemaDoc;
97
109
  expect(schema.tables?.users?.columns?.employee_no).toMatchObject({ type: "text" });
110
+ expect(readFileSync(join(migDir, "0001__baseline.sql"), "utf-8")).toContain("employee_no");
98
111
  });
99
112
  });
@@ -115,7 +115,7 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
115
115
  expect(unknown).toEqual([]);
116
116
  });
117
117
 
118
- it("t-4 27 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
118
+ it("t-4 29 张表的 A/B 分组与方案 v3 §5.2 一致(新增表须先定性)", () => {
119
119
  const { tables } = loadSchemaTables();
120
120
  const shared = [...tables].filter(([, d]) => d.tenant === false).map(([n]) => n).sort();
121
121
  // Phase 0 落地 21 张(11 A + 10 B);Phase 1 加企微三表,全部 B 组(D9:查它们时必然还没有租户上下文)
@@ -125,10 +125,14 @@ describe("templates 租户定性(A 租户表 / B 共享表)", () => {
125
125
  // (D7 不接受 corpId 参数),故不进 shared_allow——跨租户读元数据那条另走显式 tenant_id 的裸 SQL。
126
126
  // 改这张表清单 = 改租户隔离边界,必须同步 config.yaml 的 tenant.shared_allow(t-2/t-3 会把关)。
127
127
  // 2026-11-02 demo 模块下线(ecc7b35e)删掉 demo/api/schema.yaml 的 todos 表,28 → 27。
128
- expect(tables.size).toBe(27);
128
+ // 2026-11 发布凭证加 publish_credential:共享表(CLI 兑换无租户语义,归属走 owner_tenant),27 → 28。
129
+ // 2026-11 发布历史加 market_release:共享表(发布物落在站点级 CDN,历史按站点归口),28 → 29。
130
+ expect(tables.size).toBe(29);
129
131
  expect(shared).toEqual([
130
132
  "_oj_migrations",
131
133
  "market_history",
134
+ "market_release",
135
+ "publish_credential",
132
136
  "reg_source_seq",
133
137
  "sys_login_throttle",
134
138
  "sys_permission",
package/src/bundle.ts CHANGED
@@ -456,14 +456,19 @@ function checkModuleManifest(built: string, name: string, version: string, _summ
456
456
  return out;
457
457
  }
458
458
 
459
- /** api.tables ↔ schema.yaml 表集对照(C2-12a 构建列;schema 缺失/声明不一致均 fail-fast) */
459
+ /**
460
+ * api.tables ↔ schema.yaml 表集对照(C2-12a)+ 表前缀(C2-12c)。
461
+ * 引用式方案(tables 缺省)只要 schema.yaml 存在也做前缀检查——表名冲突与打包形态无关。
462
+ */
460
463
  function checkTables(m: Record<string, unknown>, layout: ProjectLayout, name: string): string[] {
461
464
  const api = m.api as Record<string, unknown> | undefined;
462
- if (api === undefined || api.tables === undefined)
465
+ if (api === undefined)
463
466
  return [];
464
467
  const schemaPath = path.join(apiSrcOf(layout, name), "schema.yaml");
465
468
  if (!fs.existsSync(schemaPath)) {
466
- return [`【C2-12a】api.tables 已声明但缺少 ${path.relative(layout.projectRoot, schemaPath)},无法对照表集(oj 冗余校验同源)`];
469
+ return api.tables === undefined
470
+ ? []
471
+ : [`【C2-12a】api.tables 已声明但缺少 ${path.relative(layout.projectRoot, schemaPath)},无法对照表集(oj 冗余校验同源)`];
467
472
  }
468
473
  return checkTablesAgainstSchema(m, fs.readFileSync(schemaPath, "utf8"));
469
474
  }
package/src/cli.ts CHANGED
@@ -223,9 +223,21 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
223
223
  await mergeManifests(out ?? "", inputs);
224
224
  };
225
225
  case "keygen":
226
- return async (opts: { id?: string, out?: string, yes?: boolean } = {}) => {
227
- const { keygenCommand } = await import("./keygen");
228
- keygenCommand({ id: opts.id, out: opts.out, yes: opts.yes });
226
+ return async (opts: { id?: string, out?: string, yes?: boolean, credential?: string, siteUrl?: string } = {}) => {
227
+ const { exchangePublishCredential, keygenCommand } = await import("./keygen");
228
+ // 令牌解析:--credential > OJM_PUBLISH_CREDENTIAL;都没有则走原离线路径(逐字节不变)
229
+ const credential = (opts.credential ?? process.env.OJM_PUBLISH_CREDENTIAL ?? "").trim();
230
+ if (credential === "") {
231
+ keygenCommand({ id: opts.id, out: opts.out, yes: opts.yes });
232
+ return;
233
+ }
234
+ // 身份只来自凭证:--id 是另一条(离线)路径的入口,同时给必然有一方被静默忽略
235
+ if (opts.id !== undefined)
236
+ throw new UsageError("[ojm] --credential 与 --id 不能同时使用:凭证已绑定发布身份(要自定义身份请走离线 ojm keygen --id)");
237
+ const identity = await exchangePublishCredential(credential, { siteUrl: opts.siteUrl });
238
+ const who = identity.display_name === null ? identity.username : `${identity.display_name}(${identity.username})`;
239
+ console.log(`[ojm] 凭证已兑换:产品 ${identity.product} / 申请人 ${who}`);
240
+ keygenCommand({ id: identity.key_id, out: opts.out, yes: opts.yes });
229
241
  };
230
242
  case "sign":
231
243
  return async (file: string | undefined, opts: { id?: string, key?: string, check?: boolean, registryId?: string } = {}) => {
@@ -283,9 +295,9 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
283
295
  exit(EXIT_FAILURE);
284
296
  };
285
297
  case "install":
286
- return async (target: string | undefined, opts: SiteOpts & { locale?: string, name?: string, version?: string, registry?: string } = {}) => {
298
+ return async (target: string | undefined, opts: SiteOpts & { locale?: string, name?: string, version?: string, registry?: string, force?: boolean } = {}) => {
287
299
  const { installCommand } = await import("./install");
288
- await installCommand(target ?? "", { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, locale: opts.locale, name: opts.name, version: opts.version, registry: opts.registry });
300
+ await installCommand(target ?? "", { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, locale: opts.locale, name: opts.name, version: opts.version, registry: opts.registry, force: opts.force });
289
301
  };
290
302
  case "list":
291
303
  return async (opts: SiteOpts & { all?: boolean, json?: boolean } = {}) => {