@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
@@ -114,26 +114,3 @@ export async function marketCommand(sub: string, argv: string[]): Promise<void>
114
114
  throw new Error(`[ojm] 未知子命令:ojm market ${sub}\n可用:setup / publish / build-index`);
115
115
  }
116
116
  }
117
-
118
- /** `ojm market` 无子命令时的提示(与 usage.ts 同源风格) */
119
- export function marketUsage(): string {
120
- return [
121
- "ojm market setup [--url <站点地址>] [--user <u> --pass <p>] [--new-pass <p>] 把市场地址指向本地 cdn/",
122
- "ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--rebuild-index]",
123
- "ojm market build-index [--id <key>] [--check] 扫描 cdn/ 重建索引",
124
- "ojm market verify [--file <tgz>] [--rebuild-index] 离线验包(不起站点、不装)",
125
- "ojm market trial [--fixture <name>@<version>] [--keep] 隔离站点试装(跑完即毁)",
126
- "ojm market walkthrough [--only <op>] [--strict] [--keep] 隔离站点跑全套市场操作并出 AC 表",
127
- ].join("\n");
128
- }
129
-
130
- /** 进程入口:从 argv 取子命令(index.ts 负责 dispatch 到此处) */
131
- export async function runMarket(argv: string[]): Promise<void> {
132
- const sub = argv[0] ?? "";
133
- if (sub === "" || sub === "--help" || sub === "-h") {
134
- console.log(marketUsage());
135
- return;
136
- }
137
- await marketCommand(sub, argv.slice(1));
138
- void process;
139
- }
@@ -118,9 +118,17 @@ async function buildFixture(args: { fixture?: string, staging: string, cdnDir: s
118
118
  return { name: spec.name, version: spec.version, tgzPath, sha256, preExisted: before.exists };
119
119
  }
120
120
 
121
- /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录 */
121
+ /** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录(一次只发一份:多方案工程请显式指定) */
122
122
  async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
123
- const built = await buildBundle(solutionRoot, { out: staging });
123
+ const builtList = await buildBundle(solutionRoot, { out: staging });
124
+ if (builtList.length !== 1) {
125
+ throw new Error(
126
+ `[ojm] market publish --real 一次只发布一份方案,但该工程打出了 ${builtList.length} 份用户方案。`
127
+ + "请改用显式链路:在方案工程里 `ojm build --bundle <name>`,再 `ojm sign dist/<name>-<version>.tgz --id <publisher>.<key>`,"
128
+ + "把 tgz + .sig 放进 cdn/ 后 `ojm market build-index`。",
129
+ );
130
+ }
131
+ const built = builtList[0]!;
124
132
  const tgzPath = path.join(cdnDir, `${built.name}-${built.version}.tgz`);
125
133
  const before = captureExisting(tgzPath);
126
134
 
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * `ojm market setup`(T5):把 `framework.market_cdn_url` 指向本地模拟 CDN。
3
3
  *
4
+ * 2026-10-23 起**非必需**:脚手架 seed 的缺省值即相对路径 `/cdn`(本站点自供,
5
+ * dev 形态 ojm 已把 /cdn 反代给 oj 子进程),新工程免 setup。本命令只剩一个场景:
6
+ * 指向**外部真 CDN**(或本地但要写绝对地址)时显式改写设置。
7
+ *
4
8
  * 两件容易踩的事都收在这一处:
5
- * 1. **地址必须是绝对的**,且端口来自 config 的 `server.port`——dev 形态下 dev server(5174)
6
- * 只会把 `/api` 反代给 oj 子进程,`/cdn/*` 会被静态层回落成 index.html(设计 §3.1.1)。
9
+ * 1. **地址必须是绝对的**,且端口来自 config 的 `server.port`——写给外部访问方的地址,
10
+ * 相对路径对浏览器/服务端消费另有按请求源补全的口径(market/api.ts withSiteOrigin)。
7
11
  * 2. **首跑凭据悬挂**:seed admin `must_change=1`,登录拿的是限权 token,写不了 system 设置;
8
12
  * 须先 `PUT /api/auth/password` 改密换全权 token(该端点是悬挂期唯一可用写端)。
9
13
  */
@@ -56,6 +60,14 @@ export async function setupMarketUrl(opts: SetupOptions = {}): Promise<SetupResu
56
60
  };
57
61
  }
58
62
 
63
+ /**
64
+ * setup 后用于登录的口令:改密闭环走了才用 newPass,否则原口令。
65
+ * 当前 seed must_change=0(账号名=角色名口径)时 setup 不改密,恒用 newPass 会 401。
66
+ */
67
+ export function loginPasswordAfterSetup(result: SetupResult, newPass: string, originalPass: string): string {
68
+ return result.changedPassword ? newPass : originalPass;
69
+ }
70
+
59
71
  /** 登录并在必要时走改密闭环,返回可用 token */
60
72
  async function obtainToken(fetchFn: typeof fetch, base: string, user: string, pass: string, newPass: string | undefined, projectRoot: string): Promise<{ value: string, changedPassword: boolean, generatedPassword?: string }> {
61
73
  let login: { access_token?: string, restricted?: boolean };
@@ -15,7 +15,7 @@ import process from "node:process";
15
15
  import { callApi, login, pollTask } from "../site";
16
16
  import { bootstrapIsolatedSite } from "./bootstrap";
17
17
  import { parseFixtureSpec } from "./fixture";
18
- import { setupMarketUrl } from "./setup";
18
+ import { loginPasswordAfterSetup, setupMarketUrl } from "./setup";
19
19
 
20
20
  export interface TrialOptions {
21
21
  /** 源工程(提供 bin/oj 与 cli 模板);缺省 process.cwd() */
@@ -53,7 +53,7 @@ export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult
53
53
  try {
54
54
  // 1) 打开市场地址:隔离库是全新 seed,首登必然处于改密悬挂期 → setup 顺带完成改密
55
55
  const newPass = "trial-pass-1";
56
- await setupMarketUrl({
56
+ const setup = await setupMarketUrl({
57
57
  projectRoot: site.root,
58
58
  url: site.baseUrl,
59
59
  user: site.admin.user,
@@ -61,7 +61,7 @@ export async function trialInstall(opts: TrialOptions = {}): Promise<TrialResult
61
61
  newPass,
62
62
  });
63
63
 
64
- const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
64
+ const token = await login(fetch, site.baseUrl, site.admin.user, loginPasswordAfterSetup(setup, newPass, site.admin.pass));
65
65
 
66
66
  // 2) 首次安装:预期停在 needs_trust(发布方是本次临时生成的身份)
67
67
  let task = await installOnce(site.baseUrl, token, spec.name, spec.version);
@@ -19,6 +19,7 @@ import fs from "node:fs";
19
19
 
20
20
  import path from "node:path";
21
21
  import process from "node:process";
22
+ import { resolveLayout } from "../layout";
22
23
  import { signCommand } from "../sign";
23
24
  import { callApi, login, pollTask, unwrapEnvelope } from "../site";
24
25
  import { bootstrapIsolatedSite, derivePublisherKey } from "./bootstrap";
@@ -27,7 +28,7 @@ import { FAILURE_PLAYBOOK } from "./failure-playbook";
27
28
  import { buildEvilZipSlipTgz, parseFixtureSpec } from "./fixture";
28
29
  import { publishPackage } from "./publish";
29
30
  import { writeWalkthroughReport } from "./report";
30
- import { setupMarketUrl } from "./setup";
31
+ import { loginPasswordAfterSetup, setupMarketUrl } from "./setup";
31
32
 
32
33
  interface Ctx {
33
34
  site: IsolatedSite
@@ -154,7 +155,7 @@ const OPS: Op[] = [
154
155
  if (task.status !== "done")
155
156
  return { ok: false, detail: `升级安装未 done:status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
156
157
  const card = await indexCard(ctx);
157
- const distDir = path.join(ctx.site.root, "api", "dist");
158
+ const distDir = resolveLayout(ctx.site.root).apiDist;
158
159
  const dir010 = path.join(distDir, `${ctx.name}-0.1.0`);
159
160
  const dir020 = path.join(distDir, `${ctx.name}-0.2.0`);
160
161
  const mig010 = path.join(dir010, "migrations", "0001__init.sql");
@@ -185,7 +186,7 @@ const OPS: Op[] = [
185
186
  return { ok: false, detail: `前置安装 0.2.0 未 done:status=${pre.status}:${pre.message}` };
186
187
  card = await indexCard(ctx);
187
188
  }
188
- const distDir = path.join(ctx.site.root, "api", "dist");
189
+ const distDir = resolveLayout(ctx.site.root).apiDist;
189
190
  const before = fs.readdirSync(distDir).sort(); // 零副作用基线:0.1.0 目录是前序合法安装的产物,不代表本次落盘
190
191
  const task = await installOnce(ctx, "0.1.0"); // 包内最大 seq 1 < head 2
191
192
  const blocked = task.status === "failed" && task.stage === "migrating";
@@ -340,7 +341,7 @@ const OPS: Op[] = [
340
341
  run: async (ctx) => {
341
342
  const evil = path.join(ctx.site.cdnDir, `${ctx.name}-evil.tgz`);
342
343
  buildEvilZipSlipTgz(evil); // 成员含 ../evil.sql,无需发布进索引(file 源直传)
343
- const distDir = path.join(ctx.site.root, "api", "dist");
344
+ const distDir = resolveLayout(ctx.site.root).apiDist;
344
345
  const before = fs.readdirSync(distDir).sort(); // 零副作用基线:9.9.9 目录可能是前序 neg-stranger 的合法产物
345
346
  try {
346
347
  let msg = "";
@@ -373,8 +374,8 @@ export async function walkthrough(opts: WalkthroughOptions = {}): Promise<Walkth
373
374
  const site = await bootstrapIsolatedSite({ projectRoot: opts.projectRoot, fixture: opts.fixture, keep: opts.keep, onLog: log });
374
375
  try {
375
376
  const newPass = "trial-pass-1";
376
- await setupMarketUrl({ projectRoot: site.root, url: site.baseUrl, user: site.admin.user, pass: site.admin.pass, newPass });
377
- const token = await login(fetch, site.baseUrl, site.admin.user, newPass);
377
+ const setup = await setupMarketUrl({ projectRoot: site.root, url: site.baseUrl, user: site.admin.user, pass: site.admin.pass, newPass });
378
+ const token = await login(fetch, site.baseUrl, site.admin.user, loginPasswordAfterSetup(setup, newPass, site.admin.pass));
378
379
  const ctx: Ctx = { site, base: site.baseUrl, token, name: spec.name, version: spec.version, log };
379
380
 
380
381
  const selected = opts.only === undefined ? OPS : OPS.filter(op => op.key === opts.only);
package/src/new.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `ojm new <name>` —— 脚手架内快速生成用户方案骨架(多方案 phase 3,设计 v2 §C)。
3
+ *
4
+ * 生成 module-root 布局的 `src/<name>/{api,web}` 最小骨架:solution.yaml(方案契约,
5
+ * `ojm build --bundle` 入口)+ web(entry.ts 示例路由 / 占位页 / 双语 locales)+
6
+ * 可选 api(oj 七键 manifest + ping 示例 handler)。生成物补产物后即过打包校验
7
+ * (round-trip 承诺,N1 用例钉死)。
8
+ *
9
+ * 前置守卫(全 fail-fast,顺序即排查顺序):
10
+ * 1. N0 非 ojm 工程(无 web.config.ts / src/)→ 指向 `ojm init`;
11
+ * 2. N6 历史布局(new/flat)→ 拒绝 + 迁移指引(生成 module-root 骨架会触发
12
+ * 双源码树互斥,把 build/dev 打挂——宁可拒绝不留半套);
13
+ * 3. N3 名字四查:kebab-case 正则(与契约 C2-01 同条)→ 保留名 → 框架自带名
14
+ * (`src/.ojm-builtin.json`)→ 目录已存在;
15
+ * 4. N7 原子性:任一步失败 best-effort 删除已建 `src/<name>/`,不留半截目录
16
+ * (半截目录会触发 scanModuleRoot 的坏模块报错)。
17
+ *
18
+ * 刻意不做:web.config.ts 登记——module-root 布局以 `src/<name>/web/entry.ts`
19
+ * 扫描为真源(0.2.20 起登记只是可选排序覆盖),生成即被扫描收录,向用户 TS 源文件
20
+ * 做文本插入既脆弱又零收益(六方评审一致结论)。
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { readBuiltinModules } from "./builtin";
26
+ import { tryResolveLayout } from "./layout";
27
+ import { assertValidSolutionName, RESERVED_NAMES } from "./manifest-contract";
28
+
29
+ export interface NewSolutionOptions {
30
+ /** 示例路由布局(默认 container;none = 无外壳裸页面) */
31
+ layout?: "container" | "none"
32
+ /** 同时生成 api 骨架(oj 七键 manifest + ping 示例 handler) */
33
+ api?: boolean
34
+ }
35
+
36
+ export interface NewSolutionResult {
37
+ /** 生成的方案目录(src/<name>) */
38
+ dir: string
39
+ /** 相对工程根的文件清单 */
40
+ files: string[]
41
+ }
42
+
43
+ export function createSolution(projectRoot: string, name: string, opts: NewSolutionOptions = {}): NewSolutionResult {
44
+ // N0:工程判定(web.config.ts 与 src/ 至少要有一个——空目录跑 new 是最常见误用)
45
+ const hasConfig = fs.existsSync(path.join(projectRoot, "web.config.ts"));
46
+ const hasSrc = fs.existsSync(path.join(projectRoot, "src"));
47
+ if (!hasConfig && !hasSrc) {
48
+ throw new Error(
49
+ `[ojm] ${projectRoot} 不是 ojm 工程(没有 web.config.ts / src/)。`
50
+ + "先 `ojm init` 创建脚手架,再在里面 `ojm new <name>`。",
51
+ );
52
+ }
53
+
54
+ // N6:布局守卫。tryResolveLayout 自带双树互斥/坏目录的人话报错,直接传播;
55
+ // 历史布局(new/flat)拒绝——module-root 骨架落进去会触发互斥,把 build/dev 打挂
56
+ const layout = tryResolveLayout(projectRoot);
57
+ if (layout !== null && layout.kind !== "module-root") {
58
+ throw new Error(
59
+ `[ojm] ojm new 只支持 module-root 布局工程(src/<name>/{api,web}),当前为 ${layout.kind} 历史布局。`
60
+ + "在历史布局工程里生成骨架会触发两套源码树互斥、把 build/dev 打挂,故拒绝。"
61
+ + "请先迁移布局,或在新脚手架工程(ojm init)里使用。",
62
+ );
63
+ }
64
+
65
+ // N3:名字四查
66
+ assertValidSolutionName(name, "ojm new <name>");
67
+ if (RESERVED_NAMES.has(name)) {
68
+ throw new Error(
69
+ `[ojm] 「${name}」是保留名(框架功能命名空间,如 system/auth/market),不能用作方案名。`
70
+ + "换一个不与框架能力撞名的名字。",
71
+ );
72
+ }
73
+ if (readBuiltinModules(projectRoot).has(name)) {
74
+ throw new Error(
75
+ `[ojm] 「${name}」是框架自带方案(见 src/.ojm-builtin.json;ojm info --all 可看全名单)。`
76
+ + "想改造自带模块:`ojm new <新名>` 生成骨架后把代码复制过去(自带身份按目录名判定,复制即脱离)。",
77
+ );
78
+ }
79
+ const dir = path.join(projectRoot, "src", name);
80
+ if (fs.existsSync(dir))
81
+ throw new Error(`[ojm] src/${name}/ 已存在。换个名字,或删掉该目录后重试。`);
82
+
83
+ const shell = opts.layout ?? "container";
84
+ const files: string[] = [];
85
+ try {
86
+ put(projectRoot, files, path.join(dir, "solution.yaml"), solutionYaml(name, opts.api));
87
+ put(projectRoot, files, path.join(dir, "web", "entry.ts"), entryTs(name, shell));
88
+ put(projectRoot, files, path.join(dir, "web", "pages", "index.tsx"), pageTsx(name));
89
+ put(projectRoot, files, path.join(dir, "web", "locales", "zh-CN.json"), `${JSON.stringify({ menu: { main: `${name} 方案` } }, null, "\t")}\n`);
90
+ put(projectRoot, files, path.join(dir, "web", "locales", "en-US.json"), `${JSON.stringify({ menu: { main: name } }, null, "\t")}\n`);
91
+ if (opts.api) {
92
+ put(projectRoot, files, path.join(dir, "api", "manifest.yaml"), apiManifest(name));
93
+ put(projectRoot, files, path.join(dir, "api", "api.ts"), apiTs(name));
94
+ }
95
+ }
96
+ catch (error) {
97
+ // N7:不留半截目录(scanModuleRoot 会把「有 api/ 或 web/ 但缺标记」的目录判成坏模块)
98
+ fs.rmSync(dir, { recursive: true, force: true });
99
+ throw error;
100
+ }
101
+ return { dir, files };
102
+ }
103
+
104
+ function put(projectRoot: string, files: string[], p: string, content: string): void {
105
+ fs.mkdirSync(path.dirname(p), { recursive: true });
106
+ fs.writeFileSync(p, content);
107
+ files.push(path.relative(projectRoot, p));
108
+ }
109
+
110
+ function solutionYaml(name: string, withApi: boolean | undefined): string {
111
+ const api = withApi
112
+ ? [
113
+ "api:",
114
+ ` module: ${name}`,
115
+ ` prefix: /api/${name}`,
116
+ "",
117
+ ].join("\n")
118
+ : "";
119
+ return `# 方案契约:ojm build --bundle 的入口(字段规范见 devkit 手册 §11.1;
120
+ # 优先级:本文件 > 工程根 solution.yaml > 同名 manifest.yaml 回退)。
121
+ name: ${name}
122
+ version: 0.1.0
123
+ title: ${name}
124
+ summary: ${name} 方案(ojm new 生成的骨架,替换为你的描述)
125
+ changelog:
126
+ - 0.1.0 首个版本(ojm new 生成)
127
+ # 市场验包五必填(market verify structureCheck):peerRuntime 对宿主运行时强制比对(C2-09),
128
+ # archive 为 Form A 占位(tgz 内仅允许 file 键,C2-29;发布侧由 publish 填 Form B)。
129
+ peerRuntime: ">=0.0.0"
130
+ archive:
131
+ file: ${name}-0.1.0.tgz
132
+ ${api}web:
133
+ route_prefix: /${name}
134
+ entry: modules/${name}/0.1.0/entry.js
135
+ `;
136
+ }
137
+
138
+ function entryTs(name: string, shell: "container" | "none"): string {
139
+ return `import { defineModule } from "@oj-module/runtime";
140
+ import { lazy } from "react";
141
+
142
+ const Index = lazy(() => import("./pages/index"));
143
+
144
+ /**
145
+ * ${name} 方案:\`ojm new\` 生成的最小骨架。
146
+ * 菜单顺序由 handle.order 决定(≥0 主菜单区升序;<0 底部固定区);
147
+ * 标题键对应 locales/<lang>.json 的 menu.main。
148
+ */
149
+ export default defineModule({
150
+ name: "${name}",
151
+ description: "${name} 方案",
152
+ version: "0.1.0",
153
+ peerRuntime: ">=0.0.0",
154
+ routes: [
155
+ {
156
+ path: "/${name}",
157
+ handle: { layout: "${shell}", order: 200, title: "${name}:menu.main" },
158
+ children: [
159
+ {
160
+ index: true,
161
+ Component: Index,
162
+ handle: { title: "${name}:menu.main" },
163
+ },
164
+ ],
165
+ },
166
+ ],
167
+ i18n: {
168
+ "zh-CN": () => import("./locales/zh-CN.json"),
169
+ "en-US": () => import("./locales/en-US.json"),
170
+ },
171
+ });
172
+ `;
173
+ }
174
+
175
+ function pageTsx(name: string): string {
176
+ return `import { BasicContent } from "@oj-module/runtime";
177
+ import { Card, Typography } from "antd";
178
+
179
+ /** ${name} 方案首页:\`ojm new\` 生成的占位页,替换为你的实现。 */
180
+ export default function IndexPage() {
181
+ return (
182
+ <BasicContent>
183
+ <Card>
184
+ <Typography.Title level={4} style={{ marginTop: 0 }}>
185
+ ${name}
186
+ </Typography.Title>
187
+ <Typography.Text type="secondary">
188
+ 由 ojm new 生成的方案骨架——从这里开始你的实现。
189
+ </Typography.Text>
190
+ </Card>
191
+ </BasicContent>
192
+ );
193
+ }
194
+ `;
195
+ }
196
+
197
+ function apiManifest(name: string): string {
198
+ return `# oj 原生七键清单:oj 按 src/<name>/api/manifest.yaml 枚举发现后端模块,
199
+ # 与方案契约(上级 solution.yaml)是两条互不干扰的枚举(devkit 手册 §1.2)。
200
+ name: ${name}
201
+ desc: ${name} 方案后端(ojm new 生成的骨架)
202
+ version: 0.1.0
203
+ tables: []
204
+ `;
205
+ }
206
+
207
+ function apiTs(name: string): string {
208
+ // 形状契约:oj 内省只读 default export、链尾须为 HTTP 动词——ping 做成嵌套组
209
+ // (链 ping.get → GET /api/<name>/ping);裸命名导出不进 default 会产出空 routes.js
210
+ // (20261023 市场链路演练实踩)。
211
+ return `// GET /api/${name}/ping —— 健康探针示例(保存即热更;出参挂平级 .response 即入 openapi.json)。
212
+ const ping = {
213
+ async get() {
214
+ json.ok({ pong: true, module: "${name}" });
215
+ },
216
+ };
217
+
218
+ ping.get.response = {
219
+ type: "object",
220
+ required: ["pong"],
221
+ properties: { pong: { type: "boolean" } },
222
+ };
223
+
224
+ export default { ping };
225
+ `;
226
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 单服务 preview 由 oj 一个进程同时出 API 与静态(web 挂载 + spa: true),
5
5
  * 而 spa 只能来自 config 文件(CLI --app-path 的 upsert 保留但不设置
6
- * spa——实测 oj 0.1.80)。用户在模板里手改 config 不可取(web/dist 缺失时
6
+ * spa——实测 oj 0.1.80)。用户在模板里手改 config 不可取(dist/web 缺失时
7
7
  * oj serve 整体拒启,会连带挂掉 ojm dev),故 preview 现场生成一份
8
8
  * 派生 config:
9
9
  *
@@ -37,7 +37,7 @@ export interface DerivePreviewConfigOptions {
37
37
  */
38
38
  prefix?: string
39
39
  /**
40
- * release 形态的后端模块根(api/dist)。给了且原 config 声明了 platform.modules 时,
40
+ * release 形态的后端模块根(layout.apiDist = dist/api)。给了且原 config 声明了 platform.modules 时,
41
41
  * 把 `platform.root` 一并改写到该目录——特权模块的受控根必须与 `--api-path` 同源,
42
42
  * 否则 release 下 reloadConfig 会按 dev 的 src 判定归属(装好的方案热更不生效)。
43
43
  */
package/src/preview.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * **单进程同时出 API 与静态**)。ojm 不再自建 http server、不再反代——
7
7
  * 前后端同端口同进程,与「oj serve + web 挂载」的生产公式逐字同形。
8
8
  *
9
- * 派生 config 为临时产物(api/.ojm-preview.yaml,与原件同目录保证相对
10
- * 路径解析一致),三路清理:ready 拒绝路径 / SIGINT / process exit 兜底
9
+ * 派生 config 为临时产物(module-root:工程根 .ojm-preview.yaml,与原件同目录
10
+ * 保证相对路径解析一致;历史布局才是 api/.ojm-preview.yaml),三路清理:ready
11
+ * 拒绝路径 / SIGINT / process exit 兜底
11
12
  * (语义与落盘见 preview-config.ts)。
12
13
  *
13
14
  * web.config.ts 的 htmlTransform 是 serve 期钩子,单服务下 oj 直出 HTML
@@ -59,7 +60,7 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
59
60
  const siteDir = opts.siteDir ?? layout.distDir;
60
61
  const ojBin = resolveOjBin(projectRoot);
61
62
  const configPath = resolveConfigPath(projectRoot);
62
- // release 根恒为扁平 api/dist(module-root 只改源码布局,不改产物形状——设计 C-3/C-7)
63
+ // release 根恒为扁平 dist/api(module-root 只改源码布局,不改产物形状——设计 C-3/C-7)
63
64
  const apiDist = layout.apiDist;
64
65
 
65
66
  // fail-fast 四查:缺一即提示对应补救(init 幂等补缺 / ojm build 产物)
@@ -89,7 +90,7 @@ export async function runPreview(projectRoot: string, opts: PreviewOptions = {})
89
90
  // 派生 config 在 migrate 成功后落盘:失败即退路径零残留
90
91
  const base = readOjApiPrefix(configPath);
91
92
  const writer = opts.configWriter ?? ((root: string, o: { siteDir: string, prefix?: string, port?: number, apiRoot?: string }) => writePreviewConfig(root, o));
92
- // apiRoot 交给派生器:release 下 platform.root 必须指向 api/dist(与 --api-path 同源)
93
+ // apiRoot 交给派生器:release 下 platform.root 必须指向 dist/api(与 --api-path 同源)
93
94
  const preview = writer(projectRoot, { siteDir, prefix: sitePrefix, port: opts.port, apiRoot: apiDist });
94
95
 
95
96
  // / api 挂载与注入的 web 挂载配对时 oj 不开 SPA 回落(oj 语义,见 devkit 手册 §13)。
package/src/publish.ts CHANGED
@@ -126,25 +126,3 @@ export function publishCommand(dir: string, opts: PublishOptions = {}): PublishR
126
126
  }
127
127
  return { bundlesPath, manifestsPath, bundlesSigPath, manifestsSigPath, keyId, publicKeyHex, fingerprint: fingerprintOfPublicKey(publicKeyHex), checkPassed };
128
128
  }
129
-
130
- /** `ojm publish <dir> [--id <publisher.id>] [--key <pem>] [--check]` 参数解析 */
131
- export function parsePublishArgs(argv: string[]): { positionals: string[], id?: string, key?: string, check?: boolean } {
132
- const values: Record<string, string> = {};
133
- let check = false;
134
- const positionals: string[] = [];
135
- for (let i = 0; i < argv.length; i++) {
136
- const a = argv[i]!;
137
- if (a === "--check") {
138
- check = true;
139
- continue;
140
- }
141
- if (a === "--id" || a === "--key") {
142
- values[a] = argv[i + 1] ?? "";
143
- i++;
144
- continue;
145
- }
146
- if (!a.startsWith("--"))
147
- positionals.push(a);
148
- }
149
- return { positionals, id: values["--id"], key: values["--key"], check };
150
- }
package/src/sign.ts CHANGED
@@ -104,28 +104,6 @@ export function signCommand(tgz: string, opts: SignOptions = {}): SignResult {
104
104
  return { sigPath, keyId, publicKeyHex, fingerprint: fingerprintOfPublicKey(publicKeyHex), sha256, canonical, checkPassed };
105
105
  }
106
106
 
107
- /** `ojm sign <tgz> [--id <publisher.id>] [--key <pem>] [--check]` 参数解析 */
108
- export function parseSignArgs(argv: string[]): { positionals: string[], id?: string, key?: string, check?: boolean } {
109
- const values: Record<string, string> = {};
110
- let check = false;
111
- const positionals: string[] = [];
112
- for (let i = 0; i < argv.length; i++) {
113
- const a = argv[i]!;
114
- if (a === "--check") {
115
- check = true;
116
- continue;
117
- }
118
- if (a === "--id" || a === "--key") {
119
- values[a] = argv[i + 1] ?? "";
120
- i++;
121
- continue;
122
- }
123
- if (!a.startsWith("--"))
124
- positionals.push(a);
125
- }
126
- return { positionals, id: values["--id"], key: values["--key"], check };
127
- }
128
-
129
107
  /** 身份从文件名推导(<name>-<version>.tgz;与 ojm install parseTarget 同规则,Finding C 正则) */
130
108
  function deriveIdentity(stem: string): { name: string, version: string } {
131
109
  const m = /^(.+)-(\d[\w.]+(?:-[\w.]+)?)$/.exec(stem);