patchrelay 0.26.0 → 0.29.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.
Files changed (57) hide show
  1. package/README.md +83 -31
  2. package/dist/agent-session-plan.js +0 -7
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/args.js +22 -18
  5. package/dist/cli/commands/feed.js +1 -1
  6. package/dist/cli/commands/issues.js +44 -4
  7. package/dist/cli/commands/linear.js +67 -0
  8. package/dist/cli/commands/repo.js +213 -0
  9. package/dist/cli/commands/setup.js +140 -21
  10. package/dist/cli/connect-flow.js +5 -3
  11. package/dist/cli/formatters/text.js +1 -1
  12. package/dist/cli/help.js +134 -63
  13. package/dist/cli/index.js +166 -188
  14. package/dist/cli/interactive.js +25 -0
  15. package/dist/cli/operator-client.js +11 -0
  16. package/dist/cli/service-commands.js +11 -4
  17. package/dist/cli/watch/App.js +1 -1
  18. package/dist/cli/watch/FactoryStateGraph.js +31 -0
  19. package/dist/cli/watch/FeedView.js +3 -2
  20. package/dist/cli/watch/FreshnessBadge.js +13 -0
  21. package/dist/cli/watch/IssueDetailView.js +9 -2
  22. package/dist/cli/watch/IssueListView.js +2 -2
  23. package/dist/cli/watch/IssueRow.js +9 -11
  24. package/dist/cli/watch/QueueObservationView.js +15 -0
  25. package/dist/cli/watch/StateHistoryView.js +0 -1
  26. package/dist/cli/watch/StatusBar.js +5 -2
  27. package/dist/cli/watch/format-utils.js +7 -0
  28. package/dist/cli/watch/freshness.js +30 -0
  29. package/dist/cli/watch/state-visualization.js +147 -0
  30. package/dist/cli/watch/theme.js +6 -7
  31. package/dist/cli/watch/use-watch-stream.js +5 -2
  32. package/dist/cli/watch/watch-state.js +9 -5
  33. package/dist/config.js +129 -36
  34. package/dist/db/linear-installation-store.js +23 -0
  35. package/dist/db/migrations.js +42 -0
  36. package/dist/db/repository-link-store.js +103 -0
  37. package/dist/db.js +61 -11
  38. package/dist/factory-state.js +1 -5
  39. package/dist/github-webhook-handler.js +115 -46
  40. package/dist/github-webhooks.js +4 -0
  41. package/dist/http.js +162 -0
  42. package/dist/install.js +93 -13
  43. package/dist/issue-query-service.js +34 -1
  44. package/dist/linear-client.js +80 -25
  45. package/dist/merge-queue-incident.js +104 -0
  46. package/dist/merge-queue-protocol.js +54 -0
  47. package/dist/preflight.js +28 -1
  48. package/dist/repository-linking.js +42 -0
  49. package/dist/run-orchestrator.js +197 -21
  50. package/dist/runtime-paths.js +0 -8
  51. package/dist/service.js +94 -49
  52. package/package.json +8 -7
  53. package/dist/cli/commands/connect.js +0 -54
  54. package/dist/cli/commands/project.js +0 -146
  55. package/dist/merge-queue.js +0 -200
  56. package/infra/patchrelay-reload.service +0 -6
  57. package/infra/patchrelay.path +0 -13
@@ -0,0 +1,213 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { loadConfig } from "../../config.js";
4
+ import { ensureRepositoryProjectSettings, removeRepositoryFromConfig, upsertRepositoryInConfig } from "../../install.js";
5
+ import { defaultLocalRepoPath, ensureLocalRepository, normalizeGitHubRepo } from "../../repository-linking.js";
6
+ import { ensureDir, execCommand } from "../../utils.js";
7
+ import { parseCsvFlag } from "../args.js";
8
+ import { formatJson } from "../formatters/json.js";
9
+ import { writeOutput } from "../output.js";
10
+ import { restartServiceCommands, tryManageService } from "../service-commands.js";
11
+ export async function handleRepoCommand(params) {
12
+ const subcommand = params.commandArgs[0] ?? "list";
13
+ switch (subcommand) {
14
+ case "list":
15
+ return await handleRepoList(params);
16
+ case "show":
17
+ return await handleRepoShow(params);
18
+ case "link":
19
+ return await handleRepoLink(params);
20
+ case "unlink":
21
+ return await handleRepoUnlink(params);
22
+ case "sync":
23
+ return await handleRepoSync(params);
24
+ default:
25
+ throw new Error(`Unknown repo subcommand: ${subcommand}`);
26
+ }
27
+ }
28
+ async function handleRepoList(params) {
29
+ const config = params.options?.config ?? loadConfig(undefined, { profile: "doctor" });
30
+ writeOutput(params.stdout, params.json
31
+ ? formatJson({ ok: true, repositories: config.repositories })
32
+ : config.repositories.length === 0
33
+ ? "No repositories linked.\n"
34
+ : `${config.repositories.map((repository) => `${repository.githubRepo} ${repository.localPath}`).join("\n")}\n`);
35
+ return 0;
36
+ }
37
+ async function handleRepoShow(params) {
38
+ const githubRepo = params.commandArgs[1];
39
+ if (!githubRepo) {
40
+ throw new Error("patchrelay repo show requires <github-repo>.");
41
+ }
42
+ const normalized = normalizeGitHubRepo(githubRepo);
43
+ const config = params.options?.config ?? loadConfig(undefined, { profile: "doctor" });
44
+ const repository = config.repositories.find((entry) => entry.githubRepo === normalized);
45
+ if (!repository) {
46
+ throw new Error(`Repository not linked: ${normalized}`);
47
+ }
48
+ writeOutput(params.stdout, params.json
49
+ ? formatJson({ ok: true, repository })
50
+ : [
51
+ `Repository: ${repository.githubRepo}`,
52
+ `Path: ${repository.localPath}`,
53
+ `Workspace: ${repository.workspace ?? "-"}`,
54
+ `Linear teams: ${repository.linearTeamIds.join(", ") || "-"}`,
55
+ `Linear projects: ${repository.linearProjectIds.join(", ") || "-"}`,
56
+ `Issue key prefixes: ${repository.issueKeyPrefixes.join(", ") || "-"}`,
57
+ ].join("\n") + "\n");
58
+ return 0;
59
+ }
60
+ async function handleRepoLink(params) {
61
+ const githubRepoArg = params.commandArgs[1];
62
+ if (!githubRepoArg) {
63
+ throw new Error("patchrelay repo link requires <github-repo>.");
64
+ }
65
+ const githubRepo = normalizeGitHubRepo(githubRepoArg);
66
+ const workspaceArg = typeof params.parsed.flags.get("workspace") === "string" ? String(params.parsed.flags.get("workspace")) : undefined;
67
+ if (!workspaceArg) {
68
+ throw new Error("patchrelay repo link requires --workspace <workspace>.");
69
+ }
70
+ const teamQueries = parseCsvFlag(params.parsed.flags.get("team"));
71
+ if (teamQueries.length === 0) {
72
+ throw new Error("patchrelay repo link requires --team <key-or-id>[,...].");
73
+ }
74
+ const projectQueries = parseCsvFlag(params.parsed.flags.get("project"));
75
+ const explicitPrefixes = parseCsvFlag(params.parsed.flags.get("prefix"));
76
+ const config = params.options?.config ?? loadConfig(undefined, { profile: "operator_cli" });
77
+ const data = params.options?.data ?? (await createCliOperatorDataAccess(config));
78
+ try {
79
+ const syncResult = await data.syncLinearWorkspace(workspaceArg);
80
+ const installation = syncResult.installation;
81
+ const teams = resolveTeams(syncResult.teams, teamQueries);
82
+ const projects = resolveProjects(syncResult.projects, projectQueries);
83
+ const derivedPrefixes = explicitPrefixes.length > 0
84
+ ? explicitPrefixes
85
+ : teams.map((team) => team.key).filter((value) => Boolean(value));
86
+ const localPathFlag = typeof params.parsed.flags.get("path") === "string" ? String(params.parsed.flags.get("path")) : undefined;
87
+ const localPath = localPathFlag ? localPathFlag : defaultLocalRepoPath(config.repos.root, githubRepo);
88
+ const repoState = await ensureLocalRepository({ config, githubRepo, localPath });
89
+ await ensureRepositoryProjectSettings(repoState.localPath);
90
+ const saveResult = await upsertRepositoryInConfig({
91
+ githubRepo,
92
+ localPath: repoState.localPath,
93
+ workspace: installation.workspaceKey ?? installation.workspaceName ?? workspaceArg,
94
+ linearTeamIds: teams.map((team) => team.id),
95
+ linearProjectIds: projects.map((project) => project.id),
96
+ issueKeyPrefixes: derivedPrefixes,
97
+ });
98
+ const { PatchRelayDatabase } = await import("../../db.js");
99
+ await ensureDir(dirname(config.database.path));
100
+ const db = new PatchRelayDatabase(config.database.path, config.database.wal);
101
+ try {
102
+ db.runMigrations();
103
+ db.linearInstallations.setProjectInstallation(githubRepo, installation.id);
104
+ }
105
+ finally {
106
+ db.connection.close();
107
+ }
108
+ const serviceState = await tryManageService(params.runCommand, restartServiceCommands());
109
+ if (!serviceState.ok) {
110
+ throw new Error(`Repository was linked, but PatchRelay could not be reloaded: ${serviceState.error}`);
111
+ }
112
+ writeOutput(params.stdout, params.json
113
+ ? formatJson({
114
+ ok: true,
115
+ repository: saveResult.repository,
116
+ clone: repoState,
117
+ installation,
118
+ teams,
119
+ projects,
120
+ })
121
+ : [
122
+ `${saveResult.status === "created" ? "Linked" : saveResult.status === "updated" ? "Updated" : "Verified"} ${githubRepo}`,
123
+ `Path: ${repoState.localPath}${repoState.reused ? " (reused)" : " (cloned)"}`,
124
+ `Workspace: ${installation.workspaceKey ?? installation.workspaceName ?? installation.id}`,
125
+ `Linear teams: ${teams.map((team) => team.key ?? team.name ?? team.id).join(", ")}`,
126
+ `Linear projects: ${projects.map((project) => project.name ?? project.id).join(", ") || "-"}`,
127
+ `Issue key prefixes: ${derivedPrefixes.join(", ") || "-"}`,
128
+ ].join("\n") + "\n");
129
+ return 0;
130
+ }
131
+ finally {
132
+ if (!params.options?.data) {
133
+ data.close();
134
+ }
135
+ }
136
+ }
137
+ async function handleRepoUnlink(params) {
138
+ const githubRepoArg = params.commandArgs[1];
139
+ if (!githubRepoArg) {
140
+ throw new Error("patchrelay repo unlink requires <github-repo>.");
141
+ }
142
+ const githubRepo = normalizeGitHubRepo(githubRepoArg);
143
+ const config = params.options?.config ?? loadConfig(undefined, { profile: "doctor" });
144
+ const result = await removeRepositoryFromConfig({ githubRepo });
145
+ const { PatchRelayDatabase } = await import("../../db.js");
146
+ await ensureDir(dirname(config.database.path));
147
+ const db = new PatchRelayDatabase(config.database.path, config.database.wal);
148
+ try {
149
+ db.runMigrations();
150
+ db.linearInstallations.unlinkProjectInstallation(githubRepo);
151
+ }
152
+ finally {
153
+ db.connection.close();
154
+ }
155
+ const serviceState = await tryManageService(params.runCommand, restartServiceCommands());
156
+ if (!serviceState.ok) {
157
+ throw new Error(`Repository was unlinked, but PatchRelay could not be reloaded: ${serviceState.error}`);
158
+ }
159
+ writeOutput(params.stdout, params.json
160
+ ? formatJson({ ok: true, githubRepo, removed: result.removed })
161
+ : result.removed
162
+ ? `Unlinked ${githubRepo}.\n`
163
+ : `Repository was not linked: ${githubRepo}\n`);
164
+ return 0;
165
+ }
166
+ async function handleRepoSync(params) {
167
+ const githubRepoArg = params.commandArgs[1];
168
+ const config = params.options?.config ?? loadConfig(undefined, { profile: "doctor" });
169
+ const repositories = githubRepoArg
170
+ ? config.repositories.filter((repository) => repository.githubRepo === normalizeGitHubRepo(githubRepoArg))
171
+ : config.repositories;
172
+ const results = [];
173
+ for (const repository of repositories) {
174
+ if (!existsSync(repository.localPath)) {
175
+ await ensureLocalRepository({ config, githubRepo: repository.githubRepo, localPath: repository.localPath });
176
+ results.push({ githubRepo: repository.githubRepo, localPath: repository.localPath, fetched: false });
177
+ continue;
178
+ }
179
+ await execCommand(config.runner.gitBin, ["-C", repository.localPath, "fetch", "origin"], { timeoutMs: 300_000 });
180
+ results.push({ githubRepo: repository.githubRepo, localPath: repository.localPath, fetched: true });
181
+ }
182
+ writeOutput(params.stdout, params.json
183
+ ? formatJson({ ok: true, repositories: results })
184
+ : `${results.map((result) => `${result.githubRepo} ${result.fetched ? "fetched" : "cloned"} ${result.localPath}`).join("\n")}\n`);
185
+ return 0;
186
+ }
187
+ function resolveTeams(teams, queries) {
188
+ return queries.map((query) => {
189
+ const normalized = query.trim().toLowerCase();
190
+ const team = teams.find((entry) => entry.id.toLowerCase() === normalized
191
+ || entry.key?.trim().toLowerCase() === normalized
192
+ || entry.name?.trim().toLowerCase() === normalized);
193
+ if (!team) {
194
+ throw new Error(`Linear team not found: ${query}`);
195
+ }
196
+ return team;
197
+ });
198
+ }
199
+ function resolveProjects(projects, queries) {
200
+ return queries.map((query) => {
201
+ const normalized = query.trim().toLowerCase();
202
+ const project = projects.find((entry) => entry.id.toLowerCase() === normalized
203
+ || entry.name?.trim().toLowerCase() === normalized);
204
+ if (!project) {
205
+ throw new Error(`Linear project not found: ${query}`);
206
+ }
207
+ return project;
208
+ });
209
+ }
210
+ async function createCliOperatorDataAccess(config) {
211
+ const { CliOperatorApiClient } = await import("../operator-client.js");
212
+ return new CliOperatorApiClient(config);
213
+ }
@@ -1,5 +1,8 @@
1
- import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdPathUnitPath, getSystemdReloadUnitPath, getSystemdUnitPath, } from "../../runtime-paths.js";
1
+ import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUnitPath, } from "../../runtime-paths.js";
2
2
  import { initializePatchRelayHome, installServiceUnits } from "../../install.js";
3
+ import { loadConfig } from "../../config.js";
4
+ import { parsePositiveIntegerFlag } from "../args.js";
5
+ import { CliUsageError } from "../errors.js";
3
6
  import { formatJson } from "../formatters/json.js";
4
7
  import { writeOutput } from "../output.js";
5
8
  import { installServiceCommands, restartServiceCommands, runServiceCommands, tryManageService } from "../service-commands.js";
@@ -21,7 +24,7 @@ export async function handleInitCommand(params) {
21
24
  publicBaseUrl,
22
25
  });
23
26
  const serviceUnits = await installServiceUnits({ force: params.parsed.flags.get("force") === true });
24
- const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
27
+ const serviceState = await tryManageService(params.runCommand, installServiceCommands());
25
28
  writeOutput(params.stdout, params.json
26
29
  ? formatJson({ ...result, serviceUnits, serviceState })
27
30
  : [
@@ -32,8 +35,6 @@ export async function handleInitCommand(params) {
32
35
  `State directory: ${result.stateDir}`,
33
36
  `Data directory: ${result.dataDir}`,
34
37
  `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
35
- `Reload unit: ${serviceUnits.reloadUnitPath} (${serviceUnits.reloadStatus})`,
36
- `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
37
38
  "",
38
39
  "PatchRelay public URLs:",
39
40
  `- Public base URL: ${result.publicBaseUrl}`,
@@ -41,9 +42,9 @@ export async function handleInitCommand(params) {
41
42
  `- OAuth callback: ${result.oauthCallbackUrl}`,
42
43
  "",
43
44
  "Created with defaults:",
44
- `- Config file contains only machine-level essentials such as server.public_base_url`,
45
- `- Database, logs, bind address, and worktree roots use built-in defaults`,
46
- `- The system service and config watcher are installed for you`,
45
+ "- Config file contains only machine-level essentials such as server.public_base_url",
46
+ "- Database, logs, bind address, and worktree roots use built-in defaults",
47
+ "- The system service is installed for you",
47
48
  "",
48
49
  "Register the app in Linear:",
49
50
  "- Open Linear Settings > API > Applications",
@@ -60,19 +61,21 @@ export async function handleInitCommand(params) {
60
61
  "",
61
62
  "Service status:",
62
63
  serviceState.ok
63
- ? "PatchRelay service and config watcher are installed and reload-or-restart has been requested."
64
+ ? "PatchRelay service is installed and reload-or-restart has been requested."
64
65
  : `PatchRelay service units were installed, but the service could not be started yet: ${serviceState.error}`,
65
66
  !serviceState.ok
66
- ? "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."
67
+ ? "This is expected until the required env vars and at least one valid repo workflow are in place. Rerun `patchrelay service restart` after updating config or env files."
67
68
  : undefined,
68
69
  "",
69
70
  "Next steps:",
70
71
  `1. Edit ${result.serviceEnvPath}`,
71
72
  "2. Paste your Linear OAuth client id and client secret into service.env and keep the generated webhook secret and token encryption key",
72
73
  "3. Paste LINEAR_WEBHOOK_SECRET from service.env into the Linear OAuth app webhook signing secret",
73
- "4. Run `patchrelay project apply <id> <repo-path>`",
74
- "5. Edit the generated project workflows if you want custom state names or workflow files, then add those workflow files to the repo",
75
- "6. Run `patchrelay doctor`",
74
+ "4. Run `patchrelay linear connect`",
75
+ "5. Run `patchrelay linear sync`",
76
+ "6. Run `patchrelay repo link <owner/repo> --workspace <workspace> --team <team>`",
77
+ "7. Add the workflow files your repo needs, then run `patchrelay doctor`",
78
+ "8. Run `patchrelay service status`",
76
79
  ]
77
80
  .filter(Boolean)
78
81
  .join("\n") + "\n");
@@ -88,21 +91,19 @@ export async function handleInstallServiceCommand(params) {
88
91
  const result = await installServiceUnits({ force: params.parsed.flags.get("force") === true });
89
92
  const writeOnly = params.parsed.flags.get("write-only") === true;
90
93
  if (!writeOnly) {
91
- await runServiceCommands(params.runInteractive, installServiceCommands());
94
+ await runServiceCommands(params.runCommand, installServiceCommands());
92
95
  }
93
96
  writeOutput(params.stdout, params.json
94
97
  ? formatJson({ ...result, writeOnly })
95
98
  : [
96
99
  `Service unit: ${result.unitPath} (${result.serviceStatus})`,
97
- `Reload unit: ${result.reloadUnitPath} (${result.reloadStatus})`,
98
- `Watcher unit: ${result.pathUnitPath} (${result.pathStatus})`,
99
100
  `Runtime env: ${result.runtimeEnvPath}`,
100
101
  `Service env: ${result.serviceEnvPath}`,
101
102
  `Config file: ${result.configPath}`,
102
103
  writeOnly
103
- ? "Service units written. Start them with: sudo systemctl daemon-reload && sudo systemctl enable --now patchrelay.path && sudo systemctl enable patchrelay.service && sudo systemctl reload-or-restart patchrelay.service"
104
- : "PatchRelay system service and config watcher are installed and running.",
105
- "After package updates, run: patchrelay restart-service",
104
+ ? "Service unit written. Start it with: sudo systemctl daemon-reload && sudo systemctl enable patchrelay.service && sudo systemctl reload-or-restart patchrelay.service"
105
+ : "PatchRelay system service is installed and running.",
106
+ "After package updates, run: patchrelay service restart",
106
107
  ].join("\n") + "\n");
107
108
  return 0;
108
109
  }
@@ -113,13 +114,11 @@ export async function handleInstallServiceCommand(params) {
113
114
  }
114
115
  export async function handleRestartServiceCommand(params) {
115
116
  try {
116
- await runServiceCommands(params.runInteractive, restartServiceCommands());
117
+ await runServiceCommands(params.runCommand, restartServiceCommands());
117
118
  writeOutput(params.stdout, params.json
118
119
  ? formatJson({
119
120
  service: "patchrelay",
120
121
  unitPath: getSystemdUnitPath(),
121
- reloadUnitPath: getSystemdReloadUnitPath(),
122
- pathUnitPath: getSystemdPathUnitPath(),
123
122
  runtimeEnvPath: getDefaultRuntimeEnvPath(),
124
123
  serviceEnvPath: getDefaultServiceEnvPath(),
125
124
  configPath: getDefaultConfigPath(),
@@ -133,6 +132,126 @@ export async function handleRestartServiceCommand(params) {
133
132
  return 1;
134
133
  }
135
134
  }
135
+ function parseSystemctlShowOutput(raw) {
136
+ const properties = {};
137
+ for (const line of raw.split(/\r?\n/)) {
138
+ const trimmed = line.trim();
139
+ if (!trimmed)
140
+ continue;
141
+ const separator = trimmed.indexOf("=");
142
+ if (separator <= 0)
143
+ continue;
144
+ properties[trimmed.slice(0, separator)] = trimmed.slice(separator + 1);
145
+ }
146
+ return properties;
147
+ }
148
+ async function readPatchRelayHealth() {
149
+ try {
150
+ const config = loadConfig(undefined, { profile: "doctor" });
151
+ const response = await fetch(`http://${config.server.bind}:${config.server.port}${config.server.healthPath}`, { signal: AbortSignal.timeout(2000) });
152
+ let body;
153
+ try {
154
+ body = await response.json();
155
+ }
156
+ catch {
157
+ body = undefined;
158
+ }
159
+ return {
160
+ reachable: true,
161
+ status: response.status,
162
+ ...(body ? { body } : {}),
163
+ };
164
+ }
165
+ catch (error) {
166
+ return {
167
+ reachable: false,
168
+ error: error instanceof Error ? error.message : String(error),
169
+ };
170
+ }
171
+ }
172
+ export async function handleServiceCommand(params) {
173
+ if (params.commandArgs.length === 0) {
174
+ throw new CliUsageError("patchrelay service requires a subcommand.", "service");
175
+ }
176
+ const subcommand = params.commandArgs[0];
177
+ if (subcommand === "install") {
178
+ return await handleInstallServiceCommand({
179
+ ...params,
180
+ commandArgs: params.commandArgs.slice(1),
181
+ });
182
+ }
183
+ if (subcommand === "restart") {
184
+ return await handleRestartServiceCommand({
185
+ ...params,
186
+ commandArgs: params.commandArgs.slice(1),
187
+ });
188
+ }
189
+ if (subcommand === "status") {
190
+ const result = await params.runCommand("sudo", [
191
+ "systemctl",
192
+ "show",
193
+ "patchrelay.service",
194
+ "--property=Id,LoadState,UnitFileState,ActiveState,SubState,FragmentPath,ExecMainPID",
195
+ ]);
196
+ if (result.exitCode !== 0) {
197
+ throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to read patchrelay.service status.");
198
+ }
199
+ const properties = parseSystemctlShowOutput(result.stdout);
200
+ const health = await readPatchRelayHealth();
201
+ const payload = {
202
+ service: "patchrelay",
203
+ unit: "patchrelay.service",
204
+ unitPath: getSystemdUnitPath(),
205
+ systemd: properties,
206
+ health,
207
+ };
208
+ writeOutput(params.stdout, params.json
209
+ ? formatJson(payload)
210
+ : [
211
+ "PatchRelay service",
212
+ "",
213
+ `Unit: ${properties.Id ?? "patchrelay.service"}`,
214
+ `Load state: ${properties.LoadState ?? "unknown"}`,
215
+ `Enabled: ${properties.UnitFileState ?? "unknown"}`,
216
+ `Active: ${properties.ActiveState ?? "unknown"}${properties.SubState ? ` (${properties.SubState})` : ""}`,
217
+ `Unit path: ${properties.FragmentPath || getSystemdUnitPath()}`,
218
+ properties.ExecMainPID ? `Main PID: ${properties.ExecMainPID}` : undefined,
219
+ health.reachable
220
+ ? `Health: reachable (HTTP ${health.status})${typeof health.body?.version === "string" ? ` version ${health.body.version}` : ""}`
221
+ : `Health: not reachable (${health.error})`,
222
+ ]
223
+ .filter(Boolean)
224
+ .join("\n") + "\n");
225
+ return 0;
226
+ }
227
+ if (subcommand === "logs") {
228
+ const lines = parsePositiveIntegerFlag(params.parsed.flags.get("lines"), "--lines") ?? 50;
229
+ const result = await params.runCommand("sudo", [
230
+ "journalctl",
231
+ "-u",
232
+ "patchrelay.service",
233
+ "-n",
234
+ String(lines),
235
+ "--no-pager",
236
+ "-o",
237
+ "short-iso",
238
+ ]);
239
+ if (result.exitCode !== 0) {
240
+ throw new Error(result.stderr.trim() || result.stdout.trim() || "Unable to read PatchRelay logs.");
241
+ }
242
+ const logs = result.stdout.split(/\r?\n/).filter(Boolean);
243
+ writeOutput(params.stdout, params.json
244
+ ? formatJson({
245
+ service: "patchrelay",
246
+ unit: "patchrelay.service",
247
+ lines,
248
+ logs,
249
+ })
250
+ : `${result.stdout}${result.stdout.endsWith("\n") || result.stdout.length === 0 ? "" : "\n"}`);
251
+ return 0;
252
+ }
253
+ throw new CliUsageError(`Unknown service command: ${subcommand}`, "service");
254
+ }
136
255
  function normalizePublicBaseUrl(value) {
137
256
  const candidate = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) ? value : `https://${value}`;
138
257
  const url = new URL(candidate);
@@ -16,7 +16,9 @@ export async function runConnectFlow(params) {
16
16
  }
17
17
  if ("completed" in result && result.completed) {
18
18
  const label = result.installation.workspaceName ?? result.installation.actorName ?? `installation #${result.installation.id}`;
19
- writeOutput(params.stdout, `Linked project ${result.projectId} to existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`);
19
+ writeOutput(params.stdout, result.projectId
20
+ ? `Linked repo ${result.projectId} to existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`
21
+ : `Reused existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`);
20
22
  return 0;
21
23
  }
22
24
  if ("completed" in result) {
@@ -24,7 +26,7 @@ export async function runConnectFlow(params) {
24
26
  }
25
27
  const opener = params.openExternal;
26
28
  const opened = params.noOpen || !opener ? false : await opener(result.authorizeUrl);
27
- 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`);
29
+ writeOutput(params.stdout, `${result.projectId ? `Repo: ${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`);
28
30
  const deadline = Date.now() + (params.timeoutSeconds ?? 180) * 1000;
29
31
  const pollIntervalMs = params.connectPollIntervalMs ?? 1000;
30
32
  do {
@@ -32,7 +34,7 @@ export async function runConnectFlow(params) {
32
34
  if (status.status === "completed") {
33
35
  const label = status.installation?.workspaceName ?? status.installation?.actorName ?? `installation #${status.installation?.id ?? "unknown"}`;
34
36
  writeOutput(params.stdout, [
35
- `Connected ${label}${status.projectId ? ` for project ${status.projectId}` : ""}.${status.installation?.id ? ` Installation ${status.installation.id}.` : ""}`,
37
+ `Connected ${label}${status.projectId ? ` for repo ${status.projectId}` : ""}.${status.installation?.id ? ` Installation ${status.installation.id}.` : ""}`,
36
38
  params.config.linear.oauth.actor === "app"
37
39
  ? "If your Linear OAuth app webhook settings are configured, Linear has now provisioned the workspace webhook automatically."
38
40
  : undefined,
@@ -88,7 +88,7 @@ export function formatOpen(result, command) {
88
88
  "git branch --show-current",
89
89
  ];
90
90
  if (result.needsNewSession) {
91
- commands.push(`# No resumable thread found; \`patchrelay open ${result.issue.issueKey ?? result.issue.linearIssueId}\` will create a fresh session.`);
91
+ commands.push(`# No resumable thread found; \`patchrelay issue open ${result.issue.issueKey ?? result.issue.linearIssueId}\` will create a fresh session.`);
92
92
  }
93
93
  commands.push(command
94
94
  ? formatCommand(command.command, command.args)