patchrelay 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- package/service.env.example +7 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { initializePatchRelayHome, installUserServiceUnits, upsertProjectInConfig } from "../install.js";
|
|
5
|
+
import { runPreflight } from "../preflight.js";
|
|
6
|
+
import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, } from "../runtime-paths.js";
|
|
7
|
+
import { CliDataAccess } from "./data.js";
|
|
8
|
+
import { formatJson } from "./formatters/json.js";
|
|
9
|
+
import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "./formatters/text.js";
|
|
10
|
+
const KNOWN_COMMANDS = new Set([
|
|
11
|
+
"serve",
|
|
12
|
+
"inspect",
|
|
13
|
+
"live",
|
|
14
|
+
"report",
|
|
15
|
+
"events",
|
|
16
|
+
"worktree",
|
|
17
|
+
"open",
|
|
18
|
+
"retry",
|
|
19
|
+
"list",
|
|
20
|
+
"doctor",
|
|
21
|
+
"init",
|
|
22
|
+
"project",
|
|
23
|
+
"connect",
|
|
24
|
+
"installations",
|
|
25
|
+
"install-service",
|
|
26
|
+
"restart-service",
|
|
27
|
+
"help",
|
|
28
|
+
]);
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const positionals = [];
|
|
31
|
+
const flags = new Map();
|
|
32
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
33
|
+
const value = argv[index];
|
|
34
|
+
if (!value.startsWith("--")) {
|
|
35
|
+
positionals.push(value);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const trimmed = value.slice(2);
|
|
39
|
+
const [name, inline] = trimmed.split("=", 2);
|
|
40
|
+
if (!name) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (inline !== undefined) {
|
|
44
|
+
flags.set(name, inline);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const next = argv[index + 1];
|
|
48
|
+
if (next && !next.startsWith("--")) {
|
|
49
|
+
flags.set(name, next);
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
flags.set(name, true);
|
|
54
|
+
}
|
|
55
|
+
return { positionals, flags };
|
|
56
|
+
}
|
|
57
|
+
function helpText() {
|
|
58
|
+
return [
|
|
59
|
+
"PatchRelay",
|
|
60
|
+
"",
|
|
61
|
+
"patchrelay is a local service and CLI that connects Linear issue delegation to Codex worktrees on your machine.",
|
|
62
|
+
"",
|
|
63
|
+
"Usage:",
|
|
64
|
+
" patchrelay <command> [args] [flags]",
|
|
65
|
+
" patchrelay <issueKey> # shorthand for `patchrelay inspect <issueKey>`",
|
|
66
|
+
"",
|
|
67
|
+
"First-time setup:",
|
|
68
|
+
" 1. patchrelay init <public-https-url>",
|
|
69
|
+
" 2. Fill in ~/.config/patchrelay/service.env",
|
|
70
|
+
" 3. patchrelay project apply <id> <repo-path>",
|
|
71
|
+
" 4. Edit the generated project workflows if needed, then add those workflow files to the repo",
|
|
72
|
+
" 5. patchrelay doctor",
|
|
73
|
+
"",
|
|
74
|
+
"Why init needs the public URL:",
|
|
75
|
+
" Linear must reach PatchRelay at a public HTTPS origin for both the webhook endpoint",
|
|
76
|
+
" and the OAuth callback. `patchrelay init` writes that origin to `server.public_base_url`.",
|
|
77
|
+
"",
|
|
78
|
+
"Default behavior:",
|
|
79
|
+
" PatchRelay already defaults the local bind address, database path, log path, worktree",
|
|
80
|
+
" root, and Codex runner settings. In the normal",
|
|
81
|
+
" case you only need the public URL, the required secrets, and at least one project.",
|
|
82
|
+
" `patchrelay init` installs the user service and config watcher, and `project apply`",
|
|
83
|
+
" upserts the repo config and reuses or starts the Linear connection flow.",
|
|
84
|
+
"",
|
|
85
|
+
"Commands:",
|
|
86
|
+
" init <public-base-url> [--force] [--json] Bootstrap the machine-level PatchRelay home",
|
|
87
|
+
" project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>] [--json]",
|
|
88
|
+
" Upsert one local repository and connect it to Linear when ready",
|
|
89
|
+
" doctor [--json] Check secrets, paths, configured workflow files, git, and codex",
|
|
90
|
+
" install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
|
|
91
|
+
" restart-service [--json] Reload-or-restart the systemd user service",
|
|
92
|
+
" connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
|
|
93
|
+
" Advanced: start or reuse a Linear installation directly",
|
|
94
|
+
" installations [--json] Show connected Linear installations",
|
|
95
|
+
" serve Run the local PatchRelay service",
|
|
96
|
+
" inspect <issueKey> Show the latest known issue state",
|
|
97
|
+
" live <issueKey> [--watch] [--json] Show the active run status",
|
|
98
|
+
" report <issueKey> [--stage <workflow>] [--stage-run <id>] [--json]",
|
|
99
|
+
" Show finished workflow reports",
|
|
100
|
+
" events <issueKey> [--stage-run <id>] [--method <name>] [--follow] [--json]",
|
|
101
|
+
" Show raw thread events",
|
|
102
|
+
" worktree <issueKey> [--cd] [--json] Print the issue worktree path",
|
|
103
|
+
" open <issueKey> [--print] [--json] Open Codex in the issue worktree",
|
|
104
|
+
" retry <issueKey> [--stage <workflow>] [--reason <text>] [--json]",
|
|
105
|
+
" Requeue a workflow",
|
|
106
|
+
" list [--active] [--failed] [--project <projectId>] [--json]",
|
|
107
|
+
" List tracked issues",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
function normalizePublicBaseUrl(value) {
|
|
111
|
+
const candidate = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) ? value : `https://${value}`;
|
|
112
|
+
const url = new URL(candidate);
|
|
113
|
+
return url.origin;
|
|
114
|
+
}
|
|
115
|
+
function getStageFlag(value) {
|
|
116
|
+
if (typeof value !== "string") {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
return trimmed || undefined;
|
|
121
|
+
}
|
|
122
|
+
function parseCsvFlag(value) {
|
|
123
|
+
if (typeof value !== "string") {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
return value
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((entry) => entry.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
function writeOutput(stream, text) {
|
|
132
|
+
stream.write(text);
|
|
133
|
+
}
|
|
134
|
+
function formatDoctor(report) {
|
|
135
|
+
const lines = ["PatchRelay doctor", ""];
|
|
136
|
+
for (const check of report.checks) {
|
|
137
|
+
const marker = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
|
|
138
|
+
lines.push(`${marker} [${check.scope}] ${check.message}`);
|
|
139
|
+
}
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push(report.ok ? "Doctor result: ready" : "Doctor result: not ready");
|
|
142
|
+
return `${lines.join("\n")}\n`;
|
|
143
|
+
}
|
|
144
|
+
function buildOpenCommand(config, worktreePath, resumeThreadId) {
|
|
145
|
+
const args = ["--dangerously-bypass-approvals-and-sandbox"];
|
|
146
|
+
if (resumeThreadId) {
|
|
147
|
+
args.push("resume", "-C", worktreePath, resumeThreadId);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
args.push("-C", worktreePath);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
command: config.runner.codex.bin,
|
|
154
|
+
args,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
async function runInteractiveCommand(command, args) {
|
|
158
|
+
return await new Promise((resolve, reject) => {
|
|
159
|
+
const child = spawn(command, args, {
|
|
160
|
+
stdio: "inherit",
|
|
161
|
+
});
|
|
162
|
+
child.on("error", reject);
|
|
163
|
+
child.on("exit", (code, signal) => {
|
|
164
|
+
if (signal) {
|
|
165
|
+
resolve(1);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
resolve(code ?? 0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function openExternalUrl(url) {
|
|
173
|
+
const candidates = process.platform === "darwin"
|
|
174
|
+
? [{ command: "open", args: [url] }]
|
|
175
|
+
: process.platform === "win32"
|
|
176
|
+
? [{ command: "cmd", args: ["/c", "start", "", url] }]
|
|
177
|
+
: [{ command: "xdg-open", args: [url] }];
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
try {
|
|
180
|
+
const exitCode = await runInteractiveCommand(candidate.command, candidate.args);
|
|
181
|
+
if (exitCode === 0) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Try the next opener.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
async function runServiceCommands(runner, commands) {
|
|
192
|
+
for (const entry of commands) {
|
|
193
|
+
const exitCode = await runner(entry.command, entry.args);
|
|
194
|
+
if (exitCode !== 0) {
|
|
195
|
+
throw new Error(`Command failed with exit code ${exitCode}: ${entry.command} ${entry.args.join(" ")}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function parseTimeoutSeconds(value, command) {
|
|
200
|
+
const timeoutSeconds = typeof value === "string" ? Number(value) : 180;
|
|
201
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
202
|
+
throw new Error(`${command} --timeout must be a positive number of seconds.`);
|
|
203
|
+
}
|
|
204
|
+
return timeoutSeconds;
|
|
205
|
+
}
|
|
206
|
+
async function runConnectFlow(params) {
|
|
207
|
+
const result = await params.data.connect(params.projectId);
|
|
208
|
+
if (params.json) {
|
|
209
|
+
writeOutput(params.stdout, formatJson(result));
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
if ("completed" in result && result.completed) {
|
|
213
|
+
const label = result.installation.workspaceName ?? result.installation.actorName ?? `installation #${result.installation.id}`;
|
|
214
|
+
writeOutput(params.stdout, `Linked project ${result.projectId} to existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
if ("completed" in result) {
|
|
218
|
+
throw new Error("Unexpected completed connect result.");
|
|
219
|
+
}
|
|
220
|
+
const opener = params.openExternal ?? openExternalUrl;
|
|
221
|
+
const opened = params.noOpen ? false : await opener(result.authorizeUrl);
|
|
222
|
+
writeOutput(params.stdout, `${result.projectId ? `Project: ${result.projectId}\n` : ""}${opened ? "Opened browser for Linear OAuth.\n" : "Open this URL in a browser:\n"}${opened ? result.authorizeUrl : `${result.authorizeUrl}\n`}Waiting for OAuth approval...\n`);
|
|
223
|
+
const deadline = Date.now() + (params.timeoutSeconds ?? 180) * 1000;
|
|
224
|
+
const pollIntervalMs = params.connectPollIntervalMs ?? 1000;
|
|
225
|
+
do {
|
|
226
|
+
const status = await params.data.connectStatus(result.state);
|
|
227
|
+
if (status.status === "completed") {
|
|
228
|
+
const label = status.installation?.workspaceName ?? status.installation?.actorName ?? `installation #${status.installation?.id ?? "unknown"}`;
|
|
229
|
+
writeOutput(params.stdout, [
|
|
230
|
+
`Connected ${label}${status.projectId ? ` for project ${status.projectId}` : ""}.${status.installation?.id ? ` Installation ${status.installation.id}.` : ""}`,
|
|
231
|
+
params.config.linear.oauth.actor === "app"
|
|
232
|
+
? "If your Linear OAuth app webhook settings are configured, Linear has now provisioned the workspace webhook automatically."
|
|
233
|
+
: undefined,
|
|
234
|
+
]
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.join("\n") + "\n");
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
if (status.status === "failed") {
|
|
240
|
+
throw new Error(status.errorMessage ?? "Linear OAuth failed.");
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() >= deadline) {
|
|
243
|
+
throw new Error(`Timed out waiting for Linear OAuth after ${params.timeoutSeconds ?? 180} seconds.`);
|
|
244
|
+
}
|
|
245
|
+
await delay(pollIntervalMs);
|
|
246
|
+
} while (true);
|
|
247
|
+
}
|
|
248
|
+
async function tryManageService(runner, commands) {
|
|
249
|
+
try {
|
|
250
|
+
await runServiceCommands(runner, commands);
|
|
251
|
+
return { ok: true };
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function installServiceCommands() {
|
|
258
|
+
return [
|
|
259
|
+
{ command: "systemctl", args: ["--user", "daemon-reload"] },
|
|
260
|
+
{ command: "systemctl", args: ["--user", "enable", "--now", "patchrelay.path"] },
|
|
261
|
+
{ command: "systemctl", args: ["--user", "enable", "patchrelay.service"] },
|
|
262
|
+
{ command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
function restartServiceCommands() {
|
|
266
|
+
return [
|
|
267
|
+
{ command: "systemctl", args: ["--user", "daemon-reload"] },
|
|
268
|
+
{ command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
function getCommandConfigProfile(command) {
|
|
272
|
+
switch (command) {
|
|
273
|
+
case "doctor":
|
|
274
|
+
case "install-service":
|
|
275
|
+
return "doctor";
|
|
276
|
+
case "connect":
|
|
277
|
+
case "installations":
|
|
278
|
+
return "operator_cli";
|
|
279
|
+
case "inspect":
|
|
280
|
+
case "live":
|
|
281
|
+
case "report":
|
|
282
|
+
case "events":
|
|
283
|
+
case "worktree":
|
|
284
|
+
case "open":
|
|
285
|
+
case "retry":
|
|
286
|
+
case "list":
|
|
287
|
+
return "cli";
|
|
288
|
+
default:
|
|
289
|
+
return "service";
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
export async function runCli(argv, options) {
|
|
293
|
+
const stdout = options?.stdout ?? process.stdout;
|
|
294
|
+
const stderr = options?.stderr ?? process.stderr;
|
|
295
|
+
const parsed = parseArgs(argv);
|
|
296
|
+
const requestedCommand = parsed.positionals[0];
|
|
297
|
+
const command = !requestedCommand
|
|
298
|
+
? "help"
|
|
299
|
+
: KNOWN_COMMANDS.has(requestedCommand)
|
|
300
|
+
? requestedCommand
|
|
301
|
+
: "inspect";
|
|
302
|
+
const commandArgs = command === requestedCommand ? parsed.positionals.slice(1) : parsed.positionals;
|
|
303
|
+
if (command === "help") {
|
|
304
|
+
writeOutput(stdout, `${helpText()}\n`);
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
if (command === "serve") {
|
|
308
|
+
return -1;
|
|
309
|
+
}
|
|
310
|
+
const runInteractive = options?.runInteractive ?? runInteractiveCommand;
|
|
311
|
+
const json = parsed.flags.get("json") === true;
|
|
312
|
+
if (command === "init") {
|
|
313
|
+
try {
|
|
314
|
+
const requestedPublicBaseUrl = typeof parsed.flags.get("public-base-url") === "string"
|
|
315
|
+
? String(parsed.flags.get("public-base-url"))
|
|
316
|
+
: commandArgs[0];
|
|
317
|
+
if (!requestedPublicBaseUrl) {
|
|
318
|
+
throw new Error([
|
|
319
|
+
"patchrelay init requires <public-base-url>.",
|
|
320
|
+
"PatchRelay must know the public HTTPS origin that Linear will call for the webhook and OAuth callback.",
|
|
321
|
+
"Example: patchrelay init https://patchrelay.example.com",
|
|
322
|
+
].join("\n"));
|
|
323
|
+
}
|
|
324
|
+
const publicBaseUrl = normalizePublicBaseUrl(requestedPublicBaseUrl);
|
|
325
|
+
const result = await initializePatchRelayHome({
|
|
326
|
+
force: parsed.flags.get("force") === true,
|
|
327
|
+
publicBaseUrl,
|
|
328
|
+
});
|
|
329
|
+
const serviceUnits = await installUserServiceUnits({ force: parsed.flags.get("force") === true });
|
|
330
|
+
const serviceState = await tryManageService(runInteractive, installServiceCommands());
|
|
331
|
+
writeOutput(stdout, json
|
|
332
|
+
? formatJson({ ...result, serviceUnits, serviceState })
|
|
333
|
+
: [
|
|
334
|
+
`Config directory: ${result.configDir}`,
|
|
335
|
+
`Runtime env: ${result.runtimeEnvPath} (${result.runtimeEnvStatus})`,
|
|
336
|
+
`Service env: ${result.serviceEnvPath} (${result.serviceEnvStatus})`,
|
|
337
|
+
`Config file: ${result.configPath} (${result.configStatus})`,
|
|
338
|
+
`State directory: ${result.stateDir}`,
|
|
339
|
+
`Data directory: ${result.dataDir}`,
|
|
340
|
+
`Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
|
|
341
|
+
`Reload unit: ${serviceUnits.reloadUnitPath} (${serviceUnits.reloadStatus})`,
|
|
342
|
+
`Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
|
|
343
|
+
"",
|
|
344
|
+
"PatchRelay public URLs:",
|
|
345
|
+
`- Public base URL: ${result.publicBaseUrl}`,
|
|
346
|
+
`- Webhook URL: ${result.webhookUrl}`,
|
|
347
|
+
`- OAuth callback: ${result.oauthCallbackUrl}`,
|
|
348
|
+
"",
|
|
349
|
+
"Created with defaults:",
|
|
350
|
+
`- Config file contains only machine-level essentials such as server.public_base_url`,
|
|
351
|
+
`- Database, logs, bind address, and worktree roots use built-in defaults`,
|
|
352
|
+
`- The user service and config watcher are installed for you`,
|
|
353
|
+
"",
|
|
354
|
+
"Register the app in Linear:",
|
|
355
|
+
"- Open Linear Settings > API > Applications",
|
|
356
|
+
"- Create an OAuth app for PatchRelay",
|
|
357
|
+
"- Choose actor `app`",
|
|
358
|
+
"- Choose scopes `read`, `write`, `app:assignable`, `app:mentionable`",
|
|
359
|
+
`- Add redirect URI ${result.oauthCallbackUrl}`,
|
|
360
|
+
`- Add webhook URL ${result.webhookUrl}`,
|
|
361
|
+
"- Enable webhook categories for issue events, comment events, agent session events, permission changes, and inbox/app-user notifications",
|
|
362
|
+
"",
|
|
363
|
+
result.configStatus === "skipped"
|
|
364
|
+
? `Config file was skipped, so make sure ${result.configPath} still has server.public_base_url: ${result.publicBaseUrl}`
|
|
365
|
+
: `Config file already includes server.public_base_url: ${result.publicBaseUrl}`,
|
|
366
|
+
"",
|
|
367
|
+
"Service status:",
|
|
368
|
+
serviceState.ok
|
|
369
|
+
? "PatchRelay service and config watcher are installed and reload-or-restart has been requested."
|
|
370
|
+
: `PatchRelay service units were installed, but the service could not be started yet: ${serviceState.error}`,
|
|
371
|
+
!serviceState.ok
|
|
372
|
+
? "This is expected until the required env vars and at least one valid project workflow are in place. The watcher will retry when config or env files change."
|
|
373
|
+
: undefined,
|
|
374
|
+
"",
|
|
375
|
+
"Next steps:",
|
|
376
|
+
`1. Edit ${result.serviceEnvPath}`,
|
|
377
|
+
"2. Paste your Linear OAuth client id and client secret into service.env and keep the generated webhook secret and token encryption key",
|
|
378
|
+
"3. Paste LINEAR_WEBHOOK_SECRET from service.env into the Linear OAuth app webhook signing secret",
|
|
379
|
+
"4. Run `patchrelay project apply <id> <repo-path>`",
|
|
380
|
+
"5. Edit the generated project workflows if you want custom state names or workflow files, then add those workflow files to the repo",
|
|
381
|
+
"6. Run `patchrelay doctor`",
|
|
382
|
+
]
|
|
383
|
+
.filter(Boolean)
|
|
384
|
+
.join("\n") + "\n");
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (command === "install-service") {
|
|
393
|
+
try {
|
|
394
|
+
const result = await installUserServiceUnits({ force: parsed.flags.get("force") === true });
|
|
395
|
+
const writeOnly = parsed.flags.get("write-only") === true;
|
|
396
|
+
if (!writeOnly) {
|
|
397
|
+
await runServiceCommands(runInteractive, installServiceCommands());
|
|
398
|
+
}
|
|
399
|
+
writeOutput(stdout, json
|
|
400
|
+
? formatJson({ ...result, writeOnly })
|
|
401
|
+
: [
|
|
402
|
+
`Service unit: ${result.unitPath} (${result.serviceStatus})`,
|
|
403
|
+
`Reload unit: ${result.reloadUnitPath} (${result.reloadStatus})`,
|
|
404
|
+
`Watcher unit: ${result.pathUnitPath} (${result.pathStatus})`,
|
|
405
|
+
`Runtime env: ${result.runtimeEnvPath}`,
|
|
406
|
+
`Service env: ${result.serviceEnvPath}`,
|
|
407
|
+
`Config file: ${result.configPath}`,
|
|
408
|
+
writeOnly
|
|
409
|
+
? "Service units written. Start them with: systemctl --user daemon-reload && systemctl --user enable --now patchrelay.path && systemctl --user enable patchrelay.service && systemctl --user reload-or-restart patchrelay.service"
|
|
410
|
+
: "PatchRelay user service and config watcher are installed and running.",
|
|
411
|
+
"After package updates, run: patchrelay restart-service",
|
|
412
|
+
].join("\n") + "\n");
|
|
413
|
+
return 0;
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (command === "restart-service") {
|
|
421
|
+
try {
|
|
422
|
+
await runServiceCommands(runInteractive, restartServiceCommands());
|
|
423
|
+
writeOutput(stdout, json
|
|
424
|
+
? formatJson({
|
|
425
|
+
service: "patchrelay",
|
|
426
|
+
unitPath: getSystemdUserUnitPath(),
|
|
427
|
+
reloadUnitPath: getSystemdUserReloadUnitPath(),
|
|
428
|
+
pathUnitPath: getSystemdUserPathUnitPath(),
|
|
429
|
+
runtimeEnvPath: getDefaultRuntimeEnvPath(),
|
|
430
|
+
serviceEnvPath: getDefaultServiceEnvPath(),
|
|
431
|
+
configPath: getDefaultConfigPath(),
|
|
432
|
+
restarted: true,
|
|
433
|
+
})
|
|
434
|
+
: "Reloaded systemd user units and reload-or-restart was requested for PatchRelay.\n");
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (command === "project") {
|
|
443
|
+
try {
|
|
444
|
+
const subcommand = commandArgs[0];
|
|
445
|
+
if (subcommand !== "apply") {
|
|
446
|
+
throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
|
|
447
|
+
}
|
|
448
|
+
const projectId = commandArgs[1];
|
|
449
|
+
const repoPath = commandArgs[2];
|
|
450
|
+
if (!projectId || !repoPath) {
|
|
451
|
+
throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
|
|
452
|
+
}
|
|
453
|
+
const result = await upsertProjectInConfig({
|
|
454
|
+
id: projectId,
|
|
455
|
+
repoPath,
|
|
456
|
+
issueKeyPrefixes: parseCsvFlag(parsed.flags.get("issue-prefix")),
|
|
457
|
+
linearTeamIds: parseCsvFlag(parsed.flags.get("team-id")),
|
|
458
|
+
});
|
|
459
|
+
const serviceUnits = await installUserServiceUnits();
|
|
460
|
+
const noConnect = parsed.flags.get("no-connect") === true;
|
|
461
|
+
const lines = [
|
|
462
|
+
`Config file: ${result.configPath}`,
|
|
463
|
+
`${result.status === "created" ? "Created" : result.status === "updated" ? "Updated" : "Verified"} project ${result.project.id} for ${result.project.repoPath}`,
|
|
464
|
+
result.project.issueKeyPrefixes.length > 0 ? `Issue key prefixes: ${result.project.issueKeyPrefixes.join(", ")}` : undefined,
|
|
465
|
+
result.project.linearTeamIds.length > 0 ? `Linear team ids: ${result.project.linearTeamIds.join(", ")}` : undefined,
|
|
466
|
+
`Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
|
|
467
|
+
`Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
|
|
468
|
+
].filter(Boolean);
|
|
469
|
+
let fullConfig;
|
|
470
|
+
try {
|
|
471
|
+
fullConfig = loadConfig(undefined, { profile: "doctor" });
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (json) {
|
|
475
|
+
writeOutput(stdout, formatJson({
|
|
476
|
+
...result,
|
|
477
|
+
serviceUnits,
|
|
478
|
+
readiness: {
|
|
479
|
+
ok: false,
|
|
480
|
+
error: error instanceof Error ? error.message : String(error),
|
|
481
|
+
},
|
|
482
|
+
connect: {
|
|
483
|
+
attempted: false,
|
|
484
|
+
skipped: "missing_env",
|
|
485
|
+
},
|
|
486
|
+
}));
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
lines.push(`Linear connect was skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
490
|
+
lines.push("Finish the required env vars and rerun `patchrelay project apply`.");
|
|
491
|
+
writeOutput(stdout, `${lines.join("\n")}\n`);
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
const report = await runPreflight(fullConfig);
|
|
495
|
+
const failedChecks = report.checks.filter((check) => check.status === "fail");
|
|
496
|
+
if (failedChecks.length > 0) {
|
|
497
|
+
if (json) {
|
|
498
|
+
writeOutput(stdout, formatJson({
|
|
499
|
+
...result,
|
|
500
|
+
serviceUnits,
|
|
501
|
+
readiness: report,
|
|
502
|
+
connect: {
|
|
503
|
+
attempted: false,
|
|
504
|
+
skipped: "preflight_failed",
|
|
505
|
+
},
|
|
506
|
+
}));
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
lines.push("Linear connect was skipped because PatchRelay is not ready yet:");
|
|
510
|
+
lines.push(...failedChecks.map((check) => `- [${check.scope}] ${check.message}`));
|
|
511
|
+
lines.push("Fix the failures above and rerun `patchrelay project apply`.");
|
|
512
|
+
writeOutput(stdout, `${lines.join("\n")}\n`);
|
|
513
|
+
return 0;
|
|
514
|
+
}
|
|
515
|
+
const serviceState = await tryManageService(runInteractive, installServiceCommands());
|
|
516
|
+
if (!serviceState.ok) {
|
|
517
|
+
throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
|
|
518
|
+
}
|
|
519
|
+
const cliData = options?.data ?? new CliDataAccess(fullConfig);
|
|
520
|
+
try {
|
|
521
|
+
if (json) {
|
|
522
|
+
const connectResult = noConnect ? undefined : await cliData.connect(projectId);
|
|
523
|
+
writeOutput(stdout, formatJson({
|
|
524
|
+
...result,
|
|
525
|
+
serviceUnits,
|
|
526
|
+
readiness: report,
|
|
527
|
+
serviceReloaded: true,
|
|
528
|
+
...(noConnect
|
|
529
|
+
? {
|
|
530
|
+
connect: {
|
|
531
|
+
attempted: false,
|
|
532
|
+
skipped: "no_connect",
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
: {
|
|
536
|
+
connect: {
|
|
537
|
+
attempted: true,
|
|
538
|
+
result: connectResult,
|
|
539
|
+
},
|
|
540
|
+
}),
|
|
541
|
+
}));
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
if (noConnect) {
|
|
545
|
+
lines.push("Project saved and PatchRelay was reloaded.");
|
|
546
|
+
lines.push(`Next: patchrelay connect --project ${result.project.id}`);
|
|
547
|
+
writeOutput(stdout, `${lines.join("\n")}\n`);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
writeOutput(stdout, `${lines.join("\n")}\n`);
|
|
551
|
+
return await runConnectFlow({
|
|
552
|
+
config: fullConfig,
|
|
553
|
+
data: cliData,
|
|
554
|
+
stdout,
|
|
555
|
+
noOpen: parsed.flags.get("no-open") === true,
|
|
556
|
+
timeoutSeconds: parseTimeoutSeconds(parsed.flags.get("timeout"), "project apply"),
|
|
557
|
+
projectId,
|
|
558
|
+
...(options?.openExternal ? { openExternal: options.openExternal } : {}),
|
|
559
|
+
...(options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: options.connectPollIntervalMs } : {}),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
if (!options?.data) {
|
|
564
|
+
cliData.close();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const config = options?.config ??
|
|
574
|
+
loadConfig(undefined, {
|
|
575
|
+
profile: getCommandConfigProfile(command),
|
|
576
|
+
});
|
|
577
|
+
let data = options?.data;
|
|
578
|
+
try {
|
|
579
|
+
if (command === "doctor") {
|
|
580
|
+
const report = await runPreflight(config);
|
|
581
|
+
writeOutput(stdout, json ? formatJson(report) : formatDoctor(report));
|
|
582
|
+
return report.ok ? 0 : 1;
|
|
583
|
+
}
|
|
584
|
+
data ??= new CliDataAccess(config);
|
|
585
|
+
if (command === "inspect") {
|
|
586
|
+
const issueKey = commandArgs[0];
|
|
587
|
+
if (!issueKey) {
|
|
588
|
+
throw new Error("inspect requires <issueKey>.");
|
|
589
|
+
}
|
|
590
|
+
const result = await data.inspect(issueKey);
|
|
591
|
+
if (!result) {
|
|
592
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
593
|
+
}
|
|
594
|
+
writeOutput(stdout, json ? formatJson(result) : formatInspect(result));
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
if (command === "live") {
|
|
598
|
+
const issueKey = commandArgs[0];
|
|
599
|
+
if (!issueKey) {
|
|
600
|
+
throw new Error("live requires <issueKey>.");
|
|
601
|
+
}
|
|
602
|
+
const watch = parsed.flags.get("watch") === true;
|
|
603
|
+
do {
|
|
604
|
+
const result = await data.live(issueKey);
|
|
605
|
+
if (!result) {
|
|
606
|
+
throw new Error(`No active stage found for ${issueKey}`);
|
|
607
|
+
}
|
|
608
|
+
writeOutput(stdout, json ? formatJson(result) : formatLive(result));
|
|
609
|
+
if (!watch || result.stageRun.status !== "running") {
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
await delay(2000);
|
|
613
|
+
} while (true);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
if (command === "report") {
|
|
617
|
+
const issueKey = commandArgs[0];
|
|
618
|
+
if (!issueKey) {
|
|
619
|
+
throw new Error("report requires <issueKey>.");
|
|
620
|
+
}
|
|
621
|
+
const reportOptions = {};
|
|
622
|
+
const stage = getStageFlag(parsed.flags.get("stage"));
|
|
623
|
+
if (stage) {
|
|
624
|
+
reportOptions.stage = stage;
|
|
625
|
+
}
|
|
626
|
+
if (typeof parsed.flags.get("stage-run") === "string") {
|
|
627
|
+
reportOptions.stageRunId = Number(parsed.flags.get("stage-run"));
|
|
628
|
+
}
|
|
629
|
+
const result = data.report(issueKey, reportOptions);
|
|
630
|
+
if (!result) {
|
|
631
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
632
|
+
}
|
|
633
|
+
writeOutput(stdout, json ? formatJson(result) : formatReport(result));
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
if (command === "events") {
|
|
637
|
+
const issueKey = commandArgs[0];
|
|
638
|
+
if (!issueKey) {
|
|
639
|
+
throw new Error("events requires <issueKey>.");
|
|
640
|
+
}
|
|
641
|
+
const follow = parsed.flags.get("follow") === true;
|
|
642
|
+
let afterId;
|
|
643
|
+
let stageRunId = typeof parsed.flags.get("stage-run") === "string" ? Number(parsed.flags.get("stage-run")) : undefined;
|
|
644
|
+
do {
|
|
645
|
+
const result = data.events(issueKey, {
|
|
646
|
+
...(stageRunId !== undefined ? { stageRunId } : {}),
|
|
647
|
+
...(typeof parsed.flags.get("method") === "string" ? { method: String(parsed.flags.get("method")) } : {}),
|
|
648
|
+
...(afterId !== undefined ? { afterId } : {}),
|
|
649
|
+
});
|
|
650
|
+
if (!result) {
|
|
651
|
+
throw new Error(`Stage run not found for ${issueKey}`);
|
|
652
|
+
}
|
|
653
|
+
stageRunId = result.stageRun.id;
|
|
654
|
+
if (result.events.length > 0) {
|
|
655
|
+
writeOutput(stdout, json ? formatJson(result) : formatEvents(result));
|
|
656
|
+
afterId = result.events.at(-1)?.id;
|
|
657
|
+
}
|
|
658
|
+
if (!follow || result.stageRun.status !== "running") {
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
await delay(2000);
|
|
662
|
+
} while (true);
|
|
663
|
+
return 0;
|
|
664
|
+
}
|
|
665
|
+
if (command === "worktree") {
|
|
666
|
+
const issueKey = commandArgs[0];
|
|
667
|
+
if (!issueKey) {
|
|
668
|
+
throw new Error("worktree requires <issueKey>.");
|
|
669
|
+
}
|
|
670
|
+
const result = data.worktree(issueKey);
|
|
671
|
+
if (!result) {
|
|
672
|
+
throw new Error(`Workspace not found for ${issueKey}`);
|
|
673
|
+
}
|
|
674
|
+
writeOutput(stdout, json ? formatJson(result) : formatWorktree(result, parsed.flags.get("cd") === true));
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
if (command === "open") {
|
|
678
|
+
const issueKey = commandArgs[0];
|
|
679
|
+
if (!issueKey) {
|
|
680
|
+
throw new Error("open requires <issueKey>.");
|
|
681
|
+
}
|
|
682
|
+
const result = data.open(issueKey);
|
|
683
|
+
if (!result) {
|
|
684
|
+
throw new Error(`Workspace not found for ${issueKey}`);
|
|
685
|
+
}
|
|
686
|
+
if (json) {
|
|
687
|
+
writeOutput(stdout, formatJson(result));
|
|
688
|
+
return 0;
|
|
689
|
+
}
|
|
690
|
+
if (parsed.flags.get("print") === true) {
|
|
691
|
+
writeOutput(stdout, formatOpen(result));
|
|
692
|
+
return 0;
|
|
693
|
+
}
|
|
694
|
+
const openCommand = buildOpenCommand(config, result.workspace.worktreePath, result.resumeThreadId);
|
|
695
|
+
return await runInteractive(openCommand.command, openCommand.args);
|
|
696
|
+
}
|
|
697
|
+
if (command === "connect") {
|
|
698
|
+
return await runConnectFlow({
|
|
699
|
+
config,
|
|
700
|
+
data,
|
|
701
|
+
stdout,
|
|
702
|
+
noOpen: parsed.flags.get("no-open") === true,
|
|
703
|
+
timeoutSeconds: parseTimeoutSeconds(parsed.flags.get("timeout"), "connect"),
|
|
704
|
+
json,
|
|
705
|
+
...(options?.openExternal ? { openExternal: options.openExternal } : {}),
|
|
706
|
+
...(options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: options.connectPollIntervalMs } : {}),
|
|
707
|
+
...(typeof parsed.flags.get("project") === "string" ? { projectId: String(parsed.flags.get("project")) } : {}),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
if (command === "installations") {
|
|
711
|
+
const result = await data.listInstallations();
|
|
712
|
+
if (json) {
|
|
713
|
+
writeOutput(stdout, formatJson(result));
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
716
|
+
writeOutput(stdout, `${(result.installations.length > 0
|
|
717
|
+
? result.installations.map((item) => `${item.installation.id} ${item.installation.workspaceName ?? item.installation.actorName ?? "-"} projects=${item.linkedProjects.join(",") || "-"}`)
|
|
718
|
+
: ["No installations found."]).join("\n")}\n`);
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
if (command === "retry") {
|
|
722
|
+
const issueKey = commandArgs[0];
|
|
723
|
+
if (!issueKey) {
|
|
724
|
+
throw new Error("retry requires <issueKey>.");
|
|
725
|
+
}
|
|
726
|
+
const retryOptions = {};
|
|
727
|
+
const stage = getStageFlag(parsed.flags.get("stage"));
|
|
728
|
+
if (stage) {
|
|
729
|
+
retryOptions.stage = stage;
|
|
730
|
+
}
|
|
731
|
+
if (typeof parsed.flags.get("reason") === "string") {
|
|
732
|
+
retryOptions.reason = String(parsed.flags.get("reason"));
|
|
733
|
+
}
|
|
734
|
+
const result = data.retry(issueKey, retryOptions);
|
|
735
|
+
if (!result) {
|
|
736
|
+
throw new Error(`Issue not found: ${issueKey}`);
|
|
737
|
+
}
|
|
738
|
+
writeOutput(stdout, json ? formatJson(result) : formatRetry(result));
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
if (command === "list") {
|
|
742
|
+
const result = data.list({
|
|
743
|
+
active: parsed.flags.get("active") === true,
|
|
744
|
+
failed: parsed.flags.get("failed") === true,
|
|
745
|
+
...(typeof parsed.flags.get("project") === "string" ? { project: String(parsed.flags.get("project")) } : {}),
|
|
746
|
+
});
|
|
747
|
+
writeOutput(stdout, json ? formatJson(result) : formatList(result));
|
|
748
|
+
return 0;
|
|
749
|
+
}
|
|
750
|
+
throw new Error(`Unknown command: ${command}`);
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
754
|
+
return 1;
|
|
755
|
+
}
|
|
756
|
+
finally {
|
|
757
|
+
if (data && !options?.data) {
|
|
758
|
+
data.close();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|