@danypops/tickets 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
14
14
  import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
15
15
  import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
16
16
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
17
+ import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
17
18
 
18
19
  function printJson(value: unknown): void {
19
20
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
@@ -31,7 +32,7 @@ async function withClient<T>(fn: (client: TicketsRpcClient) => Promise<T>): Prom
31
32
  }
32
33
 
33
34
  const program = new Command();
34
- program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.1.0");
35
+ program.name("tickets").description("Unified issue tracking CLI (GitHub, GitLab, Jira)").version("0.2.0");
35
36
 
36
37
  program
37
38
  .command("list")
@@ -156,6 +157,43 @@ program
156
157
  await withClient((client) => client.call("backends.list", {}));
157
158
  });
158
159
 
160
+ const focus = program.command("focus").description("track the single ticket you're currently working on, with its full URL");
161
+
162
+ focus
163
+ .command("set <ref>")
164
+ .description('focus a ticket, e.g. "jira:PROJ-42" or "github:#7" (resolves and stores its real web URL)')
165
+ .action(async (ref: string) => {
166
+ await withClient((client) => client.call("focus.set", { ref }));
167
+ });
168
+
169
+ focus
170
+ .command("get")
171
+ .description("show the currently focused ticket, if any")
172
+ .action(async () => {
173
+ await withClient((client) => client.call("focus.get", {}));
174
+ });
175
+
176
+ focus
177
+ .command("pause [reason]")
178
+ .description("pause the current focus without losing it (e.g. stepping away to do something else)")
179
+ .action(async (reason: string | undefined) => {
180
+ await withClient((client) => client.call("focus.pause", { reason }));
181
+ });
182
+
183
+ focus
184
+ .command("unpause")
185
+ .description("resume a paused focus")
186
+ .action(async () => {
187
+ await withClient((client) => client.call("focus.unpause", {}));
188
+ });
189
+
190
+ focus
191
+ .command("clear")
192
+ .description("clear the current focus")
193
+ .action(async () => {
194
+ await withClient((client) => client.call("focus.clear", {}));
195
+ });
196
+
159
197
  const daemon = program.command("daemon").description("manage the tickets daemon process");
160
198
 
161
199
  daemon
@@ -218,6 +256,44 @@ daemon
218
256
  }
219
257
  });
220
258
 
259
+ const service = program
260
+ .command("service")
261
+ .description("deploy the tickets daemon as a persistent systemd --user service (Linux; survives logout/reboot, unlike `daemon start`'s on-demand spawn)");
262
+
263
+ service
264
+ .command("install")
265
+ .description("write, enable, and (re)start a tickets-daemon.service systemd --user unit pointed at this install")
266
+ .action(() => {
267
+ try {
268
+ const { unitPath } = installTicketsService();
269
+ printJson({ status: "installed", unitPath });
270
+ } catch (err) {
271
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
272
+ process.exitCode = 1;
273
+ }
274
+ });
275
+
276
+ for (const action of ["start", "stop", "restart", "status"] as const) {
277
+ service
278
+ .command(action)
279
+ .description(`systemctl --user ${action} tickets-daemon.service`)
280
+ .action(() => {
281
+ try {
282
+ systemctlTickets(action);
283
+ } catch (err) {
284
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}\n`);
285
+ process.exitCode = 1;
286
+ }
287
+ });
288
+ }
289
+
290
+ service
291
+ .command("path")
292
+ .description("print where the systemd unit file would be written")
293
+ .action(() => {
294
+ printJson({ unitPath: systemdUnitPath() });
295
+ });
296
+
221
297
  const auth = program.command("auth").description("delegated OAuth login (device flow for GitHub/GitLab, authorization code for Jira)");
222
298
 
223
299
  auth
@@ -0,0 +1,97 @@
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.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { resolveDaemonEntryPath } from "../client/tickets-client.js";
16
+ import { TICKETS_DAEMON_NAMES } from "../daemon/ops.js";
17
+
18
+ export interface SystemdUnitOptions {
19
+ bunBin: string;
20
+ daemonMainPath: string;
21
+ }
22
+
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;
71
+ }
72
+
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 };
97
+ }
@@ -29,10 +29,14 @@ async function isAlive(handle: DaemonHandle, token: string): Promise<boolean> {
29
29
  }
30
30
  }
31
31
 
32
- function spawnDaemon(): void {
32
+ /** Absolute path to the daemon's real entry point, resolved from this package's own root. Used both to spawn it on demand and to point a systemd unit's ExecStart at it (see cli/systemd-service.ts). */
33
+ export function resolveDaemonEntryPath(): string {
33
34
  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" });
35
+ return join(root, "src", "daemon", "main.ts");
36
+ }
37
+
38
+ function spawnDaemon(): void {
39
+ const child = spawn("bun", ["run", resolveDaemonEntryPath()], { detached: true, stdio: "ignore" });
36
40
  child.unref();
37
41
  }
38
42
 
@@ -12,6 +12,7 @@ import { GitLabRepository } from "../adapters/gitlab.js";
12
12
  import { JiraRepository } from "../adapters/jira.js";
13
13
  import type { IssueRepository } from "../ports/repository.js";
14
14
  import { isTokenFresh, loadToken } from "../auth/token-store.js";
15
+ import { type TryEnigmaCredential, tryEnigmaCredential } from "../auth/enigma-source.js";
15
16
 
16
17
  export interface BackendConfig {
17
18
  /** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
@@ -55,19 +56,27 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
55
56
  }
56
57
 
57
58
  /**
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).
59
+ * Resolution order, highest priority first: (1) a running Enigma vault, if
60
+ * one happens to be configured for this backend entirely optional, never a
61
+ * hard dependency, and bounded so Tickets never waits long for it (see
62
+ * auth/enigma-source.ts); (2) a locally stored, still-fresh delegated OAuth
63
+ * token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
64
+ * static config/env PAT. (1) is additive to the pre-Enigma precedence this
65
+ * project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
66
+ * for the auth flows each backend actually supports (device flow for
67
+ * GitHub/GitLab, authorization code for Jira, which has no device flow or
68
+ * PKCE).
64
69
  */
65
- function preferredAuth(
70
+ export async function preferredAuth(
66
71
  name: string,
67
72
  cfg: BackendConfig,
68
73
  env: NodeJS.ProcessEnv,
69
74
  envFallback: string,
70
- ): { token: string | undefined; oauth: boolean; extra?: Record<string, string> } {
75
+ tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
76
+ ): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
77
+ const fromEnigma = await tryEnigma(name, { env });
78
+ if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
79
+
71
80
  const stored = loadToken(name, { env });
72
81
  if (stored && isTokenFresh(stored)) {
73
82
  return { token: stored.accessToken, oauth: true, extra: stored.extra };
@@ -80,45 +89,47 @@ function preferredAuth(
80
89
  * inferrable purely from environment variables when not present in the config file.
81
90
  * Config-file entries take precedence over bare env-var inference for the same name.
82
91
  */
83
- export function buildRepositories(
92
+ export async function buildRepositories(
84
93
  config: Config,
85
94
  env: NodeJS.ProcessEnv = process.env,
86
- ): Record<string, IssueRepository> {
95
+ tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
96
+ ): Promise<Record<string, IssueRepository>> {
87
97
  const repos: Record<string, IssueRepository> = {};
88
98
 
89
99
  for (const [name, cfg] of Object.entries(config.backends)) {
90
100
  const type = cfg.type ?? name;
91
- const repo = createRepository(name, type, cfg, env);
101
+ const repo = await createRepository(name, type, cfg, env, tryEnigma);
92
102
  if (repo) repos[name] = repo;
93
103
  }
94
104
 
95
105
  if (!repos.github && (env.GITHUB_OWNER || env.GITHUB_TOKEN)) {
96
- const repo = createRepository("github", "github", {}, env);
106
+ const repo = await createRepository("github", "github", {}, env, tryEnigma);
97
107
  if (repo) repos.github = repo;
98
108
  }
99
109
  if (!repos.gitlab && (env.GITLAB_PROJECT || env.GITLAB_TOKEN)) {
100
- const repo = createRepository("gitlab", "gitlab", {}, env);
110
+ const repo = await createRepository("gitlab", "gitlab", {}, env, tryEnigma);
101
111
  if (repo) repos.gitlab = repo;
102
112
  }
103
113
  if (!repos.jira && env.JIRA_URL) {
104
- const repo = createRepository("jira", "jira", {}, env);
114
+ const repo = await createRepository("jira", "jira", {}, env, tryEnigma);
105
115
  if (repo) repos.jira = repo;
106
116
  }
107
117
 
108
118
  return repos;
109
119
  }
110
120
 
111
- function createRepository(
121
+ async function createRepository(
112
122
  name: string,
113
123
  type: string,
114
124
  cfg: BackendConfig,
115
125
  env: NodeJS.ProcessEnv,
116
- ): IssueRepository | undefined {
126
+ tryEnigma: TryEnigmaCredential,
127
+ ): Promise<IssueRepository | undefined> {
117
128
  switch (type) {
118
129
  case "github": {
119
130
  const owner = cfg.owner ?? env.GITHUB_OWNER;
120
131
  if (!owner) return undefined;
121
- const auth = preferredAuth(name, cfg, env, "GITHUB_TOKEN");
132
+ const auth = await preferredAuth(name, cfg, env, "GITHUB_TOKEN", tryEnigma);
122
133
  return new GitHubRepository(name, {
123
134
  owner,
124
135
  repo: cfg.repo ?? env.GITHUB_REPO,
@@ -129,7 +140,7 @@ function createRepository(
129
140
  case "gitlab": {
130
141
  const project = cfg.project ?? env.GITLAB_PROJECT;
131
142
  if (!project) return undefined;
132
- const auth = preferredAuth(name, cfg, env, "GITLAB_TOKEN");
143
+ const auth = await preferredAuth(name, cfg, env, "GITLAB_TOKEN", tryEnigma);
133
144
  return new GitLabRepository(name, {
134
145
  projectId: project,
135
146
  token: auth.token,
@@ -138,7 +149,7 @@ function createRepository(
138
149
  });
139
150
  }
140
151
  case "jira": {
141
- const auth = preferredAuth(name, cfg, env, "JIRA_API_TOKEN");
152
+ const auth = await preferredAuth(name, cfg, env, "JIRA_API_TOKEN", tryEnigma);
142
153
  if (auth.oauth && auth.token && auth.extra?.cloudId) {
143
154
  return new JiraRepository(name, {
144
155
  accessToken: auth.token,
@@ -13,6 +13,7 @@ import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
13
13
  import { TicketService } from "../application/service.js";
14
14
  import { buildRepositories, type Config, loadConfig } from "../config/config.js";
15
15
  import type { IssueRepository } from "../ports/repository.js";
16
+ import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
16
17
  import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
17
18
  import { TICKETS_DAEMON_NAMES } from "./ops.js";
18
19
  import { buildApp } from "./server.js";
@@ -39,6 +40,7 @@ export interface BootstrapOptions {
39
40
  export interface BootstrappedDaemon {
40
41
  db: Database;
41
42
  ledger: Ledger;
43
+ focusStore: FocusStore;
42
44
  service: TicketService;
43
45
  options: StartDaemonOptions;
44
46
  }
@@ -46,13 +48,14 @@ export interface BootstrappedDaemon {
46
48
  const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
47
49
  const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
48
50
 
49
- export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
51
+ export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
50
52
  const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
51
53
  const token = ensureAuthToken(paths.token, "Tickets");
52
- const db = openSqliteWithPragmas(paths.database, { migrations: LEDGER_MIGRATIONS });
54
+ const db = openSqliteWithPragmas(paths.database, { migrations: [...LEDGER_MIGRATIONS, ...FOCUS_MIGRATIONS] });
53
55
  const ledger = new Ledger(db);
56
+ const focusStore = new FocusStore(db);
54
57
  const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
55
- const repos = opts.repos ?? buildRepositories(opts.config ?? loadConfig());
58
+ const repos = opts.repos ?? (await buildRepositories(opts.config ?? loadConfig()));
56
59
  const service = new TicketService(repos);
57
60
  const version = opts.version ?? "0.0.0-dev";
58
61
 
@@ -72,6 +75,7 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
72
75
  buildApp({
73
76
  service,
74
77
  ledger,
78
+ focusStore,
75
79
  token,
76
80
  version,
77
81
  logger,
@@ -82,5 +86,5 @@ export function bootstrap(opts: BootstrapOptions = {}): BootstrappedDaemon {
82
86
  },
83
87
  };
84
88
 
85
- return { db, ledger, service, options };
89
+ return { db, ledger, focusStore, service, options };
86
90
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Focus — the single ticket currently being worked on, independent of any
3
+ * one CLI invocation or tool call. Unlike the Ledger (a cache of every issue
4
+ * the daemon has ever seen), Focus is a pointer: one ref, its resolved title
5
+ * and full web URL, and whether work on it is active or paused. Persisted
6
+ * so it survives daemon restarts. A singleton by design — there is never
7
+ * more than one ticket in focus at a time, so setting focus always replaces
8
+ * whatever was there, the same way switching your attention to a different
9
+ * ticket in real life replaces what you were just looking at.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import type { Migration } from "@danypops/daemon-kit/storage";
13
+
14
+ export const FOCUS_MIGRATIONS: Migration[] = [
15
+ {
16
+ version: 2,
17
+ up: (db) => {
18
+ db.exec(`
19
+ CREATE TABLE ticket_focus (
20
+ id INTEGER PRIMARY KEY CHECK (id = 1),
21
+ ref TEXT NOT NULL,
22
+ title TEXT NOT NULL,
23
+ url TEXT NOT NULL,
24
+ status TEXT NOT NULL,
25
+ pause_reason TEXT,
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ `);
29
+ },
30
+ },
31
+ ];
32
+
33
+ export type FocusStatus = "active" | "paused";
34
+
35
+ export interface TicketFocusState {
36
+ ref: string;
37
+ title: string;
38
+ url: string;
39
+ status: FocusStatus;
40
+ updatedAt: string;
41
+ pauseReason?: string;
42
+ }
43
+
44
+ /** Invalid focus state transitions (nothing focused, double-pause, double-unpause) or a resolved issue with no URL to focus on. Maps to HTTP 400, not 500. */
45
+ export class FocusError extends Error {
46
+ constructor(message: string) {
47
+ super(message);
48
+ this.name = "FocusError";
49
+ }
50
+ }
51
+
52
+ interface FocusRow {
53
+ ref: string;
54
+ title: string;
55
+ url: string;
56
+ status: FocusStatus;
57
+ pause_reason: string | null;
58
+ updated_at: string;
59
+ }
60
+
61
+ function rowToState(row: FocusRow): TicketFocusState {
62
+ return {
63
+ ref: row.ref,
64
+ title: row.title,
65
+ url: row.url,
66
+ status: row.status,
67
+ updatedAt: row.updated_at,
68
+ ...(row.pause_reason ? { pauseReason: row.pause_reason } : {}),
69
+ };
70
+ }
71
+
72
+ export class FocusStore {
73
+ constructor(private readonly db: Database) {}
74
+
75
+ get(): TicketFocusState | undefined {
76
+ const row = this.db
77
+ .query("SELECT ref, title, url, status, pause_reason, updated_at FROM ticket_focus WHERE id = 1")
78
+ .get() as FocusRow | null;
79
+ return row ? rowToState(row) : undefined;
80
+ }
81
+
82
+ /** Always lands "active" and drops any prior pause reason: switching focus onto a different ticket is not the same as resuming a pause on the old one. */
83
+ set(ref: string, title: string, url: string): TicketFocusState {
84
+ const updatedAt = new Date().toISOString();
85
+ this.db
86
+ .query(
87
+ `INSERT INTO ticket_focus (id, ref, title, url, status, pause_reason, updated_at)
88
+ VALUES (1, $ref, $title, $url, 'active', NULL, $updatedAt)
89
+ ON CONFLICT(id) DO UPDATE SET
90
+ ref = excluded.ref, title = excluded.title, url = excluded.url,
91
+ status = 'active', pause_reason = NULL, updated_at = excluded.updated_at`,
92
+ )
93
+ .run({ $ref: ref, $title: title, $url: url, $updatedAt: updatedAt });
94
+ return { ref, title, url, status: "active", updatedAt };
95
+ }
96
+
97
+ pause(reason?: string): TicketFocusState {
98
+ const current = this.get();
99
+ if (!current) throw new FocusError("no ticket is currently focused");
100
+ if (current.status === "paused") throw new FocusError(`focus on "${current.ref}" is already paused`);
101
+ return this.transition("paused", reason);
102
+ }
103
+
104
+ unpause(): TicketFocusState {
105
+ const current = this.get();
106
+ if (!current) throw new FocusError("no ticket is currently focused");
107
+ if (current.status === "active") throw new FocusError(`focus on "${current.ref}" is already active`);
108
+ return this.transition("active", undefined);
109
+ }
110
+
111
+ /** Returns whether a focus existed to clear (idempotent either way). */
112
+ clear(): boolean {
113
+ const existed = this.get() !== undefined;
114
+ this.db.exec("DELETE FROM ticket_focus WHERE id = 1");
115
+ return existed;
116
+ }
117
+
118
+ private transition(status: FocusStatus, reason: string | undefined): TicketFocusState {
119
+ const updatedAt = new Date().toISOString();
120
+ this.db
121
+ .query("UPDATE ticket_focus SET status = $status, pause_reason = $reason, updated_at = $updatedAt WHERE id = 1")
122
+ .run({ $status: status, $reason: reason ?? null, $updatedAt: updatedAt });
123
+ // Non-null: transition() is only ever called right after get() confirmed a row exists.
124
+ return this.get()!;
125
+ }
126
+ }
@@ -10,7 +10,7 @@ import { readPackageVersion } from "@danypops/daemon-kit/version";
10
10
  import { bootstrap } from "./bootstrap.js";
11
11
 
12
12
  const version = readPackageVersion(new URL("../../package.json", import.meta.url), "Tickets");
13
- const { options } = bootstrap({ version });
13
+ const { options } = await bootstrap({ version });
14
14
 
15
15
  runDaemonProcess({
16
16
  ...options,
package/src/daemon/ops.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * import from either side without pulling in bun:sqlite or Bun.serve.
6
6
  */
7
7
  import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
8
+ import type { TicketFocusState } from "./focus.js";
8
9
 
9
10
  export type TicketOperation =
10
11
  | "backends.list"
@@ -18,6 +19,11 @@ export type TicketOperation =
18
19
  | "issue.comment_add"
19
20
  | "ledger.search"
20
21
  | "ledger.stats"
22
+ | "focus.set"
23
+ | "focus.get"
24
+ | "focus.pause"
25
+ | "focus.unpause"
26
+ | "focus.clear"
21
27
  | "daemon.shutdown";
22
28
 
23
29
  export interface TicketOpInputs extends Record<TicketOperation, unknown> {
@@ -32,6 +38,11 @@ export interface TicketOpInputs extends Record<TicketOperation, unknown> {
32
38
  "issue.comment_add": { ref: string; body: string };
33
39
  "ledger.search": { query: string; limit?: number };
34
40
  "ledger.stats": Record<string, never>;
41
+ "focus.set": { ref: string };
42
+ "focus.get": Record<string, never>;
43
+ "focus.pause": { reason?: string };
44
+ "focus.unpause": Record<string, never>;
45
+ "focus.clear": Record<string, never>;
35
46
  "daemon.shutdown": Record<string, never>;
36
47
  }
37
48
 
@@ -47,6 +58,11 @@ export interface TicketOpOutputs extends Record<TicketOperation, unknown> {
47
58
  "issue.comment_add": { comment: Comment };
48
59
  "ledger.search": { issues: Issue[] };
49
60
  "ledger.stats": { backends: { backend: string; count: number }[] };
61
+ "focus.set": { focus: TicketFocusState };
62
+ "focus.get": { focus: TicketFocusState | null };
63
+ "focus.pause": { focus: TicketFocusState };
64
+ "focus.unpause": { focus: TicketFocusState };
65
+ "focus.clear": { cleared: boolean };
50
66
  "daemon.shutdown": { stopping: true };
51
67
  }
52
68
 
@@ -62,6 +78,11 @@ export const TICKET_OPERATIONS: TicketOperation[] = [
62
78
  "issue.comment_add",
63
79
  "ledger.search",
64
80
  "ledger.stats",
81
+ "focus.set",
82
+ "focus.get",
83
+ "focus.pause",
84
+ "focus.unpause",
85
+ "focus.clear",
65
86
  "daemon.shutdown",
66
87
  ];
67
88
 
@@ -8,12 +8,15 @@ import { errorResponse, healthResponse, jsonResponse, readyResponse, requireBear
8
8
  import type { Logger } from "@danypops/daemon-kit/logging";
9
9
  import { AuthRequiredError, IssueNotFoundError } from "../adapters/errors.js";
10
10
  import { NotSupportedError, type TicketService, UnknownBackendError } from "../application/service.js";
11
+ import { parseRef } from "../domain/issue.js";
12
+ import { FocusError, type FocusStore } from "./focus.js";
11
13
  import type { Ledger } from "./ledger.js";
12
14
  import { TICKET_OPERATIONS, type TicketOpInputs, type TicketOperation, type TicketOpOutputs } from "./ops.js";
13
15
 
14
16
  export interface TicketsAppDeps {
15
17
  service: TicketService;
16
18
  ledger: Ledger;
19
+ focusStore: FocusStore;
17
20
  token: string;
18
21
  version: string;
19
22
  logger?: Logger;
@@ -44,6 +47,22 @@ const handlers: { [Op in TicketOperation]: Handler<Op> } = {
44
47
  "issue.comment_add": async (deps, input) => ({ comment: await deps.service.addComment(input.ref, input.body) }),
45
48
  "ledger.search": async (deps, input) => ({ issues: deps.ledger.search(input.query, input.limit) }),
46
49
  "ledger.stats": async (deps) => ({ backends: deps.ledger.stats() }),
50
+ "focus.set": async (deps, input) => {
51
+ // Ledger-first: focusing a ticket already pooled locally needs no live
52
+ // backend call. Otherwise fall back to a live get (also validates the
53
+ // ref actually exists) and opportunistically warm the ledger with it,
54
+ // since a ticket you're about to focus on is exactly the kind of issue
55
+ // worth having cached locally.
56
+ const cached = deps.ledger.get(input.ref);
57
+ const issue = cached ?? (await deps.service.get(input.ref));
58
+ if (!cached) deps.ledger.upsert(parseRef(input.ref).backend, issue);
59
+ if (!issue.url) throw new FocusError(`issue "${input.ref}" has no URL from its backend; cannot focus without a full link`);
60
+ return { focus: deps.focusStore.set(input.ref, issue.title, issue.url) };
61
+ },
62
+ "focus.get": async (deps) => ({ focus: deps.focusStore.get() ?? null }),
63
+ "focus.pause": async (deps, input) => ({ focus: deps.focusStore.pause(input.reason) }),
64
+ "focus.unpause": async (deps) => ({ focus: deps.focusStore.unpause() }),
65
+ "focus.clear": async (deps) => ({ cleared: deps.focusStore.clear() }),
47
66
  "daemon.shutdown": async (deps) => {
48
67
  // Deferred so this handler's own response has already been handed back
49
68
  // to Bun.serve before the process starts tearing down.
@@ -58,7 +77,7 @@ function isTicketOperation(value: unknown): value is TicketOperation {
58
77
 
59
78
  function statusFor(error: unknown): number {
60
79
  if (error instanceof IssueNotFoundError) return 404;
61
- if (error instanceof UnknownBackendError || error instanceof NotSupportedError) return 400;
80
+ if (error instanceof UnknownBackendError || error instanceof NotSupportedError || error instanceof FocusError) return 400;
62
81
  if (error instanceof AuthRequiredError) return 422;
63
82
  return 500;
64
83
  }
@@ -79,6 +79,8 @@ export interface CreateInput {
79
79
  project?: string;
80
80
  issueType?: string;
81
81
  parentKey?: string;
82
+ /** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
83
+ customFields?: Record<string, string>;
82
84
  }
83
85
 
84
86
  export interface UpdateInput {
@@ -89,6 +91,8 @@ export interface UpdateInput {
89
91
  labels?: string[];
90
92
  assignee?: string;
91
93
  resolution?: string;
94
+ /** Backend-specific custom fields keyed by display name (e.g. Jira's "QE Priority"), resolved to the backend's own field ID by the adapter. Not every backend supports this (only Jira does today). */
95
+ customFields?: Record<string, string>;
92
96
  }
93
97
 
94
98
  export interface ListFilter {
package/src/index.ts CHANGED
@@ -14,10 +14,12 @@ export {
14
14
  configDir,
15
15
  } from "./config/config.js";
16
16
  export type { TicketOperation, TicketOpInputs, TicketOpOutputs } from "./daemon/ops.js";
17
+ export type { FocusStatus, TicketFocusState } from "./daemon/focus.js";
17
18
  export {
18
19
  createTicketsClient,
19
20
  ensureDaemonRunning,
20
21
  ticketsPaths,
22
+ type EnsureDaemonOptions,
21
23
  type TicketsRpcClient,
22
24
  } from "./client/tickets-client.js";
23
25