@danypops/tickets 0.1.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.
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * The tickets CLI is a thin client of the tickets daemon: every command maps
4
+ * 1:1 to a daemon RPC op (see daemon/ops.ts and daemon/server.ts), the same
5
+ * ops the pi-tickets extension calls. Nothing here opens the daemon's SQLite
6
+ * ledger or a backend adapter directly.
7
+ */
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
+ import { openUrl } from "../auth/browser.js";
13
+ import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
14
+ import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
15
+ import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
16
+ import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
17
+
18
+ function printJson(value: unknown): void {
19
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
20
+ }
21
+
22
+ async function withClient<T>(fn: (client: TicketsRpcClient) => Promise<T>): Promise<void> {
23
+ try {
24
+ const client = await createTicketsClient();
25
+ printJson(await fn(client));
26
+ } catch (err) {
27
+ const message = err instanceof Error ? err.message : String(err);
28
+ process.stderr.write(`error: ${message}\n`);
29
+ process.exitCode = 1;
30
+ }
31
+ }
32
+
33
+ const program = new Command();
34
+ program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.1.0");
35
+
36
+ program
37
+ .command("list")
38
+ .description("list issues on a backend")
39
+ .requiredOption("-b, --backend <name>", "backend name")
40
+ .option("--status <status>", "filter by status")
41
+ .option("--assignee <user>", "filter by assignee")
42
+ .option("--label <label...>", "filter by label(s)")
43
+ .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
44
+ .action(async (opts) => {
45
+ const filter: ListFilter = {
46
+ status: opts.status ? parseStatus(opts.status) : undefined,
47
+ assignee: opts.assignee,
48
+ labels: opts.label,
49
+ limit: opts.limit,
50
+ };
51
+ await withClient((client) => client.call("issue.list", { backend: opts.backend, filter }));
52
+ });
53
+
54
+ program
55
+ .command("get <ref>")
56
+ .description('get one issue, e.g. "jira:PROJ-42" or "github:#7"')
57
+ .action(async (ref: string) => {
58
+ await withClient((client) => client.call("issue.get", { ref }));
59
+ });
60
+
61
+ program
62
+ .command("create <title>")
63
+ .description("create an issue")
64
+ .requiredOption("-b, --backend <name>", "backend name")
65
+ .option("--description <text>", "issue description")
66
+ .option("--priority <priority>", "priority: none|urgent|high|medium|low")
67
+ .option("--label <label...>", "label(s)")
68
+ .option("--assignee <user>", "assignee")
69
+ .option("--project <project>", "project key/id override")
70
+ .action(async (title: string, opts) => {
71
+ const input: CreateInput = {
72
+ title,
73
+ description: opts.description,
74
+ priority: opts.priority as Priority | undefined,
75
+ labels: opts.label,
76
+ assignee: opts.assignee,
77
+ project: opts.project,
78
+ };
79
+ await withClient((client) => client.call("issue.create", { backend: opts.backend, input }));
80
+ });
81
+
82
+ program
83
+ .command("update <ref>")
84
+ .description("update an issue")
85
+ .option("--title <text>", "new title")
86
+ .option("--description <text>", "new description")
87
+ .option("--status <status>", "new status")
88
+ .option("--priority <priority>", "new priority")
89
+ .option("--label <label...>", "replace labels")
90
+ .option("--assignee <user>", "reassign (empty string to unassign)")
91
+ .action(async (ref: string, opts) => {
92
+ const input: UpdateInput = {
93
+ title: opts.title,
94
+ description: opts.description,
95
+ status: opts.status ? parseStatus(opts.status) : (undefined as Status | undefined),
96
+ priority: opts.priority as Priority | undefined,
97
+ labels: opts.label,
98
+ assignee: opts.assignee,
99
+ };
100
+ await withClient((client) => client.call("issue.update", { ref, input }));
101
+ });
102
+
103
+ program
104
+ .command("search <query>")
105
+ .description("search issues on a backend")
106
+ .requiredOption("-b, --backend <name>", "backend name")
107
+ .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
108
+ .action(async (query: string, opts) => {
109
+ await withClient((client) => client.call("issue.search", { backend: opts.backend, query, limit: opts.limit }));
110
+ });
111
+
112
+ program
113
+ .command("children <ref>")
114
+ .description("list child issues (Jira sub-tasks/epics; empty on backends without native sub-issues)")
115
+ .action(async (ref: string) => {
116
+ await withClient((client) => client.call("issue.children", { ref }));
117
+ });
118
+
119
+ const comment = program.command("comment").description("comment operations");
120
+
121
+ comment
122
+ .command("list <ref>")
123
+ .description("list comments on an issue")
124
+ .action(async (ref: string) => {
125
+ await withClient((client) => client.call("issue.comments", { ref }));
126
+ });
127
+
128
+ comment
129
+ .command("add <ref> <body>")
130
+ .description("add a comment to an issue")
131
+ .action(async (ref: string, body: string) => {
132
+ await withClient((client) => client.call("issue.comment_add", { ref, body }));
133
+ });
134
+
135
+ const ledger = program.command("ledger").description("query the daemon's locally pooled issue cache");
136
+
137
+ ledger
138
+ .command("search <query>")
139
+ .description("search the local ledger (works even if the backend is currently unreachable)")
140
+ .option("--limit <n>", "max results", (v) => Number.parseInt(v, 10))
141
+ .action(async (query: string, opts) => {
142
+ await withClient((client) => client.call("ledger.search", { query, limit: opts.limit }));
143
+ });
144
+
145
+ ledger
146
+ .command("stats")
147
+ .description("issue counts per backend in the local ledger")
148
+ .action(async () => {
149
+ await withClient((client) => client.call("ledger.stats", {}));
150
+ });
151
+
152
+ program
153
+ .command("backends")
154
+ .description("list configured backend names")
155
+ .action(async () => {
156
+ await withClient((client) => client.call("backends.list", {}));
157
+ });
158
+
159
+ const daemon = program.command("daemon").description("manage the tickets daemon process");
160
+
161
+ daemon
162
+ .command("status")
163
+ .description("check whether the tickets daemon is reachable (never auto-starts it)")
164
+ .action(async () => {
165
+ try {
166
+ const client = await createTicketsClient({ autoStart: false });
167
+ printJson({ reachable: true, ...(await client.health()) });
168
+ } catch (err) {
169
+ printJson({ reachable: false, error: err instanceof Error ? err.message : String(err) });
170
+ }
171
+ });
172
+
173
+ daemon
174
+ .command("start")
175
+ .description("start the daemon if it isn't already running")
176
+ .action(async () => {
177
+ try {
178
+ const client = await createTicketsClient({ autoStart: true });
179
+ printJson({ status: "running", ...(await client.health()) });
180
+ } catch (err) {
181
+ const message = err instanceof Error ? err.message : String(err);
182
+ process.stderr.write(`error: ${message}\n`);
183
+ process.exitCode = 1;
184
+ }
185
+ });
186
+
187
+ daemon
188
+ .command("stop")
189
+ .description("ask the running daemon to shut down gracefully")
190
+ .action(async () => {
191
+ try {
192
+ const client = await createTicketsClient({ autoStart: false });
193
+ await client.call("daemon.shutdown", {});
194
+ printJson({ status: "stopping" });
195
+ } catch (err) {
196
+ printJson({ status: "not_running", detail: err instanceof Error ? err.message : String(err) });
197
+ }
198
+ });
199
+
200
+ daemon
201
+ .command("restart")
202
+ .description("stop the daemon (if running) and start a fresh one")
203
+ .action(async () => {
204
+ try {
205
+ const client = await createTicketsClient({ autoStart: false });
206
+ await client.call("daemon.shutdown", {});
207
+ } catch {
208
+ // not running — nothing to stop, proceed straight to starting a fresh one.
209
+ }
210
+ await new Promise((resolve) => setTimeout(resolve, 300));
211
+ try {
212
+ const client = await createTicketsClient({ autoStart: true });
213
+ printJson({ status: "restarted", ...(await client.health()) });
214
+ } catch (err) {
215
+ const message = err instanceof Error ? err.message : String(err);
216
+ process.stderr.write(`error: ${message}\n`);
217
+ process.exitCode = 1;
218
+ }
219
+ });
220
+
221
+ const auth = program.command("auth").description("delegated OAuth login (device flow for GitHub/GitLab, authorization code for Jira)");
222
+
223
+ auth
224
+ .command("login")
225
+ .description("authorize tickets against a backend and store the resulting token locally (0600, never printed)")
226
+ .requiredOption("-b, --backend <name>", "github | gitlab | jira (or a custom multi-instance name, paired with --type)")
227
+ .option("--type <type>", "adapter type, when --backend is a custom multi-instance name")
228
+ .option("--client-id <id>", "OAuth client/application ID (falls back to <TYPE>_OAUTH_CLIENT_ID)")
229
+ .option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
230
+ .option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
231
+ .option("--scope <scope>", "space-delimited OAuth scope override")
232
+ .action(async (opts) => {
233
+ const type = opts.type ?? opts.backend;
234
+ try {
235
+ if (type === "github") {
236
+ const clientId = opts.clientId ?? process.env.GITHUB_OAUTH_CLIENT_ID;
237
+ if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required");
238
+ const token = await loginWithGitHubDeviceFlow({
239
+ clientId,
240
+ scope: opts.scope,
241
+ onPrompt: async (prompt) => {
242
+ process.stderr.write(`Open ${prompt.verificationUri} and enter code: ${prompt.userCode}\n`);
243
+ try {
244
+ openUrl(prompt.verificationUriComplete ?? prompt.verificationUri);
245
+ } catch {
246
+ // headless environment — the printed URL/code above is still enough to proceed manually.
247
+ }
248
+ },
249
+ });
250
+ saveToken(opts.backend, { accessToken: token.accessToken, refreshToken: token.refreshToken, expiresAt: token.expiresAt, scope: token.scope });
251
+ } else if (type === "gitlab") {
252
+ const clientId = opts.clientId ?? process.env.GITLAB_OAUTH_CLIENT_ID;
253
+ if (!clientId) throw new Error("--client-id or GITLAB_OAUTH_CLIENT_ID is required");
254
+ const baseUrl = opts.url ?? process.env.GITLAB_URL;
255
+ const token = await loginWithGitLabDeviceFlow({
256
+ clientId,
257
+ baseUrl,
258
+ scope: opts.scope,
259
+ onPrompt: async (prompt) => {
260
+ process.stderr.write(`Open ${prompt.verificationUri} and enter code: ${prompt.userCode}\n`);
261
+ try {
262
+ openUrl(prompt.verificationUriComplete ?? prompt.verificationUri);
263
+ } catch {
264
+ // headless environment — printed instructions above are enough.
265
+ }
266
+ },
267
+ });
268
+ saveToken(opts.backend, { accessToken: token.accessToken, refreshToken: token.refreshToken, expiresAt: token.expiresAt, scope: token.scope });
269
+ } else if (type === "jira") {
270
+ const clientId = opts.clientId ?? process.env.JIRA_OAUTH_CLIENT_ID;
271
+ const clientSecret = opts.clientSecret ?? process.env.JIRA_OAUTH_CLIENT_SECRET;
272
+ if (!clientId || !clientSecret) {
273
+ 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)");
274
+ }
275
+ const token = await loginWithJiraAuthorizationCode({
276
+ clientId,
277
+ clientSecret,
278
+ scope: opts.scope,
279
+ onPrompt: async (authorizeUrl) => {
280
+ process.stderr.write(`Open this URL to authorize tickets against your Atlassian site:\n${authorizeUrl}\n`);
281
+ try {
282
+ openUrl(authorizeUrl);
283
+ } catch {
284
+ // headless environment — the printed URL above is enough to proceed manually.
285
+ }
286
+ },
287
+ });
288
+ saveToken(opts.backend, {
289
+ accessToken: token.accessToken,
290
+ refreshToken: token.refreshToken,
291
+ expiresAt: token.expiresAt,
292
+ extra: { cloudId: token.cloudId, siteUrl: token.siteUrl },
293
+ });
294
+ } else {
295
+ throw new Error(`auth login: unsupported type "${type}" (expected github, gitlab, or jira)`);
296
+ }
297
+ printJson({
298
+ backend: opts.backend,
299
+ status: "authorized",
300
+ note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token",
301
+ });
302
+ } catch (err) {
303
+ const message = err instanceof Error ? err.message : String(err);
304
+ process.stderr.write(`error: ${message}\n`);
305
+ process.exitCode = 1;
306
+ }
307
+ });
308
+
309
+ auth
310
+ .command("status")
311
+ .description("list backends with a locally stored delegated token")
312
+ .action(() => {
313
+ const backends = listStoredBackends();
314
+ printJson(
315
+ backends.map((backend) => {
316
+ const token = loadToken(backend);
317
+ return {
318
+ backend,
319
+ fresh: token ? isTokenFresh(token) : false,
320
+ expiresAt: token?.expiresAt ?? null,
321
+ scope: token?.scope ?? null,
322
+ };
323
+ }),
324
+ );
325
+ });
326
+
327
+ auth
328
+ .command("logout <backend>")
329
+ .description("remove a backend's locally stored delegated token (falls back to config/env PAT)")
330
+ .action((backend: string) => {
331
+ deleteToken(backend);
332
+ printJson({ backend, status: "logged_out" });
333
+ });
334
+
335
+ program.parseAsync(process.argv).catch(() => {
336
+ process.exitCode = 1;
337
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Node/Bun-portable client for the tickets daemon. Neither the CLI nor the
3
+ * pi-tickets extension opens the daemon's SQLite ledger directly or talks to
4
+ * GitHub/GitLab/Jira itself — both go through this authenticated RPC client,
5
+ * spawning the (Bun-only) daemon on first use if it isn't already running.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import type { DaemonHandle } from "@danypops/daemon-kit/paths";
11
+ import { ensureAuthToken, readDaemonHandle, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
12
+ import { AuthenticatedRpcClient } from "@danypops/daemon-kit/rpc-client";
13
+ import { packageRoot } from "../util/package-root.js";
14
+ import { TICKETS_DAEMON_NAMES, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "../daemon/ops.js";
15
+
16
+ export function ticketsPaths(env?: Record<string, string | undefined>) {
17
+ return resolveDaemonPaths(TICKETS_DAEMON_NAMES, env ? { env } : {});
18
+ }
19
+
20
+ async function isAlive(handle: DaemonHandle, token: string): Promise<boolean> {
21
+ try {
22
+ const res = await fetch(`http://${handle.host}:${handle.port}/health`, {
23
+ headers: { authorization: `Bearer ${token}` },
24
+ signal: AbortSignal.timeout(500),
25
+ });
26
+ return res.ok;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function spawnDaemon(): void {
33
+ const root = packageRoot(dirname(fileURLToPath(import.meta.url)));
34
+ const entry = join(root, "src", "daemon", "main.ts");
35
+ const child = spawn("bun", ["run", entry], { detached: true, stdio: "ignore" });
36
+ child.unref();
37
+ }
38
+
39
+ async function waitForHandle(handlePath: string, timeoutMs: number): Promise<DaemonHandle | null> {
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ const handle = readDaemonHandle(handlePath);
43
+ if (handle) return handle;
44
+ await new Promise((resolve) => setTimeout(resolve, 100));
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export interface EnsureDaemonOptions {
50
+ /** false: fail immediately with a clear message instead of spawning the daemon. */
51
+ autoStart?: boolean;
52
+ timeoutMs?: number;
53
+ }
54
+
55
+ const DEFAULT_SPAWN_TIMEOUT_MS = 4_000;
56
+
57
+ export async function ensureDaemonRunning(
58
+ opts: EnsureDaemonOptions = {},
59
+ ): Promise<{ baseUrl: string; token: string }> {
60
+ const paths = ticketsPaths();
61
+ const token = ensureAuthToken(paths.token, "Tickets");
62
+
63
+ const existing = readDaemonHandle(paths.handle);
64
+ if (existing && (await isAlive(existing, token))) {
65
+ return { baseUrl: `http://${existing.host}:${existing.port}`, token };
66
+ }
67
+
68
+ if (opts.autoStart === false) {
69
+ throw new Error(
70
+ "tickets daemon is not running. Start it with `npm run daemon` (or `bun run src/daemon/main.ts`).",
71
+ );
72
+ }
73
+
74
+ spawnDaemon();
75
+ const handle = await waitForHandle(paths.handle, opts.timeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS);
76
+ if (!handle || !(await isAlive(handle, token))) {
77
+ throw new Error("tickets daemon did not become ready within the timeout");
78
+ }
79
+ return { baseUrl: `http://${handle.host}:${handle.port}`, token };
80
+ }
81
+
82
+ export type TicketsRpcClient = AuthenticatedRpcClient<TicketOperation, TicketOpInputs, TicketOpOutputs>;
83
+
84
+ export async function createTicketsClient(opts: EnsureDaemonOptions = {}): Promise<TicketsRpcClient> {
85
+ const { baseUrl, token } = await ensureDaemonRunning(opts);
86
+ return new AuthenticatedRpcClient<TicketOperation, TicketOpInputs, TicketOpOutputs>(baseUrl, token, {
87
+ label: "Tickets",
88
+ });
89
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Configuration loading — mirrors the env-var-first, config-file-override pattern:
3
+ * $XDG_CONFIG_HOME/tickets/config.yaml (default ~/.config/tickets/config.yaml),
4
+ * falling back to well-known env vars per backend when no config file is present.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { GitHubRepository } from "../adapters/github.js";
11
+ import { GitLabRepository } from "../adapters/gitlab.js";
12
+ import { JiraRepository } from "../adapters/jira.js";
13
+ import type { IssueRepository } from "../ports/repository.js";
14
+ import { isTokenFresh, loadToken } from "../auth/token-store.js";
15
+
16
+ export interface BackendConfig {
17
+ /** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
18
+ type?: string;
19
+ token?: string;
20
+ tokenEnv?: string;
21
+ url?: string;
22
+ email?: string;
23
+ owner?: string;
24
+ project?: string;
25
+ repo?: string;
26
+ }
27
+
28
+ export interface Config {
29
+ backends: Record<string, BackendConfig>;
30
+ }
31
+
32
+ const APP_NAME = "tickets";
33
+
34
+ export function configDir(): string {
35
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
36
+ return join(base, APP_NAME);
37
+ }
38
+
39
+ export function defaultConfigPath(): string {
40
+ return join(configDir(), "config.yaml");
41
+ }
42
+
43
+ export function loadConfig(path?: string): Config {
44
+ const target = path ?? defaultConfigPath();
45
+ if (!existsSync(target)) return { backends: {} };
46
+ const data = readFileSync(target, "utf8");
47
+ const parsed = parseYaml(data) as Partial<Config> | undefined;
48
+ return { backends: parsed?.backends ?? {} };
49
+ }
50
+
51
+ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: string): string | undefined {
52
+ if (cfg.token) return cfg.token;
53
+ if (cfg.tokenEnv) return env[cfg.tokenEnv];
54
+ return env[envFallback];
55
+ }
56
+
57
+ /**
58
+ * Prefers a locally stored, still-fresh delegated OAuth token (see auth/,
59
+ * populated by `tickets auth login`) over a static config/env PAT, per the
60
+ * general "delegated auth over static token" precedence this project follows
61
+ * for GitHub, GitLab, and Jira — see RESEARCH.md for the auth flows each
62
+ * backend actually supports (device flow for GitHub/GitLab, authorization
63
+ * code for Jira, which has no device flow or PKCE).
64
+ */
65
+ function preferredAuth(
66
+ name: string,
67
+ cfg: BackendConfig,
68
+ env: NodeJS.ProcessEnv,
69
+ envFallback: string,
70
+ ): { token: string | undefined; oauth: boolean; extra?: Record<string, string> } {
71
+ const stored = loadToken(name, { env });
72
+ if (stored && isTokenFresh(stored)) {
73
+ return { token: stored.accessToken, oauth: true, extra: stored.extra };
74
+ }
75
+ return { token: resolveToken(cfg, env, envFallback), oauth: false };
76
+ }
77
+
78
+ /**
79
+ * Builds one repository per configured backend, plus any of github/gitlab/jira
80
+ * inferrable purely from environment variables when not present in the config file.
81
+ * Config-file entries take precedence over bare env-var inference for the same name.
82
+ */
83
+ export function buildRepositories(
84
+ config: Config,
85
+ env: NodeJS.ProcessEnv = process.env,
86
+ ): Record<string, IssueRepository> {
87
+ const repos: Record<string, IssueRepository> = {};
88
+
89
+ for (const [name, cfg] of Object.entries(config.backends)) {
90
+ const type = cfg.type ?? name;
91
+ const repo = createRepository(name, type, cfg, env);
92
+ if (repo) repos[name] = repo;
93
+ }
94
+
95
+ if (!repos.github && (env.GITHUB_OWNER || env.GITHUB_TOKEN)) {
96
+ const repo = createRepository("github", "github", {}, env);
97
+ if (repo) repos.github = repo;
98
+ }
99
+ if (!repos.gitlab && (env.GITLAB_PROJECT || env.GITLAB_TOKEN)) {
100
+ const repo = createRepository("gitlab", "gitlab", {}, env);
101
+ if (repo) repos.gitlab = repo;
102
+ }
103
+ if (!repos.jira && env.JIRA_URL) {
104
+ const repo = createRepository("jira", "jira", {}, env);
105
+ if (repo) repos.jira = repo;
106
+ }
107
+
108
+ return repos;
109
+ }
110
+
111
+ function createRepository(
112
+ name: string,
113
+ type: string,
114
+ cfg: BackendConfig,
115
+ env: NodeJS.ProcessEnv,
116
+ ): IssueRepository | undefined {
117
+ switch (type) {
118
+ case "github": {
119
+ const owner = cfg.owner ?? env.GITHUB_OWNER;
120
+ if (!owner) return undefined;
121
+ const auth = preferredAuth(name, cfg, env, "GITHUB_TOKEN");
122
+ return new GitHubRepository(name, {
123
+ owner,
124
+ repo: cfg.repo ?? env.GITHUB_REPO,
125
+ token: auth.token,
126
+ baseUrl: cfg.url,
127
+ });
128
+ }
129
+ case "gitlab": {
130
+ const project = cfg.project ?? env.GITLAB_PROJECT;
131
+ if (!project) return undefined;
132
+ const auth = preferredAuth(name, cfg, env, "GITLAB_TOKEN");
133
+ return new GitLabRepository(name, {
134
+ projectId: project,
135
+ token: auth.token,
136
+ tokenType: auth.oauth ? "oauth" : "private",
137
+ baseUrl: cfg.url ?? env.GITLAB_URL,
138
+ });
139
+ }
140
+ case "jira": {
141
+ const auth = preferredAuth(name, cfg, env, "JIRA_API_TOKEN");
142
+ if (auth.oauth && auth.token && auth.extra?.cloudId) {
143
+ return new JiraRepository(name, {
144
+ accessToken: auth.token,
145
+ cloudId: auth.extra.cloudId,
146
+ project: cfg.project ?? env.JIRA_PROJECT,
147
+ });
148
+ }
149
+ const baseUrl = cfg.url ?? env.JIRA_URL;
150
+ const email = cfg.email ?? env.JIRA_EMAIL;
151
+ if (!baseUrl || !email || !auth.token) return undefined;
152
+ return new JiraRepository(name, {
153
+ baseUrl,
154
+ email,
155
+ token: auth.token,
156
+ project: cfg.project ?? env.JIRA_PROJECT,
157
+ });
158
+ }
159
+ default:
160
+ return undefined;
161
+ }
162
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Composition root for the tickets daemon: wires paths, auth token, ledger
3
+ * storage, real backend repositories, and the sync poller into the options
4
+ * daemon-kit's startDaemon()/runDaemonProcess() expect. Everything here is
5
+ * injectable so tests can substitute fake repositories and a scratch XDG
6
+ * root instead of hitting real GitHub/GitLab/Jira or the real home directory.
7
+ */
8
+ import type { Database } from "bun:sqlite";
9
+ import { createLogger, type Logger } from "@danypops/daemon-kit/logging";
10
+ import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
11
+ import { checkpoint, openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
12
+ import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
13
+ import { TicketService } from "../application/service.js";
14
+ import { buildRepositories, type Config, loadConfig } from "../config/config.js";
15
+ import type { IssueRepository } from "../ports/repository.js";
16
+ import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
17
+ import { TICKETS_DAEMON_NAMES } from "./ops.js";
18
+ import { buildApp } from "./server.js";
19
+ import { createSyncTask } from "./poller.js";
20
+
21
+ export interface BootstrapOptions {
22
+ pathEnv?: PathEnvironment;
23
+ config?: Config;
24
+ /** Injected directly in tests instead of building from config/env. */
25
+ repos?: Record<string, IssueRepository>;
26
+ version?: string;
27
+ logger?: Logger;
28
+ syncIntervalMs?: number;
29
+ checkpointIntervalMs?: number;
30
+ /**
31
+ * Overrides the daemon.shutdown op's effect. Defaults to sending this
32
+ * process SIGTERM, which daemon-kit's runDaemonProcess already handles
33
+ * with a tested graceful stop (see main.ts). Tests override this instead
34
+ * of self-signaling the test runner's own process.
35
+ */
36
+ onShutdownRequested?: () => void;
37
+ }
38
+
39
+ export interface BootstrappedDaemon {
40
+ db: Database;
41
+ ledger: Ledger;
42
+ service: TicketService;
43
+ options: StartDaemonOptions;
44
+ }
45
+
46
+ const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
47
+ const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
48
+
49
+ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
50
+ const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
51
+ const token = ensureAuthToken(paths.token, "Tickets");
52
+ const db = openSqliteWithPragmas(paths.database, { migrations: LEDGER_MIGRATIONS });
53
+ const ledger = new Ledger(db);
54
+ const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
55
+ const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
56
+ const service = new TicketService(repos);
57
+ const version = opts.version ?? "0.0.0-dev";
58
+
59
+ const options: StartDaemonOptions = {
60
+ daemonLabel: "Tickets",
61
+ handlePath: paths.handle,
62
+ logger,
63
+ maintenanceTasks: [
64
+ createSyncTask(service, ledger, Object.keys(repos), opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
65
+ {
66
+ name: "checkpoint",
67
+ intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
68
+ run: () => checkpoint(db),
69
+ },
70
+ ],
71
+ buildApp: () =>
72
+ buildApp({
73
+ service,
74
+ ledger,
75
+ token,
76
+ version,
77
+ logger,
78
+ onShutdownRequested: opts.onShutdownRequested ?? (() => process.kill(process.pid, "SIGTERM")),
79
+ }),
80
+ onShutdown: () => {
81
+ db.close();
82
+ },
83
+ };
84
+
85
+ return { db, ledger, service, options };
86
+ }