@danypops/tickets 0.2.1 → 0.3.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.
- package/README.md +41 -2
- package/package.json +2 -1
- package/src/application/service.ts +12 -1
- package/src/auth/gh-cli.ts +46 -0
- package/src/cli/index.ts +10 -1
- package/src/config/config.ts +53 -3
- package/src/daemon/bootstrap.ts +20 -4
- package/src/daemon/ops.ts +1 -1
- package/src/daemon/poller.ts +8 -2
- package/src/auth/enigma-source.ts +0 -65
package/README.md
CHANGED
|
@@ -157,15 +157,54 @@ tickets auth status
|
|
|
157
157
|
tickets auth logout github
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
### GitHub: reuse an already-authenticated `gh` CLI session
|
|
161
|
+
|
|
162
|
+
`tickets auth login --backend github --gh-cli [account]` skips the device
|
|
163
|
+
flow (and the `GITHUB_OAUTH_CLIENT_ID` App registration it needs) entirely
|
|
164
|
+
by reading `gh auth token` instead — never re-implement a vendor CLI's own
|
|
165
|
+
auth, just consume its result via its own documented, stable interface.
|
|
166
|
+
Works whether `gh` stores its token in the OS keyring or a legacy
|
|
167
|
+
plaintext file. Omit `account` for `gh`'s current active account, or name
|
|
168
|
+
one of `gh`'s own multiple authenticated accounts (`gh auth status` lists
|
|
169
|
+
them) — pair with a distinct `--backend` name to register each as its own
|
|
170
|
+
tickets backend:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
tickets auth login --backend github-personal --gh-cli DanyPops
|
|
174
|
+
tickets auth login --backend github-work --gh-cli work-account
|
|
175
|
+
```
|
|
176
|
+
|
|
160
177
|
A stored, still-fresh delegated token always takes precedence over a static
|
|
161
178
|
config/env token for that backend. Tokens are written to
|
|
162
179
|
`$XDG_STATE_HOME/tickets/oauth/<backend>.json`, mode `0600`, and are never
|
|
163
180
|
printed by any command. **Restart the daemon** after logging in so it picks
|
|
164
181
|
up the new credential — `buildRepositories()` runs once at daemon startup.
|
|
165
182
|
|
|
183
|
+
### Optional: credentials via Enigma
|
|
184
|
+
|
|
185
|
+
If an [Enigma](https://github.com/DanyPops/enigma) vault is running,
|
|
186
|
+
tickets checks it first on every request, ahead of a stored delegated token
|
|
187
|
+
and any static config/env token — a credential Enigma rotates is picked up
|
|
188
|
+
on the very next call, no daemon restart needed. Purely additive: tickets
|
|
189
|
+
works identically with no Enigma running at all.
|
|
190
|
+
|
|
191
|
+
Register tickets as a scoped Enigma client (once), then pass the printed
|
|
192
|
+
token to the daemon via `ENIGMA_CLIENT_TOKEN`:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
enigma client add tickets --backends github,gitlab,jira
|
|
196
|
+
# -> prints a token once; export it wherever the tickets daemon is started
|
|
197
|
+
export ENIGMA_CLIENT_TOKEN=<printed token>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Without `ENIGMA_CLIENT_TOKEN`, tickets falls back to Enigma's shared
|
|
201
|
+
admin-token file if one exists at `$XDG_STATE_HOME/enigma/token` — fine for
|
|
202
|
+
a single-user machine where every local daemon is equally trusted, but a
|
|
203
|
+
scoped client token is the least-privilege default.
|
|
204
|
+
|
|
166
205
|
## The `pi-tickets` extension
|
|
167
206
|
|
|
168
|
-
Published as `@danypops/pi-tickets`.
|
|
207
|
+
Published as `@danypops/pi-tickets`. `../pi-tickets/` (this repo's workspace member) registers a single `tickets` tool for
|
|
169
208
|
[pi](https://github.com/badlogic/pi) with one action per CLI command (`list`,
|
|
170
209
|
`get`, `create`, `update`, `search`, `children`, `comments`, `comment_add`,
|
|
171
210
|
`backends`, `ledger_search`, `ledger_stats`, `focus_set`, `focus_get`,
|
|
@@ -196,7 +235,7 @@ To use it, add it to pi's `settings.json`:
|
|
|
196
235
|
```
|
|
197
236
|
|
|
198
237
|
Or, for local development against this monorepo, point at the workspace
|
|
199
|
-
member directory instead: `{ "packages": ["/path/to/tickets/
|
|
238
|
+
member directory instead: `{ "packages": ["/path/to/tickets/packages/pi-tickets"] }`.
|
|
200
239
|
|
|
201
240
|
## Development
|
|
202
241
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/tickets",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@danypops/daemon-kit": "^0.3.0",
|
|
28
|
+
"@danypops/enigma-client": "^0.3.0",
|
|
28
29
|
"@gitbeaker/rest": "^43.8.0",
|
|
29
30
|
"commander": "^12.1.0",
|
|
30
31
|
"jira.js": "^5.4.0",
|
|
@@ -23,12 +23,23 @@ export class NotSupportedError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export class TicketService {
|
|
26
|
-
constructor(private
|
|
26
|
+
constructor(private repos: Record<string, IssueRepository>) {}
|
|
27
27
|
|
|
28
28
|
backends(): string[] {
|
|
29
29
|
return Object.keys(this.repos);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Swaps the live backend set atomically. A backend newly configured in
|
|
34
|
+
* Enigma (or removed) becomes usable on the next call without
|
|
35
|
+
* reconstructing the service or restarting the daemon -- see
|
|
36
|
+
* config.ts's createBackendRefreshTask, the maintenance task that calls
|
|
37
|
+
* this on a schedule.
|
|
38
|
+
*/
|
|
39
|
+
setRepos(repos: Record<string, IssueRepository>): void {
|
|
40
|
+
this.repos = repos;
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
private repo(backend: string): IssueRepository {
|
|
33
44
|
const repo = this.repos[backend];
|
|
34
45
|
if (!repo) throw new UnknownBackendError(backend, this.backends());
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional shortcut: reuse an already-authenticated `gh` CLI session instead
|
|
3
|
+
* of running tickets' own OAuth device flow. Never re-implement a vendor
|
|
4
|
+
* CLI's own auth, just delegate to it and consume the result.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately shells out to `gh auth token` rather than reading gh's own
|
|
7
|
+
* credential storage directly: gh's default "secure storage" keeps the
|
|
8
|
+
* token in the OS keyring (Secret Service/libsecret on Linux, Keychain on
|
|
9
|
+
* macOS, Credential Manager on Windows) under an internal, undocumented
|
|
10
|
+
* schema -- not a published contract. `gh auth token` is gh's own
|
|
11
|
+
* documented, stable interface for exactly this scripting use case,
|
|
12
|
+
* abstracting over wherever the credential actually lives. The token never
|
|
13
|
+
* touches this process's own stdout/log output, only the returned string.
|
|
14
|
+
*/
|
|
15
|
+
export type GhCliTokenResult = { ok: true; token: string } | { ok: false; reason: string };
|
|
16
|
+
|
|
17
|
+
export interface SpawnLike {
|
|
18
|
+
(command: string[]): { stdout: ReadableStream<Uint8Array> | number; exited: Promise<number> };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const defaultSpawn: SpawnLike = (command) => Bun.spawn(command, { stdout: "pipe" });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads `gh auth token`'s output for the given account (gh's own `--user`
|
|
25
|
+
* flag; omit to use gh's currently active account). Never mints, never
|
|
26
|
+
* prompts, never falls back to a device flow itself.
|
|
27
|
+
*/
|
|
28
|
+
export async function readGhCliToken(user?: string, spawn: SpawnLike = defaultSpawn): Promise<GhCliTokenResult> {
|
|
29
|
+
const command = user ? ["gh", "auth", "token", "--user", user] : ["gh", "auth", "token"];
|
|
30
|
+
let proc: ReturnType<SpawnLike>;
|
|
31
|
+
try {
|
|
32
|
+
proc = spawn(command);
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, reason: "gh CLI not found -- install it (cli.github.com) or use a different login method" };
|
|
35
|
+
}
|
|
36
|
+
const [stdout, code] = await Promise.all([
|
|
37
|
+
proc.stdout instanceof ReadableStream ? new Response(proc.stdout).text() : Promise.resolve(""),
|
|
38
|
+
proc.exited,
|
|
39
|
+
]);
|
|
40
|
+
if (code !== 0) {
|
|
41
|
+
return { ok: false, reason: user ? `gh CLI has no authenticated account named "${user}" -- run \`gh auth login\` first` : "gh CLI is not authenticated -- run `gh auth login` first" };
|
|
42
|
+
}
|
|
43
|
+
const token = stdout.trim();
|
|
44
|
+
if (!token) return { ok: false, reason: "gh auth token returned no token" };
|
|
45
|
+
return { ok: true, token };
|
|
46
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { parseStatus } from "../domain/issue.js";
|
|
|
11
11
|
import { createTicketsClient, type TicketsRpcClient } from "../client/tickets-client.js";
|
|
12
12
|
import { openUrl } from "../auth/browser.js";
|
|
13
13
|
import { loginWithGitHubDeviceFlow } from "../auth/github-oauth.js";
|
|
14
|
+
import { readGhCliToken } from "../auth/gh-cli.js";
|
|
14
15
|
import { gitlabDeviceEndpoints, loginWithGitLabDeviceFlow } from "../auth/gitlab-oauth.js";
|
|
15
16
|
import { loginWithJiraAuthorizationCode } from "../auth/jira-oauth.js";
|
|
16
17
|
import { deleteToken, isTokenFresh, listStoredBackends, loadToken, saveToken } from "../auth/token-store.js";
|
|
@@ -305,12 +306,20 @@ auth
|
|
|
305
306
|
.option("--client-secret <secret>", "OAuth client secret (Jira only — GitHub/GitLab device flow needs none)")
|
|
306
307
|
.option("--url <baseUrl>", "self-managed GitLab URL (defaults to gitlab.com)")
|
|
307
308
|
.option("--scope <scope>", "space-delimited OAuth scope override")
|
|
309
|
+
.option("--gh-cli [account]", "github only: reuse an already-authenticated gh CLI session instead of the device flow (omit value for gh's active account)")
|
|
308
310
|
.action(async (opts) => {
|
|
309
311
|
const type = opts.type ?? opts.backend;
|
|
310
312
|
try {
|
|
313
|
+
if (type === "github" && opts.ghCli !== undefined) {
|
|
314
|
+
const result = await readGhCliToken(opts.ghCli === true ? undefined : opts.ghCli);
|
|
315
|
+
if (!result.ok) throw new Error(result.reason);
|
|
316
|
+
saveToken(opts.backend, { accessToken: result.token });
|
|
317
|
+
printJson({ backend: opts.backend, status: "authorized", via: "gh-cli", note: "restart the tickets daemon (or run `tickets daemon-status` after a fresh start) to pick up the new token" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
311
320
|
if (type === "github") {
|
|
312
321
|
const clientId = opts.clientId ?? process.env.GITHUB_OAUTH_CLIENT_ID;
|
|
313
|
-
if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required");
|
|
322
|
+
if (!clientId) throw new Error("--client-id or GITHUB_OAUTH_CLIENT_ID is required (or pass --gh-cli [account] to reuse an already-authenticated gh CLI session instead)");
|
|
314
323
|
const token = await loginWithGitHubDeviceFlow({
|
|
315
324
|
clientId,
|
|
316
325
|
scope: opts.scope,
|
package/src/config/config.ts
CHANGED
|
@@ -7,12 +7,15 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import type { MaintenanceTask } from "@danypops/daemon-kit/daemon";
|
|
11
|
+
import type { Logger } from "@danypops/daemon-kit/logging";
|
|
10
12
|
import { GitHubRepository } from "../adapters/github.js";
|
|
11
13
|
import { GitLabRepository } from "../adapters/gitlab.js";
|
|
12
14
|
import { JiraRepository } from "../adapters/jira.js";
|
|
13
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
|
+
import type { TicketService } from "../application/service.js";
|
|
14
17
|
import { isTokenFresh, loadToken } from "../auth/token-store.js";
|
|
15
|
-
import { type TryEnigmaCredential, tryEnigmaCredential } from "
|
|
18
|
+
import { type TryEnigmaCredential, tryEnigmaCredential } from "@danypops/enigma-client";
|
|
16
19
|
|
|
17
20
|
export interface BackendConfig {
|
|
18
21
|
/** Adapter type: "github" | "gitlab" | "jira". Falls back to the config key when omitted. */
|
|
@@ -59,7 +62,7 @@ function resolveToken(cfg: BackendConfig, env: NodeJS.ProcessEnv, envFallback: s
|
|
|
59
62
|
* Resolution order, highest priority first: (1) a running Enigma vault, if
|
|
60
63
|
* one happens to be configured for this backend — entirely optional, never a
|
|
61
64
|
* hard dependency, and bounded so Tickets never waits long for it (see
|
|
62
|
-
*
|
|
65
|
+
* @danypops/enigma-client); (2) a locally stored, still-fresh delegated OAuth
|
|
63
66
|
* token (see auth/token-store.ts, populated by `tickets auth login`); (3) a
|
|
64
67
|
* static config/env PAT. (1) is additive to the pre-Enigma precedence this
|
|
65
68
|
* project already followed for GitHub, GitLab, and Jira — see RESEARCH.md
|
|
@@ -74,7 +77,10 @@ export async function preferredAuth(
|
|
|
74
77
|
envFallback: string,
|
|
75
78
|
tryEnigma: TryEnigmaCredential = tryEnigmaCredential,
|
|
76
79
|
): Promise<{ token: string | undefined; oauth: boolean; extra?: Record<string, string> }> {
|
|
77
|
-
|
|
80
|
+
// ENIGMA_CLIENT_TOKEN is this daemon's own registered-client token (`enigma client add`) --
|
|
81
|
+
// Enigma's shared admin-token file is deliberately unreadable outside its own service
|
|
82
|
+
// account, so tickets must present its own scoped token to get anything back at all.
|
|
83
|
+
const fromEnigma = await tryEnigma(name, { env, token: env.ENIGMA_CLIENT_TOKEN });
|
|
78
84
|
if (fromEnigma) return { token: fromEnigma.accessToken, oauth: true, extra: fromEnigma.extra };
|
|
79
85
|
|
|
80
86
|
const stored = loadToken(name, { env });
|
|
@@ -118,6 +124,50 @@ export async function buildRepositories(
|
|
|
118
124
|
return repos;
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
export type BuildRepositories = typeof buildRepositories;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Re-runs buildRepositories on a schedule and swaps the result into a live
|
|
131
|
+
* TicketService via setRepos -- the counterpart to token-provider.ts's
|
|
132
|
+
* per-request freshness in Pipes, one level up: this refreshes which
|
|
133
|
+
* backends exist at all, not just an existing backend's token. A backend
|
|
134
|
+
* enigma login just made available becomes callable without a daemon
|
|
135
|
+
* restart; a removed one stops being offered. A failed refresh (Enigma
|
|
136
|
+
* unreachable, transient) keeps the previous backend set rather than
|
|
137
|
+
* wiping it out.
|
|
138
|
+
*/
|
|
139
|
+
export function createBackendRefreshTask(
|
|
140
|
+
service: TicketService,
|
|
141
|
+
config: Config,
|
|
142
|
+
buildRepos: BuildRepositories,
|
|
143
|
+
intervalMs: number,
|
|
144
|
+
logger?: Logger,
|
|
145
|
+
): MaintenanceTask {
|
|
146
|
+
return {
|
|
147
|
+
name: "backend-refresh",
|
|
148
|
+
intervalMs,
|
|
149
|
+
run: async () => {
|
|
150
|
+
const before = new Set(service.backends());
|
|
151
|
+
let fresh: Record<string, IssueRepository>;
|
|
152
|
+
try {
|
|
153
|
+
fresh = await buildRepos(config);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger?.warn("backend refresh failed, keeping previous backend set", {
|
|
156
|
+
error: error instanceof Error ? error.message : String(error),
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
service.setRepos(fresh);
|
|
161
|
+
const after = new Set(Object.keys(fresh));
|
|
162
|
+
const added = [...after].filter((backend) => !before.has(backend));
|
|
163
|
+
const removed = [...before].filter((backend) => !after.has(backend));
|
|
164
|
+
if (added.length > 0 || removed.length > 0) {
|
|
165
|
+
logger?.info("backend set changed", { added, removed });
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
121
171
|
async function createRepository(
|
|
122
172
|
name: string,
|
|
123
173
|
type: string,
|
package/src/daemon/bootstrap.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { ensureAuthToken, type PathEnvironment, resolveDaemonPaths } from "@dany
|
|
|
11
11
|
import { checkpoint, openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
|
|
12
12
|
import type { StartDaemonOptions } from "@danypops/daemon-kit/daemon";
|
|
13
13
|
import { TicketService } from "../application/service.js";
|
|
14
|
-
import { buildRepositories, type Config, loadConfig } from "../config/config.js";
|
|
14
|
+
import { buildRepositories, type BuildRepositories, type Config, createBackendRefreshTask, loadConfig } from "../config/config.js";
|
|
15
15
|
import type { IssueRepository } from "../ports/repository.js";
|
|
16
16
|
import { FOCUS_MIGRATIONS, FocusStore } from "./focus.js";
|
|
17
17
|
import { Ledger, LEDGER_MIGRATIONS } from "./ledger.js";
|
|
@@ -22,12 +22,20 @@ import { createSyncTask } from "./poller.js";
|
|
|
22
22
|
export interface BootstrapOptions {
|
|
23
23
|
pathEnv?: PathEnvironment;
|
|
24
24
|
config?: Config;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Injected directly in tests instead of building from config/env. Also
|
|
27
|
+
* disables the live backend-refresh task -- an injected repo set is a
|
|
28
|
+
* fixed test fixture, not something to re-resolve from Enigma/config.
|
|
29
|
+
*/
|
|
26
30
|
repos?: Record<string, IssueRepository>;
|
|
31
|
+
/** Injected in tests to control which backends a refresh cycle resolves to, without a real Enigma/GitHub/GitLab/Jira. */
|
|
32
|
+
buildRepositories?: BuildRepositories;
|
|
27
33
|
version?: string;
|
|
28
34
|
logger?: Logger;
|
|
29
35
|
syncIntervalMs?: number;
|
|
30
36
|
checkpointIntervalMs?: number;
|
|
37
|
+
/** How often the live backend set re-resolves from config/env/Enigma. Ignored when repos is injected. */
|
|
38
|
+
backendRefreshIntervalMs?: number;
|
|
31
39
|
/**
|
|
32
40
|
* Overrides the daemon.shutdown op's effect. Defaults to sending this
|
|
33
41
|
* process SIGTERM, which daemon-kit's runDaemonProcess already handles
|
|
@@ -47,6 +55,7 @@ export interface BootstrappedDaemon {
|
|
|
47
55
|
|
|
48
56
|
const DEFAULT_SYNC_INTERVAL_MS = 5 * 60_000;
|
|
49
57
|
const DEFAULT_CHECKPOINT_INTERVAL_MS = 10 * 60_000;
|
|
58
|
+
const DEFAULT_BACKEND_REFRESH_INTERVAL_MS = 30_000;
|
|
50
59
|
|
|
51
60
|
export async function bootstrap(opts: BootstrapOptions = {}): Promise<BootstrappedDaemon> {
|
|
52
61
|
const paths = resolveDaemonPaths(TICKETS_DAEMON_NAMES, opts.pathEnv);
|
|
@@ -55,7 +64,9 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
55
64
|
const ledger = new Ledger(db);
|
|
56
65
|
const focusStore = new FocusStore(db);
|
|
57
66
|
const logger = opts.logger ?? createLogger("tickets-daemon", { levelEnvVar: "TICKETS_LOG_LEVEL" });
|
|
58
|
-
const
|
|
67
|
+
const config = opts.config ?? loadConfig();
|
|
68
|
+
const buildRepos = opts.buildRepositories ?? buildRepositories;
|
|
69
|
+
const repos = opts.repos ?? (await buildRepos(config));
|
|
59
70
|
const service = new TicketService(repos);
|
|
60
71
|
const version = opts.version ?? "0.0.0-dev";
|
|
61
72
|
|
|
@@ -64,12 +75,17 @@ export async function bootstrap(opts: BootstrapOptions = {}): Promise<Bootstrapp
|
|
|
64
75
|
handlePath: paths.handle,
|
|
65
76
|
logger,
|
|
66
77
|
maintenanceTasks: [
|
|
67
|
-
createSyncTask(service, ledger,
|
|
78
|
+
createSyncTask(service, ledger, opts.syncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS, logger),
|
|
68
79
|
{
|
|
69
80
|
name: "checkpoint",
|
|
70
81
|
intervalMs: opts.checkpointIntervalMs ?? DEFAULT_CHECKPOINT_INTERVAL_MS,
|
|
71
82
|
run: () => checkpoint(db),
|
|
72
83
|
},
|
|
84
|
+
// Only when repos came from real config/env/Enigma resolution -- an
|
|
85
|
+
// injected test fixture (opts.repos) has no config to re-resolve from.
|
|
86
|
+
...(opts.repos === undefined
|
|
87
|
+
? [createBackendRefreshTask(service, config, buildRepos, opts.backendRefreshIntervalMs ?? DEFAULT_BACKEND_REFRESH_INTERVAL_MS, logger)]
|
|
88
|
+
: []),
|
|
73
89
|
],
|
|
74
90
|
buildApp: () =>
|
|
75
91
|
buildApp({
|
package/src/daemon/ops.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The RPC protocol shared between the tickets daemon (server.ts, running under
|
|
3
|
-
* Bun) and every client (cli/index.ts,
|
|
3
|
+
* Bun) and every client (cli/index.ts, packages/pi-tickets, running under
|
|
4
4
|
* whatever consumes this package). Pure types, zero runtime imports, safe to
|
|
5
5
|
* import from either side without pulling in bun:sqlite or Bun.serve.
|
|
6
6
|
*/
|
package/src/daemon/poller.ts
CHANGED
|
@@ -35,10 +35,16 @@ export async function syncOnce(
|
|
|
35
35
|
return results;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Reads the backend list fresh from service.backends() on every tick,
|
|
40
|
+
* rather than a list frozen at task-creation time -- a backend the
|
|
41
|
+
* refresh task (config.ts's createBackendRefreshTask) just added to the
|
|
42
|
+
* service is synced on this task's very next run, no daemon restart or
|
|
43
|
+
* task rebuild needed.
|
|
44
|
+
*/
|
|
38
45
|
export function createSyncTask(
|
|
39
46
|
service: TicketService,
|
|
40
47
|
ledger: Ledger,
|
|
41
|
-
backends: string[],
|
|
42
48
|
intervalMs: number,
|
|
43
49
|
logger?: Logger,
|
|
44
50
|
): MaintenanceTask {
|
|
@@ -46,7 +52,7 @@ export function createSyncTask(
|
|
|
46
52
|
name: "ledger-sync",
|
|
47
53
|
intervalMs,
|
|
48
54
|
run: async () => {
|
|
49
|
-
await syncOnce(service, ledger, backends, logger);
|
|
55
|
+
await syncOnce(service, ledger, service.backends(), logger);
|
|
50
56
|
},
|
|
51
57
|
};
|
|
52
58
|
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Optional credential source: a running Enigma vault (github.com/DanyPops/enigma),
|
|
3
|
-
* if one happens to be configured on this machine. Purely additive — Tickets
|
|
4
|
-
* has never imported Enigma's package and never will; this talks to Enigma's
|
|
5
|
-
* loopback HTTP API using only @danypops/daemon-kit, which Tickets already
|
|
6
|
-
* depends on for its own daemon plumbing. Enigma's discovery contract is three
|
|
7
|
-
* stable, documented constants (its state-directory name and its handle/token
|
|
8
|
-
* filenames), not an import of Enigma's own source.
|
|
9
|
-
*
|
|
10
|
-
* Never creates Enigma's handle or token files — those are strictly Enigma's
|
|
11
|
-
* own job on its first boot. A consumer that could mint them would be a real
|
|
12
|
-
* security problem, not a convenience. Absence of either file means "Enigma
|
|
13
|
-
* isn't running or isn't configured for this backend," not an error: every
|
|
14
|
-
* failure path here resolves `undefined` rather than throwing, and the whole
|
|
15
|
-
* attempt is time-bounded so a slow or hung Enigma can never stall Tickets'
|
|
16
|
-
* own startup.
|
|
17
|
-
*/
|
|
18
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
-
import { readDaemonHandle, resolveDaemonPaths } from "@danypops/daemon-kit/paths";
|
|
20
|
-
import { createVaultClient, type RefreshableAccessToken } from "@danypops/daemon-kit/vault";
|
|
21
|
-
|
|
22
|
-
const ENIGMA_STATE_DIRECTORY_NAME = "enigma";
|
|
23
|
-
const ENIGMA_HANDLE_FILENAME = "handle.json";
|
|
24
|
-
const ENIGMA_TOKEN_FILENAME = "token";
|
|
25
|
-
const ENIGMA_LOOKUP_TIMEOUT_MS = 500;
|
|
26
|
-
|
|
27
|
-
export interface TryEnigmaCredentialEnv {
|
|
28
|
-
env?: Record<string, string | undefined>;
|
|
29
|
-
/** Injectable for tests; production default is the real fetch, bounded by AbortSignal.timeout. */
|
|
30
|
-
fetchImpl?: typeof fetch;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type TryEnigmaCredential = (backend: string, opts?: TryEnigmaCredentialEnv) => Promise<RefreshableAccessToken | undefined>;
|
|
34
|
-
|
|
35
|
-
export const tryEnigmaCredential: TryEnigmaCredential = async (backend, opts = {}) => {
|
|
36
|
-
const env = opts.env ?? process.env;
|
|
37
|
-
const paths = resolveDaemonPaths(
|
|
38
|
-
{ stateDirectoryName: ENIGMA_STATE_DIRECTORY_NAME, handleFilename: ENIGMA_HANDLE_FILENAME, tokenFilename: ENIGMA_TOKEN_FILENAME, databaseFilename: "", systemdUnitName: "" },
|
|
39
|
-
{ env },
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
const handle = readDaemonHandle(paths.handle);
|
|
43
|
-
if (!handle) return undefined; // Enigma isn't running -- not an error, just not present
|
|
44
|
-
|
|
45
|
-
if (!existsSync(paths.token)) return undefined; // never ensureAuthToken here -- read-only, never mint Enigma's own token
|
|
46
|
-
let token: string;
|
|
47
|
-
try {
|
|
48
|
-
token = readFileSync(paths.token, "utf8").trim();
|
|
49
|
-
} catch {
|
|
50
|
-
return undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
54
|
-
const client = createVaultClient({
|
|
55
|
-
baseUrl: `http://${handle.host}:${handle.port}`,
|
|
56
|
-
authToken: token,
|
|
57
|
-
fetchImpl: (url, init) => fetchImpl(url, { ...init, signal: AbortSignal.timeout(ENIGMA_LOOKUP_TIMEOUT_MS) }),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
return await client.getCredentials(backend);
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined; // unreachable, timed out, or any other transport failure -- fall through silently
|
|
64
|
-
}
|
|
65
|
-
};
|