@danypops/tickets 0.4.0 → 0.4.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/README.md CHANGED
@@ -171,6 +171,22 @@ tickets auth status
171
171
  tickets auth logout github
172
172
  ```
173
173
 
174
+ ### Static token instead (API key/PAT, no OAuth)
175
+
176
+ For a backend with no OAuth app to register against (e.g. a plain Atlassian
177
+ API token from `id.atlassian.com/manage-profile/security/api-tokens`),
178
+ store it directly -- same 0600 local file `auth login` writes to, checked
179
+ ahead of any `token`/`token_env` config-file or plain env-var fallback:
180
+
181
+ ```bash
182
+ tickets auth set-token jira
183
+ # Paste the "jira" token (input hidden): ****
184
+
185
+ # or non-interactively, e.g. from a password manager (piped stdin works too,
186
+ # since a non-TTY stdin is read as-is with nothing to mask):
187
+ pass show jira-api-token | tickets auth set-token jira
188
+ ```
189
+
174
190
  ### GitHub: reuse an already-authenticated `gh` CLI session
175
191
 
176
192
  `tickets auth login --backend github --gh-cli [account]` skips the device
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/tickets",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reads a secret from a real TTY with keystrokes masked -- never echoed to
3
+ * the terminal, so it can't land in scrollback or a screen share the way a
4
+ * typed-and-visible value would. Falls back to reading one line from stdin
5
+ * unmasked when stdin isn't a TTY (piped input, e.g. `pass show jira |
6
+ * tickets auth set-token jira`) -- there is nothing to mask once the value
7
+ * never touched an interactive terminal in the first place.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+
11
+ export function promptMaskedSecret(
12
+ promptText: string,
13
+ input: NodeJS.ReadableStream = process.stdin,
14
+ output: NodeJS.WritableStream = process.stdout,
15
+ ): Promise<string> {
16
+ const isTTY = (input as NodeJS.ReadStream).isTTY === true;
17
+
18
+ if (!isTTY) {
19
+ return new Promise((resolve, reject) => {
20
+ let data = "";
21
+ input.setEncoding?.("utf8");
22
+ input.on("data", (chunk) => {
23
+ data += chunk;
24
+ });
25
+ input.on("end", () => resolve(data.split("\n")[0]?.trim() ?? ""));
26
+ input.on("error", reject);
27
+ });
28
+ }
29
+
30
+ return new Promise((resolve, reject) => {
31
+ const rl = createInterface({ input, output, terminal: true });
32
+ // readline has no public masking option; overriding the internal
33
+ // _writeToOutput hook (long-standing, widely-used pattern for exactly
34
+ // this) is the only way to suppress echoed keystrokes while still
35
+ // letting the prompt text itself render once.
36
+ // biome-ignore lint/suspicious/noExplicitAny: readline's internal _writeToOutput has no public type
37
+ const rlInternal = rl as any;
38
+ const originalWriteToOutput = rlInternal._writeToOutput.bind(rl);
39
+ let promptShown = false;
40
+ rlInternal._writeToOutput = (stringToWrite: string) => {
41
+ if (!promptShown) {
42
+ originalWriteToOutput(stringToWrite);
43
+ if (stringToWrite.includes(promptText)) promptShown = true;
44
+ }
45
+ // Every keystroke after the prompt itself is swallowed -- masked.
46
+ };
47
+ rl.question(promptText, (answer) => {
48
+ rl.close();
49
+ output.write("\n");
50
+ resolve(answer.trim());
51
+ });
52
+ rl.on("error", reject);
53
+ });
54
+ }
package/src/cli/index.ts CHANGED
@@ -15,6 +15,7 @@ import { readGhCliToken } from "../auth/gh-cli.js";
15
15
  import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
16
16
  import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
17
17
  import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
18
+ import { promptMaskedSecret } from "../auth/masked-prompt.js";
18
19
  import { installTicketsService, systemctlTickets, systemdUnitPath } from "./systemd-service.js";
19
20
 
20
21
  function printJson(value: unknown): void {
@@ -422,6 +423,23 @@ auth
422
423
  }
423
424
  });
424
425
 
426
+ auth
427
+ .command("set-token <backend>")
428
+ .description("store a plain static token (API key/PAT, no OAuth) for a backend -- e.g. an Atlassian API token for jira")
429
+ .action(async (backend: string) => {
430
+ // TICKETS_TOKEN_VALUE remains for non-interactive/scripted use (a provisioning
431
+ // script, `pass show jira | tickets auth set-token jira`) -- never accepted as a
432
+ // plain CLI argument, which would land in shell history the way this would not.
433
+ const value = process.env.TICKETS_TOKEN_VALUE ?? (await promptMaskedSecret(`Paste the "${backend}" token (input hidden): `));
434
+ if (!value) {
435
+ process.stderr.write("no token value provided — paste one at the prompt, or set TICKETS_TOKEN_VALUE for non-interactive use\n");
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+ saveToken(backend, { accessToken: value });
440
+ printJson({ backend, status: "stored", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
441
+ });
442
+
425
443
  auth
426
444
  .command("status")
427
445
  .description("list backends with a locally stored delegated token")