@goodandready/dsh-kanban 0.2.33 → 0.2.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +140 -345
- package/lib/config.js +25 -1
- package/lib/index.js +65 -5
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- package/lib/routes.js +9 -6
- package/lib/worktree.js +35 -2
- package/package.json +1 -1
package/lib/config.js
CHANGED
|
@@ -81,6 +81,16 @@ export const CONFIG_DEFAULTS = {
|
|
|
81
81
|
verificationTimeoutMs: 120000,
|
|
82
82
|
costMeterEnabled: true,
|
|
83
83
|
taskCostLimit: 0,
|
|
84
|
+
remoteMode: false,
|
|
85
|
+
remoteHost: '192.168.1.111',
|
|
86
|
+
remotePort: 22,
|
|
87
|
+
remoteUser: 'migrate',
|
|
88
|
+
remoteAuthType: 'key',
|
|
89
|
+
remoteKeyPath: '',
|
|
90
|
+
remoteSudoUser: 'vadim',
|
|
91
|
+
remoteRoot: '/mnt/external/Project/DEV',
|
|
92
|
+
remoteServerUrl: 'http://192.168.1.111:3080',
|
|
93
|
+
remoteServerToken: '',
|
|
84
94
|
}
|
|
85
95
|
|
|
86
96
|
/** Field descriptions reused in schema and settings card. */
|
|
@@ -121,6 +131,16 @@ export const CONFIG_HINTS = {
|
|
|
121
131
|
verificationTimeoutMs: 'Timeout in milliseconds for verification gate execution (default: 120000).',
|
|
122
132
|
costMeterEnabled: 'Enable token and cost telemetry integration via @goodandready/dsh-cost-meter.',
|
|
123
133
|
taskCostLimit: 'Cost threshold per task in USD; 0 disables threshold warnings.',
|
|
134
|
+
remoteMode: 'Enable remote execution on server 111 for tasks, worktrees, and git operations.',
|
|
135
|
+
remoteHost: 'Remote server hostname or IP address (e.g. 192.168.1.111).',
|
|
136
|
+
remotePort: 'Remote SSH port (default: 22).',
|
|
137
|
+
remoteUser: 'SSH username on remote server (e.g. migrate or vadim).',
|
|
138
|
+
remoteAuthType: 'SSH authentication mechanism: key, agent, or password.',
|
|
139
|
+
remoteKeyPath: 'Path to SSH private key file on local PC (e.g. C:/Users/.../.ssh/key).',
|
|
140
|
+
remoteSudoUser: 'Linux user to run commands as via sudo -u (e.g. vadim).',
|
|
141
|
+
remoteRoot: 'Base directory on remote server containing project repositories (/mnt/external/Project/DEV).',
|
|
142
|
+
remoteServerUrl: 'DSH server URL on 111 (http://192.168.1.111:3080) for unified board state and sessions.',
|
|
143
|
+
remoteServerToken: 'Authentication token for remote DSH server if required.',
|
|
124
144
|
}
|
|
125
145
|
|
|
126
146
|
/** Setting key for column WIP limit; not all columns have limits. */
|
|
@@ -137,7 +157,11 @@ const WIP_FIELD = {
|
|
|
137
157
|
*/
|
|
138
158
|
export function rootOf(config, cwdOf = () => process.cwd()) {
|
|
139
159
|
const named = String(config?.defaultProjectRoot ?? '').trim()
|
|
140
|
-
|
|
160
|
+
if (named !== '') return { path: named, set: true }
|
|
161
|
+
if (config?.remoteMode && config?.remoteRoot) {
|
|
162
|
+
return { path: String(config.remoteRoot).trim(), set: true }
|
|
163
|
+
}
|
|
164
|
+
return { path: cwdOf(), set: false }
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/**
|
package/lib/index.js
CHANGED
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
handleTaskNudge,
|
|
55
55
|
} from './routes.js'
|
|
56
56
|
import { isCostMeterAvailable } from './cost-telemetry.js'
|
|
57
|
+
import { createRemoteGitRunner, testRemoteConnection, isRemoteMode } from './remote-runner.js'
|
|
58
|
+
import { shouldRelay, relayRequest, testRemoteServer } from './remote-relay.js'
|
|
57
59
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
58
60
|
import {
|
|
59
61
|
runTask, resolveModel, buildStartMessage,
|
|
@@ -213,6 +215,7 @@ function urlOf(req) {
|
|
|
213
215
|
export function apply(ctx, config) {
|
|
214
216
|
let effective = withDefaults(config)
|
|
215
217
|
const getConfig = () => effective
|
|
218
|
+
const getGitRunner = () => createRemoteGitRunner(getConfig(), defaultGitRunner)
|
|
216
219
|
|
|
217
220
|
// Settings control live subscriptions: sync intervals and agent tools.
|
|
218
221
|
// Dynamic watcher re-evaluates configurations on change without requiring restart.
|
|
@@ -887,24 +890,71 @@ export function apply(ctx, config) {
|
|
|
887
890
|
ctx.effect(() => ctx.webServer.register({
|
|
888
891
|
kind: 'exact',
|
|
889
892
|
path: '/dsh-kanban/board',
|
|
890
|
-
handler: (req, res) => {
|
|
893
|
+
handler: async (req, res) => {
|
|
891
894
|
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
892
895
|
if (rejectUntrusted(req, res)) return
|
|
896
|
+
const cfg = getConfig()
|
|
897
|
+
if (shouldRelay(cfg)) {
|
|
898
|
+
const url = urlOf(req)
|
|
899
|
+
const relayRes = await relayRequest({
|
|
900
|
+
config: cfg,
|
|
901
|
+
urlPath: '/dsh-kanban/board' + (url.search || ''),
|
|
902
|
+
method: 'GET',
|
|
903
|
+
headers: req.headers,
|
|
904
|
+
})
|
|
905
|
+
writeJson(res, relayRes.status, relayRes.body)
|
|
906
|
+
return
|
|
907
|
+
}
|
|
893
908
|
const url = urlOf(req)
|
|
894
909
|
reply(res, buildBoard({
|
|
895
910
|
store,
|
|
896
|
-
config:
|
|
911
|
+
config: cfg,
|
|
897
912
|
board: url.searchParams.get('board') || 'main',
|
|
898
913
|
repo: url.searchParams.get('repo') || undefined,
|
|
899
914
|
liveOf,
|
|
900
915
|
sync: syncState.snapshot(),
|
|
901
916
|
// Resolved project root passed with board payload for session launch modal.
|
|
902
|
-
projectRoot: rootOf(
|
|
917
|
+
projectRoot: rootOf(cfg),
|
|
903
918
|
costMeterAvailable: isCostMeterAvailable(ctx),
|
|
904
919
|
}))
|
|
905
920
|
},
|
|
906
921
|
}), 'dsh-kanban: board route')
|
|
907
922
|
|
|
923
|
+
ctx.effect(() => ctx.webServer.register({
|
|
924
|
+
kind: 'exact',
|
|
925
|
+
path: '/dsh-kanban/remote/test',
|
|
926
|
+
handler: async (req, res) => {
|
|
927
|
+
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
928
|
+
if (rejectUntrusted(req, res)) return
|
|
929
|
+
const cfg = getConfig()
|
|
930
|
+
const sshRes = await testRemoteConnection(cfg)
|
|
931
|
+
const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
|
|
932
|
+
writeJson(res, 200, {
|
|
933
|
+
remoteMode: isRemoteMode(cfg),
|
|
934
|
+
ssh: sshRes,
|
|
935
|
+
server: serverRes,
|
|
936
|
+
})
|
|
937
|
+
},
|
|
938
|
+
}), 'dsh-kanban: remote connection test')
|
|
939
|
+
|
|
940
|
+
ctx.effect(() => ctx.webServer.register({
|
|
941
|
+
kind: 'exact',
|
|
942
|
+
path: '/api/dsh-kanban/remote/test',
|
|
943
|
+
handler: async (req, res) => {
|
|
944
|
+
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
|
|
945
|
+
if (rejectUntrusted(req, res)) return
|
|
946
|
+
const cfg = getConfig()
|
|
947
|
+
const sshRes = await testRemoteConnection(cfg)
|
|
948
|
+
const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
|
|
949
|
+
writeJson(res, 200, {
|
|
950
|
+
remoteMode: isRemoteMode(cfg),
|
|
951
|
+
ssh: sshRes,
|
|
952
|
+
server: serverRes,
|
|
953
|
+
})
|
|
954
|
+
},
|
|
955
|
+
}), 'dsh-kanban: remote connection test alias')
|
|
956
|
+
|
|
957
|
+
|
|
908
958
|
// Snapshot endpoint handles both export and import.
|
|
909
959
|
/**
|
|
910
960
|
* Cached identity for Gitea client to avoid redundant API lookups on task claim.
|
|
@@ -1227,6 +1277,7 @@ export function apply(ctx, config) {
|
|
|
1227
1277
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1228
1278
|
force: input?.force === true,
|
|
1229
1279
|
cleanupTaskWorktree,
|
|
1280
|
+
gitRunner: getGitRunner(),
|
|
1230
1281
|
config: getConfig(),
|
|
1231
1282
|
repoDir: rootOf(getConfig()).path,
|
|
1232
1283
|
cwdOf: () => rootOf(getConfig()).path,
|
|
@@ -1239,6 +1290,7 @@ export function apply(ctx, config) {
|
|
|
1239
1290
|
task: store.getTask(id),
|
|
1240
1291
|
file: input?.file,
|
|
1241
1292
|
discardTaskFile,
|
|
1293
|
+
gitRunner: getGitRunner(),
|
|
1242
1294
|
})
|
|
1243
1295
|
if (resObj?.body?.success) {
|
|
1244
1296
|
eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
|
|
@@ -1259,12 +1311,19 @@ export function apply(ctx, config) {
|
|
|
1259
1311
|
return
|
|
1260
1312
|
}
|
|
1261
1313
|
if (req.method === 'GET' && action === 'diff') {
|
|
1262
|
-
const resObj = await handleTaskDiff({
|
|
1314
|
+
const resObj = await handleTaskDiff({
|
|
1315
|
+
task: store.getTask(id),
|
|
1316
|
+
getTaskDiff: (opts) => getTaskDiff({ ...opts, gitRunner: getGitRunner() }),
|
|
1317
|
+
getTaskDiffInspector: (opts) => getTaskDiffInspector({ ...opts, gitRunner: getGitRunner() }),
|
|
1318
|
+
})
|
|
1263
1319
|
writeJson(res, resObj.status, resObj.body)
|
|
1264
1320
|
return
|
|
1265
1321
|
}
|
|
1266
1322
|
if (req.method === 'GET' && action === 'commits') {
|
|
1267
|
-
const resObj = await handleTaskCommits({
|
|
1323
|
+
const resObj = await handleTaskCommits({
|
|
1324
|
+
task: store.getTask(id),
|
|
1325
|
+
getTaskCommits: (opts) => getTaskCommits({ ...opts, gitRunner: getGitRunner() }),
|
|
1326
|
+
})
|
|
1268
1327
|
writeJson(res, resObj.status, resObj.body)
|
|
1269
1328
|
return
|
|
1270
1329
|
}
|
|
@@ -1274,6 +1333,7 @@ export function apply(ctx, config) {
|
|
|
1274
1333
|
targetBranch: input?.targetBranch,
|
|
1275
1334
|
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
1276
1335
|
mergeTaskWorktree,
|
|
1336
|
+
gitRunner: getGitRunner(),
|
|
1277
1337
|
})
|
|
1278
1338
|
writeJson(res, resObj.status, resObj.body)
|
|
1279
1339
|
return
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote DSH server relay client (#390).
|
|
3
|
+
* Proxies board state, task actions, and sessions between PC client and server 111.
|
|
4
|
+
* Zero Cyrillic literals in lib/ according to Issue #366 standard.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether requests should be relayed to remote DSH server.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function shouldRelay(config) {
|
|
14
|
+
return Boolean(config && config.remoteMode && config.remoteServerUrl)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve target URL on remote DSH server.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} config
|
|
21
|
+
* @param {string} path
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function getRemoteServerUrl(config, path = '') {
|
|
25
|
+
const base = String(config?.remoteServerUrl || 'http://192.168.1.111:3080').trim().replace(/\/+$/, '')
|
|
26
|
+
const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : ''
|
|
27
|
+
return `${base}${cleanPath}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build request headers including DSH auth token and cookies.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} config
|
|
34
|
+
* @param {object} [originalHeaders]
|
|
35
|
+
* @returns {Record<string, string>}
|
|
36
|
+
*/
|
|
37
|
+
export function buildRemoteHeaders(config, originalHeaders = {}) {
|
|
38
|
+
const headers = {
|
|
39
|
+
'Accept': 'application/json, text/plain, */*',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (originalHeaders && typeof originalHeaders === 'object') {
|
|
43
|
+
if (originalHeaders['content-type']) {
|
|
44
|
+
headers['Content-Type'] = originalHeaders['content-type']
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const token = String(config?.remoteServerToken || '').trim()
|
|
49
|
+
if (token) {
|
|
50
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
51
|
+
headers['Cookie'] = `dsh_token=${token}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return headers
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Relay an HTTP request to remote DSH server.
|
|
59
|
+
*
|
|
60
|
+
* @param {{
|
|
61
|
+
* config: object,
|
|
62
|
+
* path: string,
|
|
63
|
+
* method?: string,
|
|
64
|
+
* body?: any,
|
|
65
|
+
* headers?: object,
|
|
66
|
+
* timeoutMs?: number
|
|
67
|
+
* }} options
|
|
68
|
+
* @returns {Promise<{ status: number, body: any, headers?: object }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function relayRequest({ config, path, urlPath, method = 'GET', body, headers, timeoutMs = 15000 }) {
|
|
71
|
+
const targetPath = urlPath || path || ''
|
|
72
|
+
const url = getRemoteServerUrl(config, targetPath)
|
|
73
|
+
const reqHeaders = buildRemoteHeaders(config, headers)
|
|
74
|
+
|
|
75
|
+
const fetchOptions = {
|
|
76
|
+
method,
|
|
77
|
+
headers: reqHeaders,
|
|
78
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
|
|
82
|
+
if (typeof body === 'string') {
|
|
83
|
+
fetchOptions.body = body
|
|
84
|
+
if (!fetchOptions.headers['Content-Type']) {
|
|
85
|
+
fetchOptions.headers['Content-Type'] = 'application/json'
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
fetchOptions.body = JSON.stringify(body)
|
|
89
|
+
fetchOptions.headers['Content-Type'] = 'application/json'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(url, fetchOptions)
|
|
95
|
+
const contentType = res.headers.get('content-type') || ''
|
|
96
|
+
let parsedBody
|
|
97
|
+
if (contentType.includes('application/json')) {
|
|
98
|
+
parsedBody = await res.json()
|
|
99
|
+
} else {
|
|
100
|
+
parsedBody = await res.text()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
status: res.status,
|
|
105
|
+
body: parsedBody,
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return {
|
|
109
|
+
status: 503,
|
|
110
|
+
body: {
|
|
111
|
+
error: 'remote-server-unavailable',
|
|
112
|
+
detail: `Failed to reach remote DSH server at ${url}: ${err.message}`,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Probe remote DSH server connection and kanban board endpoint.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} config
|
|
122
|
+
* @returns {Promise<{ ok: boolean, latencyMs?: number, status?: number, error?: string }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function testRemoteServer(config) {
|
|
125
|
+
const startTime = Date.now()
|
|
126
|
+
const res = await relayRequest({
|
|
127
|
+
config,
|
|
128
|
+
urlPath: '/dsh-kanban/board',
|
|
129
|
+
method: 'GET',
|
|
130
|
+
timeoutMs: 8000,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const latencyMs = Date.now() - startTime
|
|
134
|
+
|
|
135
|
+
if (res.status === 200) {
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
latencyMs,
|
|
139
|
+
status: res.status,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
latencyMs,
|
|
146
|
+
status: res.status,
|
|
147
|
+
error: res.body?.detail || res.body?.error || `HTTP ${res.status}`,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote SSH runner for git and worktree operations (#390).
|
|
3
|
+
* Allows DSH running on PC (Windows) to execute commands on remote server (111).
|
|
4
|
+
* Zero Cyrillic literals in lib/ according to Issue #366 standard.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { promisify } from 'node:util'
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check whether remote execution mode is enabled in config.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} config
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function isRemoteMode(config) {
|
|
19
|
+
return Boolean(config && config.remoteMode)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Quote argument safely for remote POSIX shell execution.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|number} s
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function quoteArg(s) {
|
|
29
|
+
return "'" + String(s ?? '').replace(/'/g, "'\\''") + "'"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build arguments array for SSH CLI process.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} config
|
|
36
|
+
* @param {string} remoteCommand
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
export function buildSshArgs(config, remoteCommand) {
|
|
40
|
+
const host = String(config?.remoteHost || '192.168.1.111').trim()
|
|
41
|
+
const port = Number(config?.remotePort) || 22
|
|
42
|
+
const user = String(config?.remoteUser || 'migrate').trim()
|
|
43
|
+
const keyPath = String(config?.remoteKeyPath || '').trim()
|
|
44
|
+
const sudoUser = config?.remoteSudoUser !== undefined ? String(config.remoteSudoUser).trim() : 'vadim'
|
|
45
|
+
|
|
46
|
+
const args = [
|
|
47
|
+
'-p', String(port),
|
|
48
|
+
'-o', 'BatchMode=yes',
|
|
49
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
50
|
+
'-o', 'ConnectTimeout=10',
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if (keyPath) {
|
|
54
|
+
args.push('-i', keyPath)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const target = `${user}@${host}`
|
|
58
|
+
args.push(target)
|
|
59
|
+
|
|
60
|
+
let fullCmd = remoteCommand
|
|
61
|
+
if (sudoUser) {
|
|
62
|
+
fullCmd = `sudo -u ${sudoUser} -H bash -c ${quoteArg(remoteCommand)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
args.push(fullCmd)
|
|
66
|
+
return args
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a git runner function that delegates to remote host over SSH when remoteMode is active.
|
|
71
|
+
* Conforms to (args, cwd) => Promise<{ stdout: string, stderr: string }>.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} config
|
|
74
|
+
* @param {Function} [localGitRunner] fallback local runner
|
|
75
|
+
* @returns {Function}
|
|
76
|
+
*/
|
|
77
|
+
export function createRemoteGitRunner(config, localGitRunner) {
|
|
78
|
+
return async function remoteGitRunner(args, cwd) {
|
|
79
|
+
if (!isRemoteMode(config)) {
|
|
80
|
+
if (typeof localGitRunner === 'function') {
|
|
81
|
+
return localGitRunner(args, cwd)
|
|
82
|
+
}
|
|
83
|
+
throw new Error('[dsh-kanban] No local git runner available in non-remote mode')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const escapedArgs = (Array.isArray(args) ? args : []).map(quoteArg).join(' ')
|
|
87
|
+
const cdPart = cwd ? `cd ${quoteArg(cwd)} && ` : ''
|
|
88
|
+
const gitCmd = `${cdPart}git ${escapedArgs}`
|
|
89
|
+
const sshArgs = buildSshArgs(config, gitCmd)
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { stdout, stderr } = await execFileAsync('ssh', sshArgs, {
|
|
93
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
94
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
|
|
95
|
+
})
|
|
96
|
+
return { stdout: stdout ?? '', stderr: stderr ?? '' }
|
|
97
|
+
} catch (err) {
|
|
98
|
+
const error = new Error(`[dsh-kanban-remote] git ${args?.[0] || ''} failed on ${config?.remoteHost || 'remote'}: ${err.message}`)
|
|
99
|
+
error.code = err.code ?? 1
|
|
100
|
+
error.stdout = err.stdout ?? ''
|
|
101
|
+
error.stderr = err.stderr ?? err.message ?? ''
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check connectivity to remote server and verify remote root directory.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} config
|
|
111
|
+
* @returns {Promise<{ ok: boolean, latencyMs?: number, remoteRootFound?: boolean, error?: string }>}
|
|
112
|
+
*/
|
|
113
|
+
export async function testRemoteConnection(config) {
|
|
114
|
+
const root = String(config?.remoteRoot || '/mnt/external/Project/DEV').trim()
|
|
115
|
+
const probeCmd = `test -d ${quoteArg(root)} && echo REMOTE_ROOT_OK || echo REMOTE_ROOT_MISSING`
|
|
116
|
+
const sshArgs = buildSshArgs(config, probeCmd)
|
|
117
|
+
|
|
118
|
+
const startTime = Date.now()
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await execFileAsync('ssh', sshArgs, {
|
|
121
|
+
maxBuffer: 1024 * 1024,
|
|
122
|
+
timeout: 12000,
|
|
123
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
124
|
+
})
|
|
125
|
+
const latencyMs = Date.now() - startTime
|
|
126
|
+
const trimmed = (stdout || '').trim()
|
|
127
|
+
const remoteRootFound = trimmed.includes('REMOTE_ROOT_OK')
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
latencyMs,
|
|
132
|
+
remoteRootFound,
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: err.stderr ? err.stderr.trim() : err.message,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if path exists on remote host.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} remotePath
|
|
146
|
+
* @param {object} config
|
|
147
|
+
* @returns {Promise<boolean>}
|
|
148
|
+
*/
|
|
149
|
+
export async function remotePathExists(remotePath, config) {
|
|
150
|
+
if (!remotePath) return false
|
|
151
|
+
const probeCmd = `test -e ${quoteArg(remotePath)} && echo 1 || echo 0`
|
|
152
|
+
const sshArgs = buildSshArgs(config, probeCmd)
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const { stdout } = await execFileAsync('ssh', sshArgs, {
|
|
156
|
+
maxBuffer: 1024 * 1024,
|
|
157
|
+
timeout: 10000,
|
|
158
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
159
|
+
})
|
|
160
|
+
return (stdout || '').trim() === '1'
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -398,6 +398,7 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
|
|
|
398
398
|
// Sync telemetry state attached directly to board snapshot.
|
|
399
399
|
sync: sync ?? { state: 'never' },
|
|
400
400
|
costMeterAvailable: Boolean(costMeterAvailable),
|
|
401
|
+
remoteMode: Boolean(config?.remoteMode),
|
|
401
402
|
}
|
|
402
403
|
}
|
|
403
404
|
|
|
@@ -767,7 +768,7 @@ export function exportTasksCsv({ store, board = 'main' }) {
|
|
|
767
768
|
*/
|
|
768
769
|
export async function handleCleanupWorktree({
|
|
769
770
|
store, task, workspaceRegistry, force, cleanupTaskWorktree,
|
|
770
|
-
config, repoDir, cwdOf, env,
|
|
771
|
+
config, repoDir, cwdOf, env, gitRunner,
|
|
771
772
|
}) {
|
|
772
773
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
773
774
|
const out = await cleanupTaskWorktree({
|
|
@@ -779,6 +780,7 @@ export async function handleCleanupWorktree({
|
|
|
779
780
|
repoDir,
|
|
780
781
|
cwdOf,
|
|
781
782
|
env,
|
|
783
|
+
gitRunner,
|
|
782
784
|
})
|
|
783
785
|
const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
|
|
784
786
|
return { status, body: out }
|
|
@@ -800,32 +802,33 @@ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }
|
|
|
800
802
|
/**
|
|
801
803
|
* Discard file changes in task worktree (#371).
|
|
802
804
|
*/
|
|
803
|
-
export async function handleTaskDiscard({ task, file, discardTaskFile }) {
|
|
805
|
+
export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
|
|
804
806
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
805
807
|
if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
|
|
806
808
|
if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
|
|
807
|
-
const result = await discardTaskFile({ worktreePath: task.worktree, file })
|
|
809
|
+
const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
|
|
808
810
|
return { status: result.success ? 200 : 400, body: result }
|
|
809
811
|
}
|
|
810
812
|
|
|
811
813
|
/**
|
|
812
814
|
* Get commit list for task.
|
|
813
815
|
*/
|
|
814
|
-
export async function handleTaskCommits({ task, getTaskCommits }) {
|
|
816
|
+
export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
|
|
815
817
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
816
|
-
const commits = await getTaskCommits({ worktreePath: task.worktree })
|
|
818
|
+
const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
|
|
817
819
|
return { status: 200, body: { commits } }
|
|
818
820
|
}
|
|
819
821
|
|
|
820
822
|
/**
|
|
821
823
|
* Merge task worktree branch and transition to done.
|
|
822
824
|
*/
|
|
823
|
-
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
|
|
825
|
+
export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
|
|
824
826
|
if (!task) return { status: 404, body: { error: 'task-not-found' } }
|
|
825
827
|
const mergeResult = await mergeTaskWorktree({
|
|
826
828
|
task,
|
|
827
829
|
targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
|
|
828
830
|
workspaceRegistry,
|
|
831
|
+
gitRunner,
|
|
829
832
|
})
|
|
830
833
|
if (!mergeResult.success) {
|
|
831
834
|
return {
|
package/lib/worktree.js
CHANGED
|
@@ -71,7 +71,32 @@ export function safeRepoKey(task) {
|
|
|
71
71
|
/**
|
|
72
72
|
* Resolve root directory for all managed worktrees.
|
|
73
73
|
*/
|
|
74
|
+
/**
|
|
75
|
+
* Check if worktree directory exists locally or remotely.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} worktreePath
|
|
78
|
+
* @param {Function} [gitRunner]
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function safeWorktreeExists(worktreePath, gitRunner) {
|
|
82
|
+
if (!worktreePath) return false
|
|
83
|
+
if (existsSync(worktreePath)) return true
|
|
84
|
+
if (typeof gitRunner === 'function') {
|
|
85
|
+
try {
|
|
86
|
+
const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], worktreePath)
|
|
87
|
+
return res.stdout?.trim() === 'true'
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
|
|
96
|
+
if (config?.remoteMode && config?.remoteRoot) {
|
|
97
|
+
const root = String(config.remoteRoot).trim().replace(/\/+$/, '')
|
|
98
|
+
return `${root}/.worktrees`
|
|
99
|
+
}
|
|
75
100
|
const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
|
|
76
101
|
if (custom !== '') {
|
|
77
102
|
return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
|
|
@@ -105,6 +130,12 @@ export function isPathFenced(targetPath, rootPath) {
|
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
133
|
+
if (config?.remoteMode) {
|
|
134
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
135
|
+
const repoKey = safeRepoKey(task)
|
|
136
|
+
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
137
|
+
return `${root}/${repoKey}/${taskId}`
|
|
138
|
+
}
|
|
108
139
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
109
140
|
const repoKey = safeRepoKey(task)
|
|
110
141
|
const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
|
|
@@ -261,7 +292,8 @@ function isSubmoduleDrift(worktreePath, line) {
|
|
|
261
292
|
* Excludes submodule noise and gitlink drift (#213).
|
|
262
293
|
*/
|
|
263
294
|
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
264
|
-
|
|
295
|
+
const exists = await safeWorktreeExists(worktreePath, gitRunner)
|
|
296
|
+
if (!worktreePath || !exists) {
|
|
265
297
|
return { dirty: false, files: [] }
|
|
266
298
|
}
|
|
267
299
|
try {
|
|
@@ -289,7 +321,8 @@ export async function removeTaskWorktree({
|
|
|
289
321
|
gitRunner = defaultGitRunner,
|
|
290
322
|
rootPath,
|
|
291
323
|
}) {
|
|
292
|
-
|
|
324
|
+
const exists = await safeWorktreeExists(worktreePath, gitRunner)
|
|
325
|
+
if (!worktreePath || !exists) {
|
|
293
326
|
return { removed: false, reason: 'not-found' }
|
|
294
327
|
}
|
|
295
328
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|