patchrelay 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +73 -0
- package/dist/cli/command-types.js +1 -0
- package/dist/cli/commands/connect.js +28 -0
- package/dist/cli/commands/issues.js +147 -0
- package/dist/cli/commands/project.js +140 -0
- package/dist/cli/commands/setup.js +140 -0
- package/dist/cli/connect-flow.js +52 -0
- package/dist/cli/data.js +17 -63
- package/dist/cli/index.js +59 -615
- package/dist/cli/interactive.js +48 -0
- package/dist/cli/output.js +13 -0
- package/dist/cli/service-commands.js +31 -0
- package/dist/db/issue-projection-store.js +54 -0
- package/dist/db/issue-workflow-coordinator.js +280 -0
- package/dist/db/issue-workflow-store.js +53 -550
- package/dist/db/run-report-store.js +33 -0
- package/dist/db.js +20 -1
- package/dist/index.js +13 -4
- package/dist/install.js +4 -3
- package/dist/linear-oauth.js +8 -7
- package/dist/service-stage-finalizer.js +2 -3
- package/dist/service-stage-runner.js +4 -4
- package/dist/service.js +1 -0
- package/dist/stage-failure.js +3 -17
- package/dist/stage-lifecycle-publisher.js +5 -28
- package/dist/webhook-desired-stage-recorder.js +4 -35
- package/infra/patchrelay.path +2 -0
- package/infra/patchrelay.service +2 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,59 +1,14 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
3
1
|
import { loadConfig } from "../config.js";
|
|
4
|
-
import { initializePatchRelayHome, installUserServiceUnits, upsertProjectInConfig } from "../install.js";
|
|
5
2
|
import { runPreflight } from "../preflight.js";
|
|
6
|
-
import {
|
|
3
|
+
import { parseArgs, resolveCommand } from "./args.js";
|
|
4
|
+
import { handleConnectCommand, handleInstallationsCommand } from "./commands/connect.js";
|
|
5
|
+
import { handleEventsCommand, handleInspectCommand, handleListCommand, handleLiveCommand, handleOpenCommand, handleReportCommand, handleRetryCommand, handleWorktreeCommand, } from "./commands/issues.js";
|
|
6
|
+
import { handleProjectCommand } from "./commands/project.js";
|
|
7
|
+
import { handleInitCommand, handleInstallServiceCommand, handleRestartServiceCommand } from "./commands/setup.js";
|
|
7
8
|
import { CliDataAccess } from "./data.js";
|
|
8
9
|
import { formatJson } from "./formatters/json.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
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
|
-
}
|
|
10
|
+
import { runInteractiveCommand } from "./interactive.js";
|
|
11
|
+
import { formatDoctor, writeOutput } from "./output.js";
|
|
57
12
|
function helpText() {
|
|
58
13
|
return [
|
|
59
14
|
"PatchRelay",
|
|
@@ -107,167 +62,6 @@ function helpText() {
|
|
|
107
62
|
" List tracked issues",
|
|
108
63
|
].join("\n");
|
|
109
64
|
}
|
|
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
65
|
function getCommandConfigProfile(command) {
|
|
272
66
|
switch (command) {
|
|
273
67
|
case "doctor":
|
|
@@ -293,13 +87,7 @@ export async function runCli(argv, options) {
|
|
|
293
87
|
const stdout = options?.stdout ?? process.stdout;
|
|
294
88
|
const stderr = options?.stderr ?? process.stderr;
|
|
295
89
|
const parsed = parseArgs(argv);
|
|
296
|
-
const
|
|
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;
|
|
90
|
+
const { command, commandArgs } = resolveCommand(parsed);
|
|
303
91
|
if (command === "help") {
|
|
304
92
|
writeOutput(stdout, `${helpText()}\n`);
|
|
305
93
|
return 0;
|
|
@@ -310,265 +98,45 @@ export async function runCli(argv, options) {
|
|
|
310
98
|
const runInteractive = options?.runInteractive ?? runInteractiveCommand;
|
|
311
99
|
const json = parsed.flags.get("json") === true;
|
|
312
100
|
if (command === "init") {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
}
|
|
101
|
+
return await handleInitCommand({
|
|
102
|
+
commandArgs,
|
|
103
|
+
parsed,
|
|
104
|
+
json,
|
|
105
|
+
stdout,
|
|
106
|
+
stderr,
|
|
107
|
+
runInteractive,
|
|
108
|
+
});
|
|
391
109
|
}
|
|
392
110
|
if (command === "install-service") {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
}
|
|
111
|
+
return await handleInstallServiceCommand({
|
|
112
|
+
commandArgs,
|
|
113
|
+
parsed,
|
|
114
|
+
json,
|
|
115
|
+
stdout,
|
|
116
|
+
stderr,
|
|
117
|
+
runInteractive,
|
|
118
|
+
});
|
|
419
119
|
}
|
|
420
120
|
if (command === "restart-service") {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
}
|
|
121
|
+
return await handleRestartServiceCommand({
|
|
122
|
+
commandArgs,
|
|
123
|
+
parsed,
|
|
124
|
+
json,
|
|
125
|
+
stdout,
|
|
126
|
+
stderr,
|
|
127
|
+
runInteractive,
|
|
128
|
+
});
|
|
441
129
|
}
|
|
442
130
|
if (command === "project") {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
}
|
|
131
|
+
return await handleProjectCommand({
|
|
132
|
+
commandArgs,
|
|
133
|
+
parsed,
|
|
134
|
+
json,
|
|
135
|
+
stdout,
|
|
136
|
+
stderr,
|
|
137
|
+
runInteractive,
|
|
138
|
+
...(options ? { options } : {}),
|
|
139
|
+
});
|
|
572
140
|
}
|
|
573
141
|
const config = options?.config ??
|
|
574
142
|
loadConfig(undefined, {
|
|
@@ -583,169 +151,45 @@ export async function runCli(argv, options) {
|
|
|
583
151
|
}
|
|
584
152
|
data ??= new CliDataAccess(config);
|
|
585
153
|
if (command === "inspect") {
|
|
586
|
-
|
|
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;
|
|
154
|
+
return await handleInspectCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
596
155
|
}
|
|
597
156
|
if (command === "live") {
|
|
598
|
-
|
|
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;
|
|
157
|
+
return await handleLiveCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
615
158
|
}
|
|
616
159
|
if (command === "report") {
|
|
617
|
-
|
|
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;
|
|
160
|
+
return await handleReportCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
635
161
|
}
|
|
636
162
|
if (command === "events") {
|
|
637
|
-
|
|
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;
|
|
163
|
+
return await handleEventsCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
664
164
|
}
|
|
665
165
|
if (command === "worktree") {
|
|
666
|
-
|
|
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;
|
|
166
|
+
return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
676
167
|
}
|
|
677
168
|
if (command === "open") {
|
|
678
|
-
|
|
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);
|
|
169
|
+
return await handleOpenCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
696
170
|
}
|
|
697
171
|
if (command === "connect") {
|
|
698
|
-
return await
|
|
172
|
+
return await handleConnectCommand({
|
|
173
|
+
parsed,
|
|
174
|
+
json,
|
|
175
|
+
stdout,
|
|
699
176
|
config,
|
|
700
177
|
data,
|
|
701
|
-
|
|
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")) } : {}),
|
|
178
|
+
...(options ? { options } : {}),
|
|
708
179
|
});
|
|
709
180
|
}
|
|
710
181
|
if (command === "installations") {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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;
|
|
182
|
+
return await handleInstallationsCommand({
|
|
183
|
+
json,
|
|
184
|
+
stdout,
|
|
185
|
+
data,
|
|
186
|
+
});
|
|
720
187
|
}
|
|
721
188
|
if (command === "retry") {
|
|
722
|
-
|
|
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;
|
|
189
|
+
return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
740
190
|
}
|
|
741
191
|
if (command === "list") {
|
|
742
|
-
|
|
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;
|
|
192
|
+
return await handleListCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
|
|
749
193
|
}
|
|
750
194
|
throw new Error(`Unknown command: ${command}`);
|
|
751
195
|
}
|