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
package/dist/config.js ADDED
@@ -0,0 +1,494 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isIP } from "node:net";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
6
+ import { ensureAbsolutePath } from "./utils.js";
7
+ const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
8
+ const workflowSchema = z.object({
9
+ id: z.string().min(1),
10
+ when_state: z.string().min(1),
11
+ active_state: z.string().min(1),
12
+ workflow_file: z.string().min(1),
13
+ fallback_state: z.string().min(1).nullable().optional(),
14
+ });
15
+ const projectSchema = z.object({
16
+ id: z.string().min(1),
17
+ repo_path: z.string().min(1),
18
+ worktree_root: z.string().min(1).optional(),
19
+ workflows: z.array(workflowSchema).min(1).optional(),
20
+ workflow_labels: z
21
+ .object({
22
+ working: z.string().min(1).optional(),
23
+ awaiting_handoff: z.string().min(1).optional(),
24
+ })
25
+ .optional(),
26
+ trusted_actors: z
27
+ .object({
28
+ ids: z.array(z.string().min(1)).default([]),
29
+ names: z.array(z.string().min(1)).default([]),
30
+ emails: z.array(z.string().email()).default([]),
31
+ email_domains: z.array(z.string().min(1)).default([]),
32
+ })
33
+ .optional(),
34
+ issue_key_prefixes: z.array(z.string().min(1)).default([]),
35
+ linear_team_ids: z.array(z.string().min(1)).default([]),
36
+ allow_labels: z.array(z.string().min(1)).default([]),
37
+ trigger_events: z.array(z.string().min(1)).min(1).optional(),
38
+ branch_prefix: z.string().min(1).optional(),
39
+ });
40
+ const configSchema = z.object({
41
+ server: z.object({
42
+ bind: z.string().default("127.0.0.1"),
43
+ port: z.number().int().positive().default(8787),
44
+ public_base_url: z.string().url().optional(),
45
+ health_path: z.string().default("/health"),
46
+ readiness_path: z.string().default("/ready"),
47
+ }),
48
+ ingress: z.object({
49
+ linear_webhook_path: z.string().default("/webhooks/linear"),
50
+ max_body_bytes: z.number().int().positive().default(262144),
51
+ max_timestamp_skew_seconds: z.number().int().positive().default(60),
52
+ }),
53
+ logging: z.object({
54
+ level: z.enum(["debug", "info", "warn", "error"]).default("info"),
55
+ format: z.literal("logfmt").default("logfmt"),
56
+ file_path: z.string().min(1).default(getDefaultLogPath()),
57
+ webhook_archive_dir: z.string().optional(),
58
+ }),
59
+ database: z.object({
60
+ path: z.string().min(1).default(getDefaultDatabasePath()),
61
+ wal: z.boolean().default(true),
62
+ }),
63
+ linear: z.object({
64
+ webhook_secret_env: z.string().default("LINEAR_WEBHOOK_SECRET"),
65
+ graphql_url: z.string().url().default("https://api.linear.app/graphql"),
66
+ token_encryption_key_env: z.string().default("PATCHRELAY_TOKEN_ENCRYPTION_KEY"),
67
+ oauth: z.object({
68
+ client_id_env: z.string().default("LINEAR_OAUTH_CLIENT_ID"),
69
+ client_secret_env: z.string().default("LINEAR_OAUTH_CLIENT_SECRET"),
70
+ redirect_uri: z.string().url().optional(),
71
+ scopes: z.array(z.string().min(1)).default(["read", "write", "app:assignable", "app:mentionable"]),
72
+ actor: z.enum(["user", "app"]).default("app"),
73
+ }),
74
+ }),
75
+ operator_api: z
76
+ .object({
77
+ enabled: z.boolean().default(false),
78
+ bearer_token_env: z.string().optional(),
79
+ })
80
+ .default({
81
+ enabled: false,
82
+ }),
83
+ runner: z.object({
84
+ git_bin: z.string().default("git"),
85
+ codex: z.object({
86
+ bin: z.string().default("codex"),
87
+ args: z.array(z.string()).default(["app-server"]),
88
+ shell_bin: z.string().optional(),
89
+ source_bashrc: z.boolean().default(true),
90
+ model: z.string().optional(),
91
+ model_provider: z.string().optional(),
92
+ service_name: z.string().default("patchrelay"),
93
+ base_instructions: z.string().optional(),
94
+ developer_instructions: z.string().optional(),
95
+ approval_policy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
96
+ sandbox_mode: z.enum(["danger-full-access", "workspace-write", "read-only"]).default("danger-full-access"),
97
+ persist_extended_history: z.boolean().default(false),
98
+ }),
99
+ }),
100
+ projects: z.array(projectSchema).default([]),
101
+ });
102
+ function defaultTriggerEvents(actor) {
103
+ if (actor === "app") {
104
+ return ["agentSessionCreated", "agentPrompted", "statusChanged"];
105
+ }
106
+ return ["statusChanged"];
107
+ }
108
+ const builtinWorkflows = [
109
+ {
110
+ id: "development",
111
+ when_state: "Start",
112
+ active_state: "Implementing",
113
+ workflow_file: "IMPLEMENTATION_WORKFLOW.md",
114
+ fallback_state: "Human Needed",
115
+ },
116
+ {
117
+ id: "review",
118
+ when_state: "Review",
119
+ active_state: "Reviewing",
120
+ workflow_file: "REVIEW_WORKFLOW.md",
121
+ fallback_state: "Human Needed",
122
+ },
123
+ {
124
+ id: "deploy",
125
+ when_state: "Deploy",
126
+ active_state: "Deploying",
127
+ workflow_file: "DEPLOY_WORKFLOW.md",
128
+ fallback_state: "Human Needed",
129
+ },
130
+ {
131
+ id: "cleanup",
132
+ when_state: "Cleanup",
133
+ active_state: "Cleaning Up",
134
+ workflow_file: "CLEANUP_WORKFLOW.md",
135
+ fallback_state: "Human Needed",
136
+ },
137
+ ];
138
+ function withSectionDefaults(input) {
139
+ const source = input && typeof input === "object" ? input : {};
140
+ const { linear: _linear, runner: _runner, ...rest } = source;
141
+ const linear = source.linear && typeof source.linear === "object" ? source.linear : {};
142
+ const runner = source.runner && typeof source.runner === "object" ? source.runner : {};
143
+ const linearOauth = linear.oauth && typeof linear.oauth === "object" ? linear.oauth : {};
144
+ const runnerCodex = runner.codex && typeof runner.codex === "object" ? runner.codex : {};
145
+ return {
146
+ server: {},
147
+ ingress: {},
148
+ logging: {},
149
+ database: {},
150
+ operator_api: {},
151
+ projects: [],
152
+ ...rest,
153
+ linear: {
154
+ ...linear,
155
+ oauth: linearOauth,
156
+ },
157
+ runner: {
158
+ ...runner,
159
+ codex: runnerCodex,
160
+ },
161
+ };
162
+ }
163
+ function expandEnv(value, env) {
164
+ if (typeof value === "string") {
165
+ return value.replace(/\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, (_match, name, fallback) => {
166
+ return env[name] ?? fallback ?? "";
167
+ });
168
+ }
169
+ if (Array.isArray(value)) {
170
+ return value.map((entry) => expandEnv(entry, env));
171
+ }
172
+ if (value && typeof value === "object") {
173
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expandEnv(entry, env)]));
174
+ }
175
+ return value;
176
+ }
177
+ function readEnvFile(envPath) {
178
+ if (!existsSync(envPath)) {
179
+ return {};
180
+ }
181
+ const values = {};
182
+ const raw = readFileSync(envPath, "utf8");
183
+ for (const line of raw.split(/\r?\n/)) {
184
+ const trimmed = line.trim();
185
+ if (!trimmed || trimmed.startsWith("#")) {
186
+ continue;
187
+ }
188
+ const separator = trimmed.indexOf("=");
189
+ if (separator <= 0) {
190
+ continue;
191
+ }
192
+ const name = trimmed.slice(0, separator).trim();
193
+ if (!name) {
194
+ continue;
195
+ }
196
+ let value = trimmed.slice(separator + 1).trim();
197
+ if ((value.startsWith('"') && value.endsWith('"')) ||
198
+ (value.startsWith("'") && value.endsWith("'"))) {
199
+ value = value.slice(1, -1);
200
+ }
201
+ values[name] = value;
202
+ }
203
+ return values;
204
+ }
205
+ export function getAdjacentEnvFilePaths(configPath = process.env.PATCHRELAY_CONFIG ?? getDefaultConfigPath()) {
206
+ const resolvedPath = ensureAbsolutePath(configPath);
207
+ const configDir = path.dirname(resolvedPath);
208
+ return {
209
+ runtimeEnvPath: configDir === path.dirname(getDefaultConfigPath()) ? getDefaultRuntimeEnvPath() : path.join(configDir, "runtime.env"),
210
+ serviceEnvPath: configDir === path.dirname(getDefaultConfigPath()) ? getDefaultServiceEnvPath() : path.join(configDir, "service.env"),
211
+ };
212
+ }
213
+ function getEnvFilesForProfile(configPath, profile) {
214
+ const { runtimeEnvPath, serviceEnvPath } = getAdjacentEnvFilePaths(configPath);
215
+ switch (profile) {
216
+ case "service":
217
+ return [runtimeEnvPath, serviceEnvPath];
218
+ case "cli":
219
+ case "write_config":
220
+ return [runtimeEnvPath];
221
+ case "operator_cli":
222
+ return [runtimeEnvPath, serviceEnvPath];
223
+ case "doctor":
224
+ return [runtimeEnvPath, serviceEnvPath];
225
+ }
226
+ }
227
+ function readEnvFilesForProfile(configPath, profile) {
228
+ const paths = getEnvFilesForProfile(configPath, profile);
229
+ return Object.assign({}, ...paths.map((envPath) => readEnvFile(envPath)));
230
+ }
231
+ function resolveWorkflowFilePath(repoPath, workflowFile) {
232
+ return path.isAbsolute(workflowFile) ? ensureAbsolutePath(workflowFile) : path.resolve(repoPath, workflowFile);
233
+ }
234
+ function defaultWorktreeRoot(projectId) {
235
+ return path.join(getPatchRelayDataDir(), "worktrees", projectId);
236
+ }
237
+ function defaultBranchPrefix(projectId) {
238
+ const sanitized = projectId
239
+ .trim()
240
+ .toLowerCase()
241
+ .replace(/[^a-z0-9]+/g, "-")
242
+ .replace(/^-+|-+$/g, "");
243
+ return sanitized || "patchrelay";
244
+ }
245
+ function formatUrlHost(host) {
246
+ return isIP(host) === 6 && !host.startsWith("[") ? `[${host}]` : host;
247
+ }
248
+ function normalizeLocalRedirectHost(host) {
249
+ if (host === "0.0.0.0") {
250
+ return "127.0.0.1";
251
+ }
252
+ if (host === "::") {
253
+ return "::1";
254
+ }
255
+ return host;
256
+ }
257
+ function deriveLinearOAuthRedirectUri(server) {
258
+ if (server.public_base_url) {
259
+ return new URL(LINEAR_OAUTH_CALLBACK_PATH, new URL(server.public_base_url).origin).toString();
260
+ }
261
+ const host = normalizeLocalRedirectHost(server.bind);
262
+ return new URL(LINEAR_OAUTH_CALLBACK_PATH, `http://${formatUrlHost(host)}:${server.port}`).toString();
263
+ }
264
+ function mergeWorkflows(repoPath, workflows) {
265
+ return workflows.map((workflow) => ({
266
+ id: workflow.id,
267
+ whenState: workflow.when_state,
268
+ activeState: workflow.active_state,
269
+ workflowFile: resolveWorkflowFilePath(repoPath, workflow.workflow_file),
270
+ ...(workflow.fallback_state ? { fallbackState: workflow.fallback_state } : {}),
271
+ }));
272
+ }
273
+ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefaultConfigPath(), options) {
274
+ const requestedPath = ensureAbsolutePath(configPath);
275
+ if (!existsSync(requestedPath)) {
276
+ throw new Error(`Config file not found: ${requestedPath}. Run "patchrelay init" to create it.`);
277
+ }
278
+ const profile = options?.profile ?? "service";
279
+ const adjacentEnv = readEnvFilesForProfile(requestedPath, profile);
280
+ const env = {
281
+ ...adjacentEnv,
282
+ ...process.env,
283
+ };
284
+ const raw = readFileSync(requestedPath, "utf8");
285
+ let parsedFile;
286
+ try {
287
+ parsedFile = JSON.parse(raw);
288
+ }
289
+ catch (error) {
290
+ const message = error instanceof Error ? error.message : String(error);
291
+ throw new Error(`Invalid JSON config file: ${requestedPath}: ${message}`, {
292
+ cause: error,
293
+ });
294
+ }
295
+ const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
296
+ const requirements = getLoadProfileRequirements(profile);
297
+ const webhookSecret = env[parsed.linear.webhook_secret_env];
298
+ const tokenEncryptionKey = env[parsed.linear.token_encryption_key_env];
299
+ const oauthClientId = env[parsed.linear.oauth.client_id_env];
300
+ const oauthClientSecret = env[parsed.linear.oauth.client_secret_env];
301
+ const operatorApiToken = parsed.operator_api.bearer_token_env
302
+ ? env[parsed.operator_api.bearer_token_env]
303
+ : undefined;
304
+ if (requirements.requireWebhookSecret && !webhookSecret) {
305
+ throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
306
+ }
307
+ if (requirements.requireOAuthClientId && !oauthClientId) {
308
+ throw new Error(`Missing env var ${parsed.linear.oauth.client_id_env}`);
309
+ }
310
+ if (requirements.requireOAuthClientSecret && !oauthClientSecret) {
311
+ throw new Error(`Missing env var ${parsed.linear.oauth.client_secret_env}`);
312
+ }
313
+ if (requirements.requireTokenEncryptionKey && !tokenEncryptionKey) {
314
+ throw new Error(`Missing env var ${parsed.linear.token_encryption_key_env}`);
315
+ }
316
+ const logFilePath = env.PATCHRELAY_LOG_FILE ?? parsed.logging.file_path;
317
+ const webhookArchiveDir = env.PATCHRELAY_WEBHOOK_ARCHIVE_DIR ?? parsed.logging.webhook_archive_dir;
318
+ const oauthRedirectUri = parsed.linear.oauth.redirect_uri ?? deriveLinearOAuthRedirectUri(parsed.server);
319
+ const config = {
320
+ server: {
321
+ bind: parsed.server.bind,
322
+ port: parsed.server.port,
323
+ ...(parsed.server.public_base_url ? { publicBaseUrl: parsed.server.public_base_url } : {}),
324
+ healthPath: parsed.server.health_path,
325
+ readinessPath: parsed.server.readiness_path,
326
+ },
327
+ ingress: {
328
+ linearWebhookPath: parsed.ingress.linear_webhook_path,
329
+ maxBodyBytes: parsed.ingress.max_body_bytes,
330
+ maxTimestampSkewSeconds: parsed.ingress.max_timestamp_skew_seconds,
331
+ },
332
+ logging: {
333
+ level: env.PATCHRELAY_LOG_LEVEL ?? parsed.logging.level,
334
+ format: parsed.logging.format,
335
+ filePath: ensureAbsolutePath(logFilePath),
336
+ ...(webhookArchiveDir ? { webhookArchiveDir: ensureAbsolutePath(webhookArchiveDir) } : {}),
337
+ },
338
+ database: {
339
+ path: ensureAbsolutePath(env.PATCHRELAY_DB_PATH ?? parsed.database.path),
340
+ wal: parsed.database.wal,
341
+ },
342
+ linear: {
343
+ webhookSecret: webhookSecret ?? "",
344
+ graphqlUrl: parsed.linear.graphql_url,
345
+ oauth: {
346
+ clientId: oauthClientId ?? "",
347
+ clientSecret: oauthClientSecret ?? "",
348
+ redirectUri: oauthRedirectUri,
349
+ scopes: parsed.linear.oauth.scopes,
350
+ actor: parsed.linear.oauth.actor,
351
+ },
352
+ tokenEncryptionKey: tokenEncryptionKey ?? "",
353
+ },
354
+ operatorApi: {
355
+ enabled: parsed.operator_api.enabled,
356
+ ...(operatorApiToken ? { bearerToken: operatorApiToken } : {}),
357
+ },
358
+ runner: {
359
+ gitBin: parsed.runner.git_bin,
360
+ codex: {
361
+ bin: parsed.runner.codex.bin,
362
+ args: parsed.runner.codex.args,
363
+ ...(parsed.runner.codex.shell_bin ? { shellBin: parsed.runner.codex.shell_bin } : {}),
364
+ sourceBashrc: parsed.runner.codex.source_bashrc,
365
+ ...(parsed.runner.codex.model ? { model: parsed.runner.codex.model } : {}),
366
+ ...(parsed.runner.codex.model_provider ? { modelProvider: parsed.runner.codex.model_provider } : {}),
367
+ ...(parsed.runner.codex.service_name ? { serviceName: parsed.runner.codex.service_name } : {}),
368
+ ...(parsed.runner.codex.base_instructions ? { baseInstructions: parsed.runner.codex.base_instructions } : {}),
369
+ ...(parsed.runner.codex.developer_instructions
370
+ ? { developerInstructions: parsed.runner.codex.developer_instructions }
371
+ : {}),
372
+ approvalPolicy: parsed.runner.codex.approval_policy,
373
+ sandboxMode: parsed.runner.codex.sandbox_mode,
374
+ persistExtendedHistory: parsed.runner.codex.persist_extended_history,
375
+ },
376
+ },
377
+ projects: parsed.projects.map((project) => {
378
+ const repoPath = ensureAbsolutePath(project.repo_path);
379
+ return {
380
+ id: project.id,
381
+ repoPath,
382
+ worktreeRoot: ensureAbsolutePath(project.worktree_root ?? defaultWorktreeRoot(project.id)),
383
+ workflows: mergeWorkflows(repoPath, project.workflows ?? builtinWorkflows),
384
+ ...(project.workflow_labels
385
+ ? {
386
+ workflowLabels: {
387
+ ...(project.workflow_labels.working ? { working: project.workflow_labels.working } : {}),
388
+ ...(project.workflow_labels.awaiting_handoff ? { awaitingHandoff: project.workflow_labels.awaiting_handoff } : {}),
389
+ },
390
+ }
391
+ : {}),
392
+ ...(project.trusted_actors
393
+ ? {
394
+ trustedActors: {
395
+ ids: project.trusted_actors.ids,
396
+ names: project.trusted_actors.names,
397
+ emails: project.trusted_actors.emails,
398
+ emailDomains: project.trusted_actors.email_domains,
399
+ },
400
+ }
401
+ : {}),
402
+ issueKeyPrefixes: project.issue_key_prefixes,
403
+ linearTeamIds: project.linear_team_ids,
404
+ allowLabels: project.allow_labels,
405
+ triggerEvents: project.trigger_events ??
406
+ defaultTriggerEvents(parsed.linear.oauth.actor),
407
+ branchPrefix: project.branch_prefix ?? defaultBranchPrefix(project.id),
408
+ };
409
+ }),
410
+ };
411
+ validateConfigSemantics(config, {
412
+ allowMissingOperatorApiToken: requirements.allowMissingOperatorApiToken,
413
+ });
414
+ return config;
415
+ }
416
+ function getLoadProfileRequirements(profile) {
417
+ switch (profile) {
418
+ case "service":
419
+ return {
420
+ requireWebhookSecret: true,
421
+ requireOAuthClientId: true,
422
+ requireOAuthClientSecret: true,
423
+ requireTokenEncryptionKey: true,
424
+ allowMissingOperatorApiToken: false,
425
+ };
426
+ case "operator_cli":
427
+ return {
428
+ requireWebhookSecret: false,
429
+ requireOAuthClientId: false,
430
+ requireOAuthClientSecret: false,
431
+ requireTokenEncryptionKey: false,
432
+ allowMissingOperatorApiToken: false,
433
+ };
434
+ case "cli":
435
+ case "doctor":
436
+ case "write_config":
437
+ return {
438
+ requireWebhookSecret: false,
439
+ requireOAuthClientId: false,
440
+ requireOAuthClientSecret: false,
441
+ requireTokenEncryptionKey: false,
442
+ allowMissingOperatorApiToken: true,
443
+ };
444
+ }
445
+ }
446
+ function validateConfigSemantics(config, options) {
447
+ const redirectUri = new URL(config.linear.oauth.redirectUri);
448
+ if (redirectUri.pathname !== LINEAR_OAUTH_CALLBACK_PATH) {
449
+ throw new Error(`linear.oauth.redirect_uri must use the fixed "${LINEAR_OAUTH_CALLBACK_PATH}" path`);
450
+ }
451
+ const projectIds = new Set();
452
+ const issuePrefixes = new Map();
453
+ const linearTeamIds = new Map();
454
+ for (const project of config.projects) {
455
+ if (projectIds.has(project.id)) {
456
+ throw new Error(`Duplicate project id: ${project.id}`);
457
+ }
458
+ projectIds.add(project.id);
459
+ for (const prefix of project.issueKeyPrefixes) {
460
+ const owner = issuePrefixes.get(prefix);
461
+ if (owner && owner !== project.id) {
462
+ throw new Error(`Issue key prefix "${prefix}" is configured for both ${owner} and ${project.id}`);
463
+ }
464
+ issuePrefixes.set(prefix, project.id);
465
+ }
466
+ for (const teamId of project.linearTeamIds) {
467
+ const owner = linearTeamIds.get(teamId);
468
+ if (owner && owner !== project.id) {
469
+ throw new Error(`Linear team id "${teamId}" is configured for both ${owner} and ${project.id}`);
470
+ }
471
+ linearTeamIds.set(teamId, project.id);
472
+ }
473
+ const workflowIds = new Set();
474
+ const workflowStates = new Set();
475
+ for (const workflow of project.workflows) {
476
+ const normalizedWorkflowId = workflow.id.trim().toLowerCase();
477
+ if (workflowIds.has(normalizedWorkflowId)) {
478
+ throw new Error(`Workflow id "${workflow.id}" is configured more than once in project ${project.id}`);
479
+ }
480
+ workflowIds.add(normalizedWorkflowId);
481
+ const normalizedState = workflow.whenState.trim().toLowerCase();
482
+ if (workflowStates.has(normalizedState)) {
483
+ throw new Error(`Linear state "${workflow.whenState}" is configured for more than one workflow in project ${project.id}`);
484
+ }
485
+ workflowStates.add(normalizedState);
486
+ }
487
+ }
488
+ if (config.operatorApi.enabled &&
489
+ config.server.bind !== "127.0.0.1" &&
490
+ !config.operatorApi.bearerToken &&
491
+ !options?.allowMissingOperatorApiToken) {
492
+ throw new Error("operator_api.enabled requires operator_api.bearer_token_env when server.bind is not 127.0.0.1");
493
+ }
494
+ }