@oj-module/cli 0.2.20 → 0.2.25

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 (75) hide show
  1. package/devkit/manual.md +100 -12
  2. package/devkit/module-dev-and-cdn-playground.md +10 -10
  3. package/package.json +4 -4
  4. package/shell-dist/assets/runtime.js +61 -35
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/build-backend.test.ts +13 -13
  8. package/src/__tests__/cli.test.ts +225 -0
  9. package/src/__tests__/init-packagejson.test.ts +29 -0
  10. package/src/__tests__/layout.test.ts +8 -5
  11. package/src/__tests__/new-scaffold.test.ts +59 -0
  12. package/src/__tests__/usage.test.ts +45 -47
  13. package/src/build.ts +4 -4
  14. package/src/builtin.ts +93 -0
  15. package/src/bundle.ts +237 -33
  16. package/src/cli.ts +265 -0
  17. package/src/contract/__tests__/market-client.integration.test.ts +12 -12
  18. package/src/contract/check.ts +4 -4
  19. package/src/dev.ts +75 -2
  20. package/src/index.ts +31 -191
  21. package/src/info.ts +49 -7
  22. package/src/init.ts +25 -11
  23. package/src/keygen.ts +0 -19
  24. package/src/layout.ts +11 -11
  25. package/src/lifecycle/__tests__/authoring.test.ts +3 -14
  26. package/src/lifecycle/__tests__/builtin.test.ts +97 -0
  27. package/src/lifecycle/__tests__/bundle.test.ts +188 -25
  28. package/src/lifecycle/__tests__/install.test.ts +0 -23
  29. package/src/lifecycle/__tests__/new.test.ts +129 -0
  30. package/src/lifecycle/__tests__/smoke.test.ts +4 -3
  31. package/src/manifest-contract.ts +16 -3
  32. package/src/market/__tests__/bootstrap.test.ts +24 -0
  33. package/src/market/__tests__/setup.test.ts +8 -1
  34. package/src/market/bootstrap.ts +10 -8
  35. package/src/market/cdn-layout.ts +3 -3
  36. package/src/market/command.ts +0 -23
  37. package/src/market/publish.ts +10 -2
  38. package/src/market/setup.ts +14 -2
  39. package/src/market/trial.ts +3 -3
  40. package/src/market/walkthrough.ts +7 -6
  41. package/src/new.ts +226 -0
  42. package/src/preview-config.ts +2 -2
  43. package/src/preview.ts +5 -4
  44. package/src/publish.ts +0 -22
  45. package/src/sign.ts +0 -22
  46. package/src/usage.ts +482 -198
  47. package/templates/README.md +3 -3
  48. package/templates/cdn/README.md +6 -6
  49. package/templates/config.yaml +5 -4
  50. package/templates/init.include.yaml +1 -1
  51. package/templates/package.json +3 -1
  52. package/templates/seed.mysql.sql +2 -1
  53. package/templates/seed.pg.sql +2 -1
  54. package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
  55. package/templates/src/_platform/api/market/api.ts +11 -8
  56. package/templates/src/_platform/api/seed.mysql.sql +11 -3
  57. package/templates/src/_platform/api/seed.pg.sql +11 -3
  58. package/templates/src/_platform/api/seed.sql +18 -6
  59. package/templates/src/_platform/api/solutions/api.ts +2 -2
  60. package/templates/src/oj-market/web/api/context.ts +2 -2
  61. package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
  62. package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
  63. package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
  64. package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
  65. package/templates/src/system/web/locales/en-US.json +1 -1
  66. package/templates/src/system/web/locales/zh-CN.json +1 -1
  67. package/templates/src/system/web/pages/users/roles.tsx +3 -1
  68. package/templates/tests/l1-builder.test.ts +2 -2
  69. package/templates/tests/lifecycle/market-api.spec.ts +35 -5
  70. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  71. package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
  72. package/templates/tests/rbac-seed.spec.ts +12 -10
  73. package/templates/tests/seed-role-accounts.spec.ts +87 -0
  74. package/vendor/host-versions.json +3 -3
  75. package/src/args.ts +0 -122
package/src/index.ts CHANGED
@@ -1,203 +1,43 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
1
  import process from "node:process";
4
- import { parseApiArgs, parseBuildArgs, parseInitArgs, parseLifecycleArgs, parseVendorArgs } from "./args";
5
- import { buildBackend, buildModules } from "./build";
6
- import { buildBundle } from "./bundle";
7
- import { devServer } from "./dev";
8
- import { mergeManifests, printInfo } from "./info";
9
- import { initProject } from "./init";
10
- import { runPreview } from "./preview";
11
- import { getCommandHelp, resolveHelpRequest, usageText } from "./usage";
12
- import { vendorCommand } from "./vendor";
2
+ import { buildCli, localizeCacError, reportCliError, rewriteHelpArgv, unknownCommandResult } from "./cli";
3
+ import { isKnownCommand, resolveLang, usageText } from "./usage";
13
4
  import { readCliVersion } from "./versions";
14
5
 
15
- /** ojm → oj 透传子命令(其余参数原样转发,见 src/oj-cmd.ts) */
16
- const OJ_PASSTHROUGH = new Set(["test", "exec", "openapi", "migrate", "schema"]);
17
-
18
- const [command] = process.argv.slice(2);
19
-
20
- function usage(): never {
21
- console.log(usageText());
22
- process.exit(command ? 1 : 0);
23
- }
24
-
25
6
  async function main() {
26
- const projectRoot = process.cwd();
27
-
28
- // 帮助请求:`ojm <cmd> --help` / `ojm help <cmd>` / 顶层 `ojm --help`
29
- // 必须在 switch 之前拦截,否则会真正执行子命令(如 `ojm market setup --help` 会去建 CDN)。
30
- const helpReq = resolveHelpRequest();
31
- if (helpReq !== null) {
32
- if (!helpReq.cmd) {
33
- console.log(usageText());
34
- return;
35
- }
36
- const text = getCommandHelp(helpReq.cmd, helpReq.sub);
37
- if (text) {
38
- console.log(text);
39
- return;
40
- }
41
- console.error(`[ojm] 未知命令:${helpReq.cmd}${helpReq.sub ? ` ${helpReq.sub}` : ""}`);
42
- process.exit(1);
43
- }
7
+ const lang = resolveLang();
8
+ const argv = process.argv.slice(2);
44
9
 
45
- // 顶层 --version / -V:打印 cli 包版本(与 `ojm info` 的 cli 版本同源)
46
- if (command === "--version" || command === "-V") {
10
+ // 顶层 --version / -V:裸版本号(与 ojm info 同源);不经 cac,避免其 platformInfo 后缀
11
+ if (argv[0] === "--version" || argv[0] === "-V") {
47
12
  console.log(readCliVersion());
48
13
  return;
49
14
  }
50
15
 
51
- switch (command) {
52
- case "build": {
53
- const { bundle, out } = parseBuildArgs(process.argv.slice(3));
54
- if (bundle) {
55
- // 方案包构建(P1-11):契约校验 fail-fast → 确定性 tgz + sha256(消费方:市场安装器)
56
- await buildBundle(projectRoot, { out });
57
- break;
58
- }
59
- // 设计 §5:后端 oj build(零 DB 副作用)→ 前端全站合并(仅 build 清场合并)
60
- await buildBackend(projectRoot);
61
- await buildModules(projectRoot, { mergeSite: true });
62
- break;
63
- }
64
- case "init": {
65
- const { dest, yes } = parseInitArgs(process.argv.slice(3));
66
- await initProject(dest ? path.resolve(dest) : projectRoot, { yes });
67
- break;
68
- }
69
- case "preview": {
70
- const previewPortArg = Number(process.argv[3]);
71
- const previewPort = Number.isInteger(previewPortArg) && previewPortArg > 0 ? previewPortArg : undefined;
72
- await runPreview(projectRoot, { port: previewPort });
73
- break;
74
- }
75
- case "dev": {
76
- const portArg = Number(process.argv[3]);
77
- const port = Number.isFinite(portArg) && portArg > 0 ? portArg : 5174;
78
- await devServer(projectRoot, { port });
79
- break;
80
- }
81
- case "info":
82
- await printInfo(projectRoot);
83
- break;
84
- case "vendor": {
85
- const { force, tag } = parseVendorArgs(process.argv.slice(3));
86
- await vendorCommand(projectRoot, { force, tag });
87
- break;
88
- }
89
- case "api": {
90
- const { dir, check, docs, exempt } = parseApiArgs(process.argv.slice(3));
91
- // S5:显式目录必须先存在——否则静默在错的目录下生成/对账
92
- if (dir && !fs.existsSync(path.resolve(dir)))
93
- throw new Error(`[ojm-api] 项目目录不存在:${dir}`);
94
- const apiRoot = dir ? path.resolve(dir) : projectRoot;
95
- if (docs) {
96
- const { runApiDocs } = await import("./contract/run");
97
- const out = await runApiDocs(apiRoot);
98
- console.log(`[ojm-api] 文档站已生成:${path.relative(projectRoot, out)}(浏览器直接打开即可)`);
99
- break;
100
- }
101
- if (check) {
102
- const { checkApi } = await import("./contract/check");
103
- // --exempt <path>:相对路径按项目根解析;缺省按布局找 .ojm-api-exempt.json(工程根 → api/)
104
- // (旧名 api/.ram-api-exempt.json 仍被读取,R4)
105
- const { violations, hints } = await checkApi({ cwd: apiRoot, exempt: exempt ? path.resolve(projectRoot, exempt) : undefined });
106
- for (const v of violations)
107
- console[v.level === "error" ? "error" : "warn"](`${v.level === "error" ? "✗" : "⚠"} ${v.message}`);
108
- for (const h of hints)
109
- console.log(h);
110
- if (violations.length === 0)
111
- console.log("[ojm-api] --check 通过:生成物同步、route 双向对账、routes.js 均无 drift。");
112
- const errors = violations.filter(v => v.level === "error").length;
113
- console.log(`[ojm-api] --check 结果:${errors} error / ${violations.length - errors} warn`);
114
- if (errors > 0)
115
- process.exit(1);
116
- break;
117
- }
118
- const { runApi } = await import("./contract/run");
119
- const result = await runApi({ cwd: apiRoot });
120
- console.log(`[ojm-api] 契约 ${result.contracts} 份;写入 ${result.written.length} 个文件,跳过(未变)${result.skipped.length} 个;stub 新建 ${result.stubs.created} / 更新 ${result.stubs.updated} / 跳过 ${result.stubs.skipped}`);
121
- for (const p of result.written)
122
- console.log(` + ${path.relative(apiRoot, p)}`);
123
- break;
124
- }
125
- case "merge":
126
- await mergeManifests(process.argv[3] ?? "", process.argv.slice(4));
127
- break;
128
- case "keygen": {
129
- // 签名密钥对生成(P2-4,作者工具链):缺省 ~/.ojm/keys/<id>,工程树内落盘需 --yes
130
- const { parseKeygenArgs } = await import("./keygen");
131
- const { keygenCommand } = await import("./keygen");
132
- const { id, out, yes } = parseKeygenArgs(process.argv.slice(3));
133
- keygenCommand({ id, out, yes });
134
- break;
135
- }
136
- case "sign": {
137
- // detached 侧车签名(P2-4,G4 载体):ojm sign <tgz> [--id <publisher.id>] [--key <pem>] [--check]
138
- const { parseSignArgs } = await import("./sign");
139
- const { signCommand } = await import("./sign");
140
- const { positionals, id, key, check } = parseSignArgs(process.argv.slice(3));
141
- signCommand(positionals[0] ?? "", { id, key, check });
142
- break;
143
- }
144
- case "install": {
145
- // 方案包安装瘦客户端(P1-13,D6):file 上传 / CDN 市场源 → 轮询任务至终态
146
- const { positionals, url, user, pass, locale, name, version } = parseLifecycleArgs(process.argv.slice(3));
147
- const { installCommand } = await import("./install");
148
- await installCommand(positionals[0] ?? "", { url, user, pass, locale, name, version });
149
- break;
150
- }
151
- case "enable":
152
- case "disable": {
153
- // 生命周期开关瘦客户端(P1-13,D8):站点侧 setEnabled 四处同写
154
- const { positionals, url, user, pass } = parseLifecycleArgs(process.argv.slice(3));
155
- const { setEnabledCommand } = await import("./enable");
156
- await setEnabledCommand(positionals[0] ?? "", command === "enable", { url, user, pass });
157
- break;
158
- }
159
- case "uninstall": {
160
- // 卸载瘦客户端(P1-15a,缺口 B):站点侧 runUninstall 四处同删 + 权限级联
161
- const { positionals, url, user, pass } = parseLifecycleArgs(process.argv.slice(3));
162
- const { uninstallCommand } = await import("./uninstall");
163
- await uninstallCommand(positionals[0] ?? "", { url, user, pass });
164
- break;
165
- }
166
- case "trust": {
167
- // 信任库运维瘦客户端(P2-2,C2-38/39):ls / add <key_id> <pubkey_hex> / revoke <key_id> / confirm --task
168
- const { positionals, url, user, pass, task } = parseLifecycleArgs(process.argv.slice(3));
169
- const { trustCommand } = await import("./trust");
170
- await trustCommand(positionals[0] ?? "", positionals.slice(1), { url, user, pass, task });
171
- break;
172
- }
173
- case "publish": {
174
- // 索引发布(P3-5/6/7,作者工具链):重算 bundles/manifests 索引 + detached .sig 签名写回
175
- const { parsePublishArgs } = await import("./publish");
176
- const { publishCommand } = await import("./publish");
177
- const { positionals, id, key, check } = parsePublishArgs(process.argv.slice(3));
178
- publishCommand(positionals[0] ?? "", { id, key, check });
179
- break;
180
- }
181
- case "market": {
182
- // 本地模拟 CDN 与市场链路(setup / publish / build-index)
183
- const { runMarket } = await import("./market/command");
184
- await runMarket(process.argv.slice(3));
185
- break;
186
- }
187
- default: {
188
- if (command && OJ_PASSTHROUGH.has(command)) {
189
- const { runOjSubcommand } = await import("./oj-cmd");
190
- runOjSubcommand(projectRoot, command, process.argv.slice(3));
191
- break;
192
- }
193
- usage();
194
- }
16
+ const rewritten = rewriteHelpArgv(argv);
17
+ const cli = buildCli(process.cwd(), lang);
18
+ let unknown: string | undefined;
19
+ cli.on("command:*", () => {
20
+ // cac 在 --help 输出后会 unsetMatchedCommand 再发 command:*(args[0] 可能是子命令,如 verify)——
21
+ // 误判根源。按「首位置参数是否是已知命令」判定:market 等已知命令的子命令不误判,未知首参才兜底
22
+ const first = rewritten.find(a => !a.startsWith("-"));
23
+ if (first !== undefined && !isKnownCommand(first))
24
+ unknown = first;
25
+ });
26
+ cli.parse(["node", "ojm", ...rewritten]);
27
+
28
+ // 未知命令:help 形态报「未知命令」、否则打帮助;均 exit 1(裸 ojm 打帮助退 0 不变)
29
+ if (unknown !== undefined) {
30
+ const r = unknownCommandResult(unknown, lang, !!cli.options.help);
31
+ if (cli.options.help)
32
+ console.error(r.text);
33
+ else
34
+ console.log(r.text);
35
+ process.exit(r.code);
195
36
  }
37
+ if (argv.length === 0)
38
+ console.log(usageText(lang));
196
39
  }
197
40
 
198
- main().catch((error: unknown) => {
199
- // Error 的 message 已是人话(含修复指引);堆栈默认是噪音,OJM_DEBUG=1 才输出
200
- // (旧名 RAM_DEBUG 一并读,兼容既有排障习惯)
201
- console.error(`[ojm] ${error instanceof Error ? ((process.env.OJM_DEBUG ?? process.env.RAM_DEBUG) ? error.stack : error.message) : String(error)}`);
202
- process.exit(1);
203
- });
41
+ main().catch((error: unknown) => reportCliError(localizeCacError(error, resolveLang()), (code) => {
42
+ process.exit(code);
43
+ }));
package/src/info.ts CHANGED
@@ -12,8 +12,9 @@ import fs from "node:fs";
12
12
 
13
13
  import http from "node:http";
14
14
  import path from "node:path";
15
- import { loadModulesConfig } from "./config";
16
- import { resolveConfigPath } from "./layout";
15
+ import { readBuiltinModules } from "./builtin";
16
+ import { loadModulesConfig, loadSiteConfig, mergeModuleList } from "./config";
17
+ import { resolveConfigPath, scanModules, tryResolveLayout } from "./layout";
17
18
  import { mergeModuleManifests } from "./manifest";
18
19
  import { probeOjCaps } from "./oj-caps";
19
20
  import { readOjApiPrefix, readOjServerField } from "./oj-config";
@@ -91,7 +92,50 @@ export function realOjObservability(projectRoot: string): OjObservability {
91
92
  };
92
93
  }
93
94
 
94
- export async function printInfo(projectRoot: string, oj: OjObservability = realOjObservability(projectRoot)): Promise<void> {
95
+ export interface PrintInfoOptions {
96
+ /** 展开框架自带模块(默认折叠为一行计数) */
97
+ all?: boolean
98
+ }
99
+
100
+ /**
101
+ * 模块清单条目解析:module-root 用「扫描 + 登记覆盖」并集(与 build 同源,
102
+ * 未登记的用户模块也可见——旧实现只读 web.config.ts 会漏掉它们);历史布局
103
+ * 维持登记清单(loadModulesConfig)。
104
+ */
105
+ async function resolveModuleEntries(projectRoot: string): Promise<{ name: string, entry: string, enabled: boolean }[]> {
106
+ const layout = tryResolveLayout(projectRoot);
107
+ if (layout?.kind === "module-root") {
108
+ const config = await loadSiteConfig(projectRoot);
109
+ return mergeModuleList(scanModules(layout), config.modules, projectRoot);
110
+ }
111
+ const config = await loadModulesConfig(projectRoot);
112
+ return config.modules.map(m => ({ name: m.name, entry: m.entry, enabled: m.enabled !== false }));
113
+ }
114
+
115
+ /** 清单渲染:自带模块默认折叠(--all 展开);坏名单文件告警降级为平铺,info 不失败 */
116
+ function renderModuleLines(entries: { name: string, entry: string, enabled: boolean }[], projectRoot: string, all: boolean): string {
117
+ let builtin = new Set<string>();
118
+ try {
119
+ builtin = readBuiltinModules(projectRoot);
120
+ }
121
+ catch (error) {
122
+ console.warn(`[ojm] ⚠️ ${error instanceof Error ? error.message : String(error)}——本次按无内置名单渲染。`);
123
+ }
124
+ const fmt = (m: { name: string, entry: string, enabled: boolean }) => ` · ${m.name}(${m.entry})${m.enabled ? "" : " [disabled]"}`;
125
+ const own = entries.filter(m => builtin.has(m.name));
126
+ const user = entries.filter(m => !builtin.has(m.name));
127
+ const lines = user.map(fmt);
128
+ if (own.length > 0) {
129
+ if (all)
130
+ lines.push(...own.map(fmt));
131
+ else
132
+ lines.push(` 框架自带:${own.length} 个(ojm info --all 展开)`);
133
+ }
134
+ // 零模块时留占位行,避免清单段整段空白像渲染故障
135
+ return lines.length > 0 ? lines.join("\n") : "(无 web 模块)";
136
+ }
137
+
138
+ export async function printInfo(projectRoot: string, oj: OjObservability = realOjObservability(projectRoot), opts: PrintInfoOptions = {}): Promise<void> {
95
139
  const cliVersion = readPkgVersion(path.join(projectRoot, "node_modules/@oj-module/cli/package.json"));
96
140
  const runtimeVersion = readPkgVersion(path.join(projectRoot, "node_modules/@oj-module/runtime/package.json"));
97
141
 
@@ -107,10 +151,8 @@ export async function printInfo(projectRoot: string, oj: OjObservability = realO
107
151
 
108
152
  let moduleLines = "(web.config.ts 加载失败或无模块)";
109
153
  try {
110
- const config = await loadModulesConfig(projectRoot);
111
- moduleLines = config.modules
112
- .map(m => ` · ${m.name}(${m.entry})${m.enabled === false ? " [disabled]" : ""}`)
113
- .join("\n");
154
+ const entries = await resolveModuleEntries(projectRoot);
155
+ moduleLines = renderModuleLines(entries, projectRoot, opts.all ?? false);
114
156
  }
115
157
  catch (error) {
116
158
  moduleLines = `(加载失败:${(error as Error).message})`;
package/src/init.ts CHANGED
@@ -17,6 +17,7 @@ import process from "node:process";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import yaml from "yaml";
19
19
  import { ALIAS_PATHS_FILE, syncAliasPaths } from "./alias-paths";
20
+ import { BUILTIN_LIST_FILE, writeBuiltinList } from "./builtin";
20
21
  import { resolveCertDir, resolveConfigPath } from "./layout";
21
22
  import { probeOjCaps } from "./oj-caps";
22
23
  import { mintDevCert } from "./oj-cert";
@@ -125,6 +126,14 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
125
126
  }, report, include, templatesDir);
126
127
  pruneUnincludedApiPaths(destDir, include, report);
127
128
 
129
+ // 1a) src/.ojm-builtin.json:脚手架自带模块身份(名单派生自上面的白名单,单一真源)。
130
+ // copyIfAbsent:旧工程重跑 init 时缺失才补写,已存在不覆盖用户手改。
131
+ const builtinStatus = writeBuiltinList(destDir, include);
132
+ if (builtinStatus === "created")
133
+ report.created.push(`src/${BUILTIN_LIST_FILE}`);
134
+ else if (builtinStatus === "skipped")
135
+ report.skipped.push(`${BUILTIN_LIST_FILE}(已存在,不覆盖用户手改)`);
136
+
128
137
  // 1b) tsconfig.paths.json:把 oj 别名 `#/<模块名>/…` 投影成 TS 的 paths。
129
138
  // 必须**现场生成**而不是随模板拷贝——模板兼作 demo 工程(多出 demo/home 两个
130
139
  // 模块),而用户工程只有白名单里的那些;照拷会让 paths 指向不存在的目录。
@@ -233,7 +242,7 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
233
242
  console.log(`[ojm] · 跳过 ${item}`);
234
243
  for (const item of report.warnings)
235
244
  console.warn(`[ojm] ! ${item}`);
236
- console.log("[ojm] 下一步:pnpm install && pnpm dev(登录 admin / 123456)");
245
+ console.log("[ojm] 下一步:pnpm install && pnpm dev(登录 superadmin / 123456,角色账号见 devkit 手册 §1.1)");
237
246
  }
238
247
 
239
248
  /** 默认密封器:bin/oj secret keygen + seal(失败返回 null,不抛) */
@@ -480,10 +489,8 @@ logs/
480
489
  # 现场签发的开发证书与凭据私钥(ojm dev 缺证时自动签发,见 oj-cert.ts);私钥绝不入仓
481
490
  config/*.pem
482
491
  config/cert.jws
483
- # 构建产物
484
- api/dist/
485
- web/dist/
486
- modules/dist/
492
+ # 构建产物(ojm build:站点 dist/web + 后端 release dist/api)
493
+ dist/
487
494
  `;
488
495
 
489
496
  function ensureGitignore(destDir: string, report: InitReport): void {
@@ -576,7 +583,8 @@ function mergePackageJson(existing: Record<string, any>, generated: Record<strin
576
583
  return merged;
577
584
  }
578
585
 
579
- function generatePackageJson(cliRoot: string, projectName: string) {
586
+ /** 生成脚手架 package.json(导出供守卫测试:scripts ⊆ 模板 scripts,防双写漂移) */
587
+ export function generatePackageJson(cliRoot: string, projectName: string) {
580
588
  const { matrix: hostVersions } = resolveVersionMatrix(cliRoot);
581
589
  const cliPkg = JSON.parse(fs.readFileSync(path.join(cliRoot, "package.json"), "utf-8"));
582
590
 
@@ -626,11 +634,17 @@ function generatePackageJson(cliRoot: string, projectName: string) {
626
634
  type: "module",
627
635
  private: true,
628
636
  scripts: {
629
- dev: "ojm dev",
630
- build: "ojm build",
631
- preview: "ojm preview",
632
- info: "ojm info",
633
- typecheck: "tsc --noEmit -p tsconfig.json",
637
+ "dev": "ojm dev",
638
+ "build": "ojm build",
639
+ "preview": "ojm preview",
640
+ "info": "ojm info",
641
+ "typecheck": "tsc --noEmit -p tsconfig.json",
642
+ // typecheck:api 指向 src/tsconfig.json(后端工程面,白名单收录)——
643
+ // 早期生成器漏发这条(与模板双写漂移),补上并钉守卫测试
644
+ "typecheck:api": "tsc --noEmit -p src/tsconfig.json",
645
+ // 多方案(phase 2/3):打包全部用户方案(自带自动排除)/ 生成新方案骨架
646
+ "bundle": "ojm build --bundle",
647
+ "new": "ojm new",
634
648
  },
635
649
  devDependencies: devDeps,
636
650
  };
package/src/keygen.ts CHANGED
@@ -24,25 +24,6 @@ export interface KeygenOptions {
24
24
 
25
25
  export const DEFAULT_KEY_ID = "local.dev";
26
26
 
27
- /** `ojm keygen [--id <publisher.id>] [--out <dir>] [--yes]` 参数解析(值式 flag 逐项消费,P6 同款纪律) */
28
- export function parseKeygenArgs(argv: string[]): { id?: string, out?: string, yes?: boolean } {
29
- const values: Record<string, string> = {};
30
- let yes = false;
31
- for (let i = 0; i < argv.length; i++) {
32
- const a = argv[i]!;
33
- if (a === "--yes") {
34
- yes = true;
35
- continue;
36
- }
37
- if (a === "--id" || a === "--out") {
38
- values[a] = argv[i + 1] ?? "";
39
- i++;
40
- continue;
41
- }
42
- }
43
- return { id: values["--id"], out: values["--out"], yes };
44
- }
45
-
46
27
  /** C2-37 key_id 命名空间(与 isolate 侧 trust.ts isValidKeyId 同规则;禁裸通用名单段) */
47
28
  export function isValidKeyId(keyId: string): boolean {
48
29
  return /^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)+$/.test(keyId);
package/src/layout.ts CHANGED
@@ -14,12 +14,12 @@
14
14
  * `src/<x>/api/manifest.yaml` 与 `src/<x>/web/entry.ts` 是唯一真源(设计 C-6:
15
15
  * 一侧可缺省,两侧皆无则不是模块)。
16
16
  *
17
- * 产物根在两套布局里语义相同、位置不变:
18
- * api/dist oj 扁平 release 根(<name>-<version>/ 同层兄弟 + manifests.yaml)
19
- * web/dist 站点产物(shell + modules/ + modules.json)
17
+ * 产物根(2026-10-22 起收进工程根单一 dist/,原 {web,api}/dist 与源码混树):
18
+ * dist/api oj 扁平 release 根(<name>-<version>/ 同层兄弟 + manifests.yaml),三种布局一致
19
+ * dist/web 站点产物(shell + modules/ + modules.json);flat 例外:框架根仓维持 build/
20
20
  * 产物按模块聚合已被设计 §2.2 否决(实证见该节),禁止在此引入第二套形状。
21
21
  *
22
- * watch 目标绝不含产物目录:若 watch `web/`,重建产物(web/dist)
22
+ * watch 目标绝不含产物目录:若 watch `web/`,重建产物(dist/web)
23
23
  * 会反过来触发重建,形成自触发循环(设计 §4)。
24
24
  */
25
25
 
@@ -42,9 +42,9 @@ export interface ProjectLayout {
42
42
  projectRoot: string
43
43
  /** 模块源码目录:module-root → `<project>/src`;new → `<project>/web/src`;flat → `<project>/web` */
44
44
  modulesSrc: string
45
- /** 站点产物根:module-root / new → `<project>/web/dist`;flat → `<project>/build` */
45
+ /** 站点产物根:module-root / new → `<project>/dist/web`;flat → `<project>/build`(框架根仓约定) */
46
46
  distDir: string
47
- /** oj 扁平 release 根:恒为 `<project>/api/dist`(设计 C-3/C-7,不随布局变化) */
47
+ /** oj 扁平 release 根:恒为 `<project>/dist/api`(不随布局变化;无布局回退同值) */
48
48
  apiDist: string
49
49
  /** fs.watch 目标(纯源码,永不落产物) */
50
50
  watchTarget: string
@@ -161,8 +161,8 @@ export function tryResolveLayout(projectRoot: string): ProjectLayout | null {
161
161
  kind: "module-root",
162
162
  projectRoot,
163
163
  modulesSrc: path.join(projectRoot, "src"),
164
- distDir: path.join(projectRoot, "web", "dist"),
165
- apiDist: path.join(projectRoot, "api", "dist"),
164
+ distDir: path.join(projectRoot, "dist", "web"),
165
+ apiDist: path.join(projectRoot, "dist", "api"),
166
166
  watchTarget: path.join(projectRoot, "src"),
167
167
  moduleRoot: path.join(projectRoot, "src"),
168
168
  };
@@ -173,8 +173,8 @@ export function tryResolveLayout(projectRoot: string): ProjectLayout | null {
173
173
  kind: "new",
174
174
  projectRoot,
175
175
  modulesSrc: path.join(projectRoot, "web", "src"),
176
- distDir: path.join(projectRoot, "web", "dist"),
177
- apiDist: path.join(projectRoot, "api", "dist"),
176
+ distDir: path.join(projectRoot, "dist", "web"),
177
+ apiDist: path.join(projectRoot, "dist", "api"),
178
178
  watchTarget: path.join(projectRoot, "web", "src"),
179
179
  };
180
180
  }
@@ -186,7 +186,7 @@ export function tryResolveLayout(projectRoot: string): ProjectLayout | null {
186
186
  projectRoot,
187
187
  modulesSrc: path.join(projectRoot, "web"),
188
188
  distDir: path.join(projectRoot, "build"),
189
- apiDist: path.join(projectRoot, "api", "dist"),
189
+ apiDist: path.join(projectRoot, "dist", "api"),
190
190
  watchTarget: path.join(projectRoot, "web"),
191
191
  };
192
192
  }
@@ -5,8 +5,8 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
- import { DEFAULT_KEY_ID, isValidKeyId, keygenCommand, parseKeygenArgs } from "../../keygen";
9
- import { parseSignArgs, signCommand } from "../../sign";
8
+ import { DEFAULT_KEY_ID, isValidKeyId, keygenCommand } from "../../keygen";
9
+ import { signCommand } from "../../sign";
10
10
  import { readPrivateKeyPem } from "../keygen";
11
11
  import { fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
12
12
 
@@ -19,11 +19,7 @@ afterEach(() => {
19
19
  fs.rmSync(tmp, { recursive: true, force: true });
20
20
  });
21
21
 
22
- describe("ojm keygen 参数解析 + key_id 命名空间(C2-37)", () => {
23
- it("parseKeygenArgs:--id/--out 值式消费 + --yes 独立", () => {
24
- expect(parseKeygenArgs(["--id", "acme.market", "--out", "./k", "--yes"]))
25
- .toEqual({ id: "acme.market", out: "./k", yes: true });
26
- });
22
+ describe("ojm keygen key_id 命名空间(C2-37)", () => {
27
23
  it("isValidKeyId:<publisher>.<id> 合法 / 裸名·大写·空非法", () => {
28
24
  expect(isValidKeyId("acme.market")).toBe(true);
29
25
  expect(isValidKeyId("acme.dev.key-1")).toBe(true);
@@ -63,13 +59,6 @@ describe("ojm keygen 命令体(Ed25519 落盘 + 私钥红线)", () => {
63
59
  });
64
60
  });
65
61
 
66
- describe("ojm sign 参数解析", () => {
67
- it("parseSignArgs:位置参数 + --id/--key 值式消费 + --check 独立", () => {
68
- expect(parseSignArgs(["pkg.tgz", "--id", "acme.market", "--key", "k.pem", "--check"]))
69
- .toEqual({ positionals: ["pkg.tgz"], id: "acme.market", key: "k.pem", check: true });
70
- });
71
- });
72
-
73
62
  describe("ojm sign 命令体(detached .sig,G4 载体)", () => {
74
63
  const TGZ = path.join(tmp, "demo-solution-0.1.0.tgz");
75
64
 
@@ -0,0 +1,97 @@
1
+ // BDD:脚手架自带模块身份(`src/.ojm-builtin.json`)。
2
+ // 消费契约:bundle 排除打包 / ojm info 折叠 / ojm new 拒名;文件缺失 → 空名单(旧工程兼容)。
3
+ // 用例对应设计 docs/plans/2026-10-22-1640-multi-solution-bundle-design.md:
4
+ // B12(坏文件 fail-fast)/ T1(名单 == 白名单派生集)/ T2(copyIfAbsent,不覆盖用户手改)
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { BUILTIN_LIST_FILE, deriveBuiltinModules, readBuiltinModules, writeBuiltinList } from "../../builtin";
10
+
11
+ let project: string;
12
+
13
+ beforeEach(() => {
14
+ project = mkdtempSync(join(tmpdir(), "ojm-builtin-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(project, { recursive: true, force: true });
19
+ });
20
+
21
+ function writeList(content: string): void {
22
+ mkdirSync(join(project, "src"), { recursive: true });
23
+ writeFileSync(join(project, "src", BUILTIN_LIST_FILE), content);
24
+ }
25
+
26
+ describe("readBuiltinModules", () => {
27
+ it("文件缺失 → 空名单(旧工程/框架仓无内置概念,行为退化兼容)", () => {
28
+ expect(readBuiltinModules(project).size).toBe(0);
29
+ });
30
+
31
+ it("合法文件 → 返回 modules 集合", () => {
32
+ writeList(JSON.stringify({ modules: ["auth", "home"] }));
33
+ expect([...readBuiltinModules(project)].sort()).toEqual(["auth", "home"]);
34
+ });
35
+
36
+ it("b12 坏 JSON → fail-fast,报错含文件路径与错因(不静默降级)", () => {
37
+ writeList("{ not json");
38
+ expect(() => readBuiltinModules(project)).toThrow(new RegExp(`${BUILTIN_LIST_FILE.replace(".", "\\.")}`));
39
+ });
40
+
41
+ it("b12 modules 非数组 → fail-fast", () => {
42
+ writeList(JSON.stringify({ modules: "auth" }));
43
+ expect(() => readBuiltinModules(project)).toThrow(/modules/);
44
+ });
45
+
46
+ it("b12 modules 含非字符串项 → fail-fast", () => {
47
+ writeList(JSON.stringify({ modules: ["auth", 42] }));
48
+ expect(() => readBuiltinModules(project)).toThrow(/modules/);
49
+ });
50
+ });
51
+
52
+ describe("deriveBuiltinModules(名单派生自 init.include.yaml,单一真源)", () => {
53
+ it("t1 取 src/<name>/(api|web) 条目的模块名(含 web-only 模块),忽略无关路径", () => {
54
+ const include = new Set([
55
+ "src/auth/api",
56
+ "src/notification/api",
57
+ "src/login/web/entry.ts",
58
+ "src/oj-market/web/pages",
59
+ "src/styles.css",
60
+ "src/tsconfig.json",
61
+ "config.yaml",
62
+ "web.config.ts",
63
+ ]);
64
+ expect([...deriveBuiltinModules(include)].sort())
65
+ .toEqual(["auth", "login", "notification", "oj-market"]);
66
+ });
67
+
68
+ it("白名单无模块条目 → 空集(不凭空造身份)", () => {
69
+ expect(deriveBuiltinModules(new Set(["config.yaml", "README.md"])).size).toBe(0);
70
+ });
71
+ });
72
+
73
+ describe("writeBuiltinList(init 接线:copyIfAbsent)", () => {
74
+ it("t1 写入派生名单(排序)+ 自注释头,返回 created", () => {
75
+ const include = new Set(["src/home/web/entry.ts", "src/auth/api", "src/login/web"]);
76
+ expect(writeBuiltinList(project, include)).toBe("created");
77
+ const p = join(project, "src", BUILTIN_LIST_FILE);
78
+ expect(existsSync(p)).toBe(true);
79
+ const doc = JSON.parse(readFileSync(p, "utf-8"));
80
+ expect(doc.modules).toEqual(["auth", "home", "login"]);
81
+ expect(typeof doc["//"]).toBe("string");
82
+ });
83
+
84
+ it("t2 已存在 → 返回 skipped 且内容原样不动(用户手改权)", () => {
85
+ const include = new Set(["src/auth/api"]);
86
+ expect(writeBuiltinList(project, include)).toBe("created");
87
+ const p = join(project, "src", BUILTIN_LIST_FILE);
88
+ writeFileSync(p, JSON.stringify({ modules: ["my-edit"] }));
89
+ expect(writeBuiltinList(project, include)).toBe("skipped");
90
+ expect(JSON.parse(readFileSync(p, "utf-8")).modules).toEqual(["my-edit"]);
91
+ });
92
+
93
+ it("派生集为空 → 不写文件,返回 none", () => {
94
+ expect(writeBuiltinList(project, new Set(["config.yaml"]))).toBe("none");
95
+ expect(existsSync(join(project, "src", BUILTIN_LIST_FILE))).toBe(false);
96
+ });
97
+ });