web-presentation-cli 0.1.0__py3-none-any.whl
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.
- web_presentation_cli-0.1.0.dist-info/METADATA +64 -0
- web_presentation_cli-0.1.0.dist-info/RECORD +27 -0
- web_presentation_cli-0.1.0.dist-info/WHEEL +4 -0
- web_presentation_cli-0.1.0.dist-info/entry_points.txt +2 -0
- wp/__init__.py +3 -0
- wp/cli.py +68 -0
- wp/client.py +27 -0
- wp/commands/__init__.py +1 -0
- wp/commands/asset.py +218 -0
- wp/commands/auth.py +98 -0
- wp/commands/catalog.py +84 -0
- wp/commands/common.py +182 -0
- wp/commands/component.py +293 -0
- wp/commands/doctor.py +84 -0
- wp/commands/job.py +84 -0
- wp/commands/page.py +314 -0
- wp/commands/profile.py +71 -0
- wp/commands/project.py +244 -0
- wp/commands/screenshot.py +73 -0
- wp/commands/style.py +155 -0
- wp/commands/system.py +93 -0
- wp/commands/theme.py +152 -0
- wp/commands/workspace.py +89 -0
- wp/config.py +91 -0
- wp/formatter.py +66 -0
- wp_api_client/__init__.py +5 -0
- wp_api_client/client.py +431 -0
wp/commands/common.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""文件功能:提供 CLI 命令共享的客户端、文件载荷、异步任务和输出辅助函数。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, NoReturn
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from wp.client import ApiClient, ApiClientError
|
|
12
|
+
from wp.config import get_profile, load_config
|
|
13
|
+
from wp.formatter import print_error, print_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_client(ctx: click.Context) -> ApiClient:
|
|
17
|
+
"""根据当前命令上下文创建绑定 Profile 和 Workspace 的 API Client。"""
|
|
18
|
+
|
|
19
|
+
profile = get_profile(load_config(), ctx.obj.get("profile"))
|
|
20
|
+
return ApiClient(
|
|
21
|
+
profile,
|
|
22
|
+
workspace_id=ctx.obj.get("workspace_id"),
|
|
23
|
+
idempotency_key=ctx.obj.get("idempotency_key"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_idempotency_key(
|
|
28
|
+
ctx: click.Context,
|
|
29
|
+
_: click.Parameter,
|
|
30
|
+
value: str | None,
|
|
31
|
+
) -> str | None:
|
|
32
|
+
"""校验并保存命令级幂等键,供当前命令创建的 API Client 使用。"""
|
|
33
|
+
|
|
34
|
+
if value is None:
|
|
35
|
+
return None
|
|
36
|
+
normalized = value.strip()
|
|
37
|
+
if not normalized:
|
|
38
|
+
raise click.BadParameter("幂等键不能为空。")
|
|
39
|
+
if len(normalized) > 128 or not normalized.isascii():
|
|
40
|
+
raise click.BadParameter("幂等键必须是不超过 128 个字符的 ASCII 字符串。")
|
|
41
|
+
ctx.ensure_object(dict)
|
|
42
|
+
ctx.obj["idempotency_key"] = normalized
|
|
43
|
+
return normalized
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def idempotency_key_option(command: Any) -> Any:
|
|
47
|
+
"""为写命令增加可复用的 `--idempotency-key` 选项。"""
|
|
48
|
+
|
|
49
|
+
return click.option(
|
|
50
|
+
"--idempotency-key",
|
|
51
|
+
callback=_validate_idempotency_key,
|
|
52
|
+
expose_value=False,
|
|
53
|
+
metavar="KEY",
|
|
54
|
+
help="写操作幂等键;请求超时后可用同一键安全重放。",
|
|
55
|
+
)(command)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_json_file(file_path: str, *, label: str = "JSON 文件") -> Any:
|
|
59
|
+
"""读取 UTF-8 JSON 文件,并把文件错误转换为 Click 参数错误。"""
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
content = Path(file_path).read_text(encoding="utf-8")
|
|
63
|
+
return json.loads(content)
|
|
64
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
65
|
+
raise click.ClickException(f"无法读取{label} '{file_path}': {exc}") from exc
|
|
66
|
+
except json.JSONDecodeError as exc:
|
|
67
|
+
raise click.ClickException(f"{label} '{file_path}' 不是合法 JSON: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_text_file(file_path: str, *, label: str = "文本文件") -> str:
|
|
71
|
+
"""读取 UTF-8 文本文件,保留原始内容。"""
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return Path(file_path).read_text(encoding="utf-8")
|
|
75
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
76
|
+
raise click.ClickException(f"无法读取{label} '{file_path}': {exc}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def require_object(value: Any, *, label: str) -> dict[str, Any]:
|
|
80
|
+
"""校验载荷根节点必须为 JSON 对象。"""
|
|
81
|
+
|
|
82
|
+
if not isinstance(value, dict):
|
|
83
|
+
raise click.ClickException(f"{label}必须是 JSON 对象。")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def require_array(value: Any, *, label: str) -> list[Any]:
|
|
88
|
+
"""校验载荷根节点必须为 JSON 数组。"""
|
|
89
|
+
|
|
90
|
+
if not isinstance(value, list):
|
|
91
|
+
raise click.ClickException(f"{label}必须是 JSON 数组。")
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def require_ids(value: Any, *, label: str = "ID 列表") -> list[int]:
|
|
96
|
+
"""校验批量归档文件是只包含正整数 ID 的 JSON 数组。"""
|
|
97
|
+
|
|
98
|
+
items = require_array(value, label=label)
|
|
99
|
+
if any(isinstance(item, bool) or not isinstance(item, int) or item < 1 for item in items):
|
|
100
|
+
raise click.ClickException(f"{label}必须是只包含正整数的 JSON 数组。")
|
|
101
|
+
return items
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def output_result(ctx: click.Context, value: Any) -> None:
|
|
105
|
+
"""输出命令结果;复杂响应统一使用 JSON,保证 CLI 与 Agent 消费一致。"""
|
|
106
|
+
|
|
107
|
+
print_json(value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def handle_api_error(message: str, error: ApiClientError) -> NoReturn:
|
|
111
|
+
"""统一打印结构化 API 错误并以非零状态退出。"""
|
|
112
|
+
|
|
113
|
+
print_error(
|
|
114
|
+
f"{message}: {error.message}",
|
|
115
|
+
code=error.code,
|
|
116
|
+
details=error.details,
|
|
117
|
+
request_id=error.request_id,
|
|
118
|
+
retry_after=error.retry_after,
|
|
119
|
+
)
|
|
120
|
+
raise SystemExit(1)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def resolve_wait_job(
|
|
124
|
+
client: ApiClient,
|
|
125
|
+
job: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
wait: bool,
|
|
128
|
+
timeout: float,
|
|
129
|
+
) -> dict[str, Any]:
|
|
130
|
+
"""按命令的 wait 选择返回入队结果或任务终态。"""
|
|
131
|
+
|
|
132
|
+
if not wait:
|
|
133
|
+
return job
|
|
134
|
+
job_id = str(job.get("job_id") or "")
|
|
135
|
+
if not job_id:
|
|
136
|
+
raise click.ClickException("服务端未返回 job_id,无法等待异步任务。")
|
|
137
|
+
return client.poll_mutation_job(job_id, timeout_seconds=timeout)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def require_success_job(job: dict[str, Any], *, ctx: click.Context | None = None) -> dict[str, Any]:
|
|
141
|
+
"""将失败或取消的任务转换为 CLI 失败退出。"""
|
|
142
|
+
|
|
143
|
+
if job.get("status") in {"failed", "canceled"}:
|
|
144
|
+
raw_error = job.get("error") or {}
|
|
145
|
+
error = raw_error if isinstance(raw_error, dict) else {"message": str(raw_error)}
|
|
146
|
+
if ctx is not None:
|
|
147
|
+
if ctx.obj.get("as_json"):
|
|
148
|
+
print_json(job)
|
|
149
|
+
else:
|
|
150
|
+
print_error(
|
|
151
|
+
f"异步任务执行失败: {error.get('message') or '未提供错误信息。'}",
|
|
152
|
+
code=error.get("code"),
|
|
153
|
+
details=error,
|
|
154
|
+
)
|
|
155
|
+
raise SystemExit(1)
|
|
156
|
+
raise click.ClickException(str(error.get("message") or "异步任务执行失败。"))
|
|
157
|
+
return job
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def confirm_archive(ids: list[int], *, yes: bool, label: str) -> None:
|
|
161
|
+
"""对单项或批量归档执行统一确认。"""
|
|
162
|
+
|
|
163
|
+
if yes:
|
|
164
|
+
return
|
|
165
|
+
if not click.confirm(f"确定要归档 {label} {', '.join(map(str, ids))} 吗?"):
|
|
166
|
+
raise click.Abort()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = [
|
|
170
|
+
"confirm_archive",
|
|
171
|
+
"get_client",
|
|
172
|
+
"handle_api_error",
|
|
173
|
+
"idempotency_key_option",
|
|
174
|
+
"output_result",
|
|
175
|
+
"read_json_file",
|
|
176
|
+
"read_text_file",
|
|
177
|
+
"require_array",
|
|
178
|
+
"require_ids",
|
|
179
|
+
"require_object",
|
|
180
|
+
"require_success_job",
|
|
181
|
+
"resolve_wait_job",
|
|
182
|
+
]
|
wp/commands/component.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""文件功能:提供工作空间组件的查询、创建、编辑、校验、发布与归档命令。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from wp.client import ApiClientError
|
|
8
|
+
from wp.commands.common import (
|
|
9
|
+
confirm_archive,
|
|
10
|
+
get_client,
|
|
11
|
+
handle_api_error,
|
|
12
|
+
idempotency_key_option,
|
|
13
|
+
output_result,
|
|
14
|
+
read_json_file,
|
|
15
|
+
read_text_file,
|
|
16
|
+
require_array,
|
|
17
|
+
require_ids,
|
|
18
|
+
require_object,
|
|
19
|
+
require_success_job,
|
|
20
|
+
resolve_wait_job,
|
|
21
|
+
)
|
|
22
|
+
from wp.config import get_profile, load_config
|
|
23
|
+
from wp.formatter import print_table
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group("component")
|
|
27
|
+
def component_group() -> None:
|
|
28
|
+
"""工作空间组件管理与 Mutation 任务。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@component_group.command("list")
|
|
32
|
+
@click.option("--page", default=1, type=int)
|
|
33
|
+
@click.option("--page-size", default=50, type=int)
|
|
34
|
+
@click.option("--keyword")
|
|
35
|
+
@click.option("--scope", type=click.Choice(["all", "suggested"]), default="all")
|
|
36
|
+
@click.option("--project-id", type=int)
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def list_components_cmd(ctx: click.Context, page: int, page_size: int, keyword: str | None, scope: str, project_id: int | None) -> None:
|
|
39
|
+
"""查询工作空间组件或项目建议组件。"""
|
|
40
|
+
|
|
41
|
+
if scope == "suggested" and project_id is None:
|
|
42
|
+
raise click.UsageError("scope=suggested 时必须提供 --project-id。")
|
|
43
|
+
params = {"page": page, "page_size": page_size, "keyword": keyword, "scope": scope, "project_id": project_id}
|
|
44
|
+
try:
|
|
45
|
+
result = get_client(ctx).get("/components", params={key: value for key, value in params.items() if value is not None})
|
|
46
|
+
if ctx.obj.get("as_json"):
|
|
47
|
+
output_result(ctx, result)
|
|
48
|
+
return
|
|
49
|
+
rows = [
|
|
50
|
+
[item.get("id"), item.get("import_name", "-"), item.get("name", "-"), item.get("component_type", "-"), item.get("status", "-")]
|
|
51
|
+
for item in result.get("items", [])
|
|
52
|
+
]
|
|
53
|
+
print_table("组件列表", ["ID", "导入标识", "名称", "类型", "状态"], rows)
|
|
54
|
+
except ApiClientError as err:
|
|
55
|
+
handle_api_error("获取组件列表失败", err)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@component_group.command("get")
|
|
59
|
+
@click.argument("component_id", type=int)
|
|
60
|
+
@click.pass_context
|
|
61
|
+
def get_component_cmd(ctx: click.Context, component_id: int) -> None:
|
|
62
|
+
"""获取组件详情。"""
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
output_result(ctx, get_client(ctx).get(f"/components/{component_id}"))
|
|
66
|
+
except ApiClientError as err:
|
|
67
|
+
handle_api_error("获取组件失败", err)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@component_group.command("create")
|
|
71
|
+
@click.option("--name")
|
|
72
|
+
@click.option("--import-name")
|
|
73
|
+
@click.option("--file", "file_path", type=click.Path(exists=True, dir_okay=False))
|
|
74
|
+
@click.option("--type", "component_type", default="content")
|
|
75
|
+
@click.option("--description")
|
|
76
|
+
@click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False))
|
|
77
|
+
@click.option("--payload-file", type=click.Path(exists=True, dir_okay=False))
|
|
78
|
+
@click.option("--wait/--no-wait", default=True)
|
|
79
|
+
@click.option("--timeout", type=float, default=120.0, show_default=True)
|
|
80
|
+
@idempotency_key_option
|
|
81
|
+
@click.pass_context
|
|
82
|
+
def create_component_cmd(
|
|
83
|
+
ctx: click.Context,
|
|
84
|
+
name: str | None,
|
|
85
|
+
import_name: str | None,
|
|
86
|
+
file_path: str | None,
|
|
87
|
+
component_type: str,
|
|
88
|
+
description: str | None,
|
|
89
|
+
preview_schema_file: str | None,
|
|
90
|
+
payload_file: str | None,
|
|
91
|
+
wait: bool,
|
|
92
|
+
timeout: float,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""提交组件创建 Mutation Job。"""
|
|
95
|
+
|
|
96
|
+
profile = get_profile(load_config(), ctx.obj.get("profile"))
|
|
97
|
+
workspace_id = ctx.obj.get("workspace_id") or profile.default_workspace_id
|
|
98
|
+
if not workspace_id:
|
|
99
|
+
raise click.UsageError("必须通过 --workspace 或 Profile 默认配置指定工作空间。")
|
|
100
|
+
if payload_file:
|
|
101
|
+
payload = require_object(read_json_file(payload_file, label="组件创建载荷"), label="组件创建载荷")
|
|
102
|
+
else:
|
|
103
|
+
if not name or not import_name or not file_path:
|
|
104
|
+
raise click.UsageError("未使用 --payload-file 时必须提供 --name、--import-name 和 --file。")
|
|
105
|
+
payload = {
|
|
106
|
+
"workspace_id": workspace_id,
|
|
107
|
+
"import_name": import_name,
|
|
108
|
+
"name": name,
|
|
109
|
+
"component_type": component_type,
|
|
110
|
+
"source_code": read_text_file(file_path, label="组件源码"),
|
|
111
|
+
"description": description,
|
|
112
|
+
}
|
|
113
|
+
if preview_schema_file:
|
|
114
|
+
payload["preview_schema"] = read_json_file(preview_schema_file, label="Preview Schema")
|
|
115
|
+
try:
|
|
116
|
+
client = get_client(ctx)
|
|
117
|
+
result = resolve_wait_job(client, client.create_component(payload), wait=wait, timeout=timeout)
|
|
118
|
+
if wait:
|
|
119
|
+
require_success_job(result, ctx=ctx)
|
|
120
|
+
output_result(ctx, result)
|
|
121
|
+
except ApiClientError as err:
|
|
122
|
+
handle_api_error("提交组件创建失败", err)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@component_group.command("update")
|
|
126
|
+
@click.argument("component_id", type=int)
|
|
127
|
+
@click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
|
|
128
|
+
@click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False), help="覆盖 payload 中的 Preview Schema JSON")
|
|
129
|
+
@click.option("--wait/--no-wait", default=True)
|
|
130
|
+
@click.option("--timeout", type=float, default=120.0, show_default=True)
|
|
131
|
+
@idempotency_key_option
|
|
132
|
+
@click.pass_context
|
|
133
|
+
def update_component_cmd(
|
|
134
|
+
ctx: click.Context,
|
|
135
|
+
component_id: int,
|
|
136
|
+
payload_file: str,
|
|
137
|
+
preview_schema_file: str | None,
|
|
138
|
+
wait: bool,
|
|
139
|
+
timeout: float,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""更新组件元数据,复杂字段自动进入异步校验任务。"""
|
|
142
|
+
|
|
143
|
+
payload = require_object(read_json_file(payload_file, label="组件更新载荷"), label="组件更新载荷")
|
|
144
|
+
if preview_schema_file:
|
|
145
|
+
payload["preview_schema"] = require_object(
|
|
146
|
+
read_json_file(preview_schema_file, label="Preview Schema"),
|
|
147
|
+
label="Preview Schema",
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
client = get_client(ctx)
|
|
151
|
+
if any(field in payload for field in ("import_name", "component_type", "preview_schema")):
|
|
152
|
+
job = client.update_component_metadata_async({"component_id": component_id, **payload})
|
|
153
|
+
result = resolve_wait_job(client, job, wait=wait, timeout=timeout)
|
|
154
|
+
if wait:
|
|
155
|
+
require_success_job(result, ctx=ctx)
|
|
156
|
+
output_result(ctx, result)
|
|
157
|
+
return
|
|
158
|
+
output_result(ctx, client.patch(f"/components/{component_id}", json_data=payload))
|
|
159
|
+
except ApiClientError as err:
|
|
160
|
+
handle_api_error("更新组件失败", err)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@component_group.command("edit")
|
|
164
|
+
@click.argument("component_id", type=int)
|
|
165
|
+
@click.option("--edits-file", type=click.Path(exists=True, dir_okay=False), required=True)
|
|
166
|
+
@click.option("--base-version-no", type=int, required=True)
|
|
167
|
+
@click.option("--base-draft-hash", required=True)
|
|
168
|
+
@click.option("--wait/--no-wait", default=True)
|
|
169
|
+
@click.option("--timeout", type=float, default=120.0, show_default=True)
|
|
170
|
+
@idempotency_key_option
|
|
171
|
+
@click.pass_context
|
|
172
|
+
def edit_component_cmd(ctx: click.Context, component_id: int, edits_file: str, base_version_no: int, base_draft_hash: str, wait: bool, timeout: float) -> None:
|
|
173
|
+
"""提交组件源码结构化编辑任务。"""
|
|
174
|
+
|
|
175
|
+
edits = require_array(read_json_file(edits_file, label="组件编辑操作"), label="组件编辑操作")
|
|
176
|
+
try:
|
|
177
|
+
client = get_client(ctx)
|
|
178
|
+
payload = {"component_id": component_id, "base_version_no": base_version_no, "base_draft_hash": base_draft_hash, "edits": edits}
|
|
179
|
+
result = resolve_wait_job(client, client.edit_component(component_id, payload), wait=wait, timeout=timeout)
|
|
180
|
+
if wait:
|
|
181
|
+
require_success_job(result, ctx=ctx)
|
|
182
|
+
output_result(ctx, result)
|
|
183
|
+
except ApiClientError as err:
|
|
184
|
+
handle_api_error("提交组件编辑失败", err)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@component_group.group("version")
|
|
188
|
+
def component_version_group() -> None:
|
|
189
|
+
"""组件历史发布版本。"""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@component_version_group.command("list")
|
|
193
|
+
@click.argument("component_id", type=int)
|
|
194
|
+
@click.pass_context
|
|
195
|
+
def list_component_versions_cmd(ctx: click.Context, component_id: int) -> None:
|
|
196
|
+
"""列出组件版本。"""
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
output_result(ctx, get_client(ctx).get(f"/components/{component_id}/versions"))
|
|
200
|
+
except ApiClientError as err:
|
|
201
|
+
handle_api_error("获取组件版本失败", err)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@component_version_group.command("get")
|
|
205
|
+
@click.argument("component_id", type=int)
|
|
206
|
+
@click.argument("version_no", type=int)
|
|
207
|
+
@click.pass_context
|
|
208
|
+
def get_component_version_cmd(ctx: click.Context, component_id: int, version_no: int) -> None:
|
|
209
|
+
"""获取组件指定版本。"""
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
output_result(ctx, get_client(ctx).get(f"/components/{component_id}/versions/{version_no}"))
|
|
213
|
+
except ApiClientError as err:
|
|
214
|
+
handle_api_error("获取组件版本内容失败", err)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@component_group.command("dependencies")
|
|
218
|
+
@click.argument("component_id", type=int)
|
|
219
|
+
@click.pass_context
|
|
220
|
+
def component_dependencies_cmd(ctx: click.Context, component_id: int) -> None:
|
|
221
|
+
"""获取组件当前版本依赖。"""
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
output_result(ctx, get_client(ctx).get(f"/components/{component_id}/dependencies"))
|
|
225
|
+
except ApiClientError as err:
|
|
226
|
+
handle_api_error("获取组件依赖失败", err)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@component_group.command("validate")
|
|
230
|
+
@click.argument("component_id", type=int)
|
|
231
|
+
@click.option("--mode", type=click.Choice(["current", "content", "edits"]), default="current")
|
|
232
|
+
@click.option("--source-file", type=click.Path(exists=True, dir_okay=False))
|
|
233
|
+
@click.option("--edits-file", type=click.Path(exists=True, dir_okay=False))
|
|
234
|
+
@click.option("--preview-schema-file", type=click.Path(exists=True, dir_okay=False))
|
|
235
|
+
@click.option("--detail", is_flag=True)
|
|
236
|
+
@click.pass_context
|
|
237
|
+
def validate_component_cmd(ctx: click.Context, component_id: int, mode: str, source_file: str | None, edits_file: str | None, preview_schema_file: str | None, detail: bool) -> None:
|
|
238
|
+
"""校验组件当前或候选源码。"""
|
|
239
|
+
|
|
240
|
+
payload: dict[str, object] = {"entity_type": "component", "entity_id": component_id, "mode": mode, "detail": detail}
|
|
241
|
+
if mode == "content":
|
|
242
|
+
if not source_file:
|
|
243
|
+
raise click.UsageError("content 模式必须提供 --source-file。")
|
|
244
|
+
payload["source_code"] = read_text_file(source_file, label="组件源码")
|
|
245
|
+
if mode == "edits":
|
|
246
|
+
if not edits_file:
|
|
247
|
+
raise click.UsageError("edits 模式必须提供 --edits-file。")
|
|
248
|
+
payload["edits"] = require_array(read_json_file(edits_file, label="组件编辑操作"), label="组件编辑操作")
|
|
249
|
+
if preview_schema_file:
|
|
250
|
+
payload["preview_schema"] = require_object(read_json_file(preview_schema_file, label="Preview Schema"), label="Preview Schema")
|
|
251
|
+
try:
|
|
252
|
+
output_result(ctx, get_client(ctx).validate_entity(payload))
|
|
253
|
+
except ApiClientError as err:
|
|
254
|
+
handle_api_error("组件校验失败", err)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@component_group.command("publish")
|
|
258
|
+
@click.argument("component_id", type=int)
|
|
259
|
+
@click.option("--release-name")
|
|
260
|
+
@click.option("--change-note")
|
|
261
|
+
@idempotency_key_option
|
|
262
|
+
@click.pass_context
|
|
263
|
+
def publish_component_cmd(ctx: click.Context, component_id: int, release_name: str | None, change_note: str | None) -> None:
|
|
264
|
+
"""发布组件当前草稿。"""
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
output_result(ctx, get_client(ctx).post(f"/components/{component_id}/publish", json_data={"release_name": release_name, "change_note": change_note}))
|
|
268
|
+
except ApiClientError as err:
|
|
269
|
+
handle_api_error("发布组件失败", err)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@component_group.command("archive")
|
|
273
|
+
@click.argument("component_id", type=int, required=False)
|
|
274
|
+
@click.option("--ids-file", type=click.Path(exists=True, dir_okay=False))
|
|
275
|
+
@click.option("--yes", is_flag=True)
|
|
276
|
+
@idempotency_key_option
|
|
277
|
+
@click.pass_context
|
|
278
|
+
def archive_component_cmd(ctx: click.Context, component_id: int | None, ids_file: str | None, yes: bool) -> None:
|
|
279
|
+
"""归档单个或一批组件。"""
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
client = get_client(ctx)
|
|
283
|
+
if ids_file:
|
|
284
|
+
ids = require_ids(read_json_file(ids_file, label="归档 ID"))
|
|
285
|
+
confirm_archive(ids, yes=yes, label="组件")
|
|
286
|
+
output_result(ctx, client.post("/components/batch-archive", json_data={"ids": ids}))
|
|
287
|
+
return
|
|
288
|
+
if component_id is None:
|
|
289
|
+
raise click.UsageError("必须提供 component_id 或 --ids-file。")
|
|
290
|
+
confirm_archive([component_id], yes=yes, label="组件")
|
|
291
|
+
output_result(ctx, client.post(f"/components/{component_id}/archive"))
|
|
292
|
+
except ApiClientError as err:
|
|
293
|
+
handle_api_error("归档组件失败", err)
|
wp/commands/doctor.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""文件功能:执行 CLI 诊断(连通性、PAT 有效性、默认空间与权限检测)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
import wp
|
|
9
|
+
from wp.client import ApiClient, ApiClientError
|
|
10
|
+
from wp.config import get_profile, load_config
|
|
11
|
+
from wp.formatter import print_json, print_table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command("doctor")
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def doctor_cmd(ctx: click.Context) -> None:
|
|
17
|
+
"""全面诊断本地 CLI 环境、服务连通性与 PAT 授权状态。"""
|
|
18
|
+
|
|
19
|
+
cfg = load_config()
|
|
20
|
+
profile = get_profile(cfg, ctx.obj.get("profile"))
|
|
21
|
+
diagnostics: list[dict[str, str]] = []
|
|
22
|
+
|
|
23
|
+
# 1. CLI 版本
|
|
24
|
+
diagnostics.append({"check": "CLI 版本", "value": f"v{wp.__version__}", "status": "ok"})
|
|
25
|
+
|
|
26
|
+
# 2. 服务端探活
|
|
27
|
+
endpoint = profile.endpoint.rstrip("/")
|
|
28
|
+
health_value = "不可达"
|
|
29
|
+
health_status = "error"
|
|
30
|
+
try:
|
|
31
|
+
r = httpx.get(f"{endpoint}/api/v1/system/health", timeout=5.0)
|
|
32
|
+
if r.status_code == 200:
|
|
33
|
+
health = r.json()
|
|
34
|
+
health_data = health if isinstance(health, dict) else {}
|
|
35
|
+
backend_status = str(health_data.get("status", "unknown"))
|
|
36
|
+
health_value = (
|
|
37
|
+
f"{backend_status}(database={health_data.get('database')}, "
|
|
38
|
+
f"redis={health_data.get('redis')})"
|
|
39
|
+
)
|
|
40
|
+
health_status = "ok" if backend_status == "ok" else "warning"
|
|
41
|
+
else:
|
|
42
|
+
health_value = f"HTTP {r.status_code}"
|
|
43
|
+
except (httpx.RequestError, ValueError) as exc:
|
|
44
|
+
health_value = str(exc) or health_value
|
|
45
|
+
diagnostics.append({"check": "Backend 地址", "value": f"{endpoint}:{health_value}", "status": health_status})
|
|
46
|
+
|
|
47
|
+
# 3. PAT 凭证检测
|
|
48
|
+
token_status = "未配置"
|
|
49
|
+
if profile.token:
|
|
50
|
+
token_status = "已配置"
|
|
51
|
+
diagnostics.append(
|
|
52
|
+
{
|
|
53
|
+
"check": "访问令牌 (PAT)",
|
|
54
|
+
"value": token_status,
|
|
55
|
+
"status": "ok" if profile.token else "warning",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 4. API 连通与权限校验
|
|
60
|
+
if profile.token:
|
|
61
|
+
client = ApiClient(profile)
|
|
62
|
+
try:
|
|
63
|
+
workspaces = client.get("/workspaces")
|
|
64
|
+
diagnostics.append({"check": "授权工作空间", "value": f"{len(workspaces)} 个可用空间", "status": "ok"})
|
|
65
|
+
except ApiClientError as err:
|
|
66
|
+
diagnostics.append({"check": "API 认证", "value": f"认证失败: {err.message}", "status": "error"})
|
|
67
|
+
|
|
68
|
+
ws_id = profile.default_workspace_id
|
|
69
|
+
if ws_id:
|
|
70
|
+
try:
|
|
71
|
+
ws = client.get(f"/workspaces/{ws_id}")
|
|
72
|
+
diagnostics.append({"check": "默认工作空间", "value": f"{ws.get('name')} (ID: {ws_id})", "status": "ok"})
|
|
73
|
+
except ApiClientError:
|
|
74
|
+
diagnostics.append({"check": "默认工作空间", "value": f"访问受限 (ID: {ws_id})", "status": "error"})
|
|
75
|
+
else:
|
|
76
|
+
diagnostics.append({"check": "默认工作空间", "value": "未设置,请使用 wp workspace use <id>", "status": "warning"})
|
|
77
|
+
|
|
78
|
+
if ctx.obj.get("as_json"):
|
|
79
|
+
print_json(diagnostics)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
status_labels = {"ok": "正常", "warning": "警告", "error": "失败"}
|
|
83
|
+
rows = [[item["check"], item["value"], status_labels.get(item["status"], item["status"])] for item in diagnostics]
|
|
84
|
+
print_table("CLI 诊断检查报告", ["检查项", "当前状态", "判定结果"], rows)
|
wp/commands/job.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""文件功能:提供统一的异步 Mutation Job 查询、等待、取消与重试命令。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from wp.client import ApiClientError
|
|
8
|
+
from wp.commands.common import (
|
|
9
|
+
get_client,
|
|
10
|
+
handle_api_error,
|
|
11
|
+
idempotency_key_option,
|
|
12
|
+
output_result,
|
|
13
|
+
require_success_job,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group("job")
|
|
18
|
+
def job_group() -> None:
|
|
19
|
+
"""异步任务管理。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@job_group.command("get")
|
|
23
|
+
@click.argument("job_id")
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def get_job_cmd(ctx: click.Context, job_id: str) -> None:
|
|
26
|
+
"""查询 Mutation Job。"""
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
output_result(ctx, get_client(ctx).get_mutation_job(job_id))
|
|
30
|
+
except ApiClientError as err:
|
|
31
|
+
handle_api_error("查询 Job 失败", err)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@job_group.command("wait")
|
|
35
|
+
@click.argument("job_id")
|
|
36
|
+
@click.option("--timeout", default=120.0, type=float, show_default=True)
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def wait_job_cmd(ctx: click.Context, job_id: str, timeout: float) -> None:
|
|
39
|
+
"""等待 Mutation Job 进入终态。"""
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
result = get_client(ctx).poll_mutation_job(job_id, timeout_seconds=timeout)
|
|
43
|
+
require_success_job(result, ctx=ctx)
|
|
44
|
+
output_result(ctx, result)
|
|
45
|
+
except ApiClientError as err:
|
|
46
|
+
handle_api_error("等待 Job 失败", err)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@job_group.command("cancel")
|
|
50
|
+
@click.argument("job_id")
|
|
51
|
+
@idempotency_key_option
|
|
52
|
+
@click.pass_context
|
|
53
|
+
def cancel_job_cmd(ctx: click.Context, job_id: str) -> None:
|
|
54
|
+
"""取消 pending 或 running Mutation Job。"""
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
output_result(
|
|
58
|
+
ctx,
|
|
59
|
+
get_client(ctx).cancel_mutation_job(
|
|
60
|
+
job_id,
|
|
61
|
+
idempotency_key=ctx.obj.get("idempotency_key"),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
except ApiClientError as err:
|
|
65
|
+
handle_api_error("取消 Job 失败", err)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@job_group.command("retry")
|
|
69
|
+
@click.argument("job_id")
|
|
70
|
+
@idempotency_key_option
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def retry_job_cmd(ctx: click.Context, job_id: str) -> None:
|
|
73
|
+
"""重试一个明确允许人工重试的失败 Job。"""
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
output_result(
|
|
77
|
+
ctx,
|
|
78
|
+
get_client(ctx).retry_mutation_job(
|
|
79
|
+
job_id,
|
|
80
|
+
idempotency_key=ctx.obj.get("idempotency_key"),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
except ApiClientError as err:
|
|
84
|
+
handle_api_error("重试 Job 失败", err)
|