@danypops/tickets 0.10.1 → 0.10.4

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 CHANGED
@@ -18,11 +18,12 @@ GitHub/GitLab/Jira or opens the SQLite ledger directly. See
18
18
  ## Requirements
19
19
 
20
20
  - **[Bun](https://bun.sh) 1.1+.** The daemon uses `bun:sqlite` and
21
- `Bun.serve` (via `@danypops/daemon-kit`); the CLI, library, and pi-tickets
22
- extension are plain TypeScript but currently ship as source, run through
23
- Bun rather than a compiled Node build.
24
- - `@danypops/daemon-kit` comes from the public npm registry (`^0.2.1`) —
25
- no local checkout or `file:` path needed, `bun install` fetches it directly.
21
+ `Bun.serve` through `@danypops/vehicle-server`; the CLI, library, and
22
+ pi-tickets extension are plain TypeScript but currently ship as source and
23
+ run through Bun rather than a compiled Node build.
24
+ - The published `@danypops/vehicle-*` packages provide the daemon, authenticated
25
+ RPC/Vehicle contracts, resilient client, Pi projection, and Armada service
26
+ integration. No local Vehicle checkout or `file:` dependency is required.
26
27
 
27
28
  ## Install
28
29
 
@@ -76,25 +77,25 @@ bun run src/cli/index.ts discover statuses -b jira
76
77
  bun run src/cli/index.ts discover template -b jira --project PROJ --issue-type Bug
77
78
  ```
78
79
 
79
- ### Running the daemon persistently (systemd --user)
80
+ ### Running the daemon persistently (Armada)
80
81
 
81
- `daemon start` spawns the daemon on demand and it lives only as long as
82
- something keeps it alive. For a daemon that survives logout/reboot, install
83
- it as a systemd `--user` service instead (Linux only):
82
+ `daemon start` is the on-demand path. For a daemon owned by the native service
83
+ manager and reconciled from desired state, register it with Armada:
84
84
 
85
85
  ```bash
86
- bun run src/cli/index.ts service install # writes + enables + (re)starts the unit
86
+ bun run src/cli/index.ts service install # Armada upsert + reconcile
87
+ bun run src/cli/index.ts service uninstall # Armada remove
88
+
89
+ # Direct lifecycle actions currently target systemd --user (Linux):
87
90
  bun run src/cli/index.ts service status
88
91
  bun run src/cli/index.ts service stop
89
92
  bun run src/cli/index.ts service restart
90
- bun run src/cli/index.ts service path # where the unit file lives
91
93
  ```
92
94
 
93
- `service install` points `ExecStart` at the exact `bun` binary and package
94
- checkout currently running the CLI, so re-running it after an upgrade (a new
95
- `npm`/`bun` global install, or a fresh checkout) picks up the new path
96
- immediately via `daemon-reload` + `enable` + `restart` no manual `stop`
97
- needed first.
95
+ `service install` records the exact Bun binary, CLI entry path, version, handle
96
+ path, restart policy, and readiness probe in Armada's fleet manifest. Armada
97
+ then projects that declaration through systemd, launchd, or Windows Task
98
+ Scheduler. Re-run `service install` after upgrading or moving the package.
98
99
 
99
100
  Once installed as a package, the same commands are available as `tickets`
100
101
  and `tickets-daemon` (see `bin` in `package.json`).
@@ -273,13 +274,13 @@ member directory instead: `{ "packages": ["/path/to/tickets/packages/pi-tickets"
273
274
  ```bash
274
275
  bun install # from the repo root -- links both workspace members
275
276
  bun run typecheck # both packages
276
- bun test # both packages
277
+ bun run test # both packages, sequential isolated test processes
277
278
  ```
278
279
 
279
- Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take an
280
- injectable `fetchImpl`, and the daemon tests (`test/rpc/`, `test/sqlite/`, `test/process/`) run the real
281
- `@danypops/daemon-kit` `startDaemon()`/SQLite/HTTP stack against a scratch
282
- XDG root with a fake `IssueRepository`.
280
+ Tests never hit real GitHub/GitLab/Jira/Atlassian: adapters take injectable
281
+ transport implementations, and the daemon tests (`test/rpc/`, `test/sqlite/`,
282
+ `test/process/`) run the real `@danypops/vehicle-server` daemon/SQLite/HTTP
283
+ stack against a scratch XDG root with a fake `IssueRepository`.
283
284
 
284
285
  ## Architecture
285
286
 
@@ -292,13 +293,14 @@ Driver (inbound) Application Driven (outbound)
292
293
  └───────────────┘ │ + Ledger │───────▶│ SQLite (Ledger) │
293
294
  │ + Poller) │ └──────────────────┘
294
295
  └─────────────────┘
295
- built on @danypops/daemon-kit
296
- (paths, storage, http, logging, daemon, rpc-client)
296
+ built on @danypops/vehicle-server
297
+ (Vehicle registry, paths, storage, HTTP, logging,
298
+ daemon lifecycle, Armada service integration)
297
299
  ```
298
300
 
299
301
  Hexagonal architecture: `src/domain` has zero I/O, `src/ports` defines the
300
302
  outbound contract, `src/adapters` implement it per backend, `src/application`
301
303
  orchestrates by parsing `backend:key` refs and routing to the named
302
304
  repository, and `src/daemon` is the only place that owns the SQLite ledger,
303
- wraps it in a Bearer-authenticated HTTP RPC surface, and runs the pooling
304
- poller as a `daemon-kit` maintenance task.
305
+ wraps it in a Bearer-authenticated HTTP/Vehicle surface, and runs the pooling
306
+ poller as a Vehicle maintenance task.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.10.1",
3
+ "version": "0.10.4",
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",
@@ -24,9 +24,9 @@
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "dependencies": {
27
- "@danypops/vehicle-core": "^0.12.3",
28
- "@danypops/vehicle-server": "^0.17.1",
29
- "@danypops/vehicle-client": "^0.5.0",
27
+ "@danypops/vehicle-core": "^0.12.5",
28
+ "@danypops/vehicle-server": "^0.18.2",
29
+ "@danypops/vehicle-client": "^0.7.0",
30
30
  "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
32
32
  "commander": "^12.1.0",
@@ -0,0 +1,122 @@
1
+ import { isVehicleError, VehicleError } from "@danypops/vehicle-core";
2
+ import { ApiError, AuthRequiredError, BackendConfigurationError, BackendConnectionError, InvalidUrlError } from "../issue/errors.js";
3
+ import { statusForKnownTicketError } from "../rpc/error-status.js";
4
+
5
+ function apiErrorToVehicle(error: ApiError): VehicleError {
6
+ const details = { backend: error.backend, status: error.status };
7
+
8
+ if (error.status === 401 || error.status === 403) {
9
+ return new VehicleError("backend-authentication-failed", `${error.backend}: authentication or authorization was rejected`, {
10
+ category: "authorization",
11
+ details,
12
+ recovery: { message: "Check the configured credential and its backend permissions, then retry." },
13
+ cause: error,
14
+ });
15
+ }
16
+ if (error.status === 408) {
17
+ return new VehicleError("backend-timeout", `${error.backend}: backend request timed out`, {
18
+ category: "timeout",
19
+ retryable: true,
20
+ details,
21
+ recovery: { message: "Retry after backend connectivity recovers." },
22
+ cause: error,
23
+ });
24
+ }
25
+ if (error.status === 409) {
26
+ return new VehicleError("backend-conflict", `${error.backend}: backend rejected the request because its state changed`, {
27
+ category: "conflict",
28
+ details,
29
+ recovery: { message: "Refresh the issue and retry against its current state." },
30
+ cause: error,
31
+ });
32
+ }
33
+ if (error.status === 429) {
34
+ return new VehicleError("backend-rate-limited", `${error.backend}: backend API rate limit exceeded`, {
35
+ category: "capacity",
36
+ retryable: true,
37
+ details,
38
+ recovery: { message: "Retry after the backend rate limit resets; cached ledger reads remain available." },
39
+ cause: error,
40
+ });
41
+ }
42
+ if (error.status >= 500) {
43
+ return new VehicleError("backend-unavailable", `${error.backend}: backend API is unavailable (${error.status})`, {
44
+ category: "unavailable",
45
+ retryable: true,
46
+ details,
47
+ recovery: { message: "Retry later; for reads, use ledger.search while the live backend is unavailable." },
48
+ cause: error,
49
+ });
50
+ }
51
+ return new VehicleError("backend-request-rejected", `${error.backend}: backend rejected the request (${error.status})`, {
52
+ category: "validation",
53
+ details,
54
+ recovery: { message: "Check the operation input and backend-specific constraints, then retry." },
55
+ cause: error,
56
+ });
57
+ }
58
+
59
+ /** Converts reviewed Tickets failures into actionable, wire-safe Vehicle failures. */
60
+ export function toTicketsVehicleError(error: unknown): VehicleError {
61
+ if (isVehicleError(error)) return error;
62
+
63
+ if (error instanceof BackendConfigurationError) {
64
+ return new VehicleError("backend-not-configured", error.message, {
65
+ category: "validation",
66
+ recovery: { message: error.recovery },
67
+ cause: error,
68
+ });
69
+ }
70
+ if (error instanceof BackendConnectionError) {
71
+ return new VehicleError(error.kind === "timeout" ? "backend-timeout" : "backend-unavailable", error.message, {
72
+ category: error.kind === "timeout" ? "timeout" : "unavailable",
73
+ retryable: true,
74
+ details: { backend: error.backend },
75
+ recovery: {
76
+ message: "Check the configured URL and network, VPN, or DNS connectivity; cached ledger reads remain available.",
77
+ },
78
+ cause: error,
79
+ });
80
+ }
81
+ if (error instanceof ApiError) return apiErrorToVehicle(error);
82
+ if (error instanceof InvalidUrlError) {
83
+ return new VehicleError("invalid-backend-url", "Backend URL configuration is invalid", {
84
+ category: "validation",
85
+ recovery: { message: "Use an HTTPS backend URL (HTTP is accepted only for localhost), then restart the daemon." },
86
+ cause: error,
87
+ });
88
+ }
89
+ if (error instanceof AuthRequiredError) {
90
+ return new VehicleError("backend-authentication-required", error.message, {
91
+ category: "authorization",
92
+ recovery: { message: "Configure the backend credential, restart the daemon if needed, and retry." },
93
+ cause: error,
94
+ });
95
+ }
96
+
97
+ const status = statusForKnownTicketError(error);
98
+ if (status === 404) {
99
+ return new VehicleError("not-found", (error as Error).message, { category: "not_found", cause: error });
100
+ }
101
+ if (status === 400) {
102
+ return new VehicleError("operation-rejected", (error as Error).message, { category: "validation", cause: error });
103
+ }
104
+ if (status === 422) {
105
+ return new VehicleError("operation-rejected", (error as Error).message, { category: "authorization", cause: error });
106
+ }
107
+
108
+ // Unknown exceptions stay opaque: only reviewed domain/config/transport errors above
109
+ // may cross the daemon boundary with their original message.
110
+ return new VehicleError("handler-failed", "Tickets operation failed unexpectedly", {
111
+ category: "internal",
112
+ cause: error,
113
+ });
114
+ }
115
+
116
+ export async function withTicketsErrorParity<T>(run: () => T | Promise<T>): Promise<T> {
117
+ try {
118
+ return await run();
119
+ } catch (error) {
120
+ throw toTicketsVehicleError(error);
121
+ }
122
+ }
@@ -16,7 +16,6 @@
16
16
  */
17
17
  import {
18
18
  bindVehicleOperation,
19
- defineErrorMapping,
20
19
  defineLooseObjectSchema,
21
20
  defineVehicleOperation,
22
21
  type LooseObjectProperty,
@@ -25,21 +24,12 @@ import {
25
24
  } from "@danypops/vehicle-core";
26
25
  import { VehicleRegistry } from "@danypops/vehicle-server";
27
26
  import type { BackendCapabilities, TicketService } from "../issue/service.js";
28
- import { statusForKnownTicketError } from "../rpc/error-status.js";
29
27
  import type { TicketOperation } from "../rpc/ops.js";
30
28
  import { TICKET_OP_HANDLERS, type TicketsAppDeps } from "../rpc/server.js";
29
+ import { withTicketsErrorParity } from "./error-mapping.js";
31
30
 
32
31
  const OWNER = "tickets";
33
32
 
34
- const withTicketsErrorParity = defineErrorMapping(
35
- [
36
- { matches: (error) => statusForKnownTicketError(error) === 404, category: "not_found" },
37
- { matches: (error) => statusForKnownTicketError(error) === 400, category: "validation" },
38
- { matches: (error) => statusForKnownTicketError(error) === 422, category: "authorization" },
39
- ],
40
- { fallbackCategory: "internal", fallbackCode: "handler-failed", fallbackMessage: "Tickets operation failed" },
41
- );
42
-
43
33
  const LIMITS = { defaultTimeoutMs: 10_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
44
34
 
45
35
  const stringProp: LooseObjectProperty = { type: "string" };
package/src/cli/index.ts CHANGED
@@ -15,8 +15,9 @@ import { promptMaskedSecret } from "../auth/masked-prompt.js";
15
15
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
16
16
  import type { CreateInput, ListFilter, Priority, Status, UpdateInput } from "../issue/issue.js";
17
17
  import { parseStatus } from "../issue/issue.js";
18
+ import { serveMain } from "../process/main.js";
18
19
  import type { StagePatchFields, StagePayload } from "../stage/store.js";
19
- import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
20
+ import { ticketsServiceCli } from "./systemd-service.js";
20
21
  import { createTicketsClient, type TicketsRpcClient } from "./tickets-client.js";
21
22
 
22
23
  function printJson(value: unknown): void {
@@ -489,32 +490,53 @@ daemon
489
490
  }
490
491
  });
491
492
 
493
+ program
494
+ .command("serve")
495
+ .description("run the tickets daemon in the foreground -- Armada's service spec launches exactly this (see ./systemd-service.js)")
496
+ .action(async () => {
497
+ await serveMain();
498
+ });
499
+
492
500
  const service = program
493
501
  .command("service")
494
502
  .description(
495
- "deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)",
503
+ "register the tickets daemon with Armada's cross-platform desired-state fleet (direct start/stop/restart/status actions currently require systemd --user)",
496
504
  );
497
505
 
498
506
  service
499
507
  .command("install")
500
- .description("write, enable, and (re)start a tickets-daemon.service systemd --user unit pointed at this install")
508
+ .description("register this install with Armada as the tickets vehicle and reconcile it through the native service manager")
501
509
  .action(() => {
502
- try {
503
- const { unitPath } = installTicketsService();
504
- printJson({ status: "installed", unitPath });
505
- } catch (err) {
506
- process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
510
+ const cli = ticketsServiceCli();
511
+ const result = cli.install();
512
+ if (!result.installed) {
513
+ process.stderr.write(`error: ${result.reason}\n`);
514
+ process.exitCode = 1;
515
+ return;
516
+ }
517
+ printJson({ status: "installed", unitName: cli.unitName, via: "armada" });
518
+ });
519
+
520
+ service
521
+ .command("uninstall")
522
+ .description("remove this vehicle from Armada's fleet (stops and un-enrolls the tickets-daemon service)")
523
+ .action(() => {
524
+ const result = ticketsServiceCli().uninstall();
525
+ if (!result.installed) {
526
+ process.stderr.write(`error: ${result.reason}\n`);
507
527
  process.exitCode = 1;
528
+ return;
508
529
  }
530
+ printJson({ status: "uninstalled", via: "armada" });
509
531
  });
510
532
 
511
533
  for (const action of ["start", "stop", "restart", "status"] as const) {
512
534
  service
513
535
  .command(action)
514
- .description(`systemctl --user ${action} tickets-daemon.service`)
536
+ .description(`systemctl --user ${action} the Armada-managed tickets unit`)
515
537
  .action(() => {
516
538
  try {
517
- systemctlTickets(action);
539
+ ticketsServiceCli().action(action);
518
540
  } catch (err) {
519
541
  process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
520
542
  process.exitCode = 1;
@@ -522,13 +544,6 @@ for (const action of ["start", "stop", "restart", "status"] as const) {
522
544
  });
523
545
  }
524
546
 
525
- service
526
- .command("path")
527
- .description("print where the systemd unit file would be written")
528
- .action(() => {
529
- printJson({ unitPath: systemdUnitPath() });
530
- });
531
-
532
547
  const auth = program.command("auth").description("delegated OAuth login (device flow for GitHub/GitLab, authorization code for Jira)");
533
548
 
534
549
  auth
@@ -1,97 +1,36 @@
1
1
  /**
2
- * Deploys the tickets daemon as a persistent systemd --user service, so it
3
- * survives logout/reboot instead of only existing for as long as some CLI
4
- * command's on-demand auto-spawn keeps it alive. Mirrors papyrus's own
5
- * `papyrus service <install|start|stop|restart|status>` pattern exactly
6
- * (see ~/Projects/papyrus/src/cli.ts) -- same shape, same systemctl --user
7
- * verbs, same install order (write unit -> daemon-reload -> enable ->
8
- * restart). Every side effect (file write, directory creation, systemctl
9
- * invocation) is injectable so this is fully testable without touching a
10
- * real filesystem or spawning a real systemctl process.
2
+ * Deploys the tickets daemon as an Armada-supervised service. Builds the
3
+ * spec; createServiceCli (vehicle-server) owns install/uninstall/systemctl
4
+ * wiring shared by every Vehicle-backed daemon's CLI.
11
5
  */
12
- import { execFileSync } from "node:child_process";
13
- import { mkdirSync, writeFileSync } from "node:fs";
14
- import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createServiceCli, type ServiceCli, type ServiceSpec } from "@danypops/vehicle-server/service";
8
+ import { readPackageVersion } from "@danypops/vehicle-server/version";
15
9
  import { TICKETS_DAEMON_NAMES } from "../rpc/ops.js";
16
- import { resolveDaemonEntryPath } from "./tickets-client.js";
10
+ import { ticketsPaths } from "./tickets-client.js";
17
11
 
18
- export interface SystemdUnitOptions {
19
- bunBin: string;
20
- daemonMainPath: string;
12
+ export interface TicketsServiceSpecOptions {
13
+ version?: string;
14
+ /** Overridden in tests. */
15
+ cliEntryPath?: string;
21
16
  }
22
17
 
23
- export function renderSystemdUnit(options: SystemdUnitOptions): string {
24
- return `[Unit]
25
- Description=Tickets daemon -- unified GitHub/GitLab/Jira issue tracking
26
- After=default.target
27
-
28
- [Service]
29
- Type=simple
30
- ExecStart=${options.bunBin} run ${options.daemonMainPath}
31
- Restart=always
32
- RestartSec=2
33
-
34
- [Install]
35
- WantedBy=default.target
36
- `;
37
- }
38
-
39
- /** XDG_CONFIG_HOME/systemd/user/tickets-daemon.service, falling back to ~/.config like systemd itself does. */
40
- export function systemdUnitPath(env: Record<string, string | undefined> = process.env): string {
41
- const configHome = env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config");
42
- return join(configHome, "systemd", "user", TICKETS_DAEMON_NAMES.systemdUnitName);
43
- }
44
-
45
- export type CommandRunner = (command: string, args: string[]) => void;
46
-
47
- const defaultRunner: CommandRunner = (command, args) => {
48
- try {
49
- execFileSync(command, args, { stdio: "inherit" });
50
- } catch (err) {
51
- const message = err instanceof Error ? err.message : String(err);
52
- throw new Error(`${command} ${args.join(" ")} failed (is systemd --user available? this feature is Linux-only): ${message}`);
53
- }
54
- };
55
-
56
- export type SystemctlAction = "start" | "stop" | "restart" | "status" | "enable" | "daemon-reload";
57
-
58
- /** Always targets the tickets unit name under --user scope; daemon-reload takes no unit argument. */
59
- export function systemctlTickets(action: SystemctlAction, runner: CommandRunner = defaultRunner): void {
60
- const args = action === "daemon-reload" ? ["--user", "daemon-reload"] : ["--user", action, TICKETS_DAEMON_NAMES.systemdUnitName];
61
- runner("systemctl", args);
62
- }
63
-
64
- export interface InstallOptions {
65
- bunBin?: string;
66
- daemonMainPath?: string;
67
- env?: Record<string, string | undefined>;
68
- runner?: CommandRunner;
69
- writeFile?: (path: string, content: string) => void;
70
- ensureDir?: (path: string) => void;
18
+ /** The daemon is launched as `<bin> <this CLI's entry path> serve`. */
19
+ export function ticketsServiceSpec(opts: TicketsServiceSpecOptions = {}): ServiceSpec {
20
+ const cliEntryPath = opts.cliEntryPath ?? fileURLToPath(new URL("./index.js", import.meta.url));
21
+ const version = opts.version ?? readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
22
+ return {
23
+ name: TICKETS_DAEMON_NAMES.stateDirectoryName,
24
+ displayName: "Tickets daemon",
25
+ version,
26
+ binPath: process.execPath,
27
+ args: [cliEntryPath, "serve"],
28
+ handlePath: ticketsPaths().handle,
29
+ restartOnFailure: true,
30
+ restartSec: 2,
31
+ };
71
32
  }
72
33
 
73
- /**
74
- * Writes the unit file, then daemon-reload -> enable -> restart, in that
75
- * order -- systemd must see the file before enable/restart can act on it,
76
- * and restart (not start) so re-running install after an upgrade picks up
77
- * a changed ExecStart path immediately rather than requiring a manual stop.
78
- */
79
- export function installTicketsService(opts: InstallOptions = {}): { unitPath: string } {
80
- const unitPath = systemdUnitPath(opts.env);
81
- const ensureDir = opts.ensureDir ?? ((dir: string) => mkdirSync(dir, { recursive: true }));
82
- const writeFile = opts.writeFile ?? writeFileSync;
83
- const runner = opts.runner ?? defaultRunner;
84
-
85
- ensureDir(dirname(unitPath));
86
- writeFile(
87
- unitPath,
88
- renderSystemdUnit({
89
- bunBin: opts.bunBin ?? process.execPath,
90
- daemonMainPath: opts.daemonMainPath ?? resolveDaemonEntryPath(),
91
- }),
92
- );
93
- systemctlTickets("daemon-reload", runner);
94
- systemctlTickets("enable", runner);
95
- systemctlTickets("restart", runner);
96
- return { unitPath };
34
+ export function ticketsServiceCli(opts: TicketsServiceSpecOptions = {}): ServiceCli {
35
+ return createServiceCli(ticketsServiceSpec(opts));
97
36
  }
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { RequestError } from "@octokit/request-error";
23
23
  import { Octokit } from "octokit";
24
- import { ApiError, AuthRequiredError, IssueNotFoundError } from "../issue/errors.js";
24
+ import { ApiError, AuthRequiredError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
25
25
  import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
26
26
 
27
27
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -90,7 +90,13 @@ export class GitHubRepository {
90
90
  }
91
91
 
92
92
  private repoName(): string {
93
- if (!this.repo) throw new Error("github: repo not set — pass repo, or scope via config");
93
+ if (!this.repo) {
94
+ throw new BackendConfigurationError(
95
+ "github",
96
+ "repository is not configured; set GITHUB_REPO (or the backend's repo setting) and restart the tickets daemon",
97
+ "Set GITHUB_REPO (or the backend's repo setting), then restart the tickets daemon.",
98
+ );
99
+ }
94
100
  return this.repo;
95
101
  }
96
102
 
@@ -209,11 +215,13 @@ export class GitHubRepository {
209
215
  const res = await fn(controller.signal);
210
216
  return res.data;
211
217
  } catch (err) {
218
+ if (err instanceof BackendConfigurationError) throw err;
212
219
  if (err instanceof RequestError) {
213
220
  if (err.status === 404) throw new IssueNotFoundError("github", err.request.url);
214
221
  throw new ApiError("github", err.request.method, err.request.url, err.status, redact(err.message));
215
222
  }
216
- throw err;
223
+ if (err instanceof DOMException && err.name === "AbortError") throw new BackendConnectionError("github", "timeout", err);
224
+ throw new BackendConnectionError("github", "unreachable", err);
217
225
  } finally {
218
226
  clearTimeout(timer);
219
227
  }
@@ -14,7 +14,7 @@
14
14
  import { isIP } from "node:net";
15
15
  import { GitbeakerRequestError, type RequesterType, type ResourceOptions } from "@gitbeaker/requester-utils";
16
16
  import { Gitlab } from "@gitbeaker/rest";
17
- import { ApiError, AuthRequiredError, InvalidUrlError, IssueNotFoundError } from "../issue/errors.js";
17
+ import { ApiError, AuthRequiredError, BackendConnectionError, InvalidUrlError, IssueNotFoundError } from "../issue/errors.js";
18
18
  import type { Comment, CreateInput, Issue, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
19
19
 
20
20
  export interface GitLabOptions {
@@ -183,12 +183,13 @@ export class GitLabRepository {
183
183
  return (await fn()) as T;
184
184
  } catch (err) {
185
185
  if (err instanceof GitbeakerRequestError) {
186
- const status = err.cause?.response?.status ?? 500;
186
+ const status = err.cause?.response?.status;
187
187
  const url = err.cause?.request?.url ?? "";
188
+ if (status === undefined) throw new BackendConnectionError("gitlab", "unreachable", err);
188
189
  if (status === 404) throw new IssueNotFoundError("gitlab", url);
189
190
  throw new ApiError("gitlab", err.cause?.request?.method ?? "?", url, status, redact(err.message));
190
191
  }
191
- throw err;
192
+ throw new BackendConnectionError("gitlab", "unreachable", err);
192
193
  }
193
194
  }
194
195
  }
@@ -12,6 +12,35 @@ export class AuthRequiredError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ /** A reviewed, user-actionable backend setup failure safe to expose to clients. */
16
+ export class BackendConfigurationError extends Error {
17
+ constructor(
18
+ public readonly backend: string,
19
+ message: string,
20
+ public readonly recovery: string,
21
+ ) {
22
+ super(`${backend}: ${message}`);
23
+ this.name = "BackendConfigurationError";
24
+ }
25
+ }
26
+
27
+ /** A transport failure with no trustworthy HTTP response (DNS, VPN, connection, or timeout). */
28
+ export class BackendConnectionError extends Error {
29
+ constructor(
30
+ public readonly backend: string,
31
+ public readonly kind: "unreachable" | "timeout" = "unreachable",
32
+ cause?: unknown,
33
+ ) {
34
+ super(
35
+ kind === "timeout"
36
+ ? `${backend}: backend request timed out; retry or check backend connectivity`
37
+ : `${backend}: unable to reach the backend API; check the configured URL and network, VPN, or DNS connectivity`,
38
+ { cause },
39
+ );
40
+ this.name = "BackendConnectionError";
41
+ }
42
+ }
43
+
15
44
  export class ApiError extends Error {
16
45
  constructor(
17
46
  public readonly backend: string,
package/src/jira/jira.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  import type { AxiosAdapter } from "axios";
17
17
  import type { HttpException, Config as JiraClientConfig } from "jira.js";
18
18
  import { AgileClient, Version2Client } from "jira.js";
19
- import { ApiError, IssueNotFoundError } from "../issue/errors.js";
19
+ import { ApiError, BackendConfigurationError, BackendConnectionError, IssueNotFoundError } from "../issue/errors.js";
20
20
  import type { Comment, CreateInput, Issue, IssueLink, ListFilter, parsePriority, Status, UpdateInput } from "../issue/issue.js";
21
21
  import type { Template } from "../issue/template.js";
22
22
  import { buildTemplateBody, extractTemplateSections } from "../issue/template.js";
@@ -39,9 +39,9 @@ export interface JiraBasicAuthOptions {
39
39
  email: string;
40
40
  token: string;
41
41
  project?: string;
42
- /** Additional project keys the poller's background sync also pools into the ledger, beyond the single default `project` above -- see buildSyncQuery(). */
42
+ /** Additional default project keys, beyond the single `project` above -- widens list()/search()'s own default scope (no explicit project given) as well as the background poller's sync, both via defaultProjects(). See buildSyncQuery(). */
43
43
  syncProjects?: string[];
44
- /** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. */
44
+ /** When true, the poller's background sync also pools everything assigned to the authenticated user (JQL `assignee = currentUser()`), regardless of project -- covers projects not listed in `project`/`syncProjects`. list()/search() are unaffected -- pass an explicit assignee filter for that. */
45
45
  syncMine?: boolean;
46
46
  timeoutMs?: number;
47
47
  /** Injected in tests instead of a real network call — see axios's AxiosRequestConfig.adapter. */
@@ -180,15 +180,21 @@ export class JiraRepository {
180
180
  this.client = new Version2Client(this.clientConfig);
181
181
  }
182
182
 
183
+ /**
184
+ * An explicit filter.project always wins and narrows to exactly that one
185
+ * project; with none given, defaults to every project this repository
186
+ * cares about (defaultProjects() -- the same set buildSyncQuery() pools in
187
+ * the background), not just the single legacy `project` config field.
188
+ */
183
189
  async list(filter: ListFilter): Promise<Issue[]> {
184
- const project = filter.project ?? this.project;
190
+ const projects = filter.project ? [filter.project] : this.defaultProjects();
185
191
  const clauses: string[] = [];
186
- if (project) clauses.push(`project = ${jqlQuote(project)}`);
192
+ const scope = projectClause(projects);
193
+ if (scope) clauses.push(scope);
187
194
  if (filter.status) clauses.push(`status = ${jqlQuote(mapStatusToJira(filter.status))}`);
188
195
  if (filter.assignee) clauses.push(`assignee = ${jqlQuote(filter.assignee)}`);
189
196
  for (const label of filter.labels ?? []) clauses.push(`labels = ${jqlQuote(label)}`);
190
- const jql = `${clauses.join(" AND ")} ORDER BY created DESC`.trim();
191
- return this.searchJql(jql, filter.limit ?? 50);
197
+ return this.searchJql(buildJql(clauses, "AND"), filter.limit ?? 50);
192
198
  }
193
199
 
194
200
  async get(key: string): Promise<Issue> {
@@ -218,13 +224,19 @@ export class JiraRepository {
218
224
  const message = err instanceof Error ? err.message : String(err);
219
225
  throw new ApiError("jira", "?", key ?? "?", status, redact(message));
220
226
  }
221
- throw err;
227
+ throw new BackendConnectionError("jira", err instanceof DOMException && err.name === "AbortError" ? "timeout" : "unreachable", err);
222
228
  }
223
229
  }
224
230
 
225
231
  async create(input: CreateInput): Promise<Issue> {
226
232
  const project = input.project ?? this.project;
227
- if (!project) throw new Error("jira: project is required (pass project or set a default)");
233
+ if (!project) {
234
+ throw new BackendConfigurationError(
235
+ "jira",
236
+ "project is required to create an issue; pass input.project or configure JIRA_PROJECT",
237
+ "Pass input.project or set JIRA_PROJECT (or the backend's project setting), then retry.",
238
+ );
239
+ }
228
240
  const fields: Record<string, unknown> = {
229
241
  project: { key: project },
230
242
  summary: input.title,
@@ -259,10 +271,12 @@ export class JiraRepository {
259
271
  }
260
272
 
261
273
  async search(query: string, limit = 50, project?: string): Promise<Issue[]> {
262
- const effectiveProject = project ?? this.project;
263
- const scope = effectiveProject ? `project = ${jqlQuote(effectiveProject)} AND ` : "";
264
- const jql = `${scope}text ~ ${jqlQuote(query)} ORDER BY created DESC`;
265
- return this.searchJql(jql, limit);
274
+ const projects = project ? [project] : this.defaultProjects();
275
+ const clauses: string[] = [];
276
+ const scope = projectClause(projects);
277
+ if (scope) clauses.push(scope);
278
+ clauses.push(`text ~ ${jqlQuote(query)}`);
279
+ return this.searchJql(buildJql(clauses, "AND"), limit);
266
280
  }
267
281
 
268
282
  async listChildren(key: string): Promise<Issue[]> {
@@ -284,23 +298,33 @@ export class JiraRepository {
284
298
  return this.searchJql(query, limit);
285
299
  }
286
300
 
301
+ /**
302
+ * Every project this repository defaults to when a caller doesn't name one
303
+ * explicitly -- the single `project` config plus `syncProjects`, deduped.
304
+ * Shared by list()/search()'s own default-scope resolution and by
305
+ * buildSyncQuery() below, so "which projects do we care about" is answered
306
+ * in exactly one place instead of once per method.
307
+ */
308
+ private defaultProjects(): string[] {
309
+ return [...new Set([this.project, ...this.syncProjects].filter((p): p is string => Boolean(p)))];
310
+ }
311
+
287
312
  /**
288
313
  * SyncScopeExpandable -- widens what the poller's own background sync pools
289
- * into the local ledger beyond the single default `project` list() falls
290
- * back to: every configured project (default plus syncProjects) ORed with
314
+ * into the local ledger beyond defaultProjects() alone: ORs in
291
315
  * "assignee = currentUser()" when syncMine is set, so issues assigned to
292
- * you in a project nobody thought to list still get pooled. Returns
293
- * undefined -- letting the poller fall back to plain list() -- when
294
- * neither syncProjects nor syncMine adds anything beyond the default
295
- * project's own existing behavior.
316
+ * you in a project nobody listed still get pooled. Returns undefined --
317
+ * letting the poller fall back to plain list() -- when syncMine adds
318
+ * nothing beyond what list() already does with 0-1 default projects.
296
319
  */
297
320
  buildSyncQuery(): string | undefined {
298
- const projects = [...new Set([this.project, ...this.syncProjects].filter((p): p is string => Boolean(p)))];
321
+ const projects = this.defaultProjects();
299
322
  if (projects.length <= 1 && !this.syncMine) return undefined;
300
323
  const clauses: string[] = [];
301
- if (projects.length > 0) clauses.push(`project in (${projects.map(jqlQuote).join(", ")})`);
324
+ const scope = projectClause(projects);
325
+ if (scope) clauses.push(scope);
302
326
  if (this.syncMine) clauses.push("assignee = currentUser()");
303
- return `${clauses.join(" OR ")} ORDER BY created DESC`;
327
+ return buildJql(clauses, "OR");
304
328
  }
305
329
 
306
330
  /**
@@ -586,6 +610,18 @@ function jqlQuote(value: string): string {
586
610
  return `"${value.replace(/"/g, '\\"')}"`;
587
611
  }
588
612
 
613
+ /** Shared by list()/search()/buildSyncQuery() -- `project = X` for one project, `project in (...)` for several, undefined for none. */
614
+ function projectClause(projects: readonly string[]): string | undefined {
615
+ if (projects.length === 0) return undefined;
616
+ if (projects.length === 1) return `project = ${jqlQuote(projects[0]!)}`;
617
+ return `project in (${projects.map(jqlQuote).join(", ")})`;
618
+ }
619
+
620
+ /** Shared by list()/search()/buildSyncQuery() -- clauses joined by `joiner`, always ordered by `orderBy`. An empty clause list still yields valid JQL ("ORDER BY ..."), matching every one of this file's own pre-existing unscoped queries. */
621
+ function buildJql(clauses: readonly string[], joiner: "AND" | "OR", orderBy = "created DESC"): string {
622
+ return `${clauses.join(` ${joiner} `)} ORDER BY ${orderBy}`.trim();
623
+ }
624
+
589
625
  function mapStatusToJira(status: Status): string {
590
626
  switch (status) {
591
627
  case "backlog":
@@ -1,20 +1,28 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * The real tickets-daemon binary. Requires Bun (bun:sqlite, Bun.serve via
4
- * vehicle-server). Everything else in this package (the library, the CLI, the
5
- * pi-tickets extension) is plain Node-compatible TypeScript and talks to
6
- * this process only over the loopback HTTP RPC surface — see client.ts.
4
+ * vehicle-server). Everything else in this package talks to this process
5
+ * only over the loopback HTTP RPC surface see client.ts.
6
+ *
7
+ * serveMain() is also what `tickets serve` (cli/index.ts) runs directly --
8
+ * that's the command Armada's ServiceSpec launches.
7
9
  */
8
10
  import { runDaemonProcess } from "@danypops/vehicle-server/daemon";
9
11
  import { readPackageVersion } from "@danypops/vehicle-server/version";
10
12
  import { bootstrap } from "./bootstrap.js";
11
13
 
12
- const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
13
- const { options } = await bootstrap({ version });
14
+ export async function serveMain(): Promise<void> {
15
+ const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
16
+ const { options } = await bootstrap({ version });
14
17
 
15
- runDaemonProcess({
16
- ...options,
17
- onListen: (info) => {
18
- options.logger?.info("tickets daemon listening", { host: info.host, port: info.port, version });
19
- },
20
- });
18
+ runDaemonProcess({
19
+ ...options,
20
+ onListen: (info) => {
21
+ options.logger?.info("tickets daemon listening", { host: info.host, port: info.port, version });
22
+ },
23
+ });
24
+ }
25
+
26
+ if (import.meta.main) {
27
+ await serveMain();
28
+ }