h3factory 0.0.0-stage → 0.2.2

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 (37) hide show
  1. package/README.md +241 -2
  2. package/bin/h3factory.js +87 -0
  3. package/cli.py +951 -0
  4. package/config.example.json +8 -0
  5. package/docs/platform_gotchas.md +118 -0
  6. package/docs/requirement_checklist.md +54 -0
  7. package/docs/schema_doc.md +216 -0
  8. package/docs/workflow.md +146 -0
  9. package/h3design/__init__.py +0 -0
  10. package/h3design/controls.py +414 -0
  11. package/h3design/dsl.py +553 -0
  12. package/h3design/newbuilder.py +852 -0
  13. package/h3platform/__init__.py +0 -0
  14. package/h3platform/api.py +214 -0
  15. package/h3platform/h3console.py +99 -0
  16. package/h3platform/settings.py +74 -0
  17. package/h3verify/__init__.py +0 -0
  18. package/h3verify/verify.py +217 -0
  19. package/package.json +51 -4
  20. package/projects/demo/dicts.json +10 -0
  21. package/projects/demo/generated/product.cs +82 -0
  22. package/projects/demo/groups.json +3 -0
  23. package/projects/demo/sheets/product.json +24 -0
  24. package/scripts/postinstall.js +109 -0
  25. package/skills/h3factory/SKILL.md +137 -0
  26. package/skills/h3yun-dev-assistant/BACKEND_GUIDELINES.md +120 -0
  27. package/skills/h3yun-dev-assistant/DEV_DOC_ROUTING.md +39 -0
  28. package/skills/h3yun-dev-assistant/FORM_ACTIONS_GUIDELINES.md +116 -0
  29. package/skills/h3yun-dev-assistant/FRONTEND_GUIDELINES.md +47 -0
  30. package/skills/h3yun-dev-assistant/OPENAPI_GUIDELINES.md +8 -0
  31. package/skills/h3yun-dev-assistant/PLATFORM_CONSTRAINTS.md +160 -0
  32. package/skills/h3yun-dev-assistant/POST_GUIDELINES.md +47 -0
  33. package/skills/h3yun-dev-assistant/PRACTICE_NOTES.md +626 -0
  34. package/skills/h3yun-dev-assistant/SKILL.md +70 -0
  35. package/skills/h3yun-dev-assistant/SQL_GUIDELINES.md +9 -0
  36. package/skills/h3yun-dev-assistant/TOOL_FUNCTIONS.md +96 -0
  37. package/templates/controller.cs.tpl +78 -0
package/cli.py ADDED
@@ -0,0 +1,951 @@
1
+ # -*- coding: utf-8 -*-
2
+ """h3factory CLI —— 氚云 new-engine 表单工厂(确定性层,零 LLM 依赖)。
3
+
4
+ npm 全局安装后(工作区 = 当前目录):
5
+ h3factory init # 生成工作区 config.json(自 config.example.json)
6
+ h3factory doctor # 检查 Python/配置/JWT/技能
7
+ h3factory check <proj> [t1..] # 本地加载并校验表单定义(不联网)
8
+ h3factory list <proj> # registry:各表编码与状态
9
+ h3factory build <proj> [t1..] # 建表(已注册且线上存在则跳过;--force 重存)
10
+ h3factory verify <proj> [t1..] # LoadForm 只读回读比对(安全可重复)
11
+ h3factory group <proj> [t1..] # 表单归组:建分组并移入(幂等,见 docs)
12
+ h3factory import <proj> <sheet> <data.json> [--submit]
13
+ h3factory gen <proj> <sheet> # 生成 .cs 提交时代码骨架
14
+
15
+ 源码目录也可用: python -X utf8 cli.py <命令> ...
16
+ 项目 = <工作区>/projects/<proj>/:config.json(缺省用工作区/用户配置)+ sheets/*.json
17
+ + dicts.json + registry.json(编码注册表,勿手改)
18
+ 定义格式见 docs/schema_doc.md;平台坑位见 docs/platform_gotchas.md。"""
19
+ import argparse
20
+ import base64
21
+ import datetime
22
+ import json
23
+ import os
24
+ import random
25
+ import shutil
26
+ import string
27
+ import sys
28
+ import time
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
+ import h3design.dsl as DSL
32
+ import h3verify.verify as V
33
+ from h3platform import settings
34
+
35
+ ROOT = settings.CODE_ROOT
36
+ EXAMPLE_CONFIG = os.path.join(ROOT, "config.example.json")
37
+
38
+
39
+ def _read_version():
40
+ try:
41
+ with open(os.path.join(ROOT, "package.json"), encoding="utf-8") as f:
42
+ return json.load(f).get("version", "0.0.0")
43
+ except Exception:
44
+ return "0.0.0"
45
+
46
+
47
+ VERSION = _read_version()
48
+
49
+ REQUIRED_KEYS = ("baseUrl", "engineCode", "engineSecret",
50
+ "authorization", "appCode")
51
+
52
+ # 各 AI 工具的用户级技能目录(参考 OpenYida 的安装位置)
53
+ TOOL_SKILL_DIRS = (
54
+ (".claude", "claude"),
55
+ (".codex", "codex"),
56
+ (".cursor", "cursor"),
57
+ (".opencode", "opencode"),
58
+ (".qwenworkcn", "qwenwork"),
59
+ (".qoder", "qoder"),
60
+ (".qoderwork", "qoderwork"),
61
+ (".mulerun", "mulerun"),
62
+ (".codebuddy", "codebuddy"),
63
+ (".workbuddy", "workbuddy"),
64
+ )
65
+
66
+
67
+ def _err(msg):
68
+ print("错误: " + msg)
69
+ sys.exit(1)
70
+
71
+
72
+ def project_dir(name):
73
+ p = settings.project_dir(name)
74
+ if not os.path.isdir(p):
75
+ _err("项目 %r 不存在(%s)。请在工作区 projects/ 下创建(可参考 demo),"
76
+ "或设置 H3FACTORY_HOME 指向已有工作区" % (name, p))
77
+ return p
78
+
79
+
80
+ def read_json(path):
81
+ with open(path, encoding="utf-8") as f:
82
+ return json.load(f)
83
+
84
+
85
+ def write_json(path, obj):
86
+ os.makedirs(os.path.dirname(path), exist_ok=True)
87
+ with open(path, "w", encoding="utf-8") as f:
88
+ json.dump(obj, f, ensure_ascii=False, indent=1)
89
+
90
+
91
+ def cfg_for(proj=None, required=True):
92
+ """项目 config.json 覆盖工作区/用户配置。凭据文件——勿提交/分享。
93
+ required=False 供纯本地命令(check/list)用,无凭据给空配置。"""
94
+ if proj:
95
+ pc = os.path.join(project_dir(proj), "config.json")
96
+ if os.path.isfile(pc):
97
+ return read_json(pc)
98
+ try:
99
+ return settings.load_config(required=required)
100
+ except settings.ConfigError as e:
101
+ _err(str(e))
102
+
103
+
104
+ def reg_path(proj):
105
+ return os.path.join(project_dir(proj), "registry.json")
106
+
107
+
108
+ def load_registry(proj):
109
+ try:
110
+ return read_json(reg_path(proj))
111
+ except Exception:
112
+ return {}
113
+
114
+
115
+ def save_registry(proj, reg):
116
+ write_json(reg_path(proj), reg)
117
+
118
+
119
+ def new_sheet_code(app_code):
120
+ return app_code[:7] + "".join(random.choice("0123456789abcdef") for _ in range(32))
121
+
122
+
123
+ def new_child_code(app_code):
124
+ """子表 child schema 编码:appCode前7位 + F + 32位hex(40位;UI 实证 D001599F3ec...)。"""
125
+ return app_code[:7] + "F" + "".join(random.choice("0123456789abcdef")
126
+ for _ in range(32))
127
+
128
+
129
+ def resolve_targets(proj, sheets, targets):
130
+ keys = [t for t in (targets or sorted(sheets))]
131
+ bad = [k for k in keys if k not in sheets]
132
+ if bad:
133
+ _err("表单 %s 不在项目 %r(现有 %s)" % (bad, proj, sorted(sheets)))
134
+ return keys
135
+
136
+
137
+ # ---------------------------------------------------------------- commands
138
+ AGENT_MD_BEGIN = "<!-- h3factory:begin -->"
139
+ AGENT_MD_END = "<!-- h3factory:end -->"
140
+ AGENT_MD_BLOCK = """<!-- h3factory:begin -->
141
+ ## 氚云表单工厂(h3factory)
142
+
143
+ 本工作区使用 `h3factory`。任何 Agent 会话开始必须先执行:
144
+
145
+ h3factory auth status --json
146
+
147
+ 如果 `ok=false`,让用户在自己的终端运行 `h3factory auth set-token`(输入隐藏)
148
+ 或 `h3factory init`;`ok=true` 后才允许执行 `build/verify/group/import`。
149
+
150
+ - 命令:`h3factory --help`
151
+ - 流程:`h3factory docs workflow`
152
+ - 需求澄清:`h3factory docs requirement_checklist`
153
+ - 通用提示语:`h3factory prompt`
154
+ <!-- h3factory:end -->"""
155
+
156
+
157
+ def _ensure_agent_md(path):
158
+ """幂等写入 AGENTS.md 的托管块,保留用户原有内容。"""
159
+ text = ""
160
+ if os.path.isfile(path):
161
+ with open(path, encoding="utf-8") as f:
162
+ text = f.read()
163
+ if AGENT_MD_BEGIN in text and AGENT_MD_END in text:
164
+ pre = text.split(AGENT_MD_BEGIN, 1)[0].rstrip()
165
+ post = text.split(AGENT_MD_END, 1)[1].lstrip("\n")
166
+ parts = [p for p in (pre, AGENT_MD_BLOCK, post) if p.strip()]
167
+ new = "\n\n".join(parts) + "\n"
168
+ else:
169
+ new = (text.rstrip() + "\n\n" if text.strip() else "") + AGENT_MD_BLOCK + "\n"
170
+ with open(path, "w", encoding="utf-8") as f:
171
+ f.write(new)
172
+ return path
173
+
174
+
175
+ def cmd_agent(args):
176
+ if args.action == "init":
177
+ path = _ensure_agent_md(os.path.join(settings.workspace_root(), "AGENTS.md"))
178
+ print("已写入 h3factory 指令块: %s" % path)
179
+
180
+
181
+ def cmd_prompt(args):
182
+ if args.short:
183
+ print("用 h3factory 帮我做这个需求:<需求>")
184
+ return
185
+ print("""请使用 h3factory 帮我完成以下需求:
186
+ <把需求贴在这里>
187
+
188
+ 执行要求:
189
+ 1. 先运行 h3factory auth status --json;若 ok=false,让我运行 h3factory auth set-token 录入 token。
190
+ 2. 运行 h3factory docs workflow 和 h3factory docs requirement_checklist,按流程先澄清需求。
191
+ 3. 产出 projects/<项目名>/ 设计后停下来让我过目,确认后再 build。""")
192
+
193
+
194
+ def cmd_init(args):
195
+ if not getattr(args, "no_agent_md", False):
196
+ try:
197
+ path = _ensure_agent_md(os.path.join(settings.workspace_root(), "AGENTS.md"))
198
+ print("已写入 Agent 指令: %s" % path)
199
+ except Exception as e:
200
+ print("警告: 写 AGENTS.md 失败: %s" % e)
201
+ existing = settings.resolve_config_path()
202
+ if existing:
203
+ print("config.json 已存在(%s),不覆盖。要重填请删除后重跑" % existing)
204
+ return
205
+ if not os.path.isfile(EXAMPLE_CONFIG):
206
+ _err("缺 config.example.json(安装包不完整)")
207
+ cfg = read_json(EXAMPLE_CONFIG)
208
+ if not getattr(args, "no_input", False) and sys.stdin.isatty():
209
+ print("填写氚云连接配置(回车保留默认值;凭据不要分享或提交)")
210
+ prompts = [
211
+ ("baseUrl", "氚云地址"),
212
+ ("engineCode", "引擎编码 engineCode"),
213
+ ("engineSecret", "引擎密钥 engineSecret"),
214
+ ("authorization", "authorization(Bearer JWT,会过期)"),
215
+ ("appCode", "目标应用 appCode"),
216
+ ("appName", "应用名称(可选)"),
217
+ ]
218
+ for key, label in prompts:
219
+ cur = cfg.get(key, "")
220
+ if key in ("engineSecret", "authorization"):
221
+ shown = "已填" if cur else "空"
222
+ else:
223
+ shown = cur or "空"
224
+ val = input("%s [%s]: " % (label, shown)).strip()
225
+ if val:
226
+ cfg[key] = val
227
+ target = settings.default_config_path()
228
+ write_json(target, cfg)
229
+ print("已生成 %s" % target)
230
+ print("下一步: 填好凭据后运行 h3factory doctor 检查登录态")
231
+
232
+
233
+ def check_registry(proj):
234
+ """check 用 registry:给每个已声明但未建表的表单补占位编码。
235
+ check 是**建表前**的关卡,此时 registry 还是空的,assoc/query 解析不到被引用表
236
+ 编码会直接报错——占位码让关联定义照常走通(真实编码只在 build 时用)。"""
237
+ reg = dict(load_registry(proj) or {})
238
+ sj = os.path.join(project_dir(proj), "sheets")
239
+ if os.path.isdir(sj):
240
+ for fn in os.listdir(sj):
241
+ if fn.endswith(".json"):
242
+ k = os.path.splitext(fn)[0]
243
+ e = dict(reg.get(k) or {})
244
+ if not e.get("code"):
245
+ e["code"] = "0" * 39 # 占位,非真实编码
246
+ reg[k] = e
247
+ return reg
248
+
249
+
250
+ def cmd_check(args):
251
+ proj = args.proj
252
+ cfg = cfg_for(proj, required=False)
253
+ sheets = DSL.load_project(project_dir(proj), cfg["appCode"], check_registry(proj))
254
+ for key in resolve_targets(proj, sheets, args.targets):
255
+ d = sheets[key]()
256
+ n_rules = sum(1 for c in d["fields"]
257
+ if (c.get("Options") or {}).get("DisplayRule", {}).get("Rule"))
258
+ n_rows = sum(1 for g in d.get("layout", []) if isinstance(g, list))
259
+ subs = d.get("subs") or []
260
+ n_sub = len(subs)
261
+ n_scol = sum(len(s["cols"]) for s in subs)
262
+ extra = " 子表=%d(列%d)" % (n_sub, n_scol) if n_sub else ""
263
+ n_ex = len(d.get("extras") or [])
264
+ if n_ex:
265
+ extra += " 布局项=%d" % n_ex
266
+ line = ("%-12s 字段=%-3d 隐藏规则=%d 行组(fourCols)=%d 关联=%s%s 标题=%s"
267
+ % (key, len(d["fields"]), n_rules, n_rows,
268
+ ",".join(d["assoc"]) or "-", extra, d["title"]))
269
+ if d.get("group"):
270
+ line += " 分组=%s" % d["group"]
271
+ print(line)
272
+
273
+
274
+ def cmd_list(args):
275
+ proj = args.proj
276
+ reg = load_registry(proj)
277
+ if not reg:
278
+ print("registry 为空 —— 尚未建表")
279
+ return
280
+ for k, v in sorted(reg.items()):
281
+ if k.startswith("_"):
282
+ continue # 系统段(_groups 等),单独打印
283
+ sub = " ".join("%s=%s" % (sk, (sv or {}).get("code", ""))
284
+ for sk, sv in sorted(((v or {}).get("subs") or {}).items()))
285
+ print("%-12s code=%s created=%s %s %s%s"
286
+ % (k, v.get("code"), v.get("created"), v.get("detail"),
287
+ v.get("err"), (" 子表: " + sub) if sub else ""))
288
+ gs = reg.get("_groups")
289
+ if gs:
290
+ print("分组:")
291
+ for name, g in sorted(gs.items()):
292
+ g = g or {}
293
+ print(" %-10s code=%s objectId=%s"
294
+ % (name, g.get("code"), g.get("objectId")))
295
+
296
+
297
+ def _expect_field_count(form):
298
+ """回读设计树期望字段总数(与 V.collect_live 同口径):
299
+ 主字段 + 布局项(分组标题/描述:无 DataField,回读按 key 计一个节点)
300
+ + 每子表(1 子表主属性 + 列数)。
301
+ useOwner=true 时平台随表单附带系统拥有者两字段(OwnerId/OwnerDeptId),
302
+ 回读设计树计入其 Options.DataField(verify 白名单同源)——须补 2。"""
303
+ n = (len(form["fields"]) + len(form.get("extras") or [])
304
+ + sum(1 + len(s["cols"]) for s in (form.get("subs") or [])))
305
+ return n + (2 if form.get("useOwner") else 0)
306
+
307
+
308
+ def _live_design_count(con, code):
309
+ """LoadForm 回读设计树字段数(collect_live 口径);线上不存在/失败 → None"""
310
+ try:
311
+ rd = con.load_form(code)
312
+ if not (rd and rd.get("Successful") is True):
313
+ return None
314
+ return len(V.collect_live(rd.get("ReturnData"))[0])
315
+ except Exception:
316
+ return None
317
+
318
+
319
+ def build_one(proj, cfg, sheets, key, force=False, quiet=False, reg=None):
320
+ from h3platform.h3console import H3Console, save_form_console
321
+ from h3design import newbuilder as NB
322
+ reg = load_registry(proj) if reg is None else reg
323
+ con = H3Console(cfg)
324
+ app = cfg["appCode"]
325
+ rec = reg.get(key) or {}
326
+ # 编码一律复用(换码 = 断关联引用 + 线上留一张孤儿表);force 只跳过下面
327
+ # "已存在即跳过" 的短路,不做换码。想重建新表:先删 registry 里该表的记录。
328
+ code = rec.get("code")
329
+ live_n = _live_design_count(con, code) if code else None
330
+ if code and live_n is not None and not force:
331
+ # 已存在:只读快速核对字段数,一致则登记 created=True(不重存)
332
+ try:
333
+ form = sheets[key]()
334
+ except Exception as e:
335
+ print("%-12s 已存在 code=%s —— 跳过(定义装载失败,未核对: %s)"
336
+ % (key, code, str(e)[:200]))
337
+ return rec
338
+ n_expect = _expect_field_count(form)
339
+ if live_n == n_expect:
340
+ reg[key] = {"code": code, "created": True, "title": form["title"],
341
+ "detail": "线上已存在,字段结构一致", "err": "",
342
+ "subs": rec.get("subs", {})}
343
+ save_registry(proj, reg)
344
+ print("%-12s 已存在且结构一致 code=%s —— 跳过" % (key, code))
345
+ else:
346
+ print("%-12s 已存在但字段数不符 %d != %d —— 跳过(--force 才整表重存,"
347
+ "会覆盖界面必填/联动配置)" % (key, live_n, n_expect))
348
+ return reg[key]
349
+ if code and live_n is None:
350
+ print("%-12s 注册表编码 %s 线上探测不到 —— 按不存在处理,发新编码" % (key, code))
351
+ code = None
352
+ if not code:
353
+ code = new_sheet_code(app)
354
+ form = sheets[key]()
355
+ subs = form.get("subs") or []
356
+ schema = NB.build_schema_str(form["fields"], form["name_schema"],
357
+ assoc=form["assoc"], app_package=app, subs=subs)
358
+ biz = NB.build_biz_sheet(code, form["fields"], layout=form.get("layout"),
359
+ subs=subs)
360
+ # 第三段:位置控件的 Meters/PCEnabled/Editable 只能落在 ControlSettingsStr
361
+ settings = NB.build_control_settings(form["fields"])
362
+ err, detail, ok = "", "", False
363
+ try:
364
+ resp = save_form_console(con, code, cfg["engineCode"], app, app,
365
+ form["title"], schema, biz, pc_layout=1,
366
+ mobile_layout=1,
367
+ control_settings_str=settings)
368
+ except Exception as e:
369
+ err = str(e)[:500]
370
+ resp = None
371
+ ok = bool(resp and resp.get("Successful") is True)
372
+ # 期望字段总数 = 主字段 + 每个子表(1 主属性 + 列数);useOwner 另含两系统字段
373
+ n_expect = _expect_field_count(form)
374
+ if ok:
375
+ import time
376
+ time.sleep(0.5)
377
+ rd = con.load_form(code).get("ReturnData")
378
+ dmc = rd.get("DesignModeContent")
379
+ dmc = json.loads(dmc) if isinstance(dmc, str) else dmc
380
+ live = V.collect_live(rd)
381
+ n_sub = len(subs)
382
+ detail = ("fields=%d(子表%d/列%d) nameSchema=%s"
383
+ % (n_expect, n_sub, sum(len(s["cols"]) for s in subs),
384
+ rd.get("NameSchema")))
385
+ if len(live[0]) != n_expect:
386
+ ok, err = False, "字段数不符 %d != %d(可能子表编码被 UI 改动)" \
387
+ % (len(live[0]), n_expect)
388
+ reg[key] = {"code": code, "created": ok, "title": form["title"],
389
+ "detail": detail, "err": err[:500],
390
+ "subs": (reg.get(key) or {}).get("subs", {})}
391
+ save_registry(proj, reg)
392
+ print("%-12s created=%s code=%s %s %s" % (key, ok, code, detail, err))
393
+ return reg[key]
394
+
395
+
396
+ def cmd_build(args):
397
+ proj = args.proj
398
+ _require_auth(proj)
399
+ cfg = cfg_for(proj)
400
+ reg = load_registry(proj)
401
+ sheets = DSL.load_project(project_dir(proj), cfg["appCode"], reg)
402
+ for key in resolve_targets(proj, sheets, args.targets):
403
+ # 预注册子表 child schema 编码(须在 loader() 调用前写入 reg —— loader 惰性闭包
404
+ # 捕获同一个 reg dict;registry 条目结构 reg[key]["subs"][subkey]={"code":..})
405
+ p = os.path.join(project_dir(proj), "sheets", key + ".json")
406
+ if os.path.isfile(p):
407
+ sreg = reg.setdefault(key, {}).setdefault("subs", {})
408
+ for spec in DSL.sheet_sub_specs(p):
409
+ sk = spec.get("key")
410
+ if not sk:
411
+ _err("%s: 子表 spec 缺 key(%s)" % (key, spec))
412
+ ent = sreg.setdefault(sk, {})
413
+ # --force 重存须连子表编码一起换新:child schema 编码平台全局唯一,
414
+ # 沿用旧子码会与线上旧设计撞码(SaveForm 报"控件编码重复")
415
+ if args.force or not ent.get("code"):
416
+ ent["code"] = new_child_code(cfg["appCode"])
417
+ build_one(proj, cfg, sheets, key, force=args.force, reg=reg)
418
+
419
+
420
+ def cmd_verify(args):
421
+ proj = args.proj
422
+ _require_auth(proj)
423
+ cfg = cfg_for(proj)
424
+ reg = load_registry(proj)
425
+ from h3platform.h3console import H3Console
426
+ con = H3Console(cfg)
427
+ sheets = DSL.load_project(project_dir(proj), cfg["appCode"], reg)
428
+ lines = []
429
+ allok = True
430
+ for key in resolve_targets(proj, sheets, args.targets):
431
+ rec = reg.get(key) or {}
432
+ code = rec.get("code")
433
+ if not code:
434
+ lines.append("== %s == 未建表(registry 无编码)" % key)
435
+ allok = False
436
+ continue
437
+ rd = con.load_form(code)
438
+ if not (rd and rd.get("Successful") is True):
439
+ lines.append("== %s == LoadForm 失败: %s" % (key, rd))
440
+ allok = False
441
+ continue
442
+ r = V.compare(sheets[key](), rd["ReturnData"])
443
+ lines.append(V.format_report("表", code, sheets[key](), rd["ReturnData"]))
444
+ allok = allok and r["ok"]
445
+ report = "\n".join(lines)
446
+ logp = os.path.join(project_dir(proj), "logs", "verify_report.txt")
447
+ os.makedirs(os.path.dirname(logp), exist_ok=True)
448
+ with open(logp, "w", encoding="utf-8") as f:
449
+ f.write(report)
450
+ sys.stdout.write(report[:6000])
451
+ print("\n报告: %s | 汇总: %s" % (logp, "ALL PASS" if allok else "有 FAIL"))
452
+
453
+
454
+ def cmd_group(args):
455
+ """表单归组:确保 sheets JSON 声明的分组存在,把对应表移入所属分组。
456
+ 幂等:分组编码/objectId 存 registry["_groups"],已有组不重建;
457
+ 每次运行把声明的表重新归位(界面上拖走的会被拉回声明组)。
458
+ 平台没有分组读/删接口(2026-09-09 实证)——编码只认 registry,勿手删 _groups 段。"""
459
+ proj = args.proj
460
+ _require_auth(proj)
461
+ cfg = cfg_for(proj)
462
+ from h3platform import api as H3A
463
+ h3 = H3A.H3(cfg)
464
+ app = cfg["appCode"]
465
+ reg = load_registry(proj)
466
+ sheets = DSL.load_project(project_dir(proj), app, reg)
467
+ order = DSL.group_order(project_dir(proj)) # groups.json 顺序;None=不校验
468
+ pairs = []
469
+ for key in resolve_targets(proj, sheets, args.targets):
470
+ d = sheets[key]()
471
+ g = (d.get("group") or "").strip()
472
+ code = (reg.get(key) or {}).get("code")
473
+ if not g:
474
+ print("%-12s 未声明 group(sheets JSON 顶层)—— 跳过" % key)
475
+ elif not code:
476
+ print("%-12s 未建表(registry 无编码,先 build)—— 跳过" % key)
477
+ else:
478
+ pairs.append((key, g, code))
479
+ used = []
480
+ for _, g, _ in pairs:
481
+ if g not in used:
482
+ used.append(g)
483
+ if order is not None:
484
+ used = [g for g in order if g in used]
485
+ if not pairs:
486
+ return
487
+ greg = reg.setdefault("_groups", {})
488
+ lines = []
489
+ for g in used:
490
+ ent = greg.get(g) or {}
491
+ if ent.get("code") and ent.get("objectId"):
492
+ lines.append("分组 %-8s 已存在 code=%s —— 跳过" % (g, ent["code"]))
493
+ continue
494
+ try:
495
+ code, oid = h3.create_group(app, g)
496
+ time.sleep(0.3)
497
+ h3.update_group(app, code, oid, g) # create→update 两步落定(UI 同款)
498
+ greg[g] = {"code": code, "objectId": oid}
499
+ lines.append("新建分组 %-8s code=%s objectId=%s" % (g, code, oid))
500
+ except H3A.H3Error as e:
501
+ lines.append("分组 %-8s 创建失败: %s" % (g, str(e)[:200]))
502
+ time.sleep(0.3)
503
+ save_registry(proj, reg)
504
+ for key, g, code in pairs:
505
+ gc = (greg.get(g) or {}).get("code")
506
+ if not gc:
507
+ lines.append("%-12s → %s: 分组不存在(上一步失败),跳过" % (key, g))
508
+ continue
509
+ try:
510
+ h3.move_node(code, gc)
511
+ lines.append("%-12s → 分组 %s" % (key, g))
512
+ except H3A.H3Error as e:
513
+ lines.append("%-12s → 分组 %s 失败: %s" % (key, g, str(e)[:200]))
514
+ time.sleep(0.2)
515
+ report = "\n".join(lines)
516
+ logp = os.path.join(project_dir(proj), "logs", "group_report.txt")
517
+ os.makedirs(os.path.dirname(logp), exist_ok=True)
518
+ with open(logp, "w", encoding="utf-8") as f:
519
+ f.write(report)
520
+ print(report)
521
+ print("\n报告: %s" % logp)
522
+ print("提示: 分组码只存 registry(平台无读/删分组接口);多余分组在界面删。")
523
+
524
+
525
+ def cmd_import(args):
526
+ proj, sheet, dataf = args.proj, args.sheet, args.data
527
+ _require_auth(proj)
528
+ cfg = cfg_for(proj)
529
+ reg = load_registry(proj)
530
+ code = (reg.get(sheet) or {}).get("code")
531
+ if not code:
532
+ _err("表单 %r 未建表(registry 无编码)" % sheet)
533
+ rows = read_json(dataf)
534
+ if isinstance(rows, dict):
535
+ rows = [rows]
536
+ from h3platform import api as H3A
537
+ h3 = H3A.H3(cfg)
538
+ n, failed = h3.create_biz_objects(code, rows, is_submit=args.submit)
539
+ print("导入 %s 成功 %d 条" % (sheet, n))
540
+ if failed:
541
+ print("失败 %d 条,首条: %s" % (len(failed), str(failed[0][1])[:300]))
542
+
543
+
544
+ def cmd_gen(args):
545
+ proj, key = args.proj, args.sheet
546
+ cfg = cfg_for(proj, required=False) # 本地渲染骨架,不需要真实凭据
547
+ reg = load_registry(proj)
548
+ sheets = DSL.load_project(project_dir(proj), cfg["appCode"], reg)
549
+ if key not in sheets:
550
+ _err("表单 %r 不在项目 %r" % (key, proj))
551
+ d = sheets[key]()
552
+ code = (reg.get(key) or {}).get("code") or ""
553
+ if not code:
554
+ print("警告: %s 尚未建表,生成的 schema 常量留空 —— 先跑 build" % key)
555
+ consts = []
556
+ for c in d["fields"]:
557
+ fkey, label = c["Key"], (c.get("Options") or {}).get("DisplayName", "")
558
+ consts.append(' private const string F_%-14s = %-24s // %s'
559
+ % (fkey.upper(), '"%s";' % fkey, label))
560
+ for s in d.get("subs") or []:
561
+ # 子表: GRID_<SUBKEY> = child schema 编码;行集合访问见模板 TODO
562
+ subcode = s.get("code") or ""
563
+ consts.append(' private const string GRID_%-8s = %-30s // 子表 %s'
564
+ % (s["key"].upper(), '"%s";' % subcode, s["label"]))
565
+ for col in s["cols"]:
566
+ consts.append(' // 子表行列 %s = %s'
567
+ % (col["Key"], (col.get("Options") or {}).get("DisplayName", "")))
568
+ schema_consts = []
569
+ for k, v in sorted(reg.items()):
570
+ c = (v or {}).get("code")
571
+ if c:
572
+ # 常量名用完整 key(不截断):截断 12 字符会让 contractcollect/contractpay、
573
+ # dailyreport/dailywage、invoiceapply/invoicereceipt 撞名编译失败
574
+ schema_consts.append(' private const string %-24s = "%s"; // %s'
575
+ % ("SCHEMA_" + k.upper(), c, v.get("title")))
576
+ with open(os.path.join(ROOT, "templates", "controller.cs.tpl"), encoding="utf-8") as f:
577
+ tpl = string.Template(f.read())
578
+ out = tpl.substitute(SCHEMA_CODE=code or key, SHEET_KEY=key, TITLE=d["title"],
579
+ FIELD_CONSTS="\n".join(consts),
580
+ SCHEMA_CONSTS="\n".join(schema_consts))
581
+ outp = os.path.join(project_dir(proj), "generated", key + ".cs")
582
+ os.makedirs(os.path.dirname(outp), exist_ok=True)
583
+ with open(outp, "w", encoding="utf-8") as f:
584
+ f.write(out)
585
+ print("已生成 %s" % outp)
586
+ print("下一步:按 TODO 补业务规则 → 粘贴到表单设计器 → 业务规则/后端事件 → 提交时")
587
+
588
+
589
+ def cmd_demo(args):
590
+ src = os.path.join(ROOT, "projects", "demo")
591
+ if not os.path.isdir(src):
592
+ _err("安装包缺 projects/demo 示例")
593
+ dest = settings.project_dir("demo")
594
+ if os.path.isdir(dest):
595
+ print("示例项目已存在: %s" % dest)
596
+ return
597
+ shutil.copytree(src, dest)
598
+ print("已创建示例项目: %s" % dest)
599
+ print("下一步: h3factory check demo")
600
+
601
+
602
+ def _jwt_payload(token):
603
+ """解码 JWT payload(不校验签名),用于判断 authorization 是否过期。"""
604
+ tok = (token or "").strip()
605
+ if tok.lower().startswith("bearer "):
606
+ tok = tok[7:].strip()
607
+ parts = tok.split(".")
608
+ if len(parts) < 2:
609
+ return None
610
+ try:
611
+ pad = "=" * (-len(parts[1]) % 4)
612
+ return json.loads(base64.urlsafe_b64decode(parts[1] + pad).decode("utf-8"))
613
+ except Exception:
614
+ return None
615
+
616
+
617
+ def _config_state(proj=None):
618
+ """机器可读的凭据状态,供 agent 第一步做门禁。"""
619
+ path = None
620
+ cfg = {}
621
+ error = ""
622
+ if proj:
623
+ pc = os.path.join(settings.project_dir(proj), "config.json")
624
+ if os.path.isfile(pc):
625
+ path = pc
626
+ try:
627
+ cfg = read_json(pc)
628
+ except Exception as e:
629
+ error = "读取项目配置失败 %s: %s" % (pc, e)
630
+ if path is None:
631
+ path = settings.resolve_config_path()
632
+ if path:
633
+ try:
634
+ cfg = settings.load_config()
635
+ except settings.ConfigError as e:
636
+ error = str(e)
637
+ missing = [k for k in REQUIRED_KEYS if not cfg.get(k)]
638
+ info = _jwt_payload(cfg.get("authorization"))
639
+ exp = (info or {}).get("exp") if info else None
640
+ expired = bool(exp and exp < time.time())
641
+ expires_at = None
642
+ if exp:
643
+ expires_at = datetime.datetime.fromtimestamp(exp).astimezone().isoformat()
644
+ return {
645
+ "ok": bool(path) and not error and not missing and not expired,
646
+ "config_path": path,
647
+ "workspace": settings.workspace_root(),
648
+ "missing": missing,
649
+ "jwt_parseable": bool(info),
650
+ "jwt_exp": exp,
651
+ "jwt_expires_at": expires_at,
652
+ "jwt_expired": expired,
653
+ "app_code": cfg.get("appCode") or "",
654
+ "error": error,
655
+ }
656
+
657
+
658
+ def _require_auth(proj=None):
659
+ """联网命令的硬门禁:凭据缺失或 token 过期直接拒绝执行。"""
660
+ state = _config_state(proj)
661
+ if state["ok"]:
662
+ return
663
+ if state["error"]:
664
+ reason = state["error"]
665
+ elif state["missing"]:
666
+ reason = "缺字段: %s" % ", ".join(state["missing"])
667
+ elif state["jwt_expired"]:
668
+ reason = "authorization 已过期(%s)" % state["jwt_expires_at"]
669
+ else:
670
+ reason = "凭据不可用"
671
+ _err("%s。先运行 h3factory auth status --json;客户提供 token 后"
672
+ "用 h3factory auth set --json(从 stdin 写入)" % reason)
673
+
674
+
675
+ def cmd_auth(args):
676
+ if args.action == "status":
677
+ state = _config_state()
678
+ if args.json:
679
+ print(json.dumps(state, ensure_ascii=False, indent=2))
680
+ else:
681
+ print("配置文件: %s" % (state["config_path"] or "未找到"))
682
+ print("工作区 : %s" % state["workspace"])
683
+ if state["error"]:
684
+ print("[FAIL] %s" % state["error"])
685
+ if state["missing"]:
686
+ print("[FAIL] 缺字段: %s" % ", ".join(state["missing"]))
687
+ else:
688
+ print("[PASS] 凭据字段齐全(appCode=%s)" % state["app_code"])
689
+ if state["jwt_expired"]:
690
+ print("[FAIL] authorization 已过期: %s" % state["jwt_expires_at"])
691
+ elif state["jwt_exp"]:
692
+ print("[PASS] authorization 有效至 %s" % state["jwt_expires_at"])
693
+ elif state["app_code"]:
694
+ print("[WARN] authorization 不是可解析的 JWT")
695
+ print("结果: %s" % ("OK" if state["ok"] else "NEED_TOKEN"))
696
+ sys.exit(0 if state["ok"] else 2)
697
+
698
+ if args.action in ("set", "set-token"):
699
+ path = settings.resolve_config_path() or settings.default_config_path()
700
+ existing = {}
701
+ if os.path.isfile(path):
702
+ try:
703
+ existing = read_json(path)
704
+ except Exception as e:
705
+ _err("读取现有配置失败 %s: %s" % (path, e))
706
+ elif os.path.isfile(EXAMPLE_CONFIG):
707
+ existing = read_json(EXAMPLE_CONFIG)
708
+
709
+ if args.action == "set-token":
710
+ if sys.stdin.isatty():
711
+ import getpass
712
+ token = getpass.getpass("authorization token(输入不回显): ").strip()
713
+ else:
714
+ token = sys.stdin.read().strip()
715
+ if not token:
716
+ _err("未收到 token(交互式直接运行 h3factory auth set-token;"
717
+ "非交互式用 ... | h3factory auth set-token)")
718
+ existing["authorization"] = token
719
+ changed = ["authorization"]
720
+ else:
721
+ if sys.stdin.isatty():
722
+ _err("auth set 需要从 stdin 传 JSON;交互式请运行 h3factory init "
723
+ "或 h3factory auth set-token")
724
+ try:
725
+ payload = json.load(sys.stdin)
726
+ except Exception as e:
727
+ _err("stdin 需要 JSON 对象: %s" % e)
728
+ if not isinstance(payload, dict):
729
+ _err("stdin 需要 JSON 对象,例如 {\"authorization\":\"...\"}")
730
+ unknown = [k for k in payload if k not in REQUIRED_KEYS + ("appName",)]
731
+ if unknown:
732
+ _err("不支持的字段: %s" % ", ".join(unknown))
733
+ changed = []
734
+ for key, value in payload.items():
735
+ if value is None:
736
+ continue
737
+ existing[key] = str(value).strip()
738
+ changed.append(key)
739
+ if not changed:
740
+ _err("stdin 没有可写入的字段")
741
+
742
+ write_json(path, existing)
743
+ print("已更新 %s: %s" % (path, ", ".join(changed)))
744
+ print("(凭据值不回显;如需确认请运行 h3factory auth status)")
745
+ state = _config_state()
746
+ if state["ok"]:
747
+ print("凭据检查: OK(authorization 有效至 %s)" % state["jwt_expires_at"])
748
+ else:
749
+ print("凭据检查: 仍需处理(缺字段=%s, token过期=%s)"
750
+ % (", ".join(state["missing"]) or "无", state["jwt_expired"]))
751
+ sys.exit(2)
752
+ return
753
+
754
+
755
+ def cmd_doctor(args):
756
+ ok = True
757
+ print("h3factory doctor")
758
+ print(" 版本 : %s" % VERSION)
759
+ print(" Python : %s" % sys.version.split()[0])
760
+ print(" 解释器 : %s" % sys.executable)
761
+ print(" 代码目录 : %s" % ROOT)
762
+ print(" 工作区 : %s" % settings.workspace_root())
763
+ pdir = settings.projects_dir()
764
+ print(" 项目目录 : %s%s" % (pdir, "" if os.path.isdir(pdir)
765
+ else "(不存在,首次建项目时创建)"))
766
+ cfg_path = settings.resolve_config_path()
767
+ print(" 配置文件 : %s" % (cfg_path or "未找到"))
768
+ cfg = {}
769
+ if not cfg_path:
770
+ print(" [FAIL] 缺配置:运行 h3factory init 后填写")
771
+ ok = False
772
+ else:
773
+ try:
774
+ cfg = settings.load_config()
775
+ except settings.ConfigError as e:
776
+ print(" [FAIL] %s" % e)
777
+ ok = False
778
+ cfg = {}
779
+ missing = [k for k in REQUIRED_KEYS if not cfg.get(k)]
780
+ if missing:
781
+ print(" [FAIL] 配置缺字段: %s" % ", ".join(missing))
782
+ ok = False
783
+ elif cfg:
784
+ print(" [PASS] 配置字段齐全(appCode=%s)" % cfg.get("appCode"))
785
+ info = _jwt_payload(cfg.get("authorization"))
786
+ exp = (info or {}).get("exp")
787
+ if exp:
788
+ expire_at = datetime.datetime.fromtimestamp(exp).astimezone()
789
+ if exp < time.time():
790
+ print(" [FAIL] authorization 已过期(%s),请去氚云后台重新生成"
791
+ % expire_at.strftime("%Y-%m-%d %H:%M"))
792
+ ok = False
793
+ else:
794
+ days = max(0, int((exp - time.time()) // 86400))
795
+ print(" [PASS] authorization 有效至 %s(剩余 %d 天)"
796
+ % (expire_at.strftime("%Y-%m-%d %H:%M"), days))
797
+ elif cfg.get("authorization"):
798
+ print(" [WARN] authorization 不是可解析的 JWT,无法判断是否过期")
799
+ home = os.path.expanduser("~")
800
+ for name in ("h3factory", "h3yun-dev-assistant"):
801
+ p = os.path.join(home, ".claude", "skills", name, "SKILL.md")
802
+ if os.path.isfile(p):
803
+ print(" [PASS] 技能 %s 已安装" % name)
804
+ else:
805
+ print(" [WARN] 技能 %s 未安装:运行 h3factory skill install" % name)
806
+ if not ok:
807
+ sys.exit(1)
808
+
809
+
810
+ def cmd_docs(args):
811
+ d = os.path.join(ROOT, "docs")
812
+ if not args.name:
813
+ print("文档目录: %s" % d)
814
+ for fn in sorted(os.listdir(d)):
815
+ if fn.endswith(".md"):
816
+ print(" %s" % fn)
817
+ return
818
+ name = args.name if args.name.endswith(".md") else args.name + ".md"
819
+ p = os.path.join(d, name)
820
+ if not os.path.isfile(p):
821
+ names = ", ".join(sorted(f for f in os.listdir(d) if f.endswith(".md")))
822
+ _err("文档不存在: %s(可用: %s)" % (name, names))
823
+ with open(p, encoding="utf-8") as f:
824
+ sys.stdout.write(f.read())
825
+
826
+
827
+ def _copy_dir(src, dst):
828
+ if os.path.isdir(dst):
829
+ shutil.rmtree(dst)
830
+ shutil.copytree(src, dst)
831
+
832
+
833
+ def cmd_skill(args):
834
+ src_root = os.path.join(ROOT, "skills")
835
+ if not os.path.isdir(src_root):
836
+ _err("安装包缺 skills/ 目录")
837
+ skills = [d for d in sorted(os.listdir(src_root))
838
+ if os.path.isfile(os.path.join(src_root, d, "SKILL.md"))]
839
+ if not skills:
840
+ _err("skills/ 下没有可安装的技能")
841
+
842
+ if args.action == "list":
843
+ home = os.path.expanduser("~")
844
+ found = False
845
+ for dirname, tool in TOOL_SKILL_DIRS:
846
+ for name in skills:
847
+ p = os.path.join(home, dirname, "skills", name, "SKILL.md")
848
+ if os.path.isfile(p):
849
+ print("[%s] %s" % (tool, p))
850
+ found = True
851
+ if not found:
852
+ print("未发现已安装技能;运行 h3factory skill install --tool all")
853
+ return
854
+
855
+ home = os.path.expanduser("~")
856
+ targets = []
857
+ if args.scope == "project":
858
+ base = os.getcwd()
859
+ for dirname, tool in TOOL_SKILL_DIRS:
860
+ if args.tool in ("all", "auto", tool):
861
+ targets.append(os.path.join(base, dirname, "skills"))
862
+ else:
863
+ for dirname, tool in TOOL_SKILL_DIRS:
864
+ tool_dir = os.path.join(home, dirname)
865
+ if (args.tool == "all"
866
+ or args.tool == tool
867
+ or (args.tool == "auto"
868
+ and (tool == "claude" or os.path.isdir(tool_dir)))):
869
+ targets.append(os.path.join(tool_dir, "skills"))
870
+ if not targets:
871
+ _err("没有匹配的安装目标(tool=%s, scope=%s)" % (args.tool, args.scope))
872
+ for dest_root in targets:
873
+ os.makedirs(dest_root, exist_ok=True)
874
+ for name in skills:
875
+ _copy_dir(os.path.join(src_root, name), os.path.join(dest_root, name))
876
+ print("已安装技能到 %s: %s" % (dest_root, ", ".join(skills)))
877
+
878
+
879
+ def main():
880
+ ap = argparse.ArgumentParser(prog="h3factory", description="h3factory —— 氚云表单工厂")
881
+ ap.add_argument("--version", action="version", version=VERSION)
882
+ sub = ap.add_subparsers(dest="cmd")
883
+ p = sub.add_parser("init", help="生成配置文件并写入 AGENTS.md")
884
+ p.add_argument("--no-input", action="store_true", help="不交互,只复制模板")
885
+ p.add_argument("--no-agent-md", action="store_true",
886
+ help="不写入工作区 AGENTS.md")
887
+ p = sub.add_parser("agent", help="写入 AGENTS.md 指令(任意 Agent 通用)")
888
+ p.add_argument("action", choices=["init"])
889
+ p = sub.add_parser("prompt", help="打印通用提示语")
890
+ p.add_argument("--short", action="store_true", help="只输出一句话版本")
891
+ sub.add_parser("demo", help="把内置示例项目复制到工作区")
892
+ p = sub.add_parser("auth", help="凭据状态与录入(agent 第一步)")
893
+ p.add_argument("action", choices=["status", "set", "set-token"],
894
+ help="status 检查;set 从 stdin 读 JSON;set-token 从 stdin 读 token")
895
+ p.add_argument("--json", action="store_true", help="status 输出 JSON")
896
+ sub.add_parser("doctor", help="检查 Python/配置/JWT/技能")
897
+ p = sub.add_parser("docs", help="列出或打印内置文档")
898
+ p.add_argument("name", nargs="?")
899
+ p = sub.add_parser("skill", help="安装/更新 AI 工具技能")
900
+ p.add_argument("action", choices=["install", "list"])
901
+ p.add_argument("--tool", default="all",
902
+ choices=["auto", "all"] + [t for _, t in TOOL_SKILL_DIRS])
903
+ p.add_argument("--scope", default="user", choices=["user", "project"],
904
+ help="user=各工具全局目录;project=当前目录")
905
+ helps = {"check": "本地校验表单定义(不联网)",
906
+ "list": "查看 registry 表编码与状态",
907
+ "build": "建表(已存在且结构一致则跳过)",
908
+ "verify": "LoadForm 只读回读比对",
909
+ "group": "建分组并把表归位(幂等)"}
910
+ for name in ("check", "list", "build", "verify", "group"):
911
+ p = sub.add_parser(name, help=helps[name])
912
+ p.add_argument("proj")
913
+ p.add_argument("targets", nargs="*")
914
+ if name == "build":
915
+ p.add_argument("--force", action="store_true", default=False,
916
+ help="整表重存(会覆盖界面手工配置,慎用)")
917
+ p = sub.add_parser("import", help="批量导入表单数据")
918
+ p.add_argument("proj")
919
+ p.add_argument("sheet")
920
+ p.add_argument("data")
921
+ p.add_argument("--submit", action="store_true", default=False)
922
+ p = sub.add_parser("gen", help="生成 .cs 提交时代码骨架")
923
+ p.add_argument("proj")
924
+ p.add_argument("sheet")
925
+ args = ap.parse_args()
926
+ if not args.cmd:
927
+ state = _config_state()
928
+ print("h3factory %s —— 氚云表单工厂" % VERSION)
929
+ print("工作区: %s" % state["workspace"])
930
+ if state["ok"]:
931
+ print("凭据: OK(appCode=%s,token 有效至 %s)"
932
+ % (state["app_code"], state["jwt_expires_at"]))
933
+ print("可用命令: h3factory --help")
934
+ sys.exit(0)
935
+ print("凭据: 需要录入")
936
+ print(" 缺字段 : %s" % (", ".join(state["missing"]) or "无"))
937
+ print(" token过期: %s" % ("是" if state["jwt_expired"] else "否"))
938
+ print("agent 第一步: h3factory auth status --json")
939
+ print("录入 token: 客户在自己终端运行 h3factory auth set-token(输入不回显)")
940
+ print("或: h3factory init(交互式录入全部凭据)")
941
+ sys.exit(2)
942
+ {"init": cmd_init, "demo": cmd_demo, "auth": cmd_auth,
943
+ "agent": cmd_agent, "prompt": cmd_prompt,
944
+ "doctor": cmd_doctor, "docs": cmd_docs,
945
+ "skill": cmd_skill, "check": cmd_check, "list": cmd_list,
946
+ "build": cmd_build, "verify": cmd_verify, "group": cmd_group,
947
+ "import": cmd_import, "gen": cmd_gen}[args.cmd](args)
948
+
949
+
950
+ if __name__ == "__main__":
951
+ main()