@oj-module/cli 0.2.66 → 0.2.68
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.
- package/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +38 -4
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +146 -24
- package/devkit/menu-rules.md +19 -4
- package/devkit/module-dev-and-cdn-playground.md +4 -0
- package/devkit/publish-walkthrough.md +132 -37
- package/devkit/wecom-internal-app.md +8 -3
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +31 -0
- package/src/__tests__/new-scaffold.test.ts +13 -0
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/build/module-def.ts +1 -0
- package/src/builtin.ts +3 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +21 -5
- package/src/dev.ts +15 -10
- package/src/init.ts +47 -41
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +8 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/new.ts +2 -0
- package/src/scaffold-guard.ts +227 -0
- package/src/site.ts +2 -0
- package/src/usage.ts +13 -9
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +10 -3
- package/templates/openapi.json +460 -1
- package/templates/src/README.md +37 -0
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/register.spec.ts +22 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/rbac-solution-perms.spec.ts +48 -3
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +8 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
package/src/manifest-contract.ts
CHANGED
|
@@ -297,8 +297,8 @@ export function validateContract(m: unknown): string[] {
|
|
|
297
297
|
}
|
|
298
298
|
if (api.tables !== undefined) {
|
|
299
299
|
const t = api.tables;
|
|
300
|
-
if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,
|
|
301
|
-
v0.push(v("C2-12a", "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,
|
|
300
|
+
if (!Array.isArray(t) || !t.every(x => typeof x === "string" && /^[a-z][a-z0-9_]{0,62}$/.test(x))) {
|
|
301
|
+
v0.push(v("C2-12a", "api.tables 每项必须匹配 ^[a-z][a-z0-9_]{0,62}$ 的字符串(≤63 字符:PostgreSQL 标识符上限 63 字节、MySQL 64 字符,取最严值;与 api/src/<name>/schema.yaml 表集逐一相等另行对照)"));
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
if (api.deps !== undefined) {
|
|
@@ -682,30 +682,113 @@ export function checkLocalesComplete(m: unknown, zhText: string, enText: string)
|
|
|
682
682
|
return out;
|
|
683
683
|
}
|
|
684
684
|
|
|
685
|
+
/** C2-12c prefix 语法:小写字母开头,仅小写字母/数字/下划线,且以 `_` 结尾 */
|
|
686
|
+
const TABLE_PREFIX_RE = /^[a-z][a-z0-9_]*_$/;
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 第一方保留的表前缀段:oj 引擎 / ojm CLI 的命名空间。
|
|
690
|
+
*
|
|
691
|
+
* 框架表(_platform / auth / home / notification 下的 users / sys_* / wecom_* 等)保持裸名,
|
|
692
|
+
* 是保留的框架命名空间;方案表名一律带作者自选前缀,与框架表名天然不重叠。把 `oj_` / `ojm_`
|
|
693
|
+
* 整段留给第一方,防止方案先占用、日后框架想用时反被占(S002 归属图以表名为全局主键)。
|
|
694
|
+
* 按「段」保留(startsWith),不只禁精确值——整个命名空间都归第一方。
|
|
695
|
+
* 注:下划线开头的 `_oj_`(oj 内建账本 `_oj_migrations`)本就不满足 TABLE_PREFIX_RE,无需列举。
|
|
696
|
+
*/
|
|
697
|
+
const RESERVED_TABLE_PREFIX_SEGMENTS = ["oj_", "ojm_"];
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* 跨库可移植的表名上限:PostgreSQL 标识符上限 63 **字节**(NAMEDATALEN-1),
|
|
701
|
+
* MySQL 是 64 字符、SQLite 无实际限制——取最严的 63。表名字符集限定 ASCII,
|
|
702
|
+
* 字符数 = 字节数,因此 63 字符在三种库上都安全。
|
|
703
|
+
*/
|
|
704
|
+
const MAX_TABLE_NAME_LEN = 63;
|
|
705
|
+
|
|
706
|
+
/** 前缀上限:给表名留 ≥31 字符(63 - 32),避免前缀把表名的可用空间吃光 */
|
|
707
|
+
const MAX_PREFIX_LEN = 32;
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* C2-12c `schema.yaml` 的 `prefix` 与逐表前缀校验。
|
|
711
|
+
*
|
|
712
|
+
* 表名在**全库共享**(oj 归属图以表名为主键),前缀把模块归属编码进名字,
|
|
713
|
+
* 让两个独立发布的方案不会因为都叫 `note` 而在安装期 S002 拒启。
|
|
714
|
+
* 前缀由作者选择(短、可读、尽量全局唯一),不强制由模块名派生——长模块名
|
|
715
|
+
* 可以用 `crm_` 之类短前缀,避免表名被模块名拖到数据库上限之外。
|
|
716
|
+
*
|
|
717
|
+
* 入参为 schema.yaml 文本(fs 在调用方)。YAML 解析失败返回 []——那是 C2-12a
|
|
718
|
+
* 的判罚面,同一事故不重复报两遍。
|
|
719
|
+
*/
|
|
720
|
+
export function checkSchemaTablePrefix(schemaYamlText: string): string[] {
|
|
721
|
+
let doc: unknown;
|
|
722
|
+
try {
|
|
723
|
+
doc = parse(schemaYamlText);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return [];
|
|
727
|
+
}
|
|
728
|
+
if (!isObj(doc) || !isObj(doc.tables))
|
|
729
|
+
return []; // 缺 tables / 非映射由 C2-12a 报
|
|
730
|
+
|
|
731
|
+
const keys = Object.keys(doc.tables);
|
|
732
|
+
const out: string[] = [];
|
|
733
|
+
const prefix = doc.prefix;
|
|
734
|
+
|
|
735
|
+
if (prefix === undefined) {
|
|
736
|
+
out.push(v("C2-12c", "schema.yaml 缺 prefix:必须声明表前缀(如 prefix: crm_;小写字母开头、以 _ 结尾、≤32 字符,建议与方案名相关且全局唯一);表名在全库共享,前缀把归属编码进名字,避免方案之间撞表"));
|
|
737
|
+
}
|
|
738
|
+
else if (typeof prefix !== "string" || !TABLE_PREFIX_RE.test(prefix)) {
|
|
739
|
+
out.push(v("C2-12c", `schema.yaml 的 prefix「${String(prefix)}」必须匹配 ^[a-z][a-z0-9_]*_$(小写字母开头、仅小写字母/数字/下划线、以 _ 结尾)`));
|
|
740
|
+
}
|
|
741
|
+
if (typeof prefix === "string" && prefix.length > MAX_PREFIX_LEN) {
|
|
742
|
+
out.push(v("C2-12c", `schema.yaml 的 prefix「${prefix}」长 ${prefix.length} 字符,超过上限 ${MAX_PREFIX_LEN}——表名总长须 ≤${MAX_TABLE_NAME_LEN},前缀过长会吃掉表名可用空间;请改用更短的方案前缀(如 crm_)`));
|
|
743
|
+
}
|
|
744
|
+
if (typeof prefix === "string" && TABLE_PREFIX_RE.test(prefix)) {
|
|
745
|
+
const seg = RESERVED_TABLE_PREFIX_SEGMENTS.find(s => prefix.startsWith(s));
|
|
746
|
+
if (seg !== undefined) {
|
|
747
|
+
out.push(v("C2-12c", `schema.yaml 的 prefix「${prefix}」落在第一方保留段「${seg}*」——oj 引擎 / ojm CLI 的表命名空间由框架独占,方案不得占用,请改用自有前缀(如 crm_、acme_)`));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (typeof prefix === "string" && prefix !== "") {
|
|
752
|
+
const offenders = keys.filter(k => !k.startsWith(prefix));
|
|
753
|
+
if (offenders.length > 0) {
|
|
754
|
+
out.push(v("C2-12c", `表名必须以本方案前缀「${prefix}」开头:${offenders.map(k => `「${k}」→「${prefix}${k}」`).join("、")}`));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// 表名语法/总长(引用式方案没有 api.tables 语法列兜底,这里一并查)
|
|
758
|
+
const malformed = keys.filter(k => !/^[a-z][a-z0-9_]{0,62}$/.test(k));
|
|
759
|
+
if (malformed.length > 0) {
|
|
760
|
+
out.push(v("C2-12c", `schema.yaml 表名必须匹配 ^[a-z][a-z0-9_]{0,62}$(小写字母开头,仅小写字母/数字/下划线,≤${MAX_TABLE_NAME_LEN} 字符——PostgreSQL 上限 63 字节、MySQL 64 字符,取最严值):${malformed.join("、")}`));
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
|
|
685
765
|
/**
|
|
686
|
-
* C2-12a api.tables 与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源,不一致 fail-fast
|
|
766
|
+
* C2-12a api.tables 与 `api/src/<name>/schema.yaml` 表集逐一相等(oj 冗余校验同源,不一致 fail-fast);
|
|
767
|
+
* 同时执行 C2-12c 表前缀校验——引用式方案(api.tables 缺省)只要 schema.yaml 存在也必须带前缀。
|
|
687
768
|
* 入参为 schema.yaml 文本(fs 在调用方)。
|
|
688
769
|
*/
|
|
689
770
|
export function checkTablesAgainstSchema(m: unknown, schemaYamlText: string): string[] {
|
|
771
|
+
const prefixViolations = checkSchemaTablePrefix(schemaYamlText);
|
|
772
|
+
|
|
690
773
|
if (!isObj(m) || !isObj(m.api) || m.api.tables === undefined)
|
|
691
|
-
return
|
|
774
|
+
return prefixViolations;
|
|
692
775
|
const declared = m.api.tables;
|
|
693
776
|
if (!Array.isArray(declared))
|
|
694
|
-
return
|
|
777
|
+
return prefixViolations; // 语法违规归 validateContract(C2-12a 语法列),此处只对照表集
|
|
695
778
|
let schemaTables: unknown;
|
|
696
779
|
try {
|
|
697
780
|
const doc = parse(schemaYamlText);
|
|
698
781
|
schemaTables = isObj(doc) ? doc.tables : undefined;
|
|
699
782
|
}
|
|
700
783
|
catch {
|
|
701
|
-
return [v("C2-12a", "schema.yaml 不是合法 YAML,无法对照 api.tables 表集")];
|
|
784
|
+
return [...prefixViolations, v("C2-12a", "schema.yaml 不是合法 YAML,无法对照 api.tables 表集")];
|
|
702
785
|
}
|
|
703
786
|
if (!isObj(schemaTables)) {
|
|
704
|
-
return [v("C2-12a", "schema.yaml 缺 tables 映射,无法对照 api.tables 表集(oj 冗余校验同源)")];
|
|
787
|
+
return [...prefixViolations, v("C2-12a", "schema.yaml 缺 tables 映射,无法对照 api.tables 表集(oj 冗余校验同源)")];
|
|
705
788
|
}
|
|
706
789
|
const schemaKeys = new Set(Object.keys(schemaTables));
|
|
707
790
|
const declaredKeys = new Set(declared.filter(x => typeof x === "string"));
|
|
708
|
-
const out: string[] = [];
|
|
791
|
+
const out: string[] = [...prefixViolations];
|
|
709
792
|
const missingInSchema = [...declaredKeys].filter(k => !schemaKeys.has(k));
|
|
710
793
|
const undeclaredInManifest = [...schemaKeys].filter(k => !declaredKeys.has(k));
|
|
711
794
|
if (missingInSchema.length > 0) {
|
package/src/market/bootstrap.ts
CHANGED
|
@@ -69,6 +69,14 @@ export interface IsolatedSite {
|
|
|
69
69
|
cleanup: () => Promise<void>
|
|
70
70
|
/** 销毁清单(--keep 时给人看这里到底有什么) */
|
|
71
71
|
teardownReport: () => string[]
|
|
72
|
+
/**
|
|
73
|
+
* 重启隔离站点 oj serve(停当前进程,按同一派生 config 重新起站)。
|
|
74
|
+
* 用途:无守护形态安装会落 partial(vars/auth 等冻结段热改属 restart_pending,需进程
|
|
75
|
+
* 重启才激活)。walkthrough 在每次安装后置重启,模拟真实「部署 → 重启」时序,让模块真正
|
|
76
|
+
* 上线(路由/匿名端点注册),使后续深检/升级/重装断言落到活跃态而非空洞的 partial。
|
|
77
|
+
* token 仍有效(jwt_secret 落在 config 文件,重启不变);DB 在同根,会话延续。
|
|
78
|
+
*/
|
|
79
|
+
restart: () => Promise<void>
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
export interface BootstrapOptions {
|
|
@@ -178,8 +186,13 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
178
186
|
// 隔离站点直接预置被测方案的探活路径——与 scripts/verify-solution-smoke/bootstrap-host.mjs 同手法,
|
|
179
187
|
// 让 trial 验的是安装机制本身,而不是重启时序。
|
|
180
188
|
const healthPath = `/${parseFixtureSpec(opts.fixture).name}/ping`;
|
|
181
|
-
|
|
182
|
-
|
|
189
|
+
// 锚点行可能带行内注释与缩进(模板 config.yaml 的 /system/login-methods 即如此),
|
|
190
|
+
// 故用 lookahead 容忍尾注释;命中第一个 anonymous_paths 列表项(auth 守卫读取的那个,
|
|
191
|
+
// 与安装器 appendAnonymousPaths 同目标)即可——旧写法用 `$` 硬锚尾,注释使其失配导致预置
|
|
192
|
+
// 静默失效,安装器随后自己补匿名路径 → auth 进 restart_pending → 无守护形态判 partial。
|
|
193
|
+
const loginAnchor = /^(\s*)- \/system\/login-methods(?=\s*(?:#.*)?$)/m;
|
|
194
|
+
if (loginAnchor.test(cfg) && !cfg.includes(`- ${healthPath}`))
|
|
195
|
+
cfg = cfg.replace(loginAnchor, `$1- /system/login-methods\n$1- ${healthPath}`);
|
|
183
196
|
// fs 段:模板自 0.2.13 起已自带(安装器落盘面);旧模板没有时才补,避免重复键
|
|
184
197
|
if (!/^fs:/m.test(cfg))
|
|
185
198
|
cfg = `${cfg.trimEnd()}\nfs:\n root: .\n readonly: false\n`;
|
|
@@ -237,6 +250,16 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
237
250
|
process.on("SIGINT", onSignal);
|
|
238
251
|
process.on("SIGTERM", onSignal);
|
|
239
252
|
|
|
253
|
+
// 重启:停当前进程后按同一派生 config 重新起站(激活冻结段);cleanup 持有的是本作用域的
|
|
254
|
+
// `oj` 变量引用,故重启后 cleanup 停的是新进程(闭包捕获变量,非值)。
|
|
255
|
+
const restart = async () => {
|
|
256
|
+
log("[ojm] 隔离站点重启 oj serve(激活冻结段 auth/vars)…");
|
|
257
|
+
await oj.stop();
|
|
258
|
+
oj = starter(configPath, "/api", resolveLayout(root).apiDist);
|
|
259
|
+
await oj.ready;
|
|
260
|
+
log(`[ojm] 隔离站点重启就绪:http://127.0.0.1:${port}(根 ${root})`);
|
|
261
|
+
};
|
|
262
|
+
|
|
240
263
|
return {
|
|
241
264
|
root,
|
|
242
265
|
port,
|
|
@@ -249,6 +272,7 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
|
|
|
249
272
|
admin: { user: SEED_ADMIN_USER, pass: SEED_ADMIN_PASS },
|
|
250
273
|
cleanup,
|
|
251
274
|
teardownReport: () => teardownReport(root, port),
|
|
275
|
+
restart,
|
|
252
276
|
};
|
|
253
277
|
}
|
|
254
278
|
|
package/src/market/command.ts
CHANGED
|
@@ -109,6 +109,7 @@ export async function marketCommand(sub: string, argv: string[]): Promise<void>
|
|
|
109
109
|
key: flags.key,
|
|
110
110
|
check: flags.check,
|
|
111
111
|
registryId: flags.registryId,
|
|
112
|
+
rebuildIndex: flags.rebuildIndex === true,
|
|
112
113
|
});
|
|
113
114
|
if (flags.rebuildIndex === true)
|
|
114
115
|
await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
|
package/src/market/publish.ts
CHANGED
|
@@ -17,6 +17,7 @@ import process from "node:process";
|
|
|
17
17
|
|
|
18
18
|
import { buildBundle } from "../bundle";
|
|
19
19
|
import { createDeterministicTgz } from "../deterministic-tar";
|
|
20
|
+
import { assertDefaultModulesUntouched } from "../scaffold-guard";
|
|
20
21
|
import { signCommand } from "../sign";
|
|
21
22
|
import { resolveCdnLayout } from "./cdn-layout";
|
|
22
23
|
import { buildFixturePackage, parseFixtureSpec } from "./fixture";
|
|
@@ -38,6 +39,8 @@ export interface MarketPublishOptions {
|
|
|
38
39
|
check?: boolean
|
|
39
40
|
/** 签发方 registry 身份(REG-CS3 v2 防跨仓库重放;透传 signCommand --registry-id) */
|
|
40
41
|
registryId?: string
|
|
42
|
+
/** 命令层是否紧接着重建索引(仅用于措辞:true 时不打印「索引未重建」) */
|
|
43
|
+
rebuildIndex?: boolean
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export interface MarketPublishResult {
|
|
@@ -88,7 +91,9 @@ export async function publishPackage(opts: MarketPublishOptions = {}): Promise<M
|
|
|
88
91
|
const size = fs.statSync(tgzPath).size;
|
|
89
92
|
console.log(`[ojm] 包已落 CDN → ${path.relative(projectRoot, tgzPath)}(${size} 字节)`);
|
|
90
93
|
console.log(`[ojm] 发布方 ${signed.keyId};指纹 ${signed.fingerprint}`);
|
|
91
|
-
|
|
94
|
+
// 带了 --rebuild-index 时由命令层紧接着重建索引,这里不能再报「未重建」(否则前后两句自相矛盾)
|
|
95
|
+
if (opts.rebuildIndex !== true)
|
|
96
|
+
console.log("[ojm] 索引未重建:多人场景请由维护者跑 ojm market build-index");
|
|
92
97
|
|
|
93
98
|
return {
|
|
94
99
|
name,
|
|
@@ -122,6 +127,8 @@ async function buildFixture(args: { fixture?: string, staging: string, cdnDir: s
|
|
|
122
127
|
|
|
123
128
|
/** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录(一次只发一份:多方案工程请显式指定) */
|
|
124
129
|
async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
|
|
130
|
+
// 与 ojm build 同一道完整性门禁:发布链路同样不许携带被改过的默认模块
|
|
131
|
+
assertDefaultModulesUntouched(solutionRoot);
|
|
125
132
|
const builtList = await buildBundle(solutionRoot, { out: staging });
|
|
126
133
|
if (builtList.length !== 1) {
|
|
127
134
|
throw new Error(
|
|
@@ -70,6 +70,17 @@ export interface WalkthroughResult {
|
|
|
70
70
|
port: number
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 安装成功终态:oj 0.1.122 起,无守护形态安装会落 partial(vars/auth 等冻结段热改属
|
|
75
|
+
* restart_pending,需进程重启才完全生效)——这是正确终态(P0 §3.6 / 验收 15),不是失败。
|
|
76
|
+
* 真正的失败 partial(锁后异常 + 补偿回滚)不会带 restartRequired,故以它区分二者。
|
|
77
|
+
* 本 walkthrough 在每次安装后置重启(IsolatedSite.restart),模拟真实「部署 → 重启」时序,
|
|
78
|
+
* 让模块真正上线。
|
|
79
|
+
*/
|
|
80
|
+
function installSucceeded(task: TaskRecord): boolean {
|
|
81
|
+
return task.status === "done" || (task.status === "partial" && task.restartRequired === true);
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
/**
|
|
74
85
|
* neg-* 专用版本:与主链路(0.1.0/0.2.0)完全隔离,收尾整包摘除。
|
|
75
86
|
* 必须低于主链路 latest(0.2.0),否则发布后它会成为 latest,摘除时 REG-CS2 单调性守卫
|
|
@@ -96,8 +107,8 @@ const OPS: Op[] = [
|
|
|
96
107
|
title: "安装(11 段)",
|
|
97
108
|
run: async (ctx) => {
|
|
98
109
|
const task = await install(ctx, ctx.version);
|
|
99
|
-
const
|
|
100
|
-
return { ok
|
|
110
|
+
const ok = installSucceeded(task);
|
|
111
|
+
return { ok, detail: ok ? `${task.stages.length} 段全 ok(终态 ${task.status}${task.restartRequired ? " + restartRequired" : ""},已重启激活)` : `status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
|
|
101
112
|
},
|
|
102
113
|
},
|
|
103
114
|
{
|
|
@@ -106,7 +117,7 @@ const OPS: Op[] = [
|
|
|
106
117
|
run: async (ctx) => {
|
|
107
118
|
await ensureInstalled(ctx);
|
|
108
119
|
const task = await install(ctx, ctx.version);
|
|
109
|
-
const ok = task
|
|
120
|
+
const ok = installSucceeded(task);
|
|
110
121
|
return { ok, detail: ok ? "重装成功(未报方案名占用)" : `status=${task.status}:${task.message}` };
|
|
111
122
|
},
|
|
112
123
|
},
|
|
@@ -159,7 +170,7 @@ const OPS: Op[] = [
|
|
|
159
170
|
await ensureInstalled(ctx); // 前置:0.1.0 在列(账本 head=1)
|
|
160
171
|
await publishVersion(ctx, "0.2.0");
|
|
161
172
|
const task = await install(ctx, "0.2.0");
|
|
162
|
-
if (task
|
|
173
|
+
if (!installSucceeded(task))
|
|
163
174
|
return { ok: false, detail: `升级安装未 done:status=${task.status} stage=${task.stage ?? "?"}:${task.message}` };
|
|
164
175
|
const card = await indexCard(ctx);
|
|
165
176
|
const distDir = resolveLayout(ctx.site.root).apiDist;
|
|
@@ -189,7 +200,7 @@ const OPS: Op[] = [
|
|
|
189
200
|
if (card?.installed !== "0.2.0") {
|
|
190
201
|
await publishVersion(ctx, "0.2.0");
|
|
191
202
|
const pre = await install(ctx, "0.2.0");
|
|
192
|
-
if (pre
|
|
203
|
+
if (!installSucceeded(pre))
|
|
193
204
|
return { ok: false, detail: `前置安装 0.2.0 未 done:status=${pre.status}:${pre.message}` };
|
|
194
205
|
card = await indexCard(ctx);
|
|
195
206
|
}
|
|
@@ -320,7 +331,7 @@ const OPS: Op[] = [
|
|
|
320
331
|
return { ok: false, detail: `未停 needs_trust:status=${first.status}:${first.message}` };
|
|
321
332
|
await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: first.id });
|
|
322
333
|
const second = await installOnce(ctx, NEG_VERSION, NEG_NAME);
|
|
323
|
-
if (second
|
|
334
|
+
if (!installSucceeded(second))
|
|
324
335
|
return { ok: false, detail: `确认后重装未 done:status=${second.status}:${second.message}` };
|
|
325
336
|
// 收尾(F3):显式 revoke 陌生 key,快照 diff 断言无残留——不靠 uninstall 兜底(它本就不摘信任)
|
|
326
337
|
const del = await callApi<{ revoked?: boolean }>(fetch, ctx.base, ctx.token, "DELETE", `/api/_platform/market/trust/key?key_id=${encodeURIComponent("stranger.publisher")}`);
|
|
@@ -417,13 +428,18 @@ export async function walkthrough(opts: WalkthroughOptions = {}): Promise<Walkth
|
|
|
417
428
|
}
|
|
418
429
|
}
|
|
419
430
|
|
|
420
|
-
/**
|
|
431
|
+
/**
|
|
432
|
+
* 安装 + 轮询;needs_trust 时自动确认(隔离站点,无外部信任可言)。
|
|
433
|
+
* 成功(done 或 restartRequired 的 partial)后置重启,激活冻结段,模拟真实部署时序。
|
|
434
|
+
*/
|
|
421
435
|
async function install(ctx: Ctx, version: string): Promise<TaskRecord> {
|
|
422
436
|
let task = await installOnce(ctx, version);
|
|
423
437
|
if (task.status === "needs_trust") {
|
|
424
438
|
await callApi(fetch, ctx.base, ctx.token, "POST", "/api/_platform/market/trust", { task_id: task.id });
|
|
425
439
|
task = await installOnce(ctx, version);
|
|
426
440
|
}
|
|
441
|
+
if (installSucceeded(task))
|
|
442
|
+
await ctx.site.restart();
|
|
427
443
|
return task;
|
|
428
444
|
}
|
|
429
445
|
|
|
@@ -462,7 +478,7 @@ async function ensureInstalled(ctx: Ctx): Promise<void> {
|
|
|
462
478
|
if (card?.installed === ctx.version)
|
|
463
479
|
return;
|
|
464
480
|
const task = await install(ctx, ctx.version);
|
|
465
|
-
if (task
|
|
481
|
+
if (!installSucceeded(task))
|
|
466
482
|
throw new Error(`前置安装失败:status=${task.status}:${task.message}`);
|
|
467
483
|
}
|
|
468
484
|
|
package/src/new.ts
CHANGED
|
@@ -140,6 +140,8 @@ function solutionYaml(name: string, withApi: boolean | undefined): string {
|
|
|
140
140
|
"api:",
|
|
141
141
|
` module: ${name}`,
|
|
142
142
|
` prefix: /api/${name}`,
|
|
143
|
+
// ping 匿名可达必须在契约声明(fixture.ts 同形状);漏声明安装预检 anonymous_residual 拒装
|
|
144
|
+
` anonymous: ["${name}/ping"]`,
|
|
143
145
|
"",
|
|
144
146
|
].join("\n")
|
|
145
147
|
: "";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 脚手架完整性门禁——内置模块自治契约。
|
|
3
|
+
*
|
|
4
|
+
* 契约(下游反馈 2026-11-06):`src/app` 内置模块与 `src/oj-market` 属上游自治,
|
|
5
|
+
* 下游不得本地修改;`ojm build` 前逐文件与**当前 cli 包内 templates** 比对
|
|
6
|
+
* (上游模板即基线,不另立可被下游手改的哈希 lock)。CLI 升级后工程里的旧版
|
|
7
|
+
* 默认模块与新模板必不一致 → fail-fast,必须先 `ojm init --yes` 同步再构建
|
|
8
|
+
* (「必须先同步上游」策略,用户已确认)。
|
|
9
|
+
*
|
|
10
|
+
* 语义边界:
|
|
11
|
+
* - 保护集 = init.include.yaml 白名单中保护根前缀条目展开的模板文件清单;
|
|
12
|
+
* demo-only 文件本就不在白名单,不参与比较,无误报;
|
|
13
|
+
* - 三类漂移均拦:modified(内容不一致,CRLF 归一防 autocrlf 误报)、
|
|
14
|
+
* missing(白名单文件缺失)、extra(保护根内模板没有的文件);
|
|
15
|
+
* - 门禁开关 = `src/.ojm-builtin.json` 存在(新脚手架 init 必写;缺失 =
|
|
16
|
+
* 旧工程/框架仓,退化现状不拦——与 builtin.ts 同哲学);
|
|
17
|
+
* - **不是安全边界**:删开关文件即退回旧行为;错误信息如实告知处置路径;
|
|
18
|
+
* - 仅约束打包链路(ojm build / build --bundle / market publish --real);
|
|
19
|
+
* dev 不拦,允许本地试验。
|
|
20
|
+
*
|
|
21
|
+
* 消费方:`cli.ts` build action(分支前,失败零产物)、`market/publish.ts`、
|
|
22
|
+
* `init.ts`(--yes 恢复复用保护根/白名单/清理面)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import yaml from "yaml";
|
|
29
|
+
import { builtinListPath } from "./builtin";
|
|
30
|
+
|
|
31
|
+
/** 上游自治的保护根(相对工程根,正斜杠)。init --yes 恢复范围与此严格一致。 */
|
|
32
|
+
export const PROTECTED_ROOTS = ["src/app", "src/oj-market"] as const;
|
|
33
|
+
|
|
34
|
+
/** rel 是否落在保护根内(接受平台分隔符输入,归一后比较) */
|
|
35
|
+
export function isProtectedRel(rel: string): boolean {
|
|
36
|
+
const p = rel.split(path.sep).join("/");
|
|
37
|
+
return PROTECTED_ROOTS.some(root => p === root || p.startsWith(`${root}/`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** cli 包根(src 运行态与 dist 发布态各上跳一层即包根) */
|
|
41
|
+
export function cliRoot(): string {
|
|
42
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function templatesRoot(): string {
|
|
46
|
+
return path.join(cliRoot(), "templates");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 读取模板根 `init.include.yaml` 的 `include` 白名单,归一为相对模板根的路径集合。
|
|
51
|
+
* 清单随模板一起发布,缺失即安装包损坏——直接 fail-fast,不留「静默少拷」的
|
|
52
|
+
* 退化路径。条目指向不存在的路径同样 fail-fast:拼错一个目录名不会当场报错,
|
|
53
|
+
* 只会在用户工程里留下难以追溯的缺文件(旧 exclude 清单没有这类校验面)。
|
|
54
|
+
*/
|
|
55
|
+
export function readInitInclude(templatesDir: string): Set<string> {
|
|
56
|
+
const yamlPath = path.join(templatesDir, "init.include.yaml");
|
|
57
|
+
if (!fs.existsSync(yamlPath))
|
|
58
|
+
throw new Error(`模板缺少 init.include.yaml:${yamlPath}(安装包损坏?)`);
|
|
59
|
+
const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
|
|
60
|
+
const out = new Set<string>();
|
|
61
|
+
for (const raw of doc?.include ?? []) {
|
|
62
|
+
// 跨平台:include 清单用正斜杠书写,但 path.normalize 在 Windows 会把
|
|
63
|
+
// 正斜杠折成反斜杠,导致与 srcRel(path.relative 在 Windows 同样出反斜杠)
|
|
64
|
+
// 的对比永远落空、整棵 src 子树被 skip。这里只把反斜杠归正、剥 ./ 前缀,
|
|
65
|
+
// 不引入平台分隔符(修复:Windows 下 init 不拷贝 src 树)。
|
|
66
|
+
const p = raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
67
|
+
out.add(p);
|
|
68
|
+
if (!fs.existsSync(path.join(templatesDir, p)))
|
|
69
|
+
throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ScaffoldDrift {
|
|
75
|
+
modified: string[]
|
|
76
|
+
missing: string[]
|
|
77
|
+
extra: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 门禁开关:工程声明了内置模块概念(`src/.ojm-builtin.json` 存在) */
|
|
81
|
+
export function guardEnabled(projectRoot: string): boolean {
|
|
82
|
+
return fs.existsSync(builtinListPath(projectRoot));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 白名单中保护根条目展开成模板文件相对路径清单(正斜杠、去重、排序) */
|
|
86
|
+
export function protectedTemplateFiles(templatesDir: string, include: Set<string>): string[] {
|
|
87
|
+
const files = new Set<string>();
|
|
88
|
+
const walk = (abs: string): void => {
|
|
89
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
90
|
+
const p = path.join(abs, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
walk(p);
|
|
93
|
+
else
|
|
94
|
+
files.add(path.relative(templatesDir, p).split(path.sep).join("/"));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const rel of include) {
|
|
98
|
+
if (!isProtectedRel(rel))
|
|
99
|
+
continue;
|
|
100
|
+
const abs = path.join(templatesDir, rel);
|
|
101
|
+
if (fs.statSync(abs).isDirectory())
|
|
102
|
+
walk(abs);
|
|
103
|
+
else
|
|
104
|
+
files.add(rel);
|
|
105
|
+
}
|
|
106
|
+
return [...files].sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const normalizeText = (s: string): string => s.replaceAll("\r\n", "\n");
|
|
110
|
+
|
|
111
|
+
function walkProjectFiles(projectRoot: string, root: string, visit: (rel: string, abs: string) => void): void {
|
|
112
|
+
const absRoot = path.join(projectRoot, root);
|
|
113
|
+
if (!fs.existsSync(absRoot))
|
|
114
|
+
return;
|
|
115
|
+
const walk = (abs: string): void => {
|
|
116
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
117
|
+
const p = path.join(abs, entry.name);
|
|
118
|
+
if (entry.isDirectory())
|
|
119
|
+
walk(p);
|
|
120
|
+
else
|
|
121
|
+
visit(path.relative(projectRoot, p).split(path.sep).join("/"), p);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(absRoot);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 收集工程默认模块相对当前 cli 模板的漂移。
|
|
129
|
+
* 门禁未启用(无 `.ojm-builtin.json`)→ null;已启用 → 三类清单(可为空)。
|
|
130
|
+
*/
|
|
131
|
+
export function collectDefaultModuleDrift(projectRoot: string): ScaffoldDrift | null {
|
|
132
|
+
if (!guardEnabled(projectRoot))
|
|
133
|
+
return null;
|
|
134
|
+
const templatesDir = templatesRoot();
|
|
135
|
+
const expected = protectedTemplateFiles(templatesDir, readInitInclude(templatesDir));
|
|
136
|
+
const expectedSet = new Set(expected);
|
|
137
|
+
const drift: ScaffoldDrift = { modified: [], missing: [], extra: [] };
|
|
138
|
+
for (const rel of expected) {
|
|
139
|
+
const projectFile = path.join(projectRoot, rel);
|
|
140
|
+
if (!fs.existsSync(projectFile)) {
|
|
141
|
+
drift.missing.push(rel);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (normalizeText(fs.readFileSync(projectFile, "utf-8")) !== normalizeText(fs.readFileSync(path.join(templatesDir, rel), "utf-8")))
|
|
145
|
+
drift.modified.push(rel);
|
|
146
|
+
}
|
|
147
|
+
for (const root of PROTECTED_ROOTS) {
|
|
148
|
+
walkProjectFiles(projectRoot, root, (rel) => {
|
|
149
|
+
if (!expectedSet.has(rel))
|
|
150
|
+
drift.extra.push(rel);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
drift.extra.sort();
|
|
154
|
+
return drift;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 门禁入口:有漂移即抛(错误信息点名文件 + 上游自治说明 + `ojm init --yes` 处置)。
|
|
159
|
+
* 调用方保证在任何产物写入之前调用(失败零产物)。
|
|
160
|
+
*/
|
|
161
|
+
export function assertDefaultModulesUntouched(projectRoot: string): void {
|
|
162
|
+
const drift = collectDefaultModuleDrift(projectRoot);
|
|
163
|
+
if (!drift)
|
|
164
|
+
return;
|
|
165
|
+
const sections: string[] = [];
|
|
166
|
+
const section = (label: string, items: string[]): void => {
|
|
167
|
+
if (items.length > 0)
|
|
168
|
+
sections.push(`${label}(${items.length} 个文件):\n${items.map(f => ` - ${f}`).join("\n")}`);
|
|
169
|
+
};
|
|
170
|
+
section("内容被修改", drift.modified);
|
|
171
|
+
section("缺失", drift.missing);
|
|
172
|
+
section("多出(模板没有)", drift.extra);
|
|
173
|
+
if (sections.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
throw new Error([
|
|
176
|
+
"[ojm] 框架默认模块与上游模板不一致,已拒绝构建(未写入任何产物)。",
|
|
177
|
+
"",
|
|
178
|
+
...sections,
|
|
179
|
+
"",
|
|
180
|
+
"框架默认模块(src/app 内置模块、src/oj-market)由上游自治:本地修改会在下次同步时丢失,",
|
|
181
|
+
"功能需求请向上游仓库提交 issue,不要在下游直接改这些目录。",
|
|
182
|
+
"处置:",
|
|
183
|
+
" - 确认放弃本地修改并恢复上游版本:ojm init --yes",
|
|
184
|
+
" (仅覆盖/清理 src/app 与 src/oj-market,会丢弃其中的本地改动,执行前请备份;",
|
|
185
|
+
" 下游自定义模块与工程配置不受影响)。",
|
|
186
|
+
].join("\n"));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `ojm init --yes` 恢复的清理面:删除保护根内模板没有的文件(含遗留空目录)。
|
|
191
|
+
* 返回删除清单(正斜杠相对路径),供 init report 展示。
|
|
192
|
+
*/
|
|
193
|
+
export function removeExtraProtectedFiles(projectRoot: string): string[] {
|
|
194
|
+
const templatesDir = templatesRoot();
|
|
195
|
+
const expected = new Set(protectedTemplateFiles(templatesDir, readInitInclude(templatesDir)));
|
|
196
|
+
const removed: string[] = [];
|
|
197
|
+
for (const root of PROTECTED_ROOTS) {
|
|
198
|
+
walkProjectFiles(projectRoot, root, (rel, abs) => {
|
|
199
|
+
if (!expected.has(rel)) {
|
|
200
|
+
fs.rmSync(abs);
|
|
201
|
+
removed.push(rel);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
const absRoot = path.join(projectRoot, root);
|
|
205
|
+
if (fs.existsSync(absRoot))
|
|
206
|
+
pruneEmptyDirs(absRoot);
|
|
207
|
+
}
|
|
208
|
+
return removed.sort();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 递归删除空目录(自底向上);根自身保留,返回其是否为空 */
|
|
212
|
+
function pruneEmptyDirs(dir: string): boolean {
|
|
213
|
+
let empty = true;
|
|
214
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
215
|
+
const p = path.join(dir, entry.name);
|
|
216
|
+
if (entry.isDirectory()) {
|
|
217
|
+
if (pruneEmptyDirs(p))
|
|
218
|
+
fs.rmdirSync(p);
|
|
219
|
+
else
|
|
220
|
+
empty = false;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
empty = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return empty;
|
|
227
|
+
}
|
package/src/site.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface TaskRecord {
|
|
|
43
43
|
appliedSeq?: number
|
|
44
44
|
startedAt: number
|
|
45
45
|
finishedAt?: number
|
|
46
|
+
/** 无守护形态部分生效:冻结段(auth/vars)热改属 restart_pending,需宿主重启才完全生效 */
|
|
47
|
+
restartRequired?: boolean
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** 模板 serve 缺省端口(templates/config.yaml server.port: 9778;host 显式钉 127.0.0.1) */
|
package/src/usage.ts
CHANGED
|
@@ -77,11 +77,11 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
77
77
|
zh: "前后端一体化工程脚手架(幂等补缺)",
|
|
78
78
|
en: "Full-stack project scaffolding (idempotent, fills gaps only)",
|
|
79
79
|
options: [
|
|
80
|
-
{ raw: "--yes", zh: "
|
|
80
|
+
{ raw: "--yes", zh: "非交互:跳过非空确认,并把框架自带模块(src/app、src/oj-market)恢复为当前版本(丢弃其中的本地修改)", en: "Non-interactive: skip the non-empty check and restore built-in modules (src/app, src/oj-market) to upstream (local edits there are discarded)" },
|
|
81
81
|
],
|
|
82
82
|
detail: {
|
|
83
|
-
zh: "ojm init [dir] [--yes]\n
|
|
84
|
-
en: "ojm init [dir] [--yes]\n Full-stack project scaffolding (
|
|
83
|
+
zh: "ojm init [dir] [--yes]\n 前后端一体化工程脚手架(重跑只补缺失文件;已有文件永不覆盖)\n --yes 恢复模式:覆盖 src/app 与 src/oj-market 为当前 CLI 携带的上游版本,\n 并删除这些目录内多出的文件(会丢弃其中的本地修改,先备份;\n 下游自定义模块与工程配置不受影响)。CLI 升级后构建前需先跑一次。",
|
|
84
|
+
en: "ojm init [dir] [--yes]\n Full-stack project scaffolding (re-runs only fill missing files; existing files are never overwritten)\n --yes restore mode: overwrites src/app & src/oj-market with the upstream version\n carried by this CLI and removes extra files there (local edits in those two\n directories are discarded — back up first; your own modules and project config\n are untouched). Run once after a CLI upgrade before building.",
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
{
|
|
@@ -135,8 +135,8 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
135
135
|
{ raw: "--out <dir>", zh: "--bundle 输出目录(缺省 dist/)", en: "Output directory for --bundle (default dist/)" },
|
|
136
136
|
],
|
|
137
137
|
detail: {
|
|
138
|
-
zh: "ojm build [--bundle [name]] [--out <dir>]\n 构建后端(oj build)与模块产物(含全站合并)\n --bundle 产出 dist/<name>-<version>.tgz + sha256
|
|
139
|
-
en: "ojm build [--bundle [name]] [--out <dir>]\n Builds the backend (oj build) and module artifacts (including the site-wide merge)\n --bundle produces dist/<name>-<version>.tgz + sha256 (upstream artifact for the marketplace installer)",
|
|
138
|
+
zh: "ojm build [--bundle [name]] [--out <dir>]\n 构建后端(oj build)与模块产物(含全站合并)\n --bundle 产出 dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)\n 构建前校验框架自带模块(src/app、src/oj-market)未被本地修改——\n 与上游模板不一致即拒绝构建(内置模块自治契约,见 src/README.md)",
|
|
139
|
+
en: "ojm build [--bundle [name]] [--out <dir>]\n Builds the backend (oj build) and module artifacts (including the site-wide merge)\n --bundle produces dist/<name>-<version>.tgz + sha256 (upstream artifact for the marketplace installer)\n Before building, verifies the built-in modules (src/app, src/oj-market) are unmodified —\n any drift from the upstream templates rejects the build (built-in module autonomy, see src/README.md)",
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
142
|
{
|
|
@@ -245,11 +245,13 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
245
245
|
{ raw: "--name <name>", zh: "覆盖文件名推导的方案名", en: "Override the solution name derived from the file name" },
|
|
246
246
|
{ raw: "--version <version>", zh: "覆盖文件名推导的版本", en: "Override the version derived from the file name" },
|
|
247
247
|
{ raw: "--registry <url>", zh: "从指定 registry 源安装(白名单在服务端;旧站点 API < 0.2.0 不支持)", en: "Install from the specified registry source (allowlist is server-side; sites with API < 0.2.0 are unsupported)" },
|
|
248
|
+
{ raw: "--force", zh: "强制重装配(oj 0.1.123+ reloadConfig(force)):旁路 unchanged 幂等短路,强制宿主重做一次装配", en: "Force re-assembly (oj 0.1.123+ reloadConfig(force)): bypass the unchanged idempotent short-circuit and force a re-assembly" },
|
|
248
249
|
{ raw: "--locale <locale>", zh: "终态文案语言 zh-CN|en-US", en: "Language of the final status message zh-CN|en-US" },
|
|
250
|
+
{ raw: "--force", zh: "强制重装配(绕过「unchanged=已是最新装配」幂等短路,要求宿主重跑一次 reload;oj ≥ v0.1.123)", en: "Force a reload even if unchanged (bypass the idempotent short-circuit; requires oj ≥ v0.1.123)" },
|
|
249
251
|
],
|
|
250
252
|
detail: {
|
|
251
|
-
zh: "ojm install <file.tgz | name[@version] | @<scope>/<name>[@version]> [--site-url <u>] [--user <u>] [--pass <p>] [--name <n>] [--version <v>] [--registry <r>] [--locale <zh-CN|en-US>]\n 本地 tgz 走 file 上传(<name>-<version>.tgz 文件名推导身份,可 --name/--version 覆盖);name[@version] 走 CDN 市场源;@<scope>/<name>[@version] --registry <url> 走外部 registry 源(目录键由 CLI 归一为物理名 slug,白名单在服务端)\n 登录 → 提交 → 轮询任务至终态并打印结果(失败附原因 + 指引)\n 站点寻址:--site-url/--user/--pass,或环境变量 OJM_SITE_URL / OJM_USER / OJM_PASS",
|
|
252
|
-
en: "ojm install <file.tgz | name[@version] | @<scope>/<name>[@version]> [--site-url <u>] [--user <u>] [--pass <p>] [--name <n>] [--version <v>] [--registry <r>] [--locale <zh-CN|en-US>]\n Local tgz goes through file upload (identity derived from the <name>-<version>.tgz file name, overridable via --name/--version); name[@version] goes through the CDN marketplace; @<scope>/<name>[@version] --registry <url> goes through an external registry source (the directory key is normalized to the physical slug by the CLI; allowlist is server-side)\n Login → submit → poll the task to a terminal state and print the result (failures include the reason + guidance)\n Site addressing: --site-url/--user/--pass, or env vars OJM_SITE_URL / OJM_USER / OJM_PASS",
|
|
253
|
+
zh: "ojm install <file.tgz | name[@version] | @<scope>/<name>[@version]> [--site-url <u>] [--user <u>] [--pass <p>] [--name <n>] [--version <v>] [--registry <r>] [--force] [--locale <zh-CN|en-US>]\n 本地 tgz 走 file 上传(<name>-<version>.tgz 文件名推导身份,可 --name/--version 覆盖);name[@version] 走 CDN 市场源;@<scope>/<name>[@version] --registry <url> 走外部 registry 源(目录键由 CLI 归一为物理名 slug,白名单在服务端)\n 登录 → 提交 → 轮询任务至终态并打印结果(失败附原因 + 指引)\n --force:强制重装配(oj 0.1.123+),旁路「已是最新装配」幂等短路\n 站点寻址:--site-url/--user/--pass,或环境变量 OJM_SITE_URL / OJM_USER / OJM_PASS",
|
|
254
|
+
en: "ojm install <file.tgz | name[@version] | @<scope>/<name>[@version]> [--site-url <u>] [--user <u>] [--pass <p>] [--name <n>] [--version <v>] [--registry <r>] [--force] [--locale <zh-CN|en-US>]\n Local tgz goes through file upload (identity derived from the <name>-<version>.tgz file name, overridable via --name/--version); name[@version] goes through the CDN marketplace; @<scope>/<name>[@version] --registry <url> goes through an external registry source (the directory key is normalized to the physical slug by the CLI; allowlist is server-side)\n Login → submit → poll the task to a terminal state and print the result (failures include the reason + guidance)\n --force: force re-assembly (oj 0.1.123+), bypassing the \"already latest assembly\" idempotent short-circuit\n Site addressing: --site-url/--user/--pass, or env vars OJM_SITE_URL / OJM_USER / OJM_PASS",
|
|
253
255
|
},
|
|
254
256
|
},
|
|
255
257
|
{
|
|
@@ -364,12 +366,14 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
364
366
|
en: "Generate an Ed25519 signing key pair",
|
|
365
367
|
options: [
|
|
366
368
|
{ raw: "--id <id>", zh: "发布方 key id(形如 publisher.key)", en: "Publisher key id (e.g. publisher.key)" },
|
|
369
|
+
{ raw: "--credential <token>", zh: "在线兑换发布凭证(也读 OJM_PUBLISH_CREDENTIAL;与 --id 互斥)", en: "Redeem a publishing credential online (also reads OJM_PUBLISH_CREDENTIAL; mutually exclusive with --id)" },
|
|
370
|
+
{ raw: "--site-url <url>", zh: "兑换站点(缺省 OJM_SITE_URL / http://127.0.0.1:9778)", en: "Site for redemption (defaults to OJM_SITE_URL / http://127.0.0.1:9778)" },
|
|
367
371
|
{ raw: "--out <dir>", zh: "输出目录(缺省 ~/.ojm/keys/<id>/;指到工程树内须 --yes)", en: "Output directory (default ~/.ojm/keys/<id>/; pointing inside the project tree requires --yes)" },
|
|
368
372
|
{ raw: "--yes", zh: "确认私钥落盘到工程树内(私钥入仓即泄露)", en: "Acknowledge writing the private key inside the project tree (a private key in the repo is leaked)" },
|
|
369
373
|
],
|
|
370
374
|
detail: {
|
|
371
|
-
zh: "ojm keygen --id <publisher.id> [--out <dir>] [--yes]\n 私钥默认 ~/.ojm/keys/<id>/ 永不出目录;打印公钥 hex 供 ojm trust add /
|
|
372
|
-
en: "ojm keygen --id <publisher.id> [--out <dir>] [--yes]\n The private key stays under ~/.ojm/keys/<id>/ by default; prints the public key hex for ojm trust add / template backfill",
|
|
375
|
+
zh: "ojm keygen --id <publisher.id> [--out <dir>] [--yes]\n 私钥默认 ~/.ojm/keys/<id>/ 永不出目录;打印公钥 hex 供 ojm trust add / 模板回填\n\nojm keygen --credential <token> [--site-url <url>] [--out <dir>] [--yes]\n 凭证兑换:key_id 由站点按「产品名.用户名」算好下发,CLI 不推导;凭证一次性,兑换即作废\n 失败(过期/已用/已撤销/网络)不落任何密钥文件;兑换成功再走与离线版完全相同的落盘流程",
|
|
376
|
+
en: "ojm keygen --id <publisher.id> [--out <dir>] [--yes]\n The private key stays under ~/.ojm/keys/<id>/ by default; prints the public key hex for ojm trust add / template backfill\n\nojm keygen --credential <token> [--site-url <url>] [--out <dir>] [--yes]\n Credential redemption: key_id is derived server-side as <product>.<username>, the CLI never re-derives it; the credential is single-use\n Any failure (expired/used/revoked/network) writes no key files; on success the same keygen flow as the offline path runs",
|
|
373
377
|
},
|
|
374
378
|
},
|
|
375
379
|
{
|