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.
- package/README.md +83 -31
- package/dist/agent-session-plan.js +0 -7
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +22 -18
- package/dist/cli/commands/feed.js +1 -1
- package/dist/cli/commands/issues.js +44 -4
- package/dist/cli/commands/linear.js +67 -0
- package/dist/cli/commands/repo.js +213 -0
- package/dist/cli/commands/setup.js +140 -21
- package/dist/cli/connect-flow.js +5 -3
- package/dist/cli/formatters/text.js +1 -1
- package/dist/cli/help.js +134 -63
- package/dist/cli/index.js +166 -188
- package/dist/cli/interactive.js +25 -0
- package/dist/cli/operator-client.js +11 -0
- package/dist/cli/service-commands.js +11 -4
- package/dist/cli/watch/App.js +1 -1
- package/dist/cli/watch/FactoryStateGraph.js +31 -0
- package/dist/cli/watch/FeedView.js +3 -2
- package/dist/cli/watch/FreshnessBadge.js +13 -0
- package/dist/cli/watch/IssueDetailView.js +9 -2
- package/dist/cli/watch/IssueListView.js +2 -2
- package/dist/cli/watch/IssueRow.js +9 -11
- package/dist/cli/watch/QueueObservationView.js +15 -0
- package/dist/cli/watch/StateHistoryView.js +0 -1
- package/dist/cli/watch/StatusBar.js +5 -2
- package/dist/cli/watch/format-utils.js +7 -0
- package/dist/cli/watch/freshness.js +30 -0
- package/dist/cli/watch/state-visualization.js +147 -0
- package/dist/cli/watch/theme.js +6 -7
- package/dist/cli/watch/use-watch-stream.js +5 -2
- package/dist/cli/watch/watch-state.js +9 -5
- package/dist/config.js +129 -36
- package/dist/db/linear-installation-store.js +23 -0
- package/dist/db/migrations.js +42 -0
- package/dist/db/repository-link-store.js +103 -0
- package/dist/db.js +61 -11
- package/dist/factory-state.js +1 -5
- package/dist/github-webhook-handler.js +115 -46
- package/dist/github-webhooks.js +4 -0
- package/dist/http.js +162 -0
- package/dist/install.js +93 -13
- package/dist/issue-query-service.js +34 -1
- package/dist/linear-client.js +80 -25
- package/dist/merge-queue-incident.js +104 -0
- package/dist/merge-queue-protocol.js +54 -0
- package/dist/preflight.js +28 -1
- package/dist/repository-linking.js +42 -0
- package/dist/run-orchestrator.js +197 -21
- package/dist/runtime-paths.js +0 -8
- package/dist/service.js +94 -49
- package/package.json +8 -7
- package/dist/cli/commands/connect.js +0 -54
- package/dist/cli/commands/project.js +0 -146
- package/dist/merge-queue.js +0 -200
- package/infra/patchrelay-reload.service +0 -6
- 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,
|
|
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.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
|
74
|
-
"5.
|
|
75
|
-
"6. Run `patchrelay
|
|
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.
|
|
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
|
|
104
|
-
: "PatchRelay system service
|
|
105
|
-
"After package updates, run: patchrelay restart
|
|
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.
|
|
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);
|
package/dist/cli/connect-flow.js
CHANGED
|
@@ -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,
|
|
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 ? `
|
|
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
|
|
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)
|