fima-cli 0.0.0-stage → 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.
Files changed (4) hide show
  1. package/README.md +87 -2
  2. package/bin/fima.cjs +35 -0
  3. package/fima.py +235 -0
  4. package/package.json +26 -4
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # fima-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Fima 财务报销系统的命令行客户端,需要配合 `fima-web` 服务使用。
4
+
5
+ ## npm 安装
6
+
7
+ 运行环境:Node.js 22 及以上、Python 3.8 及以上。Python 只使用标准库,无需额外安装 pip 依赖。macOS / Linux 需确保 `python3` 在 PATH 中;Windows 支持 `py -3`、`python3` 或 `python`。
8
+
9
+ ```bash
10
+ npm install -g fima-cli
11
+ fima --version
12
+ fima --help
13
+ ```
14
+
15
+ 也可以使用 `pnpm add -g fima-cli` 安装,或直接运行 `npx fima-cli --help`。
16
+
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` 和设置配置的命令无需服务器地址。
45
+
46
+ ## 从源码运行
47
+
48
+ 先启动 `fima-web`,然后直接使用:
49
+
50
+ ```bash
51
+ ./fima -v
52
+ ./fima -h
53
+ ./fima config set-url 127.0.0.1:8000
54
+ ./fima login
55
+ ./fima whoami
56
+ ./fima create --json '{"title":"上海客户拜访","category":"差旅费","amount":1280,"date":"2026-07-12","invoice":"./invoice.pdf","submit":true}'
57
+ ./fima list
58
+ ./fima show 1
59
+ ./fima manager-approve --json '{"id":1,"operator":"李主管","comment":"同意"}'
60
+ ./fima finance-approve --json '{"id":1,"operator":"陈会计","comment":"票据无误"}'
61
+ ./fima pay --json '{"id":1,"operator":"陈出纳","comment":"已模拟打款"}'
62
+ ./fima logout
63
+ ```
64
+
65
+ 从源码安装为全局命令(macOS / Linux):
66
+
67
+ ```bash
68
+ ./install.sh
69
+ fima -h
70
+ ```
71
+
72
+ 请使用服务管理员提供的账号登录。执行 `login` 会拉起浏览器中的专用登录框,账号密码只在网页中填写;CLI 会自动检测登录完成并把会话 Cookie 保存在 `~/.fima/session.json`(可用 `FIMA_COOKIE_FILE` 覆盖)。网页会明确提示“CLI 登录已完成”,此时直接关闭当前页面即可。可通过 `--timeout` 调整等待时长(默认 5 分钟)。后续业务请求会自动携带与当前服务器匹配的 Cookie;可通过 `whoami` 查看当前登录账号,`logout` 会退出登录并清除本地会话 Cookie。
73
+
74
+ ## 参数传递
75
+
76
+ 字段超过两个的命令统一通过 `--json` 传入一个 JSON 对象,避免 Agent 在长参数列表中遗漏或混淆字段。先执行 `fima <command> -h` 查看该命令的 JSON 结构。
77
+
78
+ ```bash
79
+ # 创建报销单:title、amount、date 必填
80
+ fima create --json '{"title":"上海客户拜访","amount":1280,"date":"2026-07-12","category":"差旅费","description":"客户现场拜访","invoice":"./invoice.pdf","submit":true}'
81
+
82
+ # 流程操作:id 必填;reject 还必须提供 comment
83
+ fima submit --json '{"id":1}'
84
+ fima manager-approve --json '{"id":1,"operator":"李主管","comment":"同意"}'
85
+ fima reject --json '{"id":1,"operator":"李主管","comment":"缺少行程单"}'
86
+ ```
87
+
88
+ `list`、`show` 和 `upload` 仍使用简洁的普通参数:`fima list --status submitted`、`fima show 1`、`fima upload 1 ./invoice.pdf`。
package/bin/fima.cjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('node:child_process');
5
+ const path = require('node:path');
6
+
7
+ const candidates = process.platform === 'win32'
8
+ ? [['py', '-3'], ['python3'], ['python']]
9
+ : [['python3']];
10
+ const python = candidates.find(([command, ...args]) => {
11
+ const result = spawnSync(command, [
12
+ ...args, '-c', 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)',
13
+ ], { stdio: 'ignore' });
14
+ return !result.error && result.status === 0;
15
+ });
16
+
17
+ if (!python) {
18
+ console.error('错误:Fima CLI 需要 Python 3.8 或更高版本,请安装 Python 并将其加入 PATH。');
19
+ process.exit(1);
20
+ }
21
+
22
+ const [command, ...args] = python;
23
+ const result = spawnSync(command, [
24
+ ...args, path.join(__dirname, '..', 'fima.py'), ...process.argv.slice(2),
25
+ ], { stdio: 'inherit' });
26
+
27
+ if (result.error) {
28
+ console.error(`错误:无法启动 Fima CLI:${result.error.message}`);
29
+ process.exit(1);
30
+ }
31
+ if (result.signal) {
32
+ process.kill(process.pid, result.signal);
33
+ } else {
34
+ process.exit(result.status ?? 1);
35
+ }
package/fima.py ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env python3
2
+ """Fima CLI - 通过命令行操作 Fima 财务报销系统。"""
3
+ from __future__ import annotations
4
+ import argparse, json, math, mimetypes, os, sys, tempfile, time, webbrowser
5
+ from http.cookies import SimpleCookie
6
+ from pathlib import Path
7
+ from urllib.parse import quote, urlsplit, urlunsplit
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
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)}")
93
+
94
+ def cookie_file(): return Path(os.environ.get("FIMA_COOKIE_FILE", Path.home() / ".fima" / "session.json"))
95
+ def load_cookie():
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
102
+ def save_cookie(value):
103
+ target=cookie_file(); target.parent.mkdir(parents=True, exist_ok=True)
104
+ target.write_text(json.dumps({"url":ARGS.url,"cookie":value}),encoding="utf-8"); target.chmod(0o600)
105
+ def clear_cookie():
106
+ try: cookie_file().unlink()
107
+ except FileNotFoundError: pass
108
+
109
+ def request(method,path,data=None,file=None,include_headers=False):
110
+ url=ARGS.url.rstrip("/")+path; headers={}; body=None
111
+ if cookie:=load_cookie(): headers["Cookie"]=cookie
112
+ if file:
113
+ p=Path(file)
114
+ if not p.is_file(): fail(f"文件不存在:{p}")
115
+ boundary="----FimaBoundary7MA4YWxk"; raw=p.read_bytes(); ctype=mimetypes.guess_type(p.name)[0] or "application/octet-stream"
116
+ body=(f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{p.name}\"\r\nContent-Type: {ctype}\r\n\r\n".encode()+raw+f"\r\n--{boundary}--\r\n".encode())
117
+ headers["Content-Type"]=f"multipart/form-data; boundary={boundary}"
118
+ elif data is not None:
119
+ body=json.dumps(data,ensure_ascii=False).encode();headers["Content-Type"]="application/json"
120
+ try:
121
+ with urlopen(Request(url,data=body,headers=headers,method=method),timeout=10) as r:
122
+ result=json.loads(r.read())
123
+ return (result,r.headers) if include_headers else result
124
+ except HTTPError as e:
125
+ try: msg=json.loads(e.read()).get("error",str(e))
126
+ except Exception: msg=str(e)
127
+ fail(msg)
128
+ except URLError: fail(f"无法连接 Fima Web:{url}\n请检查服务是否启动,或执行 fima config set-url <地址> 更换服务器。")
129
+
130
+ def fail(msg): print(f"错误:{msg}",file=sys.stderr);raise SystemExit(1)
131
+ def json_object(value):
132
+ try: data=json.loads(value)
133
+ except json.JSONDecodeError as e: raise argparse.ArgumentTypeError(f"不是合法 JSON:{e.msg}")
134
+ if not isinstance(data,dict): raise argparse.ArgumentTypeError("JSON 必须是对象,例如:'{\"id\": 1}'")
135
+ return data
136
+ def required(payload,key):
137
+ value=payload.get(key)
138
+ if value is None or value=="": fail(f"JSON 缺少必填字段:{key}")
139
+ return value
140
+ def optional_string(payload,key,default=""):
141
+ value=payload.get(key,default)
142
+ if not isinstance(value,str): fail(f"JSON 字段 {key} 必须是字符串")
143
+ return value
144
+ def positive_number(payload,key):
145
+ value=required(payload,key)
146
+ if isinstance(value,bool) or not isinstance(value,(int,float)) or not math.isfinite(value) or value<=0: fail(f"JSON 字段 {key} 必须是大于 0 的数字")
147
+ return value
148
+ def allow_fields(payload,*fields):
149
+ unknown=set(payload)-set(fields)
150
+ if unknown: fail(f"JSON 包含不支持的字段:{', '.join(sorted(unknown))}")
151
+ def fmt_money(x): return f"¥{float(x):,.2f}"
152
+ def print_item(x,detail=False):
153
+ invoice=x.get("invoice_name") or "未上传"
154
+ print(f"{x['id']:>3} {x['expense_no']:<20} {x['status_label']:<10} {fmt_money(x['amount']):>12} {x['title']} [{invoice}]")
155
+ if detail:
156
+ print(f" 申请人:{x['applicant']} / {x['department']} 日期:{x['expense_date']} 类别:{x['category']}")
157
+ print(f" 说明:{x.get('description') or '-'}")
158
+ for l in reversed(x.get("logs",[])): print(f" · {l['created_at'].replace('T',' ')} {l['operator']}:{l['comment']}")
159
+
160
+ def create(a):
161
+ payload=a.payload
162
+ allow_fields(payload,"title","amount","date","category","description","applicant","department","invoice","submit")
163
+ title=required(payload,"title"); date=required(payload,"date")
164
+ if not isinstance(title,str) or not isinstance(date,str): fail("JSON 字段 title 和 date 必须是字符串")
165
+ invoice=payload.get("invoice")
166
+ if invoice is not None and not isinstance(invoice,str): fail("JSON 字段 invoice 必须是文件路径字符串")
167
+ submit=payload.get("submit",False)
168
+ if not isinstance(submit,bool): fail("JSON 字段 submit 必须是 true 或 false")
169
+ data={"title":title,"category":optional_string(payload,"category","其他"),"amount":positive_number(payload,"amount"),"expense_date":date,"description":optional_string(payload,"description"),"applicant":optional_string(payload,"applicant","王小明"),"department":optional_string(payload,"department","产品研发部")}
170
+ x=request("POST","/api/expenses",data); print(f"已创建:{x['expense_no']}(ID: {x['id']})")
171
+ if invoice is not None:
172
+ request("POST",f"/api/expenses/{x['id']}/invoice",file=invoice);print(f"已上传发票:{Path(invoice).name}")
173
+ if submit: request("POST",f"/api/expenses/{x['id']}/submit",{});print("已提交审核")
174
+ def list_items(a):
175
+ rows=request("GET","/api/expenses"+(f"?status={a.status}" if a.status else ""));print(" ID 报销单号 状态 金额 事项 [发票]")
176
+ print("-"*90)
177
+ for x in rows: print_item(x)
178
+ print(f"\n共 {len(rows)} 条")
179
+ def show(a): print_item(request("GET",f"/api/expenses/{a.id}"),True)
180
+ def upload(a): x=request("POST",f"/api/expenses/{a.id}/invoice",file=a.file);print(f"发票已上传:{x['invoice_name']}")
181
+ def action(a):
182
+ names={"submit":"提交审核","manager-approve":"主管审批通过","finance-approve":"财务审批通过","reject":"驳回","pay":"确认模拟打款"}
183
+ payload=a.payload; expense_id=required(payload,"id")
184
+ allow_fields(payload,"id","operator","comment")
185
+ if isinstance(expense_id,bool) or not isinstance(expense_id,int) or expense_id<=0: fail("JSON 字段 id 必须是正整数")
186
+ operator=optional_string(payload,"operator",a.default_operator)
187
+ comment=optional_string(payload,"comment",a.default_comment)
188
+ if a.command=="reject" and "comment" not in payload: fail("JSON 缺少必填字段:comment")
189
+ x=request("POST",f"/api/expenses/{expense_id}/{a.command}",{"operator":operator,"comment":comment});print(f"操作成功:{names[a.command]},当前状态:{x['status_label']}")
190
+ def login(a):
191
+ if a.timeout <= 0: fail("--timeout 必须大于 0")
192
+ session=request("POST","/api/cli-login",{})
193
+ browser_url=(ARGS.url.rstrip("/")+"/?cli_login="+quote(session["id"],safe="")+"&cli_token="+quote(session["browser_token"],safe=""))
194
+ print("正在打开浏览器,请在网页中完成登录。登录成功后可直接关闭该页面。")
195
+ if not webbrowser.open(browser_url, new=2): print(f"浏览器未自动打开,请访问:{browser_url}")
196
+ print("等待浏览器登录完成…", flush=True)
197
+ deadline=time.monotonic()+a.timeout
198
+ while time.monotonic()<deadline:
199
+ result=request("GET",f"/api/cli-login/{quote(session['id'],safe='')}?token={quote(session['poll_token'],safe='')}")
200
+ if result["status"]=="complete":
201
+ save_cookie(result["cookie"])
202
+ print(f"登录成功:{result['username']}(浏览器页面现在可以关闭)")
203
+ return
204
+ time.sleep(1)
205
+ fail("等待网页登录超时,请重新执行 fima login")
206
+ def logout(a):
207
+ request("POST","/api/logout",{});clear_cookie();print("已退出登录")
208
+ def whoami(a):
209
+ user=request("GET","/api/me");print(f"当前登录用户:{user.get('display_name') or user['username']} (@{user['username']})")
210
+
211
+ parser=argparse.ArgumentParser(prog="fima",description="Fima 财务报销命令行工具",epilog="多字段命令使用 JSON:fima create --json '{\"title\": \"客户拜访\", \"amount\": 280, \"date\": \"2026-07-12\"}'")
212
+ parser.add_argument("-v","--version",action="version",version=f"fima {VERSION}")
213
+ parser.add_argument("--url",help="本次请求的服务器 URL 或 IP:端口;优先级:--url > FIMA_URL > 保存的配置")
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)
221
+ p=sub.add_parser("login",help="打开浏览器登录并保存 Cookie");p.add_argument("--timeout",type=int,default=300,help="等待网页登录完成的秒数(默认:300)");p.set_defaults(func=login)
222
+ p=sub.add_parser("logout",help="退出登录并清除本地 Cookie");p.set_defaults(func=logout)
223
+ p=sub.add_parser("whoami",help="查看当前登录账号");p.set_defaults(func=whoami)
224
+ p=sub.add_parser("create",help="创建报销单(使用 JSON 传递字段)");p.add_argument("--json",dest="payload",required=True,type=json_object,metavar="JSON",help='JSON 对象;必填 title、amount、date;可选 category、description、applicant、department、invoice、submit');p.set_defaults(func=create)
225
+ p=sub.add_parser("list",help="查看报销单列表");p.add_argument("--status",choices=["draft","submitted","manager_approved","finance_approved","paid","rejected"]);p.set_defaults(func=list_items)
226
+ p=sub.add_parser("show",help="查看报销单详情和流转记录");p.add_argument("id",type=int);p.set_defaults(func=show)
227
+ p=sub.add_parser("upload",help="上传或替换发票");p.add_argument("id",type=int);p.add_argument("file");p.set_defaults(func=upload)
228
+ for cmd,help_text,default_operator in [("submit","提交报销单","王小明"),("manager-approve","主管审核通过","李主管"),("finance-approve","财务审核通过","陈会计"),("pay","确认模拟打款","陈出纳"),("reject","驳回报销单","李主管")]:
229
+ json_help='JSON 对象;必填 id;可选 operator、comment' if cmd!="reject" else 'JSON 对象;必填 id、comment;可选 operator'
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)
231
+ ARGS=parser.parse_args()
232
+ if __name__=="__main__":
233
+ if ARGS.command != "config":
234
+ ARGS.url=resolve_url(ARGS.url)
235
+ ARGS.func(ARGS)
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "fima-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.4.0",
4
+ "description": "Command-line client for the Fima expense reimbursement system",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chujianyun/fima-cli.git"
9
+ },
10
+ "bin": {
11
+ "fima": "bin/fima.cjs"
12
+ },
13
+ "files": [
14
+ "bin/fima.cjs",
15
+ "fima.py"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "prepublishOnly": "npm test"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ }
28
+ }