@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/client.js +401 -346
- package/lib/config.js +29 -1
- package/lib/cost-telemetry.js +146 -0
- package/lib/index.js +94 -6
- package/lib/remote-relay.js +149 -0
- package/lib/remote-runner.js +164 -0
- package/lib/routes.js +97 -9
- package/lib/store.js +22 -0
- package/lib/worktree.js +35 -2
- package/package.json +1 -1
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
|
-
|
|
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) =>
|
|
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:
|
|
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(
|
|
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({
|
|
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({
|
|
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
|
+
}
|