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.
@@ -0,0 +1,73 @@
1
+ """文件功能:提供页面最新截图获取与保存命令,自动触发服务端刷新并返回最新截图。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import uuid
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, print_success, print_table
14
+
15
+
16
+ @click.command("screenshot")
17
+ @click.argument("page_id", type=int)
18
+ @click.option("--output", "-o", help="截图输出保存路径 (默认保存为 page-<page_id>-v<version>.png)")
19
+ @click.pass_context
20
+ def screenshot_cmd(
21
+ ctx: click.Context,
22
+ page_id: int,
23
+ output: str | None,
24
+ ) -> None:
25
+ """获取指定页面的最新截图(像视觉分析工具一样,自动在服务端刷新并返回最新 PNG 截图)。"""
26
+
27
+ cfg = load_config()
28
+ profile = get_profile(cfg, ctx.obj.get("profile"))
29
+ client = ApiClient(profile, workspace_id=ctx.obj.get("workspace_id"))
30
+
31
+ try:
32
+ page_meta, img_bytes = client.get_latest_page_screenshot(page_id=page_id)
33
+
34
+ version_no = page_meta.get("version_no") or 1
35
+ save_path = Path(output) if output else Path(f"page-{page_id}-v{version_no}.png")
36
+ tmp_path = save_path.with_name(f".{save_path.name}.tmp.{uuid.uuid4().hex[:8]}")
37
+
38
+ try:
39
+ save_path.parent.mkdir(parents=True, exist_ok=True)
40
+ tmp_path.write_bytes(img_bytes)
41
+ os.replace(tmp_path, save_path)
42
+ except OSError as exc:
43
+ if tmp_path.exists():
44
+ try:
45
+ tmp_path.unlink()
46
+ except OSError:
47
+ pass
48
+ print_error(f"写入截图文件 '{save_path}' 失败: {exc}")
49
+ raise SystemExit(1)
50
+
51
+ result_payload = {
52
+ "page_id": page_id,
53
+ "version_no": version_no,
54
+ "saved_file": str(save_path.resolve()),
55
+ "size_bytes": len(img_bytes),
56
+ }
57
+
58
+ if ctx.obj.get("as_json"):
59
+ print_json(result_payload)
60
+ return
61
+
62
+ print_success(f"成功获取页面 (ID: {page_id}) 最新截图并保存至 [bold]{save_path}[/bold]")
63
+ rows = [
64
+ ["页面 ID", str(page_id)],
65
+ ["页面版本", f"v{version_no}"],
66
+ ["文件大小", f"{len(img_bytes)} 字节"],
67
+ ["保存路径", str(save_path.resolve())],
68
+ ]
69
+ print_table("最新页面截图信息", ["属性", "值"], rows)
70
+
71
+ except ApiClientError as err:
72
+ print_error(f"获取最新截图失败: {err.message}", code=err.code)
73
+ raise SystemExit(1)
wp/commands/style.py ADDED
@@ -0,0 +1,155 @@
1
+ """文件功能:处理样式方案管理(列表、详情、创建、复制、归档)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClient, ApiClientError
8
+ from wp.config import get_profile, load_config
9
+ from wp.formatter import print_error, print_json, print_success, print_table
10
+ from wp.commands.common import (
11
+ confirm_archive,
12
+ get_client,
13
+ handle_api_error,
14
+ idempotency_key_option,
15
+ output_result,
16
+ read_json_file,
17
+ require_ids,
18
+ require_object,
19
+ )
20
+
21
+
22
+ @click.group("style")
23
+ def style_group() -> None:
24
+ """工作空间样式方案管理。"""
25
+
26
+
27
+ @style_group.command("list")
28
+ @click.option("--page", default=1, type=int, help="页码")
29
+ @click.option("--page-size", default=50, type=int, help="每页数量")
30
+ @click.pass_context
31
+ def list_styles_cmd(ctx: click.Context, page: int, page_size: int) -> None:
32
+ """查询工作空间的样式方案列表。"""
33
+
34
+ cfg = load_config()
35
+ profile = get_profile(cfg, ctx.obj.get("profile"))
36
+ client = ApiClient(profile, workspace_id=ctx.obj.get("workspace_id"))
37
+
38
+ try:
39
+ res = client.get("/styles", params={"page": page, "page_size": page_size})
40
+ if ctx.obj.get("as_json"):
41
+ print_json(res)
42
+ return
43
+
44
+ items = res.get("items", [])
45
+ rows = [[s["id"], s.get("name", "-"), s.get("is_default", False), s.get("status", "-")] for s in items]
46
+ print_table("样式方案列表", ["ID", "名称", "是否默认", "状态"], rows)
47
+ except ApiClientError as err:
48
+ print_error(f"获取样式列表失败: {err.message}", code=err.code)
49
+ raise SystemExit(1)
50
+
51
+
52
+ @style_group.command("get")
53
+ @click.argument("style_id", type=int)
54
+ @click.pass_context
55
+ def get_style_cmd(ctx: click.Context, style_id: int) -> None:
56
+ """获取单个样式方案详情。"""
57
+
58
+ cfg = load_config()
59
+ profile = get_profile(cfg, ctx.obj.get("profile"))
60
+ client = ApiClient(profile, workspace_id=ctx.obj.get("workspace_id"))
61
+
62
+ try:
63
+ style = client.get(f"/styles/{style_id}")
64
+ if ctx.obj.get("as_json"):
65
+ print_json(style)
66
+ return
67
+
68
+ rows = [
69
+ ["ID", str(style.get("id"))],
70
+ ["名称", str(style.get("name"))],
71
+ ["是否默认", str(style.get("is_default"))],
72
+ ["描述", str(style.get("description") or "-")],
73
+ ["状态", str(style.get("status"))],
74
+ ]
75
+ print_table(f"样式方案 (ID: {style_id}) 详情", ["属性", "值"], rows)
76
+ except ApiClientError as err:
77
+ print_error(f"获取样式方案失败: {err.message}", code=err.code)
78
+ raise SystemExit(1)
79
+
80
+
81
+ @style_group.command("create")
82
+ @click.option("--name", "-n", help="样式方案名称")
83
+ @click.option("--description", "-d", help="样式描述")
84
+ @click.option(
85
+ "--payload-file",
86
+ type=click.Path(exists=True, dir_okay=False),
87
+ help="完整样式创建 JSON;支持 configuration.presentation 或顶层 page_width 等展示字段",
88
+ )
89
+ @idempotency_key_option
90
+ @click.pass_context
91
+ def create_style_cmd(ctx: click.Context, name: str | None, description: str | None, payload_file: str | None) -> None:
92
+ """创建新样式方案。"""
93
+
94
+ try:
95
+ if payload_file:
96
+ payload = require_object(read_json_file(payload_file, label="样式创建载荷"), label="样式创建载荷")
97
+ else:
98
+ if not name:
99
+ raise click.UsageError("未使用 --payload-file 时必须提供 --name。")
100
+ payload = {"name": name, "description": description}
101
+ output_result(ctx, get_client(ctx).post("/styles", json_data=payload))
102
+ except ApiClientError as err:
103
+ handle_api_error("创建样式方案失败", err)
104
+
105
+
106
+ @style_group.command("update")
107
+ @click.argument("style_id", type=int)
108
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
109
+ @idempotency_key_option
110
+ @click.pass_context
111
+ def update_style_cmd(ctx: click.Context, style_id: int, payload_file: str) -> None:
112
+ """更新样式元数据和 configuration。"""
113
+
114
+ try:
115
+ output_result(ctx, get_client(ctx).patch(f"/styles/{style_id}", json_data=require_object(read_json_file(payload_file, label="样式更新载荷"), label="样式更新载荷")))
116
+ except ApiClientError as err:
117
+ handle_api_error("更新样式失败", err)
118
+
119
+
120
+ @style_group.command("copy")
121
+ @click.argument("style_id", type=int)
122
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
123
+ @idempotency_key_option
124
+ @click.pass_context
125
+ def copy_style_cmd(ctx: click.Context, style_id: int, payload_file: str) -> None:
126
+ """复制样式方案。"""
127
+
128
+ try:
129
+ output_result(ctx, get_client(ctx).post(f"/styles/{style_id}/copy", json_data=require_object(read_json_file(payload_file, label="样式复制载荷"), label="样式复制载荷")))
130
+ except ApiClientError as err:
131
+ handle_api_error("复制样式失败", err)
132
+
133
+
134
+ @style_group.command("archive")
135
+ @click.argument("style_id", type=int, required=False)
136
+ @click.option("--ids-file", type=click.Path(exists=True, dir_okay=False))
137
+ @click.option("--yes", "-y", is_flag=True, help="跳过确认直接归档")
138
+ @idempotency_key_option
139
+ @click.pass_context
140
+ def archive_style_cmd(ctx: click.Context, style_id: int | None, ids_file: str | None, yes: bool) -> None:
141
+ """归档样式方案(默认样式受保护禁止归档)。"""
142
+
143
+ try:
144
+ client = get_client(ctx)
145
+ if ids_file:
146
+ ids = require_ids(read_json_file(ids_file, label="归档 ID"))
147
+ confirm_archive(ids, yes=yes, label="样式")
148
+ output_result(ctx, client.post("/styles/batch-archive", json_data={"ids": ids}))
149
+ return
150
+ if style_id is None:
151
+ raise click.UsageError("必须提供 style_id 或 --ids-file。")
152
+ confirm_archive([style_id], yes=yes, label="样式")
153
+ output_result(ctx, client.post(f"/styles/{style_id}/archive"))
154
+ except ApiClientError as err:
155
+ handle_api_error("归档样式方案失败", err)
wp/commands/system.py ADDED
@@ -0,0 +1,93 @@
1
+ """文件功能:提供系统探活、开发标准和 External API 操作指南命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClientError
8
+ from wp.commands.common import get_client, handle_api_error, output_result
9
+
10
+
11
+ @click.group("system")
12
+ def system_group() -> None:
13
+ """系统信息与健康检查。"""
14
+
15
+
16
+ @system_group.command("version")
17
+ @click.pass_context
18
+ def system_version_cmd(ctx: click.Context) -> None:
19
+ """获取 Backend 与 External API 版本。"""
20
+
21
+ try:
22
+ output_result(ctx, get_client(ctx).get("/system/version"))
23
+ except ApiClientError as err:
24
+ handle_api_error("获取系统版本失败", err)
25
+
26
+
27
+ @system_group.command("health")
28
+ @click.pass_context
29
+ def system_health_cmd(ctx: click.Context) -> None:
30
+ """检查 Backend 数据库和 Redis 健康状态。"""
31
+
32
+ try:
33
+ output_result(ctx, get_client(ctx).get("/system/health"))
34
+ except ApiClientError as err:
35
+ handle_api_error("检查系统健康失败", err)
36
+
37
+
38
+ @click.group("standards")
39
+ def standards_group() -> None:
40
+ """读取页面和组件开发标准。"""
41
+
42
+
43
+ def _standard(entity_type: str, ctx: click.Context) -> None:
44
+ """读取指定资源的标准规范。"""
45
+
46
+ try:
47
+ output_result(ctx, get_client(ctx).get_standard(entity_type))
48
+ except ApiClientError as err:
49
+ handle_api_error("读取开发标准失败", err)
50
+
51
+
52
+ @standards_group.command("page")
53
+ @click.pass_context
54
+ def page_standards_cmd(ctx: click.Context) -> None:
55
+ """读取页面开发标准。"""
56
+
57
+ _standard("page", ctx)
58
+
59
+
60
+ @standards_group.command("component")
61
+ @click.pass_context
62
+ def component_standards_cmd(ctx: click.Context) -> None:
63
+ """读取组件开发标准。"""
64
+
65
+ _standard("component", ctx)
66
+
67
+
68
+ @click.group("guide")
69
+ def guide_group() -> None:
70
+ """读取 External API 操作指南。"""
71
+
72
+
73
+ @guide_group.command("list")
74
+ @click.pass_context
75
+ def guide_list_cmd(ctx: click.Context) -> None:
76
+ """列出当前 External API 操作。"""
77
+
78
+ try:
79
+ output_result(ctx, get_client(ctx).get_operation_guide())
80
+ except ApiClientError as err:
81
+ handle_api_error("获取操作指南列表失败", err)
82
+
83
+
84
+ @guide_group.command("get")
85
+ @click.argument("operation_key")
86
+ @click.pass_context
87
+ def guide_get_cmd(ctx: click.Context, operation_key: str) -> None:
88
+ """获取单个 External API 操作的 HTTP 与 JSON Schema 契约。"""
89
+
90
+ try:
91
+ output_result(ctx, get_client(ctx).get_operation_guide(operation_key))
92
+ except ApiClientError as err:
93
+ handle_api_error("获取操作指南详情失败", err)
wp/commands/theme.py ADDED
@@ -0,0 +1,152 @@
1
+ """文件功能:处理主题管理(列表、详情、创建、复制、归档)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClient, ApiClientError
8
+ from wp.config import get_profile, load_config
9
+ from wp.formatter import print_error, print_json, print_success, print_table
10
+ from wp.commands.common import (
11
+ confirm_archive,
12
+ get_client,
13
+ handle_api_error,
14
+ idempotency_key_option,
15
+ output_result,
16
+ read_json_file,
17
+ require_ids,
18
+ require_object,
19
+ )
20
+
21
+
22
+ @click.group("theme")
23
+ def theme_group() -> None:
24
+ """工作空间主题管理。"""
25
+
26
+
27
+ @theme_group.command("list")
28
+ @click.option("--page", default=1, type=int, help="页码")
29
+ @click.option("--page-size", default=50, type=int, help="每页数量")
30
+ @click.pass_context
31
+ def list_themes_cmd(ctx: click.Context, page: int, page_size: int) -> None:
32
+ """查询工作空间的主题列表。"""
33
+
34
+ cfg = load_config()
35
+ profile = get_profile(cfg, ctx.obj.get("profile"))
36
+ client = ApiClient(profile, workspace_id=ctx.obj.get("workspace_id"))
37
+
38
+ try:
39
+ res = client.get("/themes", params={"page": page, "page_size": page_size})
40
+ if ctx.obj.get("as_json"):
41
+ print_json(res)
42
+ return
43
+
44
+ items = res.get("items", [])
45
+ rows = [[t["id"], t.get("key", "-"), t.get("name", "-"), t.get("status", "-")] for t in items]
46
+ print_table("主题列表", ["ID", "Key", "名称", "状态"], rows)
47
+ except ApiClientError as err:
48
+ print_error(f"获取主题列表失败: {err.message}", code=err.code)
49
+ raise SystemExit(1)
50
+
51
+
52
+ @theme_group.command("get")
53
+ @click.argument("theme_id", type=int)
54
+ @click.pass_context
55
+ def get_theme_cmd(ctx: click.Context, theme_id: int) -> None:
56
+ """获取单个主题详情。"""
57
+
58
+ cfg = load_config()
59
+ profile = get_profile(cfg, ctx.obj.get("profile"))
60
+ client = ApiClient(profile, workspace_id=ctx.obj.get("workspace_id"))
61
+
62
+ try:
63
+ theme = client.get(f"/themes/{theme_id}")
64
+ if ctx.obj.get("as_json"):
65
+ print_json(theme)
66
+ return
67
+
68
+ rows = [
69
+ ["ID", str(theme.get("id"))],
70
+ ["Key", str(theme.get("key"))],
71
+ ["名称", str(theme.get("name"))],
72
+ ["描述", str(theme.get("description") or "-")],
73
+ ["状态", str(theme.get("status"))],
74
+ ]
75
+ print_table(f"主题 (ID: {theme_id}) 详情", ["属性", "值"], rows)
76
+ except ApiClientError as err:
77
+ print_error(f"获取主题失败: {err.message}", code=err.code)
78
+ raise SystemExit(1)
79
+
80
+
81
+ @theme_group.command("create")
82
+ @click.option("--key", "-k", help="主题唯一标识 (例如 light-corporate)")
83
+ @click.option("--name", "-n", help="主题名称")
84
+ @click.option("--description", "-d", help="主题描述")
85
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), help="完整主题 JSON 请求体")
86
+ @idempotency_key_option
87
+ @click.pass_context
88
+ def create_theme_cmd(ctx: click.Context, key: str | None, name: str | None, description: str | None, payload_file: str | None) -> None:
89
+ """创建新主题。"""
90
+
91
+ try:
92
+ if payload_file:
93
+ payload = require_object(read_json_file(payload_file, label="主题创建载荷"), label="主题创建载荷")
94
+ else:
95
+ if not key or not name:
96
+ raise click.UsageError("未使用 --payload-file 时必须提供 --key 和 --name。")
97
+ payload = {"key": key, "name": name, "description": description}
98
+ output_result(ctx, get_client(ctx).post("/themes", json_data=payload))
99
+ except ApiClientError as err:
100
+ handle_api_error("创建主题失败", err)
101
+
102
+
103
+ @theme_group.command("update")
104
+ @click.argument("theme_id", type=int)
105
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
106
+ @idempotency_key_option
107
+ @click.pass_context
108
+ def update_theme_cmd(ctx: click.Context, theme_id: int, payload_file: str) -> None:
109
+ """更新主题名称、描述和色板。"""
110
+
111
+ try:
112
+ output_result(ctx, get_client(ctx).patch(f"/themes/{theme_id}", json_data=require_object(read_json_file(payload_file, label="主题更新载荷"), label="主题更新载荷")))
113
+ except ApiClientError as err:
114
+ handle_api_error("更新主题失败", err)
115
+
116
+
117
+ @theme_group.command("copy")
118
+ @click.argument("theme_id", type=int)
119
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), required=True)
120
+ @idempotency_key_option
121
+ @click.pass_context
122
+ def copy_theme_cmd(ctx: click.Context, theme_id: int, payload_file: str) -> None:
123
+ """复制主题。"""
124
+
125
+ try:
126
+ output_result(ctx, get_client(ctx).post(f"/themes/{theme_id}/copy", json_data=require_object(read_json_file(payload_file, label="主题复制载荷"), label="主题复制载荷")))
127
+ except ApiClientError as err:
128
+ handle_api_error("复制主题失败", err)
129
+
130
+
131
+ @theme_group.command("archive")
132
+ @click.argument("theme_id", type=int, required=False)
133
+ @click.option("--ids-file", type=click.Path(exists=True, dir_okay=False))
134
+ @click.option("--yes", "-y", is_flag=True, help="跳过确认直接归档")
135
+ @idempotency_key_option
136
+ @click.pass_context
137
+ def archive_theme_cmd(ctx: click.Context, theme_id: int | None, ids_file: str | None, yes: bool) -> None:
138
+ """归档主题。"""
139
+
140
+ try:
141
+ client = get_client(ctx)
142
+ if ids_file:
143
+ ids = require_ids(read_json_file(ids_file, label="归档 ID"))
144
+ confirm_archive(ids, yes=yes, label="主题")
145
+ output_result(ctx, client.post("/themes/batch-archive", json_data={"ids": ids}))
146
+ return
147
+ if theme_id is None:
148
+ raise click.UsageError("必须提供 theme_id 或 --ids-file。")
149
+ confirm_archive([theme_id], yes=yes, label="主题")
150
+ output_result(ctx, client.post(f"/themes/{theme_id}/archive"))
151
+ except ApiClientError as err:
152
+ handle_api_error("归档主题失败", err)
@@ -0,0 +1,89 @@
1
+ """文件功能:处理工作空间列表查询、默认空间切换与能力矩阵发现。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from wp.client import ApiClient, ApiClientError
8
+ from wp.config import get_profile, load_config, save_config
9
+ from wp.formatter import print_error, print_json, print_success, print_table
10
+
11
+
12
+ @click.group("workspace")
13
+ def workspace_group() -> None:
14
+ """工作空间操作。"""
15
+
16
+
17
+ @workspace_group.command("list")
18
+ @click.pass_context
19
+ def list_workspaces_cmd(ctx: click.Context) -> None:
20
+ """列出当前令牌可访问的所有工作空间。"""
21
+
22
+ cfg = load_config()
23
+ profile = get_profile(cfg, ctx.obj.get("profile"))
24
+ client = ApiClient(profile)
25
+
26
+ try:
27
+ workspaces = client.get("/workspaces")
28
+ if ctx.obj.get("as_json"):
29
+ print_json(workspaces)
30
+ return
31
+
32
+ current_ws_id = profile.default_workspace_id
33
+ rows = []
34
+ for w in workspaces:
35
+ is_curr = "*" if w["id"] == current_ws_id else ""
36
+ rows.append([f"{is_curr} {w['id']}", w["name"], w.get("code") or "-", w.get("status")])
37
+
38
+ print_table("授权工作空间 (* 当前默认)", ["ID", "名称", "Code", "状态"], rows)
39
+ except ApiClientError as err:
40
+ print_error(f"获取工作空间失败: {err.message}", code=err.code)
41
+ raise SystemExit(1)
42
+
43
+
44
+ @workspace_group.command("use")
45
+ @click.argument("workspace_id", type=int)
46
+ @click.pass_context
47
+ def use_workspace_cmd(ctx: click.Context, workspace_id: int) -> None:
48
+ """切换本地默认操作的工作空间 ID。"""
49
+
50
+ cfg = load_config()
51
+ profile = get_profile(cfg, ctx.obj.get("profile"))
52
+ client = ApiClient(profile)
53
+
54
+ try:
55
+ ws = client.get(f"/workspaces/{workspace_id}")
56
+ profile.default_workspace_id = workspace_id
57
+ save_config(cfg)
58
+ print_success(f"已将默认工作空间切换为: [bold]{ws.get('name')}[/bold] (ID: {workspace_id})")
59
+ except ApiClientError as err:
60
+ print_error(f"切换工作空间失败: {err.message}", code=err.code)
61
+ raise SystemExit(1)
62
+
63
+
64
+ @workspace_group.command("capabilities")
65
+ @click.option("--workspace-id", "-w", type=int, help="工作空间 ID")
66
+ @click.pass_context
67
+ def get_capabilities_cmd(ctx: click.Context, workspace_id: int | None) -> None:
68
+ """查询当前令牌在指定工作空间的能力矩阵与可用操作列表。"""
69
+
70
+ cfg = load_config()
71
+ profile = get_profile(cfg, ctx.obj.get("profile"))
72
+ ws_id = workspace_id or ctx.obj.get("workspace_id") or profile.default_workspace_id
73
+ if not ws_id:
74
+ print_error("未指定工作空间 ID,请先使用 `wp workspace use <id>` 或提供 `-w <id>`。")
75
+ raise SystemExit(1)
76
+
77
+ client = ApiClient(profile, workspace_id=ws_id)
78
+ try:
79
+ caps = client.get(f"/workspaces/{ws_id}/capabilities")
80
+ if ctx.obj.get("as_json"):
81
+ print_json(caps)
82
+ return
83
+
84
+ print_success(f"工作空间 (ID: {ws_id}) 权限能力:")
85
+ print_table("授权 Scope", ["Scope 标识"], [[s] for s in caps.get("scopes", [])])
86
+ print_table("可用操作 (Operations)", ["操作 Key"], [[op] for op in caps.get("operations", [])])
87
+ except ApiClientError as err:
88
+ print_error(f"查询能力矩阵失败: {err.message}", code=err.code)
89
+ raise SystemExit(1)
wp/config.py ADDED
@@ -0,0 +1,91 @@
1
+ """文件功能:管理 CLI 本地配置文件与多 Profile 切换(存储在 ~/.web-presentation/config.json)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+ import time
10
+
11
+ from pydantic import BaseModel, Field, ValidationError
12
+
13
+ CONFIG_DIR = Path.home() / ".web-presentation"
14
+ CONFIG_FILE = CONFIG_DIR / "config.json"
15
+ DEFAULT_PROFILE = "default"
16
+ DEFAULT_ENDPOINT = "http://127.0.0.1:8000"
17
+
18
+
19
+ class ProfileConfig(BaseModel):
20
+ """单个环境 Profile 配置项。"""
21
+
22
+ endpoint: str = Field(default=DEFAULT_ENDPOINT)
23
+ token: str | None = Field(default=None)
24
+ default_workspace_id: int | None = Field(default=None)
25
+
26
+
27
+ class CliConfig(BaseModel):
28
+ """CLI 全局配置文件。"""
29
+
30
+ current_profile: str = Field(default=DEFAULT_PROFILE)
31
+ profiles: dict[str, ProfileConfig] = Field(
32
+ default_factory=lambda: {DEFAULT_PROFILE: ProfileConfig()}
33
+ )
34
+
35
+
36
+ def load_config() -> CliConfig:
37
+ """加载本地配置文件;若不存在则自动初始化默认配置。"""
38
+
39
+ if not CONFIG_FILE.exists():
40
+ return CliConfig()
41
+
42
+ try:
43
+ data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
44
+ return CliConfig.model_validate(data)
45
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValidationError):
46
+ # 配置损坏时先保留现场,避免后续 login/logout 覆盖掉其它 Profile。
47
+ backup_path = CONFIG_FILE.with_name(f"{CONFIG_FILE.name}.corrupt.{time.time_ns()}")
48
+ try:
49
+ CONFIG_FILE.replace(backup_path)
50
+ except OSError:
51
+ pass
52
+ return CliConfig()
53
+
54
+
55
+ def save_config(config: CliConfig) -> None:
56
+ """持久化保存本地配置文件(在 POSIX 系统上设置 0600 权限防止凭据泄露)。"""
57
+
58
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
59
+ content = json.dumps(config.model_dump(mode="json"), indent=2, ensure_ascii=False)
60
+ temp_path: Path | None = None
61
+ try:
62
+ with tempfile.NamedTemporaryFile(
63
+ mode="w",
64
+ encoding="utf-8",
65
+ dir=CONFIG_DIR,
66
+ prefix=".config.",
67
+ suffix=".tmp",
68
+ delete=False,
69
+ ) as temp_file:
70
+ temp_path = Path(temp_file.name)
71
+ temp_file.write(content)
72
+ temp_file.flush()
73
+ if os.name != "nt":
74
+ temp_path.chmod(0o600)
75
+ os.replace(temp_path, CONFIG_FILE)
76
+ temp_path = None
77
+ finally:
78
+ if temp_path is not None:
79
+ try:
80
+ temp_path.unlink()
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ def get_profile(config: CliConfig, profile_name: str | None = None) -> ProfileConfig:
86
+ """获取指定或当前的 Profile 配置。"""
87
+
88
+ name = profile_name or config.current_profile or DEFAULT_PROFILE
89
+ if name not in config.profiles:
90
+ config.profiles[name] = ProfileConfig()
91
+ return config.profiles[name]