DSH-Arch-Setup/plugins/dsh-touched-git/lib/index.js
Шурупов Илья Викторович cf9443753e Add one-command Arch DSH setup
Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
2026-09-12 19:13:36 +03:00

546 lines
28 KiB
JavaScript

/**
* dsh-touched-git — host half.
*
* Tracks the set of git-revisioned directories an agent session has "touched",
* exposed through three model-facing tools and shown in a right-side panel.
*
* Tools:
* add_touched_git_directory — the agent calls this whenever it starts
* working in a git-revisioned directory.
* remove_touched_git_directory — the agent calls this ONLY after the user
* explicitly says a directory is no longer
* tracked.
* get_touched_git_directories — the agent reads the list; invalid earlier
* add calls are returned as error entries.
*
* Routes (loopback-only):
* GET /touched-git/list?sessionId= — entries for the panel.
* GET /touched-git/status?sessionId=&path= — `git status` for one entry.
* @module dsh-touched-git
*/
import { execFile } from 'node:child_process'
import { mkdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { EditorWorkspaces } from './editor-runtime.js'
import { createEditorOpenHandler } from './editor-route.js'
import { registerGitWriteGuard } from './git-write-guard.js'
import { MutationStore } from './mutation-store.js'
import { createMutationHandlers } from './mutation-routes.js'
const execFileAsync = promisify(execFile)
const VENDOR_DIR = join(dirname(fileURLToPath(import.meta.url)), 'vendor')
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'store.json')
const GUIDANCE = [
'Track the git-revisioned directories you work in for this session.',
'Whenever you START working in a directory that is inside a git repository or linked worktree',
'(after a cd, switching worktrees/branches, or beginning a new sub-task in another repo),',
'immediately call add_touched_git_directory with that directory.',
'Only call remove_touched_git_directory after the user explicitly tells you to stop tracking a',
"directory (e.g. \"we don't track that directory anymore\") — never remove one on your own.",
'Call get_touched_git_directories when you want to review the directories you have touched;',
'it also surfaces any earlier add calls that were invalid so you can correct them.',
'The write and edit tools block mutations inside Git repositories or linked worktrees that',
'are not registered in the current session. Register the exact repository or worktree first.',
'Writes outside Git are allowed with an explicit warning. Do not evade a Git-scope denial',
'through bash, another tool, or an alternate path; correct the session registration instead.',
].join(' ')
export const name = 'dsh-touched-git'
export const inject = ['webServer', 'tools', 'systemPrompt', 'fs']
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' }
function writeJson(response, status, body) {
response.writeHead(status, JSON_HEADERS)
response.end(JSON.stringify(body))
}
// ---- loopback trust fence (desktop only) ----
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const a = address.toLowerCase()
if (a === '::1') return true
if (a.startsWith('::ffff:')) return isIPv4Loopback(a.slice('::ffff:'.length))
return isIPv4Loopback(a)
}
function isLoopbackHostname(h) { return h === 'localhost' || h === '[::1]' || isIPv4Loopback(h) }
function isLoopbackRequest(request) {
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
const host = request.headers.host
if (typeof host !== 'string') return false
let hostUrl
try { hostUrl = new URL(`http://${host}`) } catch { return false }
if (!isLoopbackHostname(hostUrl.hostname)) return false
if (request.headers['sec-fetch-site'] === 'cross-site') return false
const origin = request.headers.origin
if (origin === undefined) return true
try { return new URL(origin).host === hostUrl.host } catch { return false }
}
/** Resolve a directory to its canonical path + git repository root. Throws a coded error otherwise. */
export async function resolveDirectoryRepository(directory) {
let workingDirectory
try {
workingDirectory = await realpath(directory)
const info = await stat(workingDirectory)
if (!info.isDirectory()) throw new Error('not a directory')
} catch {
throw Object.assign(new Error('directory does not resolve on disk'), { code: 'cwd-unavailable' })
}
try {
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: workingDirectory, encoding: 'utf8', timeout: 10_000, windowsHide: true,
})
const repository = await realpath(result.stdout.trim())
return { workingDirectory, repository }
} catch {
throw Object.assign(new Error('directory is not inside a git repository'), { code: 'not-a-repository' })
}
}
/** Parse `git status --porcelain=v1 --branch` into a small structured shape. */
export async function gitStatus(repository) {
const result = await execFileAsync('git', ['-C', repository, 'status', '--porcelain=v1', '--branch'], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
})
const lines = result.stdout.split('\n').filter(Boolean)
let branch = ''
const files = []
for (const line of lines) {
if (line.startsWith('## ')) { branch = line.slice(3); continue }
files.push({ xy: line.slice(0, 2), path: line.slice(3) })
}
const ns = await numstatMap(repository, ['diff', 'HEAD', '--numstat']).catch(() => new Map())
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
return { branch, files, clean: files.length === 0 }
}
/** Unified diff for one file in a repository (all uncommitted changes vs HEAD; whole file for untracked). */
export async function gitFileDiff(repository, file) {
let headDiff = ''
try {
const r = await execFileAsync('git', ['-C', repository, 'diff', 'HEAD', '--', file], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
headDiff = r.stdout
} catch { /* repo may have no commits yet; fall through to --no-index */ }
if (headDiff.trim() !== '') return { diff: headDiff }
// Untracked (or no HEAD): render the whole file as additions.
try {
const r = await execFileAsync('git', ['-C', repository, 'diff', '--no-index', '--', '/dev/null', join(repository, file)], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
return { diff: r.stdout }
} catch (error) {
// `git diff --no-index` exits 1 WITH the diff in stdout when the files differ.
if (error && typeof error.stdout === 'string' && error.stdout.trim() !== '') return { diff: error.stdout }
return { diff: '' }
}
}
/** Linear commit history (first-parent) in git2json shape (newest first) for @gitgraph/js import. */
export async function gitLog(repository, limit = 200) {
const SEP = '\x1f'
const fmt = ['%H', '%P', '%an', '%ae', '%at', '%D', '%s'].join(SEP)
const r = await execFileAsync('git', ['-C', repository, 'log', '--first-parent', '-n', String(limit), '--pretty=format:' + fmt], {
encoding: 'utf8', timeout: 15_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
})
const commits = []
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const [hash, parents, an, ae, at, refs, subject] = line.split(SEP)
// Keep only the first parent so the graph is a single straight line.
const first = parents ? parents.split(' ').filter(Boolean)[0] : undefined
commits.push({
hash,
parents: first ? [first] : [],
author: { name: an || '', email: ae || '', timestamp: (Number(at) || 0) * 1000 },
refs: refs ? refs.split(', ').map(s => s.trim()).filter(Boolean) : [],
subject: subject || '',
})
}
return commits
}
/** path -> { add, del } line counts from a `git ... --numstat` invocation. */
async function numstatMap(repository, args) {
const r = await execFileAsync('git', ['-C', repository, ...args], {
encoding: 'utf8', timeout: 12_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
})
const map = new Map()
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const cols = line.split('\t')
if (cols.length < 3) continue
const add = cols[0] === '-' ? null : Number(cols[0])
const del = cols[1] === '-' ? null : Number(cols[1])
map.set(cols[cols.length - 1], { add, del })
}
return map
}
/** Files changed in one commit, in the same {xy, path} shape as `git status`. */
export async function gitCommitFiles(repository, ref) {
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '--name-status', '-r', '-M', ref], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
const files = []
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const parts = line.split('\t')
files.push({ xy: `${(parts[0] || '?')[0]} `, path: parts[parts.length - 1] })
}
const ns = await numstatMap(repository, ['diff-tree', '--no-commit-id', '-r', '-M', '--numstat', ref]).catch(() => new Map())
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
return { branch: `commit ${ref.slice(0, 10)}`, files, clean: files.length === 0 }
}
/** Patch for one file at one commit (no commit-message header). */
export async function gitFileDiffAtRef(repository, ref, file) {
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref, '--', file], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
return { diff: r.stdout }
}
/** Commit metadata (subject + full body + author) for the status header. */
export async function gitCommitMeta(repository, ref) {
const SEP = '\x1f'
const r = await execFileAsync('git', ['-C', repository, 'show', '-s', '--date=short', '--format=' + ['%H', '%an', '%ae', '%ad', '%s', '%b'].join(SEP), ref], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
})
const parts = r.stdout.split(SEP)
return { hash: parts[0] || ref, author: { name: parts[1] || '', email: parts[2] || '' }, date: parts[3] || '', subject: parts[4] || '', body: (parts[5] || '').trim() }
}
/** Whole diff: every file in a commit, or the whole working tree vs HEAD. */
export async function gitWholeDiff(repository, ref) {
const args = ref
? ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref]
: ['-C', repository, 'diff', 'HEAD']
const r = await execFileAsync('git', args, { encoding: 'utf8', timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true })
return { diff: r.stdout }
}
/** A commit hash we accept as a ref (never an option/flag). */
function isCommitRef(ref) { return typeof ref === 'string' && /^[0-9a-fA-F]{4,64}$/.test(ref) }
/** Reject path segments that escape the repository. */
function isSafeRelative(file) {
if (typeof file !== 'string' || file === '' || isAbsolute(file)) return false
return !file.split(/[/\\]/).includes('..')
}
function nowIso() { return new Date().toISOString() }
/** Per-session store of touched git directories, persisted atomically to STORE_FILE. */
export class TouchedGitStore {
constructor(filePath = STORE_FILE) {
this.filePath = filePath
this.sessions = new Map() // sessionId -> Map(id -> entry)
this.ready = this.load()
this.pendingWrite = Promise.resolve()
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
for (const [sessionId, list] of Object.entries(parsed)) {
if (!Array.isArray(list)) continue
const map = new Map()
for (const e of list) if (e && typeof e.id === 'string') map.set(e.id, e)
this.sessions.set(sessionId, map)
}
} catch { /* fresh */ }
}
async list(sessionId) {
await this.ready
return [...(this.sessions.get(sessionId)?.values() ?? [])]
}
isRegistered(sessionId, repository) {
return [...(this.sessions.get(sessionId)?.values() ?? [])].some(entry => entry.ok && entry.repository === repository)
}
async upsert(sessionId, entry) {
await this.ready
let map = this.sessions.get(sessionId)
if (map === undefined) { map = new Map(); this.sessions.set(sessionId, map) }
map.set(entry.id, entry)
await this.flush()
return entry
}
async remove(sessionId, predicate) {
await this.ready
const map = this.sessions.get(sessionId)
if (map === undefined) return []
const removed = []
for (const [id, entry] of [...map]) if (predicate(entry)) { map.delete(id); removed.push(entry) }
if (map.size === 0) this.sessions.delete(sessionId)
await this.flush()
return removed
}
async flush() {
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const obj = {}
for (const [sessionId, map] of this.sessions) obj[sessionId] = [...map.values()]
const tmp = `${this.filePath}.tmp-${process.pid}`
await writeFile(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8')
await rename(tmp, this.filePath)
}
}
function sessionIdOf(execution) {
const sessionId = execution.agent?.session.header.id
if (typeof sessionId !== 'string' || sessionId === '') {
throw new Error('an active agent session is required')
}
return sessionId
}
function absoluteArgPath(execution, raw) {
const base = execution.agent?.session.header.cwd
const trimmed = String(raw ?? '').trim()
return isAbsolute(trimmed) ? trimmed : resolve(base || process.cwd(), trimmed)
}
function registerTools(context, store) {
const add = context.tools.register(defineTool({
name: 'add_touched_git_directory',
description: 'Record a git-revisioned directory this session is now working in. Call it whenever you start editing/building/committing in a repository or linked worktree, including after a cd or worktree switch. Invalid directories are still recorded as errors so you can review them later with get_touched_git_directories.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path, or a path relative to the session cwd, inside a git repository or linked worktree.' },
},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: value.ok
? `Tracking git directory: ${value.repository} (${value.tracked} tracked)`
: `Could not track ${value.path}: ${value.error?.message ?? 'invalid directory'} (recorded as an error)`,
}],
},
execute: async (args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const absolutePath = absoluteArgPath(execution, args.path)
try {
const { workingDirectory, repository } = await resolveDirectoryRepository(absolutePath)
const entry = { id: repository, path: absolutePath, workingDirectory, repository, ok: true, addedAt: nowIso() }
await store.upsert(sessionId, entry)
return { ok: true, repository, workingDirectory, tracked: (await store.list(sessionId)).length }
} catch (error) {
const err = { code: error?.code || 'error', message: error?.message || String(error) }
const entry = { id: absolutePath, path: absolutePath, ok: false, error: err, addedAt: nowIso() }
await store.upsert(sessionId, entry)
return { ok: false, path: absolutePath, error: err }
}
},
}))
const remove = context.tools.register(defineTool({
name: 'remove_touched_git_directory',
description: 'Stop tracking a directory. ONLY call this after the user explicitly asks to remove/untrack a directory (e.g. "we don\'t track that directory anymore"). Never remove a directory on your own initiative.',
parameters: {
path: { type: 'string', required: true, description: 'The directory (or its repository root) to stop tracking. Matches by repository root or the recorded path.' },
},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: value.removed
? `Stopped tracking ${value.removed} director${value.removed === 1 ? 'y' : 'ies'}: ${value.entries.join(', ')}`
: 'No matching tracked directory to remove.',
}],
},
execute: async (args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const absolutePath = absoluteArgPath(execution, args.path)
let repository
try { ({ repository } = await resolveDirectoryRepository(absolutePath)) } catch { /* may be an invalid entry */ }
const removed = await store.remove(sessionId, (e) =>
e.id === absolutePath || e.path === absolutePath || (repository !== undefined && e.repository === repository))
return { removed: removed.length, entries: removed.map(e => e.repository || e.path) }
},
}))
const get = context.tools.register(defineTool({
name: 'get_touched_git_directories',
description: 'List the git-revisioned directories tracked for this session, so you can review where you have been working. Entries whose earlier add call was invalid are returned with an error field.',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: [
`Tracked git directories (${value.count}):`,
...value.directories.map(d => `- ${d.repository}`),
...(value.errors.length ? ['Invalid earlier adds:', ...value.errors.map(e => `- ${e.path}: ${e.error?.message ?? 'error'}`)] : []),
].join('\n'),
}],
},
execute: async (_args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const entries = await store.list(sessionId)
const valid = entries.filter(e => e.ok)
const errors = entries.filter(e => !e.ok).map(e => ({ path: e.path, error: e.error }))
return {
count: entries.length,
directories: valid.map(e => ({ repository: e.repository, workingDirectory: e.workingDirectory, addedAt: e.addedAt })),
errors,
}
},
}))
return [add, remove, get]
}
function requestSessionId(requestUrl, host) {
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get('sessionId')?.trim() || '' } catch { return '' }
}
function requestParam(requestUrl, host, key) {
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get(key)?.trim() || '' } catch { return '' }
}
export function apply(context) {
const store = new TouchedGitStore()
const editor = new EditorWorkspaces()
const editorOpenHandler = createEditorOpenHandler({ store, resolveDirectoryRepository, isLoopbackRequest, editor, writeJson })
const mutations = new MutationStore(join(dirname(STORE_FILE), 'mutations.json'))
const mutationHandlers = createMutationHandlers({ mutations, store, editor, resolveDirectoryRepository, isLoopbackRequest, writeJson })
const listHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
if (sessionId === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
const stored = await store.list(sessionId)
// Re-validate each tracked directory so the panel reflects reality.
const entries = await Promise.all(stored.map(async (e) => {
if (!e.ok) return e
try {
await resolveDirectoryRepository(e.repository)
return e
} catch (error) {
return { ...e, ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } }
}
}))
writeJson(response, 200, { ok: true, value: { entries } })
}
const statusHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
// Only allow status for a directory this session actually tracks.
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const ref = requestParam(request.url, request.headers.host, 'ref')
try {
const { repository } = await resolveDirectoryRepository(path)
// A commit ref -> that commit's changed files + message; otherwise the working tree.
let status
if (ref !== '' && isCommitRef(ref)) {
status = await gitCommitFiles(repository, ref)
try { status.commit = await gitCommitMeta(repository, ref) } catch { /* meta optional */ }
} else {
status = await gitStatus(repository)
}
writeJson(response, 200, { ok: true, value: { repository, ...status } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const logHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const limit = Math.max(1, Math.min(2000, Number(requestParam(request.url, request.headers.host, 'limit')) || 20))
try {
const { repository } = await resolveDirectoryRepository(path)
const commits = await gitLog(repository, limit)
writeJson(response, 200, { ok: true, value: { commits } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const diffHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
const file = requestParam(request.url, request.headers.host, 'file')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
if (file !== '' && !isSafeRelative(file)) return writeJson(response, 400, { ok: false, error: { code: 'invalid-file', message: 'file must be a repo-relative path' } })
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const ref = requestParam(request.url, request.headers.host, 'ref')
const commitRef = ref !== '' && isCommitRef(ref) ? ref : ''
try {
const { repository } = await resolveDirectoryRepository(path)
let out
if (file === '') out = await gitWholeDiff(repository, commitRef) // whole commit / working tree
else if (commitRef) out = await gitFileDiffAtRef(repository, commitRef, file)
else out = await gitFileDiff(repository, file)
writeJson(response, 200, { ok: true, value: { file, diff: out.diff } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const staticHandler = (fileName, contentType) => async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
try {
const buffer = await readFile(join(VENDOR_DIR, fileName))
response.writeHead(200, { 'content-type': contentType, 'cache-control': 'public, max-age=86400' })
response.end(buffer)
} catch {
response.writeHead(404, { 'content-type': 'text/plain' })
response.end('not found')
}
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/touched-git/list', handler: listHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open', handler: editorOpenHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/mutation', handler: mutationHandlers.describeHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open-file', handler: mutationHandlers.openFileHandler }),
registerGitWriteGuard(context, { store, onSuccessfulMutation: mutation => mutations.record(mutation) }),
context.webServer.register({ kind: 'exact', path: '/touched-git/status', handler: statusHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/log', handler: logHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/diff', handler: diffHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/gitgraph.js', handler: staticHandler('gitgraph.umd.min.js', 'application/javascript; charset=utf-8') }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.js', handler: staticHandler('diff2html.min.js', 'application/javascript; charset=utf-8') }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.css', handler: staticHandler('diff2html.min.css', 'text/css; charset=utf-8') }),
...registerTools(context, store),
context.systemPrompt.section({ name: 'plugin:touched-git', order: 146, text: GUIDANCE }),
]
return async () => {
for (const dispose of disposers.reverse()) dispose()
await editor.dispose()
}
}, 'dsh-touched-git: tools + routes + guidance')
}