fima-cli 1.3.0 → 1.4.0
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.
- package/README.md +30 -2
- package/fima.py +103 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,34 @@ fima --help
|
|
|
14
14
|
|
|
15
15
|
也可以使用 `pnpm add -g fima-cli` 安装,或直接运行 `npx fima-cli --help`。
|
|
16
16
|
|
|
17
|
-
启动 `fima-web`
|
|
17
|
+
启动 `fima-web` 后,先设置服务器地址,再执行 `fima login` 登录。
|
|
18
|
+
|
|
19
|
+
## 服务器地址配置
|
|
20
|
+
|
|
21
|
+
首次使用必须设置服务器地址;没有配置时,业务命令会提示设置并退出,不会自动连接本机默认服务。
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# 首次设置:支持 IP:端口,未写协议时补全为 http://
|
|
25
|
+
fima config set-url 127.0.0.1:8000
|
|
26
|
+
|
|
27
|
+
# 更换服务器:使用同一条命令覆盖已保存的地址
|
|
28
|
+
fima config set-url https://fima.example.com
|
|
29
|
+
|
|
30
|
+
# 查看已保存地址及当前生效地址
|
|
31
|
+
fima config show
|
|
32
|
+
fima login
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
配置保存在 `~/.fima/config.json`,重开终端仍有效;可用 `FIMA_CONFIG_FILE` 指定其他配置文件。支持 HTTP/HTTPS URL、域名、IPv4 和方括号形式的 IPv6(如 `[::1]:8000`),也支持部署在子路径下的服务(如 `https://fima.example.com/expenses`)。地址不得包含账号密码、查询参数或片段,端口范围为 1–65535。
|
|
36
|
+
|
|
37
|
+
地址优先级为 **`--url` > `FIMA_URL` > 已保存配置**。前两种方式仅临时覆盖,不会修改配置文件:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
fima --url 192.168.1.10:8000 whoami
|
|
41
|
+
FIMA_URL=https://fima.example.com fima whoami
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
登录 Cookie 与服务器地址绑定,更换服务器后需重新登录,旧 Cookie 不会发往新地址。升级前保存的、未绑定地址的旧会话也需要重新登录。`--help`、`--version` 和设置配置的命令无需服务器地址。
|
|
18
45
|
|
|
19
46
|
## 从源码运行
|
|
20
47
|
|
|
@@ -23,6 +50,7 @@ fima --help
|
|
|
23
50
|
```bash
|
|
24
51
|
./fima -v
|
|
25
52
|
./fima -h
|
|
53
|
+
./fima config set-url 127.0.0.1:8000
|
|
26
54
|
./fima login
|
|
27
55
|
./fima whoami
|
|
28
56
|
./fima create --json '{"title":"上海客户拜访","category":"差旅费","amount":1280,"date":"2026-07-12","invoice":"./invoice.pdf","submit":true}'
|
|
@@ -41,7 +69,7 @@ fima --help
|
|
|
41
69
|
fima -h
|
|
42
70
|
```
|
|
43
71
|
|
|
44
|
-
|
|
72
|
+
请使用服务管理员提供的账号登录。执行 `login` 会拉起浏览器中的专用登录框,账号密码只在网页中填写;CLI 会自动检测登录完成并把会话 Cookie 保存在 `~/.fima/session.json`(可用 `FIMA_COOKIE_FILE` 覆盖)。网页会明确提示“CLI 登录已完成”,此时直接关闭当前页面即可。可通过 `--timeout` 调整等待时长(默认 5 分钟)。后续业务请求会自动携带与当前服务器匹配的 Cookie;可通过 `whoami` 查看当前登录账号,`logout` 会退出登录并清除本地会话 Cookie。
|
|
45
73
|
|
|
46
74
|
## 参数传递
|
|
47
75
|
|
package/fima.py
CHANGED
|
@@ -1,22 +1,107 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Fima CLI - 通过命令行操作 Fima 财务报销系统。"""
|
|
3
3
|
from __future__ import annotations
|
|
4
|
-
import argparse, json, math, mimetypes, os, sys, time, webbrowser
|
|
4
|
+
import argparse, json, math, mimetypes, os, sys, tempfile, time, webbrowser
|
|
5
5
|
from http.cookies import SimpleCookie
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from urllib.parse import quote
|
|
7
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
8
8
|
from urllib.error import HTTPError, URLError
|
|
9
9
|
from urllib.request import Request, urlopen
|
|
10
10
|
|
|
11
|
-
VERSION="1.
|
|
11
|
+
VERSION="1.4.0"
|
|
12
|
+
|
|
13
|
+
def config_file(): return Path(os.environ.get("FIMA_CONFIG_FILE", Path.home() / ".fima" / "config.json"))
|
|
14
|
+
|
|
15
|
+
def normalize_url(value):
|
|
16
|
+
if not isinstance(value, str) or not value.strip():
|
|
17
|
+
fail("服务器地址不能为空,请提供 URL 或 IP:端口")
|
|
18
|
+
value=value.strip()
|
|
19
|
+
if any(c.isspace() or ord(c)<32 or ord(c)==127 for c in value) or "\\" in value:
|
|
20
|
+
fail("服务器地址不能包含空白、控制字符或反斜杠")
|
|
21
|
+
if "://" not in value:
|
|
22
|
+
value="http://"+value
|
|
23
|
+
try:
|
|
24
|
+
parsed=urlsplit(value)
|
|
25
|
+
host=parsed.hostname
|
|
26
|
+
port=parsed.port
|
|
27
|
+
except ValueError:
|
|
28
|
+
fail("服务器地址格式不正确,端口必须是 1–65535 的整数,IPv6 地址请使用方括号")
|
|
29
|
+
if parsed.scheme not in ("http", "https") or not host:
|
|
30
|
+
fail("服务器地址必须是有效的 HTTP/HTTPS URL 或 IP:端口")
|
|
31
|
+
if parsed.username is not None or parsed.password is not None:
|
|
32
|
+
fail("服务器地址不能包含账号密码,请使用 fima login 登录")
|
|
33
|
+
if "?" in value or "#" in value:
|
|
34
|
+
fail("服务器地址不能包含查询参数或片段")
|
|
35
|
+
if port == 0 or parsed.netloc.endswith(":"):
|
|
36
|
+
fail("服务器端口必须是 1–65535 的整数")
|
|
37
|
+
try:
|
|
38
|
+
host=host.encode("idna").decode("ascii")
|
|
39
|
+
except UnicodeError:
|
|
40
|
+
fail("服务器主机名格式不正确")
|
|
41
|
+
if ":" not in host and any(not (c.isalnum() or c in ".-_") for c in host):
|
|
42
|
+
fail("服务器主机名格式不正确")
|
|
43
|
+
authority=f"[{host}]" if ":" in host else host
|
|
44
|
+
if port is not None and (parsed.scheme, port) not in (("http", 80), ("https", 443)):
|
|
45
|
+
authority+=f":{port}"
|
|
46
|
+
return urlunsplit((parsed.scheme, authority, parsed.path.rstrip("/"), "", ""))
|
|
47
|
+
|
|
48
|
+
def read_config():
|
|
49
|
+
try:
|
|
50
|
+
data=json.loads(config_file().read_text(encoding="utf-8"))
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return {}
|
|
53
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
54
|
+
fail("无法读取服务器配置,请运行 fima config set-url <地址> 重新设置")
|
|
55
|
+
if not isinstance(data, dict) or not isinstance(data.get("url"), str):
|
|
56
|
+
fail("服务器配置格式不正确,请运行 fima config set-url <地址> 重新设置")
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
def resolve_url(override=None):
|
|
60
|
+
if override is not None:
|
|
61
|
+
return normalize_url(override)
|
|
62
|
+
if "FIMA_URL" in os.environ:
|
|
63
|
+
return normalize_url(os.environ["FIMA_URL"])
|
|
64
|
+
saved=read_config().get("url")
|
|
65
|
+
if saved is None:
|
|
66
|
+
fail("尚未设置服务器地址,请先执行:fima config set-url <URL或IP:端口>\n例如:fima config set-url 127.0.0.1:8000")
|
|
67
|
+
return normalize_url(saved)
|
|
68
|
+
|
|
69
|
+
def set_url(a):
|
|
70
|
+
url=normalize_url(a.server_url)
|
|
71
|
+
target=config_file(); temporary=None
|
|
72
|
+
try:
|
|
73
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as f:
|
|
75
|
+
temporary=Path(f.name)
|
|
76
|
+
json.dump({"url":url}, f, ensure_ascii=False)
|
|
77
|
+
f.write("\n")
|
|
78
|
+
temporary.replace(target)
|
|
79
|
+
except OSError:
|
|
80
|
+
fail("无法保存服务器配置,请检查配置目录的写入权限")
|
|
81
|
+
finally:
|
|
82
|
+
if temporary is not None and temporary.exists():
|
|
83
|
+
temporary.unlink()
|
|
84
|
+
print(f"已保存服务器地址:{url}")
|
|
85
|
+
print("后续命令将使用该地址;新服务器请先执行 fima login。")
|
|
86
|
+
if a.url is not None or "FIMA_URL" in os.environ:
|
|
87
|
+
print("提示:--url 和 FIMA_URL 的优先级高于保存的配置。")
|
|
88
|
+
|
|
89
|
+
def show_config(a):
|
|
90
|
+
saved=read_config().get("url")
|
|
91
|
+
print(f"已保存地址:{normalize_url(saved) if saved is not None else '未设置'}")
|
|
92
|
+
print(f"当前生效地址:{resolve_url(a.url)}")
|
|
12
93
|
|
|
13
94
|
def cookie_file(): return Path(os.environ.get("FIMA_COOKIE_FILE", Path.home() / ".fima" / "session.json"))
|
|
14
95
|
def load_cookie():
|
|
15
|
-
try:
|
|
16
|
-
|
|
96
|
+
try:
|
|
97
|
+
session=json.loads(cookie_file().read_text(encoding="utf-8"))
|
|
98
|
+
if isinstance(session,dict) and session.get("url")==ARGS.url and isinstance(session.get("cookie"),str):
|
|
99
|
+
return session["cookie"]
|
|
100
|
+
except (OSError, UnicodeError, json.JSONDecodeError): pass
|
|
101
|
+
return None
|
|
17
102
|
def save_cookie(value):
|
|
18
103
|
target=cookie_file(); target.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
-
target.write_text(json.dumps({"cookie":value}),encoding="utf-8"); target.chmod(0o600)
|
|
104
|
+
target.write_text(json.dumps({"url":ARGS.url,"cookie":value}),encoding="utf-8"); target.chmod(0o600)
|
|
20
105
|
def clear_cookie():
|
|
21
106
|
try: cookie_file().unlink()
|
|
22
107
|
except FileNotFoundError: pass
|
|
@@ -40,7 +125,7 @@ def request(method,path,data=None,file=None,include_headers=False):
|
|
|
40
125
|
try: msg=json.loads(e.read()).get("error",str(e))
|
|
41
126
|
except Exception: msg=str(e)
|
|
42
127
|
fail(msg)
|
|
43
|
-
except URLError: fail(f"无法连接 Fima Web:{url}\n
|
|
128
|
+
except URLError: fail(f"无法连接 Fima Web:{url}\n请检查服务是否启动,或执行 fima config set-url <地址> 更换服务器。")
|
|
44
129
|
|
|
45
130
|
def fail(msg): print(f"错误:{msg}",file=sys.stderr);raise SystemExit(1)
|
|
46
131
|
def json_object(value):
|
|
@@ -125,8 +210,14 @@ def whoami(a):
|
|
|
125
210
|
|
|
126
211
|
parser=argparse.ArgumentParser(prog="fima",description="Fima 财务报销命令行工具",epilog="多字段命令使用 JSON:fima create --json '{\"title\": \"客户拜访\", \"amount\": 280, \"date\": \"2026-07-12\"}'")
|
|
127
212
|
parser.add_argument("-v","--version",action="version",version=f"fima {VERSION}")
|
|
128
|
-
parser.add_argument("--url",
|
|
213
|
+
parser.add_argument("--url",help="本次请求的服务器 URL 或 IP:端口;优先级:--url > FIMA_URL > 保存的配置")
|
|
129
214
|
sub=parser.add_subparsers(dest="command",title="支持的指令",required=True)
|
|
215
|
+
p=sub.add_parser("config",help="设置、更换或查看服务器地址")
|
|
216
|
+
config_sub=p.add_subparsers(dest="config_command",required=True)
|
|
217
|
+
c=config_sub.add_parser("set-url",help="保存或更换服务器 URL / IP:端口",description="保存服务器地址供后续命令使用;未写协议时默认 HTTP,更换服务器后需登录新服务器。")
|
|
218
|
+
c.add_argument("server_url",metavar="ADDRESS",help="例如 https://fima.example.com 或 192.168.1.10:8000")
|
|
219
|
+
c.set_defaults(func=set_url)
|
|
220
|
+
c=config_sub.add_parser("show",help="查看保存的地址及当前生效地址");c.set_defaults(func=show_config)
|
|
130
221
|
p=sub.add_parser("login",help="打开浏览器登录并保存 Cookie");p.add_argument("--timeout",type=int,default=300,help="等待网页登录完成的秒数(默认:300)");p.set_defaults(func=login)
|
|
131
222
|
p=sub.add_parser("logout",help="退出登录并清除本地 Cookie");p.set_defaults(func=logout)
|
|
132
223
|
p=sub.add_parser("whoami",help="查看当前登录账号");p.set_defaults(func=whoami)
|
|
@@ -138,4 +229,7 @@ for cmd,help_text,default_operator in [("submit","提交报销单","王小明"),
|
|
|
138
229
|
json_help='JSON 对象;必填 id;可选 operator、comment' if cmd!="reject" else 'JSON 对象;必填 id、comment;可选 operator'
|
|
139
230
|
p=sub.add_parser(cmd,help=f"{help_text}(使用 JSON 传递字段)");p.add_argument("--json",dest="payload",required=True,type=json_object,metavar="JSON",help=json_help);p.set_defaults(func=action,default_operator=default_operator,default_comment=help_text)
|
|
140
231
|
ARGS=parser.parse_args()
|
|
141
|
-
if __name__=="__main__":
|
|
232
|
+
if __name__=="__main__":
|
|
233
|
+
if ARGS.command != "config":
|
|
234
|
+
ARGS.url=resolve_url(ARGS.url)
|
|
235
|
+
ARGS.func(ARGS)
|