@danypops/tickets 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",
@@ -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
 
@@ -5,9 +5,14 @@
5
5
  * *to* GitHub/GitLab/Jira on the user's behalf. Same security posture as
6
6
  * vehicle-server's ensureAuthToken: 0700 directory, 0600 files, atomic write.
7
7
  */
8
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
+ import { existsSync, readdirSync, rmSync } from "node:fs";
9
+ import { mkdir } from "node:fs/promises";
9
10
  import { homedir } from "node:os";
10
11
  import { dirname, join } from "node:path";
12
+ import { createAtomicJsonWriter } from "@danypops/vehicle-core";
13
+ import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
14
+
15
+ const atomicJson = createAtomicJsonWriter({ fs: createNodeAtomicJsonFsAdapter() });
11
16
 
12
17
  export interface StoredToken {
13
18
  accessToken: string;
@@ -35,21 +40,19 @@ function tokenPath(backend: string, opts: TokenStoreEnv = {}): string {
35
40
  return join(tokenStoreDir(opts), `${backend}.json`);
36
41
  }
37
42
 
38
- export function saveToken(backend: string, token: StoredToken, opts: TokenStoreEnv = {}): void {
43
+ export async function saveToken(backend: string, token: StoredToken, opts: TokenStoreEnv = {}): Promise<void> {
39
44
  const path = tokenPath(backend, opts);
40
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
41
- const temp = `${path}.${process.pid}.tmp`;
42
- writeFileSync(temp, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 });
43
- renameSync(temp, path);
45
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
46
+ await atomicJson.write(path, token, { mode: 0o600, pretty: true, trailingNewline: true });
44
47
  }
45
48
 
46
- export function loadToken(backend: string, opts: TokenStoreEnv = {}): StoredToken | undefined {
49
+ export async function loadToken(backend: string, opts: TokenStoreEnv = {}): Promise<StoredToken | undefined> {
47
50
  const path = tokenPath(backend, opts);
48
- if (!existsSync(path)) return undefined;
49
- chmodSync(path, 0o600);
50
51
  try {
51
- return JSON.parse(readFileSync(path, "utf8")) as StoredToken;
52
+ return (await atomicJson.read(path)) as StoredToken | undefined;
52
53
  } catch {
54
+ // A corrupt/malformed token file is treated the same as no token --
55
+ // the caller re-authenticates rather than crashing on a parse error.
53
56
  return undefined;
54
57
  }
55
58
  }
package/src/cli/index.ts CHANGED
@@ -550,7 +550,7 @@ auth
550
550
  if (type === "github" && opts.ghCli !== undefined) {
551
551
  const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
552
552
  if (!result.ok) throw new Error(result.reason);
553
- saveToken(opts.backend, { accessToken: result.token });
553
+ await saveToken(opts.backend, { accessToken: result.token });
554
554
  printJson({
555
555
  backend: opts.backend,
556
556
  status: "authorized",
@@ -577,7 +577,7 @@ auth
577
577
  }
578
578
  },
579
579
  });
580
- saveToken(opts.backend, {
580
+ await saveToken(opts.backend, {
581
581
  accessToken: token.accessToken,
582
582
  refreshToken: token.refreshToken,
583
583
  expiresAt: token.expiresAt,
@@ -600,7 +600,7 @@ auth
600
600
  }
601
601
  },
602
602
  });
603
- saveToken(opts.backend, {
603
+ await saveToken(opts.backend, {
604
604
  accessToken: token.accessToken,
605
605
  refreshToken: token.refreshToken,
606
606
  expiresAt: token.expiresAt,
@@ -627,7 +627,7 @@ auth
627
627
  }
628
628
  },
629
629
  });
630
- saveToken(opts.backend, {
630
+ await saveToken(opts.backend, {
631
631
  accessToken: token.accessToken,
632
632
  refreshToken: token.refreshToken,
633
633
  expiresAt: token.expiresAt,
@@ -661,7 +661,7 @@ auth
661
661
  process.exitCode = 1;
662
662
  return;
663
663
  }
664
- saveToken(backend, { accessToken: value });
664
+ await saveToken(backend, { accessToken: value });
665
665
  printJson({
666
666
  backend,
667
667
  status: "stored",
@@ -672,18 +672,20 @@ auth
672
672
  auth
673
673
  .command("status")
674
674
  .description("list backends with a locally stored delegated token")
675
- .action(() => {
675
+ .action(async () => {
676
676
  const backends = listStoredBackends();
677
677
  printJson(
678
- backends.map((backend) => {
679
- const token = loadToken(backend);
680
- return {
681
- backend,
682
- fresh: token ? isTokenFresh(token) : false,
683
- expiresAt: token?.expiresAt ?? null,
684
- scope: token?.scope ?? null,
685
- };
686
- }),
678
+ await Promise.all(
679
+ backends.map(async (backend) => {
680
+ const token = await loadToken(backend);
681
+ return {
682
+ backend,
683
+ fresh: token ? isTokenFresh(token) : false,
684
+ expiresAt: token?.expiresAt ?? null,
685
+ scope: token?.scope ?? null,
686
+ };
687
+ }),
688
+ ),
687
689
  );
688
690
  });
689
691
 
@@ -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
 
@@ -103,7 +103,7 @@ export async function preferredAuth(
103
103
  const fromEnigma = await tryEnigma(name, { env, token: env.ENIGMA_CLIENT_TOKEN });
104
104
  if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
105
105
 
106
- const stored = loadToken(name, { env });
106
+ const stored = await loadToken(name, { env });
107
107
  if (stored && isTokenFresh(stored)) {
108
108
  return { token: stored.accessToken, oauth: true, extra: stored.extra };
109
109
  }