DSH-Arch-Setup/plugins/dsh-open-in-smerge/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

337 lines
11 KiB
JavaScript

import { execFile, spawn } 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 { promisify } from 'node:util'
import { defineTool } from '@deepseek-ai/dsh-tools'
const execFileAsync = promisify(execFile)
const TARGET_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'open-in-smerge', 'targets.json')
const TARGET_GUIDANCE = 'Before editing source or configuration, briefly state the basic logic progression to the user: current behavior, intended behavior, and key implementation steps. Track your Git working directory at all times. Whenever you choose the repository or linked worktree where you will edit, build, or commit — including after a cd in a shell, switching branches or worktrees, or starting a new task — immediately call set_active_git_directory with that directory, and call it again whenever the effective working directory changes. The session-header Git control has no automatic fallback: until you set it, it stays empty, which the user reads as you not tracking your location. Keep it accurate.'
export const name = 'dsh-open-in-smerge'
export const inject = ['webServer', 'tools', 'systemPrompt']
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))
}
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const normalized = address.toLowerCase()
if (normalized === '::1') return true
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
return isIPv4Loopback(normalized)
}
function isLoopbackHostname(hostname) {
return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname)
}
export 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
}
}
async function readJsonBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 64 * 1024) {
request.destroy()
return null
}
chunks.push(chunk)
}
if (chunks.length === 0) return null
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function targetRecord(value) {
if (typeof value !== 'object' || value === null) return undefined
if (typeof value.workingDirectory !== 'string' || value.workingDirectory === '') return undefined
if (typeof value.repository !== 'string' || value.repository === '') return undefined
return {
workingDirectory: value.workingDirectory,
repository: value.repository,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date(0).toISOString(),
}
}
export class ActiveGitTargetStore {
constructor(filePath = TARGET_FILE) {
this.filePath = filePath
this.targets = new Map()
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, value] of Object.entries(parsed)) {
const record = targetRecord(value)
if (record !== undefined) this.targets.set(sessionId, record)
}
} catch {
this.targets.clear()
}
}
async get(sessionId) {
await this.ready
return this.targets.get(sessionId)
}
async set(sessionId, target) {
await this.ready
const record = { ...target, updatedAt: new Date().toISOString() }
this.targets.set(sessionId, record)
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
return record
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
const body = JSON.stringify(Object.fromEntries(this.targets), null, 2)
await writeFile(temporaryPath, `${body}\n`, 'utf8')
await rename(temporaryPath, this.filePath)
}
}
export async function resolveDirectoryRepository(directory) {
let canonicalWorkingDirectory
try {
canonicalWorkingDirectory = await realpath(directory)
const info = await stat(canonicalWorkingDirectory)
if (!info.isDirectory()) throw new Error('not a directory')
} catch {
throw Object.assign(new Error('working directory does not resolve'), { code: 'cwd-unavailable', status: 409 })
}
try {
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: canonicalWorkingDirectory,
encoding: 'utf8',
timeout: 10_000,
windowsHide: true,
})
const repository = await realpath(result.stdout.trim())
return { workingDirectory: canonicalWorkingDirectory, repository }
} catch {
throw Object.assign(new Error('working directory is not inside a Git repository'), { code: 'not-a-repository', status: 409 })
}
}
export async function resolveSessionRepository(targetStore, sessionId) {
const selected = await targetStore.get(sessionId)
if (selected === undefined) return null
try {
const resolved = await resolveDirectoryRepository(selected.workingDirectory)
return { ...resolved, updatedAt: selected.updatedAt }
} catch {
return null
}
}
export async function openSublimeMerge(repository) {
const child = spawn('smerge', [repository], {
detached: true,
stdio: 'ignore',
})
await new Promise((resolveSpawn, rejectSpawn) => {
child.once('spawn', resolveSpawn)
child.once('error', rejectSpawn)
})
child.unref()
}
function sessionIdFromToolExecution(execution) {
const sessionId = execution.agent?.session.header.id
if (typeof sessionId !== 'string' || sessionId === '') {
throw new Error('set_active_git_directory requires an active agent session')
}
return sessionId
}
function registerTargetTool(context, targetStore) {
return context.tools.register(defineTool({
name: 'set_active_git_directory',
description: 'Set the Git working directory shown by the session-header Git control and opened in Sublime Merge. Call this whenever you choose or switch the repository or linked worktree where you edit, build, or commit, so the control stays accurate.',
parameters: {
path: {
type: 'string',
required: true,
description: 'Absolute path, or a path relative to the session cwd, inside the active Git repository or linked worktree.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
workingDirectory: { type: 'string', required: true },
repository: { type: 'string', required: true },
},
},
render: (_arguments, value) => [{
type: 'text',
text: `Active Git directory: ${value.workingDirectory}\nRepository: ${value.repository}`,
}],
},
execute: async (argumentsValue, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdFromToolExecution(execution)
const base = execution.agent?.session.header.cwd
const requestedPath = argumentsValue.path.trim()
const absolutePath = isAbsolute(requestedPath) ? requestedPath : resolve(base || process.cwd(), requestedPath)
const target = await resolveDirectoryRepository(absolutePath)
await targetStore.set(sessionId, target)
return target
},
}))
}
function requestSessionId(requestUrl, host) {
try {
return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get('sessionId')?.trim() || ''
} catch {
return ''
}
}
export function apply(context) {
const targetStore = new ActiveGitTargetStore()
const targetHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
const sessionId = requestSessionId(request.url, request.headers.host)
if (sessionId === '') {
writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
return
}
const resolved = await resolveSessionRepository(targetStore, sessionId)
if (resolved === null) {
writeJson(response, 200, { ok: true, value: { set: false } })
return
}
writeJson(response, 200, { ok: true, value: { set: true, ...resolved } })
}
const openHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'POST') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
const body = await readJsonBody(request)
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
if (sessionId === '') {
writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
return
}
const resolved = await resolveSessionRepository(targetStore, sessionId)
if (resolved === null) {
writeJson(response, 409, { ok: false, error: { code: 'not-set', message: 'no Git working directory set for this session' } })
return
}
await openSublimeMerge(resolved.repository)
writeJson(response, 200, { ok: true, value: { set: true, ...resolved } })
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/smerge/target', handler: targetHandler }),
context.webServer.register({ kind: 'exact', path: '/smerge/open', handler: openHandler }),
registerTargetTool(context, targetStore),
context.systemPrompt.section({
name: 'plugin:open-in-smerge',
order: 145,
text: TARGET_GUIDANCE,
}),
]
return () => {
for (const dispose of disposers.reverse()) dispose()
}
}, 'open-in-smerge: routes, tool, and active Git target guidance')
}