@danypops/tickets 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@danypops/vehicle-core": "^0.12.3",
28
- "@danypops/vehicle-server": "^0.17.1",
28
+ "@danypops/vehicle-server": "^0.18.2",
29
29
  "@danypops/vehicle-client": "^0.5.0",
30
30
  "@danypops/enigma-client": "^0.6.1",
31
31
  "@gitbeaker/rest": "^43.8.0",
@@ -9,8 +9,15 @@ import { spawn } from "node:child_process";
9
9
 
10
10
  export type Spawner = (command: string, args: string[]) => void;
11
11
 
12
- const defaultSpawner: Spawner = (command, args) => {
12
+ // A spawn() failure (e.g. no `xdg-open` on a minimal Linux install) surfaces asynchronously as
13
+ // an unlistened "error" event under Node, which is an uncaught exception that kills the whole
14
+ // host process -- this runs from inside the long-lived pi-tickets extension host (tui.ts), not
15
+ // just the standalone CLI, so that crash is a real live risk, not just a CLI inconvenience.
16
+ export const defaultSpawner: Spawner = (command, args) => {
13
17
  const child = spawn(command, args, { stdio: "ignore", detached: true });
18
+ child.on("error", (error) => {
19
+ console.error(`failed to open URL via ${command}: ${error instanceof Error ? error.message : String(error)}`);
20
+ });
14
21
  child.unref();
15
22
  };
16
23
 
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
+ "deploy the tickets daemon as a persistent, Armada-supervised service (Linux/macOS/Windows; survives logout/reboot and restarts on crash, unlike `daemon start`'s on-demand spawn)",
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 (write/enable/start its systemd unit)")
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
  }
@@ -29,14 +29,22 @@ async function isAlive(handle: DaemonHandle, token: string): Promise<boolean> {
29
29
  }
30
30
  }
31
31
 
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). */
32
+ /** Absolute path to the daemon's real entry point, resolved from this package's own root. Used to spawn it on demand -- Armada's own ServiceSpec (see cli/systemd-service.ts) launches the CLI's own `serve` command instead, not this path directly. */
33
33
  export function resolveDaemonEntryPath(): string {
34
34
  const root = packageRoot(dirname(fileURLToPath(import.meta.url)));
35
35
  return join(root, "src", "process", "main.ts");
36
36
  }
37
37
 
38
- function spawnDaemon(): void {
38
+ // A spawn() failure surfaces asynchronously as an unlistened "error" event under Node, which is
39
+ // an uncaught exception that kills the whole host process. createTicketsClient (which calls
40
+ // this via ensureDaemonRunning) runs from inside the long-lived pi-tickets extension host
41
+ // (tui.ts) as well as the CLI, so a missing/misconfigured `bun` binary would otherwise crash
42
+ // the whole Pi session, not just this one connect attempt.
43
+ export function spawnDaemon(): void {
39
44
  const child = spawn("bun", ["run", resolveDaemonEntryPath()], { detached: true, stdio: "ignore" });
45
+ child.on("error", (error) => {
46
+ console.error(`tickets daemon auto-spawn failed: ${error instanceof Error ? error.message : String(error)}`);
47
+ });
40
48
  child.unref();
41
49
  }
42
50
 
@@ -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
+ }