@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
package/src/builtin.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * 脚手架自带模块身份:`src/.ojm-builtin.json`。
3
+ *
4
+ * 名单派生自 init 白名单(`init.include.yaml` 的 `src/<name>/(api|web)` 条目)——
5
+ * 单一真源是白名单,本模块只是它的读取/派生面,不在别处另立名单常量(防双写漂移)。
6
+ *
7
+ * 语义边界(评审钉死):
8
+ * - 只是打包排除 / UI 折叠提示,**不是安全边界**——文件只活在源工程、永不入包,
9
+ * 安装侧信任链(签名 + 信任库 + TOFU,fail-closed)与它无关;
10
+ * - 文件缺失 → 空名单(旧工程/框架仓没有内置概念,行为退化为现状);
11
+ * - 坏 JSON / modules 非字符串数组 → fail-fast(与「坏清单出不了包」同哲学,
12
+ * 缺失是故意的,坏文件是事故);
13
+ * - 用户对该文件有完全控制权:删名 = 该模块恢复参与打包/显示,删文件 = 全部退化。
14
+ *
15
+ * 消费方:`ojm build --bundle`(排除)、`ojm info`(折叠)、`ojm new`(拒名)、
16
+ * `ojm init`(生成/回填)。历史布局(new/flat)工程不适用(无 src/ 模块树概念),
17
+ * 调用方自行判布局,本模块不感知 layout。
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ export const BUILTIN_LIST_FILE = ".ojm-builtin.json";
24
+
25
+ export function builtinListPath(projectRoot: string): string {
26
+ return path.join(projectRoot, "src", BUILTIN_LIST_FILE);
27
+ }
28
+
29
+ /**
30
+ * 读取自带模块名单。
31
+ * 文件缺失 → 空集;坏文件 → fail-fast(报路径与错因)。
32
+ */
33
+ export function readBuiltinModules(projectRoot: string): Set<string> {
34
+ const p = builtinListPath(projectRoot);
35
+ if (!fs.existsSync(p))
36
+ return new Set();
37
+ let doc: unknown;
38
+ try {
39
+ doc = JSON.parse(fs.readFileSync(p, "utf-8"));
40
+ }
41
+ catch (error) {
42
+ throw new Error(
43
+ `[ojm] ${p} 解析失败(${error instanceof Error ? error.message : String(error)})。`
44
+ + "该文件由 ojm init 生成、可手改/删除,但必须是合法 JSON"
45
+ + "({ \"modules\": [\"<模块名>\", …] })。",
46
+ );
47
+ }
48
+ const modules = (doc as { modules?: unknown })?.modules;
49
+ if (!Array.isArray(modules) || modules.some(m => typeof m !== "string")) {
50
+ throw new Error(
51
+ `[ojm] ${p} 的 modules 必须是字符串数组,`
52
+ + `实际为 ${Array.isArray(modules) ? `含非字符串项的数组(${JSON.stringify(modules)})` : typeof modules
53
+ }。修复或删除该文件后重试。`,
54
+ );
55
+ }
56
+ return new Set(modules);
57
+ }
58
+
59
+ /**
60
+ * 从 init.include.yaml 的 include 集合派生自带模块名:`src/<name>/(api|web)` 条目的
61
+ * `<name>` 段(整目录收录 `src/<name>/api` 与逐文件收录 `src/<name>/web/...` 都命中)。
62
+ * 与 `pruneUnincludedApiPaths` 的派生同族,扩 web 侧覆盖 web-only 模块(login/oj-market)。
63
+ */
64
+ export function deriveBuiltinModules(include: Iterable<string>): Set<string> {
65
+ const mods = new Set<string>();
66
+ for (const p of include) {
67
+ const m = /^src\/([^/]+)\/(?:api|web)(?:\/|$)/.exec(p)?.[1];
68
+ if (m)
69
+ mods.add(m);
70
+ }
71
+ return mods;
72
+ }
73
+
74
+ /**
75
+ * init 接线:生成/回填 `src/.ojm-builtin.json`(copyIfAbsent 语义)。
76
+ * 已存在 → 原样不动(用户手改权);派生集为空 → 不写。
77
+ * @returns "created" | "skipped" | "none"
78
+ */
79
+ export function writeBuiltinList(projectRoot: string, include: Iterable<string>): "created" | "skipped" | "none" {
80
+ const target = builtinListPath(projectRoot);
81
+ if (fs.existsSync(target))
82
+ return "skipped";
83
+ const modules = [...deriveBuiltinModules(include)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
84
+ if (modules.length === 0)
85
+ return "none";
86
+ fs.mkdirSync(path.dirname(target), { recursive: true });
87
+ const doc = {
88
+ "//": "由 ojm init 生成(名单派生自模板白名单)。可手改/删除:删名 = 该模块恢复参与打包与 ojm info 显示;删整个文件 = 退化为无内置概念。",
89
+ modules,
90
+ };
91
+ fs.writeFileSync(target, `${JSON.stringify(doc, null, "\t")}\n`);
92
+ return "created";
93
+ }
package/src/bundle.ts CHANGED
@@ -1,7 +1,28 @@
1
- // 方案包构建(P1-11,`ojm build --bundle`):manifest.yaml 契约校验(fail-fast 先于打包)
1
+ // 方案包构建(P1-11,`ojm build --bundle`):bundle 级契约校验(fail-fast 先于打包)
2
2
  // → 组装 tgz 内布局(契约 §2.9)→ 确定性打包(C2-32)+ sha256。
3
+ // bundle 级契约文件名:解决方案优先 `solution.yaml`;旧方案回退 `manifest.yaml`。
4
+ // 之所以用 solution.yaml 而非根目录 manifest.yaml:oj 按 `manifest.yaml` 枚举发现 api 后端模块
5
+ // (模块级 `src/<name>/api/manifest.yaml`),根目录再放一份 bundle 契约会污染后端的「搜索枚举」,
6
+ // 让 oj 误把 bundle 契约当 api 模块——故用独立文件名隔开两条枚举(避免后端搜索枚举与 bundle 冲突)。
7
+ //
8
+ // 多方案(2026-10-22,docs/plans/2026-10-22-1640-multi-solution-bundle-design.md):
9
+ // 一个脚手架可有多份用户方案,各自一份模块级契约。契约解析优先级矩阵:
10
+ // --bundle <name> → src/<name>/solution.yaml(无则 manifest.yaml 回退);
11
+ // 未命中再比对工程根契约的 name === <name>;都不中 → 报错。
12
+ // 内置名拒绝(第一方请在框架仓模板工程打包——那里无 builtin 清单)。
13
+ // 无参 + 工程根契约 → 只打根契约,src 契约被跳过打 hint。根 solution.yaml 场景与旧行为
14
+ // 逐字节一致;根 manifest.yaml 与 src 模块契约并存时旧版取 src 契约
15
+ // (旧优先级 ②>③)、现取根契约——多方案矩阵定版「根压 src」(B11)。
16
+ // 无参 + 无根契约 → 收集全部非内置模块级契约逐个打包;
17
+ // 零份用户契约 → 友好报错指向 `ojm new`
18
+ // 模块级契约强校验 name === 目录名(B8;跨契约查重因 B8 不可达已不实现,见计划文档 B7 决策)。
19
+ // 两段式:全部校验(纯读零写盘)先于全部组装——校验失败 dist/ 零增量,
20
+ // 发布者不会把半截批次扫进 CDN 索引(build-index 全量收目录)。
21
+ //
3
22
  // 组装映射(对齐 P1-9 staging copy 源一层定版,unpack 布局即安装器 extract 段消费面):
4
- // <project>/manifest.yaml → <name>-<version>/manifest.yaml(Form A)
23
+ // <project>/solution.yaml | manifest.yaml → 工程根契约(旧单方案写法)
24
+ // <project>/src/<模块>/solution.yaml | manifest.yaml → 模块级契约(绝不进 <模块>/api/,§1.2 红线)
25
+ // 任一处均 → <name>-<version>/manifest.yaml(Form A;tgz 内名固定为 manifest.yaml)
5
26
  // <project>/api/dist/<name>-<version>/ → <name>-<version>/api/<name>-<version>/(oj build 产物原样,C2-31 分层核对)
6
27
  // <project>/web/dist/modules/<name>/ → <name>-<version>/web/modules/<name>/(只入包方案自己的模块;
7
28
  // web.entry/css 存在性复核)
@@ -13,9 +34,10 @@ import path from "node:path";
13
34
 
14
35
  import { parse } from "yaml";
15
36
  import { readModuleDefinition } from "./build";
37
+ import { readBuiltinModules } from "./builtin";
16
38
  import { createDeterministicTgz } from "./deterministic-tar";
17
39
  import { apiSrcOf, resolveLayout, webSrcOf } from "./layout";
18
- import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
40
+ import { assertValidSolutionName, checkLocalesComplete, checkTablesAgainstSchema, NAME_RE, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
19
41
  import { deriveManifestRoutes, reconcileManifestRoutes } from "./manifest-routes-derive";
20
42
 
21
43
  export interface BundleBuildResult {
@@ -26,29 +48,168 @@ export interface BundleBuildResult {
26
48
  }
27
49
 
28
50
  /**
29
- * 从方案工程产出可安装 tgz + sha256。
30
- * @param projectRoot 方案工程根(manifest.yaml 所在目录)
31
- * @param opts 输出与选项
32
- * @param opts.out 输出目录,缺省 `dist/`;产物 `<name>-<version>.tgz`
51
+ * 方案名正则(`--bundle <name>` / `ojm new <name>` 入口校验共用,DRY):
52
+ * 与契约 C2-01 同一条(小写字母开头 kebab-case,≤64)——入口先校验,
53
+ * 契约校验再做正式判罚;挡住 ../ 路径穿越与引号/换行注入。
54
+ * 校验函数本体在 manifest-contract.ts(new 入口不拖入打包模块图),此处 re-export 兼容既有引用。
33
55
  */
34
- export async function buildBundle(projectRoot: string, opts: { out?: string } = {}): Promise<BundleBuildResult> {
35
- // 1) 读入并校验契约(构建即校验,坏清单出不了包)
36
- const manifestPath = path.join(projectRoot, "manifest.yaml");
37
- if (!fs.existsSync(manifestPath)) {
38
- throw new Error(`[ojm] 方案包构建失败:${projectRoot} 下缺少 manifest.yaml(bundle 级契约是打包入口,写法见 devkit 手册「打包与分发」)`);
39
- }
40
- const parsed = parseManifest(fs.readFileSync(manifestPath, "utf8"));
41
- if (!parsed.ok) {
42
- throw new Error(`[ojm] 方案包构建失败:${parsed.error}`);
56
+ export const SOLUTION_NAME_RE = NAME_RE;
57
+ export { assertValidSolutionName } from "./manifest-contract";
58
+
59
+ /** 打包目标:一份契约 = 一个方案包 */
60
+ interface BundleTarget {
61
+ /** 契约文件路径(solution.yaml 或 manifest.yaml) */
62
+ contractPath: string
63
+ /** 模块级契约所在 src/<name> 目录名(工程根契约为 undefined) */
64
+ moduleDir?: string
65
+ }
66
+
67
+ /**
68
+ * 收集模块级契约(`src/<name>/` 根下,solution.yaml 优先于 manifest.yaml 回退;
69
+ * 绝不进 `<模块>/api/`——与 oj 的 api 枚举撞车红线,§1.2)。按目录名排序保证确定性。
70
+ */
71
+ function collectModuleContracts(projectRoot: string): BundleTarget[] {
72
+ const srcDir = path.join(projectRoot, "src");
73
+ if (!fs.existsSync(srcDir))
74
+ return [];
75
+ const out: BundleTarget[] = [];
76
+ for (const e of fs.readdirSync(srcDir, { withFileTypes: true })) {
77
+ if (!e.isDirectory() || e.name.startsWith("."))
78
+ continue;
79
+ const dir = path.join(srcDir, e.name);
80
+ const sol = path.join(dir, "solution.yaml");
81
+ const man = path.join(dir, "manifest.yaml");
82
+ if (fs.existsSync(sol))
83
+ out.push({ contractPath: sol, moduleDir: e.name });
84
+ else if (fs.existsSync(man))
85
+ out.push({ contractPath: man, moduleDir: e.name });
86
+ }
87
+ out.sort((a, b) => (a.moduleDir! < b.moduleDir! ? -1 : a.moduleDir! > b.moduleDir! ? 1 : 0));
88
+ return out;
89
+ }
90
+
91
+ /** 读契约 name 用于优先级矩阵的快速比对(坏契约不在此处报错——planBundle 会给正式错误) */
92
+ function peekContractName(contractPath: string): string | undefined {
93
+ try {
94
+ const parsed = parseManifest(fs.readFileSync(contractPath, "utf8"));
95
+ return parsed.ok ? (parsed.manifest.name as string) : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 契约解析复数化(优先级矩阵见文件头注释)。
104
+ * 内置排除只是便利机制不是安全边界:伪造/删除名单的最坏后果在打包侧,
105
+ * 安装侧信任链(签名 + 信任库,fail-closed)与它无关。
106
+ */
107
+ function resolveContractTargets(projectRoot: string, name?: string): BundleTarget[] {
108
+ const builtin = readBuiltinModules(projectRoot);
109
+ const moduleContracts = collectModuleContracts(projectRoot);
110
+ const rootSolution = path.join(projectRoot, "solution.yaml");
111
+ const rootManifest = path.join(projectRoot, "manifest.yaml");
112
+ const rootContract = fs.existsSync(rootSolution) ? rootSolution : fs.existsSync(rootManifest) ? rootManifest : undefined;
113
+
114
+ // --bundle <name>:先按目录名选模块级契约,再比对根契约 name
115
+ if (name !== undefined) {
116
+ assertValidSolutionName(name, "--bundle <name>");
117
+ const hit = moduleContracts.find(c => c.moduleDir === name);
118
+ if (hit) {
119
+ if (builtin.has(name)) {
120
+ throw new Error(
121
+ `[ojm] 「${name}」是框架自带方案,不参与用户打包。`
122
+ + "第一方发布请在框架仓模板工程打包(模板工程无内置名单,走全量打包路径)。",
123
+ );
124
+ }
125
+ return [hit];
126
+ }
127
+ if (rootContract) {
128
+ const rootName = peekContractName(rootContract);
129
+ if (rootName === undefined) {
130
+ throw new Error(
131
+ `[ojm] 方案包构建失败:工程根契约 ${path.basename(rootContract)} 存在但无法解析`
132
+ + "(name 缺失或文件损坏)——请先修复它,或显式给出 src/<name>/solution.yaml。",
133
+ );
134
+ }
135
+ if (rootName === name) {
136
+ if (builtin.has(name)) {
137
+ console.warn(
138
+ `[ojm] ⚠️ 工程根契约 name「${name}」与框架自带方案同名——`
139
+ + "确认你要分发的是自有方案而非自带模块的构建产物。",
140
+ );
141
+ }
142
+ return [{ contractPath: rootContract }];
143
+ }
144
+ }
145
+ throw new Error(
146
+ `[ojm] 方案包构建失败:未找到「${name}」的契约`
147
+ + `(期望 src/${name}/solution.yaml,或工程根契约 name === "${name}")。`,
148
+ );
149
+ }
150
+
151
+ // 无参 + 工程根契约:旧单方案行为零变化(根优先于一切模块级契约)
152
+ if (rootContract) {
153
+ if (moduleContracts.length > 0) {
154
+ console.log(
155
+ `[ojm] 工程根契约优先:跳过 src/ 下 ${moduleContracts.length} 份模块级契约`
156
+ + "(用 ojm build --bundle <name> 单独打包其中一份)",
157
+ );
158
+ }
159
+ const rootName = peekContractName(rootContract);
160
+ if (rootName !== undefined && builtin.has(rootName)) {
161
+ console.warn(
162
+ `[ojm] ⚠️ 工程根契约 name「${rootName}」与框架自带方案同名——`
163
+ + "确认你要分发的是自有方案而非自带模块的构建产物。",
164
+ );
165
+ }
166
+ return [{ contractPath: rootContract }];
167
+ }
168
+
169
+ // 无参 + 无根契约:全部非内置模块级契约
170
+ for (const c of moduleContracts.filter(c => builtin.has(c.moduleDir!)))
171
+ console.log(`[ojm] 跳过框架自带方案:${c.moduleDir}(不参与用户打包)`);
172
+ const userContracts = moduleContracts.filter(c => !builtin.has(c.moduleDir!));
173
+ if (userContracts.length === 0) {
174
+ throw new Error(
175
+ "[ojm] 方案包构建失败:没有可打包的用户方案(脚手架自带方案不参与打包)。"
176
+ + "用 ojm new <name> 创建方案后再打包。",
177
+ );
43
178
  }
179
+ return userContracts;
180
+ }
181
+
182
+ /** 校验段产物:一份通过全部构建期校验、待组装的方案 */
183
+ interface BundlePlan {
184
+ manifest: Record<string, unknown>
185
+ contractPath: string
186
+ moduleDir?: string
187
+ name: string
188
+ version: string
189
+ }
190
+
191
+ /**
192
+ * 校验段(纯读、零写盘):契约解析 → routes 派生 → 契约校验 → 文件面校验。
193
+ * 模块级契约强校验 name === 目录名(--bundle <name> 按目录名选契约,
194
+ * 产物定位/入口派生全用契约 name,两者不一致会打出名字与目录对不上的包)。
195
+ */
196
+ async function planBundle(projectRoot: string, layout: ProjectLayout, target: BundleTarget): Promise<BundlePlan> {
197
+ const parsed = parseManifest(fs.readFileSync(target.contractPath, "utf8"));
198
+ if (!parsed.ok)
199
+ throw new Error(`[ojm] 方案包构建失败:${parsed.error}`);
44
200
  const m = parsed.manifest;
45
201
  const name = m.name as string;
46
202
 
203
+ if (target.moduleDir !== undefined && name !== target.moduleDir) {
204
+ throw new Error(
205
+ `[ojm] 方案包构建失败:${target.contractPath} 的 name「${name}」与目录名「${target.moduleDir}」不一致`
206
+ + "(module-root 下契约与模块同位同名)。",
207
+ );
208
+ }
209
+
47
210
  // R5:manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源),覆盖手工书写。
48
211
  // 仅当 entry.ts 声明了 category 路由才覆盖;否则保留 manifest 既有 routes(未迁移方案兼容)。
49
212
  // entry 读不出来(如第三依赖未装)时软降级:沿用既有 routes,不阻断打包。
50
- const layout = resolveLayout(projectRoot);
51
- // 前端入口两侧布局都在 webSrcOf 里收口:module-root → src/<name>/web,历史 → web/src/<name>
52
213
  const entryFile = path.join(webSrcOf(layout, name), "entry.ts");
53
214
  if (fs.existsSync(entryFile)) {
54
215
  try {
@@ -73,7 +234,7 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
73
234
  }
74
235
  const version = m.version as string;
75
236
 
76
- // 2) 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
237
+ // 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
77
238
  const fileViolations = [
78
239
  ...checkTables(m, layout, name),
79
240
  ...checkLocales(m, layout, name),
@@ -91,15 +252,27 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
91
252
  }
92
253
  }
93
254
 
255
+ return { manifest: m, contractPath: target.contractPath, moduleDir: target.moduleDir, name, version };
256
+ }
257
+
258
+ /**
259
+ * 组装段(写盘):staging 单根 <name>-<version>/ → 红线自检 → 确定性 tgz + sha256。
260
+ * 入包内容与 §11.1 一致:只组装本方案的模块产物(api/dist/<name>-<version>/ 与
261
+ * web/dist/modules/<name>/),模板/宿主模块不随包。
262
+ */
263
+ async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan: BundlePlan, outDir: string): Promise<BundleBuildResult> {
264
+ const m = plan.manifest;
265
+ const name = plan.name;
266
+ const version = plan.version;
267
+
94
268
  // 3) 组装 staging:<name>-<version>/ 单根
95
269
  const staging = fs.mkdtempSync(path.join(projectRoot, ".ojm-bundle-"));
96
270
  try {
97
271
  const root = path.join(staging, `${name}-${version}`);
98
272
  fs.mkdirSync(root);
99
- fs.copyFileSync(manifestPath, path.join(root, "manifest.yaml"));
273
+ fs.copyFileSync(plan.contractPath, path.join(root, "manifest.yaml"));
100
274
 
101
- // api:oj build 产物原样入包(编译编排不在此重跑——作者先 oj build,本命令只校验与组装;
102
- // 编排接线随 P1-15 方案脚手架定形)
275
+ // api:oj build 产物原样入包(编译编排不在此重跑——作者先 oj build,本命令只校验与组装)
103
276
  if (m.api !== undefined) {
104
277
  const built = path.join(projectRoot, "api/dist", `${name}-${version}`);
105
278
  if (!fs.existsSync(built)) {
@@ -141,7 +314,6 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
141
314
  assertNoForbiddenMembers(root);
142
315
 
143
316
  // 4) 确定性打包(C2-32;symlink 等成员守卫在打包原语内,C2-30)
144
- const outDir = path.resolve(projectRoot, opts.out ?? "dist");
145
317
  const tgzPath = path.join(outDir, `${name}-${version}.tgz`);
146
318
  const sha256 = await createDeterministicTgz(staging, tgzPath);
147
319
  const size = fs.statSync(tgzPath).size;
@@ -154,6 +326,38 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
154
326
  }
155
327
  }
156
328
 
329
+ /**
330
+ * 从方案工程产出可安装 tgz + sha256(多方案:返回 N 份结果,与目标契约一一对应)。
331
+ * 两段式:planBundle 全部先跑完(纯读、零写盘),任一失败 dist/ 零增量;
332
+ * 组装段失败保留已完成产物并在报错中指名失败方案。
333
+ */
334
+ export async function buildBundle(projectRoot: string, opts: { out?: string, name?: string } = {}): Promise<BundleBuildResult[]> {
335
+ const targets = resolveContractTargets(projectRoot, opts.name);
336
+ const layout = resolveLayout(projectRoot);
337
+ // 两段式:全部校验先跑完(纯读、零写盘),任一失败 dist/ 零增量——
338
+ // 发布者不会把半截批次扫进 CDN 索引(build-index 全量收目录)。
339
+ // name+version 撞车防护由 planBundle 的 name===目录名 强校验承担:
340
+ // 目录名在单一 src/ 层唯一 ⇒ 契约 name 唯一(评审 B7 与 B8 组合后撞车向量不可达)。
341
+ const plans = await Promise.all(targets.map(t => planBundle(projectRoot, layout, t)));
342
+
343
+ const outDir = path.resolve(projectRoot, opts.out ?? "dist");
344
+ const results: BundleBuildResult[] = [];
345
+ for (const plan of plans) {
346
+ try {
347
+ results.push(await assembleBundle(projectRoot, layout, plan, outDir));
348
+ }
349
+ catch (error) {
350
+ const done = results.map(r => `${r.name}-${r.version}`).join(", ");
351
+ throw new Error(
352
+ `[ojm] 方案「${plan.name}」打包失败${done ? `(已完成:${done})` : ""}:${
353
+ error instanceof Error ? error.message : String(error)}`,
354
+ { cause: error },
355
+ );
356
+ }
357
+ }
358
+ return results;
359
+ }
360
+
157
361
  /**
158
362
  * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
159
363
  * (oj 启动强校验同源)。
@@ -214,8 +418,8 @@ function checkLocales(m: Record<string, unknown>, layout: ProjectLayout, name: s
214
418
  return [...out, ...checkLocalesComplete(m, texts["zh-CN"] ?? "", texts["en-US"] ?? "")];
215
419
  }
216
420
 
217
- /** 私钥/凭证文件名红线(P2-4 入包自检):*.pem / *.key / .env / id_ed25519|id_rsa */
218
- const FORBIDDEN_MEMBER = /\.(?:pem|key)$|^\.env$|^id_(?:ed25519|rsa)$/;
421
+ /** 私钥/凭证文件名红线(P2-4 入包自检):*.pem/*.key/*.p12/*.pfx、.env 系(.env/.env.*)、id_ed25519|id_rsa */
422
+ const FORBIDDEN_MEMBER = /\.(?:pem|key|p12|pfx)$|^\.env(?:\.|$)|^id_(?:ed25519|rsa)$/;
219
423
 
220
424
  /** staging 全树扫描:modules.json 禁入(C2-31 红线)+ 私钥/凭证禁入(P2-4 红线) */
221
425
  function assertNoForbiddenMembers(root: string): void {
@@ -230,7 +434,7 @@ function assertNoForbiddenMembers(root: string): void {
230
434
  throw new Error(`[ojm] 打包自检失败(C2-31):modules.json 禁入方案包(该清单由安装器在目标站点 upsert,防覆盖宿主清单)——位置:${p}`);
231
435
  }
232
436
  if (e.isFile() && FORBIDDEN_MEMBER.test(e.name)) {
233
- throw new Error(`[ojm] 打包自检失败(P2-4 入包红线):私钥/凭证文件「${e.name}」禁入方案包(*.pem / *.key / .env / id_ed25519 / id_rsa)——签名私钥永不入包,签名经 detached .sig 侧车随包分发(包内只含产物与公钥)。位置:${p}`);
437
+ throw new Error(`[ojm] 打包自检失败(P2-4 入包红线):私钥/凭证文件「${e.name}」禁入方案包(*.pem / *.key / *.p12 / *.pfx / .env 系 / id_ed25519 / id_rsa)——签名私钥永不入包,签名经 detached .sig 侧车随包分发(包内只含产物与公钥)。位置:${p}`);
234
438
  }
235
439
  }
236
440
  };
@@ -63,7 +63,7 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
63
63
 
64
64
  // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
65
65
  // 桥接:占位方法零参签名 ↔ ScopedRequestLike 真实签名,经 any 双向打通(测试桩,无运行时差异)。
66
- function useMock(m: ReturnType<typeof mockRequest>) {
66
+ function bindMock(m: ReturnType<typeof mockRequest>) {
67
67
  bindRequest(m.req);
68
68
  const req = m.req as unknown as Record<keyof typeof requestImpl, () => never>;
69
69
  for (const k of Object.keys(requestImpl) as Array<keyof typeof requestImpl>)
@@ -75,12 +75,12 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
75
75
 
76
76
  beforeEach(() => {
77
77
  mock = mockRequest(() => ({ data: {} }));
78
- useMock(mock);
78
+ bindMock(mock);
79
79
  });
80
80
 
81
81
  it("meta() → GET _platform/market/meta,返回 { apiVersion }", async () => {
82
82
  mock = mockRequest(() => ({ data: { apiVersion: "1.2.3" } }));
83
- useMock(mock);
83
+ bindMock(mock);
84
84
  const r = await httpMarketClient.meta();
85
85
  expect(mock.calls).toEqual([{ method: "get", url: "_platform/market/meta", opts: undefined }]);
86
86
  expect(r).toEqual({ apiVersion: "1.2.3" });
@@ -89,7 +89,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
89
89
  it("index() → GET _platform/market/index,返回 IndexRow[]", async () => {
90
90
  const rows = [{ name: "a", latest: "1.0", installed: "1.0", enabled: true, upgradable: false }];
91
91
  mock = mockRequest(() => ({ data: rows }));
92
- useMock(mock);
92
+ bindMock(mock);
93
93
  const r = await httpMarketClient.index();
94
94
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/index" });
95
95
  expect(r).toEqual(rows);
@@ -97,7 +97,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
97
97
 
98
98
  it("index() 遇 503(未配置市场地址)→ [] 空态,不抛(PRD §5.3)", async () => {
99
99
  mock = mockRequest(c => (c.url === "_platform/market/index" ? { status: 503 } : { data: {} }));
100
- useMock(mock);
100
+ bindMock(mock);
101
101
  const r = await httpMarketClient.index();
102
102
  expect(r).toEqual([]);
103
103
  // 503 是配置态而非故障:silentError 抑制全局 toast + retry:0 不重试(原
@@ -108,7 +108,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
108
108
 
109
109
  it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
110
110
  mock = mockRequest(() => ({ data: { task_id: "t-1" } }));
111
- useMock(mock);
111
+ bindMock(mock);
112
112
  const r = await httpMarketClient.install({ name: "alpha", version: "2.0", source: "file" });
113
113
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/install" });
114
114
  expect(mock.calls[0].opts?.json).toEqual({ name: "alpha", version: "2.0", source: "file" });
@@ -117,7 +117,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
117
117
 
118
118
  it("install(input) 缺省 version/source → null / cdn 兜底", async () => {
119
119
  mock = mockRequest(() => ({ data: { task_id: "t-2" } }));
120
- useMock(mock);
120
+ bindMock(mock);
121
121
  await httpMarketClient.install({ name: "beta" });
122
122
  expect(mock.calls[0].opts?.json).toEqual({ name: "beta", version: null, source: "cdn" });
123
123
  });
@@ -125,7 +125,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
125
125
  it("task(id) → GET _platform/market/task? 带 id", async () => {
126
126
  const rec = { id: "t-1", name: "a", version: "1.0", status: "done", stage: "done", progress: 100, message: "ok", stages: [], startedAt: 1 };
127
127
  mock = mockRequest(() => ({ data: rec }));
128
- useMock(mock);
128
+ bindMock(mock);
129
129
  const r = await httpMarketClient.task("t-1");
130
130
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/task" });
131
131
  expect(mock.calls[0].opts?.searchParams).toEqual({ id: "t-1" });
@@ -134,7 +134,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
134
134
 
135
135
  it("enable(input) → POST _platform/market/enable,body {name,enabled}", async () => {
136
136
  mock = mockRequest(() => ({ data: { ok: true, message: "done" } }));
137
- useMock(mock);
137
+ bindMock(mock);
138
138
  const r = await httpMarketClient.enable({ name: "a", enabled: false });
139
139
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/enable" });
140
140
  expect(mock.calls[0].opts?.json).toEqual({ name: "a", enabled: false });
@@ -144,7 +144,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
144
144
  it("history() → GET _platform/market/history,返回 MarketHistory[]", async () => {
145
145
  const hist = [{ id: 1, solution: "a", version: "1.0", action: "install", status: "ok", operator: null, message: null, created_at: 1 }];
146
146
  mock = mockRequest(() => ({ data: hist }));
147
- useMock(mock);
147
+ bindMock(mock);
148
148
  const r = await httpMarketClient.history();
149
149
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/history" });
150
150
  expect(r).toEqual(hist);
@@ -152,7 +152,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
152
152
 
153
153
  it("uninstallImpact(name) → GET _platform/market/uninstall-impact? 带 name", async () => {
154
154
  mock = mockRequest(() => ({ data: { name: "a", affectedRoles: 2, affectedUsers: 5 } }));
155
- useMock(mock);
155
+ bindMock(mock);
156
156
  const r = await httpMarketClient.uninstallImpact("a");
157
157
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/uninstall-impact" });
158
158
  expect(mock.calls[0].opts?.searchParams).toEqual({ name: "a" });
@@ -161,7 +161,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
161
161
 
162
162
  it("uninstall(name) → POST _platform/market/uninstall,body {name}", async () => {
163
163
  mock = mockRequest(() => ({ data: { ok: true, message: "removed" } }));
164
- useMock(mock);
164
+ bindMock(mock);
165
165
  const r = await httpMarketClient.uninstall("a");
166
166
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/uninstall" });
167
167
  expect(mock.calls[0].opts?.json).toEqual({ name: "a" });
package/src/dev.ts CHANGED
@@ -294,6 +294,8 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
294
294
  // 仅 TTY 默认启用(管道/CI 下不抢 stdin);测试可经 keymap + inputStream 强制。
295
295
  const keymapEnabled = opts.keymap ?? (process.stdin.isTTY ?? false);
296
296
  if (keymapEnabled) {
297
+ const footerText = "快捷键:r 热更新 · h 帮助 · c 清屏 · o 打开浏览器 · q 退出";
298
+ const footer = pinFooter(`[ojm] ${footerText}`);
297
299
  setupDevKeymap({
298
300
  server,
299
301
  oj,
@@ -301,12 +303,15 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
301
303
  port: actualPort,
302
304
  input: opts.inputStream,
303
305
  openBrowser: opts.openBrowser,
306
+ clearScreen: footer ? () => footer.clear() : undefined,
304
307
  onReload: () => {
305
308
  void trigger();
306
309
  },
307
310
  onQuit: shutdown,
308
311
  });
309
- console.log("[ojm] 快捷键:r 热更新 · h 帮助 · c 清屏 · o 打开浏览器 · q 退出");
312
+ // 非 TTY(pinFooter 拒绝)回退一次性打印,行为与历史一致
313
+ if (!footer)
314
+ console.log(`[ojm] ${footerText}`);
310
315
  }
311
316
 
312
317
  return server;
@@ -335,6 +340,52 @@ export async function shutdownDev(
335
340
  setTimeout(exit, 1500, 0).unref();
336
341
  }
337
342
 
343
+ /**
344
+ * 快捷键提示常驻底栏:DECSTBM 把滚动区设为 1..rows-1,末行留给提示。
345
+ * 之后本进程 console.log 与 oj 透传(均经 process.stdout.write)只在滚动区内滚动,
346
+ * 末行由终端保持,无需逐条重绘。非 TTY / 行数不足返回 undefined(调用方回退一次性打印)。
347
+ */
348
+ export interface PinnedFooter {
349
+ /** 清屏:ED(\x1b[2J)保留滚动区(RIS \x1bc 会复位滚动区,禁用),随后重绘提示 */
350
+ clear: () => void
351
+ /** 恢复全屏滚动区并落光标到底行(进程退出/shell 接管前调用);幂等 */
352
+ stop: () => void
353
+ }
354
+
355
+ export function pinFooter(text: string, stream: NodeJS.WriteStream = process.stdout): PinnedFooter | undefined {
356
+ if (!stream.isTTY || (stream.rows ?? 0) < 3)
357
+ return undefined;
358
+ const write = (s: string) => {
359
+ stream.write(s);
360
+ };
361
+ const render = () => {
362
+ const rows = stream.rows || 24;
363
+ write(`\x1B[1;${rows - 1}r`); // 滚动区:首行 ~ 倒数第二行(DECSTBM 会把光标送回 1;1)
364
+ write(`\x1B[${rows};1H\x1B[2K${text}`); // 末行清行后画常驻提示
365
+ write(`\x1B[${rows - 1};1H`); // 光标回滚动区底行,后续输出照常滚动
366
+ };
367
+ render();
368
+ const onResize = () => render();
369
+ let stopped = false;
370
+ const stop = () => {
371
+ if (stopped)
372
+ return;
373
+ stopped = true;
374
+ process.removeListener("SIGWINCH", onResize);
375
+ process.removeListener("exit", stop);
376
+ write("\x1B[r\x1B[999;1H\n"); // 恢复全屏滚动区,光标夹到底行避免 shell 提示符悬空
377
+ };
378
+ process.on("SIGWINCH", onResize);
379
+ process.on("exit", stop);
380
+ return {
381
+ clear: () => {
382
+ write("\x1B[2J");
383
+ render();
384
+ },
385
+ stop,
386
+ };
387
+ }
388
+
338
389
  /**
339
390
  * Vite 风格 dev 快捷键(默认仅在 TTY 启用,见 devServer 内的 keymapEnabled 判定)。
340
391
  * r 强制重建并热更新(复用 trigger)
@@ -356,6 +407,8 @@ export interface DevKeymapDeps {
356
407
  */
357
408
  input?: NodeJS.ReadableStream & { setRawMode?: (mode: boolean) => unknown }
358
409
  openBrowser?: (url: string) => void
410
+ /** 清屏实现(默认 \x1Bc);devServer 注入底栏版以保住滚动区 */
411
+ clearScreen?: () => void
359
412
  onReload: () => void
360
413
  onQuit: () => void
361
414
  log?: (msg: string) => void
@@ -404,7 +457,10 @@ export function setupDevKeymap(deps: DevKeymapDeps): void {
404
457
  help();
405
458
  }
406
459
  else if (k === "c") {
407
- process.stdout.write("\x1Bc");
460
+ if (deps.clearScreen)
461
+ deps.clearScreen();
462
+ else
463
+ process.stdout.write("\x1Bc");
408
464
  }
409
465
  else if (k === "o") {
410
466
  open(url);