@goodandready/dsh-kanban 0.2.20 → 0.2.22

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/gitea.js CHANGED
@@ -36,7 +36,14 @@ export function isCredentialName(value) {
36
36
 
37
37
  /** Check if value looks like a raw token secret rather than a credential reference name. */
38
38
  export function looksLikeSecret(value) {
39
- return /^[A-Za-z0-9_-]{32,}$/.test(String(value || '').trim())
39
+ const str = String(value || '').trim()
40
+ if (str === '') return false
41
+ if (/^(ghp_|gho_|github_pat_|glpat-|bearer\s+|token\s+)/i.test(str)) return true
42
+ if (/^[0-9a-f]{40}$/i.test(str)) return true
43
+ if (/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(str)) return true
44
+ // Raw alphanumeric token >= 32 characters without underscores
45
+ if (/^[A-Za-z0-9]{32,}$/.test(str)) return true
46
+ return false
40
47
  }
41
48
 
42
49
  /**
package/lib/github.js CHANGED
@@ -13,12 +13,18 @@ export function normalizeGithubUrl(raw) {
13
13
  return match ? match[0].replace(/\/+$/, '') : DEFAULT_BASE_URL
14
14
  }
15
15
 
16
+ export function safeIssueIndex(value) {
17
+ const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? '').trim(), 10)
18
+ if (!Number.isSafeInteger(n) || n <= 0) return undefined
19
+ if (typeof value === 'string' && String(n) !== value.trim()) return undefined
20
+ return n
21
+ }
22
+
16
23
  export function safeSegment(value) {
17
24
  const s = String(value ?? '').trim()
18
- if (!s || s.includes('/') || s.includes('\\') || s.includes('..') || s.includes('\0')) {
19
- return undefined
20
- }
21
- return s
25
+ if (!s || s === '.' || s === '..') return undefined
26
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) return undefined
27
+ return encodeURIComponent(s)
22
28
  }
23
29
 
24
30
  export function isCredentialName(value) {
@@ -170,18 +176,28 @@ export function createGitHubClient({ getConfig, resolveToken, fetchImpl = fetch,
170
176
 
171
177
  /** Get single issue by number. */
172
178
  async getIssue({ owner, repo, index }) {
173
- const n = safeSegment(index)
179
+ const n = safeIssueIndex(index)
174
180
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
175
181
  return request('GET', repoPath(owner, repo, `/issues/${n}`))
176
182
  },
177
183
 
178
184
  /** Post comment on issue. */
179
185
  async comment({ owner, repo, index, body }) {
180
- const n = safeSegment(index)
186
+ const n = safeIssueIndex(index)
181
187
  if (n === undefined) throw Object.assign(new Error('invalid issue number'), { code: 'bad-segment' })
182
188
  return request('POST', repoPath(owner, repo, `/issues/${n}/comments`), { body })
183
189
  },
184
190
 
191
+ /** Create private, empty repository in organization or user account. */
192
+ async createRepo({ owner, name, description = '' }) {
193
+ const safe = safeSegment(name)
194
+ if (safe === undefined) throw Object.assign(new Error('invalid name'), { code: 'bad-segment' })
195
+ const path = owner ? `/orgs/${safeSegment(owner)}/repos` : '/user/repos'
196
+ return request('POST', path, {
197
+ name: safe, description, private: true, auto_init: false,
198
+ })
199
+ },
200
+
185
201
  /** Create new issue. */
186
202
  async createIssue({ owner, repo, title, body }) {
187
203
  const name = String(title ?? '').trim()
package/lib/import.js CHANGED
@@ -152,24 +152,24 @@ export async function giteaState(gitea) {
152
152
  }
153
153
 
154
154
  /** List open issues for import dialog, marking already-imported ones. */
155
- export async function listImportable({ gitea, store, owner, repo, board = 'main' }) {
155
+ export async function listImportable({ gitea, store, owner, repo, board = 'main', provider, forge }) {
156
156
  const state = await giteaState(gitea)
157
157
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
158
- const issues = await gitea.listIssues({ owner, repo, state: 'open' })
158
+ const issues = await gitea.listIssues({ owner, repo, state: 'open', provider: forge || provider })
159
159
  const existing = store.listTasks({ board })
160
160
  return { issues: markImported(issues, existing, { owner, repo }) }
161
161
  }
162
162
 
163
163
  /** Create a task from an issue. */
164
- export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog' }) {
164
+ export async function importIssue({ gitea, store, owner, repo, issueNumber, board = 'main', column = 'backlog', provider, forge }) {
165
165
  const state = await giteaState(gitea)
166
166
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
167
167
  if (typeof issueNumber !== 'number') return { error: 'issue-number-required', status: 400 }
168
168
  // Simple board is for non-issue tasks. Importing here would bring branches/PRs into columns unable to display them.
169
169
  if (store.boardKind(board) !== 'project') return { error: 'board-not-for-issues', status: 400 }
170
- const issue = await gitea.getIssue({ owner, repo, index: issueNumber })
170
+ const issue = await gitea.getIssue({ owner, repo, index: issueNumber, provider: forge || provider })
171
171
  if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
172
- return { task: store.createTask(issueToTask(issue, { board, column, owner, repo })) }
172
+ return { task: store.createTask(issueToTask(issue, { board, column, owner, repo, forge: forge || provider })) }
173
173
  }
174
174
 
175
175
  /** Re-read issue and update task, preserving local state. */
@@ -179,7 +179,8 @@ export async function refreshTask({ gitea, store, id }) {
179
179
  const task = store.getTask(id)
180
180
  if (task === undefined) return { error: 'task-not-found', status: 404 }
181
181
  if (typeof task.issueNumber !== 'number') return { error: 'task-has-no-issue', status: 400 }
182
- const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
182
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
183
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: forge, issueUrl: task.issueUrl })
183
184
  if (issue === undefined || issue === null) return { error: 'issue-not-found', status: 404 }
184
185
  return { task: store.updateTask(id, refreshPatch(task, issue)) }
185
186
  }
@@ -199,7 +200,7 @@ export async function searchRepos({ gitea, query, limit = 20 }) {
199
200
  * Failure midway avoids leaving orphaned cards.
200
201
  */
201
202
  export async function createProjectTask({
202
- gitea, store, owner, repo, newRepo, title, body, board = 'main',
203
+ gitea, store, owner, repo, newRepo, title, body, board = 'main', provider, forge,
203
204
  }) {
204
205
  const state = await giteaState(gitea)
205
206
  if (state !== 'ready') return { error: `gitea-${state}`, status: 409 }
@@ -214,7 +215,7 @@ export async function createProjectTask({
214
215
  // Validate repository name before API call.
215
216
  if (safeSegment(wanted) === undefined) return { error: 'bad-repo-name', status: 400 }
216
217
  try {
217
- await gitea.createRepo({ owner, name: wanted })
218
+ await gitea.createRepo({ owner, name: wanted, provider: forge || provider })
218
219
  } catch (error) {
219
220
  return { error: 'repo-not-created', status: 502, detail: String(error?.message ?? error) }
220
221
  }
@@ -223,11 +224,11 @@ export async function createProjectTask({
223
224
 
224
225
  let issue
225
226
  try {
226
- issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body })
227
+ issue = await gitea.createIssue({ owner, repo: targetRepo, title: name, body, provider: forge || provider })
227
228
  } catch (error) {
228
229
  // Repository may have already been created; avoid deleting on issue creation failure.
229
230
  return { error: 'issue-not-created', status: 502, detail: String(error?.message ?? error) }
230
231
  }
231
232
 
232
- return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo })) }
233
+ return { task: store.createTask(issueToTask(issue, { board, column: 'backlog', owner, repo: targetRepo, forge: forge || provider })) }
233
234
  }
package/lib/index.js CHANGED
@@ -119,6 +119,18 @@ export const SETTINGS_NAMESPACE = 'dsh-kanban'
119
119
  * Scalar values only: client config API only writes scalar fields;
120
120
  * dictionaries or arrays here would make the settings card uneditable.
121
121
  */
122
+ // Ensure .volatile() helper exists on Schemastery schema prototype
123
+ try {
124
+ const schemaProto = Object.getPrototypeOf(z.boolean())
125
+ if (schemaProto && typeof schemaProto.volatile !== 'function') {
126
+ schemaProto.volatile = function () {
127
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
128
+ }
129
+ }
130
+ } catch {
131
+ // safe fallback
132
+ }
133
+
122
134
  export const Config = z.object(Object.fromEntries(
123
135
  Object.entries(CONFIG_DEFAULTS).map(([field, fallback]) => {
124
136
  // Node type derived from default value type.
@@ -128,7 +140,8 @@ export const Config = z.object(Object.fromEntries(
128
140
  if (field === 'giteaTokenRef' || field === 'webhookSecretRef' || field === 'githubTokenRef' || field === 'githubWebhookSecretRef') {
129
141
  node = node.role('credential-ref')
130
142
  }
131
- return [field, node.default(fallback).description(CONFIG_HINTS[field] ?? '')]
143
+ const described = node.default(fallback).description(CONFIG_HINTS[field] ?? '')
144
+ return [field, typeof described.volatile === 'function' ? described.volatile() : described]
132
145
  }),
133
146
  ))
134
147
 
@@ -215,13 +228,57 @@ export function apply(ctx, config) {
215
228
  }
216
229
  }
217
230
 
231
+ let settingsSvc = null
232
+
233
+ const readServedConfig = () => {
234
+ if (typeof settingsSvc?.describe !== 'function') return null
235
+ try {
236
+ const row = settingsSvc.describe().find((r) => r?.ns === SETTINGS_NAMESPACE)
237
+ if (!row || row.status !== 'ready' || !row.value) return null
238
+ return withDefaults(row.value)
239
+ } catch (err) {
240
+ ctx.logger?.warn?.(`dsh-kanban: settings describe failed: ${err?.message || err}`)
241
+ return null
242
+ }
243
+ }
244
+
218
245
  ctx.inject(['settings'], (sctx) => {
219
- const scope = sctx.settings.register(SETTINGS_NAMESPACE, Config, { base: effective })
220
- const read = () => withDefaults(scope.get() ?? effective)
221
- effective = read()
222
- ctx.effect(() => scope.watch(() => { effective = read(); rearm() }), 'dsh-kanban: settings watcher')
246
+ try {
247
+ settingsSvc = sctx?.settings ?? sctx?.get?.('settings') ?? null
248
+ const served = readServedConfig()
249
+ if (served) {
250
+ effective = served
251
+ rearm()
252
+ }
253
+ } catch (err) {
254
+ ctx.logger?.warn?.(`dsh-kanban: settings service unavailable: ${err?.message || err}`)
255
+ }
223
256
  })
224
257
 
258
+ // Listen for config and volatile updates from host
259
+ if (typeof ctx?.on === 'function') {
260
+ const applyPatch = (updated) => {
261
+ if (updated && typeof updated === 'object') {
262
+ const incoming = updated[SETTINGS_NAMESPACE] ?? updated
263
+ if (incoming && typeof incoming === 'object') {
264
+ try {
265
+ effective = withDefaults({ ...effective, ...incoming })
266
+ rearm()
267
+ } catch { /* ignore invalid update */ }
268
+ }
269
+ }
270
+ }
271
+ if (typeof ctx.effect === 'function') {
272
+ ctx.effect(() => ctx.on('loader/volatile-update', applyPatch))
273
+ ctx.effect(() => ctx.on('config', applyPatch))
274
+ ctx.effect(() => ctx.on('settings/document-updated', applyPatch))
275
+ } else {
276
+ ctx.on('loader/volatile-update', applyPatch)
277
+ ctx.on('config', applyPatch)
278
+ ctx.on('settings/document-updated', applyPatch)
279
+ }
280
+ }
281
+
225
282
  // Dedicated Gitea client resolves token via core credentials service.
226
283
  // Secret token values are never exposed externally; only the credential reference name is stored.
227
284
  const resolveSecret = async (name) => {
@@ -470,7 +527,7 @@ export function apply(ctx, config) {
470
527
 
471
528
  // Periodic Gitea reconciliation. 0 disables it; overlapping executions are prevented.
472
529
  // Outbound push queue to Gitea. Card moves do not block on network; failures retry in background.
473
- const outbox = createOutbox({ gitea: git, store, logger: ctx.logger })
530
+ const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
474
531
 
475
532
  const queueOutbound = (taskId, column) => {
476
533
  if (getConfig().pushToGitea !== true) return
@@ -750,14 +807,14 @@ export function apply(ctx, config) {
750
807
  // Single setting switch controls both board tools.
751
808
  const registerBoardTool = () => {
752
809
  const off = [
753
- ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
754
- ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
755
- ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
810
+ ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig, eventHub }))),
811
+ ctx.tools.register(defineTool(boardPlanDefinition({ store, eventHub }))),
812
+ ctx.tools.register(defineTool(boardChecklistDefinition({ store, eventHub }))),
756
813
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
757
814
  ctx.tools.register(defineTool(boardCommentsDefinition({ store }))),
758
815
  ctx.tools.register(defineTool(boardCommentAddDefinition({ store }))),
759
816
  ctx.tools.register(defineTool(boardDecomposeDefinition({ store, gitea: git }))),
760
- ctx.tools.register(defineTool(boardChecklistItemDefinition({ store }))),
817
+ ctx.tools.register(defineTool(boardChecklistItemDefinition({ store, eventHub }))),
761
818
  ]
762
819
  return () => { for (const stop of off) stop?.() }
763
820
  }
@@ -852,7 +909,7 @@ export function apply(ctx, config) {
852
909
  kind: 'exact',
853
910
  path: '/dsh-kanban/events',
854
911
  handler: (req, res) => {
855
- eventHub.handleSseRequest(req, res)
912
+ eventHub.handleSseRequest(req, res, { isTrustedRequest })
856
913
  },
857
914
  }), 'dsh-kanban: sse events stream')
858
915
 
@@ -889,7 +946,11 @@ export function apply(ctx, config) {
889
946
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
890
947
  try {
891
948
  const input = await readBody(req)
892
- reply(res, importBoard({ store, input }))
949
+ const impResult = importBoard({ store, input })
950
+ if (impResult?.imported > 0) {
951
+ eventHub.broadcast('task:change', { action: 'imported', count: impResult.imported })
952
+ }
953
+ reply(res, impResult)
893
954
  } catch {
894
955
  writeJson(res, 400, { error: 'bad-request' })
895
956
  }
@@ -908,7 +969,11 @@ export function apply(ctx, config) {
908
969
  if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
909
970
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
910
971
  try {
911
- reply(res, createTask({ store, input: await readBody(req) }))
972
+ const resObj = createTask({ store, input: await readBody(req) })
973
+ if (resObj?.task) {
974
+ eventHub.broadcast('task:change', { taskId: resObj.task.id, action: 'created' })
975
+ }
976
+ reply(res, resObj)
912
977
  } catch (error) {
913
978
  writeJson(res, error?.status ?? 500, { error: 'bad-request' })
914
979
  }
@@ -930,12 +995,23 @@ export function apply(ctx, config) {
930
995
  return
931
996
  }
932
997
  if (!isTrustedRequest(req)) { writeJson(res, 403, { error: 'cross-site' }); return }
933
- if (req.method === 'DELETE' && action === undefined) { reply(res, deleteTask({ store, id })); return }
998
+ if (req.method === 'DELETE' && action === undefined) {
999
+ const resObj = deleteTask({ store, id })
1000
+ if (resObj?.deleted) {
1001
+ eventHub.broadcast('task:change', { taskId: id, action: 'deleted' })
1002
+ }
1003
+ reply(res, resObj)
1004
+ return
1005
+ }
934
1006
  // Card fields update: title, body, labels, priority. Priority is board-local.
935
1007
  if (req.method === 'POST' && action === 'update') {
936
1008
  try {
937
1009
  const input = await readBody(req)
938
- reply(res, updateTask({ store, id, input }))
1010
+ const resObj = updateTask({ store, id, input })
1011
+ if (resObj?.task) {
1012
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1013
+ }
1014
+ reply(res, resObj)
939
1015
  } catch {
940
1016
  writeJson(res, 400, { error: 'bad-request' })
941
1017
  }
@@ -1112,6 +1188,9 @@ export function apply(ctx, config) {
1112
1188
  }
1113
1189
  if (req.method === 'POST' && action === 'checklist-toggle') {
1114
1190
  const resObj = handleToggleChecklistItem({ store, task: store.getTask(id), input })
1191
+ if (resObj?.body?.task) {
1192
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1193
+ }
1115
1194
  writeJson(res, resObj.status, resObj.body)
1116
1195
  return
1117
1196
  }
@@ -1122,6 +1201,9 @@ export function apply(ctx, config) {
1122
1201
  }
1123
1202
  if (req.method === 'POST' && action === 'checklist') {
1124
1203
  const resObj = handleTaskChecklist({ store, task: store.getTask(id), input })
1204
+ if (resObj?.body?.task) {
1205
+ eventHub.broadcast('task:change', { taskId: id, action: 'updated' })
1206
+ }
1125
1207
  writeJson(res, resObj.status, resObj.body)
1126
1208
  return
1127
1209
  }
@@ -1174,7 +1256,7 @@ export function apply(ctx, config) {
1174
1256
  agentPreset: input?.agentPreset || task.agentPreset,
1175
1257
  permission: input?.permission || task.permission,
1176
1258
  permissions: ctx.get('permissionPresets'),
1177
- claimAs: req.headers['x-dsh-user'] || (await whoAmI()),
1259
+ claimAs: await whoAmI(),
1178
1260
  workspaceRegistry: ctx.get('workspaceRegistry'),
1179
1261
  resume: true,
1180
1262
  })