hap-cli 0.5.0__tar.gz → 0.5.1__tar.gz

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 (63) hide show
  1. {hap_cli-0.5.0 → hap_cli-0.5.1}/PKG-INFO +18 -10
  2. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/README.md +17 -9
  3. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/README_CN.md +19 -10
  4. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/config_cmd.py +1 -9
  5. hap_cli-0.5.1/hap_cli/core/auth.py +534 -0
  6. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/session.py +0 -6
  7. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/PKG-INFO +18 -10
  8. {hap_cli-0.5.0 → hap_cli-0.5.1}/setup.py +1 -1
  9. hap_cli-0.5.0/hap_cli/core/auth.py +0 -219
  10. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/__init__.py +0 -0
  11. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/__init__.py +0 -0
  12. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/ai_cmd.py +0 -0
  13. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/app_cmd.py +0 -0
  14. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/calendar_cmd.py +0 -0
  15. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/chat_cmd.py +0 -0
  16. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/contact_cmd.py +0 -0
  17. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/department_cmd.py +0 -0
  18. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/group_cmd.py +0 -0
  19. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/instance_cmd.py +0 -0
  20. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/node_cmd.py +0 -0
  21. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/optionset_cmd.py +0 -0
  22. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/page_cmd.py +0 -0
  23. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/plugin_cmd.py +0 -0
  24. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/post_cmd.py +0 -0
  25. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/record_cmd.py +0 -0
  26. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/role_cmd.py +0 -0
  27. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/workflow_cmd.py +0 -0
  28. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/worksheet_cmd.py +0 -0
  29. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/context.py +0 -0
  30. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/__init__.py +0 -0
  31. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/ai.py +0 -0
  32. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/app.py +0 -0
  33. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/calendar_mod.py +0 -0
  34. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/chat.py +0 -0
  35. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/contact.py +0 -0
  36. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/department.py +0 -0
  37. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/flow_node.py +0 -0
  38. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/group.py +0 -0
  39. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/instance.py +0 -0
  40. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/optionset.py +0 -0
  41. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/page.py +0 -0
  42. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/plugin.py +0 -0
  43. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/post.py +0 -0
  44. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/record.py +0 -0
  45. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/role.py +0 -0
  46. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/workflow.py +0 -0
  47. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/worksheet.py +0 -0
  48. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/hap_cli.py +0 -0
  49. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/skills/SKILL.md +0 -0
  50. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/skills/__init__.py +0 -0
  51. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/__init__.py +0 -0
  52. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_core.py +0 -0
  53. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_full_e2e.py +0 -0
  54. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_integration.py +0 -0
  55. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/__init__.py +0 -0
  56. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/formatting.py +0 -0
  57. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/options.py +0 -0
  58. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/SOURCES.txt +0 -0
  59. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/dependency_links.txt +0 -0
  60. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/entry_points.txt +0 -0
  61. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/requires.txt +0 -0
  62. {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/top_level.txt +0 -0
  63. {hap_cli-0.5.0 → hap_cli-0.5.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hap-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: CLI harness for MingDAO HAP - Enterprise no-code platform
5
5
  Author: hap-cli
6
6
  License: Apache-2.0
@@ -34,7 +34,7 @@ CLI harness for **MingDAO HAP** (明道云) - an enterprise no-code platform (ha
34
34
  ## Installation
35
35
 
36
36
  ```bash
37
- pip install -e .
37
+ pip install hap-cli
38
38
  ```
39
39
 
40
40
  ## Quick Start
@@ -60,9 +60,7 @@ Opens your browser to the MingDAO login page. Token is saved automatically after
60
60
  ```bash
61
61
  hap config set \
62
62
  --server https://your-mingdao-server.com \
63
- --token YOUR_MD_PSS_ID_TOKEN \
64
- --app-id YOUR_DEFAULT_APP_ID \
65
- --project-id YOUR_PROJECT_ID
63
+ --token YOUR_MD_PSS_ID_TOKEN
66
64
  ```
67
65
 
68
66
  **Other auth commands**
@@ -72,19 +70,27 @@ hap config whoami # Show current user info
72
70
  hap config logout # Clear saved token
73
71
  ```
74
72
 
75
- ### 2. List worksheets
73
+ ### 2. Find your org and app IDs
76
74
 
77
75
  ```bash
78
- hap app worksheets
76
+ hap config orgs # list orgs → get Project ID
77
+ hap app list --project-id PROJECT_ID # list apps → get App ID
78
+ hap app list-managed --project-id PROJECT_ID # apps where you are manager
79
79
  ```
80
80
 
81
- ### 3. Query records
81
+ ### 3. List worksheets
82
+
83
+ ```bash
84
+ hap app worksheets APP_ID
85
+ ```
86
+
87
+ ### 4. Query records
82
88
 
83
89
  ```bash
84
90
  hap record list WORKSHEET_ID --page-size 10
85
91
  ```
86
92
 
87
- ### 4. JSON output (for automation)
93
+ ### 5. JSON output (for automation)
88
94
 
89
95
  ```bash
90
96
  hap --json record list WORKSHEET_ID
@@ -194,8 +200,10 @@ pip install hap-cli[crypto]
194
200
  ## More Examples
195
201
 
196
202
  ```bash
197
- # List apps
203
+ # List orgs, then apps
204
+ hap config orgs
198
205
  hap app list --project-id PROJECT_ID
206
+ hap app list-managed --project-id PROJECT_ID
199
207
 
200
208
  # Get worksheet fields
201
209
  hap worksheet fields WORKSHEET_ID
@@ -5,7 +5,7 @@ CLI harness for **MingDAO HAP** (明道云) - an enterprise no-code platform (ha
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- pip install -e .
8
+ pip install hap-cli
9
9
  ```
10
10
 
11
11
  ## Quick Start
@@ -31,9 +31,7 @@ Opens your browser to the MingDAO login page. Token is saved automatically after
31
31
  ```bash
32
32
  hap config set \
33
33
  --server https://your-mingdao-server.com \
34
- --token YOUR_MD_PSS_ID_TOKEN \
35
- --app-id YOUR_DEFAULT_APP_ID \
36
- --project-id YOUR_PROJECT_ID
34
+ --token YOUR_MD_PSS_ID_TOKEN
37
35
  ```
38
36
 
39
37
  **Other auth commands**
@@ -43,19 +41,27 @@ hap config whoami # Show current user info
43
41
  hap config logout # Clear saved token
44
42
  ```
45
43
 
46
- ### 2. List worksheets
44
+ ### 2. Find your org and app IDs
47
45
 
48
46
  ```bash
49
- hap app worksheets
47
+ hap config orgs # list orgs → get Project ID
48
+ hap app list --project-id PROJECT_ID # list apps → get App ID
49
+ hap app list-managed --project-id PROJECT_ID # apps where you are manager
50
50
  ```
51
51
 
52
- ### 3. Query records
52
+ ### 3. List worksheets
53
+
54
+ ```bash
55
+ hap app worksheets APP_ID
56
+ ```
57
+
58
+ ### 4. Query records
53
59
 
54
60
  ```bash
55
61
  hap record list WORKSHEET_ID --page-size 10
56
62
  ```
57
63
 
58
- ### 4. JSON output (for automation)
64
+ ### 5. JSON output (for automation)
59
65
 
60
66
  ```bash
61
67
  hap --json record list WORKSHEET_ID
@@ -165,8 +171,10 @@ pip install hap-cli[crypto]
165
171
  ## More Examples
166
172
 
167
173
  ```bash
168
- # List apps
174
+ # List orgs, then apps
175
+ hap config orgs
169
176
  hap app list --project-id PROJECT_ID
177
+ hap app list-managed --project-id PROJECT_ID
170
178
 
171
179
  # Get worksheet fields
172
180
  hap worksheet fields WORKSHEET_ID
@@ -7,7 +7,7 @@
7
7
  ## 安装
8
8
 
9
9
  ```bash
10
- pip install -e .
10
+ pip install hap-cli
11
11
  ```
12
12
 
13
13
  ## 快速开始
@@ -33,9 +33,7 @@ hap config login https://hap.example.com # 私有部署
33
33
  ```bash
34
34
  hap config set \
35
35
  --server https://你的明道云服务器地址 \
36
- --token 你的_MD_PSS_ID_令牌 \
37
- --app-id 默认应用ID \
38
- --project-id 组织ID
36
+ --token 你的_MD_PSS_ID_令牌
39
37
  ```
40
38
 
41
39
  > `md_pss_id` 令牌可从浏览器登录明道云后的 Cookie 中获取。
@@ -52,19 +50,27 @@ hap config whoami
52
50
  hap config logout
53
51
  ```
54
52
 
55
- ### 2. 查看工作表列表
53
+ ### 2. 获取组织和应用 ID
56
54
 
57
55
  ```bash
58
- hap app worksheets
56
+ hap config orgs # 查看组织列表 → 获取 Project ID
57
+ hap app list --project-id 组织ID # 查看应用列表 → 获取 App ID
58
+ hap app list-managed --project-id 组织ID # 查看你作为管理员的应用
59
59
  ```
60
60
 
61
- ### 3. 查询数据记录
61
+ ### 3. 查看工作表列表
62
+
63
+ ```bash
64
+ hap app worksheets 应用ID
65
+ ```
66
+
67
+ ### 4. 查询数据记录
62
68
 
63
69
  ```bash
64
70
  hap record list 工作表ID --page-size 10
65
71
  ```
66
72
 
67
- ### 4. JSON 输出(用于自动化)
73
+ ### 5. JSON 输出(用于自动化)
68
74
 
69
75
  ```bash
70
76
  hap --json record list 工作表ID
@@ -422,11 +428,14 @@ hap record delete 工作表ID 记录ID1 记录ID2 --yes
422
428
  ## 应用与工作表
423
429
 
424
430
  ```bash
431
+ # 查看组织列表
432
+ hap config orgs
433
+
425
434
  # 列出组织下的应用
426
435
  hap app list --project-id 组织ID
427
436
 
428
- # 搜索应用
429
- hap app list --project-id 组织ID --keyword "人事"
437
+ # 查看你作为管理员的应用
438
+ hap app list-managed --project-id 组织ID
430
439
 
431
440
  # 查看应用详情
432
441
  hap app info 应用ID
@@ -23,14 +23,10 @@ def config():
23
23
  @config.command("set")
24
24
  @click.option("--server", "-s", required=True, help="MingDAO server URL")
25
25
  @click.option("--token", "-t", required=True, help="Authentication token (md_pss_id)")
26
- @click.option("--app-id", "-a", default="", help="Default application ID")
27
- @click.option("--project-id", "-p", default="", help="Default project/org ID")
28
26
  @pass_context
29
- def config_set(ctx, server, token, app_id, project_id):
27
+ def config_set(ctx, server, token):
30
28
  """Set server URL and authentication token."""
31
29
  session = Session(server_url=server, auth_token=token)
32
- session.default_app_id = app_id
33
- session.default_project_id = project_id
34
30
  session.save()
35
31
  ctx.output(
36
32
  {"status": "ok", "server": server},
@@ -46,8 +42,6 @@ def config_show(ctx):
46
42
  data = {
47
43
  "server_url": session.server_url,
48
44
  "auth_token": session.auth_token[:8] + "..." if session.auth_token else "",
49
- "default_app_id": session.default_app_id,
50
- "default_project_id": session.default_project_id,
51
45
  "configured": session.is_configured(),
52
46
  }
53
47
  ctx.output(
@@ -55,8 +49,6 @@ def config_show(ctx):
55
49
  lambda d: output_kv(d, labels={
56
50
  "server_url": "Server URL",
57
51
  "auth_token": "Auth Token",
58
- "default_app_id": "Default App ID",
59
- "default_project_id": "Default Project ID",
60
52
  "configured": "Configured",
61
53
  }),
62
54
  )
@@ -0,0 +1,534 @@
1
+ """Browser-based authentication flow for MingDAO HAP.
2
+
3
+ Implements a local callback server pattern:
4
+ 1. Start local HTTP server on a free port
5
+ 2. Open browser to HAP login page with callback URL
6
+ 3. Capture token from redirect, save to config
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import socket
12
+ import threading
13
+ import webbrowser
14
+ from http.server import HTTPServer, BaseHTTPRequestHandler
15
+ from typing import Optional
16
+ from urllib.parse import urlparse, parse_qs
17
+
18
+ import requests
19
+
20
+ from hap_cli.core.session import _main_api_base_for_url
21
+
22
+ # Known server presets: name -> (api_base, webui_url)
23
+ # api_base is kept for backward compatibility with resolve_server callers.
24
+ SERVER_PRESETS = {
25
+ "mingdao": ("https://www.mingdao.com/api", "https://www.mingdao.com"),
26
+ "nocoly": ("https://www.nocoly.com/wwwapi", "https://www.nocoly.com"),
27
+ }
28
+
29
+
30
+ def resolve_server(server: str) -> tuple[str, str]:
31
+ """Resolve server name or URL to (api_host, webui_url).
32
+
33
+ Args:
34
+ server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
35
+
36
+ Returns:
37
+ Tuple of (api_host, webui_url).
38
+ """
39
+ key = server.lower().strip()
40
+ if key in SERVER_PRESETS:
41
+ return SERVER_PRESETS[key]
42
+
43
+ # Self-hosted: URL is both webui base and API base
44
+ base = server.rstrip("/")
45
+ return (f"{base}/wwwapi", base)
46
+
47
+
48
+ def get_available_port(start: int = 5100) -> int:
49
+ """Find an available TCP port starting from `start`."""
50
+ for port in range(start, start + 100):
51
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
52
+ try:
53
+ s.bind(("127.0.0.1", port))
54
+ return port
55
+ except OSError:
56
+ continue
57
+ raise RuntimeError("No available port found in range 5100-5199")
58
+
59
+
60
+ def build_auth_url(webui: str, port: int) -> str:
61
+ """Build the browser auth URL with base64-encoded callback info."""
62
+ callback_info = json.dumps({"url": f"http://localhost:{port}"})
63
+ encoded = base64.b64encode(callback_info.encode()).decode()
64
+ return f"{webui}/cliauth?p={encoded}"
65
+
66
+
67
+ def _decrypt_response(encrypted_value: str, decrypt_key: str) -> dict:
68
+ """Decrypt AES-CBC encrypted API response (private deployments).
69
+
70
+ Requires pycryptodome: pip install pycryptodome
71
+ """
72
+ try:
73
+ from Crypto.Cipher import AES
74
+ except ImportError:
75
+ raise ImportError(
76
+ "pycryptodome is required for encrypted responses. "
77
+ "Install it with: pip install pycryptodome"
78
+ )
79
+
80
+ IV = b"MIGfMA0GCSqGSIb3" # Fixed 16-byte IV
81
+ key_bytes = decrypt_key.encode("utf-8")
82
+ key_len = len(key_bytes)
83
+ required_len = 16 if key_len <= 16 else (24 if key_len <= 24 else 32)
84
+ padded_key = key_bytes[:required_len].ljust(required_len, b"\x00")
85
+ cipher = AES.new(padded_key, AES.MODE_CBC, IV)
86
+ ct = base64.b64decode(encrypted_value)
87
+ decrypted = cipher.decrypt(ct)
88
+ pad_len = decrypted[-1]
89
+ raw = decrypted[:-pad_len].decode("utf-8").replace("\t", "\\t")
90
+ return json.loads(raw)
91
+
92
+
93
+ def get_user_info(host: str, token: str) -> dict:
94
+ """Verify token and get user info via GetGlobalMeta API.
95
+
96
+ Args:
97
+ host: Login URL or API base URL (e.g. 'https://www.mingdao.com' or
98
+ 'https://www.mingdao.com/api'). Host type is auto-detected.
99
+ token: Auth token.
100
+
101
+ Returns:
102
+ Dict with keys: id, name, email, avatar, lang.
103
+
104
+ Raises:
105
+ requests.HTTPError: If API call fails.
106
+ ValueError: If response is invalid or token is rejected.
107
+ """
108
+ url = _main_api_base_for_url(host) + "/Global/GetGlobalMeta"
109
+ resp = requests.post(
110
+ url,
111
+ json={},
112
+ headers={
113
+ "Content-Type": "application/json",
114
+ "Authorization": f"md_pss_id {token}",
115
+ },
116
+ timeout=15,
117
+ )
118
+ resp.raise_for_status()
119
+ raw = resp.json()
120
+
121
+ if raw.get("state") == 0:
122
+ raise ValueError(raw.get("exception", "Token verification failed"))
123
+
124
+ # Handle encrypted responses (private deployments)
125
+ if raw.get("encrypted") and raw.get("key"):
126
+ data = _decrypt_response(raw["data"], raw["key"])
127
+ else:
128
+ data = raw
129
+
130
+ account = data.get("data", {}).get("md.global", {}).get("Account", {})
131
+ if not account:
132
+ raise ValueError("Unable to extract account info from response")
133
+
134
+ return {
135
+ "id": account.get("accountId", ""),
136
+ "name": account.get("fullname", ""),
137
+ "email": account.get("email", ""),
138
+ "avatar": account.get("avatar", ""),
139
+ "lang": account.get("lang", ""),
140
+ }
141
+
142
+
143
+ def _success_html(token: str) -> str:
144
+ """Return the local success page served in the browser after login."""
145
+ return f"""<!DOCTYPE html>
146
+ <html lang="en">
147
+ <head>
148
+ <meta charset="utf-8">
149
+ <meta name="viewport" content="width=device-width, initial-scale=1">
150
+ <title>hap-cli · Authenticated</title>
151
+ <style>
152
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
153
+
154
+ :root {{
155
+ --blue: #2196f3;
156
+ --blue-light: #e3f2fd;
157
+ --blue-mid: #bbdefb;
158
+ --green: #43a047;
159
+ --green-light: #e8f5e9;
160
+ --text: #1a1a1a;
161
+ --muted: #6b7280;
162
+ --border: #e5e7eb;
163
+ --bg: #ffffff;
164
+ --surface: #f9fafb;
165
+ }}
166
+
167
+ body {{
168
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
169
+ background: var(--bg);
170
+ color: var(--text);
171
+ min-height: 100vh;
172
+ display: flex;
173
+ flex-direction: column;
174
+ align-items: center;
175
+ justify-content: center;
176
+ padding: 32px 24px;
177
+ }}
178
+
179
+ .card {{
180
+ width: 100%;
181
+ max-width: 480px;
182
+ animation: rise .4s cubic-bezier(.22,1,.36,1) both;
183
+ }}
184
+
185
+ @keyframes rise {{
186
+ from {{ opacity: 0; transform: translateY(14px); }}
187
+ to {{ opacity: 1; transform: translateY(0); }}
188
+ }}
189
+
190
+ /* ── Primary section ── */
191
+ .primary {{
192
+ text-align: center;
193
+ padding: 48px 40px 40px;
194
+ background: var(--bg);
195
+ border: 1px solid var(--border);
196
+ border-radius: 16px 16px 0 0;
197
+ border-bottom: none;
198
+ }}
199
+
200
+ .circle {{
201
+ width: 72px; height: 72px;
202
+ background: var(--green-light);
203
+ border-radius: 50%;
204
+ display: flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ margin: 0 auto 24px;
208
+ animation: pop .5s .15s cubic-bezier(.34,1.56,.64,1) both;
209
+ }}
210
+
211
+ @keyframes pop {{
212
+ from {{ opacity: 0; transform: scale(.4); }}
213
+ to {{ opacity: 1; transform: scale(1); }}
214
+ }}
215
+
216
+ .circle svg {{ width: 32px; height: 32px; }}
217
+
218
+ h1 {{
219
+ font-size: 22px;
220
+ font-weight: 700;
221
+ letter-spacing: -.02em;
222
+ color: var(--text);
223
+ margin-bottom: 10px;
224
+ }}
225
+
226
+ .subtitle {{
227
+ font-size: 15px;
228
+ color: var(--muted);
229
+ line-height: 1.55;
230
+ }}
231
+
232
+ .close-hint {{
233
+ display: inline-flex;
234
+ align-items: center;
235
+ gap: 6px;
236
+ margin-top: 24px;
237
+ background: var(--blue-light);
238
+ color: var(--blue);
239
+ font-size: 13px;
240
+ font-weight: 500;
241
+ padding: 8px 16px;
242
+ border-radius: 20px;
243
+ }}
244
+
245
+ .close-hint svg {{ width: 14px; height: 14px; }}
246
+
247
+ /* ── Secondary section ── */
248
+ .secondary {{
249
+ background: var(--surface);
250
+ border: 1px solid var(--border);
251
+ border-radius: 0 0 16px 16px;
252
+ overflow: hidden;
253
+ }}
254
+
255
+ .toggle {{
256
+ width: 100%;
257
+ display: flex;
258
+ align-items: center;
259
+ justify-content: space-between;
260
+ padding: 14px 20px;
261
+ background: none;
262
+ border: none;
263
+ cursor: pointer;
264
+ font-size: 12.5px;
265
+ font-weight: 500;
266
+ color: var(--muted);
267
+ text-align: left;
268
+ transition: color .15s;
269
+ }}
270
+
271
+ .toggle:hover {{ color: var(--text); }}
272
+
273
+ .toggle-icon {{
274
+ width: 16px; height: 16px;
275
+ flex-shrink: 0;
276
+ transition: transform .2s;
277
+ }}
278
+
279
+ .toggle[aria-expanded="true"] .toggle-icon {{ transform: rotate(180deg); }}
280
+
281
+ .collapsible {{
282
+ display: none;
283
+ padding: 0 20px 20px;
284
+ border-top: 1px solid var(--border);
285
+ }}
286
+
287
+ .collapsible.open {{ display: block; }}
288
+
289
+ .hint {{
290
+ font-size: 12.5px;
291
+ color: var(--muted);
292
+ line-height: 1.6;
293
+ margin-bottom: 12px;
294
+ padding-top: 14px;
295
+ }}
296
+
297
+ .hint code {{
298
+ font-family: "SFMono-Regular", "Consolas", "Liberation Mono", monospace;
299
+ font-size: 11px;
300
+ background: var(--blue-light);
301
+ color: var(--blue);
302
+ padding: 1px 5px;
303
+ border-radius: 4px;
304
+ }}
305
+
306
+ .token-wrap {{
307
+ position: relative;
308
+ }}
309
+
310
+ .token {{
311
+ display: block;
312
+ width: 100%;
313
+ background: var(--bg);
314
+ border: 1px solid var(--border);
315
+ border-radius: 8px;
316
+ padding: 11px 44px 11px 13px;
317
+ font-family: "SFMono-Regular", "Consolas", "Liberation Mono", monospace;
318
+ font-size: 11px;
319
+ line-height: 1.7;
320
+ color: #1565c0;
321
+ word-break: break-all;
322
+ user-select: all;
323
+ cursor: text;
324
+ transition: border-color .15s, box-shadow .15s;
325
+ }}
326
+
327
+ .token:focus {{
328
+ outline: none;
329
+ border-color: var(--blue);
330
+ box-shadow: 0 0 0 3px rgba(33,150,243,.12);
331
+ }}
332
+
333
+ .copy-btn {{
334
+ position: absolute;
335
+ top: 8px; right: 8px;
336
+ width: 28px; height: 28px;
337
+ background: var(--surface);
338
+ border: 1px solid var(--border);
339
+ border-radius: 6px;
340
+ cursor: pointer;
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: center;
344
+ color: var(--muted);
345
+ transition: background .15s, color .15s, border-color .15s, transform .1s;
346
+ }}
347
+
348
+ .copy-btn:hover {{
349
+ background: var(--blue-light);
350
+ border-color: var(--blue-mid);
351
+ color: var(--blue);
352
+ }}
353
+
354
+ .copy-btn:active {{ transform: scale(.9); }}
355
+ .copy-btn svg {{ width: 13px; height: 13px; pointer-events: none; }}
356
+
357
+ .copy-btn.copied {{
358
+ background: var(--green-light);
359
+ border-color: #a5d6a7;
360
+ color: var(--green);
361
+ }}
362
+
363
+ /* ── Branding ── */
364
+ .brand {{
365
+ margin-top: 28px;
366
+ font-size: 11.5px;
367
+ color: #d1d5db;
368
+ text-align: center;
369
+ letter-spacing: .01em;
370
+ }}
371
+ </style>
372
+ </head>
373
+ <body>
374
+ <div class="card">
375
+
376
+ <div class="primary">
377
+ <div class="circle">
378
+ <svg viewBox="0 0 24 24" fill="none" stroke="#43a047" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
379
+ <polyline points="20 6 9 17 4 12"/>
380
+ </svg>
381
+ </div>
382
+ <h1>Authorization successful</h1>
383
+ <p class="subtitle">hap-cli has been granted access.<br>You can close this tab and return to the terminal.</p>
384
+ <span class="close-hint">
385
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
386
+ <path d="M18 6L6 18M6 6l12 12"/>
387
+ </svg>
388
+ Safe to close this tab
389
+ </span>
390
+ </div>
391
+
392
+ <div class="secondary">
393
+ <button class="toggle" onclick="toggleManual(this)" aria-expanded="false">
394
+ Not seeing success in the terminal? Manual setup
395
+ <svg class="toggle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
396
+ <polyline points="6 9 12 15 18 9"/>
397
+ </svg>
398
+ </button>
399
+ <div class="collapsible" id="manual">
400
+ <p class="hint">
401
+ Copy the token below and paste it into <code>~/.hap-cli/config.json</code>
402
+ as the value for <code>"auth_token"</code>.
403
+ </p>
404
+ <div class="token-wrap">
405
+ <div class="token" id="token" tabindex="0">{token}</div>
406
+ <button class="copy-btn" id="copyBtn" title="Copy token" onclick="copyToken()">
407
+ <svg id="iconCopy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
408
+ <rect x="9" y="9" width="13" height="13" rx="2"/>
409
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
410
+ </svg>
411
+ <svg id="iconCheck" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="display:none">
412
+ <polyline points="20 6 9 17 4 12"/>
413
+ </svg>
414
+ </button>
415
+ </div>
416
+ </div>
417
+ </div>
418
+
419
+ </div>
420
+
421
+ <div class="brand">hap-cli</div>
422
+
423
+ <script>
424
+ function toggleManual(btn) {{
425
+ var panel = document.getElementById('manual');
426
+ var open = panel.classList.toggle('open');
427
+ btn.setAttribute('aria-expanded', open);
428
+ }}
429
+
430
+ function copyToken() {{
431
+ var text = document.getElementById('token').innerText;
432
+ var btn = document.getElementById('copyBtn');
433
+ var ic = document.getElementById('iconCopy');
434
+ var ick = document.getElementById('iconCheck');
435
+ navigator.clipboard.writeText(text).then(function() {{
436
+ btn.classList.add('copied');
437
+ ic.style.display = 'none';
438
+ ick.style.display = '';
439
+ setTimeout(function() {{
440
+ btn.classList.remove('copied');
441
+ ic.style.display = '';
442
+ ick.style.display = 'none';
443
+ }}, 2000);
444
+ }}).catch(function() {{
445
+ var r = document.createRange();
446
+ r.selectNode(document.getElementById('token'));
447
+ window.getSelection().removeAllRanges();
448
+ window.getSelection().addRange(r);
449
+ }});
450
+ }}
451
+ </script>
452
+ </body>
453
+ </html>"""
454
+
455
+
456
+ def login(server: str, timeout: int = 300) -> tuple[str, str, dict]:
457
+ """Run the full browser-based login flow.
458
+
459
+ Args:
460
+ server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
461
+ timeout: Max seconds to wait for user to complete login.
462
+
463
+ Returns:
464
+ Tuple of (token, api_host, user_info).
465
+
466
+ Raises:
467
+ TimeoutError: If user does not complete login in time.
468
+ """
469
+ api_host, webui = resolve_server(server)
470
+ port = get_available_port()
471
+
472
+ received = threading.Event()
473
+ result: dict = {}
474
+ server_ref: dict = {}
475
+
476
+ class AuthCallbackHandler(BaseHTTPRequestHandler):
477
+ def do_OPTIONS(self):
478
+ self.send_response(204)
479
+ self.send_header("Access-Control-Allow-Origin", "*")
480
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
481
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
482
+ self.end_headers()
483
+
484
+ def do_GET(self):
485
+ parsed = urlparse(self.path)
486
+ params = parse_qs(parsed.query)
487
+ encoded_token = params.get("t", [None])[0]
488
+
489
+ if not encoded_token:
490
+ self.send_response(400)
491
+ self.end_headers()
492
+ self.wfile.write(b"No token provided")
493
+ return
494
+
495
+ token = base64.b64decode(encoded_token).decode("utf-8")
496
+ result["token"] = token
497
+
498
+ body = _success_html(token).encode("utf-8")
499
+ self.send_response(200)
500
+ self.send_header("Content-Type", "text/html; charset=utf-8")
501
+ self.send_header("Content-Length", str(len(body)))
502
+ self.send_header("Access-Control-Allow-Origin", "*")
503
+ self.end_headers()
504
+ self.wfile.write(body)
505
+
506
+ # Shut down server after responding
507
+ threading.Timer(
508
+ 0.5,
509
+ lambda: (server_ref["srv"].shutdown(), received.set()),
510
+ ).start()
511
+
512
+ def log_message(self, format, *args):
513
+ pass # Suppress HTTP server logs
514
+
515
+ srv = HTTPServer(("127.0.0.1", port), AuthCallbackHandler)
516
+ server_ref["srv"] = srv
517
+
518
+ thread = threading.Thread(target=srv.serve_forever, daemon=True)
519
+ thread.start()
520
+
521
+ auth_url = build_auth_url(webui, port)
522
+ webbrowser.open(auth_url)
523
+
524
+ received.wait(timeout=timeout)
525
+
526
+ token = result.get("token")
527
+ if not token:
528
+ raise TimeoutError("Login timed out — no token received")
529
+
530
+ # Verify token and get user info
531
+ user_info = get_user_info(webui, token)
532
+ # Return the login URL (webui), not the API base, so callers store
533
+ # the canonical login URL in session config.
534
+ return token, webui, user_info
@@ -48,8 +48,6 @@ NOCOLY_HOSTS = {"www.nocoly.com"}
48
48
  DEFAULT_CONFIG = {
49
49
  "login_url": "",
50
50
  "auth_token": "",
51
- "default_app_id": "",
52
- "default_project_id": "",
53
51
  }
54
52
 
55
53
 
@@ -301,8 +299,6 @@ class Session:
301
299
  server_url=login_url,
302
300
  auth_token=config.get("auth_token", ""),
303
301
  )
304
- session.default_app_id = config.get("default_app_id", "")
305
- session.default_project_id = config.get("default_project_id", "")
306
302
  return session
307
303
 
308
304
  def save(self) -> None:
@@ -311,8 +307,6 @@ class Session:
311
307
  {
312
308
  "login_url": self._login_url,
313
309
  "auth_token": self.auth_token,
314
- "default_app_id": self.default_app_id,
315
- "default_project_id": self.default_project_id,
316
310
  }
317
311
  )
318
312
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hap-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: CLI harness for MingDAO HAP - Enterprise no-code platform
5
5
  Author: hap-cli
6
6
  License: Apache-2.0
@@ -34,7 +34,7 @@ CLI harness for **MingDAO HAP** (明道云) - an enterprise no-code platform (ha
34
34
  ## Installation
35
35
 
36
36
  ```bash
37
- pip install -e .
37
+ pip install hap-cli
38
38
  ```
39
39
 
40
40
  ## Quick Start
@@ -60,9 +60,7 @@ Opens your browser to the MingDAO login page. Token is saved automatically after
60
60
  ```bash
61
61
  hap config set \
62
62
  --server https://your-mingdao-server.com \
63
- --token YOUR_MD_PSS_ID_TOKEN \
64
- --app-id YOUR_DEFAULT_APP_ID \
65
- --project-id YOUR_PROJECT_ID
63
+ --token YOUR_MD_PSS_ID_TOKEN
66
64
  ```
67
65
 
68
66
  **Other auth commands**
@@ -72,19 +70,27 @@ hap config whoami # Show current user info
72
70
  hap config logout # Clear saved token
73
71
  ```
74
72
 
75
- ### 2. List worksheets
73
+ ### 2. Find your org and app IDs
76
74
 
77
75
  ```bash
78
- hap app worksheets
76
+ hap config orgs # list orgs → get Project ID
77
+ hap app list --project-id PROJECT_ID # list apps → get App ID
78
+ hap app list-managed --project-id PROJECT_ID # apps where you are manager
79
79
  ```
80
80
 
81
- ### 3. Query records
81
+ ### 3. List worksheets
82
+
83
+ ```bash
84
+ hap app worksheets APP_ID
85
+ ```
86
+
87
+ ### 4. Query records
82
88
 
83
89
  ```bash
84
90
  hap record list WORKSHEET_ID --page-size 10
85
91
  ```
86
92
 
87
- ### 4. JSON output (for automation)
93
+ ### 5. JSON output (for automation)
88
94
 
89
95
  ```bash
90
96
  hap --json record list WORKSHEET_ID
@@ -194,8 +200,10 @@ pip install hap-cli[crypto]
194
200
  ## More Examples
195
201
 
196
202
  ```bash
197
- # List apps
203
+ # List orgs, then apps
204
+ hap config orgs
198
205
  hap app list --project-id PROJECT_ID
206
+ hap app list-managed --project-id PROJECT_ID
199
207
 
200
208
  # Get worksheet fields
201
209
  hap worksheet fields WORKSHEET_ID
@@ -4,7 +4,7 @@ from setuptools import setup, find_packages
4
4
 
5
5
  setup(
6
6
  name="hap-cli",
7
- version="0.5.0",
7
+ version="0.5.1",
8
8
  description="CLI harness for MingDAO HAP - Enterprise no-code platform",
9
9
  long_description=open("hap_cli/README.md").read(),
10
10
  long_description_content_type="text/markdown",
@@ -1,219 +0,0 @@
1
- """Browser-based authentication flow for MingDAO HAP.
2
-
3
- Implements a local callback server pattern:
4
- 1. Start local HTTP server on a free port
5
- 2. Open browser to HAP login page with callback URL
6
- 3. Capture token from redirect, save to config
7
- """
8
-
9
- import base64
10
- import json
11
- import socket
12
- import threading
13
- import webbrowser
14
- from http.server import HTTPServer, BaseHTTPRequestHandler
15
- from typing import Optional
16
- from urllib.parse import urlparse, parse_qs
17
-
18
- import requests
19
-
20
- from hap_cli.core.session import _main_api_base_for_url
21
-
22
- # Known server presets: name -> (api_base, webui_url)
23
- # api_base is kept for backward compatibility with resolve_server callers.
24
- SERVER_PRESETS = {
25
- "mingdao": ("https://www.mingdao.com/api", "https://www.mingdao.com"),
26
- "nocoly": ("https://www.nocoly.com/wwwapi", "https://www.nocoly.com"),
27
- }
28
-
29
-
30
- def resolve_server(server: str) -> tuple[str, str]:
31
- """Resolve server name or URL to (api_host, webui_url).
32
-
33
- Args:
34
- server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
35
-
36
- Returns:
37
- Tuple of (api_host, webui_url).
38
- """
39
- key = server.lower().strip()
40
- if key in SERVER_PRESETS:
41
- return SERVER_PRESETS[key]
42
-
43
- # Self-hosted: URL is both webui base and API base
44
- base = server.rstrip("/")
45
- return (f"{base}/wwwapi", base)
46
-
47
-
48
- def get_available_port(start: int = 5100) -> int:
49
- """Find an available TCP port starting from `start`."""
50
- for port in range(start, start + 100):
51
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
52
- try:
53
- s.bind(("127.0.0.1", port))
54
- return port
55
- except OSError:
56
- continue
57
- raise RuntimeError("No available port found in range 5100-5199")
58
-
59
-
60
- def build_auth_url(webui: str, port: int) -> str:
61
- """Build the browser auth URL with base64-encoded callback info."""
62
- callback_info = json.dumps({"url": f"http://localhost:{port}"})
63
- encoded = base64.b64encode(callback_info.encode()).decode()
64
- return f"{webui}/cliauth?p={encoded}"
65
-
66
-
67
- def _decrypt_response(encrypted_value: str, decrypt_key: str) -> dict:
68
- """Decrypt AES-CBC encrypted API response (private deployments).
69
-
70
- Requires pycryptodome: pip install pycryptodome
71
- """
72
- try:
73
- from Crypto.Cipher import AES
74
- except ImportError:
75
- raise ImportError(
76
- "pycryptodome is required for encrypted responses. "
77
- "Install it with: pip install pycryptodome"
78
- )
79
-
80
- IV = b"MIGfMA0GCSqGSIb3" # Fixed 16-byte IV
81
- key_bytes = decrypt_key.encode("utf-8")
82
- key_len = len(key_bytes)
83
- required_len = 16 if key_len <= 16 else (24 if key_len <= 24 else 32)
84
- padded_key = key_bytes[:required_len].ljust(required_len, b"\x00")
85
- cipher = AES.new(padded_key, AES.MODE_CBC, IV)
86
- ct = base64.b64decode(encrypted_value)
87
- decrypted = cipher.decrypt(ct)
88
- pad_len = decrypted[-1]
89
- raw = decrypted[:-pad_len].decode("utf-8").replace("\t", "\\t")
90
- return json.loads(raw)
91
-
92
-
93
- def get_user_info(host: str, token: str) -> dict:
94
- """Verify token and get user info via GetGlobalMeta API.
95
-
96
- Args:
97
- host: Login URL or API base URL (e.g. 'https://www.mingdao.com' or
98
- 'https://www.mingdao.com/api'). Host type is auto-detected.
99
- token: Auth token.
100
-
101
- Returns:
102
- Dict with keys: id, name, email, avatar, lang.
103
-
104
- Raises:
105
- requests.HTTPError: If API call fails.
106
- ValueError: If response is invalid or token is rejected.
107
- """
108
- url = _main_api_base_for_url(host) + "/Global/GetGlobalMeta"
109
- resp = requests.post(
110
- url,
111
- json={},
112
- headers={
113
- "Content-Type": "application/json",
114
- "Authorization": f"md_pss_id {token}",
115
- },
116
- timeout=15,
117
- )
118
- resp.raise_for_status()
119
- raw = resp.json()
120
-
121
- if raw.get("state") == 0:
122
- raise ValueError(raw.get("exception", "Token verification failed"))
123
-
124
- # Handle encrypted responses (private deployments)
125
- if raw.get("encrypted") and raw.get("key"):
126
- data = _decrypt_response(raw["data"], raw["key"])
127
- else:
128
- data = raw
129
-
130
- account = data.get("data", {}).get("md.global", {}).get("Account", {})
131
- if not account:
132
- raise ValueError("Unable to extract account info from response")
133
-
134
- return {
135
- "id": account.get("accountId", ""),
136
- "name": account.get("fullname", ""),
137
- "email": account.get("email", ""),
138
- "avatar": account.get("avatar", ""),
139
- "lang": account.get("lang", ""),
140
- }
141
-
142
-
143
- def login(server: str, timeout: int = 300) -> tuple[str, str, dict]:
144
- """Run the full browser-based login flow.
145
-
146
- Args:
147
- server: Preset name ('mingdao', 'nocoly') or self-hosted URL.
148
- timeout: Max seconds to wait for user to complete login.
149
-
150
- Returns:
151
- Tuple of (token, api_host, user_info).
152
-
153
- Raises:
154
- TimeoutError: If user does not complete login in time.
155
- """
156
- api_host, webui = resolve_server(server)
157
- port = get_available_port()
158
-
159
- received = threading.Event()
160
- result: dict = {}
161
- server_ref: dict = {}
162
-
163
- class AuthCallbackHandler(BaseHTTPRequestHandler):
164
- def do_OPTIONS(self):
165
- self.send_response(204)
166
- self.send_header("Access-Control-Allow-Origin", "*")
167
- self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
168
- self.send_header("Access-Control-Allow-Headers", "Content-Type")
169
- self.end_headers()
170
-
171
- def do_GET(self):
172
- self.send_header("Access-Control-Allow-Origin", "*")
173
- parsed = urlparse(self.path)
174
- params = parse_qs(parsed.query)
175
- encoded_token = params.get("t", [None])[0]
176
-
177
- if not encoded_token:
178
- self.send_response(400)
179
- self.end_headers()
180
- self.wfile.write(b"No token provided")
181
- return
182
-
183
- token = base64.b64decode(encoded_token).decode("utf-8")
184
- result["token"] = token
185
-
186
- # Redirect browser to success page
187
- self.send_response(302)
188
- self.send_header("Location", f"{webui}/cliauth/success")
189
- self.end_headers()
190
-
191
- # Shut down server after responding
192
- threading.Timer(
193
- 0.1,
194
- lambda: (server_ref["srv"].shutdown(), received.set()),
195
- ).start()
196
-
197
- def log_message(self, format, *args):
198
- pass # Suppress HTTP server logs
199
-
200
- srv = HTTPServer(("127.0.0.1", port), AuthCallbackHandler)
201
- server_ref["srv"] = srv
202
-
203
- thread = threading.Thread(target=srv.serve_forever, daemon=True)
204
- thread.start()
205
-
206
- auth_url = build_auth_url(webui, port)
207
- webbrowser.open(auth_url)
208
-
209
- received.wait(timeout=timeout)
210
-
211
- token = result.get("token")
212
- if not token:
213
- raise TimeoutError("Login timed out — no token received")
214
-
215
- # Verify token and get user info
216
- user_info = get_user_info(webui, token)
217
- # Return the login URL (webui), not the API base, so callers store
218
- # the canonical login URL in session config.
219
- return token, webui, user_info
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes