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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +11 -3
- package/dist/cli/commands/project.js +128 -122
- package/dist/cli/data.js +3 -133
- package/dist/cli/errors.js +20 -0
- package/dist/cli/help.js +108 -0
- package/dist/cli/index.js +184 -83
- package/dist/cli/operator-client.js +140 -0
- package/dist/cli/output.js +4 -0
- package/dist/index.js +12 -10
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
|
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
|
|
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 {
|
|
4
|
-
import { parseCsvFlag } from "../args.js";
|
|
3
|
+
import { hasHelpFlag, parseCsvFlag } from "../args.js";
|
|
5
4
|
import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
|
|
6
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
package/dist/cli/help.js
ADDED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|
package/dist/cli/output.js
CHANGED
|
@@ -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);
|