Connect server SVN repositories

This commit is contained in:
lwt
2026-06-18 14:35:58 +08:00
parent 72153127b6
commit 7650fd5bfb
6 changed files with 831 additions and 210 deletions
+6
View File
@@ -66,6 +66,8 @@ export SUMI_STORAGE_DIR="/var/lib/sumi-work"
export SUMI_DATA_DIR="/var/lib/sumi-work/data"
export SUMI_UPLOAD_DIR="/var/lib/sumi-work/uploads"
export SUMI_SVN_DIR="/var/lib/sumi-work/svn"
export SUMI_SVN_REPO_ROOTS="/data/tools/svn_server"
export SUMI_SVN_PUBLIC_BASE_URL="svn://服务器IP"
export MAX_UPLOAD_BYTES=1073741824
export PLAYER_SESSION_TTL_MS=604800000
```
@@ -80,6 +82,8 @@ export PLAYER_SESSION_TTL_MS=604800000
- `SUMI_DATA_DIR`:软件列表和玩家账号数据目录;设置后会覆盖 `SUMI_STORAGE_DIR/data`
- `SUMI_UPLOAD_DIR`:上传图标、安装包和 version.json 目录;设置后会覆盖 `SUMI_STORAGE_DIR/uploads`
- `SUMI_SVN_DIR`:SVN 工作副本和导出目录;设置后会覆盖 `SUMI_STORAGE_DIR/svn`
- `SUMI_SVN_REPO_ROOTS`:服务器已安装 SVN 服务的仓库根目录,多个目录用英文分号或逗号分隔,例如 `/data/tools/svn_server`
- `SUMI_SVN_PUBLIC_BASE_URL`:旧 SVN 服务对外访问地址,例如 `svn://43.136.76.224`;管理后台会用它生成旧仓库 URL
- `MAX_UPLOAD_BYTES`:最大上传文件大小,默认 1GB
- `PLAYER_SESSION_TTL_MS`:玩家登录有效期,默认 7 天
@@ -455,6 +459,8 @@ SVN 管理不可用:
- 检查服务器是否安装 `svn``svn --version --quiet`
- 检查 `SUMI_SVN_DIR``/var/lib/sumi-work/svn/` 是否可写
- 如果要接入服务器已有仓库,确认 `SUMI_SVN_REPO_ROOTS` 指向 `svnserve -r` 的根目录
- 确认 `SUMI_SVN_PUBLIC_BASE_URL` 是玩家/管理员能访问到的 SVN 地址
- 检查仓库 URL、账号、密码和证书信任设置
- 写操作需要玩家登录并提供正确的 `ADMIN_TOKEN`
+237 -193
View File
@@ -6,13 +6,13 @@
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#edf5ff" />
<title>sumi.work - SVN 管理</title>
<link rel="stylesheet" href="../assets/css/software.css?v=20260618-1" />
<link rel="stylesheet" href="../assets/css/software.css?v=20260618-3" />
</head>
<body>
<header class="soft-topbar">
<div class="soft-topbar-inner">
<a class="soft-brand" href="../software.html" aria-label="返回软件中心">
<img src="../assets/icons/software.svg" alt="" aria-hidden="true" />
<a class="soft-brand" href="svn.html" aria-label="返回 SVN 管理">
<img src="../assets/icons/svn.svg" alt="" aria-hidden="true" />
<span>SVN 管理</span>
</a>
<nav class="soft-nav" aria-label="SVN 管理导航">
@@ -24,235 +24,279 @@
</div>
</header>
<main class="admin-shell svn-shell">
<section class="admin-panel" aria-labelledby="svn-token-title">
<div class="section-title-row">
<main class="admin-shell svn-shell svn-github-shell">
<section class="admin-panel svn-hero" aria-labelledby="svn-token-title">
<div class="svn-hero-copy">
<p class="hero-kicker">SVN Repositories</p>
<div>
<h1 id="svn-token-title">SVN 控制台</h1>
<p class="admin-note">集中管理 SVN 仓库、工作副本、提交、更新、分支标签、合并、锁和属性</p>
<p class="admin-note">接入服务器现有 SVN 服务和旧仓库,在一个清晰的仓库视图里完成更新、提交、分支标签、合并和维护操作</p>
</div>
<button class="text-button" type="button" id="svn-health">检测 SVN</button>
</div>
<div class="token-row">
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
<button class="primary-action" type="button" id="save-token">保存令牌</button>
<div class="svn-hero-controls">
<div class="token-row">
<input id="admin-token" type="password" autocomplete="current-password" placeholder="输入 ADMIN_TOKEN" />
<button class="primary-action" type="button" id="save-token">保存令牌</button>
</div>
<div class="svn-toolbar-actions">
<button class="text-button" type="button" id="svn-health">检测 SVN</button>
<button class="text-button" type="button" id="reload-repos">刷新仓库</button>
<button class="secondary-action" type="button" id="sync-server-repos">接入旧仓库</button>
</div>
<p class="status-message" id="token-status"></p>
</div>
<p class="status-message" id="token-status"></p>
</section>
<div class="svn-console">
<div class="svn-github-layout">
<aside class="admin-panel svn-sidebar" aria-labelledby="repo-title">
<div class="section-title-row compact">
<div class="section-title-row compact svn-sidebar-title">
<div>
<h2 id="repo-title">仓库</h2>
<p id="repo-summary">正在加载...</p>
</div>
<button class="text-button" type="button" id="reload-repos">刷新</button>
</div>
<div class="svn-search">
<input id="repo-search" type="search" autocomplete="off" placeholder="搜索仓库、URL 或路径" />
</div>
<div class="svn-repo-list" id="repo-list"></div>
<div class="form-actions">
<button class="danger-button" type="button" id="delete-repo">删除选中配置</button>
</div>
<section class="svn-server-box" aria-labelledby="server-repo-title">
<div class="section-title-row compact">
<div>
<h3 id="server-repo-title">服务器 SVN 服务</h3>
<p id="server-summary">等待扫描...</p>
</div>
</div>
<div class="svn-server-list" id="server-repo-list"></div>
</section>
<details class="svn-config-details" id="repo-config-details">
<summary><span id="repo-form-title">新增仓库配置</span></summary>
<form id="repo-form">
<input type="hidden" name="id" />
<div class="field-grid">
<div class="field">
<label for="repo-name">仓库名称</label>
<input id="repo-name" name="name" required maxlength="120" placeholder="game-server" />
</div>
<div class="field">
<label for="checkout-path">工作副本目录</label>
<input id="checkout-path" name="checkoutPath" maxlength="200" placeholder="game-server" />
</div>
<div class="field full">
<label for="repo-url">仓库 URL</label>
<input id="repo-url" name="url" required maxlength="1000" placeholder="svn://服务器IP/project" />
</div>
<div class="field">
<label for="repo-username">用户名</label>
<input id="repo-username" name="username" autocomplete="username" maxlength="200" />
</div>
<div class="field">
<label for="repo-password">密码</label>
<input id="repo-password" name="password" type="password" autocomplete="current-password" placeholder="留空保持原密码" />
</div>
<div class="field">
<label for="trunk-path">Trunk</label>
<input id="trunk-path" name="trunkPath" value="trunk" maxlength="200" />
</div>
<div class="field">
<label for="branches-path">Branches</label>
<input id="branches-path" name="branchesPath" value="branches" maxlength="200" />
</div>
<div class="field">
<label for="tags-path">Tags</label>
<input id="tags-path" name="tagsPath" value="tags" maxlength="200" />
</div>
<div class="field full">
<label for="repo-description">备注</label>
<textarea id="repo-description" name="description" maxlength="500"></textarea>
</div>
</div>
<div class="checkbox-row">
<label class="checkbox-field"><input name="trustServerCert" type="checkbox" checked /> 信任证书</label>
<label class="checkbox-field"><input name="noAuthCache" type="checkbox" checked /> 不缓存凭据</label>
</div>
<div class="form-actions">
<button class="primary-action" type="submit" id="repo-submit">保存</button>
<button class="secondary-action" type="button" id="repo-reset">清空</button>
</div>
<p class="status-message" id="repo-form-status"></p>
</form>
</details>
</aside>
<section class="admin-panel svn-form-panel" aria-labelledby="repo-form-title">
<h2 id="repo-form-title">新增仓库</h2>
<form id="repo-form">
<input type="hidden" name="id" />
<div class="field-grid">
<div class="field">
<label for="repo-name">仓库名称</label>
<input id="repo-name" name="name" required maxlength="120" placeholder="game-server" />
</div>
<div class="field">
<label for="checkout-path">本地目录</label>
<input id="checkout-path" name="checkoutPath" maxlength="200" placeholder="game-server" />
</div>
<div class="field full">
<label for="repo-url">仓库 URL</label>
<input id="repo-url" name="url" required maxlength="1000" placeholder="https://svn.example.com/project" />
</div>
<div class="field">
<label for="repo-username">用户名</label>
<input id="repo-username" name="username" autocomplete="username" maxlength="200" />
</div>
<div class="field">
<label for="repo-password">密码</label>
<input id="repo-password" name="password" type="password" autocomplete="current-password" placeholder="留空则保持原密码" />
</div>
<div class="field">
<label for="trunk-path">Trunk 目录</label>
<input id="trunk-path" name="trunkPath" value="trunk" maxlength="200" />
</div>
<div class="field">
<label for="branches-path">Branches 目录</label>
<input id="branches-path" name="branchesPath" value="branches" maxlength="200" />
</div>
<div class="field">
<label for="tags-path">Tags 目录</label>
<input id="tags-path" name="tagsPath" value="tags" maxlength="200" />
</div>
<div class="field full">
<label for="repo-description">备注</label>
<textarea id="repo-description" name="description" maxlength="500"></textarea>
</div>
<section class="admin-panel svn-repository-view" aria-labelledby="selected-repo-name">
<div class="svn-repo-header">
<div>
<p class="hero-kicker">Repository</p>
<h1 id="selected-repo-name">选择仓库</h1>
<p id="selected-repo">请选择一个 SVN 仓库。</p>
</div>
<div class="checkbox-row">
<label class="checkbox-field"><input name="trustServerCert" type="checkbox" checked /> 自动信任服务端证书</label>
<label class="checkbox-field"><input name="noAuthCache" type="checkbox" checked /> 不缓存 SVN 凭据</label>
<div class="svn-header-actions">
<button class="danger-button" type="button" id="delete-repo">删除配置</button>
</div>
<div class="form-actions">
<button class="primary-action" type="submit" id="repo-submit">保存仓库</button>
<button class="secondary-action" type="button" id="repo-reset">清空</button>
</div>
<p class="status-message" id="repo-form-status"></p>
</form>
</section>
</div>
<section class="admin-panel svn-workbench" aria-labelledby="workbench-title">
<div class="section-title-row">
<div>
<h2 id="workbench-title">工作副本</h2>
<p id="selected-repo">请选择一个 SVN 仓库。</p>
</div>
<div class="svn-action-row">
<div class="svn-repo-meta-grid">
<div>
<span>SVN URL</span>
<strong id="selected-repo-url">--</strong>
</div>
<div>
<span>工作副本</span>
<strong id="selected-repo-path">--</strong>
</div>
<div>
<span>服务端版本</span>
<strong id="selected-repo-revision">--</strong>
</div>
<div>
<span>来源</span>
<strong id="selected-repo-source">--</strong>
</div>
</div>
<div class="svn-quick-actions" aria-label="常用 SVN 操作">
<button class="primary-action" type="button" data-action="checkout">Checkout</button>
<button class="text-button" type="button" data-action="update">Update</button>
<button class="text-button" type="button" data-action="info">Info</button>
<button class="text-button" type="button" data-action="status">Status</button>
<button class="text-button" type="button" data-action="log">Log</button>
<button class="text-button" type="button" data-action="diff">Diff</button>
<button class="text-button" type="button" data-action="list">Files</button>
</div>
</div>
<div class="svn-tabs" role="tablist" aria-label="SVN 操作分类">
<button class="svn-tab is-active" type="button" data-tab="daily">日常操作</button>
<button class="svn-tab" type="button" data-tab="history">历史差异</button>
<button class="svn-tab" type="button" data-tab="branch">分支标签</button>
<button class="svn-tab" type="button" data-tab="advanced">高级维护</button>
</div>
<div class="svn-tabs" role="tablist" aria-label="SVN 操作分类">
<button class="svn-tab is-active" type="button" data-tab="daily">日常</button>
<button class="svn-tab" type="button" data-tab="history">历史与文件</button>
<button class="svn-tab" type="button" data-tab="branch">分支标签</button>
<button class="svn-tab" type="button" data-tab="advanced">维护</button>
</div>
<div class="svn-tab-panel is-active" data-panel="daily">
<div class="svn-command-grid">
<button type="button" data-action="checkout">Checkout</button>
<button type="button" data-action="update">Update</button>
<button type="button" data-action="cleanup">Cleanup</button>
<button type="button" data-action="revert">Revert</button>
<button type="button" data-action="resolve">Resolve</button>
<button type="button" data-action="commit">Commit</button>
<button type="button" data-action="add">Add</button>
<button type="button" data-action="delete">Delete</button>
<button type="button" data-action="lock">Lock</button>
<button type="button" data-action="unlock">Unlock</button>
<div class="svn-tab-panel is-active" data-panel="daily">
<div class="svn-command-grid">
<button type="button" data-action="cleanup">Cleanup</button>
<button type="button" data-action="revert">Revert</button>
<button type="button" data-action="resolve">Resolve</button>
<button type="button" data-action="commit">Commit</button>
<button type="button" data-action="add">Add</button>
<button type="button" data-action="delete">Delete</button>
<button type="button" data-action="lock">Lock</button>
<button type="button" data-action="unlock">Unlock</button>
</div>
</div>
</div>
<div class="svn-tab-panel" data-panel="history">
<div class="svn-command-grid">
<button type="button" data-action="list">List</button>
<button type="button" data-action="blame">Blame</button>
<button type="button" data-action="propget">Propget</button>
<button type="button" data-action="propset">Propset</button>
<button type="button" data-action="ignore">Ignore</button>
<button type="button" data-action="export">Export</button>
<div class="svn-tab-panel" data-panel="history">
<div class="svn-command-grid">
<button type="button" data-action="blame">Blame</button>
<button type="button" data-action="propget">Propget</button>
<button type="button" data-action="propset">Propset</button>
<button type="button" data-action="ignore">Ignore</button>
<button type="button" data-action="export">Export</button>
</div>
</div>
</div>
<div class="svn-tab-panel" data-panel="branch">
<div class="svn-command-grid">
<button type="button" data-action="branch">Create Branch</button>
<button type="button" data-action="tag">Create Tag</button>
<button type="button" data-action="switch">Switch</button>
<button type="button" data-action="merge">Merge</button>
<button type="button" data-action="copy">Copy</button>
<button type="button" data-action="move">Move</button>
<div class="svn-tab-panel" data-panel="branch">
<div class="svn-command-grid">
<button type="button" data-action="branch">Create Branch</button>
<button type="button" data-action="tag">Create Tag</button>
<button type="button" data-action="switch">Switch</button>
<button type="button" data-action="merge">Merge</button>
<button type="button" data-action="copy">Copy</button>
<button type="button" data-action="move">Move</button>
</div>
</div>
</div>
<div class="svn-tab-panel" data-panel="advanced">
<div class="svn-command-grid">
<button type="button" data-action="mkdir">Mkdir</button>
<button type="button" data-action="relocate">Relocate</button>
<div class="svn-tab-panel" data-panel="advanced">
<div class="svn-command-grid">
<button type="button" data-action="mkdir">Mkdir</button>
<button type="button" data-action="relocate">Relocate</button>
</div>
</div>
</div>
<form class="svn-action-form" id="action-form">
<div class="field-grid">
<div class="field">
<label for="action-target">目标路径</label>
<input id="action-target" name="target" placeholder="相对工作副本路径,默认 ." />
<form class="svn-action-form" id="action-form">
<div class="field-grid">
<div class="field">
<label for="action-target">路径</label>
<input id="action-target" name="target" placeholder="相对工作副本路径,默认 ." />
</div>
<div class="field">
<label for="action-revision">版本</label>
<input id="action-revision" name="revision" placeholder="123 或 100:HEAD" />
</div>
<div class="field">
<label for="action-source">来源</label>
<input id="action-source" name="source" placeholder="URL 或相对路径" />
</div>
<div class="field">
<label for="action-destination">目标/导出目录</label>
<input id="action-destination" name="destination" placeholder="URL、相对路径或导出目录名" />
</div>
<div class="field">
<label for="action-url">切换/迁移 URL</label>
<input id="action-url" name="url" placeholder="switch 或 relocate 目标 URL" />
</div>
<div class="field">
<label for="action-name">分支/标签名</label>
<input id="action-name" name="name" placeholder="release-1.0.0" />
</div>
<div class="field">
<label for="action-property">属性名</label>
<input id="action-property" name="property" placeholder="svn:ignore" />
</div>
<div class="field">
<label for="action-pattern">忽略规则/属性值</label>
<input id="action-pattern" name="pattern" placeholder="*.log 或 dist" />
</div>
<div class="field">
<label for="action-limit">日志数量</label>
<input id="action-limit" name="limit" inputmode="numeric" placeholder="30" />
</div>
<div class="field">
<label for="action-depth">Depth</label>
<select id="action-depth" name="depth">
<option value="">默认</option>
<option value="empty">empty</option>
<option value="files">files</option>
<option value="immediates">immediates</option>
<option value="infinity">infinity</option>
</select>
</div>
<div class="field">
<label for="action-accept">冲突策略</label>
<select id="action-accept" name="accept">
<option value="working">working</option>
<option value="mine-conflict">mine-conflict</option>
<option value="theirs-conflict">theirs-conflict</option>
<option value="base">base</option>
</select>
</div>
<div class="field full">
<label for="action-message">提交说明</label>
<textarea id="action-message" name="message" placeholder="commit、branch、tag、lock 等操作需要提交说明"></textarea>
</div>
</div>
<div class="field">
<label for="action-revision">版本范围</label>
<input id="action-revision" name="revision" placeholder="123 或 100:HEAD" />
<div class="checkbox-row">
<label class="checkbox-field"><input name="recursive" type="checkbox" checked /> 递归</label>
<label class="checkbox-field"><input name="dryRun" type="checkbox" /> Merge dry-run</label>
<label class="checkbox-field"><input name="force" type="checkbox" checked /> 强制/覆盖</label>
<label class="checkbox-field"><input name="verbose" type="checkbox" /> Log verbose</label>
<label class="checkbox-field"><input name="showUpdates" type="checkbox" /> Status show-updates</label>
</div>
<div class="field">
<label for="action-source">来源</label>
<input id="action-source" name="source" placeholder="URL 或相对路径" />
</div>
<div class="field">
<label for="action-destination">目标/导出目录</label>
<input id="action-destination" name="destination" placeholder="URL、相对路径或导出目录名" />
</div>
<div class="field">
<label for="action-url">切换/迁移 URL</label>
<input id="action-url" name="url" placeholder="switch 或 relocate 目标 URL" />
</div>
<div class="field">
<label for="action-name">分支/标签名</label>
<input id="action-name" name="name" placeholder="release-1.0.0" />
</div>
<div class="field">
<label for="action-property">属性名</label>
<input id="action-property" name="property" placeholder="svn:ignore" />
</div>
<div class="field">
<label for="action-pattern">忽略规则/属性值</label>
<input id="action-pattern" name="pattern" placeholder="*.log 或 dist" />
</div>
<div class="field">
<label for="action-depth">Depth</label>
<select id="action-depth" name="depth">
<option value="">默认</option>
<option value="empty">empty</option>
<option value="files">files</option>
<option value="immediates">immediates</option>
<option value="infinity">infinity</option>
</select>
</div>
<div class="field">
<label for="action-accept">冲突接受策略</label>
<select id="action-accept" name="accept">
<option value="working">working</option>
<option value="mine-conflict">mine-conflict</option>
<option value="theirs-conflict">theirs-conflict</option>
<option value="base">base</option>
</select>
</div>
<div class="field full">
<label for="action-message">提交说明</label>
<textarea id="action-message" name="message" placeholder="commit、branch、tag、lock 等操作需要提交说明"></textarea>
</div>
</div>
<div class="checkbox-row">
<label class="checkbox-field"><input name="recursive" type="checkbox" checked /> 递归</label>
<label class="checkbox-field"><input name="dryRun" type="checkbox" /> Merge dry-run</label>
<label class="checkbox-field"><input name="force" type="checkbox" checked /> 强制/覆盖</label>
<label class="checkbox-field"><input name="verbose" type="checkbox" /> Log verbose</label>
<label class="checkbox-field"><input name="showUpdates" type="checkbox" /> Status show-updates</label>
</div>
</form>
</form>
<div class="svn-output-toolbar">
<div>
<strong id="last-action">等待操作</strong>
<span id="last-command"></span>
<div class="svn-output-toolbar">
<div>
<strong id="last-action">等待操作</strong>
<span id="last-command"></span>
</div>
<button class="text-button" type="button" id="clear-output">清空输出</button>
</div>
<button class="text-button" type="button" id="clear-output">清空输出</button>
</div>
<pre class="svn-output" id="svn-output" aria-live="polite"></pre>
</section>
<pre class="svn-output" id="svn-output" aria-live="polite"></pre>
</section>
</div>
</main>
<script src="../assets/js/svn-admin.js?v=20260618-1"></script>
<script src="../assets/js/svn-admin.js?v=20260618-3"></script>
</body>
</html>
+247 -6
View File
@@ -915,6 +915,46 @@ main,
gap: 18px;
}
.svn-github-shell {
width: min(1280px, calc(100% - 32px));
}
.svn-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 520px);
gap: 24px;
align-items: center;
}
.svn-hero-copy h1 {
margin-bottom: 10px;
color: #0f172a;
font-size: clamp(34px, 4vw, 48px);
line-height: 1.08;
letter-spacing: 0;
}
.svn-hero-controls {
display: grid;
gap: 12px;
}
.svn-toolbar-actions,
.svn-header-actions,
.svn-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.svn-github-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.svn-console {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
@@ -923,14 +963,44 @@ main,
.svn-sidebar,
.svn-form-panel,
.svn-workbench {
.svn-workbench,
.svn-repository-view {
min-width: 0;
}
.svn-sidebar {
position: sticky;
top: 106px;
display: grid;
max-height: calc(100svh - 128px);
overflow: auto;
gap: 16px;
}
.svn-sidebar-title {
margin-bottom: 0;
}
.section-title-row.compact {
margin-bottom: 14px;
}
.svn-search input {
width: 100%;
min-height: 42px;
padding: 0 12px;
color: var(--text);
background: #f6f8fb;
border: 1px solid var(--line);
border-radius: 6px;
outline: 0;
}
.svn-search input:focus {
border-color: rgba(32, 73, 238, 0.28);
box-shadow: 0 0 0 3px rgba(32, 73, 238, 0.08);
}
.svn-repo-list {
display: grid;
gap: 8px;
@@ -939,9 +1009,9 @@ main,
.svn-repo-item {
display: grid;
width: 100%;
min-height: 86px;
min-height: 92px;
gap: 5px;
padding: 12px;
padding: 14px;
color: inherit;
text-align: left;
background: #fbfcff;
@@ -972,6 +1042,152 @@ main,
white-space: nowrap;
}
.svn-server-box,
.svn-config-details {
padding-top: 14px;
border-top: 1px solid var(--line);
}
.svn-server-box h3 {
margin: 0;
color: #111827;
font-size: 16px;
}
.svn-server-list {
display: grid;
gap: 8px;
}
.svn-server-repo {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
color: inherit;
text-align: left;
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-server-repo:hover,
.svn-server-repo:focus-visible {
border-color: rgba(32, 73, 238, 0.24);
outline: 0;
}
.svn-server-repo.is-linked {
background: #f7fbff;
}
.svn-server-repo strong,
.svn-server-repo small,
.svn-server-repo em {
display: block;
}
.svn-server-repo strong {
overflow: hidden;
color: #172033;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-server-repo small {
overflow: hidden;
margin-top: 3px;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-server-repo em {
color: var(--blue);
font-size: 11px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.svn-config-details summary {
display: flex;
min-height: 38px;
align-items: center;
justify-content: space-between;
color: #111827;
cursor: pointer;
font-weight: 850;
}
.svn-config-details form {
margin-top: 12px;
}
.svn-config-details .field-grid {
grid-template-columns: 1fr;
}
.svn-repo-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.svn-repo-header h1 {
margin: 0 0 8px;
color: #0f172a;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.12;
letter-spacing: 0;
}
.svn-repo-meta-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 18px 0;
}
.svn-repo-meta-grid > div {
min-width: 0;
padding: 12px;
background: #f8fafc;
border: 1px solid var(--line);
border-radius: 8px;
}
.svn-repo-meta-grid span,
.svn-repo-meta-grid strong {
display: block;
}
.svn-repo-meta-grid span {
margin-bottom: 5px;
color: var(--muted);
font-size: 12px;
}
.svn-repo-meta-grid strong {
overflow: hidden;
color: #182235;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.svn-quick-actions {
justify-content: flex-start;
margin-bottom: 18px;
}
.svn-action-row,
.svn-output-toolbar {
display: flex;
@@ -1016,7 +1232,7 @@ main,
.svn-command-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 18px;
}
@@ -1128,10 +1344,21 @@ main,
.topic-grid,
.admin-layout,
.login-panel,
.svn-console {
.svn-console,
.svn-hero,
.svn-github-layout {
grid-template-columns: 1fr;
}
.svn-sidebar {
position: static;
max-height: none;
}
.svn-repo-meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.svn-command-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -1193,10 +1420,24 @@ main,
}
.field-grid,
.token-row {
.token-row,
.svn-repo-header,
.svn-repo-meta-grid {
grid-template-columns: 1fr;
}
.svn-toolbar-actions,
.svn-header-actions,
.svn-quick-actions {
justify-content: stretch;
}
.svn-toolbar-actions button,
.svn-header-actions button,
.svn-quick-actions button {
width: 100%;
}
.soft-footer {
flex-direction: column;
align-items: center;
+107 -10
View File
@@ -10,9 +10,19 @@ const els = {
repoReset: document.querySelector("#repo-reset"),
deleteRepo: document.querySelector("#delete-repo"),
reloadRepos: document.querySelector("#reload-repos"),
syncServerRepos: document.querySelector("#sync-server-repos"),
repoSearch: document.querySelector("#repo-search"),
repoList: document.querySelector("#repo-list"),
repoSummary: document.querySelector("#repo-summary"),
serverRepoList: document.querySelector("#server-repo-list"),
serverSummary: document.querySelector("#server-summary"),
selectedRepo: document.querySelector("#selected-repo"),
selectedRepoName: document.querySelector("#selected-repo-name"),
selectedRepoUrl: document.querySelector("#selected-repo-url"),
selectedRepoPath: document.querySelector("#selected-repo-path"),
selectedRepoRevision: document.querySelector("#selected-repo-revision"),
selectedRepoSource: document.querySelector("#selected-repo-source"),
repoConfigDetails: document.querySelector("#repo-config-details"),
tabs: [...document.querySelectorAll(".svn-tab")],
panels: [...document.querySelectorAll(".svn-tab-panel")],
actionForm: document.querySelector("#action-form"),
@@ -24,7 +34,9 @@ const els = {
};
let repositories = [];
let serverRepositories = [];
let selectedRepoId = "";
let repoFilter = "";
const ACTION_LABELS = {
checkout: "Checkout",
@@ -87,6 +99,12 @@ function formatDate(value) {
return date.toLocaleString("zh-CN", { hour12: false });
}
function compactPath(value = "") {
const text = String(value || "");
if (text.length <= 56) return text || "--";
return `${text.slice(0, 24)}...${text.slice(-28)}`;
}
async function apiFetch(url, options = {}) {
const token = getToken();
const headers = {
@@ -125,33 +143,65 @@ function formToObject(form) {
}
function renderRepos() {
els.repoSummary.textContent = `${repositories.length} 个仓库`;
els.repoList.innerHTML = repositories.map((repo) => `<button class="svn-repo-item${repo.id === selectedRepoId ? " is-active" : ""}" type="button" data-id="${escapeHtml(repo.id)}">
const keyword = repoFilter.trim().toLowerCase();
const visibleRepos = repositories.filter((repo) => {
if (!keyword) return true;
return [repo.name, repo.url, repo.checkoutPath, repo.localPath, repo.description, repo.source].join(" ").toLowerCase().includes(keyword);
});
els.repoSummary.textContent = `${repositories.length} 个仓库${keyword ? `,匹配 ${visibleRepos.length}` : ""}`;
els.repoList.innerHTML = visibleRepos.map((repo) => `<button class="svn-repo-item${repo.id === selectedRepoId ? " is-active" : ""}" type="button" data-id="${escapeHtml(repo.id)}">
<span class="repo-name">${escapeHtml(repo.name)}</span>
<span class="repo-url">${escapeHtml(repo.url)}</span>
<span class="repo-meta">${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"} · ${formatDate(repo.updatedAt)}</span>
</button>`).join("") || `<p class="empty-state">暂无 SVN 仓库,先新增一个配置。</p>`;
<span class="repo-meta">${escapeHtml(repo.source === "server" ? "服务器仓库" : "手动配置")} · ${escapeHtml(repo.localPath || repo.checkoutPath || repo.id)} · ${repo.hasPassword ? "已配置凭据" : "无密码"}</span>
</button>`).join("") || `<p class="empty-state">${repositories.length ? "没有匹配的仓库。" : "暂无 SVN 仓库,可以先点击“接入旧仓库”。"}</p>`;
const repo = selectedRepo();
els.selectedRepoName.textContent = repo ? repo.name : "选择仓库";
els.selectedRepo.textContent = repo
? `${repo.name} · ${repo.url} · ${repo.localPath || repo.checkoutPath}`
: "请选择一个 SVN 仓库。";
? (repo.description || `${repo.name} · ${repo.url}`)
: "请选择一个 SVN 仓库,或先从服务器 SVN 服务接入旧仓库。";
els.selectedRepoUrl.textContent = compactPath(repo?.url);
els.selectedRepoPath.textContent = compactPath(repo?.localPath || repo?.checkoutPath);
els.selectedRepoRevision.textContent = repo?.serverRevision ? `r${repo.serverRevision}` : (repo?.lastAction?.at ? formatDate(repo.lastAction.at) : "--");
els.selectedRepoSource.textContent = repo ? (repo.source === "server" ? "服务器旧仓库" : "手动配置") : "--";
els.deleteRepo.disabled = !repo;
}
function renderServerRepos() {
const configuredCount = serverRepositories.filter((repo) => repo.configured).length;
els.serverSummary.textContent = serverRepositories.length
? `发现 ${serverRepositories.length} 个,已接入 ${configuredCount}`
: "未发现旧仓库";
els.serverRepoList.innerHTML = serverRepositories.map((repo) => `<button class="svn-server-repo${repo.configured ? " is-linked" : ""}" type="button" data-id="${escapeHtml(repo.id)}" data-configured-id="${escapeHtml(repo.configuredId || "")}">
<span>
<strong>${escapeHtml(repo.name)}</strong>
<small>${escapeHtml(repo.url)}</small>
</span>
<em>${repo.configured ? "已接入" : "待接入"}${repo.revision ? ` · r${escapeHtml(repo.revision)}` : ""}</em>
</button>`).join("") || `<p class="empty-state">没有扫描到服务器 SVN 仓库。</p>`;
}
async function loadRepos() {
try {
setStatus(els.tokenStatus, "正在读取 SVN 仓库...");
const payload = await apiFetch("/api/admin/svn/repositories");
const [payload, serverPayload] = await Promise.all([
apiFetch("/api/admin/svn/repositories"),
apiFetch("/api/admin/svn/server-repositories"),
]);
repositories = payload.repositories || [];
serverRepositories = serverPayload.repositories || [];
if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) {
selectedRepoId = repositories[0]?.id || "";
}
renderRepos();
setStatus(els.tokenStatus, `SVN 根目录:${payload.svnRoot || "-"}`, "ok");
renderServerRepos();
setStatus(els.tokenStatus, `工作副本:${payload.svnRoot || "-"};服务根:${serverPayload.repositoryRoots?.filter((root) => root.exists).map((root) => root.rootPath).join("、") || "-"}`, "ok");
} catch (error) {
repositories = [];
serverRepositories = [];
selectedRepoId = "";
renderRepos();
renderServerRepos();
setStatus(els.tokenStatus, `读取失败:${error.message}`, "error");
}
}
@@ -166,6 +216,7 @@ function resetRepoForm() {
els.repoForm.elements.noAuthCache.checked = true;
els.repoFormTitle.textContent = "新增仓库";
els.repoSubmit.textContent = "保存仓库";
if (els.repoConfigDetails) els.repoConfigDetails.open = true;
setStatus(els.repoFormStatus, "");
}
@@ -184,8 +235,8 @@ function fillRepoForm(repo) {
els.repoForm.elements.noAuthCache.checked = repo.noAuthCache !== false;
els.repoFormTitle.textContent = `编辑 ${repo.name}`;
els.repoSubmit.textContent = "更新仓库";
if (els.repoConfigDetails) els.repoConfigDetails.open = true;
setStatus(els.repoFormStatus, repo.hasPassword ? "已载入仓库,密码留空会保持原密码。" : "已载入仓库。", "ok");
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function saveRepo(event) {
@@ -219,6 +270,32 @@ async function deleteRepo(repo) {
}
}
function fillRepoFormFromServer(repo) {
resetRepoForm();
els.repoForm.elements.name.value = repo.name || "";
els.repoForm.elements.url.value = repo.url || "";
els.repoForm.elements.checkoutPath.value = repo.id || repo.name || "";
els.repoForm.elements.description.value = `从服务器 SVN 服务接入:${repo.repositoryPath || repo.name}`;
setStatus(els.repoFormStatus, "已填入服务器旧仓库,保存后即可纳入管理。", "ok");
}
async function syncServerRepos() {
try {
setStatus(els.tokenStatus, "正在接入服务器旧仓库...");
const payload = await apiFetch("/api/admin/svn/server-repositories/sync", { method: "POST", json: {} });
repositories = payload.repositories || [];
serverRepositories = payload.discovered || [];
if (!selectedRepoId || !repositories.some((repo) => repo.id === selectedRepoId)) {
selectedRepoId = repositories[0]?.id || "";
}
renderRepos();
renderServerRepos();
setStatus(els.tokenStatus, `接入完成:新增 ${payload.created || 0} 个,更新 ${payload.updated || 0} 个。`, "ok");
} catch (error) {
setStatus(els.tokenStatus, `接入失败:${error.message}`, "error");
}
}
function actionPayload() {
const payload = formToObject(els.actionForm);
payload.value = payload.pattern;
@@ -268,7 +345,7 @@ async function checkHealth() {
try {
setStatus(els.tokenStatus, "正在检测 svn 命令...");
const payload = await apiFetch("/api/admin/svn/health");
setStatus(els.tokenStatus, payload.ok ? `SVN CLI 可用:${payload.version || "unknown"}根目录${payload.svnRoot}` : payload.stderr, payload.ok ? "ok" : "error");
setStatus(els.tokenStatus, payload.ok ? `SVN CLI 可用:${payload.version || "unknown"}工作副本${payload.svnRoot},服务地址:${payload.publicBaseUrl || "-"}` : payload.stderr, payload.ok ? "ok" : "error");
} catch (error) {
setStatus(els.tokenStatus, `检测失败:${error.message}`, "error");
}
@@ -283,6 +360,11 @@ els.saveToken.addEventListener("click", () => {
els.health.addEventListener("click", checkHealth);
els.reloadRepos.addEventListener("click", loadRepos);
els.syncServerRepos.addEventListener("click", syncServerRepos);
els.repoSearch.addEventListener("input", () => {
repoFilter = els.repoSearch.value;
renderRepos();
});
els.repoForm.addEventListener("submit", saveRepo);
els.repoReset.addEventListener("click", resetRepoForm);
els.deleteRepo.addEventListener("click", () => {
@@ -304,6 +386,21 @@ els.repoList.addEventListener("click", (event) => {
if (repo) fillRepoForm(repo);
});
els.serverRepoList.addEventListener("click", (event) => {
const item = event.target.closest(".svn-server-repo");
if (!item) return;
const configuredId = item.dataset.configuredId;
if (configuredId) {
selectedRepoId = configuredId;
renderRepos();
const repo = selectedRepo();
if (repo) fillRepoForm(repo);
return;
}
const repo = serverRepositories.find((entry) => entry.id === item.dataset.id);
if (repo) fillRepoFormFromServer(repo);
});
els.repoList.addEventListener("contextmenu", (event) => {
const item = event.target.closest(".svn-repo-item");
if (!item) return;
+6 -1
View File
@@ -15,6 +15,8 @@ const storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI
const dataDir = path.resolve(process.env.SUMI_DATA_DIR || (storageRoot ? path.join(storageRoot, "data") : path.join(__dirname, "data")));
const uploadDir = path.resolve(process.env.SUMI_UPLOAD_DIR || (storageRoot ? path.join(storageRoot, "uploads") : path.join(__dirname, "uploads")));
const svnRoot = path.resolve(process.env.SUMI_SVN_DIR || (storageRoot ? path.join(storageRoot, "svn") : path.join(__dirname, "svn")));
const svnRepositoryRoots = process.env.SUMI_SVN_REPO_ROOTS || process.env.SUMI_SVN_REPOSITORY_ROOTS || "";
const svnPublicBaseUrl = process.env.SUMI_SVN_PUBLIC_BASE_URL || process.env.SUMI_SVN_BASE_URL || "";
const packageDir = path.join(uploadDir, "packages");
const iconDir = path.join(uploadDir, "icons");
const manifestDir = path.join(uploadDir, "manifests");
@@ -1150,7 +1152,7 @@ app.delete("/api/admin/software/:id", requirePlayer, requireAdmin, async (req, r
}
});
registerSvnRoutes(app, { svnRoot, svnDataFile, requirePlayer, requireAdmin });
registerSvnRoutes(app, { svnRoot, svnDataFile, svnRepositoryRoots, svnPublicBaseUrl, requirePlayer, requireAdmin });
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
@@ -1178,6 +1180,9 @@ app.listen(port, () => {
console.log(`data directory: ${dataDir}`);
console.log(`upload directory: ${uploadDir}`);
console.log(`svn directory: ${svnRoot}`);
if (svnRepositoryRoots) {
console.log(`svn repository roots: ${svnRepositoryRoots}`);
}
if (!process.env.ADMIN_TOKEN) {
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
}
+228
View File
@@ -5,6 +5,14 @@ import path from "node:path";
const DEFAULT_TIMEOUT_MS = 120000;
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
const REMOTE_TARGET_PATTERN = /^(https?|svn|file):\/\//i;
const DEFAULT_REPOSITORY_ROOTS = [
"/data/tools/svn_server",
"/var/svn",
"/srv/svn",
"/var/lib/svn",
"/opt/svn",
"/home/svn",
];
function slugify(value) {
const slug = String(value || "")
@@ -34,6 +42,14 @@ function parseIntValue(value, fallback, min, max) {
return Math.min(max, Math.max(min, parsed));
}
function splitRootList(value) {
if (Array.isArray(value)) return value;
return String(value || "")
.split(/[;\n,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function limitText(value, maxLength = 4000) {
return String(value || "").trim().slice(0, maxLength);
}
@@ -114,6 +130,14 @@ async function runSvn(args, options = {}) {
});
}
async function readTextIfExists(filePath) {
try {
return (await fsp.readFile(filePath, "utf8")).trim();
} catch {
return "";
}
}
function addAuthArgs(args, repo) {
const next = [...args, "--non-interactive"];
if (repo.trustServerCert !== false) {
@@ -163,6 +187,146 @@ function publicRepo(repo) {
return copy;
}
function repositoryRootCandidates(options) {
const roots = [...splitRootList(options.svnRepositoryRoots), ...DEFAULT_REPOSITORY_ROOTS]
.map((item) => path.resolve(item))
.filter((item, index, list) => list.indexOf(item) === index);
return roots;
}
async function isSvnRepositoryDir(dirPath) {
try {
const [formatStat, dbStat] = await Promise.all([
fsp.stat(path.join(dirPath, "format")),
fsp.stat(path.join(dirPath, "db")),
]);
return formatStat.isFile() && dbStat.isDirectory();
} catch {
return false;
}
}
async function discoverRepoDirs(rootPath, maxDepth = 2) {
const found = [];
async function walk(dirPath, depth) {
if (found.length >= 200) return;
if (await isSvnRepositoryDir(dirPath)) {
found.push(dirPath);
return;
}
if (depth <= 0) return;
let entries = [];
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
await walk(path.join(dirPath, entry.name), depth - 1);
}
}
await walk(rootPath, maxDepth);
return found;
}
function requestSvnBaseUrl(options, req) {
const configured = String(options.svnPublicBaseUrl || "").trim().replace(/\/+$/g, "");
if (configured) return configured;
const host = req?.hostname || "localhost";
return `svn://${host}`;
}
function publicUrlForRepo(options, req, rootPath, repoPath) {
const baseUrl = requestSvnBaseUrl(options, req);
const relativePath = path.relative(rootPath, repoPath).split(path.sep).filter(Boolean).map(encodeURIComponent).join("/");
return relativePath ? `${baseUrl}/${relativePath}` : baseUrl;
}
async function discoveredRepoInfo(options, req, rootPath, repoPath) {
const relativePath = path.relative(rootPath, repoPath).split(path.sep).join("/");
const name = relativePath || path.basename(repoPath);
const current = await readTextIfExists(path.join(repoPath, "db", "current"));
const revision = current.split(/\s+/)[0] || "";
return {
id: slugify(name),
name,
url: publicUrlForRepo(options, req, rootPath, repoPath),
rootPath,
repositoryPath: repoPath,
relativePath,
revision,
uuid: await readTextIfExists(path.join(repoPath, "db", "uuid")),
fsType: await readTextIfExists(path.join(repoPath, "db", "fs-type")),
format: await readTextIfExists(path.join(repoPath, "format")),
};
}
async function discoverServerRepositories(options, req) {
const roots = repositoryRootCandidates(options);
const repositories = [];
const rootStatus = [];
for (const rootPath of roots) {
let exists = false;
try {
exists = (await fsp.stat(rootPath)).isDirectory();
} catch {
exists = false;
}
if (!exists) {
rootStatus.push({ rootPath, exists: false, repositoryCount: 0 });
continue;
}
const repoDirs = await discoverRepoDirs(rootPath);
rootStatus.push({ rootPath, exists: true, repositoryCount: repoDirs.length });
for (const repoPath of repoDirs) {
repositories.push(await discoveredRepoInfo(options, req, rootPath, repoPath));
}
}
return {
repositoryRoots: rootStatus,
publicBaseUrl: requestSvnBaseUrl(options, req),
repositories,
};
}
function markConfigured(discoveredRepos, configuredRepos) {
return discoveredRepos.map((repo) => {
const configured = configuredRepos.find((item) => (
item.serverRepositoryPath === repo.repositoryPath
|| item.url === repo.url
|| item.id === repo.id
|| item.name === repo.name
));
return {
...repo,
configured: Boolean(configured),
configuredId: configured?.id || "",
};
});
}
function repoFromDiscovered(discovered, existing = {}) {
return normalizeRepoPatch({
...existing,
name: discovered.name,
url: discovered.url,
checkoutPath: existing.checkoutPath || discovered.id,
source: "server",
serverRepositoryRoot: discovered.rootPath,
serverRepositoryPath: discovered.repositoryPath,
serverRelativePath: discovered.relativePath,
serverRevision: discovered.revision,
serverUuid: discovered.uuid,
description: existing.description || `从服务器 SVN 服务接入:${discovered.repositoryPath}`,
}, existing);
}
function getRepoPath(options, repo) {
const checkoutPath = String(repo.checkoutPath || repo.id || "").trim();
const localPath = path.resolve(options.svnRoot, checkoutPath);
@@ -228,6 +392,12 @@ function normalizeRepoPatch(body, existing = {}) {
trustServerCert: parseBoolean(body.trustServerCert, existing.trustServerCert !== false),
noAuthCache: parseBoolean(body.noAuthCache, existing.noAuthCache !== false),
description: limitText(body.description || existing.description, 500),
source: limitText(body.source || existing.source, 40),
serverRepositoryRoot: limitText(body.serverRepositoryRoot || existing.serverRepositoryRoot, 1000),
serverRepositoryPath: limitText(body.serverRepositoryPath || existing.serverRepositoryPath, 1000),
serverRelativePath: limitText(body.serverRelativePath || existing.serverRelativePath, 1000),
serverRevision: limitText(body.serverRevision || existing.serverRevision, 80),
serverUuid: limitText(body.serverUuid || existing.serverUuid, 120),
updatedAt: new Date().toISOString(),
createdAt: existing.createdAt || new Date().toISOString(),
};
@@ -440,10 +610,68 @@ export function registerSvnRoutes(app, options) {
ok: result.ok,
version: result.stdout.trim(),
svnRoot: options.svnRoot,
repositoryRoots: repositoryRootCandidates(options),
publicBaseUrl: requestSvnBaseUrl(options, req),
stderr: result.stderr,
});
});
app.get("/api/admin/svn/server-repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
try {
const store = await readSvnStore(options);
const discovered = await discoverServerRepositories(options, req);
res.json({
...discovered,
svnRoot: options.svnRoot,
repositories: markConfigured(discovered.repositories, store.repositories),
});
} catch (error) {
next(error);
}
});
app.post("/api/admin/svn/server-repositories/sync", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
try {
const store = await readSvnStore(options);
const discovered = await discoverServerRepositories(options, req);
let created = 0;
let updated = 0;
for (const repo of discovered.repositories) {
const index = store.repositories.findIndex((item) => (
item.serverRepositoryPath === repo.repositoryPath
|| item.url === repo.url
|| item.id === repo.id
|| item.name === repo.name
));
if (index === -1) {
let nextRepo = repoFromDiscovered(repo);
let id = nextRepo.id;
while (store.repositories.some((item) => item.id === id)) {
id = `${nextRepo.id}-${Math.random().toString(16).slice(2, 8)}`;
}
nextRepo = { ...nextRepo, id };
getRepoPath(options, nextRepo);
store.repositories.push(nextRepo);
created += 1;
} else {
store.repositories[index] = repoFromDiscovered(repo, store.repositories[index]);
updated += 1;
}
}
await writeSvnStore(options, store);
res.json({
created,
updated,
repositories: store.repositories.map(publicRepo),
discovered: markConfigured(discovered.repositories, store.repositories),
});
} catch (error) {
next(error);
}
});
app.get("/api/admin/svn/repositories", options.requirePlayer, options.requireAdmin, async (req, res, next) => {
try {
const store = await readSvnStore(options);