@oj-module/cli 0.2.15 → 0.2.17

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.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 站点部署 base(子路径部署,2026-10-21)。
3
+ *
4
+ * 问题:`ojm build` 产出的全站(shell-dist 拷来的 index.html + assets/)里,
5
+ * 所有 URL 都是**站点根绝对路径**(`/assets/react.js`、`/favicon.ico`)。站点
6
+ * 只要不挂在域名根(oj `mounts: [{ prefix: /oj, web: dist, spa: true }]`),
7
+ * 浏览器就会去宿主机根取资源 → 全部 404 → 首页白屏;同时 react-router 的
8
+ * basename 也必须是 `/oj`,否则深链刷新匹配不到任何路由。
9
+ *
10
+ * 解法(两件事,都在构建期一次性完成):
11
+ * 1. 重写 index.html 里的站内绝对 URL,统一加上 `web.config.ts` 的 `siteBase`;
12
+ * 2. 注入 `window.__OJ_BASE__`,供 runtime 与 shell 在运行期取真值
13
+ * (`@oj-module/runtime` 的 `resolveSiteBase()`,见其 utils/site-base.ts)。
14
+ *
15
+ * 为什么不用 `<base href>`:shell 的 CSP 含 `base-uri 'none'`(P6.2),
16
+ * 而且 `<base>` 会连带改变所有相对 URL 的解析,语义过宽。
17
+ *
18
+ * 为什么不用运行期改写:oj 只做静态直出(web 挂载),没有 HTML 模板层;
19
+ * 构建期是唯一能同时改 HTML 与 URL 的时点。
20
+ *
21
+ * 未配置 `siteBase`(默认)时本模块**逐字节返回原 HTML**——根路径部署的产物
22
+ * 与 0.2.16 完全一致,零回归风险。
23
+ */
24
+
25
+ const ROOT_BASE = "/";
26
+
27
+ /** 归一化为「以 `/` 开头且以 `/` 结尾」;空值与 `"/"` 归为根路径 */
28
+ export function normalizeSiteBase(raw: string | null | undefined): string {
29
+ if (!raw)
30
+ return ROOT_BASE;
31
+ const trimmed = raw.trim();
32
+ if (!trimmed || trimmed === ROOT_BASE)
33
+ return ROOT_BASE;
34
+ const withLeading = trimmed.startsWith(ROOT_BASE) ? trimmed : `${ROOT_BASE}${trimmed}`;
35
+ return withLeading.endsWith(ROOT_BASE) ? withLeading : `${withLeading}${ROOT_BASE}`;
36
+ }
37
+
38
+ /** 站内绝对路径加 base(已加过的幂等;`//host`、`http(s)://` 等原样返回) */
39
+ export function withSiteBase(url: string, base: string): string {
40
+ if (!url.startsWith(ROOT_BASE) || url.startsWith("//"))
41
+ return url;
42
+ const normalized = normalizeSiteBase(base);
43
+ if (normalized === ROOT_BASE || url.startsWith(normalized))
44
+ return url;
45
+ return `${normalized.slice(0, -1)}${url}`;
46
+ }
47
+
48
+ /** importmap 里所有映射值加 base(保留 JSON 值类型与顺序) */
49
+ function rewriteImportmap(html: string, base: string): string {
50
+ return html.replace(
51
+ /(<script type="importmap"[^>]*>)([\s\S]*?)(<\/script>)/,
52
+ (full, open: string, body: string, close: string) => {
53
+ let parsed: { imports?: Record<string, string> };
54
+ try {
55
+ parsed = JSON.parse(body);
56
+ }
57
+ catch {
58
+ // 不是合法 JSON:宁可让构建失败,也不静默产出会白屏的 HTML
59
+ throw new Error("[ojm] index.html 的 importmap 不是合法 JSON,无法按 siteBase 重写");
60
+ }
61
+ if (!parsed.imports)
62
+ return full;
63
+ const imports: Record<string, string> = {};
64
+ for (const [specifier, url] of Object.entries(parsed.imports))
65
+ imports[specifier] = withSiteBase(url, base);
66
+ return `${open}${JSON.stringify({ ...parsed, imports }, null, 2)}${close}`;
67
+ },
68
+ );
69
+ }
70
+
71
+ /**
72
+ * 重写全站 index.html:
73
+ * - importmap 映射值、`<script src>`、`<link href>` 统一加 `siteBase`;
74
+ * - `<head>` 内注入 `window.__OJ_BASE__`(**复用 importmap 的 nonce**,
75
+ * CSP 是 `script-src 'self' … 'nonce-xxx'`,无 nonce 的内联脚本会被直接拦掉)。
76
+ */
77
+ export function rewriteIndexHtml(html: string, siteBase: string | undefined): string {
78
+ const base = normalizeSiteBase(siteBase);
79
+ // 根路径部署:原样返回(保持与既有产物逐字节一致)
80
+ if (base === ROOT_BASE)
81
+ return html;
82
+
83
+ const nonce = html.match(/<script type="importmap"[^>]*\snonce="([^"]*)"/)?.[1];
84
+ if (!nonce) {
85
+ throw new Error(
86
+ "[ojm] 站点 index.html 缺少带 nonce 的 importmap,无法注入 window.__OJ_BASE__"
87
+ + "(CSP 会拦掉无 nonce 的内联脚本,子路径部署下路由 basename 会退化成 \"/\")。"
88
+ + "请确认 shell-dist 是最新构建产物(pnpm build:shell)。",
89
+ );
90
+ }
91
+
92
+ let out = rewriteImportmap(html, base);
93
+ // 属性值:只要以单个 "/" 开头(排除 "//host")。importmap 是脚本正文、非属性,
94
+ // 上一步已处理,二者不重叠。
95
+ out = out.replace(/(\b(?:href|src)=")(\/[^/"][^"]*|\/")/g, (_m, attr: string, url: string) =>
96
+ `${attr}${withSiteBase(url, base)}`);
97
+
98
+ const injected = `<script nonce="${nonce}">window.__OJ_BASE__=${JSON.stringify(base)};</script>`;
99
+ // 注入点选在 importmap 之前:importmap 本身必须在任何 module script 之前,
100
+ // 而内联 classic script 是解析期同步执行,因此宿主 main 与模块 entry 读
101
+ // `window.__OJ_BASE__` 时它一定已经在位。
102
+ return out.replace("<script type=\"importmap\"", `${injected}\n\t\t<script type="importmap"`);
103
+ }
package/src/usage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `ojm` 用法文本(抽为纯函数供测试断言;index.ts 仅负责打印与退出码)。
3
3
  */
4
+ import process from "node:process";
4
5
 
5
6
  export function usageText(): string {
6
7
  return `@oj-module/cli
@@ -89,3 +90,121 @@ export function usageText(): string {
89
90
  例:ojm test --anonymous / ojm migrate --db report / ojm openapi --check
90
91
  `;
91
92
  }
93
+
94
+ /**
95
+ * 单命令帮助文本(供 `ojm <cmd> --help` / `ojm help <cmd>` 打印)。
96
+ * key 可为「命令」或「命令 子命令」(market 的子命令)。查不到具体子命令时回退到命令级。
97
+ * 内容与 usageText 同源,但按命令拆分以便独立打印。
98
+ */
99
+ const COMMAND_HELP: Record<string, string> = {
100
+ "init": `ojm init [dir] [--yes]
101
+ 前后端一体化工程脚手架(幂等补缺:重跑只补缺失文件,永不覆盖已有文件)`,
102
+ "dev": `ojm dev [port]
103
+ 启动开发服务器(/api 反代 oj + 模块重建 + SSE 刷新)
104
+ 开发模式快捷键(仅 TTY 终端,类 Vite):
105
+ r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开
106
+ q 退出(后端 oj 一并终止)
107
+ port 缺省 5174(oj 后端端口以 api/config.yaml 的 server.port 为准)`,
108
+ "build": `ojm build [--bundle] [--out <dir>]
109
+ 构建后端(oj build)与模块产物(含全站合并)
110
+ --bundle 方案包构建:manifest.yaml 契约校验(fail-fast)→ 确定性 tgz
111
+ → dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)
112
+ --out <dir> --bundle 输出目录(缺省 dist/)`,
113
+ "preview": `ojm preview [port]
114
+ 生产形态预览(migrate → oj serve 单服务:API 与静态 SPA 同进程,深链回落)
115
+ 静态由 oj web 挂载直出;ojm 不起第二个服务、不做反代
116
+ port 缺省取 config 的 server.port`,
117
+ "api": `ojm api [dir] [--check] [--docs] [--exempt <path>]
118
+ 契约代码生成(client / routes / openapi / stub)
119
+ --check 四重对账(生成物同步 / route 双向 / routes.js / WS 路由鉴权)
120
+ --exempt <path> 指定豁免清单,覆盖默认 api/.ojm-api-exempt.json
121
+ --docs 出自包含文档站(单文件离线可看)
122
+ dir 缺省当前目录;显式目录须先存在`,
123
+ "info": `ojm info
124
+ 输出版本矩阵与模块清单(报障用)`,
125
+ "vendor": `ojm vendor [tag] [--force]
126
+ 下载/重装 oj vendor(经 npm 包 @oj-bin/oj;tag 缺省取最新,形如 v0.1.13)
127
+ --force 即使已存在也重新下载`,
128
+ "merge": `ojm merge <out.json> <in1.json> [in2.json ...]
129
+ 合并多团队清单(清单先后 = 接管优先级)`,
130
+ "install": `ojm install <file.tgz | name[@version]> [--url <u>] [--user <u>] [--pass <p>] [--name <n>] [--version <v>] [--locale <zh-CN|en-US>]
131
+ 安装方案包到目标站点:本地 tgz 走 file 上传(<name>-<version>.tgz 文件名推导身份,可 --name/--version 覆盖);name[@version] 走 CDN 市场源
132
+ 登录 → 提交 → 轮询任务至终态并打印结果(失败附原因 + 指引,--locale 切双语)
133
+ 站点寻址:--url/--user/--pass,或环境变量 OJM_URL / OJM_USER / OJM_PASS`,
134
+ "enable": `ojm enable|disable <name> [--url <u>] [--user <u>] [--pass <p>]
135
+ 启用/禁用已装方案(四处同写:锁 / sidecar / 路由清单 / 模块清单)
136
+ 站点寻址同上`,
137
+ "disable": `ojm enable|disable <name> [--url <u>] [--user <u>] [--pass <p>]
138
+ 启用/禁用已装方案(四处同写:锁 / sidecar / 路由清单 / 模块清单)
139
+ 站点寻址同上`,
140
+ "uninstall": `ojm uninstall <name> [--url <u>] [--user <u>] [--pass <p>]
141
+ 卸载已装方案(四处同删 + 权限级联清理 + 版本目录移除;站点至少保留一个已锁模块)
142
+ 站点寻址同上`,
143
+ "trust": `ojm trust ls|add|revoke|confirm [--url <u>] [--user <u>] [--pass <p>]
144
+ 发布方信任库管理(站点 .solutions/trusted-keys.yaml):
145
+ ls <key_id 前缀?> 列出已信任 key(含 sha256 指纹分组展示)
146
+ add <key_id> <pub> 新增信任(64 hex 公钥;首次绑定不可改)
147
+ revoke <key_id> 吊销(阻断新装/升级,不动已装方案)
148
+ confirm --task <id> TOFU 确认:把 needs_trust 终态待确认 key 入库
149
+ 站点寻址:--url/--user/--pass,或环境变量 OJM_URL / OJM_USER / OJM_PASS`,
150
+ "trust ls": `ojm trust ls [key_id 前缀?] [--url <u>] [--user <u>] [--pass <p>]
151
+ 列出已信任 key(含 sha256 指纹分组展示)`,
152
+ "trust add": `ojm trust add <key_id> <pub> [--url <u>] [--user <u>] [--pass <p>]
153
+ 新增信任(64 hex 公钥;首次绑定不可改)`,
154
+ "trust revoke": `ojm trust revoke <key_id> [--url <u>] [--user <u>] [--pass <p>]
155
+ 吊销(阻断新装/升级,不动已装方案)`,
156
+ "trust confirm": `ojm trust confirm --task <id> [--url <u>] [--user <u>] [--pass <p>]
157
+ TOFU 确认:把 needs_trust 终态待确认 key 入库`,
158
+ "keygen": `ojm keygen --id <publisher.id> [--out <dir>] [--yes]
159
+ 生成 Ed25519 签名密钥对:私钥默认 ~/.ojm/keys/<id>/ 永不出目录(--out 指到工程树内须 --yes 显式确认——私钥入仓即泄露);打印公钥 hex 供 ojm trust add / 模板回填`,
160
+ "sign": `ojm sign <file.tgz> [--id <publisher.id>] [--key <pem>] [--check]
161
+ detached 侧车签名:产出 <tgz>.sig(ojm install 自动探测随包上传)
162
+ 私钥缺省取 ~/.ojm/keys/<id>/private.pem(--key 指定 pem 时须同时 --id)
163
+ --check 离线自检签名(不必靠宿主装失败发现签错)`,
164
+ "publish": `ojm publish <dir> [--id <key>] [--key <pem>] [--check]
165
+ 索引发布:重算 <dir>/bundles.yaml + manifests.yaml(确定性 stringify,消弭格式漂移)→ 对两份索引各产出 detached 侧车签名 <kind>.yaml.sig(与 archive .sig 同构,公钥取 ~/.ojm/keys/<id>/private.pem,缺省 --id 推导)→ 写回 <dir>
166
+ --check 离线自检两份索引签名`,
167
+ "market": `ojm market <subcommand> [opts]
168
+ 本地模拟 CDN 与市场链路子命令:setup / publish / build-index / verify / trial
169
+ 详见各子命令帮助:ojm market setup --help 等`,
170
+ "market setup": `ojm market setup [--url <站点地址>] [--user <u> --pass <p>] [--new-pass <p>]
171
+ 把系统设置 framework.market_cdn_url 指向本地模拟 CDN(工程根 cdn/,由 api/config.yaml 的 /cdn 挂载直出)。首跑若账号处于首次改密期(seed admin must_change=1),会自动改密换全权 token 后再写入`,
172
+ "market publish": `ojm market publish [--fixture <name>@<version>] [--real <方案工程>] [--id <key>] [--key <pem>] [--rebuild-index] [--check]
173
+ 把方案包落进 cdn/:合成 fixture(或 --real 走 ojm build --bundle)→ 确定性 tgz → 包级 detached 签名 <name>-<version>.tgz.sig
174
+ 不碰索引(索引由 build-index 扫描重建,多人发布才不会互相覆盖)`,
175
+ "market build-index": `ojm market build-index [--id <key>] [--key <pem>] [--check]
176
+ 扫描 cdn/*.tgz、提取包内 manifest,全量重建 bundles.yaml + manifests.yaml 并签名
177
+ 结果只取决于目录内容:连续执行字节一致`,
178
+ "market verify": `ojm market verify [--file <tgz>] [--rebuild-index]
179
+ 离线验包(不起站点、不装、不要凭据):sha256 完整性 / 包级 Ed25519 验签 / 索引验签 / manifest 结构分列输出
180
+ --rebuild-index 额外比对「扫描重建的索引」与盘上索引,检出落了包却没重建的漂移
181
+ 注意:结论恒带边界声明——校验通过只说明来源与完整性,不构成内容安全性背书`,
182
+ "market trial": `ojm market trial [--fixture <name>@<version>] [--keep]
183
+ 在一次性隔离站点(随机端口 + 一次性 DB/密钥/凭据/信任库/CDN)里跑完整安装,跑完即毁;--keep 保留现场供排查。用于确认「这个包到底能不能装」,不污染你的工程`,
184
+ };
185
+
186
+ /** 取单命令帮助;返回 undefined 表示未知命令。 */
187
+ export function getCommandHelp(command: string, subcommand?: string): string | undefined {
188
+ if (subcommand) {
189
+ const specific = COMMAND_HELP[`${command} ${subcommand}`];
190
+ if (specific)
191
+ return specific;
192
+ }
193
+ return COMMAND_HELP[command];
194
+ }
195
+
196
+ /**
197
+ * 解析「帮助请求」:`ojm <cmd> --help` / `ojm <cmd> -h` / `ojm help <cmd>` 三种形态。
198
+ * 返回 null 表示本次调用不是帮助请求;否则带出要查的命令与子命令(可能没有)。
199
+ * 抽为纯逻辑(不碰 process.exit、不读模块外状态)便于单测。
200
+ */
201
+ export function resolveHelpRequest(argv: string[] = process.argv.slice(2)): { cmd?: string, sub?: string } | null {
202
+ const helpRequested = argv.some(a => a === "--help" || a === "-h") || argv[0] === "help";
203
+ if (!helpRequested)
204
+ return null;
205
+ const positional = argv.filter(a => a !== "--help" && a !== "-h" && !a.startsWith("-"));
206
+ if (positional[0] === "help")
207
+ positional.shift();
208
+ const [cmd, sub] = positional;
209
+ return { cmd, sub };
210
+ }
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.15",
12
- "@oj-module/runtime/contract": "0.2.15",
13
- "@oj-module/runtime/contract/errors": "0.2.15",
11
+ "@oj-module/runtime": "0.2.17",
12
+ "@oj-module/runtime/contract": "0.2.17",
13
+ "@oj-module/runtime/contract/errors": "0.2.17",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",