@lichidental/saas-cli 0.0.0-stage → 1.0.1
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/AGENTS.md +246 -0
- package/README.md +92 -2
- package/api/custom-mark.mjs +52 -0
- package/api/custom.mjs +16 -0
- package/api/employee.mjs +19 -0
- package/api/treat-item.mjs +10 -0
- package/cli.mjs +88 -0
- package/commands/auth/login.mjs +144 -0
- package/commands/auth/logout.mjs +39 -0
- package/commands/auth/status.mjs +64 -0
- package/commands/config/set-token.mjs +39 -0
- package/commands/config/show.mjs +39 -0
- package/commands/reservation/_shared.mjs +216 -0
- package/commands/reservation/create.mjs +146 -0
- package/commands/reservation/doctors.mjs +21 -0
- package/commands/reservation/items.mjs +41 -0
- package/commands/reservation/list.mjs +78 -0
- package/commands/reservation/patients.mjs +30 -0
- package/docs/CLI/344/275/277/347/224/250/346/211/213/345/206/214.md +198 -0
- package/docs/api/openapi.yaml +442 -0
- package/docs/api//346/216/245/345/217/243/350/257/264/346/230/216.md +259 -0
- package/docs//345/274/200/345/217/221/346/214/207/345/215/227.md +176 -0
- package/lib/config.mjs +109 -0
- package/lib/context.mjs +49 -0
- package/lib/errors.mjs +61 -0
- package/lib/http.mjs +106 -0
- package/lib/match.mjs +39 -0
- package/lib/options.mjs +32 -0
- package/lib/output.mjs +31 -0
- package/lib/time.mjs +41 -0
- package/lib/types.mjs +51 -0
- package/lib/update-check.mjs +112 -0
- package/package.json +40 -4
package/lib/http.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// lib/http.mjs — HTTP 传输层:环境地址、请求头、版本号、统一响应解包
|
|
2
|
+
import { CliError, EXIT_CODES, mapApiError } from "./errors.mjs"
|
|
3
|
+
|
|
4
|
+
export const ENV_BASE_URLS = {
|
|
5
|
+
test: "https://test.saas.lichidental.com",
|
|
6
|
+
pre: "https://pre.saas.lichidental.com",
|
|
7
|
+
prod: "https://saas.lichidental.com",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 15000
|
|
11
|
+
|
|
12
|
+
// 版本号缓存:每个环境只解析一次
|
|
13
|
+
const versionCache = new Map()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从 <环境地址>/index.html 解析 <meta name="version"> 的值。
|
|
17
|
+
* 请求头 Version 用于后端版本校验(不一致会返回 status 999),解析失败返回 null(省略该头继续请求)。
|
|
18
|
+
* @param {string} baseUrl
|
|
19
|
+
* @returns {Promise<string|null>}
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchVersion(baseUrl) {
|
|
22
|
+
if (versionCache.has(baseUrl)) return versionCache.get(baseUrl)
|
|
23
|
+
let version = null
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${baseUrl}/index.html`, { signal: AbortSignal.timeout(TIMEOUT_MS) })
|
|
26
|
+
const html = await res.text()
|
|
27
|
+
const m =
|
|
28
|
+
html.match(/<meta[^>]*\sname=["']version["'][^>]*\scontent=["']([^"']+)["']/i) ||
|
|
29
|
+
html.match(/<meta[^>]*\scontent=["']([^"']+)["'][^>]*\sname=["']version["']/i)
|
|
30
|
+
if (m) version = m[1]
|
|
31
|
+
} catch {
|
|
32
|
+
// 忽略:拿不到版本号时不带 Version 头
|
|
33
|
+
}
|
|
34
|
+
versionCache.set(baseUrl, version)
|
|
35
|
+
return version
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function maskToken(token) {
|
|
39
|
+
if (!token || token.length <= 8) return "****"
|
|
40
|
+
return `${token.slice(0, 4)}****${token.slice(-4)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 创建请求器(所有接口均为 POST + JSON,统一响应 {status, code, message, data})
|
|
45
|
+
* @param {{env: string, token: string, verbose?: boolean, log?: (...args: any[]) => void}} options
|
|
46
|
+
* @returns {import("./types.mjs").Http}
|
|
47
|
+
*/
|
|
48
|
+
export function createHttp({ env, token, verbose = false, log = () => {} }) {
|
|
49
|
+
const baseUrl = ENV_BASE_URLS[env]
|
|
50
|
+
if (!baseUrl) {
|
|
51
|
+
throw new CliError(`未知环境「${env}」,可选:${Object.keys(ENV_BASE_URLS).join(" / ")}`, {
|
|
52
|
+
exitCode: EXIT_CODES.USAGE,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function post(path, body) {
|
|
57
|
+
const version = await fetchVersion(baseUrl)
|
|
58
|
+
const url = `${baseUrl}${path}`
|
|
59
|
+
const headers = {
|
|
60
|
+
"Content-Type": "application/json;charset=UTF-8",
|
|
61
|
+
token,
|
|
62
|
+
}
|
|
63
|
+
if (version) {
|
|
64
|
+
headers.Version = version
|
|
65
|
+
headers.channel = "html"
|
|
66
|
+
}
|
|
67
|
+
if (verbose) {
|
|
68
|
+
log(`→ POST ${url}`)
|
|
69
|
+
log(` 请求头: token=${maskToken(token)}${version ? `, Version=${version}, channel=html` : ""}`)
|
|
70
|
+
log(` 请求体: ${JSON.stringify(body ?? {})}`)
|
|
71
|
+
}
|
|
72
|
+
let res
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(url, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
body: JSON.stringify(body ?? {}),
|
|
78
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
79
|
+
})
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new CliError(`网络请求失败:${err?.message ?? err}`, {
|
|
82
|
+
exitCode: EXIT_CODES.NETWORK,
|
|
83
|
+
hint: `请求地址:${url}`,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
let payload
|
|
87
|
+
try {
|
|
88
|
+
payload = await res.json()
|
|
89
|
+
} catch {
|
|
90
|
+
throw new CliError(`响应不是合法 JSON(HTTP ${res.status})`, {
|
|
91
|
+
exitCode: EXIT_CODES.NETWORK,
|
|
92
|
+
hint: `请求地址:${url}`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
if (verbose) log(`← HTTP ${res.status} ${JSON.stringify(payload)}`)
|
|
96
|
+
if (payload && typeof payload === "object" && "status" in payload) {
|
|
97
|
+
if (payload.status === 200) return payload
|
|
98
|
+
throw mapApiError(payload)
|
|
99
|
+
}
|
|
100
|
+
throw new CliError(`响应格式异常(缺少 status 字段):${JSON.stringify(payload).slice(0, 200)}`, {
|
|
101
|
+
exitCode: EXIT_CODES.NETWORK,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { post, baseUrl, env }
|
|
106
|
+
}
|
package/lib/match.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// lib/match.mjs — 名称 → 唯一候选解析(重名时不瞎猜,列候选让使用者指定)
|
|
2
|
+
import { CliError, EXIT_CODES } from "./errors.mjs"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 从候选列表中挑出唯一一项:
|
|
6
|
+
* 先精确匹配(全等),没有再模糊匹配(包含);命中 0 条或多条都报错
|
|
7
|
+
* @template T
|
|
8
|
+
* @param {T[]} items 候选列表
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {(item: T) => Array<string | number | null | undefined>} opts.match 参与匹配的字段取值函数
|
|
11
|
+
* @param {(item: T) => string} opts.describe 多命中时展示候选行的函数
|
|
12
|
+
* @param {string} opts.input 使用者输入
|
|
13
|
+
* @param {string} opts.label 名词(如 "患者" / "医生")
|
|
14
|
+
* @param {string} [opts.hint] 报错时的补充提示
|
|
15
|
+
* @returns {T}
|
|
16
|
+
*/
|
|
17
|
+
export function pickOne(items, { match, describe, input, label, hint }) {
|
|
18
|
+
const norm = (v) => String(v ?? "").trim()
|
|
19
|
+
const target = norm(input)
|
|
20
|
+
const exact = items.filter((it) => match(it).some((v) => norm(v) === target))
|
|
21
|
+
const hit = exact.length
|
|
22
|
+
? exact
|
|
23
|
+
: items.filter((it) =>
|
|
24
|
+
match(it).some((v) => {
|
|
25
|
+
const vv = norm(v)
|
|
26
|
+
return vv !== "" && vv.includes(target)
|
|
27
|
+
})
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (hit.length === 1) return hit[0]
|
|
31
|
+
if (hit.length === 0) {
|
|
32
|
+
throw new CliError(`未找到${label}「${input}」`, { exitCode: EXIT_CODES.USAGE, hint })
|
|
33
|
+
}
|
|
34
|
+
const lines = hit.map((it) => ` - ${describe(it)}`).join("\n")
|
|
35
|
+
throw new CliError(`${label}「${input}」匹配到 ${hit.length} 条,无法确定是哪一条:\n${lines}`, {
|
|
36
|
+
exitCode: EXIT_CODES.USAGE,
|
|
37
|
+
hint,
|
|
38
|
+
})
|
|
39
|
+
}
|
package/lib/options.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// lib/options.mjs — 每个命令共用的全局选项(--env / --token / --json / --verbose)
|
|
2
|
+
import { CliError, EXIT_CODES } from "./errors.mjs"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 给命令挂上通用选项。挂在命令级而不是 program 级,这样选项可以出现在命令后的任意位置,
|
|
6
|
+
* 例如:saas-cli reservation create --env test --patient 张三
|
|
7
|
+
* @param {import("commander").Command} cmd
|
|
8
|
+
* @returns {import("commander").Command}
|
|
9
|
+
*/
|
|
10
|
+
export function addCommonOptions(cmd) {
|
|
11
|
+
return cmd
|
|
12
|
+
.option("--env <env>", "环境:test | pre | prod", "test")
|
|
13
|
+
.option("--token <token>", "接口 token(默认读环境变量 SAAS_TOKEN 或配置文件)")
|
|
14
|
+
.option("--json", "输出结构化 JSON(默认输出人读摘要)")
|
|
15
|
+
.option("--verbose", "打印请求/响应详情(token 脱敏)")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 校验"需要值"的选项的取值是否像另一个选项。
|
|
20
|
+
* commander 会把紧跟选项的下一个 token 当作它的值(如 `--remark --dry-run` 时 remark 取到 "--dry-run"),
|
|
21
|
+
* 以 - 开头说明使用者很可能漏写了值,提前拦下,避免静默用错值(如 --dry-run 被吞后变成真实创建)。
|
|
22
|
+
* @param {string|undefined|null} value 使用者传入的值
|
|
23
|
+
* @param {{option: string, hint?: string}} opts option 为选项名(如 "--doctor-id")
|
|
24
|
+
*/
|
|
25
|
+
export function assertValueNotOptionLike(value, { option, hint }) {
|
|
26
|
+
if (typeof value === "string" && value.startsWith("-")) {
|
|
27
|
+
throw new CliError(`${option} 的值看起来是另一个选项(「${value}」),请检查是否漏写了它的值`, {
|
|
28
|
+
exitCode: EXIT_CODES.USAGE,
|
|
29
|
+
hint: hint ?? `用法:${option} <值>;选项后面要紧跟自己的值,不能接其他选项`,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
package/lib/output.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// lib/output.mjs — 输出约定:stdout 只放数据(人读摘要 / JSON),日志一律走 stderr
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 创建输出器
|
|
5
|
+
* @param {{json?: boolean, verbose?: boolean}} [options]
|
|
6
|
+
* @returns {import("./types.mjs").Output}
|
|
7
|
+
*/
|
|
8
|
+
export function createOutput({ json = false, verbose = false } = {}) {
|
|
9
|
+
const log = (...args) => console.error(...args)
|
|
10
|
+
const verboseLog = verbose ? (...args) => console.error(...args) : () => {}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
log,
|
|
14
|
+
verboseLog,
|
|
15
|
+
isJson: json,
|
|
16
|
+
/**
|
|
17
|
+
* 成功输出
|
|
18
|
+
* @param {string} command 命令名(如 "reservation create")
|
|
19
|
+
* @param {string} env 环境
|
|
20
|
+
* @param {object} data 结构化数据(JSON 模式下的 data 字段)
|
|
21
|
+
* @param {string} [humanText] 人读摘要(默认模式输出;JSON 模式忽略)
|
|
22
|
+
*/
|
|
23
|
+
success(command, env, data, humanText) {
|
|
24
|
+
if (json) {
|
|
25
|
+
console.log(JSON.stringify({ ok: true, env, command, data }, null, 2))
|
|
26
|
+
} else if (humanText) {
|
|
27
|
+
console.log(humanText)
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/time.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// lib/time.mjs — 时间解析与格式化(统一以本机本地时区为准)
|
|
2
|
+
import { CliError, EXIT_CODES } from "./errors.mjs"
|
|
3
|
+
|
|
4
|
+
const pad = (n) => String(n).padStart(2, "0")
|
|
5
|
+
|
|
6
|
+
/** Date → "YYYY-MM-DD HH:mm:ss" @param {Date} d @returns {string} */
|
|
7
|
+
export function formatDateTime(d) {
|
|
8
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Date → "YYYY-MM-DD HH:mm" @param {Date} d @returns {string} */
|
|
12
|
+
export function formatMinute(d) {
|
|
13
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Date → "YYYY-MM-DD" @param {Date} d @returns {string} */
|
|
17
|
+
export function formatDate(d) {
|
|
18
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 解析使用者传入的时间,支持:
|
|
23
|
+
* - 省略 → 当前时间
|
|
24
|
+
* - "YYYY-MM-DD HH:mm"
|
|
25
|
+
* - "YYYY-MM-DD HH:mm:ss"
|
|
26
|
+
* @param {string|undefined} input
|
|
27
|
+
* @returns {Date}
|
|
28
|
+
*/
|
|
29
|
+
export function parseUserTime(input) {
|
|
30
|
+
if (!input) return new Date()
|
|
31
|
+
const s = String(input).trim()
|
|
32
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/)
|
|
33
|
+
if (!m) {
|
|
34
|
+
throw new CliError(`时间格式不正确:「${input}」`, {
|
|
35
|
+
exitCode: EXIT_CODES.USAGE,
|
|
36
|
+
hint: '正确格式:--time "2026-10-15 10:00" 或 "2026-10-15 10:00:00";省略则用当前时间',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
const [, y, mo, d, h, mi, se] = m
|
|
40
|
+
return new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se ?? 0))
|
|
41
|
+
}
|
package/lib/types.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// lib/types.mjs — 核心数据结构类型定义(纯 JSDoc,无运行代码)
|
|
2
|
+
// 供各文件通过 import("./types.mjs").Xxx 引用,提升 IDE 的跳转 / 提示 / 重构体验。
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 统一响应包装(所有后端接口返回的结构)
|
|
6
|
+
* @typedef {{status: number, code?: string, message?: string, data?: any}} ApiResponse
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 请求器(由 lib/http.mjs 的 createHttp 返回)
|
|
11
|
+
* @typedef {{post: (path: string, body?: object) => Promise<ApiResponse>, baseUrl: string, env: string}} Http
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 医生 / 员工(getStoreUserListByParam 返回元素)
|
|
16
|
+
* @typedef {{userUid: string | number, storeUserName?: string, userName?: string,
|
|
17
|
+
* storeUserPostIds?: string[], delFlag?: number}} Doctor
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 患者(customParamPageList 的 data.list 元素)
|
|
22
|
+
* @typedef {{customCode: string, customName?: string, customPhone?: string,
|
|
23
|
+
* codeNum?: string, sex?: number}} Patient
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 预约记录(customMarkListByCustom 返回的 data.cmList 元素)
|
|
28
|
+
* @typedef {{id: number, clinicId?: number, customCode?: string, codeNum?: string,
|
|
29
|
+
* customName?: string, customPhone?: string, doctorId?: string | number,
|
|
30
|
+
* boolFirst?: number, markDate?: string, markStartTime?: string, markEndTime?: string,
|
|
31
|
+
* markTimeLength?: number, treatItemJson?: string, remark?: string, status?: number}} CustomMark
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 预约项目(getTreatItemList 返回元素)
|
|
36
|
+
* @typedef {{id: number, treatItemName: string, treatItemTitle?: string,
|
|
37
|
+
* upTreatItemId?: number, level?: number}} TreatItem
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* treatItemJson 字符串解析后的元素
|
|
42
|
+
* @typedef {{up_id: number, up_title: string, id: number, title: string}} TreatItemJsonItem
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 命令的输出器(由 lib/output.mjs 的 createOutput 返回)
|
|
47
|
+
* @typedef {{log: (...args: any[]) => void, verboseLog: (...args: any[]) => void,
|
|
48
|
+
* isJson: boolean, success: (command: string, env: string, data: object, humanText?: string) => void}} Output
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export {}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// lib/update-check.mjs — 版本更新提醒(对齐 lark-cli:24h 缓存 + 一行 stderr 提示,全程静默失败)
|
|
2
|
+
// 机制:启动时同步读本地缓存(毫秒级)决定是否提示;缓存过期时后台发起刷新(socket unref,不阻塞进程退出)
|
|
3
|
+
import fs from "node:fs"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import http from "node:http"
|
|
6
|
+
import https from "node:https"
|
|
7
|
+
import { getConfigPath } from "./config.mjs"
|
|
8
|
+
|
|
9
|
+
const PKG_NAME = "@lichidental/saas-cli"
|
|
10
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org"
|
|
11
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
12
|
+
const FETCH_TIMEOUT_MS = 2500
|
|
13
|
+
|
|
14
|
+
/** 更新状态缓存文件(与配置文件同目录) @returns {string} */
|
|
15
|
+
export function getUpdateStatePath() {
|
|
16
|
+
return path.join(path.dirname(getConfigPath()), "update-state.json")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 版本号比较(仅支持 x.y.z 数字段)
|
|
21
|
+
* @param {string} a
|
|
22
|
+
* @param {string} b
|
|
23
|
+
* @returns {number} a<b 返回 -1,a=b 返回 0,a>b 返回 1;格式非法时返回 0(视为相等,不提示)
|
|
24
|
+
*/
|
|
25
|
+
export function compareVersions(a, b) {
|
|
26
|
+
const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10))
|
|
27
|
+
const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10))
|
|
28
|
+
if (pa.length === 0 || pb.length === 0 || pa.some(Number.isNaN) || pb.some(Number.isNaN)) return 0
|
|
29
|
+
const len = Math.max(pa.length, pb.length)
|
|
30
|
+
for (let i = 0; i < len; i++) {
|
|
31
|
+
const x = pa[i] ?? 0
|
|
32
|
+
const y = pb[i] ?? 0
|
|
33
|
+
if (x < y) return -1
|
|
34
|
+
if (x > y) return 1
|
|
35
|
+
}
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 读取更新状态缓存,失败返回空对象 */
|
|
40
|
+
function readState() {
|
|
41
|
+
try {
|
|
42
|
+
const s = JSON.parse(fs.readFileSync(getUpdateStatePath(), "utf8"))
|
|
43
|
+
return s && typeof s === "object" ? s : {}
|
|
44
|
+
} catch {
|
|
45
|
+
return {}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 写入更新状态缓存(失败静默) */
|
|
50
|
+
function writeState(state) {
|
|
51
|
+
try {
|
|
52
|
+
const file = getUpdateStatePath()
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
54
|
+
fs.writeFileSync(file, JSON.stringify(state) + "\n")
|
|
55
|
+
} catch {
|
|
56
|
+
// 缓存写失败不影响任何功能
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 后台刷新版本缓存:socket unref 不阻塞进程退出;任何失败静默 */
|
|
61
|
+
function refreshInBackground() {
|
|
62
|
+
try {
|
|
63
|
+
const registry = (process.env.SAAS_CLI_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, "")
|
|
64
|
+
const url = `${registry}/${encodeURIComponent(PKG_NAME)}/latest`
|
|
65
|
+
const mod = url.startsWith("https:") ? https : http
|
|
66
|
+
const req = mod.get(url, { timeout: FETCH_TIMEOUT_MS, headers: { accept: "application/json" } }, (res) => {
|
|
67
|
+
if (res.statusCode !== 200) {
|
|
68
|
+
res.resume()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
let body = ""
|
|
72
|
+
res.on("data", (c) => (body += c))
|
|
73
|
+
res.on("end", () => {
|
|
74
|
+
try {
|
|
75
|
+
const latest = JSON.parse(body)?.version
|
|
76
|
+
if (latest) writeState({ checkedAt: Date.now(), latestVersion: String(latest) })
|
|
77
|
+
} catch {
|
|
78
|
+
// 静默
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
req.on("socket", (s) => s.unref())
|
|
83
|
+
req.on("timeout", () => req.destroy())
|
|
84
|
+
req.on("error", () => {})
|
|
85
|
+
} catch {
|
|
86
|
+
// 静默
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 版本更新检查(启动时调用):
|
|
92
|
+
* 1) 用本地缓存同步提示(毫秒级,不联网):发现新版本时向 stderr 打一行提示
|
|
93
|
+
* 2) 缓存超过 24h 时后台刷新(不阻塞)
|
|
94
|
+
* 永不抛出、永不阻塞。SAAS_CLI_NO_UPDATE_CHECK=1 可整体关闭。
|
|
95
|
+
* @param {string} currentVersion 当前版本(来自 package.json)
|
|
96
|
+
*/
|
|
97
|
+
export function checkForUpdate(currentVersion) {
|
|
98
|
+
try {
|
|
99
|
+
if (process.env.SAAS_CLI_NO_UPDATE_CHECK === "1") return
|
|
100
|
+
const state = readState()
|
|
101
|
+
const latest = state.latestVersion
|
|
102
|
+
if (latest && compareVersions(currentVersion, latest) < 0) {
|
|
103
|
+
console.error(`提示:发现新版本 ${latest}(当前 ${currentVersion}),升级: npm i -g ${PKG_NAME}@latest`)
|
|
104
|
+
}
|
|
105
|
+
const checkedAt = Number(state.checkedAt ?? 0)
|
|
106
|
+
if (!Number.isFinite(checkedAt) || Date.now() - checkedAt > CHECK_INTERVAL_MS) {
|
|
107
|
+
refreshInBackground()
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// 检查永不阻塞主流程
|
|
111
|
+
}
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lichidental/saas-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "励齿 SaaS 数据操作 CLI:新建预约、查询医生/患者/预约项目等",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"saas-cli": "./cli.mjs"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"cli.mjs",
|
|
14
|
+
"commands",
|
|
15
|
+
"lib",
|
|
16
|
+
"api",
|
|
17
|
+
"README.md",
|
|
18
|
+
"AGENTS.md",
|
|
19
|
+
"docs/CLI使用手册.md",
|
|
20
|
+
"docs/开发指南.md",
|
|
21
|
+
"docs/api/openapi.yaml",
|
|
22
|
+
"docs/api/接口说明.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"registry": "https://registry.npmjs.org/",
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"license": "UNLICENSED",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://lichigit.lichidental.com/sass/create-saas-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^15.0.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^26.6.5"
|
|
41
|
+
}
|
|
42
|
+
}
|