@goodandready/dsh-kanban 0.2.33 → 0.2.35

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.
@@ -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
+ }
@@ -0,0 +1,282 @@
1
+ import { COLUMN_ORDER, columnsOf, wipLimitField, columnNamesOf } from '../config.js'
2
+ import { parsePlan, planItems, planProgress, taskState, queuePosition } from '../plan.js'
3
+ import { columnStats, doneWithin, stale } from '../metrics.js'
4
+ import { isStale } from '../time.js'
5
+ import { facetsOf, facetsOfTask, groupByRepo, sortByLife } from '../filters.js'
6
+ import { computeNextRun } from '../cron.js'
7
+
8
+ export function withPlan(task, liveOf, config, now, sameSession) {
9
+ const plan = parsePlan(task.plan)
10
+ // Registry error implies agent unavailable; do not fail whole board render.
11
+ let live
12
+ try {
13
+ if (typeof liveOf === 'function' && task.sessionId) live = liveOf(task.sessionId)
14
+ } catch { live = undefined }
15
+ const liveStatus = typeof live === 'object' && live !== null ? live.status : live
16
+ const liveStep = typeof live === 'object' && live !== null
17
+ ? (live.step || live.activity || live.stateDetail || '')
18
+ : ''
19
+
20
+ const costMetrics = task.costMetrics || null
21
+ const costLimitExceeded = (config?.costMeterEnabled !== false && Number(config?.taskCostLimit) > 0 && costMetrics?.costUsd)
22
+ ? costMetrics.costUsd >= Number(config.taskCostLimit)
23
+ : Boolean(task.costLimitExceeded)
24
+
25
+ return {
26
+ ...task,
27
+ plan: { items: planItems(plan), progress: planProgress(plan) },
28
+ state: taskState({ task, live: liveStatus }),
29
+ liveStep: liveStep || task.liveStep || '',
30
+ costMetrics,
31
+ costLimitExceeded,
32
+ // Queue position calculated dynamically across active tasks in same session.
33
+ queuePos: queuePosition(task, sameSession),
34
+ // Label facets extracted on server for consistent client filtering.
35
+ facets: facetsOfTask(task),
36
+ // Stale calculation performed on host where configuration resides.
37
+ stale: isStale({
38
+ now,
39
+ updatedAt: task.updatedAt,
40
+ sessionId: task.sessionId,
41
+ afterMinutes: config?.staleAfterMin,
42
+ }),
43
+ // Overdue calculated on host using server clock.
44
+ overdue: typeof task.dueAt === 'number' && task.dueAt > 0 && task.dueAt < now,
45
+ }
46
+ }
47
+
48
+ /** Order tasks: by column, grouped by repository, with active tasks on top. */
49
+ function orderForBoard(tasks, columns) {
50
+ const out = []
51
+ for (const column of columns) {
52
+ const inColumn = tasks.filter((t) => t.column === column)
53
+ for (const group of groupByRepo(sortByLife(inColumn))) out.push(...group.tasks)
54
+ }
55
+ // Retain tasks in columns not visible on current board to prevent data loss.
56
+ const seen = new Set(out.map((t) => t.id))
57
+ for (const task of tasks) if (!seen.has(task.id)) out.push(task)
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * Build board response payload: columns in canonical order, tasks, and limits.
63
+ */
64
+ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync, projectRoot, costMeterAvailable = false }) {
65
+ // Uniform timestamp across snapshot calculation.
66
+ const now = Date.now()
67
+ // Tasks are sorted and grouped on server: repo grouping with active items first.
68
+ const raw = store.listTasks({ board, repo })
69
+ // Queue position scoped per session.
70
+ const bySession = new Map()
71
+ for (const task of raw) {
72
+ const id = typeof task.sessionId === 'string' ? task.sessionId : ''
73
+ if (id === '') continue
74
+ if (!bySession.has(id)) bySession.set(id, [])
75
+ bySession.get(id).push(task)
76
+ }
77
+ const tasks = orderForBoard(
78
+ raw.map((task) => {
79
+ const enriched = withPlan(task, liveOf, config, now, bySession.get(task.sessionId) ?? [])
80
+ if (task.isEpic) {
81
+ enriched.subtasksCount = store.countSubtasks(task.id)
82
+ }
83
+ return enriched
84
+ }),
85
+ columnsOf(store.boardKind(board)),
86
+ )
87
+ const kind = store.boardKind(board)
88
+ // Column custom names sent from server; locale translation handled by client.
89
+ const names = columnNamesOf(config)
90
+ const columns = columnsOf(kind).map((id) => {
91
+ const count = tasks.filter((t) => t.column === id).length
92
+ const limit = wipLimitField(config, id)
93
+ return { id, name: names[id], count, limit, overLimit: limit !== undefined && count > limit }
94
+ })
95
+ // Filter counts calculated over all tasks on board.
96
+ return {
97
+ board, kind, now, boards: store.listBoards(), columns, tasks,
98
+ // Resolved project root {path, set} computed on host.
99
+ projectRoot: projectRoot ?? { path: '', set: false },
100
+
101
+ facets: facetsOf(tasks),
102
+ // Sync telemetry state attached directly to board snapshot.
103
+ sync: sync ?? { state: 'never' },
104
+ costMeterAvailable: Boolean(costMeterAvailable),
105
+ remoteMode: Boolean(config?.remoteMode),
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Card move transition. WIP limit is advisory: move executes, and exceeded status
111
+ * is returned for UI highlighting.
112
+ */
113
+
114
+ export function boardMetrics({ store, board = 'main', now = Date.now(), staleDays = 14 }) {
115
+ const tasks = store.listTasks({ board })
116
+ const byTask = new Map()
117
+ for (const row of store.listAllTransitions()) {
118
+ if (!byTask.has(row.taskId)) byTask.set(row.taskId, [])
119
+ byTask.get(row.taskId).push(row)
120
+ }
121
+ const rows = tasks.map((task) => ({ task, moves: byTask.get(task.id) ?? [] }))
122
+ const day = 24 * 60 * 60 * 1000
123
+
124
+ return {
125
+ tasks: tasks.length,
126
+ columns: columnStats(rows, now),
127
+ done: {
128
+ week: doneWithin(rows, now - 7 * day, now),
129
+ month: doneWithin(rows, now - 30 * day, now),
130
+ },
131
+ staleDays,
132
+ stale: stale(tasks, now, staleDays * day).slice(0, 50),
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Check whether a task can be resumed.
138
+ * Eligible when task has recorded sessionId but no active agent is running.
139
+ */
140
+
141
+ export function exportBoard({ store }) {
142
+ return store.exportAll()
143
+ }
144
+
145
+ /**
146
+ * Restore board from backup snapshot (idempotent; skips existing IDs).
147
+ *
148
+ * @returns {{imported: number, skipped: number, error?: string}}
149
+ */
150
+ export function importBoard({ store, input }) {
151
+ let backupMeta = undefined
152
+ if (typeof store.backupDatabase === 'function') {
153
+ try {
154
+ backupMeta = store.backupDatabase()
155
+ } catch { /* backup best effort */ }
156
+ }
157
+ if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
158
+ if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
159
+ if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
160
+
161
+ let imported = 0
162
+ let skipped = 0
163
+
164
+ const doImport = () => {
165
+ for (const board of Array.isArray(input.boards) ? input.boards : []) {
166
+ if (typeof board?.id !== 'string' || board.id === '') continue
167
+ store.createBoard({
168
+ id: board.id,
169
+ title: board.title,
170
+ kind: board.kind,
171
+ position: board.position,
172
+ createdAt: board.createdAt,
173
+ })
174
+ }
175
+
176
+ for (const task of input.tasks) {
177
+ if (typeof task?.id !== 'string' || task.id === '') continue
178
+ if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
179
+ if (typeof store.restoreTask === 'function') {
180
+ store.restoreTask(task)
181
+ } else {
182
+ store.createTask(task)
183
+ }
184
+ imported += 1
185
+ }
186
+
187
+ for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
188
+ if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
189
+ if (typeof store.restoreTransition === 'function') {
190
+ store.restoreTransition(trans)
191
+ } else if (typeof store.addTransition === 'function') {
192
+ store.addTransition(trans)
193
+ }
194
+ }
195
+ }
196
+
197
+ if (typeof store.withTransaction === 'function') {
198
+ store.withTransaction(doImport)
199
+ } else {
200
+ doImport()
201
+ }
202
+
203
+ return { imported, skipped, backup: backupMeta?.backup }
204
+ }
205
+
206
+ /**
207
+ * Export tasks to Excel-compatible CSV with UTF-8 BOM (#223).
208
+ *
209
+ * @param {{store: object, board?: string}} options
210
+ * @returns {string} CSV string with UTF-8 BOM prefix
211
+ */
212
+ export function exportTasksCsv({ store, board = 'main' }) {
213
+ const tasks = store.listTasks({ board })
214
+ const headers = [
215
+ 'ID',
216
+ 'Title',
217
+ 'Column',
218
+ 'Priority',
219
+ 'Repository',
220
+ 'Issue #',
221
+ 'Assignee',
222
+ 'Model',
223
+ 'Provider',
224
+ 'Reasoning Effort',
225
+ 'Workspace',
226
+ 'Branch',
227
+ 'Created',
228
+ 'Updated',
229
+ ]
230
+
231
+ function escapeCsv(val) {
232
+ if (val === null || val === undefined) return ''
233
+ let str = String(val)
234
+ // OWASP CSV Injection mitigation (#340)
235
+ const trimmed = str.replace(/^[\s\t\r\n]+/, '')
236
+ if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
237
+ str = `'${str}`
238
+ }
239
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
240
+ return `"${str.replace(/"/g, '""')}"`
241
+ }
242
+ return str
243
+ }
244
+
245
+ function formatDate(ts) {
246
+ if (!ts || typeof ts !== 'number') return ''
247
+ try {
248
+ return new Date(ts).toISOString().replace('T', ' ').slice(0, 19)
249
+ } catch {
250
+ return ''
251
+ }
252
+ }
253
+
254
+ const rows = [headers.join(',')]
255
+ for (const t of tasks) {
256
+ const row = [
257
+ escapeCsv(t.id),
258
+ escapeCsv(t.title),
259
+ escapeCsv(t.column),
260
+ escapeCsv(t.priority),
261
+ escapeCsv(t.repo),
262
+ escapeCsv(t.issueNumber ?? ''),
263
+ escapeCsv(t.assignee),
264
+ escapeCsv(t.model),
265
+ escapeCsv(t.provider),
266
+ escapeCsv(t.reasoningEffort ?? ''),
267
+ escapeCsv(t.workspaceId),
268
+ escapeCsv(t.branch),
269
+ escapeCsv(formatDate(t.createdAt)),
270
+ escapeCsv(formatDate(t.updatedAt)),
271
+ ]
272
+ rows.push(row.join(','))
273
+ }
274
+
275
+ // UTF-8 BOM prefix for Microsoft Excel
276
+ return '\uFEFF' + rows.join('\r\n')
277
+ }
278
+
279
+
280
+ /**
281
+ * Clean up task worktree.
282
+ */