patchrelay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/config/patchrelay.example.json +5 -0
  4. package/dist/build-info.js +29 -0
  5. package/dist/build-info.json +6 -0
  6. package/dist/cli/data.js +461 -0
  7. package/dist/cli/formatters/json.js +3 -0
  8. package/dist/cli/formatters/text.js +119 -0
  9. package/dist/cli/index.js +761 -0
  10. package/dist/codex-app-server.js +353 -0
  11. package/dist/codex-types.js +1 -0
  12. package/dist/config-types.js +1 -0
  13. package/dist/config.js +494 -0
  14. package/dist/db/authoritative-ledger-store.js +437 -0
  15. package/dist/db/issue-workflow-store.js +690 -0
  16. package/dist/db/linear-installation-store.js +184 -0
  17. package/dist/db/migrations.js +183 -0
  18. package/dist/db/shared.js +101 -0
  19. package/dist/db/stage-event-store.js +33 -0
  20. package/dist/db/webhook-event-store.js +46 -0
  21. package/dist/db-ports.js +5 -0
  22. package/dist/db-types.js +1 -0
  23. package/dist/db.js +40 -0
  24. package/dist/file-permissions.js +40 -0
  25. package/dist/http.js +321 -0
  26. package/dist/index.js +69 -0
  27. package/dist/install.js +302 -0
  28. package/dist/installation-ports.js +1 -0
  29. package/dist/issue-query-service.js +68 -0
  30. package/dist/ledger-ports.js +1 -0
  31. package/dist/linear-client.js +338 -0
  32. package/dist/linear-oauth-service.js +131 -0
  33. package/dist/linear-oauth.js +154 -0
  34. package/dist/linear-types.js +1 -0
  35. package/dist/linear-workflow.js +78 -0
  36. package/dist/logging.js +62 -0
  37. package/dist/preflight.js +227 -0
  38. package/dist/project-resolution.js +51 -0
  39. package/dist/reconciliation-action-applier.js +55 -0
  40. package/dist/reconciliation-actions.js +1 -0
  41. package/dist/reconciliation-engine.js +312 -0
  42. package/dist/reconciliation-snapshot-builder.js +96 -0
  43. package/dist/reconciliation-types.js +1 -0
  44. package/dist/runtime-paths.js +89 -0
  45. package/dist/service-queue.js +49 -0
  46. package/dist/service-runtime.js +96 -0
  47. package/dist/service-stage-finalizer.js +348 -0
  48. package/dist/service-stage-runner.js +233 -0
  49. package/dist/service-webhook-processor.js +181 -0
  50. package/dist/service-webhooks.js +148 -0
  51. package/dist/service.js +139 -0
  52. package/dist/stage-agent-activity-publisher.js +33 -0
  53. package/dist/stage-event-ports.js +1 -0
  54. package/dist/stage-failure.js +92 -0
  55. package/dist/stage-launch.js +54 -0
  56. package/dist/stage-lifecycle-publisher.js +213 -0
  57. package/dist/stage-reporting.js +153 -0
  58. package/dist/stage-turn-input-dispatcher.js +102 -0
  59. package/dist/token-crypto.js +21 -0
  60. package/dist/types.js +5 -0
  61. package/dist/utils.js +163 -0
  62. package/dist/webhook-agent-session-handler.js +157 -0
  63. package/dist/webhook-archive.js +24 -0
  64. package/dist/webhook-comment-handler.js +89 -0
  65. package/dist/webhook-desired-stage-recorder.js +150 -0
  66. package/dist/webhook-event-ports.js +1 -0
  67. package/dist/webhook-installation-handler.js +57 -0
  68. package/dist/webhooks.js +301 -0
  69. package/dist/workflow-policy.js +42 -0
  70. package/dist/workflow-ports.js +1 -0
  71. package/dist/workflow-types.js +1 -0
  72. package/dist/worktree-manager.js +66 -0
  73. package/infra/patchrelay-reload.service +6 -0
  74. package/infra/patchrelay.path +11 -0
  75. package/infra/patchrelay.service +28 -0
  76. package/package.json +55 -0
  77. package/runtime.env.example +8 -0
  78. package/service.env.example +7 -0
@@ -0,0 +1,301 @@
1
+ function deriveTriggerEvent(payload) {
2
+ if (payload.type === "AgentSessionEvent") {
3
+ if (payload.action === "created") {
4
+ return "agentSessionCreated";
5
+ }
6
+ if (payload.action === "prompted") {
7
+ return "agentPrompted";
8
+ }
9
+ return "issueUpdated";
10
+ }
11
+ if (payload.type === "Issue") {
12
+ if (payload.action === "create") {
13
+ return "issueCreated";
14
+ }
15
+ if (payload.action === "remove") {
16
+ return "issueRemoved";
17
+ }
18
+ const updatedFields = new Set(Object.keys(payload.updatedFrom ?? {}));
19
+ if (updatedFields.has("labels")) {
20
+ return "labelChanged";
21
+ }
22
+ if (updatedFields.has("stateId") || updatedFields.has("state")) {
23
+ return "statusChanged";
24
+ }
25
+ if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
26
+ return "assignmentChanged";
27
+ }
28
+ if (updatedFields.has("delegateId") || updatedFields.has("delegate")) {
29
+ return "delegateChanged";
30
+ }
31
+ return "issueUpdated";
32
+ }
33
+ if (payload.type === "Comment") {
34
+ if (payload.action === "create") {
35
+ return "commentCreated";
36
+ }
37
+ if (payload.action === "remove") {
38
+ return "commentRemoved";
39
+ }
40
+ return "commentUpdated";
41
+ }
42
+ if (payload.type === "PermissionChange") {
43
+ return "installationPermissionsChanged";
44
+ }
45
+ if (payload.type === "OAuthApp" && payload.action === "revoked") {
46
+ return "installationRevoked";
47
+ }
48
+ if (payload.type === "AppUserNotification") {
49
+ return "appUserNotification";
50
+ }
51
+ return "issueUpdated";
52
+ }
53
+ function asRecord(value) {
54
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
55
+ }
56
+ function getString(record, key) {
57
+ const value = record[key];
58
+ return typeof value === "string" && value.trim() ? value : undefined;
59
+ }
60
+ function getBoolean(record, key) {
61
+ const value = record[key];
62
+ return typeof value === "boolean" ? value : undefined;
63
+ }
64
+ function getStringArray(record, key) {
65
+ const value = record[key];
66
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.trim().length > 0) : [];
67
+ }
68
+ function extractLabelNames(record) {
69
+ const source = record.labels;
70
+ if (Array.isArray(source)) {
71
+ return source
72
+ .flatMap((entry) => {
73
+ if (typeof entry === "string") {
74
+ return [entry];
75
+ }
76
+ const entryRecord = asRecord(entry);
77
+ const name = entryRecord ? getString(entryRecord, "name") : undefined;
78
+ return name ? [name] : [];
79
+ })
80
+ .filter(Boolean);
81
+ }
82
+ const labelsRecord = asRecord(source);
83
+ const nodes = labelsRecord?.nodes;
84
+ if (Array.isArray(nodes)) {
85
+ return nodes
86
+ .flatMap((entry) => {
87
+ const entryRecord = asRecord(entry);
88
+ const name = entryRecord ? getString(entryRecord, "name") : undefined;
89
+ return name ? [name] : [];
90
+ })
91
+ .filter(Boolean);
92
+ }
93
+ return [];
94
+ }
95
+ function extractIssueMetadata(payload) {
96
+ const data = asRecord(payload.data);
97
+ if (!data) {
98
+ return undefined;
99
+ }
100
+ const issueRecord = payload.type === "Issue"
101
+ ? data
102
+ : payload.type === "AgentSessionEvent"
103
+ ? (() => {
104
+ const sessionRecord = asRecord(data.agentSession) ?? data;
105
+ return asRecord(sessionRecord.issue) ?? asRecord(data.issue) ?? sessionRecord;
106
+ })()
107
+ : payload.type === "AppUserNotification"
108
+ ? (() => {
109
+ const notificationRecord = asRecord(data.notification) ?? data;
110
+ return (asRecord(notificationRecord.issue) ??
111
+ asRecord(asRecord(notificationRecord.comment)?.issue) ??
112
+ asRecord(data.issue));
113
+ })()
114
+ : (() => {
115
+ const nestedIssue = asRecord(data.issue);
116
+ return nestedIssue ?? data;
117
+ })();
118
+ if (!issueRecord) {
119
+ return undefined;
120
+ }
121
+ const id = getString(issueRecord, "id") ?? getString(data, "issueId");
122
+ if (!id) {
123
+ return undefined;
124
+ }
125
+ const teamRecord = asRecord(issueRecord.team);
126
+ const identifier = getString(issueRecord, "identifier");
127
+ const title = getString(issueRecord, "title");
128
+ const url = getString(issueRecord, "url") ?? payload.url;
129
+ const delegateRecord = asRecord(issueRecord.delegate);
130
+ const teamId = getString(issueRecord, "teamId") ?? getString(teamRecord ?? {}, "id");
131
+ const teamKey = getString(teamRecord ?? {}, "key");
132
+ const stateRecord = asRecord(issueRecord.state);
133
+ const stateId = getString(issueRecord, "stateId") ?? getString(stateRecord ?? {}, "id");
134
+ const stateName = getString(stateRecord ?? {}, "name");
135
+ const stateType = getString(stateRecord ?? {}, "type");
136
+ const delegateId = getString(issueRecord, "delegateId") ?? getString(delegateRecord ?? {}, "id");
137
+ const delegateName = getString(delegateRecord ?? {}, "name");
138
+ return {
139
+ id,
140
+ ...(identifier ? { identifier } : {}),
141
+ ...(title ? { title } : {}),
142
+ ...(url ? { url } : {}),
143
+ ...(teamId ? { teamId } : {}),
144
+ ...(teamKey ? { teamKey } : {}),
145
+ ...(stateId ? { stateId } : {}),
146
+ ...(stateName ? { stateName } : {}),
147
+ ...(stateType ? { stateType } : {}),
148
+ ...(delegateId ? { delegateId } : {}),
149
+ ...(delegateName ? { delegateName } : {}),
150
+ labelNames: extractLabelNames(issueRecord),
151
+ };
152
+ }
153
+ function extractActorFromRecord(record) {
154
+ if (!record) {
155
+ return undefined;
156
+ }
157
+ const nestedUser = asRecord(record.user);
158
+ const id = getString(record, "id") ?? getString(record, "actorId") ?? getString(record, "userId") ?? getString(nestedUser ?? {}, "id");
159
+ const name = getString(record, "name") ?? getString(nestedUser ?? {}, "name");
160
+ const email = getString(record, "email") ?? getString(nestedUser ?? {}, "email");
161
+ const type = getString(record, "type") ?? getString(record, "__typename");
162
+ if (!id && !name && !email && !type) {
163
+ return undefined;
164
+ }
165
+ return {
166
+ ...(id ? { id } : {}),
167
+ ...(name ? { name } : {}),
168
+ ...(email ? { email } : {}),
169
+ ...(type ? { type } : {}),
170
+ };
171
+ }
172
+ function extractActorMetadata(payload) {
173
+ const payloadActor = extractActorFromRecord(asRecord(payload.actor));
174
+ if (payloadActor) {
175
+ return payloadActor;
176
+ }
177
+ const data = asRecord(payload.data);
178
+ const fallbacks = [
179
+ extractActorFromRecord(asRecord(data?.actor)),
180
+ extractActorFromRecord(asRecord(data?.user)),
181
+ extractActorFromRecord(asRecord(data?.creator)),
182
+ extractActorFromRecord(asRecord(data?.createdBy)),
183
+ ];
184
+ return fallbacks.find(Boolean);
185
+ }
186
+ function extractCommentMetadata(payload) {
187
+ if (payload.type !== "Comment") {
188
+ return undefined;
189
+ }
190
+ const data = asRecord(payload.data);
191
+ if (!data) {
192
+ return undefined;
193
+ }
194
+ const id = getString(data, "id");
195
+ const body = getString(data, "body");
196
+ const userRecord = asRecord(data.user);
197
+ const userName = getString(userRecord ?? {}, "name");
198
+ if (!id) {
199
+ return undefined;
200
+ }
201
+ return {
202
+ id,
203
+ ...(body ? { body } : {}),
204
+ ...(userName ? { userName } : {}),
205
+ };
206
+ }
207
+ function extractAgentSessionMetadata(payload) {
208
+ if (payload.type !== "AgentSessionEvent") {
209
+ return undefined;
210
+ }
211
+ const data = asRecord(payload.data);
212
+ if (!data) {
213
+ return undefined;
214
+ }
215
+ const sessionRecord = asRecord(data.agentSession) ?? data;
216
+ const id = getString(sessionRecord, "id") ?? getString(data, "agentSessionId");
217
+ if (!id) {
218
+ return undefined;
219
+ }
220
+ const agentActivity = asRecord(data.agentActivity);
221
+ const commentRecord = asRecord(data.comment) ?? asRecord(sessionRecord.comment);
222
+ const promptContext = getString(data, "promptContext") ?? getString(sessionRecord, "promptContext");
223
+ const promptBody = getString(agentActivity ?? {}, "body") ?? getString(commentRecord ?? {}, "body");
224
+ const issueCommentId = getString(commentRecord ?? {}, "id");
225
+ return {
226
+ id,
227
+ ...(promptContext ? { promptContext } : {}),
228
+ ...(promptBody ? { promptBody } : {}),
229
+ ...(issueCommentId ? { issueCommentId } : {}),
230
+ };
231
+ }
232
+ function extractInstallationMetadata(payload) {
233
+ const data = asRecord(payload.data);
234
+ if (!data) {
235
+ return undefined;
236
+ }
237
+ if (payload.type === "PermissionChange") {
238
+ const organizationId = getString(data, "organizationId");
239
+ const oauthClientId = getString(data, "oauthClientId");
240
+ const appUserId = getString(data, "appUserId");
241
+ const canAccessAllPublicTeams = getBoolean(data, "canAccessAllPublicTeams");
242
+ return {
243
+ ...(organizationId ? { organizationId } : {}),
244
+ ...(oauthClientId ? { oauthClientId } : {}),
245
+ ...(appUserId ? { appUserId } : {}),
246
+ ...(canAccessAllPublicTeams !== undefined ? { canAccessAllPublicTeams } : {}),
247
+ addedTeamIds: getStringArray(data, "addedTeamIds"),
248
+ removedTeamIds: getStringArray(data, "removedTeamIds"),
249
+ };
250
+ }
251
+ if (payload.type === "OAuthApp") {
252
+ const organizationId = getString(data, "organizationId");
253
+ const oauthClientId = getString(data, "oauthClientId");
254
+ return {
255
+ ...(organizationId ? { organizationId } : {}),
256
+ ...(oauthClientId ? { oauthClientId } : {}),
257
+ addedTeamIds: [],
258
+ removedTeamIds: [],
259
+ };
260
+ }
261
+ if (payload.type === "AppUserNotification") {
262
+ const notificationRecord = asRecord(data.notification) ?? data;
263
+ const organizationId = getString(data, "organizationId");
264
+ const oauthClientId = getString(data, "oauthClientId");
265
+ const appUserId = getString(data, "appUserId");
266
+ const notificationType = getString(notificationRecord, "type");
267
+ return {
268
+ ...(organizationId ? { organizationId } : {}),
269
+ ...(oauthClientId ? { oauthClientId } : {}),
270
+ ...(appUserId ? { appUserId } : {}),
271
+ ...(notificationType ? { notificationType } : {}),
272
+ addedTeamIds: [],
273
+ removedTeamIds: [],
274
+ };
275
+ }
276
+ return undefined;
277
+ }
278
+ export function normalizeWebhook(params) {
279
+ const issue = extractIssueMetadata(params.payload);
280
+ const comment = extractCommentMetadata(params.payload);
281
+ const agentSession = extractAgentSessionMetadata(params.payload);
282
+ const installation = extractInstallationMetadata(params.payload);
283
+ const actor = extractActorMetadata(params.payload);
284
+ if (!issue && !installation) {
285
+ throw new Error(`Unable to determine issue metadata from ${params.payload.type} webhook`);
286
+ }
287
+ const triggerEvent = deriveTriggerEvent(params.payload);
288
+ return {
289
+ webhookId: params.webhookId,
290
+ entityType: params.payload.type,
291
+ action: params.payload.action,
292
+ triggerEvent,
293
+ eventType: `${params.payload.type}.${params.payload.action}`,
294
+ ...(actor ? { actor } : {}),
295
+ ...(issue ? { issue } : {}),
296
+ ...(comment ? { comment } : {}),
297
+ ...(agentSession ? { agentSession } : {}),
298
+ ...(installation ? { installation } : {}),
299
+ payload: params.payload,
300
+ };
301
+ }
@@ -0,0 +1,42 @@
1
+ function normalize(value) {
2
+ const trimmed = value?.trim();
3
+ return trimmed ? trimmed.toLowerCase() : undefined;
4
+ }
5
+ function extractIssuePrefix(identifier) {
6
+ const value = identifier?.trim();
7
+ if (!value) {
8
+ return undefined;
9
+ }
10
+ const [prefix] = value.split("-", 1);
11
+ return prefix ? prefix.toUpperCase() : undefined;
12
+ }
13
+ export function resolveWorkflow(project, stateName) {
14
+ const normalized = normalize(stateName);
15
+ if (!normalized) {
16
+ return undefined;
17
+ }
18
+ return project.workflows.find((workflow) => normalize(workflow.whenState) === normalized);
19
+ }
20
+ export function resolveWorkflowStage(project, stateName) {
21
+ return resolveWorkflow(project, stateName)?.id;
22
+ }
23
+ export function resolveWorkflowById(project, workflowId) {
24
+ const normalized = normalize(workflowId);
25
+ if (!normalized) {
26
+ return undefined;
27
+ }
28
+ return project.workflows.find((workflow) => normalize(workflow.id) === normalized);
29
+ }
30
+ export function listRunnableStates(project) {
31
+ return project.workflows.map((workflow) => workflow.whenState);
32
+ }
33
+ export function matchesProject(issue, project) {
34
+ const issuePrefix = extractIssuePrefix(issue.identifier);
35
+ const teamCandidates = [issue.teamId, issue.teamKey].filter((value) => Boolean(value));
36
+ const labelNames = new Set(issue.labelNames.map((label) => label.toLowerCase()));
37
+ const matchesPrefix = project.issueKeyPrefixes.length === 0 ||
38
+ (issuePrefix ? project.issueKeyPrefixes.map((value) => value.toUpperCase()).includes(issuePrefix) : false);
39
+ const matchesTeam = project.linearTeamIds.length === 0 || teamCandidates.some((candidate) => project.linearTeamIds.includes(candidate));
40
+ const matchesLabel = project.allowLabels.length === 0 || project.allowLabels.some((label) => labelNames.has(label.toLowerCase()));
41
+ return matchesPrefix && matchesTeam && matchesLabel;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureDir, execCommand } from "./utils.js";
4
+ export class WorktreeManager {
5
+ config;
6
+ constructor(config) {
7
+ this.config = config;
8
+ }
9
+ async ensureIssueWorktree(repoPath, worktreeRoot, worktreePath, branchName) {
10
+ if (existsSync(worktreePath)) {
11
+ await this.assertTrustedExistingWorktree(repoPath, worktreeRoot, worktreePath);
12
+ return;
13
+ }
14
+ await ensureDir(path.dirname(worktreePath));
15
+ await execCommand(this.config.runner.gitBin, ["-C", repoPath, "worktree", "add", "--force", "-B", branchName, worktreePath, "HEAD"], {
16
+ timeoutMs: 120_000,
17
+ });
18
+ }
19
+ async assertTrustedExistingWorktree(repoPath, worktreeRoot, worktreePath) {
20
+ const worktreeStats = lstatSync(worktreePath);
21
+ if (worktreeStats.isSymbolicLink()) {
22
+ throw new Error(`Refusing to reuse symlinked worktree path: ${worktreePath}`);
23
+ }
24
+ if (!worktreeStats.isDirectory()) {
25
+ throw new Error(`Refusing to reuse non-directory worktree path: ${worktreePath}`);
26
+ }
27
+ const resolvedRoot = realpathSync(worktreeRoot);
28
+ const resolvedWorktree = realpathSync(worktreePath);
29
+ if (!isPathWithinRoot(resolvedRoot, resolvedWorktree)) {
30
+ throw new Error(`Refusing to reuse worktree outside configured root: ${worktreePath}`);
31
+ }
32
+ const listedWorktrees = await this.listRegisteredWorktrees(repoPath);
33
+ if (!listedWorktrees.has(resolvedWorktree)) {
34
+ throw new Error(`Refusing to reuse unregistered worktree path: ${worktreePath}`);
35
+ }
36
+ }
37
+ async listRegisteredWorktrees(repoPath) {
38
+ const result = await execCommand(this.config.runner.gitBin, ["-C", repoPath, "worktree", "list", "--porcelain"], {
39
+ timeoutMs: 120_000,
40
+ });
41
+ if (result.exitCode !== 0) {
42
+ throw new Error(`Unable to verify registered worktrees for ${repoPath}`);
43
+ }
44
+ const worktrees = new Set();
45
+ for (const line of result.stdout.split(/\r?\n/)) {
46
+ if (!line.startsWith("worktree ")) {
47
+ continue;
48
+ }
49
+ const listedPath = line.slice("worktree ".length).trim();
50
+ if (!listedPath) {
51
+ continue;
52
+ }
53
+ try {
54
+ worktrees.add(realpathSync(listedPath));
55
+ }
56
+ catch {
57
+ worktrees.add(path.resolve(listedPath));
58
+ }
59
+ }
60
+ return worktrees;
61
+ }
62
+ }
63
+ function isPathWithinRoot(rootPath, candidatePath) {
64
+ const relative = path.relative(rootPath, candidatePath);
65
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
66
+ }
@@ -0,0 +1,6 @@
1
+ [Unit]
2
+ Description=PatchRelay reload helper (systemd user service)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ ExecStart=/usr/bin/env systemctl --user reload-or-restart patchrelay.service
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Watch PatchRelay config and env changes
3
+
4
+ [Path]
5
+ Unit=patchrelay-reload.service
6
+ PathChanged=/home/your-user/.config/patchrelay/runtime.env
7
+ PathChanged=/home/your-user/.config/patchrelay/service.env
8
+ PathChanged=/home/your-user/.config/patchrelay/patchrelay.json
9
+
10
+ [Install]
11
+ WantedBy=default.target
@@ -0,0 +1,28 @@
1
+ [Unit]
2
+ Description=PatchRelay (systemd user service)
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ WorkingDirectory=/home/your-user
9
+ EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
10
+ EnvironmentFile=/home/your-user/.config/patchrelay/service.env
11
+ Environment=NODE_ENV=production
12
+ Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
13
+ Environment=PATH=/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
14
+ ExecStart=/usr/bin/env patchrelay serve
15
+ Restart=on-failure
16
+ RestartSec=5
17
+ NoNewPrivileges=true
18
+ PrivateTmp=true
19
+ ProtectSystem=full
20
+ ProtectHome=false
21
+ ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
22
+
23
+ # PatchRelay is intended to run as your real user so Codex inherits your
24
+ # existing git, SSH, and local tool permissions.
25
+ # Add your managed repository roots to ReadWritePaths if you keep hardening enabled.
26
+
27
+ [Install]
28
+ WantedBy=default.target
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "patchrelay",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/krasnoperov/patchrelay.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "bin": {
14
+ "patchrelay": "dist/index.js"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "runtime.env.example",
19
+ "service.env.example",
20
+ "config/patchrelay.example.json",
21
+ "infra/patchrelay.service",
22
+ "infra/patchrelay-reload.service",
23
+ "infra/patchrelay.path"
24
+ ],
25
+ "description": "Self-hosted harness for Linear-driven Codex work with durable issue worktrees, staged runs, and inspection.",
26
+ "engines": {
27
+ "node": ">=24.0.0"
28
+ },
29
+ "scripts": {
30
+ "dev": "node --watch --experimental-transform-types src/index.ts",
31
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/index.js && node scripts/write-build-info.mjs",
32
+ "prepack": "npm run build",
33
+ "start": "node dist/index.js serve",
34
+ "doctor": "node dist/index.js doctor",
35
+ "restart": "node dist/index.js restart-service",
36
+ "lint": "eslint .",
37
+ "check": "tsc -p tsconfig.json --noEmit",
38
+ "test": "node --experimental-transform-types --test test/**/*.test.ts",
39
+ "ci": "npm run lint && npm run check && npm test && npm run build"
40
+ },
41
+ "dependencies": {
42
+ "fastify": "^5.8.2",
43
+ "fastify-raw-body": "^5.0.0",
44
+ "pino": "^10.3.1",
45
+ "pino-logfmt": "^1.1.3",
46
+ "zod": "^4.3.6"
47
+ },
48
+ "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
50
+ "@types/node": "^24.12.0",
51
+ "eslint": "^10.0.3",
52
+ "typescript": "^5.9.3",
53
+ "typescript-eslint": "^8.57.0"
54
+ }
55
+ }
@@ -0,0 +1,8 @@
1
+ # PatchRelay runtime overrides.
2
+ # These are non-secret process-level overrides that both the service and local
3
+ # CLI may need to read.
4
+
5
+ # PATCHRELAY_CONFIG=$HOME/.config/patchrelay/patchrelay.json
6
+ # PATCHRELAY_DB_PATH=$HOME/.local/state/patchrelay/patchrelay.sqlite
7
+ # PATCHRELAY_LOG_LEVEL=info
8
+ # PATCHRELAY_LOG_FILE=$HOME/.local/state/patchrelay/patchrelay.log
@@ -0,0 +1,7 @@
1
+ # PatchRelay service secrets.
2
+ # Keep these values at the machine level for this PatchRelay instance.
3
+
4
+ LINEAR_WEBHOOK_SECRET=replace-with-linear-webhook-secret
5
+ PATCHRELAY_TOKEN_ENCRYPTION_KEY=replace-with-long-random-secret
6
+ LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
7
+ LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret