Compare commits

...

10 Commits

Author SHA1 Message Date
097dde0ebb fix: open account browser maximized without Chrome first-run
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Human open now uses start-maximized and no-first-run flags, and profile shortcuts share the same args plus the platform URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 14:11:05 +08:00
718aeefd44 fix: unify row open labels and silent success
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Rename 打开文件位置 to 打开目录, and print empty SAS message so the host skips 执行成功 after opening Explorer or the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 13:15:26 +08:00
123195fcd9 fix: align add-web form fields to column names for lookup
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 15:14:31 +08:00
ba991a2462 feat: bind accounts create to add-web via replaces
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 14:41:36 +08:00
e9455cbb86 feat: data-management lookup for platform/account fields
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 13:37:07 +08:00
87877010ac fix: account open launches Chrome/Edge directly like reveal-profile
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 11:58:18 +08:00
0f54204a61 fix: account open fire-and-forget; open-browser direct+sync
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 11:43:11 +08:00
f48e8aa75d feat: row actions use interaction direct/confirm
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-07-31 11:15:05 +08:00
f5bab01421 feat: 数据管理行内打开文件位置与打开浏览器
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-31 10:02:00 +08:00
cbbec7cdfc fix: add init-db CLI for host install/upgrade metadata seed
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-26 16:03:27 +08:00
19 changed files with 763 additions and 143 deletions

View File

@@ -1,7 +1,7 @@
--- ---
name: 账号与凭据管理 name: 账号与凭据管理
description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。"
version: 2.0.12 version: 2.0.22
author: 深圳匠厂科技有限公司 author: 深圳匠厂科技有限公司
metadata: metadata:
openclaw: openclaw:
@@ -27,19 +27,21 @@ allowed-tools:
## 何时使用本技能 ## 何时使用本技能
1. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。 1. **安装/升级后初始化库与数据管理元数据**:`init-db`(宿主会自动调用;也可手工执行)
2. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。 2. **登记网页/RPA 账号**:`account add-web` — 平台 + 登录标识 + 自动生成 profile_dir;可选 `--auth-strategy` / `--session-persistent` / `--device-fingerprint`。数据管理「新增」已绑定该能力(`replaces: create`),勿用宿主直写库新建账号。
3. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。 3. **登记凭据(API Key / Token / 加密密码)**:`account add-secret` — `none` / `env` / `windows_credential` / `local_encrypted`。
4. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。 4. **查看/列出账号**:`account get/list` — JSON 输出;list 可按 `--login-id` / `--label` 筛选。
5. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。 5. **用人话定位账号**:`account resolve --login-id <手机号/用户名>` 或 `--label` / `--query`(不必先查数字 id)。
6. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。 6. **为 RPA 挑选账号**:`account pick-web` — 按平台/环境/角色,也可 `--login-id` / `--label`;业务首启推荐 `account ensure-web`(或 `pick-web --ensure`):无账号则自动 `add-web` 再返回。
7. **浏览器打开查看**:`account open <id>`(或兼容别名 `open <id>`)— 仅查看,不做站点侧登录判定。 7. **获取 API Key(凭据表)**:`credential pick` — 默认隐藏 secret,`--reveal` 时才返回。
8. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。 8. **打开浏览器**:`account open <id>`(或兼容别名 `open <id>`)— **本进程**直接拉起系统 Chrome/Edge(独立 `--user-data-dir`),最大化并跳过 Chrome 首次欢迎页,直接打开平台链接;与「打开目录」同为一点即走,不做登录判定。数据管理行内按钮均为 `interaction: direct` + `sync`,成功不弹提示。
9. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。 9. **打开目录**:`account reveal-profile <id>` — 在系统文件管理器中打开该账号 `profile_dir`。
10. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。 10. **管理 lease**:`lease release/list/cleanup` — 防止 RPA 并发。
11. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。 11. **初始化加密**:`account init-master-key` — 生成 Fernet master.key。
12. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。 12. **加密备份与迁移**:`account export-credentials --plaintext` / `account import-credentials` — 导出明文 JSON、在新环境重新加密写入。
13. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。 13. **按 lease 取明文密码**:`account get-credential <id> --reveal --lease-token <token>` — 结合 pick-web 返回的 lease 解密 `local_encrypted` 等路径。
14. **业务 skill 同进程登录**:`from service.rpa_helpers import ensure_logged_in` — 详见 [`references/RPA_HELPERS.md`](references/RPA_HELPERS.md)。
15. **有则用、无则建(网页账号)**:`account ensure-web --platform <p>` — 登记账号 ≠ 已登录;登录仍由业务技能 / `ensure_logged_in` 处理。
## CLI 调用 ## CLI 调用

View File

@@ -1,5 +1,6 @@
# assets # assets
- `actions.json`:匠厂宿主 Skill Action 清单(数据管理「账号信息」行内:打开目录 / 打开浏览器)。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:`get` / `list-json` / `pick-web` 单条对象的轻量 JSON Schema(`additionalProperties: true` 允许扩展字段)。

138
assets/actions.json Normal file
View File

@@ -0,0 +1,138 @@
{
"schemaVersion": 1,
"skill": "account-manager",
"actions": [
{
"id": "account-add-web",
"label": "新增账号",
"description": "登记网页/RPA 账号并自动创建浏览器 Profile 目录(走 account add-web,非宿主直写库)。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["accounts"]
},
"inputSchema": {
"type": "object",
"required": ["platform_key", "account_label"],
"properties": {
"platform_key": {
"type": "string",
"title": "平台",
"description": "关联平台信息表;宿主按列 lookup 渲染下拉"
},
"account_label": {
"type": "string",
"title": "账号名称"
},
"login_id": {
"type": "string",
"title": "登录账号"
},
"login_id_type": {
"type": "string",
"title": "登录标识类型",
"default": "unknown"
},
"tenant_id": {
"type": "string",
"title": "租户编号"
},
"environment": {
"type": "string",
"title": "环境",
"default": "production"
},
"role": {
"type": "string",
"title": "角色",
"default": "default"
},
"provider_code": {
"type": "string",
"title": "供应商代码"
},
"url": {
"type": "string",
"title": "入口链接"
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": [
"add-web",
"--platform", "{{platform_key}}",
"--label", "{{account_label}}",
"--login-id", "{{login_id}}",
"--login-id-type", "{{login_id_type}}",
"--tenant-id", "{{tenant_id}}",
"--environment", "{{environment}}",
"--role", "{{role}}",
"--provider-code", "{{provider_code}}",
"--url", "{{url}}"
]
}
},
{
"id": "reveal-profile",
"label": "打开目录",
"description": "在系统文件管理器中打开该账号的浏览器 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"description": "accounts 表主键",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["reveal-profile", "{{accountId}}"]
}
},
{
"id": "open-browser",
"label": "打开浏览器",
"description": "用该账号持久化 Profile 直接打开系统 Chrome/Edge,仅供肉眼核对(不做登录判定)。与打开目录同为一点即开。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": {
"type": "number",
"title": "账号 ID",
"description": "accounts 表主键",
"readOnly": true
}
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["open", "{{accountId}}"]
}
}
]
}

View File

@@ -17,6 +17,12 @@
## 子命令速查(按业务流程) ## 子命令速查(按业务流程)
### 0. 运行时初始化(宿主标准)
| CLI | 说明 |
|---|---|
| `init-db` | 幂等初始化本地 SQLite、schema 迁移与数据管理中文元数据(`_jiangchang_*`)。成功输出 JSON:`{"ok":true,"skill":"account-manager","db_path":"..."}`。**匠厂安装/升级技能时会调用本命令**;业务命令内部也会触发同等 `init_db()`。 |
### A. 平台管理 ### A. 平台管理
| CLI | 说明 | | CLI | 说明 |
@@ -48,7 +54,8 @@
| `account mark-session <id> --session-status <status>` | 标记会话状态 | | `account mark-session <id> --session-status <status>` | 标记会话状态 |
| `account mark-error <id> --code <code> --message <msg>` | 标记错误 | | `account mark-error <id> --code <code> --message <msg>` | 标记错误 |
| `account mark-used <id>` | 标记已使用 | | `account mark-used <id>` | 标记已使用 |
| `account open <id>` | 打开持久化浏览器 profile(仅查看) | | `account open <id>` | 本进程直接打开系统 Chrome/Edge(独立 profile;最大化;跳过首次欢迎页;打开平台 URL) |
| `account reveal-profile <id>` | 在系统文件管理器中打开该账号 `profile_dir` |
### D. 加密备份与迁移(v2) ### D. 加密备份与迁移(v2)
@@ -68,7 +75,8 @@
| `lease release <lease_token>` | 释放租约 | | `lease release <lease_token>` | 释放租约 |
| `lease list [--active]` | 列出租约 | | `lease list [--active]` | 列出租约 |
| `lease cleanup` | 清理过期租约 | | `lease cleanup` | 清理过期租约 |
| `account open <id>` / `open <id>` | 浏览器查看 | | `account open <id>` / `open <id>` | 打开浏览器 |
| `account reveal-profile <id>` | 打开目录 |
### 兼容入口(旧版别名) ### 兼容入口(旧版别名)

View File

@@ -87,6 +87,7 @@
|---|---| |---|---|
| `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge | | `ERROR:需要 playwright Python 包` | 未安装 `playwright` 包或本机无 Chrome/Edge |
| `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 | | `ERROR:PROFILE_DIR_MISSING` | 账号记录中 `profile_dir` 为空 |
| `ERROR:REVEAL_PROFILE_FAILED` | `account reveal-profile` 无法打开目录(系统调用失败) |
--- ---

View File

@@ -4,7 +4,7 @@ CLI entry point: argv dispatch for account-manager v2.
Subcommand tree: Subcommand tree:
platform list/get/ensure platform list/get/ensure
account add-web|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open account add-web|ensure-web|add-secret|get|get-credential|list|resolve|pick-web|mark-session|mark-error|mark-used|open|reveal-profile
init-master-key|export-credentials|import-credentials init-master-key|export-credentials|import-credentials
credential pick|resolve credential pick|resolve
lease release|list|cleanup lease release|list|cleanup
@@ -38,6 +38,7 @@ from service.account_service import (
cmd_delete_by_platform, cmd_delete_by_platform,
cmd_delete_by_platform_phone, cmd_delete_by_platform_phone,
cmd_get, cmd_get,
cmd_init_db,
cmd_lease_cleanup, cmd_lease_cleanup,
cmd_lease_list, cmd_lease_list,
cmd_lease_release, cmd_lease_release,
@@ -48,7 +49,7 @@ from service.account_service import (
cmd_platform_get, cmd_platform_get,
cmd_platform_list, cmd_platform_list,
) )
from service.browser_service import cmd_open from service.browser_service import cmd_open, cmd_reveal_profile
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
from util.logging_config import get_skill_logger, setup_skill_logging from util.logging_config import get_skill_logger, setup_skill_logging
from util.platforms import _platform_list_cn_for_help, resolve_platform_key from util.platforms import _platform_list_cn_for_help, resolve_platform_key
@@ -60,6 +61,9 @@ from util.runtime_paths import _maybe_print_paths_debug_cli
_USAGE = """account-manager v2 — Credential & Browser Profile Manager _USAGE = """account-manager v2 — Credential & Browser Profile Manager
运行时 / 数据管理:
init-db # 幂等初始化本地库与数据管理中文元数据(宿主安装/升级会调用)
平台管理: 平台管理:
platform list [--domain logistics|llm|content] platform list [--domain logistics|llm|content]
platform get <platform> platform get <platform>
@@ -100,6 +104,7 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
account mark-error <id> --code <code> --message <msg> account mark-error <id> --code <code> --message <msg>
account mark-used <id> account mark-used <id>
account open <id> account open <id>
account reveal-profile <id>
account init-master-key [--print|--from-env|--rotate] account init-master-key [--print|--from-env|--rotate]
account export-credentials --plaintext account export-credentials --plaintext
account import-credentials [--replace-all] < backup.json account import-credentials [--replace-all] < backup.json
@@ -114,8 +119,9 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
lease list [--active] lease list [--active]
lease cleanup lease cleanup
浏览器: 浏览器 / Profile:
account open <id> account open <id>
account reveal-profile <id> # 在文件管理器中打开 profile_dir
open <id> # 兼容别名 open <id> # 兼容别名
兼容入口(旧版): 兼容入口(旧版):
@@ -217,6 +223,10 @@ def main() -> None:
# Determine if first arg is a platform key (for legacy shorthand) # Determine if first arg is a platform key (for legacy shorthand)
plat_from_first = resolve_platform_key(cmd) plat_from_first = resolve_platform_key(cmd)
# ---- init-db(宿主 install/upgrade 标准入口)----
if cmd == "init-db":
sys.exit(cmd_init_db())
# ---- platform subcommand ---- # ---- platform subcommand ----
if cmd == "platform": if cmd == "platform":
if len(av) < 3: if len(av) < 3:
@@ -314,6 +324,11 @@ def main() -> None:
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。") print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
sys.exit(1) sys.exit(1)
cmd_open(av[3]) cmd_open(av[3])
elif sub == "reveal-profile":
if len(av) < 4:
print("ERROR:CLI_BAD_ARGS account reveal-profile 需要 <id>。")
sys.exit(1)
cmd_reveal_profile(av[3])
elif sub == "init-master-key": elif sub == "init-master-key":
cmd_account_init_master_key(av) cmd_account_init_master_key(av)
elif sub == "export-credentials": elif sub == "export-credentials":

View File

@@ -6,6 +6,7 @@ SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据
from __future__ import annotations from __future__ import annotations
import json
from typing import Iterable from typing import Iterable
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
@@ -20,6 +21,26 @@ ACCOUNT_LEASES_TABLE = "account_leases"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds" DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
# Data-management lookup: host resolves options_json object into select options.
PLATFORM_KEY_LOOKUP_JSON = json.dumps(
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
},
ensure_ascii=False,
)
ACCOUNT_ID_LOOKUP_JSON = json.dumps(
{
"ref_table": "accounts",
"value_column": "id",
"label_column": "account_label",
},
ensure_ascii=False,
)
USER_BUSINESS_TABLES = ( USER_BUSINESS_TABLES = (
PLATFORMS_TABLE, PLATFORMS_TABLE,
ACCOUNTS_TABLE, ACCOUNTS_TABLE,
@@ -123,7 +144,13 @@ ACCOUNTS_TABLE_METADATA = {
ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="账号唯一编号", editable=0), _col("id", "编号", description="账号唯一编号", editable=0),
_col("platform_key", "平台", description="所属平台键"), _col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
),
_col("account_label", "账号名称", description="人可读的账号名称"), _col("account_label", "账号名称", description="人可读的账号名称"),
_col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"), _col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"),
_col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"), _col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"),
@@ -158,8 +185,20 @@ CREDENTIALS_TABLE_METADATA = {
CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
_col("id", "编号", description="凭据唯一编号", editable=0), _col("id", "编号", description="凭据唯一编号", editable=0),
_col("account_id", "账号编号", description="关联的 accounts.id,可空"), _col(
_col("platform_key", "平台", description="所属平台键"), "account_id",
"账号编号",
description="关联的 accounts.id,可空",
display_type="lookup",
options_json=ACCOUNT_ID_LOOKUP_JSON,
),
_col(
"platform_key",
"平台",
description="所属平台键(来自平台信息表)",
display_type="lookup",
options_json=PLATFORM_KEY_LOOKUP_JSON,
),
_col("credential_label", "凭据名称", description="人可读的凭据名称"), _col("credential_label", "凭据名称", description="人可读的凭据名称"),
_col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"), _col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"),
_col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0), _col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0),

View File

@@ -162,7 +162,9 @@ def create_web_account_record(
finally: finally:
conn.close() conn.close()
shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log) shortcut_path = create_profile_browser_shortcut(
resolved_profile_dir, safe_label, log, url=resolved_url,
)
log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir)
ok_payload = { ok_payload = {

View File

@@ -39,7 +39,12 @@ from service.legacy_commands import (
cmd_list, cmd_list,
) )
from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release from service.lease_commands import cmd_lease_cleanup, cmd_lease_list, cmd_lease_release
from service.platform_commands import cmd_platform_ensure, cmd_platform_get, cmd_platform_list from service.platform_commands import (
cmd_init_db,
cmd_platform_ensure,
cmd_platform_get,
cmd_platform_list,
)
__all__ = [ __all__ = [
"PlatformCallerMeta", "PlatformCallerMeta",
@@ -60,6 +65,7 @@ __all__ = [
"cmd_delete_by_platform", "cmd_delete_by_platform",
"cmd_delete_by_platform_phone", "cmd_delete_by_platform_phone",
"cmd_get", "cmd_get",
"cmd_init_db",
"cmd_lease_cleanup", "cmd_lease_cleanup",
"cmd_lease_list", "cmd_lease_list",
"cmd_lease_release", "cmd_lease_release",

View File

@@ -2,7 +2,7 @@
""" """
Chromium/Edge browser profile opener. Chromium/Edge browser profile opener.
Uses system Chrome/Edge channel, Playwright launch_persistent_context. Uses system Chrome/Edge executable with --user-data-dir (same profile as RPA shortcuts).
Does NOT download Playwright Chromium. Does NOT download Playwright Chromium.
Does NOT check login status. Does NOT check login status.
Does NOT write secret to SQLite. Does NOT write secret to SQLite.
@@ -11,20 +11,12 @@ import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
from db.connection import get_conn, init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id, get_platform_from_db from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import get_skill_logger
get_skill_logger,
subprocess_env_with_trace,
)
from util.platforms import PLATFORMS from util.platforms import PLATFORMS
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
def _win_find_exe(candidates): def _win_find_exe(candidates):
for p in candidates: for p in candidates:
@@ -83,6 +75,35 @@ def resolve_chromium_channel():
return "chrome" return "chrome"
# 给人核对/手工登录用:跳过 Chrome 首次欢迎页并最大化。不要带 Playwright 的 --enable-automation。
HUMAN_CHROME_FLAGS = (
"--start-maximized",
"--no-first-run",
"--no-default-browser-check",
"--disable-search-engine-choice-screen",
)
def human_chrome_launch_args(profile_dir, url=None):
"""chrome.exe 之后的参数(Popen 列表形式,路径不额外加引号)。"""
profile_abs = os.path.abspath(profile_dir)
args = [f"--user-data-dir={profile_abs}", *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
args.append(cleaned)
return args
def human_chrome_shortcut_arguments(profile_dir, url=None):
"""Windows .lnk Arguments 字符串(user-data-dir 带引号)。"""
profile_abs = os.path.abspath(profile_dir)
parts = [f'--user-data-dir="{profile_abs}"', *HUMAN_CHROME_FLAGS]
cleaned = (url or "").strip()
if cleaned:
parts.append(cleaned)
return " ".join(parts)
def _print_browser_install_hint(): def _print_browser_install_hint():
print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:") print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:")
print(" • Chrome: https://www.google.com/chrome/") print(" • Chrome: https://www.google.com/chrome/")
@@ -114,10 +135,67 @@ def resolve_account_browser_url(account: dict, conn=None) -> str:
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip() return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
def cmd_reveal_profile(account_id):
"""
在系统文件管理器中打开该账号的 profile_dir。
不写数据库、不启动浏览器。
"""
log = get_skill_logger()
init_db()
log.info("reveal_profile_start account_id=%r", account_id)
target = get_account_by_id(account_id)
if not target:
log.warning("reveal_profile_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND")
sys.exit(1)
profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir:
log.warning("reveal_profile_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
sys.exit(1)
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
try:
if sys.platform == "win32":
# explorer 即使成功也常返回非 0;用 startfile 打开目录更稳妥
os.startfile(profile_abs) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.run(["open", profile_abs], check=False)
else:
subprocess.run(["xdg-open", profile_abs], check=False)
except OSError as exc:
log.error("reveal_profile_error account_id=%s error=%s", account_id, str(exc))
print(f"ERROR:REVEAL_PROFILE_FAILED {exc}")
sys.exit(1)
log.info("reveal_profile_ok account_id=%s profile_dir=%s", account_id, profile_abs)
print(
json.dumps(
{
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True,
"data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
"profile_dir": profile_abs,
},
},
ensure_ascii=False,
)
)
def cmd_open(account_id): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。
不写数据库、不判定是否已登录。 与 reveal-profile 同模式:本进程直接拉起系统 Chrome/Edge 后立即返回。
最大化、跳过 Chrome 首次欢迎页;不写数据库、不判定是否已登录、不起 Playwright。
""" """
log = get_skill_logger() log = get_skill_logger()
init_db() init_db()
@@ -127,27 +205,25 @@ def cmd_open(account_id):
if not target: if not target:
log.warning("browser_open_error account_id=%r not_found", account_id) log.warning("browser_open_error account_id=%r not_found", account_id)
print("ERROR:ACCOUNT_NOT_FOUND") print("ERROR:ACCOUNT_NOT_FOUND")
return sys.exit(1)
channel = resolve_chromium_channel() browser_exe = resolve_chromium_executable()
if not channel: if not browser_exe:
_print_browser_install_hint() channel = resolve_chromium_channel()
return if sys.platform == "win32" or not channel:
_print_browser_install_hint()
try: sys.exit(1)
import playwright # noqa: F401 # 非 Windows:退回 PATH 上的常见命令名
except ImportError: browser_exe = "google-chrome" if channel == "chrome" else "microsoft-edge"
print(
"ERROR:需要 playwright Python 包。"
)
return
profile_dir = (target.get("profile_dir") or "").strip() profile_dir = (target.get("profile_dir") or "").strip()
if not profile_dir: if not profile_dir:
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id) log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。") print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
return sys.exit(1)
os.makedirs(profile_dir, exist_ok=True)
profile_abs = os.path.abspath(profile_dir)
os.makedirs(profile_abs, exist_ok=True)
conn = get_conn() conn = get_conn()
try: try:
@@ -155,37 +231,66 @@ def cmd_open(account_id):
finally: finally:
conn.close() conn.close()
# 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态
cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)]
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s",
profile_dir, browser_exe,
profile_abs,
url, url,
channel,
target.get("platform_key"), target.get("platform_key"),
) )
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
print(f"地址:{url}")
print("本命令不做登录检测。")
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
) as jf:
json.dump(cfg, jf, ensure_ascii=False)
cfg_path = jf.name
try: try:
subprocess.run( # GUI 浏览器:不挂控制台黑框;不等待关闭(与 reveal 一致立刻返回)
[sys.executable, "-m", "service.open_child_runner", cfg_path], popen_kwargs: dict = {
cwd=_SCRIPTS_ROOT, "stdin": subprocess.DEVNULL,
env=subprocess_env_with_trace(), "stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if os.path.isabs(browser_exe):
popen_kwargs["cwd"] = os.path.dirname(browser_exe)
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
child = subprocess.Popen(cmd, **popen_kwargs)
except OSError as exc:
log.error("browser_open_error account_id=%s error=%s", account_id, str(exc))
print(f"ERROR:BROWSER_OPEN_FAILED {exc}")
sys.exit(1)
browser_name = (
"Microsoft Edge"
if os.path.basename(browser_exe).lower() == "msedge.exe"
else "Google Chrome"
)
log.info(
"browser_open_ok account_id=%s browser=%s pid=%s",
account_id,
browser_name,
child.pid,
)
print(
json.dumps(
{
"type": "result",
"schema_version": 1,
"status": "success",
"message": "",
"success": True,
"data": {
"account_id": int(target["id"]) if target.get("id") is not None else account_id,
"account_label": target.get("account_label"),
"browser": browser_name,
"browser_exe": browser_exe,
"profile_dir": profile_abs,
"url": url,
"pid": child.pid,
},
},
ensure_ascii=False,
) )
log.info("browser_open_close account_id=%s channel=%s", account_id, channel) )
except Exception as e:
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
raise
finally:
try:
os.unlink(cfg_path)
except OSError:
pass

View File

@@ -1,46 +0,0 @@
"""Playwright 仅打开浏览器子进程:由 browser_service.cmd_open 启动,argv[1] 为 JSON 配置路径。"""
import json
import sys
from playwright.sync_api import sync_playwright
from util.playwright_stealth import (
STEALTH_INIT_SCRIPT,
persistent_context_launch_parts,
stealth_enabled,
)
def main():
with open(sys.argv[1], encoding="utf-8") as f:
c = json.load(f)
with sync_playwright() as p:
args, ignore_automation = persistent_context_launch_parts()
lc_kw = dict(
user_data_dir=c["profile_dir"],
headless=False,
channel=c["channel"],
no_viewport=True,
args=args,
)
if ignore_automation is not None:
lc_kw["ignore_default_args"] = ignore_automation
ctx = p.chromium.launch_persistent_context(**lc_kw)
if stealth_enabled():
ctx.add_init_script(STEALTH_INIT_SCRIPT)
try:
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto(c["url"], wait_until="domcontentloaded", timeout=60000)
ctx.wait_for_event("close", timeout=86400000)
except Exception as e:
print(e, file=sys.stderr)
raise
finally:
try:
ctx.close()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -6,8 +6,38 @@ from typing import Optional
from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform from db.accounts_repo import get_platform_from_db, list_platforms_from_db, upsert_platform
from db.connection import get_conn, init_db from db.connection import get_conn, init_db
from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say from service._svc_common import _err_json, _ok_json, _resolve_or_fail, _say
from util.constants import SKILL_SLUG
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms from util.platforms import DEFAULT_CAPABILITIES_JSON, is_valid_platform_key_format, seed_platforms
from util.runtime_paths import get_db_path
def cmd_init_db() -> int:
"""幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。"""
log = get_skill_logger()
log.info("cli_start subcommand=init_db")
try:
init_db()
except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败
log.exception("init_db_failure")
print(
json.dumps(
{"ok": False, "skill": SKILL_SLUG, "error": str(exc)},
ensure_ascii=False,
)
)
return 1
print(
json.dumps(
{
"ok": True,
"skill": SKILL_SLUG,
"db_path": get_db_path(),
},
ensure_ascii=False,
)
)
return 0
def cmd_platform_list(domain: Optional[str] = None) -> None: def cmd_platform_list(domain: Optional[str] = None) -> None:

View File

@@ -9,7 +9,10 @@ import subprocess
import sys import sys
from typing import Optional from typing import Optional
from service.browser_service import resolve_chromium_executable from service.browser_service import (
human_chrome_shortcut_arguments,
resolve_chromium_executable,
)
_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
@@ -28,9 +31,10 @@ def create_profile_browser_shortcut(
profile_dir: str, profile_dir: str,
account_label: str, account_label: str,
log: Optional[logging.Logger] = None, log: Optional[logging.Logger] = None,
url: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
在 profile_dir 内创建 {account_label}.lnk,目标为系统 Chrome/Edge + --user-data-dir。 在 profile_dir 内创建 {account_label}.lnk,与「打开浏览器」同一套人用参数。
非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。 非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。
成功返回 .lnk 绝对路径。 成功返回 .lnk 绝对路径。
@@ -52,7 +56,7 @@ def create_profile_browser_shortcut(
lnk_name = f"{_lnk_safe_filename(account_label)}.lnk" lnk_name = f"{_lnk_safe_filename(account_label)}.lnk"
lnk_path = os.path.join(profile_abs, lnk_name) lnk_path = os.path.join(profile_abs, lnk_name)
arguments = f'--user-data-dir="{profile_abs}"' arguments = human_chrome_shortcut_arguments(profile_abs, url)
working_dir = os.path.dirname(browser_exe) working_dir = os.path.dirname(browser_exe)
ps = ( ps = (

View File

@@ -2,6 +2,9 @@
""" """
Browser launch helper checks (no real browser started). Browser launch helper checks (no real browser started).
""" """
import json
from unittest import mock
import pytest import pytest
@@ -14,24 +17,100 @@ class TestPlaywrightLaunchHelpers:
args, _ignore = persistent_context_launch_parts() args, _ignore = persistent_context_launch_parts()
assert "--start-maximized" in args assert "--start-maximized" in args
def test_open_child_runner_launch_keywords_documented(self):
"""launch_persistent_context kwargs contract (static read of source)."""
import os
path = os.path.join( class TestCmdOpenDirectBrowser:
os.path.dirname(__file__), """account open = same-process Chrome/Edge launch (like reveal-profile)."""
"..",
"scripts", def test_cmd_open_launches_browser_exe(self, clean_db, tmp_path, capsys, monkeypatch):
"service", from db.accounts_repo import insert_account
"open_child_runner.py", from service import browser_service
from util.platforms import seed_platforms
seed_platforms(clean_db)
profile = tmp_path / "prof"
aid = insert_account(
clean_db,
platform_key="sohu",
account_label="open-test",
login_id="1",
login_id_type="phone",
tenant_id=None,
environment="production",
role="default",
provider_code="sohu",
profile_dir=str(profile),
url="https://example.com",
extra_json=None,
now=1_700_000_000,
auth_strategy="password_auto",
) )
path = os.path.abspath(path) clean_db.commit()
with open(path, encoding="utf-8") as f:
src = f.read() fake_exe = str(tmp_path / "chrome.exe")
assert "launch_persistent_context" in src monkeypatch.setattr(
assert "headless=False" in src browser_service,
assert "no_viewport=True" in src "resolve_chromium_executable",
assert '"channel"' in src or "'channel'" in src lambda: fake_exe,
)
class FakeProc:
pid = 5252
spawned = {}
def fake_popen(cmd, **kwargs):
spawned["cmd"] = list(cmd)
spawned["kwargs"] = kwargs
return FakeProc()
monkeypatch.setattr(browser_service.subprocess, "Popen", fake_popen)
browser_service.cmd_open(str(aid))
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["message"] == ""
assert payload["type"] == "result"
assert payload["status"] == "success"
assert payload["data"]["pid"] == 5252
assert payload["data"]["browser_exe"] == fake_exe
assert spawned["cmd"][0] == fake_exe
assert spawned["cmd"][1].startswith("--user-data-dir=")
assert "--start-maximized" in spawned["cmd"]
assert "--no-first-run" in spawned["cmd"]
assert "--no-default-browser-check" in spawned["cmd"]
assert "--disable-search-engine-choice-screen" in spawned["cmd"]
assert spawned["cmd"][-1] == "https://example.com"
assert "--enable-automation" not in spawned["cmd"]
# must NOT spawn python / open_child_runner
joined = " ".join(spawned["cmd"])
assert "open_child_runner" not in joined
assert "python" not in joined.lower()
class TestHumanChromeLaunchArgs:
def test_flags_then_url(self, tmp_path):
from service.browser_service import HUMAN_CHROME_FLAGS, human_chrome_launch_args
profile = str(tmp_path / "prof")
args = human_chrome_launch_args(profile, "https://chat.deepseek.com")
assert args[0].startswith("--user-data-dir=")
for flag in HUMAN_CHROME_FLAGS:
assert flag in args
assert args[-1] == "https://chat.deepseek.com"
assert "--enable-automation" not in args
def test_shortcut_arguments_quote_profile_and_include_url(self, tmp_path):
from service.browser_service import human_chrome_shortcut_arguments
profile = tmp_path / "my prof"
profile.mkdir()
text = human_chrome_shortcut_arguments(str(profile), "https://example.com")
assert '--user-data-dir="' in text
assert "--start-maximized" in text
assert "--no-first-run" in text
assert text.endswith("https://example.com")
class TestResolveChromiumChannel: class TestResolveChromiumChannel:

View File

@@ -102,6 +102,44 @@ def test_sensitive_credential_fields_not_editable(metadata_db):
assert int(searchable) == 0, column_name assert int(searchable) == 0, column_name
def test_account_and_credential_platform_lookups(metadata_db):
import json
cur = metadata_db.cursor()
for table_name, column_name in (
(ACCOUNTS_TABLE, "platform_key"),
(CREDENTIALS_TABLE, "platform_key"),
):
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup", table_name
payload = json.loads(options_json)
assert payload["ref_table"] == "platforms"
assert payload["value_column"] == "platform_key"
assert payload["label_column"] == "display_name"
cur.execute(
"""
SELECT display_type, options_json
FROM _jiangchang_columns
WHERE table_name = ? AND column_name = 'account_id'
""",
(CREDENTIALS_TABLE,),
)
display_type, options_json = cur.fetchone()
assert display_type == "lookup"
payload = json.loads(options_json)
assert payload["ref_table"] == "accounts"
assert payload["value_column"] == "id"
def test_lease_token_not_editable(metadata_db): def test_lease_token_not_editable(metadata_db):
cur = metadata_db.cursor() cur = metadata_db.cursor()
cur.execute( cur.execute(

View File

@@ -31,6 +31,33 @@ class TestCreateProfileBrowserShortcut:
out = create_profile_browser_shortcut(str(tmp_path), "demo") out = create_profile_browser_shortcut(str(tmp_path), "demo")
assert out is None assert out is None
def test_windows_lnk_uses_human_chrome_args(self, tmp_path, monkeypatch) -> None:
captured = {}
def fake_run(argv, **kwargs):
captured["argv"] = list(argv)
class Result:
returncode = 0
return Result()
monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32")
monkeypatch.setattr(
"util.profile_shortcut.resolve_chromium_executable",
lambda: r"C:\Program Files\Google\Chrome\Application\chrome.exe",
)
monkeypatch.setattr("util.profile_shortcut.subprocess.run", fake_run)
monkeypatch.setattr("util.profile_shortcut.os.path.isfile", lambda path: True)
out = create_profile_browser_shortcut(
str(tmp_path), "demo", url="https://chat.deepseek.com",
)
assert out is not None
ps = captured["argv"][-1]
assert "--start-maximized" in ps
assert "--no-first-run" in ps
assert "https://chat.deepseek.com" in ps
assert "--enable-automation" not in ps
@pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only") @pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only")
def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None: def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None:
from service.browser_service import resolve_chromium_executable from service.browser_service import resolve_chromium_executable

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""account reveal-profile:打开 profile 目录(不启动浏览器)。"""
from __future__ import annotations
import json
import os
from unittest import mock
import pytest
def _insert_account(conn, *, profile_dir: str) -> int:
from db.accounts_repo import insert_account
from util.platforms import seed_platforms
seed_platforms(conn)
aid = insert_account(
conn,
platform_key="sohu",
account_label="reveal-test",
login_id="13800000000",
login_id_type="phone",
tenant_id=None,
environment="production",
role="default",
provider_code="sohu",
profile_dir=profile_dir,
url="https://mp.sohu.com",
extra_json=None,
now=1_700_000_000,
auth_strategy="password_auto",
session_persistent=1,
device_fingerprint=None,
)
conn.commit()
return aid
def test_reveal_profile_opens_dir(clean_db, tmp_path, capsys):
from service.browser_service import cmd_reveal_profile
profile = tmp_path / "profiles" / "sohu" / "reveal-test"
aid = _insert_account(clean_db, profile_dir=str(profile))
with mock.patch("service.browser_service.os.startfile") as startfile:
with mock.patch("service.browser_service.sys.platform", "win32"):
cmd_reveal_profile(str(aid))
startfile.assert_called_once()
called_path = startfile.call_args[0][0]
assert os.path.isdir(called_path)
assert os.path.abspath(str(profile)) == called_path
out = capsys.readouterr().out.strip()
payload = json.loads(out.splitlines()[-1])
assert payload["success"] is True
assert payload["message"] == ""
assert payload["type"] == "result"
assert payload["status"] == "success"
assert payload["data"]["account_id"] == aid
assert payload["data"]["profile_dir"] == called_path
def test_reveal_profile_missing_account(clean_db):
from service.browser_service import cmd_reveal_profile
with pytest.raises(SystemExit) as ei:
cmd_reveal_profile("999999")
assert ei.value.code == 1
def test_reveal_profile_empty_dir(clean_db):
from service.browser_service import cmd_reveal_profile
aid = _insert_account(clean_db, profile_dir="")
with pytest.raises(SystemExit) as ei:
cmd_reveal_profile(str(aid))
assert ei.value.code == 1

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""数据管理行内打开类:label 对齐模板,不与宿主 CRUD 重复。"""
from __future__ import annotations
import json
import os
import unittest
def _skill_root() -> str:
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_actions() -> list[dict]:
path = os.path.join(_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
manifest = json.load(f)
return list(manifest.get("actions") or [])
def _by_id(actions: list[dict], action_id: str) -> dict:
for item in actions:
if item.get("id") == action_id:
return item
raise AssertionError(f"missing action {action_id}")
class TestRowOpenActions(unittest.TestCase):
def test_reveal_and_open_use_template_labels(self) -> None:
actions = _load_actions()
reveal = _by_id(actions, "reveal-profile")
open_browser = _by_id(actions, "open-browser")
self.assertEqual(reveal.get("label"), "打开目录")
self.assertEqual(open_browser.get("label"), "打开浏览器")
self.assertEqual(reveal.get("placements"), ["row"])
self.assertEqual(open_browser.get("placements"), ["row"])
self.assertEqual(reveal.get("interaction"), "direct")
self.assertEqual(open_browser.get("interaction"), "direct")
self.assertFalse(reveal.get("replaces"))
self.assertFalse(open_browser.get("replaces"))
def test_create_takes_over_host_add_without_row_duplicate(self) -> None:
create = _by_id(_load_actions(), "account-add-web")
self.assertEqual(create.get("replaces"), "create")
self.assertEqual(create.get("placements") or [], [])
self.assertEqual(create.get("label"), "新增账号")
if __name__ == "__main__":
unittest.main()

View File

@@ -23,6 +23,7 @@ def test_usage_includes_all_account_subcommands():
assert r.returncode == 1 assert r.returncode == 1
usage = r.stdout usage = r.stdout
for cmd in ( for cmd in (
"init-db",
"init-master-key", "init-master-key",
"export-credentials", "export-credentials",
"import-credentials", "import-credentials",
@@ -31,7 +32,49 @@ def test_usage_includes_all_account_subcommands():
"get-credential", "get-credential",
"pick-web", "pick-web",
"resolve", "resolve",
"reveal-profile",
): ):
assert cmd in usage, f"USAGE 缺少 {cmd}" assert cmd in usage, f"USAGE 缺少 {cmd}"
assert "--ensure" in usage assert "--ensure" in usage
assert "--login-id" in usage assert "--login-id" in usage
def test_init_db_cli_succeeds(fake_env):
"""宿主调用的 init-db 必须成功并写入展示元数据。"""
import json
env = {
**os.environ,
"PYTHONPATH": _scripts,
"JIANGCHANG_DATA_ROOT": fake_env["data_root"],
"JIANGCHANG_USER_ID": fake_env["user_id"],
}
r = subprocess.run(
[sys.executable, "main.py", "init-db"],
capture_output=True,
text=True,
env=env,
cwd=_scripts,
)
assert r.returncode == 0, r.stdout + r.stderr
payload = json.loads(r.stdout.strip().splitlines()[-1])
assert payload.get("ok") is True
assert payload.get("skill") == "account-manager"
assert payload.get("db_path")
from db.connection import get_conn
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
assert int(cur.fetchone()[0]) > 0
cur.execute(
"""
SELECT display_name FROM _jiangchang_columns
WHERE table_name = 'accounts' AND column_name = 'role'
"""
)
assert cur.fetchone()[0] == "角色"
finally:
conn.close()