feat: 新增 mactfs-mcp 服务(AI 改文件前自动签出/加入)

- mactfs-mcp:通过 SSE 暴露 tfs_checkout / tfs_add,转调本机 Java 后端操作 TFS
- 后端 MacTfsCoreService/MacTfsServer 新增文件状态查询等接口,供 MCP 执行前校验
- Electron 随客户端拉起并回收 MCP 子进程(父死自杀、崩溃自动重拉)
- tfs_checkout 默认行为:新文件自动 add、本地落后自动 get-latest 后再签出
  (可分别用 autoAdd=false / getLatestIfStale=false 关闭;dryRun 无副作用)
- 新增 docs/打包构建与MCP使用指南.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fenghp
2026-06-23 10:31:31 +08:00
co-authored by Cursor
parent 627adc907e
commit e9f942e695
15 changed files with 2826 additions and 1 deletions
+269
View File
@@ -0,0 +1,269 @@
import path from "node:path"
import { tfsClient } from "./tfs-client.js"
import type {
FileStatus,
PendAction,
PendItemResult,
PendReason,
} from "./types.js"
export interface PendOptions {
dryRun?: boolean
/** 本地落后于服务器时是否先自动获取最新再签出。默认 true,传 false 才退回仅警告。 */
getLatestIfStale?: boolean
/** 遇到服务端尚不存在的新文件时是否自动加入版本控制(add)。默认 true,传 false 才退回仅提示。 */
autoAdd?: boolean
}
/**
* 组装单条结果,缺省字段从已知的 FileStatus 回填。
*/
function item(
filePath: string,
serverPath: string | null,
action: PendAction,
reason: PendReason,
message: string,
status?: FileStatus,
): PendItemResult {
return {
path: filePath,
serverPath: serverPath ?? status?.serverPath ?? null,
action,
reason,
mapped: status?.mapped ?? false,
pendingEdit: status?.pendingEdit ?? false,
localVersion: status?.localVersion ?? 0,
latestVersion: status?.latestVersion ?? 0,
upToDate: status?.upToDate ?? false,
message,
}
}
/**
* 连接预检:返回 null 表示后端可用,否则返回给所有路径的统一失败结果。
*/
async function connectionGuard(paths: string[]): Promise<PendItemResult[] | null> {
const health = await tfsClient.health()
if (health.ok) {
return null
}
return paths.map((p) =>
item(
p,
null,
"error",
"notConnected",
`连不上本地 MacTFS 服务(${health.errorMessage ?? "服务未运行"})。请确认 MacTFS 客户端已打开。`,
),
)
}
/** 读取单个本地文件状态,失败时返回 null(调用方据此产出 error 结果)。 */
async function loadStatus(localPath: string): Promise<FileStatus | { error: string }> {
const resp = await tfsClient.fileStatus(localPath)
if (!resp.ok || !resp.data || !resp.data.status) {
return { error: resp.errorMessage ?? "未知错误" }
}
return resp.data.status
}
/**
* 真正执行签出(pendEdit)。checkoutArg 可为本地绝对路径或服务端路径。
*/
async function doCheckout(
checkoutArg: string,
serverPath: string | null,
status?: FileStatus,
): Promise<PendItemResult> {
const resp = await tfsClient.checkout([checkoutArg], false)
if (!resp.ok || !resp.data) {
return item(checkoutArg, serverPath, "error", "error", `签出失败:${resp.errorMessage ?? "未知错误"}`, status)
}
const result = resp.data.result
if (result.failures && result.failures.length > 0) {
return item(checkoutArg, serverPath, "error", "error", `签出未成功:${result.failures.join(";")}`, status)
}
return item(checkoutArg, serverPath, "checkedOut", "ok", `已签出(影响 ${result.affected} 项),可以开始修改。`, status)
}
/**
* 真正执行加入版本控制(pendAdd),只接受本地路径。
*/
async function doAdd(localPath: string, status: FileStatus): Promise<PendItemResult> {
const resp = await tfsClient.add([localPath], false)
if (!resp.ok || !resp.data) {
return item(localPath, status.serverPath, "error", "error", `加入版本控制失败:${resp.errorMessage ?? "未知错误"}`, status)
}
const result = resp.data.result
if (result.failures && result.failures.length > 0) {
return item(localPath, status.serverPath, "error", "error", `加入未成功:${result.failures.join(";")}`, status)
}
return item(localPath, status.serverPath, "added", "ok", `已加入版本控制(影响 ${result.affected} 项),签入后服务器生效。`, status)
}
/**
* 对单个路径跑「签出」校验流水线:
* 映射 → 已签出 → 新文件 → 未下载 → 是否落后 → 真正签出。
*/
async function checkoutOne(raw: string, options: PendOptions): Promise<PendItemResult> {
if (raw.startsWith("$/")) {
if (options.dryRun) {
return item(raw, raw, "skipped", "ok", "服务端路径,dryRun 跳过实际签出。")
}
return doCheckout(raw, raw)
}
const localPath = path.resolve(raw)
const status = await loadStatus(localPath)
if ("error" in status) {
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
}
if (!status.mapped) {
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无需签出。", status)
}
if (status.pendingEdit) {
return item(localPath, status.serverPath, "alreadyCheckedOut", "ok", "文件已处于签出状态,可直接修改。", status)
}
if (!status.serverExists) {
if (options.autoAdd === false) {
return item(localPath, status.serverPath, "skipped", "newFileNeedsAdd", "这是服务端尚不存在的新文件,请改用 tfs_add 加入版本控制。", status)
}
if (!status.exists) {
return item(localPath, status.serverPath, "skipped", "notExist", "这是新文件但本地不存在;请先创建文件再加入版本控制。", status)
}
if (status.pendingChangeType === "pendingAdd") {
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:这是新文件,将自动加入版本控制(dryRun 未真正执行)。", status)
}
return doAdd(localPath, status)
}
if (!status.exists || status.status === "notDownloaded") {
return item(localPath, status.serverPath, "skipped", "notDownloaded", "本地尚未下载该文件,请先获取最新。", status)
}
const stale = !status.upToDate || status.status === "remoteChanged" || status.status === "bothChanged"
const autoGetLatest = options.getLatestIfStale !== false
if (stale && !autoGetLatest) {
return item(
localPath,
status.serverPath,
"skipped",
"stale",
`本地版本(${status.localVersion})落后于服务器最新(${status.latestVersion})。已按 getLatestIfStale=false 跳过;若要在旧版本上修改请自行处理。`,
status,
)
}
if (stale) {
if (options.dryRun) {
return item(
localPath,
status.serverPath,
"skipped",
"ok",
`校验通过:本地落后(${status.localVersion}→${status.latestVersion}),将先自动获取最新再签出(dryRun 未真正执行)。`,
status,
)
}
const gl = await tfsClient.getLatest(status.serverPath ?? undefined, false)
if (!gl.ok) {
return item(localPath, status.serverPath, "error", "stale", `自动获取最新失败:${gl.errorMessage ?? "未知错误"}`, status)
}
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可签出(dryRun 未真正执行)。", status)
}
return doCheckout(localPath, status.serverPath, status)
}
/**
* 对单个路径跑「加入版本控制」校验流水线:
* 映射 → 本地存在 → 已加入 → 是否已受控(应改签出) → 真正 add。
*/
async function addOne(raw: string, options: PendOptions): Promise<PendItemResult> {
const localPath = path.resolve(raw)
const status = await loadStatus(localPath)
if ("error" in status) {
return item(localPath, null, "error", "error", `查询文件状态失败:${status.error}`)
}
if (!status.mapped) {
return item(localPath, status.serverPath, "skipped", "notMapped", "该文件不在任何 TFS 映射目录下,无法加入版本控制。", status)
}
if (!status.exists) {
return item(localPath, status.serverPath, "skipped", "notExist", "本地文件不存在;请先创建文件再加入版本控制。", status)
}
if (status.pendingChangeType === "pendingAdd") {
return item(localPath, status.serverPath, "alreadyAdded", "ok", "文件已挂起新增,无需重复加入。", status)
}
if (status.serverExists) {
return item(localPath, status.serverPath, "skipped", "alreadyTracked", "该文件已在版本控制中;若要修改请用 tfs_checkout 签出,而非加入。", status)
}
if (options.dryRun) {
return item(localPath, status.serverPath, "skipped", "ok", "校验通过:可加入版本控制(dryRun 未真正执行)。", status)
}
return doAdd(localPath, status)
}
/**
* tfs_checkout 主流程。
*/
export async function runCheckout(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
if (paths.length === 0) {
return []
}
const guard = await connectionGuard(paths)
if (guard) {
return guard
}
const results: PendItemResult[] = []
for (const raw of paths) {
results.push(await checkoutOne(raw, options))
}
return results
}
/**
* tfs_add 主流程。
*/
export async function runAdd(rawPaths: string[], options: PendOptions): Promise<PendItemResult[]> {
const paths = rawPaths.map((p) => p.trim()).filter((p) => p.length > 0)
if (paths.length === 0) {
return []
}
const guard = await connectionGuard(paths)
if (guard) {
return guard
}
const results: PendItemResult[] = []
for (const raw of paths) {
results.push(await addOne(raw, options))
}
return results
}
const ACTION_TAGS: Record<PendAction, string> = {
checkedOut: "✓ 已签出",
alreadyCheckedOut: "✓ 已签出(无需重复)",
added: "✓ 已加入",
alreadyAdded: "✓ 已加入(无需重复)",
skipped: "• 跳过",
error: "✗ 失败",
}
/**
* 把结构化结果汇总成一段人话 summary,方便 AI 与人快速读懂。
*/
export function summarize(results: PendItemResult[]): string {
if (results.length === 0) {
return "未提供任何文件路径。"
}
return results.map((r) => `${ACTION_TAGS[r.action]} ${r.path} — ${r.message}`).join("\n")
}
+60
View File
@@ -0,0 +1,60 @@
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
/** 读取整数环境变量,非法或缺省时回退默认值。 */
function intEnv(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) ? value : fallback
}
// MacTFS 本地 Java 服务地址,与 Electron / 前端约定一致(127.0.0.1:38765)。
export const TFS_API_BASE_URL =
process.env.MACTFS_API_BASE_URL ?? "http://127.0.0.1:38765"
// MCP SSE 监听地址,AI 客户端通过 http://127.0.0.1:38766/sse 连接。
export const MCP_HOST = process.env.MACTFS_MCP_HOST ?? "127.0.0.1"
export const MCP_PORT = intEnv("MACTFS_MCP_PORT", 38766)
// 本地 Bearer Token 文件,与 Java 服务、Electron 复用同一份。
export const TOKEN_FILE =
process.env.MACTFS_TOKEN_FILE ?? path.join(os.homedir(), ".mactfs", "server-token")
// 启动时等待 Java 后端就绪的最大轮询次数与间隔(毫秒)。
export const HEALTH_WAIT_TRIES = intEnv("MACTFS_MCP_HEALTH_TRIES", 60)
export const HEALTH_WAIT_INTERVAL_MS = intEnv("MACTFS_MCP_HEALTH_INTERVAL_MS", 1000)
// 父进程(Electron)pid:用于「父死则自杀」的看护,避免遗留孤儿进程。
export const PARENT_PID = (() => {
const raw = process.env.MACTFS_PARENT_PID
if (!raw) {
return null
}
const value = Number.parseInt(raw, 10)
return Number.isFinite(value) && value > 0 ? value : null
})()
// HTTP 调用 Java 服务的默认超时(毫秒)。
export const HTTP_TIMEOUT_MS = intEnv("MACTFS_MCP_HTTP_TIMEOUT_MS", 30000)
// 是否要求 AI 客户端连接 SSE 时携带 token。
// 默认关闭:已有「仅监听 127.0.0.1 + 随客户端生命周期绑定」两道保护,AI 端无需配置 token。
// 设 MACTFS_MCP_REQUIRE_TOKEN=1 可开启更严格的本机鉴权。
export const MCP_REQUIRE_TOKEN = (process.env.MACTFS_MCP_REQUIRE_TOKEN ?? "").trim() === "1"
/**
* 读取本地 Bearer Token,文件缺失或为空时返回 null。
* Token 由 Java 服务在 ~/.mactfs/server-token 生成。
*/
export function readToken(): string | null {
try {
const value = fs.readFileSync(TOKEN_FILE, "utf8").trim()
return value.length > 0 ? value : null
} catch {
return null
}
}
+6
View File
@@ -0,0 +1,6 @@
import { startServer } from "./server.js"
startServer().catch((error) => {
process.stderr.write(`[mactfs-mcp] fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
process.exit(1)
})
+218
View File
@@ -0,0 +1,218 @@
import express from "express"
import type { Request } from "express"
import { z } from "zod"
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"
import {
HEALTH_WAIT_INTERVAL_MS,
HEALTH_WAIT_TRIES,
MCP_HOST,
MCP_PORT,
MCP_REQUIRE_TOKEN,
PARENT_PID,
readToken,
} from "./config.js"
import { runAdd, runCheckout, summarize } from "./checkout.js"
import { tfsClient } from "./tfs-client.js"
const SERVER_NAME = "mactfs-mcp"
const SERVER_VERSION = "0.1.0"
/** 简单延时。 */
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** stderr 日志,SSE 走 HTTP,stdout 可用但统一打 stderr 更安全。 */
function log(message: string): void {
process.stderr.write(`[mactfs-mcp] ${message}\n`)
}
/**
* 校验请求是否允许连接。
* 默认不强制 token(已有 127.0.0.1 + 客户端绑定两道保护);
* 仅当 MACTFS_MCP_REQUIRE_TOKEN=1 时,才要求 Authorization: Bearer 或 ?token=。
*/
function authorized(req: Request): boolean {
if (!MCP_REQUIRE_TOKEN) {
return true
}
const expected = readToken()
if (!expected) {
return true
}
const auth = req.headers["authorization"]
if (typeof auth === "string" && auth.startsWith("Bearer ") && auth.slice(7).trim() === expected) {
return true
}
const queryToken = req.query.token
if (typeof queryToken === "string" && queryToken === expected) {
return true
}
return false
}
/**
* 创建一个 MCP server 实例并注册工具。每个 SSE 连接用独立实例,避免跨连接状态串扰。
*/
function createMcpServer(): McpServer {
const mcp = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION })
mcp.registerTool(
"tfs_checkout",
{
title: "TFS 签出文件",
description:
"在修改受 TFS 版本控制的文件之前调用本工具签出(checkout / pendEdit)。" +
"会先做执行前校验:是否在映射目录内、是否已签出、是否为新文件、本地是否落后于服务器最新版本。" +
"凡是即将编辑、且位于 MacTFS 映射目录下的已有文件,都应先调用本工具。" +
"默认行为:遇到服务端尚不存在的新文件会自动加入版本控制(add);本地版本落后时会自动先获取最新再签出。" +
"返回每个文件的处理结果(checkedOut / alreadyCheckedOut / added / alreadyAdded / skipped / error)。",
inputSchema: {
paths: z
.array(z.string())
.min(1)
.describe("要签出的文件本地绝对路径列表(也兼容 $/ 开头的服务端路径)"),
dryRun: z
.boolean()
.optional()
.describe("只做执行前校验、不真正签出/加入/获取最新,用于预检"),
getLatestIfStale: z
.boolean()
.optional()
.describe("当本地版本落后于服务器时,自动先获取最新再签出(默认 true;传 false 则仅警告、不自动获取)"),
autoAdd: z
.boolean()
.optional()
.describe("遇到服务端尚不存在的新文件时,自动加入版本控制(add)(默认 true;传 false 则仅提示改用 tfs_add)"),
},
},
async ({ paths, dryRun, getLatestIfStale, autoAdd }) => {
const results = await runCheckout(paths, { dryRun, getLatestIfStale, autoAdd })
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
return { content: [{ type: "text" as const, text }] }
},
)
mcp.registerTool(
"tfs_add",
{
title: "TFS 加入版本控制",
description:
"为 MacTFS 映射目录下、尚未纳入版本控制的【新建文件】执行加入(add / pendAdd)。" +
"新建文件请在写入磁盘后调用本工具(此时文件已存在于本地)。" +
"会先做执行前校验:是否在映射目录内、本地是否存在、是否已加入、是否其实已受控(已受控应改用 tfs_checkout)。" +
"返回每个文件的处理结果(added / alreadyAdded / skipped / error)。",
inputSchema: {
paths: z
.array(z.string())
.min(1)
.describe("要加入版本控制的文件本地绝对路径列表"),
dryRun: z
.boolean()
.optional()
.describe("只做执行前校验、不真正加入,用于预检"),
},
},
async ({ paths, dryRun }) => {
const results = await runAdd(paths, { dryRun })
const text = `${summarize(results)}\n\n${JSON.stringify({ results }, null, 2)}`
return { content: [{ type: "text" as const, text }] }
},
)
return mcp
}
/**
* 启动「父进程(Electron)看护」:父进程消失则自杀,避免遗留孤儿。
*/
function startParentWatchdog(): void {
const pid = PARENT_PID
if (pid === null) {
return
}
log(`watching parent pid ${pid}`)
setInterval(() => {
try {
process.kill(pid, 0)
} catch {
log("parent process gone, exiting")
process.exit(0)
}
}, 3000)
}
/**
* 等待 Java 后端 health 就绪;超过上限仍未就绪则降级继续(工具会返回 notConnected)。
*/
async function waitForBackend(): Promise<boolean> {
for (let attempt = 0; attempt < HEALTH_WAIT_TRIES; attempt += 1) {
const health = await tfsClient.health()
if (health.ok) {
return true
}
await delay(HEALTH_WAIT_INTERVAL_MS)
}
return false
}
/**
* 启动 MCP SSE 服务:等待后端、注册路由、监听端口、挂看护。
*/
export async function startServer(): Promise<void> {
startParentWatchdog()
log("waiting for MacTFS backend ...")
const ready = await waitForBackend()
if (ready) {
log("backend ready")
} else {
log("backend not ready after wait, starting in degraded mode")
}
const app = express()
const transports: Record<string, SSEServerTransport> = {}
app.get("/sse", async (req, res) => {
if (!authorized(req)) {
res.status(401).json({ error: "unauthorized" })
return
}
const transport = new SSEServerTransport("/messages", res)
transports[transport.sessionId] = transport
res.on("close", () => {
delete transports[transport.sessionId]
})
const mcp = createMcpServer()
try {
await mcp.connect(transport)
} catch (error) {
log(`sse connect error: ${error instanceof Error ? error.message : String(error)}`)
}
})
app.post("/messages", async (req, res) => {
if (!authorized(req)) {
res.status(401).json({ error: "unauthorized" })
return
}
const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : ""
const transport = transports[sessionId]
if (!transport) {
res.status(400).json({ error: "no transport for sessionId" })
return
}
await transport.handlePostMessage(req, res)
})
app.get("/healthz", (_req, res) => {
res.json({ ok: true, name: SERVER_NAME, version: SERVER_VERSION })
})
app.listen(MCP_PORT, MCP_HOST, () => {
log(`SSE listening on http://${MCP_HOST}:${MCP_PORT}/sse`)
})
}
+99
View File
@@ -0,0 +1,99 @@
import { HTTP_TIMEOUT_MS, TFS_API_BASE_URL, readToken } from "./config.js"
import type {
ApiResponse,
ApiResult,
FileOperationResult,
FileStatus,
HealthData,
} from "./types.js"
/**
* 发起一次对 Java 服务的请求,自动带 Bearer Token、统一封装结果与错误。
* 任何网络/解析失败都收敛成 { ok:false },绝不抛异常,保证 MCP 不被打断。
*/
async function request<T>(
method: string,
path: string,
options: { query?: Record<string, string | undefined>; body?: unknown } = {},
): Promise<ApiResponse<T>> {
const token = readToken()
const url = new URL(path, TFS_API_BASE_URL)
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value)
}
}
}
const headers: Record<string, string> = { Accept: "application/json" }
if (token) {
headers.Authorization = `Bearer ${token}`
}
const init: RequestInit = { method, headers }
if (options.body !== undefined) {
headers["Content-Type"] = "application/json;charset=utf-8"
init.body = JSON.stringify(options.body)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS)
init.signal = controller.signal
let response: Response
try {
response = await fetch(url, init)
} catch (error) {
clearTimeout(timer)
const message = error instanceof Error ? error.message : String(error)
return { ok: false, status: 0, data: null, errorMessage: `无法连接本地服务:${message}` }
}
clearTimeout(timer)
let result: ApiResult<T> | null = null
try {
result = (await response.json()) as ApiResult<T>
} catch {
result = null
}
const ok = response.ok && result?.success === true
return {
ok,
status: response.status,
data: result?.data ?? null,
errorMessage: ok
? null
: result?.errorMessage || result?.message || `请求失败(HTTP ${response.status})`,
}
}
// 统一的 Java 服务客户端,MCP 只通过本对象访问后端。
export const tfsClient = {
/** 健康检查,返回 { connected } 等服务状态。 */
health() {
return request<HealthData>("GET", "/api/health")
},
/** 查询单个本地文件的 TFS 状态(依赖后端新增的 /api/files/status)。 */
fileStatus(localPath: string) {
return request<{ status: FileStatus }>("GET", "/api/files/status", { query: { localPath } })
},
/** 对给定路径执行签出(pendEdit),paths 可为本地绝对路径或服务端路径。 */
checkout(paths: string[], recursive = false) {
return request<{ result: FileOperationResult }>("POST", "/api/files/checkout", {
body: { paths, recursive },
})
},
/** 将本地新增文件加入 pending add,paths 为本地绝对路径。 */
add(paths: string[], recursive = false) {
return request<{ result: FileOperationResult }>("POST", "/api/files/add", {
body: { paths, recursive },
})
},
/** 获取最新(用于 getLatestIfStale 场景),serverPath 缺省按当前 Workspace 全量。 */
getLatest(serverPath: string | undefined, recursive = false) {
return request<{ result: unknown }>("POST", "/api/files/get-latest", {
body: { serverPath, recursive, force: false },
})
},
}
+77
View File
@@ -0,0 +1,77 @@
// 与 Java 后端 ApiResult 对齐的统一响应外壳。
export interface ApiResult<T = Record<string, unknown>> {
success: boolean
message?: string
errorMessage?: string
operation?: string
data: T
}
// HTTP 客户端对外返回结构,统一封装网络层与业务层。
export interface ApiResponse<T = Record<string, unknown>> {
ok: boolean
status: number
data: T | null
errorMessage: string | null
}
// /api/health 返回的 data 字段。
export interface HealthData {
status?: string
connected?: boolean
}
// /api/files/status(新增)返回的单文件状态。
export interface FileStatus {
localPath: string
serverPath: string | null
mapped: boolean
exists: boolean
serverExists: boolean
pendingEdit: boolean
pendingChangeType: string | null
localVersion: number
latestVersion: number
upToDate: boolean
status: string
}
// /api/files/checkout 返回的 result 字段。
export interface FileOperationResult {
operation: string
affected: number
failures: string[]
}
// tfs_checkout / tfs_add 对每个路径返回的结构化结果(共用一套结构)。
export type PendAction =
| "checkedOut"
| "alreadyCheckedOut"
| "added"
| "alreadyAdded"
| "skipped"
| "error"
export type PendReason =
| "ok"
| "notConnected"
| "clientNotRunning"
| "notMapped"
| "newFileNeedsAdd"
| "notDownloaded"
| "stale"
| "notExist"
| "alreadyTracked"
| "error"
export interface PendItemResult {
path: string
serverPath: string | null
action: PendAction
reason: PendReason
mapped: boolean
pendingEdit: boolean
localVersion: number
latestVersion: number
upToDate: boolean
message: string
}