@goodandready/dsh-kanban 0.2.32 → 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/config.js CHANGED
@@ -79,6 +79,18 @@ export const CONFIG_DEFAULTS = {
79
79
  verificationGateEnabled: true,
80
80
  verificationCommand: 'npm test',
81
81
  verificationTimeoutMs: 120000,
82
+ costMeterEnabled: true,
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: '',
82
94
  }
83
95
 
84
96
  /** Field descriptions reused in schema and settings card. */
@@ -117,6 +129,18 @@ export const CONFIG_HINTS = {
117
129
  verificationGateEnabled: 'Run automated verification gate (e.g. npm test) before allowing cards to move to Review.',
118
130
  verificationCommand: 'Default verification command to execute in task worktree before Review (default: npm test).',
119
131
  verificationTimeoutMs: 'Timeout in milliseconds for verification gate execution (default: 120000).',
132
+ costMeterEnabled: 'Enable token and cost telemetry integration via @goodandready/dsh-cost-meter.',
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.',
120
144
  }
121
145
 
122
146
  /** Setting key for column WIP limit; not all columns have limits. */
@@ -133,7 +157,11 @@ const WIP_FIELD = {
133
157
  */
134
158
  export function rootOf(config, cwdOf = () => process.cwd()) {
135
159
  const named = String(config?.defaultProjectRoot ?? '').trim()
136
- return named !== '' ? { path: named, set: true } : { path: cwdOf(), set: false }
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 }
137
165
  }
138
166
 
139
167
  /**
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Token and cost telemetry integration with @goodandready/dsh-cost-meter (#374).
3
+ * All functions fail-soft without throwing when cost-meter is absent.
4
+ */
5
+
6
+ /**
7
+ * Check whether @goodandready/dsh-cost-meter plugin is active in current runtime.
8
+ *
9
+ * @param {object} ctx - Cordis context
10
+ * @returns {boolean}
11
+ */
12
+ export function isCostMeterAvailable(ctx) {
13
+ if (!ctx || typeof ctx !== 'object') return false
14
+ try {
15
+ if (ctx.costMeter || (typeof ctx.get === 'function' && ctx.get('costMeter'))) return true
16
+ if (ctx.settings && typeof ctx.settings.describe === 'function') {
17
+ const rows = ctx.settings.describe({ redactSecrets: true })
18
+ if (Array.isArray(rows) && rows.some((r) => r?.ns === 'dsh-cost-meter')) return true
19
+ }
20
+ if (ctx.registry && typeof ctx.registry[Symbol.iterator] === 'function') {
21
+ for (const [plugin] of ctx.registry) {
22
+ if (plugin === '@goodandready/dsh-cost-meter' || plugin?.name === '@goodandready/dsh-cost-meter') {
23
+ return true
24
+ }
25
+ }
26
+ }
27
+ return false
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract aggregated token usage from a session's costByModel projection view.
35
+ *
36
+ * @param {object} projectionView - View object from usageByModelProjection
37
+ * @returns {{
38
+ * totalTokens: number,
39
+ * uncachedInputTokens: number,
40
+ * outputTokens: number,
41
+ * cacheReadTokens: number,
42
+ * cacheWriteTokens: number,
43
+ * turnsCount: number,
44
+ * models: Array<string>
45
+ * }}
46
+ */
47
+ export function extractSessionUsage(projectionView) {
48
+ const result = {
49
+ totalTokens: 0,
50
+ uncachedInputTokens: 0,
51
+ outputTokens: 0,
52
+ cacheReadTokens: 0,
53
+ cacheWriteTokens: 0,
54
+ turnsCount: 0,
55
+ models: [],
56
+ }
57
+ if (!projectionView || typeof projectionView !== 'object') return result
58
+
59
+ const modelsList = Array.isArray(projectionView.models) ? projectionView.models : []
60
+ for (const m of modelsList) {
61
+ const key = (m.provider ? m.provider + '/' : '') + (m.model || '')
62
+ if (key && !result.models.includes(key)) result.models.push(key)
63
+ const slots = m.slots && typeof m.slots === 'object' ? m.slots : {}
64
+ for (const slotKey of Object.keys(slots)) {
65
+ const b = slots[slotKey] || {}
66
+ result.uncachedInputTokens += Number(b.uncachedInputTokens) || 0
67
+ result.outputTokens += Number(b.outputTokens) || 0
68
+ result.cacheReadTokens += Number(b.cacheReadTokens) || 0
69
+ result.cacheWriteTokens += Number(b.cacheWriteTokens) || 0
70
+ }
71
+ }
72
+
73
+ if (Array.isArray(projectionView.turns)) {
74
+ result.turnsCount = projectionView.turns.length
75
+ if (result.totalTokens === 0 && projectionView.turns.length > 0) {
76
+ for (const t of projectionView.turns) {
77
+ result.uncachedInputTokens += Number(t.uncachedInputTokens) || 0
78
+ result.outputTokens += Number(t.outputTokens) || 0
79
+ result.cacheReadTokens += Number(t.cacheReadTokens) || 0
80
+ result.cacheWriteTokens += Number(t.cacheWriteTokens) || 0
81
+ }
82
+ }
83
+ }
84
+
85
+ result.totalTokens = result.uncachedInputTokens + result.outputTokens + result.cacheReadTokens + result.cacheWriteTokens
86
+ return result
87
+ }
88
+
89
+ /**
90
+ * Calculate cost for token usage given rate schedule or fallback tariff.
91
+ * Default tariff: standard DeepSeek rates ($0.14/M input, $0.28/M output, $0.014/M cache hit).
92
+ *
93
+ * @param {{
94
+ * usage: object,
95
+ * schedule?: object,
96
+ * usdRate?: number,
97
+ * currency?: string
98
+ * }} options
99
+ * @returns {{
100
+ * costUsd: number,
101
+ * costFormatted: string,
102
+ * currency: string
103
+ * }}
104
+ */
105
+ export function calculateTokenCost({ usage, schedule, usdRate = 1, currency = '$' }) {
106
+ if (!usage || typeof usage !== 'object') {
107
+ return { costUsd: 0, costFormatted: currency + '0.00', currency }
108
+ }
109
+
110
+ const uncachedInput = Number(usage.uncachedInputTokens) || 0
111
+ const output = Number(usage.outputTokens) || 0
112
+ const cacheRead = Number(usage.cacheReadTokens) || 0
113
+ const cacheWrite = Number(usage.cacheWriteTokens) || 0
114
+
115
+ const rateInput = Number(schedule?.inputPerMillion ?? schedule?.input) || 0.14
116
+ const rateOutput = Number(schedule?.outputPerMillion ?? schedule?.output) || 0.28
117
+ const rateCacheRead = Number(schedule?.cacheReadPerMillion ?? schedule?.cacheHit) || 0.014
118
+ const rateCacheWrite = Number(schedule?.cacheWritePerMillion) || rateInput
119
+
120
+ const costUsd = (uncachedInput * rateInput + output * rateOutput + cacheRead * rateCacheRead + cacheWrite * rateCacheWrite) / 1_000_000
121
+ const converted = costUsd * (Number(usdRate) > 0 ? Number(usdRate) : 1)
122
+
123
+ const costFormatted = currency + (converted < 0.01 && converted > 0 ? '<0.01' : converted.toFixed(2))
124
+
125
+ return {
126
+ costUsd: Math.round(costUsd * 10000) / 10000,
127
+ costFormatted,
128
+ currency,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Check whether task cost exceeds configured budget limit.
134
+ *
135
+ * @param {{ costUsd: number, limit: number }} options
136
+ * @returns {{ exceeded: boolean, limit: number, costUsd: number }}
137
+ */
138
+ export function checkCostLimit({ costUsd = 0, limit = 0 }) {
139
+ const numLimit = Number(limit) || 0
140
+ if (numLimit <= 0) return { exceeded: false, limit: 0, costUsd }
141
+ return {
142
+ exceeded: costUsd >= numLimit,
143
+ limit: numLimit,
144
+ costUsd,
145
+ }
146
+ }
package/lib/index.js CHANGED
@@ -51,7 +51,11 @@ import {
51
51
  handleCreateSubtask,
52
52
  handleToggleChecklistItem,
53
53
  handleCreateTaskPr,
54
+ handleTaskNudge,
54
55
  } from './routes.js'
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'
55
59
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
56
60
  import {
57
61
  runTask, resolveModel, buildStartMessage,
@@ -211,6 +215,7 @@ function urlOf(req) {
211
215
  export function apply(ctx, config) {
212
216
  let effective = withDefaults(config)
213
217
  const getConfig = () => effective
218
+ const getGitRunner = () => createRemoteGitRunner(getConfig(), defaultGitRunner)
214
219
 
215
220
  // Settings control live subscriptions: sync intervals and agent tools.
216
221
  // Dynamic watcher re-evaluates configurations on change without requiring restart.
@@ -867,28 +872,89 @@ export function apply(ctx, config) {
867
872
  /**
868
873
  * Return active agent state or undefined if session is inactive.
869
874
  */
870
- const liveOf = (sessionId) => ctx.agents.get(sessionId)?.status
875
+ const liveOf = (sessionId) => {
876
+ try {
877
+ const a = ctx.agents?.get?.(sessionId);
878
+ if (!a) return undefined;
879
+ return {
880
+ status: a.status || (typeof a.isIdle === 'function' && a.isIdle() ? 'idle' : 'running'),
881
+ state: a.state || a.lifecycleState,
882
+ step: a.step || a.currentStep || a.stateDetail || a.activity || '',
883
+ activity: a.activity || a.statusDetail || '',
884
+ };
885
+ } catch {
886
+ return undefined;
887
+ }
888
+ };
871
889
 
872
890
  ctx.effect(() => ctx.webServer.register({
873
891
  kind: 'exact',
874
892
  path: '/dsh-kanban/board',
875
- handler: (req, res) => {
893
+ handler: async (req, res) => {
876
894
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
877
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
+ }
878
908
  const url = urlOf(req)
879
909
  reply(res, buildBoard({
880
910
  store,
881
- config: getConfig(),
911
+ config: cfg,
882
912
  board: url.searchParams.get('board') || 'main',
883
913
  repo: url.searchParams.get('repo') || undefined,
884
914
  liveOf,
885
915
  sync: syncState.snapshot(),
886
916
  // Resolved project root passed with board payload for session launch modal.
887
- projectRoot: rootOf(getConfig()),
917
+ projectRoot: rootOf(cfg),
918
+ costMeterAvailable: isCostMeterAvailable(ctx),
888
919
  }))
889
920
  },
890
921
  }), 'dsh-kanban: board route')
891
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
+
892
958
  // Snapshot endpoint handles both export and import.
893
959
  /**
894
960
  * Cached identity for Gitea client to avoid redundant API lookups on task claim.
@@ -1211,6 +1277,7 @@ export function apply(ctx, config) {
1211
1277
  workspaceRegistry: ctx.get('workspaceRegistry'),
1212
1278
  force: input?.force === true,
1213
1279
  cleanupTaskWorktree,
1280
+ gitRunner: getGitRunner(),
1214
1281
  config: getConfig(),
1215
1282
  repoDir: rootOf(getConfig()).path,
1216
1283
  cwdOf: () => rootOf(getConfig()).path,
@@ -1223,6 +1290,7 @@ export function apply(ctx, config) {
1223
1290
  task: store.getTask(id),
1224
1291
  file: input?.file,
1225
1292
  discardTaskFile,
1293
+ gitRunner: getGitRunner(),
1226
1294
  })
1227
1295
  if (resObj?.body?.success) {
1228
1296
  eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
@@ -1230,13 +1298,32 @@ export function apply(ctx, config) {
1230
1298
  writeJson(res, resObj.status, resObj.body)
1231
1299
  return
1232
1300
  }
1301
+ if (req.method === 'POST' && (action === 'nudge' || (input && input.action === 'nudge'))) {
1302
+ const resObj = await handleTaskNudge({
1303
+ store,
1304
+ id,
1305
+ prompt: input?.prompt || input?.text,
1306
+ agents: ctx.agents,
1307
+ createMessage: createUserMessage,
1308
+ eventHub,
1309
+ })
1310
+ writeJson(res, resObj.status, resObj.body)
1311
+ return
1312
+ }
1233
1313
  if (req.method === 'GET' && action === 'diff') {
1234
- const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff, getTaskDiffInspector })
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
+ })
1235
1319
  writeJson(res, resObj.status, resObj.body)
1236
1320
  return
1237
1321
  }
1238
1322
  if (req.method === 'GET' && action === 'commits') {
1239
- const resObj = await handleTaskCommits({ task: store.getTask(id), getTaskCommits })
1323
+ const resObj = await handleTaskCommits({
1324
+ task: store.getTask(id),
1325
+ getTaskCommits: (opts) => getTaskCommits({ ...opts, gitRunner: getGitRunner() }),
1326
+ })
1240
1327
  writeJson(res, resObj.status, resObj.body)
1241
1328
  return
1242
1329
  }
@@ -1246,6 +1333,7 @@ export function apply(ctx, config) {
1246
1333
  targetBranch: input?.targetBranch,
1247
1334
  workspaceRegistry: ctx.get('workspaceRegistry'),
1248
1335
  mergeTaskWorktree,
1336
+ gitRunner: getGitRunner(),
1249
1337
  })
1250
1338
  writeJson(res, resObj.status, resObj.body)
1251
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
+ }