@andypai/agent-kanban 0.7.0 → 0.8.1
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/README.md +22 -12
- package/package.json +8 -1
- package/src/__tests__/api.test.ts +220 -0
- package/src/__tests__/cli-task-lifecycle-matrix.test.ts +1262 -0
- package/src/__tests__/index.test.ts +107 -3
- package/src/__tests__/jira-wiring.test.ts +25 -2
- package/src/__tests__/server.test.ts +78 -0
- package/src/__tests__/sync-config.test.ts +14 -1
- package/src/__tests__/tracker-config-webhook-secret.test.ts +89 -0
- package/src/__tests__/transport-input.test.ts +67 -1
- package/src/__tests__/tunnel.test.ts +116 -0
- package/src/__tests__/webhook-events-receipts.test.ts +155 -0
- package/src/__tests__/webhooks.test.ts +47 -1
- package/src/api.ts +54 -16
- package/src/db.ts +18 -1
- package/src/index.ts +76 -17
- package/src/mcp/index.ts +2 -10
- package/src/mcp/types.ts +1 -1
- package/src/metrics-spec.ts +1 -1
- package/src/providers/factory.ts +1 -1
- package/src/providers/jira-adf.ts +8 -11
- package/src/providers/jira-client.ts +2 -2
- package/src/providers/jira-core.ts +12 -6
- package/src/providers/linear-cache.ts +4 -4
- package/src/providers/linear-client.ts +1 -1
- package/src/providers/linear-core.ts +9 -3
- package/src/providers/local.ts +2 -2
- package/src/providers/postgres-linear-cache.ts +1 -1
- package/src/providers/sqlite-local-store.ts +5 -1
- package/src/storage-config.ts +2 -4
- package/src/sync-config.ts +7 -2
- package/src/tracker-config.ts +74 -6
- package/src/transport-input.ts +46 -14
- package/src/tunnel.ts +22 -5
|
@@ -168,7 +168,7 @@ export interface JiraIssueType {
|
|
|
168
168
|
name: string
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
interface JiraChangelogItem {
|
|
172
172
|
field: string
|
|
173
173
|
fieldtype?: string
|
|
174
174
|
fromString?: string | null
|
|
@@ -177,7 +177,7 @@ export interface JiraChangelogItem {
|
|
|
177
177
|
to?: string | null
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
interface JiraChangelogEntry {
|
|
181
181
|
id: string
|
|
182
182
|
author?: { accountId?: string; displayName?: string }
|
|
183
183
|
created: string
|
|
@@ -47,12 +47,13 @@ import type {
|
|
|
47
47
|
UpdateTaskInput,
|
|
48
48
|
} from './types'
|
|
49
49
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
50
|
+
import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
|
|
50
51
|
import { warnOnce } from './warn-once'
|
|
51
52
|
import { applyTaskFilters, forEachWithConcurrency, SyncGate, syncStatusFromMeta } from './sync-core'
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
const FULL_RECONCILE_INTERVAL_MS = 5 * 60_000
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
function shouldRunFullReconcile(lastFullSyncAt: string | null, now: number): boolean {
|
|
56
57
|
if (!lastFullSyncAt) return true
|
|
57
58
|
const lastFullSyncAtMs = Date.parse(lastFullSyncAt)
|
|
58
59
|
if (!Number.isFinite(lastFullSyncAtMs)) return true
|
|
@@ -63,7 +64,7 @@ export function shouldRunFullReconcile(lastFullSyncAt: string | null, now: numbe
|
|
|
63
64
|
// priorities; the write path looks up the resolved name (case-insensitive)
|
|
64
65
|
// in the cached `jira_priorities` table, so renames that preserve the default
|
|
65
66
|
// casing still resolve.
|
|
66
|
-
|
|
67
|
+
const CANONICAL_TO_JIRA_DEFAULT: Record<Priority, string> = {
|
|
67
68
|
urgent: 'Highest',
|
|
68
69
|
high: 'High',
|
|
69
70
|
medium: 'Medium',
|
|
@@ -872,14 +873,19 @@ export class JiraProviderCore implements KanbanProvider {
|
|
|
872
873
|
// Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
|
|
873
874
|
// SQLite calls it directly via the default handleWebhook above.
|
|
874
875
|
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
875
|
-
|
|
876
|
+
// Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
|
|
877
|
+
// enforcement here and the assertTunnelSecurity tunnel gate read the same env
|
|
878
|
+
// name — they can't drift into a fail-open where the gate requires one env and
|
|
879
|
+
// verification reads another (unset) one.
|
|
880
|
+
const secret = webhookSecretFromEnv('jira')
|
|
881
|
+
if (!secret) {
|
|
876
882
|
warnOnce(
|
|
877
883
|
'jira-webhook-open-dev-mode',
|
|
878
|
-
|
|
884
|
+
`[jira] ${WEBHOOK_SECRET_ENV['jira']} is not set — accepting webhook without signature verification (open dev mode)`,
|
|
879
885
|
)
|
|
880
886
|
}
|
|
881
887
|
const auth = authorizeWebhook({
|
|
882
|
-
secret
|
|
888
|
+
secret,
|
|
883
889
|
rawBody: payload.rawBody,
|
|
884
890
|
signature: headerLower(payload.headers, 'x-hub-signature'),
|
|
885
891
|
verify: verifySha256HmacSignatureHeader,
|
|
@@ -316,10 +316,10 @@ export function upsertProjects(
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
// Bound per-value storage so repeated edits to a long description can't balloon the cache.
|
|
319
|
-
//
|
|
320
|
-
// backends clamp identically and the truncation marker can't drift
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
// clampActivityValue is shared with the Postgres cache (postgres-linear-cache.ts)
|
|
320
|
+
// so both backends clamp identically and the truncation marker can't drift.
|
|
321
|
+
const ACTIVITY_VALUE_MAX_CHARS = 4096
|
|
322
|
+
const ACTIVITY_TRUNCATION_SUFFIX = '…[truncated]'
|
|
323
323
|
const ACTIVITY_VALUE_BUDGET = ACTIVITY_VALUE_MAX_CHARS - ACTIVITY_TRUNCATION_SUFFIX.length
|
|
324
324
|
|
|
325
325
|
export function clampActivityValue(value: string): string {
|
|
@@ -694,7 +694,7 @@ export async function resolveLabelIdsForCreate(
|
|
|
694
694
|
return resolveLinearLabelIds(inputLabels, await client.listIssueLabels())
|
|
695
695
|
}
|
|
696
696
|
|
|
697
|
-
|
|
697
|
+
function resolveLinearLabelIds(
|
|
698
698
|
inputLabels: string[] | undefined,
|
|
699
699
|
availableLabels: LinearIssueLabel[],
|
|
700
700
|
): string[] | undefined {
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
UpdateTaskInput,
|
|
41
41
|
} from './types'
|
|
42
42
|
import { DEFAULT_POLLING_SYNC_INTERVAL_MS } from '../sync-config'
|
|
43
|
+
import { WEBHOOK_SECRET_ENV, webhookSecretFromEnv } from '../tracker-config'
|
|
43
44
|
import { warnOnce } from './warn-once'
|
|
44
45
|
import {
|
|
45
46
|
applyTaskFilters,
|
|
@@ -596,14 +597,19 @@ export class LinearProviderCore implements KanbanProvider {
|
|
|
596
597
|
// Shared webhook dispatch. Postgres wraps this with webhook-event auditing;
|
|
597
598
|
// SQLite calls it directly via the default handleWebhook above.
|
|
598
599
|
protected async handleWebhookCore(payload: WebhookRequest): Promise<WebhookResult> {
|
|
599
|
-
|
|
600
|
+
// Resolve the signing secret through WEBHOOK_SECRET_ENV so the runtime
|
|
601
|
+
// enforcement here and the assertTunnelSecurity tunnel gate read the same env
|
|
602
|
+
// name — they can't drift into a fail-open where the gate requires one env and
|
|
603
|
+
// verification reads another (unset) one.
|
|
604
|
+
const secret = webhookSecretFromEnv('linear')
|
|
605
|
+
if (!secret) {
|
|
600
606
|
warnOnce(
|
|
601
607
|
'linear-webhook-open-dev-mode',
|
|
602
|
-
|
|
608
|
+
`[linear] ${WEBHOOK_SECRET_ENV['linear']} is not set — accepting webhook without signature verification (open dev mode)`,
|
|
603
609
|
)
|
|
604
610
|
}
|
|
605
611
|
const auth = authorizeWebhook({
|
|
606
|
-
secret
|
|
612
|
+
secret,
|
|
607
613
|
rawBody: payload.rawBody,
|
|
608
614
|
signature: headerLower(payload.headers, 'linear-signature'),
|
|
609
615
|
verify: verifyHmacSha256,
|
package/src/providers/local.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { LocalProviderCore } from './local-core'
|
|
|
5
5
|
import { SqliteLocalStore } from './sqlite-local-store'
|
|
6
6
|
|
|
7
7
|
export class LocalProvider extends LocalProviderCore {
|
|
8
|
-
constructor(db: Database, dbPath = getDbPath()) {
|
|
9
|
-
super(new SqliteLocalStore(db, dbPath))
|
|
8
|
+
constructor(db: Database, dbPath = getDbPath(), defaultTaskColumn?: string) {
|
|
9
|
+
super(new SqliteLocalStore(db, dbPath, defaultTaskColumn))
|
|
10
10
|
}
|
|
11
11
|
}
|
|
@@ -15,7 +15,7 @@ import { parseProviderTeamInfo } from './team-info'
|
|
|
15
15
|
|
|
16
16
|
export type { LinearActivityRow, LinearStateRow, LinearSyncMeta } from './linear-cache'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
type LinearIssueRow = LinearTaskRow
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Postgres-backed cache/repository for the Linear provider. Mirrors the role of
|
|
@@ -30,6 +30,7 @@ export class SqliteLocalStore implements LocalStorePort {
|
|
|
30
30
|
constructor(
|
|
31
31
|
private readonly db: Database,
|
|
32
32
|
private readonly dbPath: string,
|
|
33
|
+
private readonly defaultTaskColumn?: string,
|
|
33
34
|
) {}
|
|
34
35
|
|
|
35
36
|
getBoard() {
|
|
@@ -53,7 +54,10 @@ export class SqliteLocalStore implements LocalStorePort {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
createTask(input: CreateTaskInput) {
|
|
56
|
-
return addTask(this.db, input.title,
|
|
57
|
+
return addTask(this.db, input.title, {
|
|
58
|
+
...input,
|
|
59
|
+
column: input.column ?? this.defaultTaskColumn,
|
|
60
|
+
})
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
updateTask(idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
|
package/src/storage-config.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { getDbPath } from './db'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export interface SqliteKanbanStorageConfig {
|
|
3
|
+
interface SqliteKanbanStorageConfig {
|
|
6
4
|
mode: 'sqlite'
|
|
7
5
|
sqlitePath: string
|
|
8
6
|
}
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
interface PostgresKanbanStorageConfig {
|
|
11
9
|
mode: 'postgres'
|
|
12
10
|
databaseUrl: string
|
|
13
11
|
}
|
package/src/sync-config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ErrorCode, KanbanError } from './errors'
|
|
2
|
+
import { parseDecimalDigits } from './transport-input'
|
|
2
3
|
|
|
3
4
|
export const DEFAULT_POLLING_SYNC_INTERVAL_MS = 30_000
|
|
4
5
|
export const MIN_POLLING_SYNC_INTERVAL_MS = 1_000
|
|
@@ -10,8 +11,12 @@ export function resolvePollingSyncIntervalMs(
|
|
|
10
11
|
const value = raw?.trim()
|
|
11
12
|
if (!value) return DEFAULT_POLLING_SYNC_INTERVAL_MS
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Digits-only via the shared parser (rejects hex/scientific like `0x3e8`/`1e3`
|
|
15
|
+
// and over-long strings that round past MAX_SAFE_INTEGER / overflow to
|
|
16
|
+
// Infinity); a set-but-invalid value is a config error, not a fall-back to the
|
|
17
|
+
// default, mirroring the strict --sync-interval-ms CLI flag.
|
|
18
|
+
const parsed = parseDecimalDigits(value)
|
|
19
|
+
if (parsed === null || parsed < MIN_POLLING_SYNC_INTERVAL_MS) {
|
|
15
20
|
throw new KanbanError(
|
|
16
21
|
ErrorCode.INVALID_CONFIG,
|
|
17
22
|
`${opts.label ?? 'KANBAN_SYNC_INTERVAL_MS'} must be an integer >= ${MIN_POLLING_SYNC_INTERVAL_MS}`,
|
package/src/tracker-config.ts
CHANGED
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
import { ErrorCode, KanbanError } from './errors'
|
|
2
2
|
import { providerNotConfigured } from './providers/errors'
|
|
3
3
|
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
4
|
+
import { parseDecimalDigits } from './transport-input'
|
|
4
5
|
|
|
5
6
|
export type TrackerProvider = 'local' | 'linear' | 'jira'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Single source of truth for which env var holds each provider's webhook signing
|
|
10
|
+
* secret (`null` = the provider has no webhook ingestion). Typed as
|
|
11
|
+
* `Record<TrackerProvider, …>`, so adding a new provider to the union is a
|
|
12
|
+
* compile error here until its secret env is declared — preventing a new
|
|
13
|
+
* webhook-capable provider from silently slipping past the tunnel-security gate
|
|
14
|
+
* (assertTunnelSecurity) with no secret enforced.
|
|
15
|
+
*/
|
|
16
|
+
export const WEBHOOK_SECRET_ENV: Record<TrackerProvider, string | null> = {
|
|
17
|
+
local: null,
|
|
18
|
+
linear: 'LINEAR_WEBHOOK_SECRET',
|
|
19
|
+
jira: 'JIRA_WEBHOOK_SECRET',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize the raw `KANBAN_PROVIDER` env value to a known `TrackerProvider`,
|
|
24
|
+
* falling back to `'local'` for anything unset/unrecognized — matching how
|
|
25
|
+
* trackerConfigFromEnv resolves the provider. Centralized so the security gate
|
|
26
|
+
* and the config loader agree on a single derivation.
|
|
27
|
+
*/
|
|
28
|
+
export function trackerProviderFromEnv(
|
|
29
|
+
env: Record<string, string | undefined> = process.env,
|
|
30
|
+
): TrackerProvider {
|
|
31
|
+
const raw = (env['KANBAN_PROVIDER'] ?? 'local').trim().toLowerCase()
|
|
32
|
+
// Recognize exactly the providers declared in WEBHOOK_SECRET_ENV — own keys
|
|
33
|
+
// only, so inherited names like "constructor"/"toString" don't match — and
|
|
34
|
+
// fall back to local otherwise. Deriving from the map keeps this normalizer in
|
|
35
|
+
// lockstep with it, so a newly-added provider is picked up here automatically.
|
|
36
|
+
return Object.prototype.hasOwnProperty.call(WEBHOOK_SECRET_ENV, raw)
|
|
37
|
+
? (raw as TrackerProvider)
|
|
38
|
+
: 'local'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a provider's webhook signing secret from `env` via WEBHOOK_SECRET_ENV
|
|
43
|
+
* (the single source of truth), returning `undefined` when the provider declares
|
|
44
|
+
* no secret env or the env var is unset — the case where the webhook handler
|
|
45
|
+
* falls back to open dev mode. The runtime signature enforcement (the provider
|
|
46
|
+
* webhook handlers) and the assertTunnelSecurity tunnel gate both resolve the
|
|
47
|
+
* secret env name through this same map, so the gate cannot require one env name
|
|
48
|
+
* while enforcement reads another (a fail-open if they drifted).
|
|
49
|
+
*/
|
|
50
|
+
export function webhookSecretFromEnv(
|
|
51
|
+
provider: TrackerProvider,
|
|
52
|
+
env: Record<string, string | undefined> = process.env,
|
|
53
|
+
): string | undefined {
|
|
54
|
+
const envName = WEBHOOK_SECRET_ENV[provider]
|
|
55
|
+
return envName ? env[envName] : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
7
58
|
export interface LocalTrackerConfig {
|
|
8
59
|
provider: 'local'
|
|
9
60
|
defaultColumns?: string[]
|
|
@@ -11,14 +62,14 @@ export interface LocalTrackerConfig {
|
|
|
11
62
|
syncIntervalMs?: number
|
|
12
63
|
}
|
|
13
64
|
|
|
14
|
-
|
|
65
|
+
interface LinearTrackerConfig {
|
|
15
66
|
provider: 'linear'
|
|
16
67
|
apiKey: string
|
|
17
68
|
teamId: string
|
|
18
69
|
syncIntervalMs?: number
|
|
19
70
|
}
|
|
20
71
|
|
|
21
|
-
|
|
72
|
+
interface JiraTrackerConfig {
|
|
22
73
|
provider: 'jira'
|
|
23
74
|
baseUrl: string
|
|
24
75
|
email: string
|
|
@@ -34,7 +85,7 @@ export type TrackerConfig = LocalTrackerConfig | LinearTrackerConfig | JiraTrack
|
|
|
34
85
|
export function trackerConfigFromEnv(
|
|
35
86
|
env: Record<string, string | undefined> = process.env,
|
|
36
87
|
): TrackerConfig {
|
|
37
|
-
const provider = (env
|
|
88
|
+
const provider = trackerProviderFromEnv(env)
|
|
38
89
|
|
|
39
90
|
if (provider === 'linear') {
|
|
40
91
|
const apiKey = env['LINEAR_API_KEY']
|
|
@@ -70,15 +121,14 @@ export function trackerConfigFromEnv(
|
|
|
70
121
|
`${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} required when KANBAN_PROVIDER=jira`,
|
|
71
122
|
)
|
|
72
123
|
}
|
|
73
|
-
const
|
|
74
|
-
const boardId = boardIdRaw ? Number.parseInt(boardIdRaw, 10) : undefined
|
|
124
|
+
const boardId = jiraBoardIdFromEnv(env['JIRA_BOARD_ID'])
|
|
75
125
|
return {
|
|
76
126
|
provider,
|
|
77
127
|
baseUrl: baseUrl!,
|
|
78
128
|
email: email!,
|
|
79
129
|
apiToken: apiToken!,
|
|
80
130
|
projectKey: projectKey!,
|
|
81
|
-
...(
|
|
131
|
+
...(boardId !== undefined ? { boardId } : {}),
|
|
82
132
|
defaultIssueType: env['JIRA_ISSUE_TYPE'] ?? 'Task',
|
|
83
133
|
syncIntervalMs: resolvePollingSyncIntervalMs(env['KANBAN_SYNC_INTERVAL_MS']),
|
|
84
134
|
}
|
|
@@ -94,6 +144,24 @@ export function trackerConfigFromEnv(
|
|
|
94
144
|
}
|
|
95
145
|
}
|
|
96
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the optional `JIRA_BOARD_ID` env var to a positive integer board id.
|
|
149
|
+
* Unset/blank → `undefined` (no board pinned). A set-but-invalid value throws
|
|
150
|
+
* `INVALID_CONFIG` rather than silently falling back: the previous
|
|
151
|
+
* `Number.parseInt` accepted trailing garbage and signs (`'12abc'→12`,
|
|
152
|
+
* `'-5'→-5`, `'1e3'→1`), quietly pinning a *wrong* board. This matches how
|
|
153
|
+
* resolvePollingSyncIntervalMs treats a malformed optional numeric env var.
|
|
154
|
+
*/
|
|
155
|
+
function jiraBoardIdFromEnv(raw: string | undefined): number | undefined {
|
|
156
|
+
const value = raw?.trim()
|
|
157
|
+
if (!value) return undefined
|
|
158
|
+
const parsed = parseDecimalDigits(value)
|
|
159
|
+
if (parsed === null || parsed < 1) {
|
|
160
|
+
throw new KanbanError(ErrorCode.INVALID_CONFIG, 'JIRA_BOARD_ID must be a positive integer')
|
|
161
|
+
}
|
|
162
|
+
return parsed
|
|
163
|
+
}
|
|
164
|
+
|
|
97
165
|
function defaultColumnsFromEnv(env: Record<string, string | undefined>): string[] | undefined {
|
|
98
166
|
const raw = env['KANBAN_DEFAULT_COLUMNS']?.trim()
|
|
99
167
|
if (!raw) return undefined
|
package/src/transport-input.ts
CHANGED
|
@@ -1,26 +1,58 @@
|
|
|
1
1
|
import { ErrorCode, KanbanError } from './errors'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Parse
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Parse a plain decimal-digit string into an exact non-negative integer, or
|
|
5
|
+
* `null` if it is not one. Rejects signs, decimals, and exponent/hex forms like
|
|
6
|
+
* `"-1"` / `"3.5"` / `"1e100"` / `"0x10"` that `Number()` would otherwise coerce
|
|
7
|
+
* to an integer, and rejects an over-long digit string that `Number()` rounds
|
|
8
|
+
* past `MAX_SAFE_INTEGER` or overflows to `Infinity`, so the result is always
|
|
9
|
+
* exact. Surrounding whitespace is trimmed. This is the shared digits-only
|
|
10
|
+
* primitive the throwing boundary/config parsers (`parseBoundedInt`,
|
|
11
|
+
* `resolvePollingSyncIntervalMs`, the JIRA_BOARD_ID loader) build on — keeping a
|
|
12
|
+
* single definition of "what counts as an integer" so they can't drift.
|
|
13
|
+
*/
|
|
14
|
+
export function parseDecimalDigits(value: string): number | null {
|
|
15
|
+
const trimmed = value.trim()
|
|
16
|
+
if (!/^\d+$/.test(trimmed)) return null
|
|
17
|
+
const parsed = Number(trimmed)
|
|
18
|
+
return Number.isSafeInteger(parsed) ? parsed : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse a required integer supplied through a transport boundary (HTTP query
|
|
23
|
+
* string, CLI flag) into the inclusive range `[min, max]` (max defaults to
|
|
24
|
+
* `MAX_SAFE_INTEGER`), via {@link parseDecimalDigits}. Throws
|
|
25
|
+
* `KanbanError(INVALID_ARGUMENT)` on any violation.
|
|
26
|
+
*/
|
|
27
|
+
export function parseBoundedInt(
|
|
28
|
+
value: string,
|
|
29
|
+
opts: { min: number; max?: number; field: string },
|
|
30
|
+
): number {
|
|
31
|
+
const parsed = parseDecimalDigits(value)
|
|
32
|
+
const max = opts.max ?? Number.MAX_SAFE_INTEGER
|
|
33
|
+
if (parsed === null || parsed < opts.min || parsed > max) {
|
|
34
|
+
const range = opts.max === undefined ? `>= ${opts.min}` : `between ${opts.min} and ${opts.max}`
|
|
35
|
+
throw new KanbanError(ErrorCode.INVALID_ARGUMENT, `${opts.field} must be an integer ${range}`)
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse an optional positive integer supplied through a transport boundary.
|
|
42
|
+
* Returns `undefined` when the value is absent/blank, otherwise resolves via
|
|
43
|
+
* {@link parseDecimalDigits} (which trims and caps at `MAX_SAFE_INTEGER`) and
|
|
44
|
+
* enforces `>= 1`, throwing `KanbanError(INVALID_ARGUMENT)` framed as "positive
|
|
45
|
+
* integer" so invalid limits don't leak into provider logic (SQL `LIMIT`,
|
|
46
|
+
* `slice`, PG).
|
|
11
47
|
*/
|
|
12
48
|
export function parsePositiveInt(
|
|
13
49
|
value: string | null | undefined,
|
|
14
50
|
field = 'limit',
|
|
15
51
|
): number | undefined {
|
|
16
52
|
if (value === null || value === undefined) return undefined
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// "1e100" that Number() would otherwise treat as integers and let escape into
|
|
21
|
-
// SQL `LIMIT`. Cap at MAX_SAFE_INTEGER so the parsed value stays exact.
|
|
22
|
-
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN
|
|
23
|
-
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > Number.MAX_SAFE_INTEGER) {
|
|
53
|
+
if (value.trim() === '') return undefined
|
|
54
|
+
const parsed = parseDecimalDigits(value)
|
|
55
|
+
if (parsed === null || parsed < 1) {
|
|
24
56
|
throw new KanbanError(
|
|
25
57
|
ErrorCode.INVALID_ARGUMENT,
|
|
26
58
|
`${field} must be a positive integer (received '${value}')`,
|
package/src/tunnel.ts
CHANGED
|
@@ -57,15 +57,32 @@ export function startCloudflareTunnel(port: number, opts: TunnelOptions = {}): T
|
|
|
57
57
|
): Promise<void> => {
|
|
58
58
|
if (!stream) return
|
|
59
59
|
const decoder = new TextDecoder()
|
|
60
|
+
// Accumulate across chunks: cloudflared may split the URL across read
|
|
61
|
+
// boundaries, so matching each chunk in isolation can miss it.
|
|
62
|
+
let buffer = ''
|
|
60
63
|
for await (const chunk of stream) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
+
// Once announced, keep draining the pipe (so the child doesn't block on a
|
|
65
|
+
// full stdout buffer) but stop scanning.
|
|
66
|
+
if (announced) continue
|
|
67
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true })
|
|
68
|
+
const match = buffer.match(TRYCLOUDFLARE_URL)
|
|
69
|
+
if (match) {
|
|
70
|
+
announce(match[0])
|
|
71
|
+
} else if (buffer.length > 4096) {
|
|
72
|
+
// Bound memory while keeping a tail long enough to span a split URL.
|
|
73
|
+
buffer = buffer.slice(-256)
|
|
74
|
+
}
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
// A stream error while draining stdout/stderr (e.g. the pipe tears down as the
|
|
79
|
+
// child is killed) must not surface as an unhandled rejection; the child.exited
|
|
80
|
+
// handler below still emits the no-URL warning when nothing was announced.
|
|
81
|
+
const drain = (stream: ReadableStream<Uint8Array> | null | undefined): void => {
|
|
82
|
+
void scanForUrl(stream).catch(() => {})
|
|
83
|
+
}
|
|
84
|
+
drain(child.stdout as ReadableStream<Uint8Array>)
|
|
85
|
+
drain(child.stderr as ReadableStream<Uint8Array>)
|
|
69
86
|
|
|
70
87
|
void child.exited.then((code) => {
|
|
71
88
|
if (!announced) {
|