patchrelay 0.14.0 → 0.14.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 CHANGED
@@ -150,14 +150,14 @@ patchrelay init https://patchrelay.example.com
150
150
 
151
151
  `patchrelay init` requires the public HTTPS origin up front because Linear needs a fixed webhook URL and OAuth callback URL for this PatchRelay instance.
152
152
 
153
- It creates the local config, env file, and user service units:
153
+ It creates the local config, env file, and system service units:
154
154
 
155
155
  - `~/.config/patchrelay/runtime.env`
156
156
  - `~/.config/patchrelay/service.env`
157
157
  - `~/.config/patchrelay/patchrelay.json`
158
- - `~/.config/systemd/user/patchrelay.service`
159
- - `~/.config/systemd/user/patchrelay-reload.service`
160
- - `~/.config/systemd/user/patchrelay.path`
158
+ - `/etc/systemd/system/patchrelay.service`
159
+ - `/etc/systemd/system/patchrelay-reload.service`
160
+ - `/etc/systemd/system/patchrelay.path`
161
161
 
162
162
  The generated `patchrelay.json` is intentionally minimal, and `patchrelay init` prints the webhook URL, OAuth callback URL, and the Linear app values you need next.
163
163
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.14.0",
4
- "commit": "e8b5806460ef",
5
- "builtAt": "2026-03-25T09:41:38.220Z"
3
+ "version": "0.14.1",
4
+ "commit": "ceb28a181ab2",
5
+ "builtAt": "2026-03-25T11:27:52.659Z"
6
6
  }
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "../../config.js";
2
- import { installUserServiceUnits, upsertProjectInConfig } from "../../install.js";
2
+ import { installServiceUnits, upsertProjectInConfig } from "../../install.js";
3
3
  import { hasHelpFlag, parseCsvFlag } from "../args.js";
4
4
  import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
5
5
  import { CliUsageError } from "../errors.js";
@@ -30,7 +30,7 @@ export async function handleProjectCommand(params) {
30
30
  issueKeyPrefixes: parseCsvFlag(params.parsed.flags.get("issue-prefix")),
31
31
  linearTeamIds: parseCsvFlag(params.parsed.flags.get("team-id")),
32
32
  });
33
- const serviceUnits = await installUserServiceUnits();
33
+ const serviceUnits = await installServiceUnits();
34
34
  const noConnect = params.parsed.flags.get("no-connect") === true;
35
35
  const lines = [
36
36
  `Config file: ${result.configPath}`,
@@ -66,7 +66,7 @@ export async function handleProjectCommand(params) {
66
66
  return 0;
67
67
  }
68
68
  const { runPreflight } = await import("../../preflight.js");
69
- const report = await runPreflight(fullConfig);
69
+ const report = await runPreflight(fullConfig, { skipServiceCheck: true });
70
70
  const failedChecks = report.checks.filter((check) => check.status === "fail");
71
71
  if (failedChecks.length > 0) {
72
72
  if (params.json) {
@@ -1,5 +1,5 @@
1
- import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, } from "../../runtime-paths.js";
2
- import { initializePatchRelayHome, installUserServiceUnits } from "../../install.js";
1
+ import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdPathUnitPath, getSystemdReloadUnitPath, getSystemdUnitPath, } from "../../runtime-paths.js";
2
+ import { initializePatchRelayHome, installServiceUnits } from "../../install.js";
3
3
  import { formatJson } from "../formatters/json.js";
4
4
  import { writeOutput } from "../output.js";
5
5
  import { installServiceCommands, restartServiceCommands, runServiceCommands, tryManageService } from "../service-commands.js";
@@ -20,7 +20,7 @@ export async function handleInitCommand(params) {
20
20
  force: params.parsed.flags.get("force") === true,
21
21
  publicBaseUrl,
22
22
  });
23
- const serviceUnits = await installUserServiceUnits({ force: params.parsed.flags.get("force") === true });
23
+ const serviceUnits = await installServiceUnits({ force: params.parsed.flags.get("force") === true });
24
24
  const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
25
25
  writeOutput(params.stdout, params.json
26
26
  ? formatJson({ ...result, serviceUnits, serviceState })
@@ -43,7 +43,7 @@ export async function handleInitCommand(params) {
43
43
  "Created with defaults:",
44
44
  `- Config file contains only machine-level essentials such as server.public_base_url`,
45
45
  `- Database, logs, bind address, and worktree roots use built-in defaults`,
46
- `- The user service and config watcher are installed for you`,
46
+ `- The system service and config watcher are installed for you`,
47
47
  "",
48
48
  "Register the app in Linear:",
49
49
  "- Open Linear Settings > API > Applications",
@@ -85,7 +85,7 @@ export async function handleInitCommand(params) {
85
85
  }
86
86
  export async function handleInstallServiceCommand(params) {
87
87
  try {
88
- const result = await installUserServiceUnits({ force: params.parsed.flags.get("force") === true });
88
+ const result = await installServiceUnits({ force: params.parsed.flags.get("force") === true });
89
89
  const writeOnly = params.parsed.flags.get("write-only") === true;
90
90
  if (!writeOnly) {
91
91
  await runServiceCommands(params.runInteractive, installServiceCommands());
@@ -100,8 +100,8 @@ export async function handleInstallServiceCommand(params) {
100
100
  `Service env: ${result.serviceEnvPath}`,
101
101
  `Config file: ${result.configPath}`,
102
102
  writeOnly
103
- ? "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"
104
- : "PatchRelay user service and config watcher are installed and running.",
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
105
  "After package updates, run: patchrelay restart-service",
106
106
  ].join("\n") + "\n");
107
107
  return 0;
@@ -117,15 +117,15 @@ export async function handleRestartServiceCommand(params) {
117
117
  writeOutput(params.stdout, params.json
118
118
  ? formatJson({
119
119
  service: "patchrelay",
120
- unitPath: getSystemdUserUnitPath(),
121
- reloadUnitPath: getSystemdUserReloadUnitPath(),
122
- pathUnitPath: getSystemdUserPathUnitPath(),
120
+ unitPath: getSystemdUnitPath(),
121
+ reloadUnitPath: getSystemdReloadUnitPath(),
122
+ pathUnitPath: getSystemdPathUnitPath(),
123
123
  runtimeEnvPath: getDefaultRuntimeEnvPath(),
124
124
  serviceEnvPath: getDefaultServiceEnvPath(),
125
125
  configPath: getDefaultConfigPath(),
126
126
  restarted: true,
127
127
  })
128
- : "Reloaded systemd user units and reload-or-restart was requested for PatchRelay.\n");
128
+ : "Reloaded systemd units and reload-or-restart was requested for PatchRelay.\n");
129
129
  return 0;
130
130
  }
131
131
  catch (error) {
package/dist/cli/help.js CHANGED
@@ -23,7 +23,7 @@ export function rootHelpText() {
23
23
  " PatchRelay already defaults the local bind address, database path, log path, worktree",
24
24
  " root, and Codex runner settings. In the normal",
25
25
  " case you only need the public URL, the required secrets, and at least one project.",
26
- " `patchrelay init` installs the user service and config watcher, and `project apply`",
26
+ " `patchrelay init` installs the system service and config watcher, and `project apply`",
27
27
  " upserts the repo config and reuses or starts the Linear connection flow.",
28
28
  "",
29
29
  "Commands:",
@@ -32,8 +32,8 @@ export function rootHelpText() {
32
32
  " project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--no-open] [--timeout <seconds>] [--json]",
33
33
  " Upsert one local repository and connect it to Linear when ready",
34
34
  " doctor [--json] Check secrets, paths, git, and codex",
35
- " install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
36
- " restart-service [--json] Reload-or-restart the systemd user service",
35
+ " install-service [--force] [--write-only] [--json] Reinstall the systemd system service and watcher",
36
+ " restart-service [--json] Reload-or-restart the systemd system service",
37
37
  " connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
38
38
  " Advanced: start or reuse a Linear installation directly",
39
39
  " installations [--json] Show connected Linear installations",
@@ -17,15 +17,15 @@ export async function tryManageService(runner, commands) {
17
17
  }
18
18
  export function installServiceCommands() {
19
19
  return [
20
- { command: "systemctl", args: ["--user", "daemon-reload"] },
21
- { command: "systemctl", args: ["--user", "enable", "--now", "patchrelay.path"] },
22
- { command: "systemctl", args: ["--user", "enable", "patchrelay.service"] },
23
- { command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
20
+ { command: "sudo", args: ["systemctl", "daemon-reload"] },
21
+ { command: "sudo", args: ["systemctl", "enable", "--now", "patchrelay.path"] },
22
+ { command: "sudo", args: ["systemctl", "enable", "patchrelay.service"] },
23
+ { command: "sudo", args: ["systemctl", "reload-or-restart", "patchrelay.service"] },
24
24
  ];
25
25
  }
26
26
  export function restartServiceCommands() {
27
27
  return [
28
- { command: "systemctl", args: ["--user", "daemon-reload"] },
29
- { command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
28
+ { command: "sudo", args: ["systemctl", "daemon-reload"] },
29
+ { command: "sudo", args: ["systemctl", "reload-or-restart", "patchrelay.service"] },
30
30
  ];
31
31
  }
package/dist/install.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultLogPath, getDefaultWebhookArchiveDir, getPatchRelayConfigDir, getPatchRelayDataDir, getPatchRelayStateDir, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, readBundledAsset, } from "./runtime-paths.js";
6
+ import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultLogPath, getDefaultWebhookArchiveDir, getPatchRelayConfigDir, getPatchRelayDataDir, getPatchRelayStateDir, getSystemdPathUnitPath, getSystemdReloadUnitPath, getSystemdUnitPath, readBundledAsset, } from "./runtime-paths.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { enforceArbitraryFilePermissions } from "./file-permissions.js";
9
9
  import { ensureAbsolutePath } from "./utils.js";
@@ -175,11 +175,11 @@ export async function initializePatchRelayHome(options) {
175
175
  : {}),
176
176
  };
177
177
  }
178
- export async function installUserServiceUnits(options) {
178
+ export async function installServiceUnits(options) {
179
179
  const force = options?.force ?? false;
180
- const unitPath = getSystemdUserUnitPath();
181
- const reloadUnitPath = getSystemdUserReloadUnitPath();
182
- const pathUnitPath = getSystemdUserPathUnitPath();
180
+ const unitPath = getSystemdUnitPath();
181
+ const reloadUnitPath = getSystemdReloadUnitPath();
182
+ const pathUnitPath = getSystemdPathUnitPath();
183
183
  const serviceStatus = await writeTemplateFile(unitPath, renderTemplate(readBundledAsset("infra/patchrelay.service")), force);
184
184
  const reloadStatus = await writeTemplateFile(reloadUnitPath, renderTemplate(readBundledAsset("infra/patchrelay-reload.service")), force);
185
185
  const pathStatus = await writeTemplateFile(pathUnitPath, renderTemplate(readBundledAsset("infra/patchrelay.path")), force);
package/dist/preflight.js CHANGED
@@ -2,34 +2,15 @@ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs"
2
2
  import path from "node:path";
3
3
  import { runPatchRelayMigrations } from "./db/migrations.js";
4
4
  import { SqliteConnection } from "./db/shared.js";
5
- import { resolveSecret } from "./resolve-secret.js";
6
5
  import { execCommand } from "./utils.js";
7
6
  export async function runPreflight(config, options) {
8
7
  const connectivity = options?.connectivity ?? true;
8
+ const skipServiceCheck = options?.skipServiceCheck ?? false;
9
9
  const checks = [];
10
- if (!config.linear.webhookSecret) {
11
- checks.push(fail("linear", "LINEAR_WEBHOOK_SECRET is missing"));
12
- }
13
- else {
14
- checks.push(pass("linear", "Linear webhook secret is configured"));
15
- }
16
- if (!config.linear.oauth.clientId) {
17
- checks.push(fail("linear_oauth", "LINEAR_OAUTH_CLIENT_ID is missing"));
18
- }
19
- else {
20
- checks.push(pass("linear_oauth", `Linear OAuth is configured with actor=${config.linear.oauth.actor}`));
21
- }
22
- if (!config.linear.oauth.clientSecret) {
23
- checks.push(fail("linear_oauth", "LINEAR_OAUTH_CLIENT_SECRET is missing"));
24
- }
25
- else {
26
- checks.push(pass("linear_oauth", "Linear OAuth client secret is configured"));
27
- }
28
- if (!config.linear.tokenEncryptionKey) {
29
- checks.push(fail("linear_oauth", "PATCHRELAY_TOKEN_ENCRYPTION_KEY is missing"));
30
- }
31
- else {
32
- checks.push(pass("linear_oauth", "Token encryption key is configured"));
10
+ // Secrets are managed by systemd credstore — the CLI cannot read them directly.
11
+ // Instead, query the running service's readiness endpoint to verify secrets are loaded.
12
+ if (!skipServiceCheck) {
13
+ checks.push(await checkServiceReadiness(config));
33
14
  }
34
15
  if (config.linear.oauth.actor === "app") {
35
16
  const scopes = new Set(config.linear.oauth.scopes);
@@ -84,24 +65,44 @@ export async function runPreflight(config, options) {
84
65
  }
85
66
  checks.push(await checkExecutable("git", config.runner.gitBin));
86
67
  checks.push(await checkExecutable("codex", config.runner.codex.bin));
87
- // Connectivity checks — verify secrets actually work against live APIs.
68
+ // Connectivity checks — verify external APIs are reachable.
88
69
  // Skipped when graphqlUrl uses a non-routable domain (.example, .test, .invalid).
89
70
  const skipConnectivity = !connectivity || isNonRoutableDomain(config.linear.graphqlUrl);
90
71
  if (!skipConnectivity) {
91
- if (config.linear.oauth.clientId && config.linear.oauth.clientSecret) {
92
- checks.push(await checkLinearApi(config.linear.graphqlUrl));
93
- }
94
- const ghAppId = process.env.PATCHRELAY_GITHUB_APP_ID;
95
- const ghAppKey = resolveSecret("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
96
- if (ghAppId && ghAppKey) {
97
- checks.push(await checkGitHubApp(ghAppId, ghAppKey));
98
- }
72
+ checks.push(await checkLinearApi(config.linear.graphqlUrl));
99
73
  }
100
74
  return {
101
75
  checks,
102
76
  ok: checks.every((check) => check.status !== "fail"),
103
77
  };
104
78
  }
79
+ async function checkServiceReadiness(config) {
80
+ const host = config.server.bind === "0.0.0.0" ? "127.0.0.1" : config.server.bind;
81
+ const url = `http://${host}:${config.server.port}${config.server.readinessPath}`;
82
+ try {
83
+ const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
84
+ const body = await response.json();
85
+ if (response.ok && body.ready) {
86
+ const parts = ["Service is running and ready"];
87
+ if (body.version)
88
+ parts[0] += ` (v${body.version})`;
89
+ if (body.codexStarted)
90
+ parts.push("codex started");
91
+ if (body.linearConnected)
92
+ parts.push("Linear connected");
93
+ return pass("service", parts.join(", "));
94
+ }
95
+ const issues = [];
96
+ if (!body.codexStarted)
97
+ issues.push("codex not started");
98
+ if (!body.linearConnected)
99
+ issues.push("Linear not connected");
100
+ return warn("service", `Service is running but not ready: ${issues.join(", ") || "unknown reason"}`);
101
+ }
102
+ catch {
103
+ return fail("service", `Service is not reachable at ${url} — is it running? (sudo systemctl status patchrelay)`);
104
+ }
105
+ }
105
106
  async function checkLinearApi(graphqlUrl) {
106
107
  try {
107
108
  const response = await fetch(graphqlUrl, {
@@ -119,44 +120,6 @@ async function checkLinearApi(graphqlUrl) {
119
120
  return fail("linear_api", `Linear GraphQL API is unreachable at ${graphqlUrl}: ${formatError(error)}`);
120
121
  }
121
122
  }
122
- async function checkGitHubApp(appId, privateKey) {
123
- try {
124
- const { createSign } = await import("node:crypto");
125
- const now = Math.floor(Date.now() / 1000);
126
- const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
127
- const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 120, iss: appId })).toString("base64url");
128
- const signer = createSign("RSA-SHA256");
129
- signer.update(`${header}.${payload}`);
130
- let signature;
131
- try {
132
- signature = signer.sign(privateKey, "base64url");
133
- }
134
- catch (error) {
135
- return fail("github_app", `GitHub App private key is invalid: ${formatError(error)}`);
136
- }
137
- const jwt = `${header}.${payload}.${signature}`;
138
- const response = await fetch("https://api.github.com/app", {
139
- headers: {
140
- Authorization: `Bearer ${jwt}`,
141
- Accept: "application/vnd.github+json",
142
- "X-GitHub-Api-Version": "2022-11-28",
143
- },
144
- signal: AbortSignal.timeout(5000),
145
- });
146
- if (response.ok) {
147
- const app = await response.json();
148
- const label = app.slug ?? app.name ?? appId;
149
- return pass("github_app", `GitHub App authenticated as "${label}"`);
150
- }
151
- if (response.status === 401) {
152
- return fail("github_app", "GitHub App authentication failed — check APP_ID and private key");
153
- }
154
- return warn("github_app", `GitHub App API returned ${response.status}`);
155
- }
156
- catch (error) {
157
- return fail("github_app", `GitHub API is unreachable: ${formatError(error)}`);
158
- }
159
- }
160
123
  function checkDatabaseHealth(config) {
161
124
  const checks = [];
162
125
  let connection;
@@ -14,7 +14,7 @@ export function getPatchRelayPathLayout() {
14
14
  const serviceEnvPath = path.join(configDir, "service.env");
15
15
  const stateDir = path.join(xdgStateHome, "patchrelay");
16
16
  const shareDir = path.join(xdgDataHome, "patchrelay");
17
- const systemdUserDir = path.join(xdgConfigHome, "systemd", "user");
17
+ const systemdDir = process.env.PATCHRELAY_SYSTEMD_DIR ?? "/etc/systemd/system";
18
18
  return {
19
19
  homeDir,
20
20
  configDir,
@@ -25,10 +25,10 @@ export function getPatchRelayPathLayout() {
25
25
  shareDir,
26
26
  databasePath: ensureAbsolutePath(process.env.PATCHRELAY_DB_PATH ?? path.join(stateDir, "patchrelay.sqlite")),
27
27
  logFilePath: ensureAbsolutePath(process.env.PATCHRELAY_LOG_FILE ?? path.join(stateDir, "patchrelay.log")),
28
- systemdUserDir,
29
- systemdUnitPath: path.join(systemdUserDir, "patchrelay.service"),
30
- systemdReloadUnitPath: path.join(systemdUserDir, "patchrelay-reload.service"),
31
- systemdPathUnitPath: path.join(systemdUserDir, "patchrelay.path"),
28
+ systemdDir,
29
+ systemdUnitPath: path.join(systemdDir, "patchrelay.service"),
30
+ systemdReloadUnitPath: path.join(systemdDir, "patchrelay-reload.service"),
31
+ systemdPathUnitPath: path.join(systemdDir, "patchrelay.path"),
32
32
  };
33
33
  }
34
34
  export function getPatchRelayConfigDir() {
@@ -58,13 +58,13 @@ export function getDefaultLogPath() {
58
58
  export function getDefaultWebhookArchiveDir() {
59
59
  return path.join(getPatchRelayStateDir(), "webhooks");
60
60
  }
61
- export function getSystemdUserUnitPath() {
61
+ export function getSystemdUnitPath() {
62
62
  return getPatchRelayPathLayout().systemdUnitPath;
63
63
  }
64
- export function getSystemdUserReloadUnitPath() {
64
+ export function getSystemdReloadUnitPath() {
65
65
  return getPatchRelayPathLayout().systemdReloadUnitPath;
66
66
  }
67
- export function getSystemdUserPathUnitPath() {
67
+ export function getSystemdPathUnitPath() {
68
68
  return getPatchRelayPathLayout().systemdPathUnitPath;
69
69
  }
70
70
  export function getPackageRoot() {
@@ -1,6 +1,6 @@
1
1
  [Unit]
2
- Description=PatchRelay reload helper (systemd user service)
2
+ Description=PatchRelay reload helper
3
3
 
4
4
  [Service]
5
5
  Type=oneshot
6
- ExecStart=/usr/bin/env systemctl --user reload-or-restart patchrelay.service
6
+ ExecStart=/usr/bin/env systemctl reload-or-restart patchrelay.service
@@ -10,4 +10,4 @@ TriggerLimitIntervalSec=5
10
10
  TriggerLimitBurst=1
11
11
 
12
12
  [Install]
13
- WantedBy=default.target
13
+ WantedBy=multi-user.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {