patchrelay 0.6.1 → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.6.1",
4
- "commit": "dbfd3577f1dc",
5
- "builtAt": "2026-03-13T13:28:48.369Z"
3
+ "version": "0.7.1",
4
+ "commit": "c70c0c3117a6",
5
+ "builtAt": "2026-03-13T18:54:01.444Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UnknownCommandError, UnknownFlagsError } from "./errors.js";
1
2
  export const KNOWN_COMMANDS = new Set([
2
3
  "version",
3
4
  "serve",
@@ -25,6 +26,10 @@ export function parseArgs(argv) {
25
26
  const flags = new Map();
26
27
  for (let index = 0; index < argv.length; index += 1) {
27
28
  const value = argv[index];
29
+ if (value === "-h" || value === "--help") {
30
+ flags.set("help", true);
31
+ continue;
32
+ }
28
33
  if (!value.startsWith("--")) {
29
34
  positionals.push(value);
30
35
  continue;
@@ -59,7 +64,10 @@ export function resolveCommand(parsed) {
59
64
  if (ISSUE_KEY_PATTERN.test(requestedCommand)) {
60
65
  return { command: "inspect", commandArgs: parsed.positionals };
61
66
  }
62
- throw new Error(`Unknown command: ${requestedCommand}. Run \`patchrelay help\`.`);
67
+ throw new UnknownCommandError(requestedCommand);
68
+ }
69
+ export function hasHelpFlag(parsed) {
70
+ return parsed.flags.get("help") === true;
63
71
  }
64
72
  export function getStageFlag(value) {
65
73
  if (typeof value !== "string") {
@@ -78,12 +86,12 @@ export function parseCsvFlag(value) {
78
86
  .filter(Boolean);
79
87
  }
80
88
  export function assertKnownFlags(parsed, command, allowedFlags) {
81
- const allowed = new Set(allowedFlags);
89
+ const allowed = new Set(["help", ...allowedFlags]);
82
90
  const unknownFlags = [...parsed.flags.keys()].filter((flag) => !allowed.has(flag)).sort();
83
91
  if (unknownFlags.length === 0) {
84
92
  return;
85
93
  }
86
- throw new Error(`Unknown flag${unknownFlags.length === 1 ? "" : "s"} for ${command}: ${unknownFlags.map((flag) => `--${flag}`).join(", ")}`);
94
+ throw new UnknownFlagsError(unknownFlags, command === "project" || command === "project apply" ? "project" : "root");
87
95
  }
88
96
  export function parsePositiveIntegerFlag(value, flagName) {
89
97
  if (typeof value !== "string") {
@@ -1,140 +1,146 @@
1
1
  import { loadConfig } from "../../config.js";
2
2
  import { installUserServiceUnits, upsertProjectInConfig } from "../../install.js";
3
- import { runPreflight } from "../../preflight.js";
4
- import { parseCsvFlag } from "../args.js";
3
+ import { hasHelpFlag, parseCsvFlag } from "../args.js";
5
4
  import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
6
- import { CliDataAccess } from "../data.js";
5
+ import { CliUsageError } from "../errors.js";
7
6
  import { formatJson } from "../formatters/json.js";
7
+ import { projectHelpText } from "../help.js";
8
8
  import { writeOutput } from "../output.js";
9
9
  import { installServiceCommands, tryManageService } from "../service-commands.js";
10
10
  export async function handleProjectCommand(params) {
11
+ if (hasHelpFlag(params.parsed)) {
12
+ writeOutput(params.stdout, `${projectHelpText()}\n`);
13
+ return 0;
14
+ }
15
+ if (params.commandArgs.length === 0) {
16
+ throw new CliUsageError("patchrelay project requires a subcommand.", "project");
17
+ }
18
+ const subcommand = params.commandArgs[0];
19
+ if (subcommand !== "apply") {
20
+ throw new CliUsageError(`Unknown project command: ${subcommand}`, "project");
21
+ }
22
+ const projectId = params.commandArgs[1];
23
+ const repoPath = params.commandArgs[2];
24
+ if (!projectId || !repoPath) {
25
+ throw new CliUsageError("patchrelay project apply requires <id> and <repo-path>.", "project");
26
+ }
27
+ const result = await upsertProjectInConfig({
28
+ id: projectId,
29
+ repoPath,
30
+ issueKeyPrefixes: parseCsvFlag(params.parsed.flags.get("issue-prefix")),
31
+ linearTeamIds: parseCsvFlag(params.parsed.flags.get("team-id")),
32
+ });
33
+ const serviceUnits = await installUserServiceUnits();
34
+ const noConnect = params.parsed.flags.get("no-connect") === true;
35
+ const lines = [
36
+ `Config file: ${result.configPath}`,
37
+ `${result.status === "created" ? "Created" : result.status === "updated" ? "Updated" : "Verified"} project ${result.project.id} for ${result.project.repoPath}`,
38
+ result.project.issueKeyPrefixes.length > 0 ? `Issue key prefixes: ${result.project.issueKeyPrefixes.join(", ")}` : undefined,
39
+ result.project.linearTeamIds.length > 0 ? `Linear team ids: ${result.project.linearTeamIds.join(", ")}` : undefined,
40
+ `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
41
+ `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
42
+ ].filter(Boolean);
43
+ let fullConfig;
11
44
  try {
12
- const subcommand = params.commandArgs[0];
13
- if (subcommand !== "apply") {
14
- throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
15
- }
16
- const projectId = params.commandArgs[1];
17
- const repoPath = params.commandArgs[2];
18
- if (!projectId || !repoPath) {
19
- throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
20
- }
21
- const result = await upsertProjectInConfig({
22
- id: projectId,
23
- repoPath,
24
- issueKeyPrefixes: parseCsvFlag(params.parsed.flags.get("issue-prefix")),
25
- linearTeamIds: parseCsvFlag(params.parsed.flags.get("team-id")),
26
- });
27
- const serviceUnits = await installUserServiceUnits();
28
- const noConnect = params.parsed.flags.get("no-connect") === true;
29
- const lines = [
30
- `Config file: ${result.configPath}`,
31
- `${result.status === "created" ? "Created" : result.status === "updated" ? "Updated" : "Verified"} project ${result.project.id} for ${result.project.repoPath}`,
32
- result.project.issueKeyPrefixes.length > 0 ? `Issue key prefixes: ${result.project.issueKeyPrefixes.join(", ")}` : undefined,
33
- result.project.linearTeamIds.length > 0 ? `Linear team ids: ${result.project.linearTeamIds.join(", ")}` : undefined,
34
- `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
35
- `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
36
- ].filter(Boolean);
37
- let fullConfig;
38
- try {
39
- fullConfig = loadConfig(undefined, { profile: "doctor" });
40
- }
41
- catch (error) {
42
- if (params.json) {
43
- writeOutput(params.stdout, formatJson({
44
- ...result,
45
- serviceUnits,
46
- readiness: {
47
- ok: false,
48
- error: error instanceof Error ? error.message : String(error),
49
- },
50
- connect: {
51
- attempted: false,
52
- skipped: "missing_env",
53
- },
54
- }));
55
- return 0;
56
- }
57
- lines.push(`Linear connect was skipped: ${error instanceof Error ? error.message : String(error)}`);
58
- lines.push("Finish the required env vars and rerun `patchrelay project apply`.");
59
- writeOutput(params.stdout, `${lines.join("\n")}\n`);
45
+ fullConfig = loadConfig(undefined, { profile: "doctor" });
46
+ }
47
+ catch (error) {
48
+ if (params.json) {
49
+ writeOutput(params.stdout, formatJson({
50
+ ...result,
51
+ serviceUnits,
52
+ readiness: {
53
+ ok: false,
54
+ error: error instanceof Error ? error.message : String(error),
55
+ },
56
+ connect: {
57
+ attempted: false,
58
+ skipped: "missing_env",
59
+ },
60
+ }));
60
61
  return 0;
61
62
  }
62
- const report = await runPreflight(fullConfig);
63
- const failedChecks = report.checks.filter((check) => check.status === "fail");
64
- if (failedChecks.length > 0) {
65
- if (params.json) {
66
- writeOutput(params.stdout, formatJson({
67
- ...result,
68
- serviceUnits,
69
- readiness: report,
70
- connect: {
71
- attempted: false,
72
- skipped: "preflight_failed",
73
- },
74
- }));
75
- return 0;
76
- }
77
- lines.push("Linear connect was skipped because PatchRelay is not ready yet:");
78
- lines.push(...failedChecks.map((check) => `- [${check.scope}] ${check.message}`));
79
- lines.push("Fix the failures above and rerun `patchrelay project apply`.");
80
- writeOutput(params.stdout, `${lines.join("\n")}\n`);
63
+ lines.push(`Linear connect was skipped: ${error instanceof Error ? error.message : String(error)}`);
64
+ lines.push("Finish the required env vars and rerun `patchrelay project apply`.");
65
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
66
+ return 0;
67
+ }
68
+ const { runPreflight } = await import("../../preflight.js");
69
+ const report = await runPreflight(fullConfig);
70
+ const failedChecks = report.checks.filter((check) => check.status === "fail");
71
+ if (failedChecks.length > 0) {
72
+ if (params.json) {
73
+ writeOutput(params.stdout, formatJson({
74
+ ...result,
75
+ serviceUnits,
76
+ readiness: report,
77
+ connect: {
78
+ attempted: false,
79
+ skipped: "preflight_failed",
80
+ },
81
+ }));
81
82
  return 0;
82
83
  }
83
- const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
84
- if (!serviceState.ok) {
85
- throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
84
+ lines.push("Linear connect was skipped because PatchRelay is not ready yet:");
85
+ lines.push(...failedChecks.map((check) => `- [${check.scope}] ${check.message}`));
86
+ lines.push("Fix the failures above and rerun `patchrelay project apply`.");
87
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
88
+ return 0;
89
+ }
90
+ const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
91
+ if (!serviceState.ok) {
92
+ throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
93
+ }
94
+ const cliData = params.options?.data ?? (await createCliOperatorDataAccess(fullConfig));
95
+ try {
96
+ if (params.json) {
97
+ const connectResult = noConnect ? undefined : await cliData.connect(projectId);
98
+ writeOutput(params.stdout, formatJson({
99
+ ...result,
100
+ serviceUnits,
101
+ readiness: report,
102
+ serviceReloaded: true,
103
+ ...(noConnect
104
+ ? {
105
+ connect: {
106
+ attempted: false,
107
+ skipped: "no_connect",
108
+ },
109
+ }
110
+ : {
111
+ connect: {
112
+ attempted: true,
113
+ result: connectResult,
114
+ },
115
+ }),
116
+ }));
117
+ return 0;
86
118
  }
87
- const cliData = params.options?.data ?? new CliDataAccess(fullConfig);
88
- try {
89
- if (params.json) {
90
- const connectResult = noConnect ? undefined : await cliData.connect(projectId);
91
- writeOutput(params.stdout, formatJson({
92
- ...result,
93
- serviceUnits,
94
- readiness: report,
95
- serviceReloaded: true,
96
- ...(noConnect
97
- ? {
98
- connect: {
99
- attempted: false,
100
- skipped: "no_connect",
101
- },
102
- }
103
- : {
104
- connect: {
105
- attempted: true,
106
- result: connectResult,
107
- },
108
- }),
109
- }));
110
- return 0;
111
- }
112
- if (noConnect) {
113
- lines.push("Project saved and PatchRelay was reloaded.");
114
- lines.push(`Next: patchrelay connect --project ${result.project.id}`);
115
- writeOutput(params.stdout, `${lines.join("\n")}\n`);
116
- return 0;
117
- }
119
+ if (noConnect) {
120
+ lines.push("Project saved and PatchRelay was reloaded.");
121
+ lines.push(`Next: patchrelay connect --project ${result.project.id}`);
118
122
  writeOutput(params.stdout, `${lines.join("\n")}\n`);
119
- return await runConnectFlow({
120
- config: fullConfig,
121
- data: cliData,
122
- stdout: params.stdout,
123
- noOpen: params.parsed.flags.get("no-open") === true,
124
- timeoutSeconds: parseTimeoutSeconds(params.parsed.flags.get("timeout"), "project apply"),
125
- projectId,
126
- ...(params.options?.openExternal ? { openExternal: params.options.openExternal } : {}),
127
- ...(params.options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: params.options.connectPollIntervalMs } : {}),
128
- });
129
- }
130
- finally {
131
- if (!params.options?.data) {
132
- cliData.close();
133
- }
123
+ return 0;
134
124
  }
125
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
126
+ return await runConnectFlow({
127
+ config: fullConfig,
128
+ data: cliData,
129
+ stdout: params.stdout,
130
+ noOpen: params.parsed.flags.get("no-open") === true,
131
+ timeoutSeconds: parseTimeoutSeconds(params.parsed.flags.get("timeout"), "project apply"),
132
+ projectId,
133
+ ...(params.options?.openExternal ? { openExternal: params.options.openExternal } : {}),
134
+ ...(params.options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: params.options.connectPollIntervalMs } : {}),
135
+ });
135
136
  }
136
- catch (error) {
137
- writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
138
- return 1;
137
+ finally {
138
+ if (!params.options?.data) {
139
+ cliData.close();
140
+ }
139
141
  }
140
142
  }
143
+ async function createCliOperatorDataAccess(config) {
144
+ const { CliOperatorApiClient } = await import("../operator-client.js");
145
+ return new CliOperatorApiClient(config);
146
+ }
package/dist/cli/data.js CHANGED
@@ -3,6 +3,7 @@ import pino from "pino";
3
3
  import { CodexAppServerClient } from "../codex-app-server.js";
4
4
  import { PatchRelayDatabase } from "../db.js";
5
5
  import { WorktreeManager } from "../worktree-manager.js";
6
+ import { CliOperatorApiClient } from "./operator-client.js";
6
7
  import { resolveWorkflowStage } from "../workflow-policy.js";
7
8
  function safeJsonParse(value) {
8
9
  if (!value) {
@@ -40,12 +41,13 @@ function resolveStageFromState(config, projectId, stateName) {
40
41
  }
41
42
  return resolveWorkflowStage(project, stateName);
42
43
  }
43
- export class CliDataAccess {
44
+ export class CliDataAccess extends CliOperatorApiClient {
44
45
  config;
45
46
  db;
46
47
  codex;
47
48
  codexStarted = false;
48
49
  constructor(config, options) {
50
+ super(config);
49
51
  this.config = config;
50
52
  this.db = options?.db ?? new PatchRelayDatabase(config.database.path, config.database.wal);
51
53
  this.codex = options?.codex;
@@ -448,138 +450,6 @@ export class CliDataAccess {
448
450
  const worktreeManager = new WorktreeManager(this.config);
449
451
  await worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktree.workspace.worktreePath, worktree.workspace.branchName);
450
452
  }
451
- async connect(projectId) {
452
- return await this.requestJson("/api/oauth/linear/start", {
453
- ...(projectId ? { projectId } : {}),
454
- });
455
- }
456
- async connectStatus(state) {
457
- if (!state) {
458
- throw new Error("OAuth state is required.");
459
- }
460
- return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
461
- }
462
- async listInstallations() {
463
- return await this.requestJson("/api/installations");
464
- }
465
- async listOperatorFeed(options) {
466
- return await this.requestJson("/api/feed", {
467
- ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
468
- ...(options?.issueKey ? { issue: options.issueKey } : {}),
469
- ...(options?.projectId ? { project: options.projectId } : {}),
470
- });
471
- }
472
- async followOperatorFeed(onEvent, options) {
473
- const url = new URL("/api/feed", this.getOperatorBaseUrl());
474
- url.searchParams.set("follow", "1");
475
- if (options?.limit && options.limit > 0) {
476
- url.searchParams.set("limit", String(options.limit));
477
- }
478
- if (options?.issueKey) {
479
- url.searchParams.set("issue", options.issueKey);
480
- }
481
- if (options?.projectId) {
482
- url.searchParams.set("project", options.projectId);
483
- }
484
- const response = await fetch(url, {
485
- method: "GET",
486
- headers: {
487
- accept: "text/event-stream",
488
- ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
489
- },
490
- });
491
- if (!response.ok || !response.body) {
492
- const body = await response.text().catch(() => "");
493
- const message = this.readErrorMessage(body);
494
- throw new Error(message ?? `Request failed: ${response.status}`);
495
- }
496
- const reader = response.body.getReader();
497
- const decoder = new TextDecoder();
498
- let buffer = "";
499
- let dataLines = [];
500
- while (true) {
501
- const { done, value } = await reader.read();
502
- if (done) {
503
- break;
504
- }
505
- buffer += decoder.decode(value, { stream: true });
506
- let newlineIndex = buffer.indexOf("\n");
507
- while (newlineIndex !== -1) {
508
- const rawLine = buffer.slice(0, newlineIndex);
509
- buffer = buffer.slice(newlineIndex + 1);
510
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
511
- if (!line) {
512
- if (dataLines.length > 0) {
513
- const parsed = JSON.parse(dataLines.join("\n"));
514
- onEvent(parsed);
515
- dataLines = [];
516
- }
517
- newlineIndex = buffer.indexOf("\n");
518
- continue;
519
- }
520
- if (line.startsWith(":")) {
521
- newlineIndex = buffer.indexOf("\n");
522
- continue;
523
- }
524
- if (line.startsWith("data:")) {
525
- dataLines.push(line.slice(5).trimStart());
526
- }
527
- newlineIndex = buffer.indexOf("\n");
528
- }
529
- }
530
- }
531
- getOperatorBaseUrl() {
532
- const host = this.normalizeLocalHost(this.config.server.bind);
533
- return `http://${host}:${this.config.server.port}/`;
534
- }
535
- normalizeLocalHost(bind) {
536
- if (bind === "0.0.0.0") {
537
- return "127.0.0.1";
538
- }
539
- if (bind === "::") {
540
- return "[::1]";
541
- }
542
- if (bind.includes(":") && !bind.startsWith("[")) {
543
- return `[${bind}]`;
544
- }
545
- return bind;
546
- }
547
- async requestJson(pathname, query, init) {
548
- const url = new URL(pathname, this.getOperatorBaseUrl());
549
- for (const [key, value] of Object.entries(query ?? {})) {
550
- if (value) {
551
- url.searchParams.set(key, value);
552
- }
553
- }
554
- const response = await fetch(url, {
555
- method: init?.method ?? "GET",
556
- headers: {
557
- accept: "application/json",
558
- ...(init?.body !== undefined ? { "content-type": "application/json" } : {}),
559
- ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
560
- },
561
- ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
562
- });
563
- const body = await response.text();
564
- if (!response.ok) {
565
- const message = this.readErrorMessage(body);
566
- throw new Error(message ?? `Request failed: ${response.status}`);
567
- }
568
- const parsed = JSON.parse(body);
569
- if (parsed.ok === false) {
570
- throw new Error(this.readErrorMessage(body) ?? "Request failed.");
571
- }
572
- return parsed;
573
- }
574
- readErrorMessage(body) {
575
- try {
576
- const parsed = JSON.parse(body);
577
- return parsed.message ?? parsed.reason;
578
- }
579
- catch {
580
- return undefined;
581
- }
582
- }
583
453
  async readLiveSummary(threadId, latestTimestampSeen) {
584
454
  const codex = await this.getCodex();
585
455
  const thread = await codex.readThread(threadId, true);
@@ -0,0 +1,20 @@
1
+ export class CliUsageError extends Error {
2
+ helpTopic;
3
+ constructor(message, helpTopic = "root") {
4
+ super(message);
5
+ this.helpTopic = helpTopic;
6
+ this.name = "CliUsageError";
7
+ }
8
+ }
9
+ export class UnknownCommandError extends CliUsageError {
10
+ constructor(command) {
11
+ super(`Unknown command: ${command}`);
12
+ this.name = "UnknownCommandError";
13
+ }
14
+ }
15
+ export class UnknownFlagsError extends CliUsageError {
16
+ constructor(flags, helpTopic = "root") {
17
+ super(`Unknown flag${flags.length === 1 ? "" : "s"}: ${flags.map((flag) => `--${flag}`).join(", ")}`, helpTopic);
18
+ this.name = "UnknownFlagsError";
19
+ }
20
+ }
@@ -0,0 +1,108 @@
1
+ export function rootHelpText() {
2
+ return [
3
+ "PatchRelay",
4
+ "",
5
+ "patchrelay is a local service and CLI that connects Linear issue delegation to Codex worktrees on your machine.",
6
+ "",
7
+ "Usage:",
8
+ " patchrelay <command> [args] [flags]",
9
+ " patchrelay <issueKey> # shorthand for `patchrelay inspect <issueKey>`",
10
+ "",
11
+ "First-time setup:",
12
+ " 1. patchrelay init <public-https-url>",
13
+ " 2. Fill in ~/.config/patchrelay/service.env",
14
+ " 3. patchrelay project apply <id> <repo-path>",
15
+ " 4. Edit the generated project workflows if needed, then add those workflow files to the repo",
16
+ " 5. patchrelay doctor",
17
+ "",
18
+ "Why init needs the public URL:",
19
+ " Linear must reach PatchRelay at a public HTTPS origin for both the webhook endpoint",
20
+ " and the OAuth callback. `patchrelay init` writes that origin to `server.public_base_url`.",
21
+ "",
22
+ "Default behavior:",
23
+ " PatchRelay already defaults the local bind address, database path, log path, worktree",
24
+ " root, and Codex runner settings. In the normal",
25
+ " case you only need the public URL, the required secrets, and at least one project.",
26
+ " `patchrelay init` installs the user service and config watcher, and `project apply`",
27
+ " upserts the repo config and reuses or starts the Linear connection flow.",
28
+ "",
29
+ "Commands:",
30
+ " version [--json] Show the installed PatchRelay build version",
31
+ " init <public-base-url> [--force] [--json] Bootstrap the machine-level PatchRelay home",
32
+ " project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--no-open] [--timeout <seconds>] [--json]",
33
+ " Upsert one local repository and connect it to Linear when ready",
34
+ " doctor [--json] Check secrets, paths, configured workflow files, git, and codex",
35
+ " install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
36
+ " restart-service [--json] Reload-or-restart the systemd user service",
37
+ " connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
38
+ " Advanced: start or reuse a Linear installation directly",
39
+ " installations [--json] Show connected Linear installations",
40
+ " feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
41
+ " Show a live operator feed from the daemon",
42
+ " serve Run the local PatchRelay service",
43
+ " inspect <issueKey> Show the latest known issue state",
44
+ " live <issueKey> [--watch] [--json] Show the active run status",
45
+ " report <issueKey> [--stage <workflow>] [--stage-run <id>] [--json]",
46
+ " Show finished workflow reports",
47
+ " events <issueKey> [--stage-run <id>] [--method <name>] [--follow] [--json]",
48
+ " Show raw thread events",
49
+ " worktree <issueKey> [--cd] [--json] Print the issue worktree path",
50
+ " open <issueKey> [--print] [--json] Open Codex in the issue worktree",
51
+ " retry <issueKey> [--stage <workflow>] [--reason <text>] [--json]",
52
+ " Requeue a workflow",
53
+ " list [--active] [--failed] [--project <projectId>] [--json]",
54
+ " List tracked issues",
55
+ "",
56
+ "Environment options:",
57
+ " --help, -h Show help for the root command or current command group",
58
+ "",
59
+ "Automation env vars:",
60
+ " PATCHRELAY_CONFIG Override the config file path",
61
+ " PATCHRELAY_DB_PATH Override the SQLite database path",
62
+ " PATCHRELAY_LOG_FILE Override the log file path",
63
+ " PATCHRELAY_LOG_LEVEL Override the log level",
64
+ "",
65
+ "Examples:",
66
+ " patchrelay init https://patchrelay.example.com",
67
+ " patchrelay project apply app /absolute/path/to/repo",
68
+ " patchrelay doctor",
69
+ " patchrelay USE-54",
70
+ " patchrelay version --json",
71
+ "",
72
+ "Command help:",
73
+ " patchrelay help",
74
+ " patchrelay help project",
75
+ " patchrelay project --help",
76
+ ].join("\n");
77
+ }
78
+ export function projectHelpText() {
79
+ return [
80
+ "Usage:",
81
+ " patchrelay project apply <id> <repo-path> [options]",
82
+ "",
83
+ "Commands:",
84
+ " apply <id> <repo-path> Create or update a repository entry in the local PatchRelay config",
85
+ "",
86
+ "Options:",
87
+ " --issue-prefix <prefixes> Comma-separated issue key prefixes for routing",
88
+ " --team-id <ids> Comma-separated Linear team ids for routing",
89
+ " --no-connect Save the project without starting or reusing Linear OAuth",
90
+ " --no-open Do not open the browser during connect",
91
+ " --timeout <seconds> Override the connect wait timeout",
92
+ " --json Emit structured JSON output",
93
+ " --help, -h Show this help",
94
+ "",
95
+ "Behavior:",
96
+ " `patchrelay project apply` is the idempotent happy-path command. It updates",
97
+ " the local config, reruns readiness checks, reloads the service when ready,",
98
+ " and reuses or starts the Linear connect flow unless `--no-connect` is set.",
99
+ "",
100
+ "Examples:",
101
+ " patchrelay project apply app /absolute/path/to/repo",
102
+ " patchrelay project apply app /absolute/path/to/repo --issue-prefix APP",
103
+ " patchrelay project apply app /absolute/path/to/repo --team-id team-123 --no-connect",
104
+ ].join("\n");
105
+ }
106
+ export function helpTextFor(topic) {
107
+ return topic === "project" ? projectHelpText() : rootHelpText();
108
+ }
package/dist/cli/index.js CHANGED
@@ -1,72 +1,16 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { getBuildInfo } from "../build-info.js";
3
- import { runPreflight } from "../preflight.js";
4
- import { assertKnownFlags, parseArgs, resolveCommand } from "./args.js";
3
+ import { assertKnownFlags, hasHelpFlag, parseArgs, resolveCommand } from "./args.js";
5
4
  import { handleConnectCommand, handleInstallationsCommand } from "./commands/connect.js";
6
5
  import { handleFeedCommand } from "./commands/feed.js";
7
6
  import { handleEventsCommand, handleInspectCommand, handleListCommand, handleLiveCommand, handleOpenCommand, handleReportCommand, handleRetryCommand, handleWorktreeCommand, } from "./commands/issues.js";
8
7
  import { handleProjectCommand } from "./commands/project.js";
9
8
  import { handleInitCommand, handleInstallServiceCommand, handleRestartServiceCommand } from "./commands/setup.js";
10
- import { CliDataAccess } from "./data.js";
9
+ import { CliUsageError } from "./errors.js";
11
10
  import { formatJson } from "./formatters/json.js";
11
+ import { helpTextFor, rootHelpText } from "./help.js";
12
12
  import { runInteractiveCommand } from "./interactive.js";
13
- import { formatDoctor, writeOutput } from "./output.js";
14
- function helpText() {
15
- return [
16
- "PatchRelay",
17
- "",
18
- "patchrelay is a local service and CLI that connects Linear issue delegation to Codex worktrees on your machine.",
19
- "",
20
- "Usage:",
21
- " patchrelay <command> [args] [flags]",
22
- " patchrelay <issueKey> # shorthand for `patchrelay inspect <issueKey>`",
23
- "",
24
- "First-time setup:",
25
- " 1. patchrelay init <public-https-url>",
26
- " 2. Fill in ~/.config/patchrelay/service.env",
27
- " 3. patchrelay project apply <id> <repo-path>",
28
- " 4. Edit the generated project workflows if needed, then add those workflow files to the repo",
29
- " 5. patchrelay doctor",
30
- "",
31
- "Why init needs the public URL:",
32
- " Linear must reach PatchRelay at a public HTTPS origin for both the webhook endpoint",
33
- " and the OAuth callback. `patchrelay init` writes that origin to `server.public_base_url`.",
34
- "",
35
- "Default behavior:",
36
- " PatchRelay already defaults the local bind address, database path, log path, worktree",
37
- " root, and Codex runner settings. In the normal",
38
- " case you only need the public URL, the required secrets, and at least one project.",
39
- " `patchrelay init` installs the user service and config watcher, and `project apply`",
40
- " upserts the repo config and reuses or starts the Linear connection flow.",
41
- "",
42
- "Commands:",
43
- " version [--json] Show the installed PatchRelay build version",
44
- " init <public-base-url> [--force] [--json] Bootstrap the machine-level PatchRelay home",
45
- " project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--no-open] [--timeout <seconds>] [--json]",
46
- " Upsert one local repository and connect it to Linear when ready",
47
- " doctor [--json] Check secrets, paths, configured workflow files, git, and codex",
48
- " install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
49
- " restart-service [--json] Reload-or-restart the systemd user service",
50
- " connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
51
- " Advanced: start or reuse a Linear installation directly",
52
- " installations [--json] Show connected Linear installations",
53
- " feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
54
- " Show a live operator feed from the daemon",
55
- " serve Run the local PatchRelay service",
56
- " inspect <issueKey> Show the latest known issue state",
57
- " live <issueKey> [--watch] [--json] Show the active run status",
58
- " report <issueKey> [--stage <workflow>] [--stage-run <id>] [--json]",
59
- " Show finished workflow reports",
60
- " events <issueKey> [--stage-run <id>] [--method <name>] [--follow] [--json]",
61
- " Show raw thread events",
62
- " worktree <issueKey> [--cd] [--json] Print the issue worktree path",
63
- " open <issueKey> [--print] [--json] Open Codex in the issue worktree",
64
- " retry <issueKey> [--stage <workflow>] [--reason <text>] [--json]",
65
- " Requeue a workflow",
66
- " list [--active] [--failed] [--project <projectId>] [--json]",
67
- " List tracked issues",
68
- ].join("\n");
69
- }
13
+ import { formatDoctor, writeOutput, writeUsageError } from "./output.js";
70
14
  function getCommandConfigProfile(command) {
71
15
  switch (command) {
72
16
  case "version":
@@ -168,12 +112,25 @@ export async function runCli(argv, options) {
168
112
  validateFlags(command, commandArgs, parsed);
169
113
  }
170
114
  catch (error) {
115
+ if (error instanceof CliUsageError) {
116
+ writeUsageError(stderr, error);
117
+ return 1;
118
+ }
171
119
  writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
172
120
  return 1;
173
121
  }
174
122
  const json = parsed.flags.get("json") === true;
175
123
  if (command === "help") {
176
- writeOutput(stdout, `${helpText()}\n`);
124
+ const topic = commandArgs[0];
125
+ if (topic === "project") {
126
+ writeOutput(stdout, `${helpTextFor("project")}\n`);
127
+ return 0;
128
+ }
129
+ if (topic) {
130
+ writeUsageError(stderr, new CliUsageError(`Unknown help topic: ${topic}`));
131
+ return 1;
132
+ }
133
+ writeOutput(stdout, `${rootHelpText()}\n`);
177
134
  return 0;
178
135
  }
179
136
  if (command === "version") {
@@ -181,6 +138,10 @@ export async function runCli(argv, options) {
181
138
  writeOutput(stdout, json ? formatJson(buildInfo) : `${buildInfo.version}\n`);
182
139
  return 0;
183
140
  }
141
+ if (hasHelpFlag(parsed)) {
142
+ writeOutput(stdout, `${helpTextFor(command === "project" ? "project" : "root")}\n`);
143
+ return 0;
144
+ }
184
145
  if (command === "serve") {
185
146
  return -1;
186
147
  }
@@ -216,86 +177,226 @@ export async function runCli(argv, options) {
216
177
  });
217
178
  }
218
179
  if (command === "project") {
219
- return await handleProjectCommand({
220
- commandArgs,
221
- parsed,
222
- json,
223
- stdout,
224
- stderr,
225
- runInteractive,
226
- ...(options ? { options } : {}),
227
- });
180
+ try {
181
+ return await handleProjectCommand({
182
+ commandArgs,
183
+ parsed,
184
+ json,
185
+ stdout,
186
+ stderr,
187
+ runInteractive,
188
+ ...(options ? { options } : {}),
189
+ });
190
+ }
191
+ catch (error) {
192
+ if (error instanceof CliUsageError) {
193
+ writeUsageError(stderr, error);
194
+ return 1;
195
+ }
196
+ writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
197
+ return 1;
198
+ }
228
199
  }
229
200
  const config = options?.config ??
230
201
  loadConfig(undefined, {
231
202
  profile: getCommandConfigProfile(command),
232
203
  });
233
204
  let data = options?.data;
205
+ let ownsData = false;
234
206
  try {
235
207
  if (command === "doctor") {
208
+ const { runPreflight } = await import("../preflight.js");
236
209
  const report = await runPreflight(config);
237
210
  writeOutput(stdout, json ? formatJson(report) : formatDoctor(report));
238
211
  return report.ok ? 0 : 1;
239
212
  }
240
- data ??= new CliDataAccess(config);
241
213
  if (command === "inspect") {
242
- return await handleInspectCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
214
+ const issueData = await ensureIssueDataAccess(data, config);
215
+ if (!data) {
216
+ data = issueData;
217
+ ownsData = true;
218
+ }
219
+ return await handleInspectCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
243
220
  }
244
221
  if (command === "live") {
245
- return await handleLiveCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
222
+ const issueData = await ensureIssueDataAccess(data, config);
223
+ if (!data) {
224
+ data = issueData;
225
+ ownsData = true;
226
+ }
227
+ return await handleLiveCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
246
228
  }
247
229
  if (command === "report") {
248
- return await handleReportCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
230
+ const issueData = await ensureIssueDataAccess(data, config);
231
+ if (!data) {
232
+ data = issueData;
233
+ ownsData = true;
234
+ }
235
+ return await handleReportCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
249
236
  }
250
237
  if (command === "events") {
251
- return await handleEventsCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
238
+ const issueData = await ensureIssueDataAccess(data, config);
239
+ if (!data) {
240
+ data = issueData;
241
+ ownsData = true;
242
+ }
243
+ return await handleEventsCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
252
244
  }
253
245
  if (command === "worktree") {
254
- return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
246
+ const issueData = await ensureIssueDataAccess(data, config);
247
+ if (!data) {
248
+ data = issueData;
249
+ ownsData = true;
250
+ }
251
+ return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
255
252
  }
256
253
  if (command === "open") {
257
- return await handleOpenCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
254
+ const issueData = await ensureIssueDataAccess(data, config);
255
+ if (!data) {
256
+ data = issueData;
257
+ ownsData = true;
258
+ }
259
+ return await handleOpenCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
258
260
  }
259
261
  if (command === "connect") {
262
+ const operatorData = await ensureConnectDataAccess(data, config);
263
+ if (!data) {
264
+ data = operatorData;
265
+ ownsData = true;
266
+ }
260
267
  return await handleConnectCommand({
261
268
  parsed,
262
269
  json,
263
270
  stdout,
264
271
  config,
265
- data,
272
+ data: operatorData,
266
273
  ...(options ? { options } : {}),
267
274
  });
268
275
  }
269
276
  if (command === "installations") {
277
+ const operatorData = await ensureInstallationsDataAccess(data, config);
278
+ if (!data) {
279
+ data = operatorData;
280
+ ownsData = true;
281
+ }
270
282
  return await handleInstallationsCommand({
271
283
  json,
272
284
  stdout,
273
- data,
285
+ data: operatorData,
274
286
  });
275
287
  }
276
288
  if (command === "feed") {
289
+ const operatorData = parsed.flags.get("follow") === true
290
+ ? await ensureFeedFollowDataAccess(data, config)
291
+ : await ensureFeedListDataAccess(data, config);
292
+ if (!data) {
293
+ data = operatorData;
294
+ ownsData = true;
295
+ }
277
296
  return await handleFeedCommand({
278
297
  parsed,
279
298
  json,
280
299
  stdout,
281
- data,
300
+ data: operatorData,
282
301
  });
283
302
  }
284
303
  if (command === "retry") {
285
- return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
304
+ const issueData = await ensureIssueDataAccess(data, config);
305
+ if (!data) {
306
+ data = issueData;
307
+ ownsData = true;
308
+ }
309
+ return await handleRetryCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
286
310
  }
287
311
  if (command === "list") {
288
- return await handleListCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
312
+ const issueData = await ensureIssueDataAccess(data, config);
313
+ if (!data) {
314
+ data = issueData;
315
+ ownsData = true;
316
+ }
317
+ return await handleListCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
289
318
  }
290
319
  throw new Error(`Unknown command: ${command}`);
291
320
  }
292
321
  catch (error) {
322
+ if (error instanceof CliUsageError) {
323
+ writeUsageError(stderr, error);
324
+ return 1;
325
+ }
293
326
  writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
294
327
  return 1;
295
328
  }
296
329
  finally {
297
- if (data && !options?.data) {
330
+ if (ownsData && data) {
298
331
  data.close();
299
332
  }
300
333
  }
301
334
  }
335
+ async function createCliDataAccess(config) {
336
+ const { CliDataAccess } = await import("./data.js");
337
+ return new CliDataAccess(config);
338
+ }
339
+ async function createCliOperatorDataAccess(config) {
340
+ const { CliOperatorApiClient } = await import("./operator-client.js");
341
+ return new CliOperatorApiClient(config);
342
+ }
343
+ async function ensureIssueDataAccess(data, config) {
344
+ if (data) {
345
+ if (isIssueDataAccess(data)) {
346
+ return data;
347
+ }
348
+ throw new Error("Issue inspection commands require local SQLite-backed CLI data access.");
349
+ }
350
+ return await createCliDataAccess(config);
351
+ }
352
+ async function ensureConnectDataAccess(data, config) {
353
+ if (data) {
354
+ if (hasConnectDataAccess(data)) {
355
+ return data;
356
+ }
357
+ throw new Error("The connect command requires HTTP-backed OAuth CLI data access.");
358
+ }
359
+ return await createCliOperatorDataAccess(config);
360
+ }
361
+ function isIssueDataAccess(data) {
362
+ return !!data && typeof data === "object" && "inspect" in data && typeof data.inspect === "function";
363
+ }
364
+ async function ensureInstallationsDataAccess(data, config) {
365
+ if (data) {
366
+ if (hasInstallationsDataAccess(data)) {
367
+ return data;
368
+ }
369
+ throw new Error("The installations command requires HTTP-backed installation data access.");
370
+ }
371
+ return await createCliOperatorDataAccess(config);
372
+ }
373
+ async function ensureFeedListDataAccess(data, config) {
374
+ if (data) {
375
+ if (hasFeedListDataAccess(data)) {
376
+ return data;
377
+ }
378
+ throw new Error("The feed command requires listOperatorFeed() data access.");
379
+ }
380
+ return await createCliOperatorDataAccess(config);
381
+ }
382
+ function hasConnectDataAccess(data) {
383
+ return !!data && typeof data === "object" && "connect" in data && typeof data.connect === "function";
384
+ }
385
+ function hasInstallationsDataAccess(data) {
386
+ return !!data && typeof data === "object" && "listInstallations" in data && typeof data.listInstallations === "function";
387
+ }
388
+ async function ensureFeedFollowDataAccess(data, config) {
389
+ if (data) {
390
+ if (hasFeedFollowDataAccess(data)) {
391
+ return data;
392
+ }
393
+ throw new Error("The feed --follow command requires followOperatorFeed() data access.");
394
+ }
395
+ return await createCliOperatorDataAccess(config);
396
+ }
397
+ function hasFeedListDataAccess(data) {
398
+ return !!data && typeof data === "object" && "listOperatorFeed" in data && typeof data.listOperatorFeed === "function";
399
+ }
400
+ function hasFeedFollowDataAccess(data) {
401
+ return !!data && typeof data === "object" && "followOperatorFeed" in data && typeof data.followOperatorFeed === "function";
402
+ }
@@ -0,0 +1,140 @@
1
+ export class CliOperatorApiClient {
2
+ config;
3
+ constructor(config) {
4
+ this.config = config;
5
+ }
6
+ close() { }
7
+ async connect(projectId) {
8
+ return await this.requestJson("/api/oauth/linear/start", {
9
+ ...(projectId ? { projectId } : {}),
10
+ });
11
+ }
12
+ async connectStatus(state) {
13
+ if (!state) {
14
+ throw new Error("OAuth state is required.");
15
+ }
16
+ return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
17
+ }
18
+ async listInstallations() {
19
+ return await this.requestJson("/api/installations");
20
+ }
21
+ async listOperatorFeed(options) {
22
+ return await this.requestJson("/api/feed", {
23
+ ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
24
+ ...(options?.issueKey ? { issue: options.issueKey } : {}),
25
+ ...(options?.projectId ? { project: options.projectId } : {}),
26
+ });
27
+ }
28
+ async followOperatorFeed(onEvent, options) {
29
+ const url = new URL("/api/feed", this.getOperatorBaseUrl());
30
+ url.searchParams.set("follow", "1");
31
+ if (options?.limit && options.limit > 0) {
32
+ url.searchParams.set("limit", String(options.limit));
33
+ }
34
+ if (options?.issueKey) {
35
+ url.searchParams.set("issue", options.issueKey);
36
+ }
37
+ if (options?.projectId) {
38
+ url.searchParams.set("project", options.projectId);
39
+ }
40
+ const response = await fetch(url, {
41
+ method: "GET",
42
+ headers: {
43
+ accept: "text/event-stream",
44
+ ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
45
+ },
46
+ });
47
+ if (!response.ok || !response.body) {
48
+ const body = await response.text().catch(() => "");
49
+ const message = this.readErrorMessage(body);
50
+ throw new Error(message ?? `Request failed: ${response.status}`);
51
+ }
52
+ const reader = response.body.getReader();
53
+ const decoder = new TextDecoder();
54
+ let buffer = "";
55
+ let dataLines = [];
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done) {
59
+ break;
60
+ }
61
+ buffer += decoder.decode(value, { stream: true });
62
+ let newlineIndex = buffer.indexOf("\n");
63
+ while (newlineIndex !== -1) {
64
+ const rawLine = buffer.slice(0, newlineIndex);
65
+ buffer = buffer.slice(newlineIndex + 1);
66
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
67
+ if (!line) {
68
+ if (dataLines.length > 0) {
69
+ const parsed = JSON.parse(dataLines.join("\n"));
70
+ onEvent(parsed);
71
+ dataLines = [];
72
+ }
73
+ newlineIndex = buffer.indexOf("\n");
74
+ continue;
75
+ }
76
+ if (line.startsWith(":")) {
77
+ newlineIndex = buffer.indexOf("\n");
78
+ continue;
79
+ }
80
+ if (line.startsWith("data:")) {
81
+ dataLines.push(line.slice(5).trimStart());
82
+ }
83
+ newlineIndex = buffer.indexOf("\n");
84
+ }
85
+ }
86
+ }
87
+ getOperatorBaseUrl() {
88
+ const host = this.normalizeLocalHost(this.config.server.bind);
89
+ return `http://${host}:${this.config.server.port}/`;
90
+ }
91
+ normalizeLocalHost(bind) {
92
+ if (bind === "0.0.0.0") {
93
+ return "127.0.0.1";
94
+ }
95
+ if (bind === "::") {
96
+ return "[::1]";
97
+ }
98
+ if (bind.includes(":") && !bind.startsWith("[")) {
99
+ return `[${bind}]`;
100
+ }
101
+ return bind;
102
+ }
103
+ async requestJson(pathname, query, init) {
104
+ const url = new URL(pathname, this.getOperatorBaseUrl());
105
+ for (const [key, value] of Object.entries(query ?? {})) {
106
+ if (value) {
107
+ url.searchParams.set(key, value);
108
+ }
109
+ }
110
+ const response = await fetch(url, {
111
+ method: init?.method ?? "GET",
112
+ headers: {
113
+ accept: "application/json",
114
+ connection: "close",
115
+ ...(init?.body !== undefined ? { "content-type": "application/json" } : {}),
116
+ ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
117
+ },
118
+ ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
119
+ });
120
+ const body = await response.text();
121
+ if (!response.ok) {
122
+ const message = this.readErrorMessage(body);
123
+ throw new Error(message ?? `Request failed: ${response.status}`);
124
+ }
125
+ const parsed = JSON.parse(body);
126
+ if (parsed.ok === false) {
127
+ throw new Error(this.readErrorMessage(body) ?? "Request failed.");
128
+ }
129
+ return parsed;
130
+ }
131
+ readErrorMessage(body) {
132
+ try {
133
+ const parsed = JSON.parse(body);
134
+ return parsed.message ?? parsed.reason;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ }
@@ -1,6 +1,10 @@
1
+ import { helpTextFor } from "./help.js";
1
2
  export function writeOutput(stream, text) {
2
3
  stream.write(text);
3
4
  }
5
+ export function writeUsageError(stream, error) {
6
+ writeOutput(stream, `${helpTextFor(error.helpTopic)}\n\nError: ${error.message}\n`);
7
+ }
4
8
  export function formatDoctor(report) {
5
9
  const lines = ["PatchRelay doctor", ""];
6
10
  for (const check of report.checks) {
package/dist/index.js CHANGED
@@ -1,22 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname } from "node:path";
3
3
  import { runCli } from "./cli/index.js";
4
- import { CodexAppServerClient } from "./codex-app-server.js";
5
- import { getAdjacentEnvFilePaths, loadConfig } from "./config.js";
6
- import { PatchRelayDatabase } from "./db.js";
7
- import { enforceRuntimeFilePermissions, enforceServiceEnvPermissions } from "./file-permissions.js";
8
- import { buildHttpServer } from "./http.js";
9
- import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
10
- import { createLogger } from "./logging.js";
11
- import { runPreflight } from "./preflight.js";
12
- import { PatchRelayService } from "./service.js";
13
- import { ensureDir } from "./utils.js";
14
4
  async function main() {
15
5
  const cliExitCode = await runCli(process.argv.slice(2));
16
6
  if (cliExitCode !== -1) {
17
7
  process.exitCode = cliExitCode;
18
8
  return;
19
9
  }
10
+ const [{ CodexAppServerClient }, { getAdjacentEnvFilePaths, loadConfig }, { PatchRelayDatabase }, { enforceRuntimeFilePermissions, enforceServiceEnvPermissions }, { buildHttpServer }, { DatabaseBackedLinearClientProvider }, { createLogger }, { runPreflight }, { PatchRelayService }, { ensureDir },] = await Promise.all([
11
+ import("./codex-app-server.js"),
12
+ import("./config.js"),
13
+ import("./db.js"),
14
+ import("./file-permissions.js"),
15
+ import("./http.js"),
16
+ import("./linear-client.js"),
17
+ import("./logging.js"),
18
+ import("./preflight.js"),
19
+ import("./service.js"),
20
+ import("./utils.js"),
21
+ ]);
20
22
  const configPath = process.env.PATCHRELAY_CONFIG;
21
23
  const config = loadConfig(configPath);
22
24
  await enforceServiceEnvPermissions(getAdjacentEnvFilePaths(configPath).serviceEnvPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {