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.
- {hap_cli-0.5.0 → hap_cli-0.5.1}/PKG-INFO +18 -10
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/README.md +17 -9
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/README_CN.md +19 -10
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/config_cmd.py +1 -9
- hap_cli-0.5.1/hap_cli/core/auth.py +534 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/session.py +0 -6
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/PKG-INFO +18 -10
- {hap_cli-0.5.0 → hap_cli-0.5.1}/setup.py +1 -1
- hap_cli-0.5.0/hap_cli/core/auth.py +0 -219
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/ai_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/app_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/calendar_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/chat_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/contact_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/department_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/group_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/instance_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/node_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/optionset_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/page_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/plugin_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/post_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/record_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/role_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/workflow_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/commands/worksheet_cmd.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/context.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/ai.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/app.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/calendar_mod.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/chat.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/contact.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/department.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/flow_node.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/group.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/instance.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/optionset.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/page.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/plugin.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/post.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/record.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/role.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/workflow.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/core/worksheet.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/hap_cli.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/skills/SKILL.md +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/skills/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_core.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_full_e2e.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/tests/test_integration.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/__init__.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/formatting.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli/utils/options.py +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/SOURCES.txt +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/dependency_links.txt +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/entry_points.txt +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/requires.txt +0 -0
- {hap_cli-0.5.0 → hap_cli-0.5.1}/hap_cli.egg-info/top_level.txt +0 -0
- {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.
|
|
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 -
|
|
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.
|
|
73
|
+
### 2. Find your org and app IDs
|
|
76
74
|
|
|
77
75
|
```bash
|
|
78
|
-
hap
|
|
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.
|
|
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
|
-
###
|
|
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 -
|
|
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.
|
|
44
|
+
### 2. Find your org and app IDs
|
|
47
45
|
|
|
48
46
|
```bash
|
|
49
|
-
hap
|
|
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.
|
|
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
|
-
###
|
|
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 -
|
|
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
|
|
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
|
-
###
|
|
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
|
|
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
|
|
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.
|
|
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 -
|
|
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.
|
|
73
|
+
### 2. Find your org and app IDs
|
|
76
74
|
|
|
77
75
|
```bash
|
|
78
|
-
hap
|
|
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.
|
|
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
|
-
###
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|