@danypops/tickets 0.5.3 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Unified CLI, daemon, and TypeScript library for issue tracking across GitHub, GitLab, and Jira.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,10 +18,10 @@
18
18
  * indeterminate duration). Both plugins are explicitly disabled below, and
19
19
  * every call carries a hard timeout matching the old hand-rolled HttpClient's.
20
20
  */
21
- import { Octokit } from "octokit";
21
+
22
22
  import { RequestError } from "@octokit/request-error";
23
- import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
24
- import { parsePriority } from "../domain/issue.js";
23
+ import { Octokit } from "octokit";
24
+ import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
25
25
  import { ApiError, AuthRequiredError, IssueNotFoundError } from "./errors.js";
26
26
 
27
27
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -10,11 +10,11 @@
10
10
  * Self-hosted base URLs are still validated to reject SSRF-prone targets before
11
11
  * any request is made.
12
12
  */
13
- import { Gitlab } from "@gitbeaker/rest";
14
- import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
13
+
15
14
  import { isIP } from "node:net";
16
- import type { Comment, CreateInput, Issue, ListFilter, Status, UpdateInput } from "../domain/issue.js";
17
- import { parsePriority } from "../domain/issue.js";
15
+ import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
16
+ import { Gitlab } from "@gitbeaker/rest";
17
+ import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
18
18
  import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "./errors.js";
19
19
 
20
20
  export interface GitLabOptions {
@@ -80,11 +80,7 @@ export class GitLabRepository {
80
80
  // is set (confirmed by reading its source), so a stalled call fails predictably
81
81
  // instead of hanging -- same class of gap the octokit adapter needed a manual fix for.
82
82
  queryTimeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
83
- ...(opts.token
84
- ? opts.tokenType === "oauth"
85
- ? { oauthToken: opts.token }
86
- : { token: opts.token }
87
- : {}),
83
+ ...(opts.token ? (opts.tokenType === "oauth" ? { oauthToken: opts.token } : { token: opts.token }) : {}),
88
84
  ...(opts.requesterFn ? { requesterFn: opts.requesterFn } : {}),
89
85
  });
90
86
  }
@@ -144,9 +140,7 @@ export class GitLabRepository {
144
140
  // project is accepted for IssueRepository interface parity but ignored --
145
141
  // GitLab's scope (projectId) is fixed at construction, not overridable per call.
146
142
  async search(query: string, limit = 50, _project?: string): Promise<Issue[]> {
147
- const raw = await this.call<GlIssue[]>(() =>
148
- this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }),
149
- );
143
+ const raw = await this.call<GlIssue[]>(() => this.client.Issues.all({ projectId: this.projectId, search: query, perPage: limit }));
150
144
  return raw.map(toDomain);
151
145
  }
152
146
 
@@ -275,9 +269,7 @@ export function validateUrl(rawUrl: string): void {
275
269
  throw new InvalidUrlError(`gitlab: scheme must be http(s) (got ${parsed.protocol})`);
276
270
  }
277
271
  if (parsed.protocol === "http:" && parsed.hostname !== "localhost") {
278
- throw new InvalidUrlError(
279
- `gitlab: http:// only allowed for localhost (got ${parsed.hostname}); use https:// for remote instances`,
280
- );
272
+ throw new InvalidUrlError(`gitlab: http:// only allowed for localhost (got ${parsed.hostname}); use https:// for remote instances`);
281
273
  }
282
274
  if (isIP(parsed.hostname) && isPrivateIp(parsed.hostname)) {
283
275
  throw new InvalidUrlError("gitlab: private IP addresses are not allowed (blocks SSRF)");
@@ -80,7 +80,5 @@ export class HttpClient {
80
80
 
81
81
  /** Strips anything that looks like a bearer/basic credential from error bodies before logging. */
82
82
  function redact(text: string): string {
83
- return text
84
- .replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"')
85
- .slice(0, 2000);
83
+ return text.replace(/"(token|password|secret|api_key|authorization)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"').slice(0, 2000);
86
84
  }
@@ -12,15 +12,15 @@
12
12
  * client's `UserDetails.accountId` typing would make the fix straightforward
13
13
  * (see RESEARCH.md for the full analysis of the bug this leaves unfixed).
14
14
  */
15
- import { AgileClient, Version2Client } from "jira.js";
16
- import type { Config as JiraClientConfig, HttpException } from "jira.js";
15
+
17
16
  import type { AxiosAdapter } from "axios";
18
- import type { Comment, CreateInput, Issue, IssueLink, ListFilter, Status, UpdateInput } from "../domain/issue.js";
19
- import { parsePriority } from "../domain/issue.js";
20
- import { ApiError, IssueNotFoundError } from "./errors.js";
17
+ import type { HttpException, Config as JiraClientConfig } from "jira.js";
18
+ import { AgileClient, Version2Client } from "jira.js";
19
+ import type { Comment, CreateInput, Issue, IssueLink, ListFilter, parsePriority, Status, UpdateInput } from "../domain/issue.js";
21
20
  import type { Template } from "../domain/template.js";
22
21
  import { buildTemplateBody, extractTemplateSections } from "../domain/template.js";
23
22
  import * as manifest from "../manifest/manifest.js";
23
+ import { ApiError, IssueNotFoundError } from "./errors.js";
24
24
 
25
25
  /**
26
26
  * Basic-auth mode (email + API token) hits the tenant's own *.atlassian.net
@@ -188,7 +188,11 @@ export class JiraRepository {
188
188
  ]);
189
189
  const issue = this.toDomain(raw);
190
190
  if (remoteLinks.length > 0) {
191
- issue.externalLinks = remoteLinks.map((link) => ({ url: link.object?.url ?? "", title: link.object?.title, type: link.application?.name }));
191
+ issue.externalLinks = remoteLinks.map((link) => ({
192
+ url: link.object?.url ?? "",
193
+ title: link.object?.title,
194
+ type: link.application?.name,
195
+ }));
192
196
  }
193
197
  return issue;
194
198
  }
@@ -256,18 +260,12 @@ export class JiraRepository {
256
260
  }
257
261
 
258
262
  async listComments(key: string): Promise<Comment[]> {
259
- const result = await this.call<{ comments?: JiraComment[] }>(
260
- () => this.client.issueComments.getComments({ issueIdOrKey: key }),
261
- key,
262
- );
263
+ const result = await this.call<{ comments?: JiraComment[] }>(() => this.client.issueComments.getComments({ issueIdOrKey: key }), key);
263
264
  return (result?.comments ?? []).map(commentToDomain);
264
265
  }
265
266
 
266
267
  async addComment(key: string, body: string): Promise<Comment> {
267
- const raw = await this.call<JiraComment>(
268
- () => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }),
269
- key,
270
- );
268
+ const raw = await this.call<JiraComment>(() => this.client.issueComments.addComment({ issueIdOrKey: key, comment: body }), key);
271
269
  return commentToDomain(raw);
272
270
  }
273
271
 
@@ -526,7 +524,9 @@ function formatCustomFieldValue(raw: unknown): string | undefined {
526
524
  if (typeof raw === "string") return raw;
527
525
  if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
528
526
  if (Array.isArray(raw)) {
529
- const names = raw.map((item) => (item && typeof item === "object" && "name" in item ? String((item as { name: unknown }).name) : undefined)).filter((n): n is string => !!n);
527
+ const names = raw
528
+ .map((item) => (item && typeof item === "object" && "name" in item ? String((item as { name: unknown }).name) : undefined))
529
+ .filter((n): n is string => !!n);
530
530
  return names.length > 0 ? names.join(", ") : undefined;
531
531
  }
532
532
  if (raw && typeof raw === "object") {
@@ -538,7 +538,10 @@ function formatCustomFieldValue(raw: unknown): string | undefined {
538
538
 
539
539
  function coerceCustomFieldValue(field: { type: string; items?: string }, rawValue: string): unknown {
540
540
  if (field.type === "array") {
541
- const parts = rawValue.split(",").map((v) => v.trim()).filter(Boolean);
541
+ const parts = rawValue
542
+ .split(",")
543
+ .map((v) => v.trim())
544
+ .filter(Boolean);
542
545
  return field.items === "option" ? parts.map((v) => ({ value: v })) : parts;
543
546
  }
544
547
  if (field.type === "option") return { value: rawValue };
@@ -18,6 +18,16 @@ import {
18
18
  type IssueRepository,
19
19
  } from "../ports/repository.js";
20
20
 
21
+ export interface BackendCapabilities {
22
+ readonly name: string;
23
+ readonly supportsRawQuery: boolean;
24
+ readonly supportsFieldDiscovery: boolean;
25
+ readonly supportsStatusDiscovery: boolean;
26
+ readonly supportsTemplateDiscovery: boolean;
27
+ readonly supportsBoardQuickFilterDiscovery: boolean;
28
+ readonly supportsBoardFilterDiscovery: boolean;
29
+ }
30
+
21
31
  export class UnknownBackendError extends Error {
22
32
  constructor(backend: string, known: string[]) {
23
33
  super(`unknown backend "${backend}" (known: ${known.join(", ") || "none configured"})`);
@@ -39,6 +49,19 @@ export class TicketService {
39
49
  return Object.keys(this.repos);
40
50
  }
41
51
 
52
+ /** Every configured backend's name plus which optional capabilities its own repository actually implements -- lets a driving adapter (the CLI, pi-tickets, the Vehicle tool-availability sync) branch on real capability instead of a hardcoded backend name. */
53
+ backendCapabilities(): BackendCapabilities[] {
54
+ return Object.values(this.repos).map((repo) => ({
55
+ name: repo.name,
56
+ supportsRawQuery: hasRawQuery(repo),
57
+ supportsFieldDiscovery: hasFieldDiscovery(repo),
58
+ supportsStatusDiscovery: hasStatusDiscovery(repo),
59
+ supportsTemplateDiscovery: hasTemplateDiscovery(repo),
60
+ supportsBoardQuickFilterDiscovery: hasBoardQuickFilterDiscovery(repo),
61
+ supportsBoardFilterDiscovery: hasBoardFilterDiscovery(repo),
62
+ }));
63
+ }
64
+
42
65
  /**
43
66
  * Swaps the live backend set atomically. A backend newly configured in
44
67
  * Enigma (or removed) becomes usable on the next call without
@@ -29,7 +29,13 @@ export function openUrl(url: string, opts: { platform?: NodeJS.Platform; spawner
29
29
  const platform = opts.platform ?? process.platform;
30
30
  const spawner = opts.spawner ?? defaultSpawner;
31
31
 
32
- if (platform === "darwin") return spawner("open", [url]);
33
- if (platform === "win32") return spawner("cmd", ["/c", "start", '""', url]);
34
- return spawner("xdg-open", [url]);
32
+ if (platform === "darwin") {
33
+ spawner("open", [url]);
34
+ return;
35
+ }
36
+ if (platform === "win32") {
37
+ spawner("cmd", ["/c", "start", '""', url]);
38
+ return;
39
+ }
40
+ spawner("xdg-open", [url]);
35
41
  }
@@ -54,11 +54,7 @@ export interface DeviceFlowToken {
54
54
  refreshToken?: string;
55
55
  }
56
56
 
57
- async function postForm(
58
- fetchImpl: FetchLike,
59
- url: string,
60
- params: Record<string, string>,
61
- ): Promise<Record<string, unknown>> {
57
+ async function postForm(fetchImpl: FetchLike, url: string, params: Record<string, string>): Promise<Record<string, unknown>> {
62
58
  const res = await fetchImpl(url, {
63
59
  method: "POST",
64
60
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -80,8 +76,7 @@ export async function requestDeviceAuthorization(config: DeviceFlowConfig): Prom
80
76
  deviceCode: body.device_code,
81
77
  userCode: body.user_code,
82
78
  verificationUri: String(body.verification_uri ?? ""),
83
- verificationUriComplete:
84
- typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : undefined,
79
+ verificationUriComplete: typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : undefined,
85
80
  expiresInSeconds: Number(body.expires_in ?? 900),
86
81
  intervalSeconds: Number(body.interval ?? 5),
87
82
  };
@@ -130,8 +125,7 @@ export async function pollForToken(
130
125
  accessToken: body.access_token,
131
126
  tokenType: String(body.token_type ?? "Bearer"),
132
127
  scope: typeof body.scope === "string" ? body.scope : undefined,
133
- expiresAt:
134
- typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
128
+ expiresAt: typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
135
129
  refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
136
130
  };
137
131
  }
@@ -14,9 +14,7 @@
14
14
  */
15
15
  export type GhCliTokenResult = { ok: true; token: string } | { ok: false; reason: string };
16
16
 
17
- export interface SpawnLike {
18
- (command: string[]): { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
19
- }
17
+ export type SpawnLike = (command: string[]) => { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
20
18
 
21
19
  const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe" });
22
20
 
@@ -26,21 +24,26 @@ const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe"
26
24
  * prompts, never falls back to a device flow itself.
27
25
  */
28
26
  export async function readGhCliToken(user?: string, spawn: SpawnLike = defaultSpawn): Promise<GhCliTokenResult> {
29
- const command = user ? ["gh", "auth", "token", "--user", user] : ["gh", "auth", "token"];
30
- let proc: ReturnType<SpawnLike>;
31
- try {
32
- proc = spawn(command);
33
- } catch {
34
- return { ok: false, reason: "gh CLI not found -- install it (cli.github.com) or use a different login method" };
35
- }
36
- const [stdout, code] = await Promise.all([
37
- proc.stdout instanceof ReadableStream ? new Response(proc.stdout).text() : Promise.resolve(""),
38
- proc.exited,
39
- ]);
40
- if (code !== 0) {
41
- return { ok: false, reason: user ? `gh CLI has no authenticated account named "${user}" -- run \`gh auth login\` first` : "gh CLI is not authenticated -- run `gh auth login` first" };
42
- }
43
- const token = stdout.trim();
44
- if (!token) return { ok: false, reason: "gh auth token returned no token" };
45
- return { ok: true, token };
27
+ const command = user ? ["gh", "auth", "token", "--user", user] : ["gh", "auth", "token"];
28
+ let proc: ReturnType<SpawnLike>;
29
+ try {
30
+ proc = spawn(command);
31
+ } catch {
32
+ return { ok: false, reason: "gh CLI not found -- install it (cli.github.com) or use a different login method" };
33
+ }
34
+ const [stdout, code] = await Promise.all([
35
+ proc.stdout instanceof ReadableStream ? new Response(proc.stdout).text() : Promise.resolve(""),
36
+ proc.exited,
37
+ ]);
38
+ if (code !== 0) {
39
+ return {
40
+ ok: false,
41
+ reason: user
42
+ ? `gh CLI has no authenticated account named "${user}" -- run \`gh auth login\` first`
43
+ : "gh CLI is not authenticated -- run `gh auth login` first",
44
+ };
45
+ }
46
+ const token = stdout.trim();
47
+ if (!token) return { ok: false, reason: "gh auth token returned no token" };
48
+ return { ok: true, token };
46
49
  }
@@ -92,7 +92,9 @@ export function startCallbackServer(expectedState: string, port = 0): CallbackSe
92
92
  rejectCode?.(new JiraOAuthError("callback missing authorization code"));
93
93
  return;
94
94
  }
95
- res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body>Authorized. You can close this tab and return to the terminal.</body></html>");
95
+ res
96
+ .writeHead(200, { "Content-Type": "text/html" })
97
+ .end("<html><body>Authorized. You can close this tab and return to the terminal.</body></html>");
96
98
  resolveCode?.({ code, redirectUri: redirectUriFor() });
97
99
  });
98
100
 
@@ -118,12 +120,7 @@ export function startCallbackServer(expectedState: string, port = 0): CallbackSe
118
120
  };
119
121
  }
120
122
 
121
- export function buildAuthorizeUrl(opts: {
122
- clientId: string;
123
- redirectUri: string;
124
- scope: string;
125
- state: string;
126
- }): string {
123
+ export function buildAuthorizeUrl(opts: { clientId: string; redirectUri: string; scope: string; state: string }): string {
127
124
  const url = new URL(ATLASSIAN_AUTHORIZE_URL);
128
125
  url.searchParams.set("audience", "api.atlassian.com");
129
126
  url.searchParams.set("client_id", opts.clientId);
package/src/cli/index.ts CHANGED
@@ -6,16 +6,16 @@
6
6
  * ledger or a backend adapter directly.
7
7
  */
8
8
  import { Command } from "commander";
9
- import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../domain/issue.js";
10
- import { parseStatus } from "../domain/issue.js";
11
- import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
12
9
  import { openUrl } from "../auth/browser.js";
13
- import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
14
10
  import { readGhCliToken } from "../auth/gh-cli.js";
15
- import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
11
+ import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
12
+ import { loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
16
13
  import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
17
- import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
18
14
  import { promptMaskedSecret } from "../auth/masked-prompt.js";
15
+ import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
16
+ import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
17
+ import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../domain/issue.js";
18
+ import { parseStatus } from "../domain/issue.js";
19
19
  import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
20
20
 
21
21
  function printJson(value: unknown): void {
@@ -144,8 +144,9 @@ ledger
144
144
  .command("search <query>")
145
145
  .description("search the local ledger (works even if the backend is currently unreachable)")
146
146
  .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
147
+ .option("--backend <name>", "scope the search to one configured backend")
147
148
  .action(async (query: string, opts) => {
148
- await withClient((client) => client.call("ledger.search", { query, limit: opts.limit }));
149
+ await withClient((client) => client.call("ledger.search", { query, limit: opts.limit, backend: opts.backend }));
149
150
  });
150
151
 
151
152
  ledger
@@ -199,7 +200,9 @@ focus
199
200
  await withClient((client) => client.call("focus.clear", {}));
200
201
  });
201
202
 
202
- const discoverCmd = program.command("discover").description("discover and persist backend-specific mappings (custom fields, statuses) or a description template");
203
+ const discoverCmd = program
204
+ .command("discover")
205
+ .description("discover and persist backend-specific mappings (custom fields, statuses) or a description template");
203
206
 
204
207
  discoverCmd
205
208
  .command("fields")
@@ -226,7 +229,12 @@ discoverCmd
226
229
  .option("--sample-size <n>", "how many recent issues to sample", (v) => Number.parseInt(v, 10))
227
230
  .action(async (opts) => {
228
231
  await withClient((client) =>
229
- client.call("discover.template", { backend: opts.backend, project: opts.project, issueType: opts.issueType, sampleSize: opts.sampleSize }),
232
+ client.call("discover.template", {
233
+ backend: opts.backend,
234
+ project: opts.project,
235
+ issueType: opts.issueType,
236
+ sampleSize: opts.sampleSize,
237
+ }),
230
238
  );
231
239
  });
232
240
 
@@ -239,7 +247,9 @@ discoverCmd
239
247
  await withClient((client) => client.call("discover.board_filter", { backend: opts.backend, boardId: opts.board }));
240
248
  });
241
249
 
242
- const queryCmd = program.command("query").description("save and run named raw backend queries (Jira JQL) -- e.g. a board's sprint or backlog view");
250
+ const queryCmd = program
251
+ .command("query")
252
+ .description("save and run named raw backend queries (Jira JQL) -- e.g. a board's sprint or backlog view");
243
253
 
244
254
  queryCmd
245
255
  .command("save <name>")
@@ -248,7 +258,9 @@ queryCmd
248
258
  .requiredOption("--jql <jql>", "the raw query string (Jira JQL)")
249
259
  .option("--description <text>", "human-readable note about what this query is")
250
260
  .action(async (name: string, opts) => {
251
- await withClient((client) => client.call("query.save", { name, backend: opts.backend, query: opts.jql, description: opts.description }));
261
+ await withClient((client) =>
262
+ client.call("query.save", { name, backend: opts.backend, query: opts.jql, description: opts.description }),
263
+ );
252
264
  });
253
265
 
254
266
  queryCmd
@@ -275,12 +287,16 @@ queryCmd
275
287
 
276
288
  discoverCmd
277
289
  .command("board_quickfilter")
278
- .description("resolve a Jira board's quick filter id to its JQL fragment -- board view's quickFilter=, backlog view's customFilter=, are the same id")
290
+ .description(
291
+ "resolve a Jira board's quick filter id to its JQL fragment -- board view's quickFilter=, backlog view's customFilter=, are the same id",
292
+ )
279
293
  .requiredOption("-b, --backend <name>", "backend name")
280
294
  .requiredOption("--board <id>", "board id", (v) => Number.parseInt(v, 10))
281
295
  .requiredOption("--quick-filter <id>", "quick filter id", (v) => Number.parseInt(v, 10))
282
296
  .action(async (opts) => {
283
- await withClient((client) => client.call("discover.board_quickfilter", { backend: opts.backend, boardId: opts.board, quickFilterId: opts.quickFilter }));
297
+ await withClient((client) =>
298
+ client.call("discover.board_quickfilter", { backend: opts.backend, boardId: opts.board, quickFilterId: opts.quickFilter }),
299
+ );
284
300
  });
285
301
 
286
302
  const daemon = program.command("daemon").description("manage the tickets daemon process");
@@ -347,7 +363,9 @@ daemon
347
363
 
348
364
  const service = program
349
365
  .command("service")
350
- .description("deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)");
366
+ .description(
367
+ "deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)",
368
+ );
351
369
 
352
370
  service
353
371
  .command("install")
@@ -394,7 +412,10 @@ auth
394
412
  .option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
395
413
  .option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
396
414
  .option("--scope <scope>", "space-delimited OAuth scope override")
397
- .option("--gh-cli [account]", "github only: reuse an already-authenticated gh CLI session instead of the device flow (omit value for gh's active account)")
415
+ .option(
416
+ "--gh-cli [account]",
417
+ "github only: reuse an already-authenticated gh CLI session instead of the device flow (omit value for gh's active account)",
418
+ )
398
419
  .action(async (opts) => {
399
420
  const type = opts.type ?? opts.backend;
400
421
  try {
@@ -402,12 +423,20 @@ auth
402
423
  const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
403
424
  if (!result.ok) throw new Error(result.reason);
404
425
  saveToken(opts.backend, { accessToken: result.token });
405
- printJson({ backend: opts.backend, status: "authorized", via: "gh-cli", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
426
+ printJson({
427
+ backend: opts.backend,
428
+ status: "authorized",
429
+ via: "gh-cli",
430
+ note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token",
431
+ });
406
432
  return;
407
433
  }
408
434
  if (type === "github") {
409
435
  const clientId = opts.clientId ?? process.env.GITHUB_OAUTH_CLIENT_ID;
410
- if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required (or pass --gh-cli [account] to reuse an already-authenticated gh CLI session instead)");
436
+ if (!clientId)
437
+ throw new Error(
438
+ "--client-id or GITHUB_OAUTH_CLIENT_ID is required (or pass --gh-cli [account] to reuse an already-authenticated gh CLI session instead)",
439
+ );
411
440
  const token = await loginWithGitHubDeviceFlow({
412
441
  clientId,
413
442
  scope: opts.scope,
@@ -420,7 +449,12 @@ auth
420
449
  }
421
450
  },
422
451
  });
423
- saveToken(opts.backend, { accessToken: token.accessToken, refreshToken: token.refreshToken, expiresAt: token.expiresAt, scope: token.scope });
452
+ saveToken(opts.backend, {
453
+ accessToken: token.accessToken,
454
+ refreshToken: token.refreshToken,
455
+ expiresAt: token.expiresAt,
456
+ scope: token.scope,
457
+ });
424
458
  } else if (type === "gitlab") {
425
459
  const clientId = opts.clientId ?? process.env.GITLAB_OAUTH_CLIENT_ID;
426
460
  if (!clientId) throw new Error("--client-id or GITLAB_OAUTH_CLIENT_ID is required");
@@ -438,12 +472,19 @@ auth
438
472
  }
439
473
  },
440
474
  });
441
- saveToken(opts.backend, { accessToken: token.accessToken, refreshToken: token.refreshToken, expiresAt: token.expiresAt, scope: token.scope });
475
+ saveToken(opts.backend, {
476
+ accessToken: token.accessToken,
477
+ refreshToken: token.refreshToken,
478
+ expiresAt: token.expiresAt,
479
+ scope: token.scope,
480
+ });
442
481
  } else if (type === "jira") {
443
482
  const clientId = opts.clientId ?? process.env.JIRA_OAUTH_CLIENT_ID;
444
483
  const clientSecret = opts.clientSecret ?? process.env.JIRA_OAUTH_CLIENT_SECRET;
445
484
  if (!clientId || !clientSecret) {
446
- throw new Error("--client-id/--client-secret or JIRA_OAUTH_CLIENT_ID/JIRA_OAUTH_CLIENT_SECRET are required (Atlassian 3LO has no public-client flow)");
485
+ throw new Error(
486
+ "--client-id/--client-secret or JIRA_OAUTH_CLIENT_ID/JIRA_OAUTH_CLIENT_SECRET are required (Atlassian 3LO has no public-client flow)",
487
+ );
447
488
  }
448
489
  const token = await loginWithJiraAuthorizationCode({
449
490
  clientId,
@@ -493,7 +534,11 @@ auth
493
534
  return;
494
535
  }
495
536
  saveToken(backend, { accessToken: value });
496
- printJson({ backend, status: "stored", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
537
+ printJson({
538
+ backend,
539
+ status: "stored",
540
+ note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token",
541
+ });
497
542
  });
498
543
 
499
544
  auth
@@ -7,11 +7,11 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
10
11
  import type { DaemonHandle } from "@danypops/vehicle-server/paths";
11
12
  import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
12
- import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
13
+ import { TICKETS_DAEMON_NAMES, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "../daemon/ops.js";
13
14
  import { packageRoot } from "../util/package-root.js";
14
- import { TICKETS_DAEMON_NAMES, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "../daemon/ops.js";
15
15
 
16
16
  export function ticketsPaths(env?: Record<string, string | undefined>) {
17
17
  return resolveDaemonPaths(TICKETS_DAEMON_NAMES, env ? { env } : {});
@@ -58,9 +58,7 @@ export interface EnsureDaemonOptions {
58
58
 
59
59
  const DEFAULT_SPAWN_TIMEOUT_MS = 4_000;
60
60
 
61
- export async function ensureDaemonRunning(
62
- opts: EnsureDaemonOptions = {},
63
- ): Promise<{ baseUrl: string; token: string }> {
61
+ export async function ensureDaemonRunning(opts: EnsureDaemonOptions = {}): Promise<{ baseUrl: string; token: string }> {
64
62
  const paths = ticketsPaths();
65
63
  const token = ensureAuthToken(paths.token, "Tickets");
66
64
 
@@ -70,9 +68,7 @@ export async function ensureDaemonRunning(
70
68
  }
71
69
 
72
70
  if (opts.autoStart === false) {
73
- throw new Error(
74
- "tickets daemon is not running. Start it with `npm run daemon` (or `bun run src/daemon/main.ts`).",
75
- );
71
+ throw new Error("tickets daemon is not running. Start it with `npm run daemon` (or `bun run src/daemon/main.ts`).");
76
72
  }
77
73
 
78
74
  spawnDaemon();
@@ -6,16 +6,16 @@
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
- import { parse as parseYaml } from "yaml";
9
+ import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
10
10
  import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
11
11
  import type { Logger } from "@danypops/vehicle-server/logging";
12
+ import { parse as parseYaml } from "yaml";
12
13
  import { GitHubRepository } from "../adapters/github.js";
13
14
  import { GitLabRepository } from "../adapters/gitlab.js";
14
15
  import { JiraRepository } from "../adapters/jira.js";
15
- import type { IssueRepository } from "../ports/repository.js";
16
16
  import type { TicketService } from "../application/service.js";
17
17
  import { isTokenFresh, loadToken } from "../auth/token-store.js";
18
- import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
18
+ import type { IssueRepository } from "../ports/repository.js";
19
19
 
20
20
  export interface BackendConfig {
21
21
  /** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
@@ -142,6 +142,8 @@ export function createBackendRefreshTask(
142
142
  buildRepos: BuildRepositories,
143
143
  intervalMs: number,
144
144
  logger?: Logger,
145
+ /** Re-syncs Vehicle tool availability (createTicketsVehicleRegistry's syncDiscoverAvailability) against the freshly swapped-in backend set -- called after every successful refresh, so a Jira credential added or removed at runtime flips discover.* tool visibility without a daemon restart. Optional: a caller with no vehicleRegistry yet (tests, injected repos) just skips it. */
146
+ onRefreshed?: (service: TicketService) => void,
145
147
  ): MaintenanceTask {
146
148
  return {
147
149
  name: "backend-refresh",
@@ -158,6 +160,7 @@ export function createBackendRefreshTask(
158
160
  return;
159
161
  }
160
162
  service.setRepos(fresh);
163
+ onRefreshed?.(service);
161
164
  const after = new Set(Object.keys(fresh));
162
165
  const added = [...after].filter((backend) => !before.has(backend));
163
166
  const removed = [...before].filter((backend) => !after.has(backend));
@@ -6,20 +6,20 @@
6
6
  * root instead of hitting real GitHub/GitLab/Jira or the real home directory.
7
7
  */
8
8
  import type { Database } from "bun:sqlite";
9
+ import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
9
10
  import { createLogger, type Logger } from "@danypops/vehicle-server/logging";
10
11
  import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
11
12
  import { checkpoint, openSqliteWithPragmas } from "@danypops/vehicle-server/storage";
12
- import type { StartDaemonOptions } from "@danypops/vehicle-server/daemon";
13
13
  import { TicketService } from "../application/service.js";
14
- import { buildRepositories, type BuildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
14
+ import { type BuildRepositories, buildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
15
15
  import type { IssueRepository } from "../ports/repository.js";
16
+ import { createTicketsVehicleRegistry, syncDiscoverAvailability } from "../vehicle/tickets-vehicle.js";
16
17
  import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
17
- import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
18
- import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "./saved-queries.js";
18
+ import { LEDGER_MIGRATIONS, Ledger } from "./ledger.js";
19
19
  import { TICKETS_DAEMON_NAMES } from "./ops.js";
20
- import { buildApp, type TicketsAppDeps } from "./server.js";
21
20
  import { createSyncTask } from "./poller.js";
22
- import { createTicketsVehicleRegistry } from "../vehicle/tickets-vehicle.js";
21
+ import { SAVED_QUERY_MIGRATIONS, SavedQueryStore } from "./saved-queries.js";
22
+ import { buildApp, type TicketsAppDeps } from "./server.js";
23
23
 
24
24
  export interface BootstrapOptions {
25
25
  pathEnv?: PathEnvironment;
@@ -79,7 +79,16 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
79
79
  // the registry field itself -- createTicketsVehicleRegistry never reads
80
80
  // deps.vehicleRegistry, so this ordering is safe (see server.ts's own
81
81
  // comment on why the registry is built outside it, not imported into it).
82
- const vehicleRegistry = createTicketsVehicleRegistry({ service, ledger, focusStore, queries, token, version, logger, onShutdownRequested } as TicketsAppDeps);
82
+ const vehicleRegistry = createTicketsVehicleRegistry({
83
+ service,
84
+ ledger,
85
+ focusStore,
86
+ queries,
87
+ token,
88
+ version,
89
+ logger,
90
+ onShutdownRequested,
91
+ } as TicketsAppDeps);
83
92
 
84
93
  const options: StartDaemonOptions = {
85
94
  daemonLabel: "Tickets",
@@ -95,7 +104,16 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
95
104
  // Only when repos came from real config/env/Enigma resolution -- an
96
105
  // injected test fixture (opts.repos) has no config to re-resolve from.
97
106
  ...(opts.repos === undefined
98
- ? [createBackendRefreshTask(service, config, buildRepos, opts.backendRefreshIntervalMs ?? DEFAULT_BACKEND_REFRESH_INTERVAL_MS, logger)]
107
+ ? [
108
+ createBackendRefreshTask(
109
+ service,
110
+ config,
111
+ buildRepos,
112
+ opts.backendRefreshIntervalMs ?? DEFAULT_BACKEND_REFRESH_INTERVAL_MS,
113
+ logger,
114
+ (refreshedService) => syncDiscoverAvailability(vehicleRegistry, refreshedService),
115
+ ),
116
+ ]
99
117
  : []),
100
118
  ],
101
119
  buildApp: () =>
@@ -98,9 +98,15 @@ export class Ledger {
98
98
  return row ? rowToIssue(row) : undefined;
99
99
  }
100
100
 
101
- /** Bounded LIKE search over title, newest-synced first. Explicit limit, capped hard. */
102
- search(query: string, limit = DEFAULT_SEARCH_LIMIT): Issue[] {
101
+ /** Bounded LIKE search over title, newest-synced first, optionally scoped to one backend. Explicit limit, capped hard. */
102
+ search(query: string, limit = DEFAULT_SEARCH_LIMIT, backend?: string): Issue[] {
103
103
  const bounded = Math.min(Math.max(limit, 1), MAX_SEARCH_LIMIT);
104
+ if (backend) {
105
+ const rows = this.db
106
+ .query("SELECT * FROM issues WHERE title LIKE ? AND backend = ? ORDER BY synced_at DESC LIMIT ?")
107
+ .all(`%${query}%`, backend, bounded) as IssueRow[];
108
+ return rows.map(rowToIssue);
109
+ }
104
110
  const rows = this.db
105
111
  .query("SELECT * FROM issues WHERE title LIKE ? ORDER BY synced_at DESC LIMIT ?")
106
112
  .all(`%${query}%`, bounded) as IssueRow[];
@@ -116,9 +122,10 @@ export class Ledger {
116
122
  }
117
123
 
118
124
  stats(): { backend: string; count: number }[] {
119
- const rows = this.db
120
- .query("SELECT backend, COUNT(*) as count FROM issues GROUP BY backend ORDER BY backend")
121
- .all() as { backend: string; count: number }[];
125
+ const rows = this.db.query("SELECT backend, COUNT(*) as count FROM issues GROUP BY backend ORDER BY backend").all() as {
126
+ backend: string;
127
+ count: number;
128
+ }[];
122
129
  return rows;
123
130
  }
124
131
  }
package/src/daemon/ops.ts CHANGED
@@ -47,7 +47,7 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
47
47
  "issue.children": { ref: string };
48
48
  "issue.comments": { ref: string };
49
49
  "issue.comment_add": { ref: string; body: string };
50
- "ledger.search": { query: string; limit?: number };
50
+ "ledger.search": { query: string; limit?: number; backend?: string };
51
51
  "ledger.stats": Record<string, never>;
52
52
  "focus.set": { ref: string };
53
53
  "focus.get": Record<string, never>;
@@ -67,7 +67,7 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
67
67
  }
68
68
 
69
69
  export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
70
- "backends.list": { backends: string[] };
70
+ "backends.list": { backends: { name: string; supportsRawQuery: boolean }[] };
71
71
  "issue.list": { issues: Issue[] };
72
72
  "issue.get": { issue: Issue };
73
73
  "issue.create": { issue: Issue };
@@ -5,8 +5,9 @@
5
5
  * network, bad creds) is logged and skipped; it never crashes the daemon
6
6
  * and never blocks other backends' syncs.
7
7
  */
8
- import type { Logger } from "@danypops/vehicle-server/logging";
8
+
9
9
  import type { MaintenanceTask } from "@danypops/vehicle-server/daemon";
10
+ import type { Logger } from "@danypops/vehicle-server/logging";
10
11
  import type { TicketService } from "../application/service.js";
11
12
  import type { Ledger } from "./ledger.js";
12
13
 
@@ -42,12 +43,7 @@ export async function syncOnce(
42
43
  * service is synced on this task's very next run, no daemon restart or
43
44
  * task rebuild needed.
44
45
  */
45
- export function createSyncTask(
46
- service: TicketService,
47
- ledger: Ledger,
48
- intervalMs: number,
49
- logger?: Logger,
50
- ): MaintenanceTask {
46
+ export function createSyncTask(service: TicketService, ledger: Ledger, intervalMs: number, logger?: Logger): MaintenanceTask {
51
47
  return {
52
48
  name: "ledger-sync",
53
49
  intervalMs,
@@ -6,16 +6,17 @@
6
6
  * stand up. Every operation here has a CLI command (cli/index.ts) and a
7
7
  * pi-tickets tool action — no operation exists only for one caller.
8
8
  */
9
- import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
10
- import type { Logger } from "@danypops/vehicle-server/logging";
11
- import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
9
+
12
10
  import type { VehicleRegistry } from "@danypops/vehicle-server";
11
+ import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
12
+ import type { Logger } from "@danypops/vehicle-server/logging";
13
+ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBearerToken } from "@danypops/vehicle-server/rpc-http";
13
14
  import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
14
15
  import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
15
16
  import { parseRef } from "../domain/issue.js";
16
17
  import { FocusError, type FocusStore } from "./focus.js";
17
18
  import type { Ledger } from "./ledger.js";
18
- import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
19
+ import { TICKET_OPERATIONS, type TicketOperation, type TicketOpInputs, type TicketOpOutputs } from "./ops.js";
19
20
  import { SavedQueryNotFoundError, type SavedQueryStore } from "./saved-queries.js";
20
21
 
21
22
  export interface TicketsAppDeps {
@@ -59,7 +60,7 @@ export type Handler<Op extends TicketOperation> = (
59
60
  * newer surface.
60
61
  */
61
62
  export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
62
- "backends.list": async (deps) => ({ backends: deps.service.backends() }),
63
+ "backends.list": async (deps) => ({ backends: deps.service.backendCapabilities() }),
63
64
  "issue.list": async (deps, input) => ({ issues: await deps.service.list(input.backend, input.filter) }),
64
65
  "issue.get": async (deps, input) => ({ issue: await deps.service.get(input.ref) }),
65
66
  "issue.create": async (deps, input) => ({ issue: await deps.service.create(input.backend, input.input) }),
@@ -68,7 +69,7 @@ export const TICKET_OP_HANDLERS: { [Op in TicketOperation]: Handler<Op> } = {
68
69
  "issue.children": async (deps, input) => ({ issues: await deps.service.children(input.ref) }),
69
70
  "issue.comments": async (deps, input) => ({ comments: await deps.service.comments(input.ref) }),
70
71
  "issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
71
- "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit) }),
72
+ "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit, input.backend) }),
72
73
  "ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
73
74
  "focus.set": async (deps, input) => {
74
75
  // Ledger-first: focusing a ticket already pooled locally needs no live
@@ -15,14 +15,7 @@ export function parsePriority(value: unknown): Priority {
15
15
  return (PRIORITIES as readonly string[]).includes(lower) ? (lower as Priority) : "none";
16
16
  }
17
17
 
18
- export const STATUSES = [
19
- "backlog",
20
- "todo",
21
- "in_progress",
22
- "in_review",
23
- "done",
24
- "canceled",
25
- ] as const;
18
+ export const STATUSES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"] as const;
26
19
  export type Status = (typeof STATUSES)[number];
27
20
 
28
21
  export function parseStatus(value: unknown, fallback: Status = "todo"): Status {
package/src/index.ts CHANGED
@@ -1,35 +1,34 @@
1
- export * from "./domain/issue.js";
2
- export * from "./ports/repository.js";
3
1
  export * from "./adapters/errors.js";
4
- export { GitHubRepository, type GitHubOptions } from "./adapters/github.js";
5
- export { GitLabRepository, type GitLabOptions } from "./adapters/gitlab.js";
6
- export { JiraRepository, type JiraOptions } from "./adapters/jira.js";
7
- export { TicketService, UnknownBackendError, NotSupportedError } from "./application/service.js";
8
- export {
9
- type BackendConfig,
10
- type Config,
11
- loadConfig,
12
- buildRepositories,
13
- defaultConfigPath,
14
- configDir,
15
- } from "./config/config.js";
16
- export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
17
- export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
2
+ export { type GitHubOptions, GitHubRepository } from "./adapters/github.js";
3
+ export { type GitLabOptions, GitLabRepository } from "./adapters/gitlab.js";
4
+ export { type JiraOptions, JiraRepository } from "./adapters/jira.js";
5
+ export { NotSupportedError, TicketService, UnknownBackendError } from "./application/service.js";
6
+ export { openUrl } from "./auth/browser.js";
7
+ // Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
8
+ // — see RESEARCH.md for why each backend gets a different flow.
9
+ export * from "./auth/device-flow.js";
10
+ export * from "./auth/github-oauth.js";
11
+ export * from "./auth/gitlab-oauth.js";
12
+ export * from "./auth/jira-oauth.js";
13
+ export * from "./auth/token-store.js";
18
14
  export {
19
15
  createTicketsClient,
16
+ type EnsureDaemonOptions,
20
17
  ensureDaemonRunning,
21
18
  resolveVehicleClientTarget,
22
- ticketsPaths,
23
- type EnsureDaemonOptions,
24
19
  type TicketsRpcClient,
20
+ ticketsPaths,
25
21
  type VehicleClientTarget,
26
22
  } from "./client/tickets-client.js";
27
-
28
- // Delegated OAuth (device flow for GitHub/GitLab, authorization code for Jira)
29
- // — see RESEARCH.md for why each backend gets a different flow.
30
- export * from "./auth/device-flow.js";
31
- export * from "./auth/token-store.js";
32
- export { openUrl } from "./auth/browser.js";
33
- export * from "./auth/github-oauth.js";
34
- export * from "./auth/gitlab-oauth.js";
35
- export * from "./auth/jira-oauth.js";
23
+ export {
24
+ type BackendConfig,
25
+ buildRepositories,
26
+ type Config,
27
+ configDir,
28
+ defaultConfigPath,
29
+ loadConfig,
30
+ } from "./config/config.js";
31
+ export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
32
+ export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
33
+ export * from "./domain/issue.js";
34
+ export * from "./ports/repository.js";
@@ -14,10 +14,18 @@
14
14
  * daemon.shutdown is deliberately excluded: it's an admin/lifecycle
15
15
  * operation, not something an agent should be able to call as a tool.
16
16
  */
17
- import { bindVehicleOperation, defineLooseObjectSchema, defineVehicleOperation, passthroughVehicleSchema, type VehicleEffect, type LooseObjectProperty } from "@danypops/vehicle-core";
17
+ import {
18
+ bindVehicleOperation,
19
+ defineLooseObjectSchema,
20
+ defineVehicleOperation,
21
+ type LooseObjectProperty,
22
+ passthroughVehicleSchema,
23
+ type VehicleEffect,
24
+ } from "@danypops/vehicle-core";
18
25
  import { VehicleRegistry } from "@danypops/vehicle-server";
19
- import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../daemon/server.js";
26
+ import type { BackendCapabilities, TicketService } from "../application/service.js";
20
27
  import type { TicketOperation } from "../daemon/ops.js";
28
+ import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../daemon/server.js";
21
29
 
22
30
  const OWNER = "tickets";
23
31
 
@@ -52,19 +60,38 @@ function definedEntriesOnly(input: Record<string, unknown>): Record<string, unkn
52
60
  }
53
61
 
54
62
  const OPERATIONS: readonly OperationSpec[] = [
55
- { action: "backends.list", description: "Lists every configured backend name (github, gitlab, jira, ...).", effect: "read", properties: {}, required: [] },
63
+ {
64
+ action: "backends.list",
65
+ description: "Lists every configured backend name (github, gitlab, jira, ...).",
66
+ effect: "read",
67
+ properties: {},
68
+ required: [],
69
+ },
56
70
  {
57
71
  action: "issue.list",
58
72
  description: "Lists issues from one backend, optionally filtered.",
59
73
  effect: "read",
60
- properties: { backend: stringProp, project: stringProp, status: stringProp, assignee: stringProp, labels: stringArrayProp, limit: numberProp },
74
+ properties: {
75
+ backend: stringProp,
76
+ project: stringProp,
77
+ status: stringProp,
78
+ assignee: stringProp,
79
+ labels: stringArrayProp,
80
+ limit: numberProp,
81
+ },
61
82
  required: ["backend"],
62
83
  mapInput: ({ backend, project, status, assignee, labels, limit }) => ({
63
84
  backend,
64
85
  filter: definedEntriesOnly({ project, status, assignee, labels, limit }),
65
86
  }),
66
87
  },
67
- { action: "issue.get", description: "Gets one issue by its ref (e.g. \"github:#42\").", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
88
+ {
89
+ action: "issue.get",
90
+ description: 'Gets one issue by its ref (e.g. "github:#42").',
91
+ effect: "read",
92
+ properties: { ref: stringProp },
93
+ required: ["ref"],
94
+ },
68
95
  {
69
96
  action: "issue.create",
70
97
  description: "Creates a new issue on a live backend -- a real, externally visible write, not a local draft.",
@@ -79,9 +106,27 @@ const OPERATIONS: readonly OperationSpec[] = [
79
106
  properties: { ref: stringProp, input: { type: "object" } },
80
107
  required: ["ref", "input"],
81
108
  },
82
- { action: "issue.search", description: "Searches one backend's issues by text query.", effect: "read", properties: { backend: stringProp, query: stringProp, limit: numberProp, project: stringProp }, required: ["backend", "query"] },
83
- { action: "issue.children", description: "Lists an issue's child issues.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
84
- { action: "issue.comments", description: "Lists an issue's comments.", effect: "read", properties: { ref: stringProp }, required: ["ref"] },
109
+ {
110
+ action: "issue.search",
111
+ description: "Searches one backend's issues by text query.",
112
+ effect: "read",
113
+ properties: { backend: stringProp, query: stringProp, limit: numberProp, project: stringProp },
114
+ required: ["backend", "query"],
115
+ },
116
+ {
117
+ action: "issue.children",
118
+ description: "Lists an issue's child issues.",
119
+ effect: "read",
120
+ properties: { ref: stringProp },
121
+ required: ["ref"],
122
+ },
123
+ {
124
+ action: "issue.comments",
125
+ description: "Lists an issue's comments.",
126
+ effect: "read",
127
+ properties: { ref: stringProp },
128
+ required: ["ref"],
129
+ },
85
130
  {
86
131
  action: "issue.comment_add",
87
132
  description: "Adds a comment to an issue on its live backend -- a real, externally visible write.",
@@ -89,15 +134,51 @@ const OPERATIONS: readonly OperationSpec[] = [
89
134
  properties: { ref: stringProp, body: stringProp },
90
135
  required: ["ref", "body"],
91
136
  },
92
- { action: "ledger.search", description: "Searches the local pooled-issue ledger (no live backend call).", effect: "read", properties: { query: stringProp, limit: numberProp }, required: ["query"] },
93
- { action: "ledger.stats", description: "Per-backend counts of issues pooled into the local ledger.", effect: "read", properties: {}, required: [] },
94
- { action: "focus.set", description: "Sets the currently focused issue, by ref.", effect: "local-write", properties: { ref: stringProp }, required: ["ref"] },
137
+ {
138
+ action: "ledger.search",
139
+ description: "Searches the local pooled-issue ledger (no live backend call).",
140
+ effect: "read",
141
+ properties: { query: stringProp, limit: numberProp },
142
+ required: ["query"],
143
+ },
144
+ {
145
+ action: "ledger.stats",
146
+ description: "Per-backend counts of issues pooled into the local ledger.",
147
+ effect: "read",
148
+ properties: {},
149
+ required: [],
150
+ },
151
+ {
152
+ action: "focus.set",
153
+ description: "Sets the currently focused issue, by ref.",
154
+ effect: "local-write",
155
+ properties: { ref: stringProp },
156
+ required: ["ref"],
157
+ },
95
158
  { action: "focus.get", description: "Gets the currently focused issue, if any.", effect: "read", properties: {}, required: [] },
96
- { action: "focus.pause", description: "Pauses focus with an optional reason, without clearing it.", effect: "local-write", properties: { reason: stringProp }, required: [] },
159
+ {
160
+ action: "focus.pause",
161
+ description: "Pauses focus with an optional reason, without clearing it.",
162
+ effect: "local-write",
163
+ properties: { reason: stringProp },
164
+ required: [],
165
+ },
97
166
  { action: "focus.unpause", description: "Resumes a paused focus.", effect: "local-write", properties: {}, required: [] },
98
167
  { action: "focus.clear", description: "Clears the currently focused issue.", effect: "local-write", properties: {}, required: [] },
99
- { action: "discover.fields", description: "Discovers a backend's custom field display names and IDs (Jira).", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
100
- { action: "discover.statuses", description: "Discovers a backend's real status names.", effect: "read", properties: { backend: stringProp }, required: ["backend"] },
168
+ {
169
+ action: "discover.fields",
170
+ description: "Discovers a backend's custom field display names and IDs (Jira).",
171
+ effect: "read",
172
+ properties: { backend: stringProp },
173
+ required: ["backend"],
174
+ },
175
+ {
176
+ action: "discover.statuses",
177
+ description: "Discovers a backend's real status names.",
178
+ effect: "read",
179
+ properties: { backend: stringProp },
180
+ required: ["backend"],
181
+ },
101
182
  {
102
183
  action: "discover.template",
103
184
  description: "Samples recent issues for a project/issueType and extracts a reusable description template (Jira).",
@@ -107,27 +188,36 @@ const OPERATIONS: readonly OperationSpec[] = [
107
188
  },
108
189
  {
109
190
  action: "discover.board_quickfilter",
110
- description: "Resolves a Jira board's quick filter id to its JQL fragment -- the one-time step to turn a board/backlog view into a saved query.",
191
+ description:
192
+ "Resolves a Jira board's quick filter id to its JQL fragment -- the one-time step to turn a board/backlog view into a saved query.",
111
193
  effect: "read",
112
194
  properties: { backend: stringProp, boardId: numberProp, quickFilterId: numberProp },
113
195
  required: ["backend", "boardId", "quickFilterId"],
114
196
  },
115
197
  {
116
198
  action: "discover.board_filter",
117
- description: "Resolves a Jira board's own real base scope -- its saved filter's JQL -- rather than assuming it tracks one named project.",
199
+ description:
200
+ "Resolves a Jira board's own real base scope -- its saved filter's JQL -- rather than assuming it tracks one named project.",
118
201
  effect: "read",
119
202
  properties: { backend: stringProp, boardId: numberProp },
120
203
  required: ["backend", "boardId"],
121
204
  },
122
205
  {
123
206
  action: "query.save",
124
- description: "Saves a raw backend query (Jira JQL) under a name, so it can be run again later without retyping it -- e.g. a board's sprint or backlog view.",
207
+ description:
208
+ "Saves a raw backend query (Jira JQL) under a name, so it can be run again later without retyping it -- e.g. a board's sprint or backlog view.",
125
209
  effect: "local-write",
126
210
  properties: { name: stringProp, backend: stringProp, query: stringProp, description: stringProp },
127
211
  required: ["name", "backend", "query"],
128
212
  },
129
213
  { action: "query.list", description: "Lists every saved query.", effect: "read", properties: {}, required: [] },
130
- { action: "query.remove", description: "Removes a saved query by name.", effect: "local-write", properties: { name: stringProp }, required: ["name"] },
214
+ {
215
+ action: "query.remove",
216
+ description: "Removes a saved query by name.",
217
+ effect: "local-write",
218
+ properties: { name: stringProp },
219
+ required: ["name"],
220
+ },
131
221
  {
132
222
  action: "query.run",
133
223
  description: "Runs a saved query by name against its backend and returns the matching issues.",
@@ -137,6 +227,54 @@ const OPERATIONS: readonly OperationSpec[] = [
137
227
  },
138
228
  ];
139
229
 
230
+ /**
231
+ * The five discover.* operations only ever succeed against a backend whose
232
+ * repository implements the matching optional capability (Jira today,
233
+ * structurally -- never a hardcoded backend name). An operation none of the
234
+ * currently configured backends could possibly satisfy is marked
235
+ * unavailable so it never appears in the LLM's callable tool list in the
236
+ * first place, instead of being offered and then failing with
237
+ * NotSupportedError on the first real call.
238
+ */
239
+ const DISCOVER_AVAILABILITY: readonly { action: TicketOperation; capability: keyof BackendCapabilities; reason: string }[] = [
240
+ { action: "discover.fields", capability: "supportsFieldDiscovery", reason: "no configured backend supports field discovery (Jira only)" },
241
+ {
242
+ action: "discover.statuses",
243
+ capability: "supportsStatusDiscovery",
244
+ reason: "no configured backend supports status discovery (Jira only)",
245
+ },
246
+ {
247
+ action: "discover.template",
248
+ capability: "supportsTemplateDiscovery",
249
+ reason: "no configured backend supports template discovery (Jira only)",
250
+ },
251
+ {
252
+ action: "discover.board_quickfilter",
253
+ capability: "supportsBoardQuickFilterDiscovery",
254
+ reason: "no configured backend supports board quick-filter discovery (Jira only)",
255
+ },
256
+ {
257
+ action: "discover.board_filter",
258
+ capability: "supportsBoardFilterDiscovery",
259
+ reason: "no configured backend supports board filter discovery (Jira only)",
260
+ },
261
+ ];
262
+
263
+ /**
264
+ * Re-syncs the five discover.* operations' availability against the
265
+ * service's current backend set -- called once right after the registry is
266
+ * built, and again after every live backend refresh (config.ts's
267
+ * createBackendRefreshTask), so a Jira credential added or removed at
268
+ * runtime flips these tools' visibility without a daemon restart.
269
+ */
270
+ export function syncDiscoverAvailability(registry: VehicleRegistry, service: TicketService): void {
271
+ const capabilities = service.backendCapabilities();
272
+ for (const { action, capability, reason } of DISCOVER_AVAILABILITY) {
273
+ const available = capabilities.some((backend) => backend[capability]);
274
+ registry.setAvailability(action, 1, available, available ? undefined : reason);
275
+ }
276
+ }
277
+
140
278
  /**
141
279
  * Builds a VehicleRegistry exposing every real ticket operation, backed by
142
280
  * the exact same deps shape the daemon's own hand-rolled dispatch already
@@ -145,7 +283,11 @@ const OPERATIONS: readonly OperationSpec[] = [
145
283
  * this registry to the same base object afterward).
146
284
  */
147
285
  export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicleRegistry">): VehicleRegistry {
148
- const registry = new VehicleRegistry({ name: "tickets", version: "1.0.0", description: "Unified issue tracking across GitHub, GitLab, and Jira." });
286
+ const registry = new VehicleRegistry({
287
+ name: "tickets",
288
+ version: "1.0.0",
289
+ description: "Unified issue tracking across GitHub, GitLab, and Jira.",
290
+ });
149
291
 
150
292
  for (const spec of OPERATIONS) {
151
293
  const operation = defineVehicleOperation({
@@ -167,5 +309,6 @@ export function createTicketsVehicleRegistry(deps: Omit<TicketsAppDeps, "vehicle
167
309
  );
168
310
  }
169
311
 
312
+ syncDiscoverAvailability(registry, deps.service);
170
313
  return registry;
171
314
  }