meshbook-cli 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/PKG-INFO +1 -1
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/mesh/cli.py +467 -1
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/pyproject.toml +1 -1
- meshbook_cli-0.4.0/tests/test_cli_smoke.py +413 -0
- meshbook_cli-0.2.0/tests/test_cli_smoke.py +0 -203
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/.gitignore +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/CHANGELOG.md +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/LICENSE +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/README.md +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/docs/onboarding/task-template-non-human.md +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/mesh/__init__.py +0 -0
- {meshbook_cli-0.2.0 → meshbook_cli-0.4.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshbook-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM.
|
|
5
5
|
Project-URL: Homepage, https://meshbook.org
|
|
6
6
|
Project-URL: Documentation, https://meshbook.org/docs
|
|
@@ -49,7 +49,7 @@ import urllib.parse
|
|
|
49
49
|
import urllib.request
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
-
VERSION = "0.
|
|
52
|
+
VERSION = "0.4.0"
|
|
53
53
|
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
|
|
54
54
|
|
|
55
55
|
|
|
@@ -165,6 +165,18 @@ def _data(payload: dict) -> object:
|
|
|
165
165
|
return payload
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def _items(payload: object) -> list:
|
|
169
|
+
"""Normalise a list response through the envelope to a plain list.
|
|
170
|
+
|
|
171
|
+
Handles all three shapes the API emits: a bare list, {data: [...]},
|
|
172
|
+
and the ok_list paginated shape {data: {items: [...], total}}.
|
|
173
|
+
"""
|
|
174
|
+
data = payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
175
|
+
if isinstance(data, dict) and "items" in data:
|
|
176
|
+
data = data["items"]
|
|
177
|
+
return data or []
|
|
178
|
+
|
|
179
|
+
|
|
168
180
|
# ─── command implementations ───────────────────────────────────────────
|
|
169
181
|
|
|
170
182
|
|
|
@@ -374,6 +386,10 @@ def cmd_chat_post(args, cfg: dict) -> int:
|
|
|
374
386
|
return 2
|
|
375
387
|
mesh_id = cfg["active_mesh_id"]
|
|
376
388
|
body = {"bodyMd": args.message}
|
|
389
|
+
if getattr(args, "reply_to", None):
|
|
390
|
+
# Entity-chat threading: the server accepts parentMessageId
|
|
391
|
+
# (also replyToId) on the mesh-chat POST.
|
|
392
|
+
body["parentMessageId"] = args.reply_to
|
|
377
393
|
payload = _api_call(
|
|
378
394
|
"POST", f"/api/entities/mesh/{mesh_id}/chat", cfg=cfg, body=body
|
|
379
395
|
)
|
|
@@ -814,6 +830,333 @@ def cmd_notifications(args, cfg: dict) -> int:
|
|
|
814
830
|
return 0
|
|
815
831
|
|
|
816
832
|
|
|
833
|
+
# ─── §31 batch 2: CRM verbs (leads / tasks / projects / companies) ─────
|
|
834
|
+
#
|
|
835
|
+
# Batch 1 (v0.2.0) gave non-humans channels + DMs + reactions. Batch 2
|
|
836
|
+
# (v0.3.0) adds the daily-driver CRM verbs so the CLI is a full working
|
|
837
|
+
# client, not just a chat tool: read + create across the four core
|
|
838
|
+
# entities, plus lead stage-moves and task completion, plus read access
|
|
839
|
+
# to custom-field definitions and saved views. Same auth + envelope
|
|
840
|
+
# contract; every list uses _items() for envelope normalisation.
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def cmd_leads_list(args, cfg: dict) -> int:
|
|
844
|
+
params = {
|
|
845
|
+
"limit": args.limit, "pipelineId": args.pipeline,
|
|
846
|
+
"stageId": args.stage, "companyId": args.company,
|
|
847
|
+
}
|
|
848
|
+
items = _items(_api_call("GET", "/api/leads", cfg=cfg, params=params))
|
|
849
|
+
if args.json:
|
|
850
|
+
print(json.dumps(items, indent=2))
|
|
851
|
+
return 0
|
|
852
|
+
for ld in items:
|
|
853
|
+
val = ld.get("valueAmount")
|
|
854
|
+
money = f" {val} {ld.get('currency', '')}".rstrip() if val is not None else ""
|
|
855
|
+
stage = ld.get("stageName") or ld.get("stageId") or ""
|
|
856
|
+
print(f" {ld.get('title')}{money} [{stage}] {ld.get('id')}")
|
|
857
|
+
return 0
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def cmd_leads_create(args, cfg: dict) -> int:
|
|
861
|
+
body = {"title": args.title, "pipelineId": args.pipeline, "stageId": args.stage}
|
|
862
|
+
if args.value is not None:
|
|
863
|
+
body["valueAmount"] = args.value
|
|
864
|
+
if args.description:
|
|
865
|
+
body["description"] = args.description
|
|
866
|
+
data = _data(_api_call("POST", "/api/leads", cfg=cfg, body=body))
|
|
867
|
+
if args.json:
|
|
868
|
+
print(json.dumps(data, indent=2))
|
|
869
|
+
return 0
|
|
870
|
+
print(f"Created lead: {data.get('title')} ({data.get('id')})")
|
|
871
|
+
return 0
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def cmd_leads_move(args, cfg: dict) -> int:
|
|
875
|
+
data = _data(_api_call(
|
|
876
|
+
"POST", f"/api/leads/{args.lead_id}/move-stage",
|
|
877
|
+
cfg=cfg, body={"stageId": args.stage},
|
|
878
|
+
))
|
|
879
|
+
if args.json:
|
|
880
|
+
print(json.dumps(data, indent=2))
|
|
881
|
+
return 0
|
|
882
|
+
print(f"Moved lead {args.lead_id} → stage {args.stage}")
|
|
883
|
+
return 0
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def cmd_tasks_list(args, cfg: dict) -> int:
|
|
887
|
+
params = {
|
|
888
|
+
"limit": args.limit, "projectId": args.project,
|
|
889
|
+
"assigneeId": args.assignee, "status": args.status,
|
|
890
|
+
}
|
|
891
|
+
items = _items(_api_call("GET", "/api/tasks", cfg=cfg, params=params))
|
|
892
|
+
if args.json:
|
|
893
|
+
print(json.dumps(items, indent=2))
|
|
894
|
+
return 0
|
|
895
|
+
for tk in items:
|
|
896
|
+
print(f" [{tk.get('status', '?')}] {tk.get('title')} {tk.get('id')}")
|
|
897
|
+
return 0
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def cmd_tasks_create(args, cfg: dict) -> int:
|
|
901
|
+
body = {"projectId": args.project, "title": args.title}
|
|
902
|
+
if args.priority:
|
|
903
|
+
body["priority"] = args.priority
|
|
904
|
+
if args.description:
|
|
905
|
+
body["description"] = args.description
|
|
906
|
+
data = _data(_api_call("POST", "/api/tasks", cfg=cfg, body=body))
|
|
907
|
+
if args.json:
|
|
908
|
+
print(json.dumps(data, indent=2))
|
|
909
|
+
return 0
|
|
910
|
+
print(f"Created task: {data.get('title')} ({data.get('id')})")
|
|
911
|
+
return 0
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def cmd_tasks_complete(args, cfg: dict) -> int:
|
|
915
|
+
# Valid task statuses: NotStarted / InProgress / Blocked / Review /
|
|
916
|
+
# Done / Cancelled. "Done" is the completion terminal; --status lets
|
|
917
|
+
# a caller set a different terminal (e.g. Cancelled).
|
|
918
|
+
data = _data(_api_call(
|
|
919
|
+
"PATCH", f"/api/tasks/{args.task_id}",
|
|
920
|
+
cfg=cfg, body={"status": args.status},
|
|
921
|
+
))
|
|
922
|
+
if args.json:
|
|
923
|
+
print(json.dumps(data, indent=2))
|
|
924
|
+
return 0
|
|
925
|
+
print(f"Task {args.task_id} → {args.status}")
|
|
926
|
+
return 0
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def cmd_projects_list(args, cfg: dict) -> int:
|
|
930
|
+
params = {"limit": args.limit, "portfolioId": args.portfolio, "statusFilter": args.status}
|
|
931
|
+
items = _items(_api_call("GET", "/api/projects", cfg=cfg, params=params))
|
|
932
|
+
if args.json:
|
|
933
|
+
print(json.dumps(items, indent=2))
|
|
934
|
+
return 0
|
|
935
|
+
for pr in items:
|
|
936
|
+
code = f" ({pr.get('code')})" if pr.get("code") else ""
|
|
937
|
+
print(f" [{pr.get('status', '?')}] {pr.get('name')}{code} {pr.get('id')}")
|
|
938
|
+
return 0
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def cmd_projects_create(args, cfg: dict) -> int:
|
|
942
|
+
body = {"name": args.name}
|
|
943
|
+
if args.code:
|
|
944
|
+
body["code"] = args.code
|
|
945
|
+
if args.description:
|
|
946
|
+
body["description"] = args.description
|
|
947
|
+
if args.portfolio:
|
|
948
|
+
body["portfolioId"] = args.portfolio
|
|
949
|
+
data = _data(_api_call("POST", "/api/projects", cfg=cfg, body=body))
|
|
950
|
+
if args.json:
|
|
951
|
+
print(json.dumps(data, indent=2))
|
|
952
|
+
return 0
|
|
953
|
+
print(f"Created project: {data.get('name')} ({data.get('id')})")
|
|
954
|
+
return 0
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def cmd_companies_list(args, cfg: dict) -> int:
|
|
958
|
+
params = {"limit": args.limit, "search": args.search}
|
|
959
|
+
items = _items(_api_call("GET", "/api/companies", cfg=cfg, params=params))
|
|
960
|
+
if args.json:
|
|
961
|
+
print(json.dumps(items, indent=2))
|
|
962
|
+
return 0
|
|
963
|
+
for co in items:
|
|
964
|
+
ind = f" ({co.get('industry')})" if co.get("industry") else ""
|
|
965
|
+
print(f" {co.get('name')}{ind} {co.get('id')}")
|
|
966
|
+
return 0
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def cmd_companies_create(args, cfg: dict) -> int:
|
|
970
|
+
body = {"name": args.name}
|
|
971
|
+
for arg_name, wire in (("website", "website"), ("industry", "industry"),
|
|
972
|
+
("email", "email"), ("phone", "phone")):
|
|
973
|
+
val = getattr(args, arg_name)
|
|
974
|
+
if val:
|
|
975
|
+
body[wire] = val
|
|
976
|
+
data = _data(_api_call("POST", "/api/companies", cfg=cfg, body=body))
|
|
977
|
+
if args.json:
|
|
978
|
+
print(json.dumps(data, indent=2))
|
|
979
|
+
return 0
|
|
980
|
+
print(f"Created company: {data.get('name')} ({data.get('id')})")
|
|
981
|
+
return 0
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def cmd_custom_fields_list(args, cfg: dict) -> int:
|
|
985
|
+
params = {"entityType": args.entity}
|
|
986
|
+
items = _items(_api_call("GET", "/api/custom-fields", cfg=cfg, params=params))
|
|
987
|
+
if args.json:
|
|
988
|
+
print(json.dumps(items, indent=2))
|
|
989
|
+
return 0
|
|
990
|
+
for cf in items:
|
|
991
|
+
req = " *required" if cf.get("isRequired") else ""
|
|
992
|
+
print(f" {cf.get('entityType')}.{cf.get('fieldKey')} "
|
|
993
|
+
f"({cf.get('fieldType')}) — {cf.get('label')}{req} {cf.get('id')}")
|
|
994
|
+
return 0
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cmd_saved_views_list(args, cfg: dict) -> int:
|
|
998
|
+
params = {"entityType": args.entity} if args.entity else None
|
|
999
|
+
items = _items(_api_call("GET", "/api/saved-views", cfg=cfg, params=params))
|
|
1000
|
+
if args.json:
|
|
1001
|
+
print(json.dumps(items, indent=2))
|
|
1002
|
+
return 0
|
|
1003
|
+
for sv in items:
|
|
1004
|
+
print(f" {sv.get('entityType')}: {sv.get('name')} {sv.get('id')}")
|
|
1005
|
+
return 0
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
# ─── §31 batch 2b — members / task time-logs / saved-view create ───────
|
|
1009
|
+
#
|
|
1010
|
+
# Closes the remaining daily-driver gaps from §31.
|
|
1011
|
+
#
|
|
1012
|
+
# NOTE: `api-tokens` (list/mint/revoke) is deliberately NOT added. Those
|
|
1013
|
+
# endpoints (/api/me/api-tokens) refuse API-token callers by design — a
|
|
1014
|
+
# leaked bearer token must not be able to mint more tokens — and this CLI
|
|
1015
|
+
# authenticates with a bearer token, so the verbs would only ever 403.
|
|
1016
|
+
# Token management stays SPA-cookie-only. (Verified against
|
|
1017
|
+
# app/routers/api_tokens.py `_refuse_if_api_token_call` on all three routes.)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _self_user_id(cfg: dict) -> str | None:
|
|
1021
|
+
"""Resolve the signed-in user's own UUID via /api/me (for `leave`)."""
|
|
1022
|
+
try:
|
|
1023
|
+
me = _data(_api_call("GET", "/api/me", cfg=cfg))
|
|
1024
|
+
except APIError:
|
|
1025
|
+
return None
|
|
1026
|
+
user = me.get("user") if isinstance(me, dict) else None
|
|
1027
|
+
return (user or {}).get("id")
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def cmd_members_invite(args, cfg: dict) -> int:
|
|
1031
|
+
if not cfg.get("active_mesh_id"):
|
|
1032
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1033
|
+
return 2
|
|
1034
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1035
|
+
uname = args.user.lstrip("@")
|
|
1036
|
+
body: dict = {"username": uname}
|
|
1037
|
+
if args.role:
|
|
1038
|
+
body["role"] = args.role
|
|
1039
|
+
data = _data(_api_call("POST", f"/api/meshes/{mesh_id}/invite", cfg=cfg, body=body))
|
|
1040
|
+
if args.json:
|
|
1041
|
+
print(json.dumps(data, indent=2))
|
|
1042
|
+
return 0
|
|
1043
|
+
suffix = f" as {args.role}" if args.role else " (default invite role)"
|
|
1044
|
+
print(f"Invited @{uname}{suffix}")
|
|
1045
|
+
return 0
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def cmd_members_accept(args, cfg: dict) -> int:
|
|
1049
|
+
"""Accept (or --decline) a pending invitation to a mesh by its UUID.
|
|
1050
|
+
The mesh isn't active yet, so this takes the mesh id explicitly —
|
|
1051
|
+
list pending invites in the SPA or via /api/meshes/my-pending."""
|
|
1052
|
+
action = "decline" if args.decline else "accept"
|
|
1053
|
+
data = _data(_api_call(
|
|
1054
|
+
"POST", f"/api/meshes/{args.mesh}/respond", cfg=cfg, body={"action": action}
|
|
1055
|
+
))
|
|
1056
|
+
if args.json:
|
|
1057
|
+
print(json.dumps(data, indent=2))
|
|
1058
|
+
return 0
|
|
1059
|
+
print(f"{action.title()}ed invitation to mesh {args.mesh}")
|
|
1060
|
+
return 0
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def cmd_members_set_role(args, cfg: dict) -> int:
|
|
1064
|
+
if not cfg.get("active_mesh_id"):
|
|
1065
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1066
|
+
return 2
|
|
1067
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1068
|
+
user = _resolve_user(args.user, cfg)
|
|
1069
|
+
if not user:
|
|
1070
|
+
print(f"User {args.user!r} not found in active mesh.", file=sys.stderr)
|
|
1071
|
+
return 1
|
|
1072
|
+
data = _data(_api_call(
|
|
1073
|
+
"POST", f"/api/meshes/{mesh_id}/set-role",
|
|
1074
|
+
cfg=cfg, body={"userId": user["id"], "role": args.role},
|
|
1075
|
+
))
|
|
1076
|
+
if args.json:
|
|
1077
|
+
print(json.dumps(data, indent=2))
|
|
1078
|
+
return 0
|
|
1079
|
+
print(f"Set {args.user} → {args.role} in active mesh")
|
|
1080
|
+
return 0
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def cmd_members_remove(args, cfg: dict) -> int:
|
|
1084
|
+
if not cfg.get("active_mesh_id"):
|
|
1085
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1086
|
+
return 2
|
|
1087
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1088
|
+
user = _resolve_user(args.user, cfg)
|
|
1089
|
+
if not user:
|
|
1090
|
+
print(f"User {args.user!r} not found in active mesh.", file=sys.stderr)
|
|
1091
|
+
return 1
|
|
1092
|
+
data = _data(_api_call(
|
|
1093
|
+
"POST", f"/api/meshes/{mesh_id}/remove", cfg=cfg, body={"userId": user["id"]}
|
|
1094
|
+
))
|
|
1095
|
+
if args.json:
|
|
1096
|
+
print(json.dumps(data, indent=2))
|
|
1097
|
+
return 0
|
|
1098
|
+
print(f"Removed {args.user} from active mesh")
|
|
1099
|
+
return 0
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def cmd_members_leave(args, cfg: dict) -> int:
|
|
1103
|
+
if not cfg.get("active_mesh_id"):
|
|
1104
|
+
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
1105
|
+
return 2
|
|
1106
|
+
mesh_id = cfg["active_mesh_id"]
|
|
1107
|
+
uid = _self_user_id(cfg)
|
|
1108
|
+
if not uid:
|
|
1109
|
+
print("Couldn't resolve your own user id from /api/me.", file=sys.stderr)
|
|
1110
|
+
return 1
|
|
1111
|
+
# §49: Account Managers can't leave a mesh attached to their own
|
|
1112
|
+
# subscription — the server returns 403; surface its message.
|
|
1113
|
+
data = _data(_api_call(
|
|
1114
|
+
"POST", f"/api/meshes/{mesh_id}/remove", cfg=cfg, body={"userId": uid}
|
|
1115
|
+
))
|
|
1116
|
+
if args.json:
|
|
1117
|
+
print(json.dumps(data, indent=2))
|
|
1118
|
+
return 0
|
|
1119
|
+
print(f"Left mesh {mesh_id}")
|
|
1120
|
+
return 0
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def cmd_tasks_log(args, cfg: dict) -> int:
|
|
1124
|
+
"""Log hours against a task (POST /api/task-time-logs). Date defaults
|
|
1125
|
+
to today (UTC). Hours must be > 0 and ≤ 24."""
|
|
1126
|
+
import datetime as _dt
|
|
1127
|
+
log_date = args.date or _dt.datetime.now(_dt.timezone.utc).date().isoformat()
|
|
1128
|
+
body: dict = {"taskId": args.task_id, "hours": args.hours, "loggedForDate": log_date}
|
|
1129
|
+
if args.note:
|
|
1130
|
+
body["note"] = args.note
|
|
1131
|
+
data = _data(_api_call("POST", "/api/task-time-logs", cfg=cfg, body=body))
|
|
1132
|
+
if args.json:
|
|
1133
|
+
print(json.dumps(data, indent=2))
|
|
1134
|
+
return 0
|
|
1135
|
+
print(f"Logged {args.hours}h on task {args.task_id} for {log_date}")
|
|
1136
|
+
return 0
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def cmd_saved_views_create(args, cfg: dict) -> int:
|
|
1140
|
+
"""Create a saved view. NOTE the create field is `module` (the entity
|
|
1141
|
+
grouping the view belongs to, e.g. leads / contacts / tasks), NOT
|
|
1142
|
+
`entityType` — verified against app/routers/saved_views.py SavedViewIn."""
|
|
1143
|
+
body: dict = {"module": args.module, "name": args.name}
|
|
1144
|
+
if args.filter:
|
|
1145
|
+
try:
|
|
1146
|
+
body["filterJson"] = json.loads(args.filter)
|
|
1147
|
+
except json.JSONDecodeError as e:
|
|
1148
|
+
print(f"--filter must be valid JSON: {e}", file=sys.stderr)
|
|
1149
|
+
return 2
|
|
1150
|
+
if args.shared:
|
|
1151
|
+
body["isShared"] = True
|
|
1152
|
+
data = _data(_api_call("POST", "/api/saved-views", cfg=cfg, body=body))
|
|
1153
|
+
if args.json:
|
|
1154
|
+
print(json.dumps(data, indent=2))
|
|
1155
|
+
return 0
|
|
1156
|
+
print(f"Created saved view '{args.name}' for {args.module} ({data.get('id')})")
|
|
1157
|
+
return 0
|
|
1158
|
+
|
|
1159
|
+
|
|
817
1160
|
# ─── argparse plumbing ─────────────────────────────────────────────────
|
|
818
1161
|
|
|
819
1162
|
|
|
@@ -867,6 +1210,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
867
1210
|
chs = ch.add_subparsers(dest="chat_cmd", required=True)
|
|
868
1211
|
s = chs.add_parser("post", help="post a message in active mesh")
|
|
869
1212
|
s.add_argument("message")
|
|
1213
|
+
s.add_argument("--reply-to", dest="reply_to",
|
|
1214
|
+
help="UUID of a message in this mesh to thread the reply under")
|
|
870
1215
|
s.set_defaults(func=cmd_chat_post)
|
|
871
1216
|
s = chs.add_parser("list", help="recent messages in active mesh")
|
|
872
1217
|
s.add_argument("--limit", type=int, default=20)
|
|
@@ -935,6 +1280,127 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
935
1280
|
s.add_argument("message", help="message body (markdown)")
|
|
936
1281
|
s.set_defaults(func=cmd_dm_send)
|
|
937
1282
|
|
|
1283
|
+
# leads (§31 batch 2 — v0.3.0)
|
|
1284
|
+
sl = sub.add_parser("leads", help="CRM leads")
|
|
1285
|
+
sls = sl.add_subparsers(dest="leads_cmd", required=True)
|
|
1286
|
+
s = sls.add_parser("list", help="list leads in active mesh")
|
|
1287
|
+
s.add_argument("--limit", type=int, default=50)
|
|
1288
|
+
s.add_argument("--pipeline", help="filter by pipeline UUID")
|
|
1289
|
+
s.add_argument("--stage", help="filter by stage UUID")
|
|
1290
|
+
s.add_argument("--company", help="filter by company UUID")
|
|
1291
|
+
s.set_defaults(func=cmd_leads_list)
|
|
1292
|
+
s = sls.add_parser("create", help="create a lead")
|
|
1293
|
+
s.add_argument("--title", required=True)
|
|
1294
|
+
s.add_argument("--pipeline", required=True, help="pipeline UUID")
|
|
1295
|
+
s.add_argument("--stage", required=True, help="stage UUID")
|
|
1296
|
+
s.add_argument("--value", type=float, help="value amount")
|
|
1297
|
+
s.add_argument("--description")
|
|
1298
|
+
s.set_defaults(func=cmd_leads_create)
|
|
1299
|
+
s = sls.add_parser("move", help="move a lead to a different pipeline stage")
|
|
1300
|
+
s.add_argument("lead_id", help="lead UUID")
|
|
1301
|
+
s.add_argument("stage", help="target stage UUID")
|
|
1302
|
+
s.set_defaults(func=cmd_leads_move)
|
|
1303
|
+
|
|
1304
|
+
# tasks (§31 batch 2 — v0.3.0)
|
|
1305
|
+
st = sub.add_parser("tasks", help="project tasks")
|
|
1306
|
+
sts = st.add_subparsers(dest="tasks_cmd", required=True)
|
|
1307
|
+
s = sts.add_parser("list", help="list tasks in active mesh")
|
|
1308
|
+
s.add_argument("--limit", type=int, default=50)
|
|
1309
|
+
s.add_argument("--project", help="filter by project UUID")
|
|
1310
|
+
s.add_argument("--assignee", help="filter by assignee UUID")
|
|
1311
|
+
s.add_argument("--status", help="filter by status (NotStarted/InProgress/Blocked/Review/Done/Cancelled)")
|
|
1312
|
+
s.set_defaults(func=cmd_tasks_list)
|
|
1313
|
+
s = sts.add_parser("create", help="create a task")
|
|
1314
|
+
s.add_argument("--project", required=True, help="project UUID")
|
|
1315
|
+
s.add_argument("--title", required=True)
|
|
1316
|
+
s.add_argument("--priority", help="Low/Normal/High/Urgent")
|
|
1317
|
+
s.add_argument("--description")
|
|
1318
|
+
s.set_defaults(func=cmd_tasks_create)
|
|
1319
|
+
s = sts.add_parser("complete", help="mark a task done (or another terminal status)")
|
|
1320
|
+
s.add_argument("task_id", help="task UUID")
|
|
1321
|
+
s.add_argument("--status", default="Done",
|
|
1322
|
+
help="terminal status to set (default Done; e.g. Cancelled)")
|
|
1323
|
+
s.set_defaults(func=cmd_tasks_complete)
|
|
1324
|
+
s = sts.add_parser("log", help="log hours against a task (time tracking)")
|
|
1325
|
+
s.add_argument("task_id", help="task UUID")
|
|
1326
|
+
s.add_argument("--hours", type=float, required=True, help="hours worked (0 < h <= 24)")
|
|
1327
|
+
s.add_argument("--date", help="date worked (YYYY-MM-DD; default today UTC)")
|
|
1328
|
+
s.add_argument("--note", help="optional note")
|
|
1329
|
+
s.set_defaults(func=cmd_tasks_log)
|
|
1330
|
+
|
|
1331
|
+
# projects (§31 batch 2 — v0.3.0)
|
|
1332
|
+
sp = sub.add_parser("projects", help="task-container projects")
|
|
1333
|
+
sps = sp.add_subparsers(dest="projects_cmd", required=True)
|
|
1334
|
+
s = sps.add_parser("list", help="list projects in active mesh")
|
|
1335
|
+
s.add_argument("--limit", type=int, default=50)
|
|
1336
|
+
s.add_argument("--portfolio", help="filter by portfolio UUID")
|
|
1337
|
+
s.add_argument("--status", help="filter by status (Planning/Active/OnHold/Complete/Cancelled)")
|
|
1338
|
+
s.set_defaults(func=cmd_projects_list)
|
|
1339
|
+
s = sps.add_parser("create", help="create a project")
|
|
1340
|
+
s.add_argument("--name", required=True)
|
|
1341
|
+
s.add_argument("--code", help="short project code")
|
|
1342
|
+
s.add_argument("--description")
|
|
1343
|
+
s.add_argument("--portfolio", help="portfolio UUID")
|
|
1344
|
+
s.set_defaults(func=cmd_projects_create)
|
|
1345
|
+
|
|
1346
|
+
# companies (§31 batch 2 — v0.3.0)
|
|
1347
|
+
sco = sub.add_parser("companies", help="CRM companies")
|
|
1348
|
+
scos = sco.add_subparsers(dest="companies_cmd", required=True)
|
|
1349
|
+
s = scos.add_parser("list", help="list companies in active mesh")
|
|
1350
|
+
s.add_argument("--search", help="search term")
|
|
1351
|
+
s.add_argument("--limit", type=int, default=50)
|
|
1352
|
+
s.set_defaults(func=cmd_companies_list)
|
|
1353
|
+
s = scos.add_parser("create", help="create a company")
|
|
1354
|
+
s.add_argument("--name", required=True)
|
|
1355
|
+
s.add_argument("--website")
|
|
1356
|
+
s.add_argument("--industry")
|
|
1357
|
+
s.add_argument("--email")
|
|
1358
|
+
s.add_argument("--phone")
|
|
1359
|
+
s.set_defaults(func=cmd_companies_create)
|
|
1360
|
+
|
|
1361
|
+
# custom-fields (read) (§31 batch 2 — v0.3.0)
|
|
1362
|
+
scf = sub.add_parser("custom-fields", help="custom field definitions (read)")
|
|
1363
|
+
scfs = scf.add_subparsers(dest="custom_fields_cmd", required=True)
|
|
1364
|
+
s = scfs.add_parser("list", help="list custom field definitions")
|
|
1365
|
+
s.add_argument("--entity", help="filter by entity type (contact/company/lead/...)")
|
|
1366
|
+
s.set_defaults(func=cmd_custom_fields_list)
|
|
1367
|
+
|
|
1368
|
+
# saved-views (read) (§31 batch 2 — v0.3.0)
|
|
1369
|
+
ssv = sub.add_parser("saved-views", help="saved views (read)")
|
|
1370
|
+
ssvs = ssv.add_subparsers(dest="saved_views_cmd", required=True)
|
|
1371
|
+
s = ssvs.add_parser("list", help="list saved views")
|
|
1372
|
+
s.add_argument("--entity", help="filter by entity type")
|
|
1373
|
+
s.set_defaults(func=cmd_saved_views_list)
|
|
1374
|
+
s = ssvs.add_parser("create", help="create a saved view")
|
|
1375
|
+
s.add_argument("--module", required=True,
|
|
1376
|
+
help="entity grouping the view belongs to (leads/contacts/tasks/...)")
|
|
1377
|
+
s.add_argument("--name", required=True, help="view name")
|
|
1378
|
+
s.add_argument("--filter", help="filter as a JSON object string")
|
|
1379
|
+
s.add_argument("--shared", action="store_true", help="share with the whole mesh")
|
|
1380
|
+
s.set_defaults(func=cmd_saved_views_create)
|
|
1381
|
+
|
|
1382
|
+
# members (§31 batch 2b)
|
|
1383
|
+
smem = sub.add_parser("members", help="mesh membership (invite / accept / role / remove / leave)")
|
|
1384
|
+
smems = smem.add_subparsers(dest="members_cmd", required=True)
|
|
1385
|
+
s = smems.add_parser("invite", help="invite a user to the active mesh by username")
|
|
1386
|
+
s.add_argument("user", help="username (with or without '@')")
|
|
1387
|
+
s.add_argument("--role", choices=("admin", "member", "reader"),
|
|
1388
|
+
help="role (omit to use the mesh's default invite role)")
|
|
1389
|
+
s.set_defaults(func=cmd_members_invite)
|
|
1390
|
+
s = smems.add_parser("accept", help="accept (or --decline) a pending invitation by mesh UUID")
|
|
1391
|
+
s.add_argument("mesh", help="mesh UUID you were invited to")
|
|
1392
|
+
s.add_argument("--decline", action="store_true", help="decline instead of accept")
|
|
1393
|
+
s.set_defaults(func=cmd_members_accept)
|
|
1394
|
+
s = smems.add_parser("set-role", help="change a member's role in the active mesh")
|
|
1395
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1396
|
+
s.add_argument("role", choices=("member", "reader"), help="new role")
|
|
1397
|
+
s.set_defaults(func=cmd_members_set_role)
|
|
1398
|
+
s = smems.add_parser("remove", help="remove a member from the active mesh")
|
|
1399
|
+
s.add_argument("user", help="username, displayName, or UUID")
|
|
1400
|
+
s.set_defaults(func=cmd_members_remove)
|
|
1401
|
+
s = smems.add_parser("leave", help="leave the active mesh (you)")
|
|
1402
|
+
s.set_defaults(func=cmd_members_leave)
|
|
1403
|
+
|
|
938
1404
|
# notifications
|
|
939
1405
|
s = sub.add_parser("notifications", help="recent notifications")
|
|
940
1406
|
s.set_defaults(func=cmd_notifications)
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Smoke tests for meshbook-cli — argparse wiring + config persistence.
|
|
2
|
+
|
|
3
|
+
Network-dependent commands aren't exercised here (the CLI calls
|
|
4
|
+
meshbook.org directly via stdlib urllib). For end-to-end coverage,
|
|
5
|
+
mint a token and run `mesh doctor` against a real or staging server.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from mesh import cli
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_version_constant():
|
|
17
|
+
assert cli.VERSION
|
|
18
|
+
# Pep 440 sanity — three dot-separated numeric components.
|
|
19
|
+
parts = cli.VERSION.split(".")
|
|
20
|
+
assert len(parts) >= 2
|
|
21
|
+
assert parts[0].isdigit()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_help_runs(capsys):
|
|
25
|
+
"""`mesh --help` must exit 0 and mention the top-level commands."""
|
|
26
|
+
parser = cli.build_parser()
|
|
27
|
+
with pytest.raises(SystemExit) as exc:
|
|
28
|
+
parser.parse_args(["--help"])
|
|
29
|
+
assert exc.value.code == 0
|
|
30
|
+
out = capsys.readouterr().out
|
|
31
|
+
for cmd in ("login", "logout", "whoami", "doctor",
|
|
32
|
+
"meshes", "contacts", "chat", "channels", "dm",
|
|
33
|
+
"leads", "tasks", "projects", "companies",
|
|
34
|
+
"custom-fields", "saved-views", "notifications"):
|
|
35
|
+
assert cmd in out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_chat_subcommands_present(capsys):
|
|
39
|
+
parser = cli.build_parser()
|
|
40
|
+
with pytest.raises(SystemExit) as exc:
|
|
41
|
+
parser.parse_args(["chat", "--help"])
|
|
42
|
+
assert exc.value.code == 0
|
|
43
|
+
out = capsys.readouterr().out
|
|
44
|
+
for sub in ("post", "list", "attach", "react", "unreact"):
|
|
45
|
+
assert sub in out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_channels_subcommands_present(capsys):
|
|
49
|
+
"""§31 sweep — channel verbs landed in v0.2.0."""
|
|
50
|
+
parser = cli.build_parser()
|
|
51
|
+
with pytest.raises(SystemExit) as exc:
|
|
52
|
+
parser.parse_args(["channels", "--help"])
|
|
53
|
+
assert exc.value.code == 0
|
|
54
|
+
out = capsys.readouterr().out
|
|
55
|
+
for sub in ("list", "read", "post", "reply", "create"):
|
|
56
|
+
assert sub in out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_dm_subcommands_present(capsys):
|
|
60
|
+
"""§31 sweep — DM verbs landed in v0.2.0."""
|
|
61
|
+
parser = cli.build_parser()
|
|
62
|
+
with pytest.raises(SystemExit) as exc:
|
|
63
|
+
parser.parse_args(["dm", "--help"])
|
|
64
|
+
assert exc.value.code == 0
|
|
65
|
+
out = capsys.readouterr().out
|
|
66
|
+
for sub in ("list", "read", "send"):
|
|
67
|
+
assert sub in out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_channels_create_broadcast_severity_required_via_default(capsys, tmp_path, monkeypatch):
|
|
71
|
+
"""`channels create foo --type broadcast` should default broadcastSeverity
|
|
72
|
+
to 'fyi' on the wire — verifies the body shape we send to the server."""
|
|
73
|
+
captured = {}
|
|
74
|
+
|
|
75
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
76
|
+
captured["method"] = method
|
|
77
|
+
captured["path"] = path
|
|
78
|
+
captured["body"] = body
|
|
79
|
+
return {"data": {"id": "new-id", "name": "test-bc", "channelType": "broadcast",
|
|
80
|
+
"broadcastSeverity": "fyi"}}
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
83
|
+
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
84
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
85
|
+
|
|
86
|
+
args = type("A", (), {
|
|
87
|
+
"name": "#test-bc", "topic": None, "type": "broadcast",
|
|
88
|
+
"severity": None, "private": False, "json": False,
|
|
89
|
+
})()
|
|
90
|
+
rc = cli.cmd_channels_create(args, {"active_mesh_id": "mesh-1"})
|
|
91
|
+
assert rc == 0
|
|
92
|
+
assert captured["method"] == "POST"
|
|
93
|
+
assert captured["path"] == "/api/meshes/mesh-1/channels"
|
|
94
|
+
assert captured["body"]["name"] == "test-bc" # # stripped
|
|
95
|
+
assert captured["body"]["channelType"] == "broadcast"
|
|
96
|
+
assert captured["body"]["broadcastSeverity"] == "fyi" # default applied
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_resolve_channel_strips_hash(monkeypatch):
|
|
100
|
+
"""Channel resolution must strip a leading '#' (humans type `#bugs`,
|
|
101
|
+
the underlying name is just `bugs`)."""
|
|
102
|
+
monkeypatch.setattr(cli, "_list_channels_raw", lambda cfg, mesh_id=None: [
|
|
103
|
+
{"id": "ch-1", "name": "bugs"},
|
|
104
|
+
{"id": "ch-2", "name": "general"},
|
|
105
|
+
])
|
|
106
|
+
out = cli._resolve_channel("#bugs", {"active_mesh_id": "m"})
|
|
107
|
+
assert out and out["id"] == "ch-1"
|
|
108
|
+
# Also case-insensitive
|
|
109
|
+
out = cli._resolve_channel("BUGS", {"active_mesh_id": "m"})
|
|
110
|
+
assert out and out["id"] == "ch-1"
|
|
111
|
+
# Missing name
|
|
112
|
+
assert cli._resolve_channel("nope", {"active_mesh_id": "m"}) is None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─── §31 batch 2 (v0.3.0) — CRM verbs ──────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize("group,subs", [
|
|
119
|
+
("leads", ("list", "create", "move")),
|
|
120
|
+
("tasks", ("list", "create", "complete")),
|
|
121
|
+
("projects", ("list", "create")),
|
|
122
|
+
("companies", ("list", "create")),
|
|
123
|
+
("custom-fields", ("list",)),
|
|
124
|
+
("saved-views", ("list",)),
|
|
125
|
+
])
|
|
126
|
+
def test_batch2_subcommands_present(capsys, group, subs):
|
|
127
|
+
parser = cli.build_parser()
|
|
128
|
+
with pytest.raises(SystemExit) as exc:
|
|
129
|
+
parser.parse_args([group, "--help"])
|
|
130
|
+
assert exc.value.code == 0
|
|
131
|
+
out = capsys.readouterr().out
|
|
132
|
+
for sub in subs:
|
|
133
|
+
assert sub in out, f"{group} missing subcommand {sub}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_items_envelope_shapes():
|
|
137
|
+
"""_items must normalise bare-list, {data:[...]}, and
|
|
138
|
+
{data:{items:[...]}} into a plain list."""
|
|
139
|
+
assert cli._items([1, 2, 3]) == [1, 2, 3]
|
|
140
|
+
assert cli._items({"data": [1, 2]}) == [1, 2]
|
|
141
|
+
assert cli._items({"data": {"items": [9], "total": 1}}) == [9]
|
|
142
|
+
assert cli._items({"data": None}) == []
|
|
143
|
+
assert cli._items(None) == []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_leads_create_wire_shape(monkeypatch):
|
|
147
|
+
"""Lead create must send camelCase pipelineId/stageId + title."""
|
|
148
|
+
captured = {}
|
|
149
|
+
|
|
150
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
151
|
+
captured.update(method=method, path=path, body=body)
|
|
152
|
+
return {"data": {"id": "lead-1", "title": "Big deal"}}
|
|
153
|
+
|
|
154
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
155
|
+
args = type("A", (), {
|
|
156
|
+
"title": "Big deal", "pipeline": "pid", "stage": "sid",
|
|
157
|
+
"value": 1000.0, "description": None, "json": False,
|
|
158
|
+
})()
|
|
159
|
+
rc = cli.cmd_leads_create(args, {"active_mesh_id": "m"})
|
|
160
|
+
assert rc == 0
|
|
161
|
+
assert captured["method"] == "POST"
|
|
162
|
+
assert captured["path"] == "/api/leads"
|
|
163
|
+
assert captured["body"] == {
|
|
164
|
+
"title": "Big deal", "pipelineId": "pid", "stageId": "sid",
|
|
165
|
+
"valueAmount": 1000.0,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_tasks_complete_wire_shape(monkeypatch):
|
|
170
|
+
"""tasks complete defaults to PATCH status=Done on /api/tasks/{id}."""
|
|
171
|
+
captured = {}
|
|
172
|
+
|
|
173
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
174
|
+
captured.update(method=method, path=path, body=body)
|
|
175
|
+
return {"data": {"id": "t-1", "status": "Done"}}
|
|
176
|
+
|
|
177
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
178
|
+
args = type("A", (), {"task_id": "t-1", "status": "Done", "json": False})()
|
|
179
|
+
rc = cli.cmd_tasks_complete(args, {"active_mesh_id": "m"})
|
|
180
|
+
assert rc == 0
|
|
181
|
+
assert captured["method"] == "PATCH"
|
|
182
|
+
assert captured["path"] == "/api/tasks/t-1"
|
|
183
|
+
assert captured["body"] == {"status": "Done"}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_leads_move_wire_shape(monkeypatch):
|
|
187
|
+
captured = {}
|
|
188
|
+
|
|
189
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
190
|
+
captured.update(method=method, path=path, body=body)
|
|
191
|
+
return {"data": {"id": "lead-1"}}
|
|
192
|
+
|
|
193
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
194
|
+
args = type("A", (), {"lead_id": "lead-1", "stage": "stage-2", "json": False})()
|
|
195
|
+
rc = cli.cmd_leads_move(args, {"active_mesh_id": "m"})
|
|
196
|
+
assert rc == 0
|
|
197
|
+
assert captured["path"] == "/api/leads/lead-1/move-stage"
|
|
198
|
+
assert captured["body"] == {"stageId": "stage-2"}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─── §31 batch 2b — members / task time-logs / saved-view create ───────
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_help_includes_members(capsys):
|
|
205
|
+
parser = cli.build_parser()
|
|
206
|
+
with pytest.raises(SystemExit):
|
|
207
|
+
parser.parse_args(["--help"])
|
|
208
|
+
assert "members" in capsys.readouterr().out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.mark.parametrize("group,subs", [
|
|
212
|
+
("members", ("invite", "accept", "set-role", "remove", "leave")),
|
|
213
|
+
("tasks", ("log",)),
|
|
214
|
+
("saved-views", ("create",)),
|
|
215
|
+
])
|
|
216
|
+
def test_batch2b_subcommands_present(capsys, group, subs):
|
|
217
|
+
parser = cli.build_parser()
|
|
218
|
+
with pytest.raises(SystemExit) as exc:
|
|
219
|
+
parser.parse_args([group, "--help"])
|
|
220
|
+
assert exc.value.code == 0
|
|
221
|
+
out = capsys.readouterr().out
|
|
222
|
+
for sub in subs:
|
|
223
|
+
assert sub in out, f"{group} missing subcommand {sub}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_members_invite_wire_shape(monkeypatch):
|
|
227
|
+
captured = {}
|
|
228
|
+
|
|
229
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
230
|
+
captured.update(method=method, path=path, body=body)
|
|
231
|
+
return {"data": {"invited": True}}
|
|
232
|
+
|
|
233
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
234
|
+
args = type("A", (), {"user": "@rook", "role": "member", "json": False})()
|
|
235
|
+
rc = cli.cmd_members_invite(args, {"active_mesh_id": "m1"})
|
|
236
|
+
assert rc == 0
|
|
237
|
+
assert captured["method"] == "POST"
|
|
238
|
+
assert captured["path"] == "/api/meshes/m1/invite"
|
|
239
|
+
assert captured["body"] == {"username": "rook", "role": "member"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_members_invite_default_role_omitted(monkeypatch):
|
|
243
|
+
captured = {}
|
|
244
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
245
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(body=body) or {"data": {}})
|
|
246
|
+
args = type("A", (), {"user": "rook", "role": None, "json": False})()
|
|
247
|
+
cli.cmd_members_invite(args, {"active_mesh_id": "m1"})
|
|
248
|
+
assert captured["body"] == {"username": "rook"} # no role key when default
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_members_set_role_wire_shape(monkeypatch):
|
|
252
|
+
captured = {}
|
|
253
|
+
|
|
254
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
255
|
+
captured.update(method=method, path=path, body=body)
|
|
256
|
+
return {"data": {}}
|
|
257
|
+
|
|
258
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
259
|
+
monkeypatch.setattr(cli, "_resolve_user", lambda u, cfg: {"id": "u-9"})
|
|
260
|
+
args = type("A", (), {"user": "rook", "role": "reader", "json": False})()
|
|
261
|
+
rc = cli.cmd_members_set_role(args, {"active_mesh_id": "m1"})
|
|
262
|
+
assert rc == 0
|
|
263
|
+
assert captured["path"] == "/api/meshes/m1/set-role"
|
|
264
|
+
assert captured["body"] == {"userId": "u-9", "role": "reader"}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_members_accept_wire_shape(monkeypatch):
|
|
268
|
+
captured = {}
|
|
269
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
270
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {}})
|
|
271
|
+
args = type("A", (), {"mesh": "mesh-7", "decline": False, "json": False})()
|
|
272
|
+
cli.cmd_members_accept(args, {})
|
|
273
|
+
assert captured["path"] == "/api/meshes/mesh-7/respond"
|
|
274
|
+
assert captured["body"] == {"action": "accept"}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_tasks_log_wire_shape_defaults_today(monkeypatch):
|
|
278
|
+
captured = {}
|
|
279
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
280
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(method=m, path=p, body=body) or {"data": {}})
|
|
281
|
+
args = type("A", (), {"task_id": "t-1", "hours": 2.5, "date": None, "note": None, "json": False})()
|
|
282
|
+
rc = cli.cmd_tasks_log(args, {"active_mesh_id": "m1"})
|
|
283
|
+
assert rc == 0
|
|
284
|
+
assert captured["method"] == "POST"
|
|
285
|
+
assert captured["path"] == "/api/task-time-logs"
|
|
286
|
+
assert captured["body"]["taskId"] == "t-1"
|
|
287
|
+
assert captured["body"]["hours"] == 2.5
|
|
288
|
+
# date defaulted to an ISO YYYY-MM-DD string
|
|
289
|
+
assert len(captured["body"]["loggedForDate"]) == 10 and captured["body"]["loggedForDate"].count("-") == 2
|
|
290
|
+
assert "note" not in captured["body"]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_saved_views_create_uses_module_field(monkeypatch):
|
|
294
|
+
"""Regression guard: create body uses `module`, NOT `entityType`
|
|
295
|
+
(verified against SavedViewIn). A wrong key would 422 server-side."""
|
|
296
|
+
captured = {}
|
|
297
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
298
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {"id": "sv-1"}})
|
|
299
|
+
args = type("A", (), {"module": "leads", "name": "Hot", "filter": '{"stageId":"x"}', "shared": True, "json": False})()
|
|
300
|
+
rc = cli.cmd_saved_views_create(args, {"active_mesh_id": "m1"})
|
|
301
|
+
assert rc == 0
|
|
302
|
+
assert captured["path"] == "/api/saved-views"
|
|
303
|
+
assert captured["body"] == {"module": "leads", "name": "Hot",
|
|
304
|
+
"filterJson": {"stageId": "x"}, "isShared": True}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_saved_views_create_bad_filter_json(monkeypatch):
|
|
308
|
+
monkeypatch.setattr(cli, "_api_call", lambda *a, **k: {"data": {}})
|
|
309
|
+
args = type("A", (), {"module": "leads", "name": "X", "filter": "{not json", "shared": False, "json": False})()
|
|
310
|
+
assert cli.cmd_saved_views_create(args, {}) == 2
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_chat_post_reply_to_threads(monkeypatch):
|
|
314
|
+
captured = {}
|
|
315
|
+
monkeypatch.setattr(cli, "_api_call",
|
|
316
|
+
lambda m, p, *, cfg, body=None, **kw: captured.update(path=p, body=body) or {"data": {"id": "msg-2"}})
|
|
317
|
+
args = type("A", (), {"message": "re: that", "reply_to": "msg-1", "json": False})()
|
|
318
|
+
rc = cli.cmd_chat_post(args, {"active_mesh_id": "m1"})
|
|
319
|
+
assert rc == 0
|
|
320
|
+
assert captured["path"] == "/api/entities/mesh/m1/chat"
|
|
321
|
+
assert captured["body"] == {"bodyMd": "re: that", "parentMessageId": "msg-1"}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_config_round_trip(tmp_path, monkeypatch):
|
|
325
|
+
"""Config writes + reads cleanly through load/save/reset."""
|
|
326
|
+
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
327
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
328
|
+
|
|
329
|
+
assert cli.load_config() == {}
|
|
330
|
+
|
|
331
|
+
payload = {"token": "mb_token_test", "active_mesh_id": "abc-123"}
|
|
332
|
+
cli.save_config(payload)
|
|
333
|
+
assert cli.CONFIG_PATH.exists()
|
|
334
|
+
|
|
335
|
+
# POSIX permission tightening — only assert on platforms that have
|
|
336
|
+
# `os.chmod` semantics matching ours. Windows masks 0o600 to 0o666;
|
|
337
|
+
# the call is harmless but the bits differ.
|
|
338
|
+
if os.name == "posix":
|
|
339
|
+
assert (cli.CONFIG_PATH.stat().st_mode & 0o777) == 0o600
|
|
340
|
+
|
|
341
|
+
loaded = cli.load_config()
|
|
342
|
+
assert loaded["token"] == "mb_token_test"
|
|
343
|
+
assert loaded["active_mesh_id"] == "abc-123"
|
|
344
|
+
|
|
345
|
+
cli.reset_config()
|
|
346
|
+
assert cli.load_config() == {}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_corrupt_config_returns_empty(tmp_path, monkeypatch):
|
|
350
|
+
"""A garbled config file shouldn't crash the CLI."""
|
|
351
|
+
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
352
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
353
|
+
|
|
354
|
+
cli.CONFIG_DIR.mkdir()
|
|
355
|
+
cli.CONFIG_PATH.write_text("{ not valid json")
|
|
356
|
+
assert cli.load_config() == {}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_invalid_token_does_not_persist(tmp_path, monkeypatch):
|
|
360
|
+
"""Bug Rook flagged 2026-05-10 — an invalid `--token` would write
|
|
361
|
+
to disk before /api/me verification. The fix: verify against an
|
|
362
|
+
in-memory test_cfg first, only persist on success."""
|
|
363
|
+
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
364
|
+
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
365
|
+
|
|
366
|
+
# Stub out _api_call so it returns the "authenticated=false" shape
|
|
367
|
+
# /api/me actually emits for an invalid bearer.
|
|
368
|
+
def fake_api_call(method, path, *, cfg, **kw):
|
|
369
|
+
return {"data": {"authenticated": False}}
|
|
370
|
+
|
|
371
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
372
|
+
|
|
373
|
+
args = type("A", (), {"token": "mb_token_garbage", "base": None})()
|
|
374
|
+
rc = cli.cmd_login(args, {})
|
|
375
|
+
assert rc == 1, "cmd_login should fail on authenticated=false response"
|
|
376
|
+
assert not cli.CONFIG_PATH.exists(), \
|
|
377
|
+
"invalid token must NOT have been persisted to config"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_xdg_config_home_honoured(monkeypatch, tmp_path):
|
|
381
|
+
"""Defensive — if XDG_CONFIG_HOME is set AND ~/.meshbook doesn't
|
|
382
|
+
exist, the CLI should write under XDG. Legacy ~/.meshbook stays
|
|
383
|
+
canonical for upgrade safety."""
|
|
384
|
+
fake_home = tmp_path / "home"
|
|
385
|
+
fake_xdg = tmp_path / "xdg"
|
|
386
|
+
fake_home.mkdir()
|
|
387
|
+
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
|
|
388
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_xdg))
|
|
389
|
+
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
|
|
390
|
+
resolved = cli._resolve_config_dir()
|
|
391
|
+
# Legacy doesn't exist → XDG wins
|
|
392
|
+
assert resolved == fake_xdg / "meshbook", resolved
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_legacy_config_dir_takes_precedence(monkeypatch, tmp_path):
|
|
396
|
+
"""If `~/.meshbook` already exists from a prior install, keep
|
|
397
|
+
using it — don't silently migrate the user's token away."""
|
|
398
|
+
fake_home = tmp_path / "home"
|
|
399
|
+
legacy = fake_home / ".meshbook"
|
|
400
|
+
legacy.mkdir(parents=True)
|
|
401
|
+
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
|
|
402
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
403
|
+
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
|
|
404
|
+
assert cli._resolve_config_dir() == legacy
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
|
|
408
|
+
"""`MESHBOOK_CONFIG_DIR` overrides everything (Pi users w/
|
|
409
|
+
read-only home directories pin to a writable mount)."""
|
|
410
|
+
explicit = tmp_path / "explicit"
|
|
411
|
+
monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
|
|
412
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
413
|
+
assert cli._resolve_config_dir() == explicit
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
"""Smoke tests for meshbook-cli — argparse wiring + config persistence.
|
|
2
|
-
|
|
3
|
-
Network-dependent commands aren't exercised here (the CLI calls
|
|
4
|
-
meshbook.org directly via stdlib urllib). For end-to-end coverage,
|
|
5
|
-
mint a token and run `mesh doctor` against a real or staging server.
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
from mesh import cli
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def test_version_constant():
|
|
17
|
-
assert cli.VERSION
|
|
18
|
-
# Pep 440 sanity — three dot-separated numeric components.
|
|
19
|
-
parts = cli.VERSION.split(".")
|
|
20
|
-
assert len(parts) >= 2
|
|
21
|
-
assert parts[0].isdigit()
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def test_help_runs(capsys):
|
|
25
|
-
"""`mesh --help` must exit 0 and mention the top-level commands."""
|
|
26
|
-
parser = cli.build_parser()
|
|
27
|
-
with pytest.raises(SystemExit) as exc:
|
|
28
|
-
parser.parse_args(["--help"])
|
|
29
|
-
assert exc.value.code == 0
|
|
30
|
-
out = capsys.readouterr().out
|
|
31
|
-
for cmd in ("login", "logout", "whoami", "doctor",
|
|
32
|
-
"meshes", "contacts", "chat", "channels", "dm",
|
|
33
|
-
"notifications"):
|
|
34
|
-
assert cmd in out
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_chat_subcommands_present(capsys):
|
|
38
|
-
parser = cli.build_parser()
|
|
39
|
-
with pytest.raises(SystemExit) as exc:
|
|
40
|
-
parser.parse_args(["chat", "--help"])
|
|
41
|
-
assert exc.value.code == 0
|
|
42
|
-
out = capsys.readouterr().out
|
|
43
|
-
for sub in ("post", "list", "attach", "react", "unreact"):
|
|
44
|
-
assert sub in out
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def test_channels_subcommands_present(capsys):
|
|
48
|
-
"""§31 sweep — channel verbs landed in v0.2.0."""
|
|
49
|
-
parser = cli.build_parser()
|
|
50
|
-
with pytest.raises(SystemExit) as exc:
|
|
51
|
-
parser.parse_args(["channels", "--help"])
|
|
52
|
-
assert exc.value.code == 0
|
|
53
|
-
out = capsys.readouterr().out
|
|
54
|
-
for sub in ("list", "read", "post", "reply", "create"):
|
|
55
|
-
assert sub in out
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_dm_subcommands_present(capsys):
|
|
59
|
-
"""§31 sweep — DM verbs landed in v0.2.0."""
|
|
60
|
-
parser = cli.build_parser()
|
|
61
|
-
with pytest.raises(SystemExit) as exc:
|
|
62
|
-
parser.parse_args(["dm", "--help"])
|
|
63
|
-
assert exc.value.code == 0
|
|
64
|
-
out = capsys.readouterr().out
|
|
65
|
-
for sub in ("list", "read", "send"):
|
|
66
|
-
assert sub in out
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def test_channels_create_broadcast_severity_required_via_default(capsys, tmp_path, monkeypatch):
|
|
70
|
-
"""`channels create foo --type broadcast` should default broadcastSeverity
|
|
71
|
-
to 'fyi' on the wire — verifies the body shape we send to the server."""
|
|
72
|
-
captured = {}
|
|
73
|
-
|
|
74
|
-
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
75
|
-
captured["method"] = method
|
|
76
|
-
captured["path"] = path
|
|
77
|
-
captured["body"] = body
|
|
78
|
-
return {"data": {"id": "new-id", "name": "test-bc", "channelType": "broadcast",
|
|
79
|
-
"broadcastSeverity": "fyi"}}
|
|
80
|
-
|
|
81
|
-
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
82
|
-
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
83
|
-
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
84
|
-
|
|
85
|
-
args = type("A", (), {
|
|
86
|
-
"name": "#test-bc", "topic": None, "type": "broadcast",
|
|
87
|
-
"severity": None, "private": False, "json": False,
|
|
88
|
-
})()
|
|
89
|
-
rc = cli.cmd_channels_create(args, {"active_mesh_id": "mesh-1"})
|
|
90
|
-
assert rc == 0
|
|
91
|
-
assert captured["method"] == "POST"
|
|
92
|
-
assert captured["path"] == "/api/meshes/mesh-1/channels"
|
|
93
|
-
assert captured["body"]["name"] == "test-bc" # # stripped
|
|
94
|
-
assert captured["body"]["channelType"] == "broadcast"
|
|
95
|
-
assert captured["body"]["broadcastSeverity"] == "fyi" # default applied
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def test_resolve_channel_strips_hash(monkeypatch):
|
|
99
|
-
"""Channel resolution must strip a leading '#' (humans type `#bugs`,
|
|
100
|
-
the underlying name is just `bugs`)."""
|
|
101
|
-
monkeypatch.setattr(cli, "_list_channels_raw", lambda cfg, mesh_id=None: [
|
|
102
|
-
{"id": "ch-1", "name": "bugs"},
|
|
103
|
-
{"id": "ch-2", "name": "general"},
|
|
104
|
-
])
|
|
105
|
-
out = cli._resolve_channel("#bugs", {"active_mesh_id": "m"})
|
|
106
|
-
assert out and out["id"] == "ch-1"
|
|
107
|
-
# Also case-insensitive
|
|
108
|
-
out = cli._resolve_channel("BUGS", {"active_mesh_id": "m"})
|
|
109
|
-
assert out and out["id"] == "ch-1"
|
|
110
|
-
# Missing name
|
|
111
|
-
assert cli._resolve_channel("nope", {"active_mesh_id": "m"}) is None
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def test_config_round_trip(tmp_path, monkeypatch):
|
|
115
|
-
"""Config writes + reads cleanly through load/save/reset."""
|
|
116
|
-
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
117
|
-
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
118
|
-
|
|
119
|
-
assert cli.load_config() == {}
|
|
120
|
-
|
|
121
|
-
payload = {"token": "mb_token_test", "active_mesh_id": "abc-123"}
|
|
122
|
-
cli.save_config(payload)
|
|
123
|
-
assert cli.CONFIG_PATH.exists()
|
|
124
|
-
|
|
125
|
-
# POSIX permission tightening — only assert on platforms that have
|
|
126
|
-
# `os.chmod` semantics matching ours. Windows masks 0o600 to 0o666;
|
|
127
|
-
# the call is harmless but the bits differ.
|
|
128
|
-
if os.name == "posix":
|
|
129
|
-
assert (cli.CONFIG_PATH.stat().st_mode & 0o777) == 0o600
|
|
130
|
-
|
|
131
|
-
loaded = cli.load_config()
|
|
132
|
-
assert loaded["token"] == "mb_token_test"
|
|
133
|
-
assert loaded["active_mesh_id"] == "abc-123"
|
|
134
|
-
|
|
135
|
-
cli.reset_config()
|
|
136
|
-
assert cli.load_config() == {}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def test_corrupt_config_returns_empty(tmp_path, monkeypatch):
|
|
140
|
-
"""A garbled config file shouldn't crash the CLI."""
|
|
141
|
-
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
142
|
-
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
143
|
-
|
|
144
|
-
cli.CONFIG_DIR.mkdir()
|
|
145
|
-
cli.CONFIG_PATH.write_text("{ not valid json")
|
|
146
|
-
assert cli.load_config() == {}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def test_invalid_token_does_not_persist(tmp_path, monkeypatch):
|
|
150
|
-
"""Bug Rook flagged 2026-05-10 — an invalid `--token` would write
|
|
151
|
-
to disk before /api/me verification. The fix: verify against an
|
|
152
|
-
in-memory test_cfg first, only persist on success."""
|
|
153
|
-
monkeypatch.setattr(cli, "CONFIG_DIR", tmp_path / ".meshbook")
|
|
154
|
-
monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / ".meshbook" / "config")
|
|
155
|
-
|
|
156
|
-
# Stub out _api_call so it returns the "authenticated=false" shape
|
|
157
|
-
# /api/me actually emits for an invalid bearer.
|
|
158
|
-
def fake_api_call(method, path, *, cfg, **kw):
|
|
159
|
-
return {"data": {"authenticated": False}}
|
|
160
|
-
|
|
161
|
-
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
162
|
-
|
|
163
|
-
args = type("A", (), {"token": "mb_token_garbage", "base": None})()
|
|
164
|
-
rc = cli.cmd_login(args, {})
|
|
165
|
-
assert rc == 1, "cmd_login should fail on authenticated=false response"
|
|
166
|
-
assert not cli.CONFIG_PATH.exists(), \
|
|
167
|
-
"invalid token must NOT have been persisted to config"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def test_xdg_config_home_honoured(monkeypatch, tmp_path):
|
|
171
|
-
"""Defensive — if XDG_CONFIG_HOME is set AND ~/.meshbook doesn't
|
|
172
|
-
exist, the CLI should write under XDG. Legacy ~/.meshbook stays
|
|
173
|
-
canonical for upgrade safety."""
|
|
174
|
-
fake_home = tmp_path / "home"
|
|
175
|
-
fake_xdg = tmp_path / "xdg"
|
|
176
|
-
fake_home.mkdir()
|
|
177
|
-
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
|
|
178
|
-
monkeypatch.setenv("XDG_CONFIG_HOME", str(fake_xdg))
|
|
179
|
-
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
|
|
180
|
-
resolved = cli._resolve_config_dir()
|
|
181
|
-
# Legacy doesn't exist → XDG wins
|
|
182
|
-
assert resolved == fake_xdg / "meshbook", resolved
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def test_legacy_config_dir_takes_precedence(monkeypatch, tmp_path):
|
|
186
|
-
"""If `~/.meshbook` already exists from a prior install, keep
|
|
187
|
-
using it — don't silently migrate the user's token away."""
|
|
188
|
-
fake_home = tmp_path / "home"
|
|
189
|
-
legacy = fake_home / ".meshbook"
|
|
190
|
-
legacy.mkdir(parents=True)
|
|
191
|
-
monkeypatch.setattr(cli.Path, "home", classmethod(lambda cls: fake_home))
|
|
192
|
-
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
193
|
-
monkeypatch.delenv("MESHBOOK_CONFIG_DIR", raising=False)
|
|
194
|
-
assert cli._resolve_config_dir() == legacy
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
|
|
198
|
-
"""`MESHBOOK_CONFIG_DIR` overrides everything (Pi users w/
|
|
199
|
-
read-only home directories pin to a writable mount)."""
|
|
200
|
-
explicit = tmp_path / "explicit"
|
|
201
|
-
monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
|
|
202
|
-
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
203
|
-
assert cli._resolve_config_dir() == explicit
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|