autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
225 lines (224 loc) • 8.76 kB
JavaScript
/**
* CapabilityProbe — 子仓库能力探针
*
* 通过 `git push --dry-run` 探测当前用户对子仓库的物理写权限。
* 探测结果被缓存(默认 24h)以避免重复执行。
*
* 子仓库默认指向 `AutoSnippet/recipes/`(可通过 config 或 options 自定义)。
* 探测路径解析优先级:
* 1. 构造函数 options.subRepoPath(显式指定)
* 2. `.autosnippet/config.json` 中 `core.subRepoDir`
* 3. 默认 `AutoSnippet/recipes`
*
* 三种探测结果:
* 'admin' — 无子仓库(个人项目)/ 有 push 权限 → developer
* 'contributor' — 有子仓库但无 push 权限 → developer(本地用户 = 项目 Owner)
* 'visitor' — noRemote=deny 严格模式 → developer(仅探针级别区分,角色统一为 developer)
*
* 当没有 remote 时根据 constitution capabilities.git_write.no_remote 策略决定:
* 'allow' (默认) — 本地开发,视为 admin
* 'deny' — 严格模式,视为 visitor
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { readSubRepoUrlFromConfig, resolveSubRepoPath } from '#shared/ProjectMarkers.js';
import { resolveProjectRoot } from '#shared/resolveProjectRoot.js';
import Logger from '../../infrastructure/logging/Logger.js';
export class CapabilityProbe {
subRepoPath;
_cache;
cacheTTL;
logger;
noRemote;
/**
* @param [options.subRepoPath] 子仓库根路径(默认 cwd/AutoSnippet)
* @param [options.cacheTTL] 缓存 TTL(秒),默认 86400
* @param [options.noRemote] 无 remote 策略: 'allow' | 'deny'
*/
constructor(options = {}) {
this.logger = Logger.getInstance();
this.subRepoPath = options.subRepoPath || this._detectSubRepo();
this.cacheTTL = (options.cacheTTL ?? 86400) * 1000; // 转为 ms
this.noRemote = options.noRemote || 'allow';
this._cache = null;
}
// ═══════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════
/** 执行探测,返回角色级别 */
probe() {
// 命中缓存
if (this._cache && Date.now() < this._cache.expiresAt) {
this.logger.debug('CapabilityProbe: cache hit', { result: this._cache.result });
return this._cache.result;
}
const result = this._runProbe();
this._cache = {
result,
cachedAt: Date.now(),
expiresAt: Date.now() + this.cacheTTL,
detail: `probed at ${new Date().toISOString()}`,
};
this.logger.info('CapabilityProbe: probed', {
subRepoPath: this.subRepoPath,
result,
});
return result;
}
/**
* 将探测结果映射为 Constitution 角色 ID
*
* 映射规则:
* 'admin' → 'developer' 有 push 权限 / 无子仓库(个人项目)→ 完整权限
* 'contributor' → 'contributor' 有子仓库但无 push 权限 → 只读,禁止提交 Recipe
* 'visitor' → 'visitor' noRemote=deny 严格模式 → 最小权限
*/
toRole(probeResult) {
switch (probeResult) {
case 'admin':
return 'developer';
case 'contributor':
return 'contributor';
case 'visitor':
return 'visitor';
default:
return 'contributor';
}
}
/**
* 一步到位:探测并返回角色
* @returns Constitution role ID
*/
probeRole() {
return this.toRole(this.probe());
}
/** 获取当前缓存状态(for dashboard display) */
getCacheStatus() {
if (!this._cache) {
return { cached: false };
}
return {
cached: true,
result: this._cache.result,
cachedAt: this._cache.cachedAt,
expiresAt: this._cache.expiresAt,
expired: Date.now() >= this._cache.expiresAt,
};
}
/** 清除缓存(强制下次重新探测) */
invalidate() {
this._cache = null;
}
// ═══════════════════════════════════════════════════
// Internal
// ═══════════════════════════════════════════════════
/**
* 自动检测子仓库路径
* 优先级:config.json > 默认 AutoSnippet/recipes
*/
_detectSubRepo() {
const effectiveRoot = resolveProjectRoot();
const resolved = resolveSubRepoPath(effectiveRoot);
// 检查目标路径是否存在
if (fs.existsSync(resolved)) {
return resolved;
}
return null;
}
/** 执行实际探测 */
_runProbe() {
// Case 1: 子仓库路径不存在 → 个人项目模式,全权限
if (!this.subRepoPath || !fs.existsSync(this.subRepoPath)) {
this.logger.debug('CapabilityProbe: no sub-repo — personal project, granting admin');
return 'admin';
}
// Case 2: 检查是否是 git 仓库
const isGitRepo = this._isGitRepo(this.subRepoPath);
if (!isGitRepo) {
// 有目录但不是 git 仓库 → 本地个人项目(asd setup 创建),给全权限
this.logger.debug('CapabilityProbe: directory exists but not a git repo — local project, granting admin');
return 'admin';
}
// Case 3: 检查是否有 remote
const hasRemote = this._hasRemote(this.subRepoPath);
if (!hasRemote) {
// 无 remote,根据策略决定
this.logger.debug('CapabilityProbe: no remote', { noRemote: this.noRemote });
return this.noRemote === 'allow' ? 'admin' : 'visitor';
}
// Case 4: 有 remote → 执行 git push --dry-run 探测写权限
try {
return this._probePush(this.subRepoPath);
}
catch (err) {
this.logger.warn('CapabilityProbe: push probe failed', {
error: err instanceof Error ? err.message : String(err),
});
return 'contributor';
}
}
_isGitRepo(repoPath) {
// 检查是否是独立的 git 仓库(有自己的 .git 目录/文件),
// 而非仅仅位于父项目 git 仓库内
return fs.existsSync(`${repoPath}/.git`);
}
_hasRemote(repoPath) {
// 快速路径:config 有 subRepoUrl 即认为有 remote
try {
const effectiveRoot = resolveProjectRoot();
const url = readSubRepoUrlFromConfig(effectiveRoot);
if (url) {
return true;
}
}
catch {
/* 读取失败走原有逻辑 */
}
try {
const output = execSync('git remote', {
cwd: repoPath,
stdio: 'pipe',
timeout: 5000,
encoding: 'utf8',
});
return output.trim().length > 0;
}
catch {
return false;
}
}
/** git push --dry-run 探测 */
_probePush(repoPath) {
try {
execSync('git push --dry-run 2>&1', {
cwd: repoPath,
stdio: 'pipe',
timeout: 15000,
encoding: 'utf8',
});
// 成功 → 有写权限
return 'admin';
}
catch (err) {
const execErr = err;
const stderr = (execErr.stderr || execErr.stdout || execErr.message || '').toString();
// "Everything up-to-date" 也算成功
if (stderr.includes('Everything up-to-date') || stderr.includes('up to date')) {
return 'admin';
}
// 明确被拒绝
if (stderr.includes('permission') ||
stderr.includes('denied') ||
stderr.includes('403') ||
stderr.includes('401')) {
return 'contributor';
}
// 网络错误等 → 降级为 contributor
this.logger.debug('CapabilityProbe: push dry-run inconclusive', {
stderr: stderr.slice(0, 200),
});
return 'contributor';
}
}
}
export default CapabilityProbe;