patchrelay 0.2.0 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.2.0",
4
- "commit": "67bd98adc642",
5
- "builtAt": "2026-03-12T18:57:08.889Z"
3
+ "version": "0.3.0",
4
+ "commit": "c82e03b38d6e",
5
+ "builtAt": "2026-03-13T01:42:32.759Z"
6
6
  }
@@ -0,0 +1,73 @@
1
+ export const KNOWN_COMMANDS = new Set([
2
+ "serve",
3
+ "inspect",
4
+ "live",
5
+ "report",
6
+ "events",
7
+ "worktree",
8
+ "open",
9
+ "retry",
10
+ "list",
11
+ "doctor",
12
+ "init",
13
+ "project",
14
+ "connect",
15
+ "installations",
16
+ "install-service",
17
+ "restart-service",
18
+ "help",
19
+ ]);
20
+ export function parseArgs(argv) {
21
+ const positionals = [];
22
+ const flags = new Map();
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const value = argv[index];
25
+ if (!value.startsWith("--")) {
26
+ positionals.push(value);
27
+ continue;
28
+ }
29
+ const trimmed = value.slice(2);
30
+ const [name, inline] = trimmed.split("=", 2);
31
+ if (!name) {
32
+ continue;
33
+ }
34
+ if (inline !== undefined) {
35
+ flags.set(name, inline);
36
+ continue;
37
+ }
38
+ const next = argv[index + 1];
39
+ if (next && !next.startsWith("--")) {
40
+ flags.set(name, next);
41
+ index += 1;
42
+ continue;
43
+ }
44
+ flags.set(name, true);
45
+ }
46
+ return { positionals, flags };
47
+ }
48
+ export function resolveCommand(parsed) {
49
+ const requestedCommand = parsed.positionals[0];
50
+ const command = !requestedCommand
51
+ ? "help"
52
+ : KNOWN_COMMANDS.has(requestedCommand)
53
+ ? requestedCommand
54
+ : "inspect";
55
+ const commandArgs = command === requestedCommand ? parsed.positionals.slice(1) : parsed.positionals;
56
+ return { command, commandArgs };
57
+ }
58
+ export function getStageFlag(value) {
59
+ if (typeof value !== "string") {
60
+ return undefined;
61
+ }
62
+ const trimmed = value.trim();
63
+ return trimmed || undefined;
64
+ }
65
+ export function parseCsvFlag(value) {
66
+ if (typeof value !== "string") {
67
+ return [];
68
+ }
69
+ return value
70
+ .split(",")
71
+ .map((entry) => entry.trim())
72
+ .filter(Boolean);
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
2
+ import { formatJson } from "../formatters/json.js";
3
+ import { openExternalUrl } from "../interactive.js";
4
+ import { writeOutput } from "../output.js";
5
+ export async function handleConnectCommand(params) {
6
+ return await runConnectFlow({
7
+ config: params.config,
8
+ data: params.data,
9
+ stdout: params.stdout,
10
+ noOpen: params.parsed.flags.get("no-open") === true,
11
+ timeoutSeconds: parseTimeoutSeconds(params.parsed.flags.get("timeout"), "connect"),
12
+ json: params.json,
13
+ openExternal: params.options?.openExternal ?? openExternalUrl,
14
+ ...(params.options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: params.options.connectPollIntervalMs } : {}),
15
+ ...(typeof params.parsed.flags.get("project") === "string" ? { projectId: String(params.parsed.flags.get("project")) } : {}),
16
+ });
17
+ }
18
+ export async function handleInstallationsCommand(params) {
19
+ const result = await params.data.listInstallations();
20
+ if (params.json) {
21
+ writeOutput(params.stdout, formatJson(result));
22
+ return 0;
23
+ }
24
+ writeOutput(params.stdout, `${(result.installations.length > 0
25
+ ? result.installations.map((item) => `${item.installation.id} ${item.installation.workspaceName ?? item.installation.actorName ?? "-"} projects=${item.linkedProjects.join(",") || "-"}`)
26
+ : ["No installations found."]).join("\n")}\n`);
27
+ return 0;
28
+ }
@@ -0,0 +1,147 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { getStageFlag } from "../args.js";
3
+ import { formatJson } from "../formatters/json.js";
4
+ import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "../formatters/text.js";
5
+ import { buildOpenCommand } from "../interactive.js";
6
+ import { writeOutput } from "../output.js";
7
+ export async function handleInspectCommand(params) {
8
+ const issueKey = params.commandArgs[0];
9
+ if (!issueKey) {
10
+ throw new Error("inspect requires <issueKey>.");
11
+ }
12
+ const result = await params.data.inspect(issueKey);
13
+ if (!result) {
14
+ throw new Error(`Issue not found: ${issueKey}`);
15
+ }
16
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatInspect(result));
17
+ return 0;
18
+ }
19
+ export async function handleLiveCommand(params) {
20
+ const issueKey = params.commandArgs[0];
21
+ if (!issueKey) {
22
+ throw new Error("live requires <issueKey>.");
23
+ }
24
+ const watch = params.parsed.flags.get("watch") === true;
25
+ do {
26
+ const result = await params.data.live(issueKey);
27
+ if (!result) {
28
+ throw new Error(`No active stage found for ${issueKey}`);
29
+ }
30
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatLive(result));
31
+ if (!watch || result.stageRun.status !== "running") {
32
+ break;
33
+ }
34
+ await delay(2000);
35
+ } while (true);
36
+ return 0;
37
+ }
38
+ export async function handleReportCommand(params) {
39
+ const issueKey = params.commandArgs[0];
40
+ if (!issueKey) {
41
+ throw new Error("report requires <issueKey>.");
42
+ }
43
+ const reportOptions = {};
44
+ const stage = getStageFlag(params.parsed.flags.get("stage"));
45
+ if (stage) {
46
+ reportOptions.stage = stage;
47
+ }
48
+ if (typeof params.parsed.flags.get("stage-run") === "string") {
49
+ reportOptions.stageRunId = Number(params.parsed.flags.get("stage-run"));
50
+ }
51
+ const result = params.data.report(issueKey, reportOptions);
52
+ if (!result) {
53
+ throw new Error(`Issue not found: ${issueKey}`);
54
+ }
55
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatReport(result));
56
+ return 0;
57
+ }
58
+ export async function handleEventsCommand(params) {
59
+ const issueKey = params.commandArgs[0];
60
+ if (!issueKey) {
61
+ throw new Error("events requires <issueKey>.");
62
+ }
63
+ const follow = params.parsed.flags.get("follow") === true;
64
+ let afterId;
65
+ let stageRunId = typeof params.parsed.flags.get("stage-run") === "string" ? Number(params.parsed.flags.get("stage-run")) : undefined;
66
+ do {
67
+ const result = params.data.events(issueKey, {
68
+ ...(stageRunId !== undefined ? { stageRunId } : {}),
69
+ ...(typeof params.parsed.flags.get("method") === "string" ? { method: String(params.parsed.flags.get("method")) } : {}),
70
+ ...(afterId !== undefined ? { afterId } : {}),
71
+ });
72
+ if (!result) {
73
+ throw new Error(`Stage run not found for ${issueKey}`);
74
+ }
75
+ stageRunId = result.stageRun.id;
76
+ if (result.events.length > 0) {
77
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatEvents(result));
78
+ afterId = result.events.at(-1)?.id;
79
+ }
80
+ if (!follow || result.stageRun.status !== "running") {
81
+ break;
82
+ }
83
+ await delay(2000);
84
+ } while (true);
85
+ return 0;
86
+ }
87
+ export async function handleWorktreeCommand(params) {
88
+ const issueKey = params.commandArgs[0];
89
+ if (!issueKey) {
90
+ throw new Error("worktree requires <issueKey>.");
91
+ }
92
+ const result = params.data.worktree(issueKey);
93
+ if (!result) {
94
+ throw new Error(`Workspace not found for ${issueKey}`);
95
+ }
96
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatWorktree(result, params.parsed.flags.get("cd") === true));
97
+ return 0;
98
+ }
99
+ export async function handleOpenCommand(params) {
100
+ const issueKey = params.commandArgs[0];
101
+ if (!issueKey) {
102
+ throw new Error("open requires <issueKey>.");
103
+ }
104
+ const result = params.data.open(issueKey);
105
+ if (!result) {
106
+ throw new Error(`Workspace not found for ${issueKey}`);
107
+ }
108
+ if (params.json) {
109
+ writeOutput(params.stdout, formatJson(result));
110
+ return 0;
111
+ }
112
+ if (params.parsed.flags.get("print") === true) {
113
+ writeOutput(params.stdout, formatOpen(result));
114
+ return 0;
115
+ }
116
+ const openCommand = buildOpenCommand(params.config, result.workspace.worktreePath, result.resumeThreadId);
117
+ return await params.runInteractive(openCommand.command, openCommand.args);
118
+ }
119
+ export async function handleRetryCommand(params) {
120
+ const issueKey = params.commandArgs[0];
121
+ if (!issueKey) {
122
+ throw new Error("retry requires <issueKey>.");
123
+ }
124
+ const retryOptions = {};
125
+ const stage = getStageFlag(params.parsed.flags.get("stage"));
126
+ if (stage) {
127
+ retryOptions.stage = stage;
128
+ }
129
+ if (typeof params.parsed.flags.get("reason") === "string") {
130
+ retryOptions.reason = String(params.parsed.flags.get("reason"));
131
+ }
132
+ const result = params.data.retry(issueKey, retryOptions);
133
+ if (!result) {
134
+ throw new Error(`Issue not found: ${issueKey}`);
135
+ }
136
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatRetry(result));
137
+ return 0;
138
+ }
139
+ export async function handleListCommand(params) {
140
+ const result = params.data.list({
141
+ active: params.parsed.flags.get("active") === true,
142
+ failed: params.parsed.flags.get("failed") === true,
143
+ ...(typeof params.parsed.flags.get("project") === "string" ? { project: String(params.parsed.flags.get("project")) } : {}),
144
+ });
145
+ writeOutput(params.stdout, params.json ? formatJson(result) : formatList(result));
146
+ return 0;
147
+ }
@@ -0,0 +1,140 @@
1
+ import { loadConfig } from "../../config.js";
2
+ import { installUserServiceUnits, upsertProjectInConfig } from "../../install.js";
3
+ import { runPreflight } from "../../preflight.js";
4
+ import { parseCsvFlag } from "../args.js";
5
+ import { runConnectFlow, parseTimeoutSeconds } from "../connect-flow.js";
6
+ import { CliDataAccess } from "../data.js";
7
+ import { formatJson } from "../formatters/json.js";
8
+ import { writeOutput } from "../output.js";
9
+ import { installServiceCommands, tryManageService } from "../service-commands.js";
10
+ export async function handleProjectCommand(params) {
11
+ try {
12
+ const subcommand = params.commandArgs[0];
13
+ if (subcommand !== "apply") {
14
+ throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
15
+ }
16
+ const projectId = params.commandArgs[1];
17
+ const repoPath = params.commandArgs[2];
18
+ if (!projectId || !repoPath) {
19
+ throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
20
+ }
21
+ const result = await upsertProjectInConfig({
22
+ id: projectId,
23
+ repoPath,
24
+ issueKeyPrefixes: parseCsvFlag(params.parsed.flags.get("issue-prefix")),
25
+ linearTeamIds: parseCsvFlag(params.parsed.flags.get("team-id")),
26
+ });
27
+ const serviceUnits = await installUserServiceUnits();
28
+ const noConnect = params.parsed.flags.get("no-connect") === true;
29
+ const lines = [
30
+ `Config file: ${result.configPath}`,
31
+ `${result.status === "created" ? "Created" : result.status === "updated" ? "Updated" : "Verified"} project ${result.project.id} for ${result.project.repoPath}`,
32
+ result.project.issueKeyPrefixes.length > 0 ? `Issue key prefixes: ${result.project.issueKeyPrefixes.join(", ")}` : undefined,
33
+ result.project.linearTeamIds.length > 0 ? `Linear team ids: ${result.project.linearTeamIds.join(", ")}` : undefined,
34
+ `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
35
+ `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
36
+ ].filter(Boolean);
37
+ let fullConfig;
38
+ try {
39
+ fullConfig = loadConfig(undefined, { profile: "doctor" });
40
+ }
41
+ catch (error) {
42
+ if (params.json) {
43
+ writeOutput(params.stdout, formatJson({
44
+ ...result,
45
+ serviceUnits,
46
+ readiness: {
47
+ ok: false,
48
+ error: error instanceof Error ? error.message : String(error),
49
+ },
50
+ connect: {
51
+ attempted: false,
52
+ skipped: "missing_env",
53
+ },
54
+ }));
55
+ return 0;
56
+ }
57
+ lines.push(`Linear connect was skipped: ${error instanceof Error ? error.message : String(error)}`);
58
+ lines.push("Finish the required env vars and rerun `patchrelay project apply`.");
59
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
60
+ return 0;
61
+ }
62
+ const report = await runPreflight(fullConfig);
63
+ const failedChecks = report.checks.filter((check) => check.status === "fail");
64
+ if (failedChecks.length > 0) {
65
+ if (params.json) {
66
+ writeOutput(params.stdout, formatJson({
67
+ ...result,
68
+ serviceUnits,
69
+ readiness: report,
70
+ connect: {
71
+ attempted: false,
72
+ skipped: "preflight_failed",
73
+ },
74
+ }));
75
+ return 0;
76
+ }
77
+ lines.push("Linear connect was skipped because PatchRelay is not ready yet:");
78
+ lines.push(...failedChecks.map((check) => `- [${check.scope}] ${check.message}`));
79
+ lines.push("Fix the failures above and rerun `patchrelay project apply`.");
80
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
81
+ return 0;
82
+ }
83
+ const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
84
+ if (!serviceState.ok) {
85
+ throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
86
+ }
87
+ const cliData = params.options?.data ?? new CliDataAccess(fullConfig);
88
+ try {
89
+ if (params.json) {
90
+ const connectResult = noConnect ? undefined : await cliData.connect(projectId);
91
+ writeOutput(params.stdout, formatJson({
92
+ ...result,
93
+ serviceUnits,
94
+ readiness: report,
95
+ serviceReloaded: true,
96
+ ...(noConnect
97
+ ? {
98
+ connect: {
99
+ attempted: false,
100
+ skipped: "no_connect",
101
+ },
102
+ }
103
+ : {
104
+ connect: {
105
+ attempted: true,
106
+ result: connectResult,
107
+ },
108
+ }),
109
+ }));
110
+ return 0;
111
+ }
112
+ if (noConnect) {
113
+ lines.push("Project saved and PatchRelay was reloaded.");
114
+ lines.push(`Next: patchrelay connect --project ${result.project.id}`);
115
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
116
+ return 0;
117
+ }
118
+ writeOutput(params.stdout, `${lines.join("\n")}\n`);
119
+ return await runConnectFlow({
120
+ config: fullConfig,
121
+ data: cliData,
122
+ stdout: params.stdout,
123
+ noOpen: params.parsed.flags.get("no-open") === true,
124
+ timeoutSeconds: parseTimeoutSeconds(params.parsed.flags.get("timeout"), "project apply"),
125
+ projectId,
126
+ ...(params.options?.openExternal ? { openExternal: params.options.openExternal } : {}),
127
+ ...(params.options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: params.options.connectPollIntervalMs } : {}),
128
+ });
129
+ }
130
+ finally {
131
+ if (!params.options?.data) {
132
+ cliData.close();
133
+ }
134
+ }
135
+ }
136
+ catch (error) {
137
+ writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
138
+ return 1;
139
+ }
140
+ }
@@ -0,0 +1,140 @@
1
+ import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, } from "../../runtime-paths.js";
2
+ import { initializePatchRelayHome, installUserServiceUnits } from "../../install.js";
3
+ import { formatJson } from "../formatters/json.js";
4
+ import { writeOutput } from "../output.js";
5
+ import { installServiceCommands, restartServiceCommands, runServiceCommands, tryManageService } from "../service-commands.js";
6
+ export async function handleInitCommand(params) {
7
+ try {
8
+ const requestedPublicBaseUrl = typeof params.parsed.flags.get("public-base-url") === "string"
9
+ ? String(params.parsed.flags.get("public-base-url"))
10
+ : params.commandArgs[0];
11
+ if (!requestedPublicBaseUrl) {
12
+ throw new Error([
13
+ "patchrelay init requires <public-base-url>.",
14
+ "PatchRelay must know the public HTTPS origin that Linear will call for the webhook and OAuth callback.",
15
+ "Example: patchrelay init https://patchrelay.example.com",
16
+ ].join("\n"));
17
+ }
18
+ const publicBaseUrl = normalizePublicBaseUrl(requestedPublicBaseUrl);
19
+ const result = await initializePatchRelayHome({
20
+ force: params.parsed.flags.get("force") === true,
21
+ publicBaseUrl,
22
+ });
23
+ const serviceUnits = await installUserServiceUnits({ force: params.parsed.flags.get("force") === true });
24
+ const serviceState = await tryManageService(params.runInteractive, installServiceCommands());
25
+ writeOutput(params.stdout, params.json
26
+ ? formatJson({ ...result, serviceUnits, serviceState })
27
+ : [
28
+ `Config directory: ${result.configDir}`,
29
+ `Runtime env: ${result.runtimeEnvPath} (${result.runtimeEnvStatus})`,
30
+ `Service env: ${result.serviceEnvPath} (${result.serviceEnvStatus})`,
31
+ `Config file: ${result.configPath} (${result.configStatus})`,
32
+ `State directory: ${result.stateDir}`,
33
+ `Data directory: ${result.dataDir}`,
34
+ `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
35
+ `Reload unit: ${serviceUnits.reloadUnitPath} (${serviceUnits.reloadStatus})`,
36
+ `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
37
+ "",
38
+ "PatchRelay public URLs:",
39
+ `- Public base URL: ${result.publicBaseUrl}`,
40
+ `- Webhook URL: ${result.webhookUrl}`,
41
+ `- OAuth callback: ${result.oauthCallbackUrl}`,
42
+ "",
43
+ "Created with defaults:",
44
+ `- Config file contains only machine-level essentials such as server.public_base_url`,
45
+ `- Database, logs, bind address, and worktree roots use built-in defaults`,
46
+ `- The user service and config watcher are installed for you`,
47
+ "",
48
+ "Register the app in Linear:",
49
+ "- Open Linear Settings > API > Applications",
50
+ "- Create an OAuth app for PatchRelay",
51
+ "- Choose actor `app`",
52
+ "- Choose scopes `read`, `write`, `app:assignable`, `app:mentionable`",
53
+ `- Add redirect URI ${result.oauthCallbackUrl}`,
54
+ `- Add webhook URL ${result.webhookUrl}`,
55
+ "- Enable webhook categories for issue events, comment events, agent session events, permission changes, and inbox/app-user notifications",
56
+ "",
57
+ result.configStatus === "skipped"
58
+ ? `Config file was skipped, so make sure ${result.configPath} still has server.public_base_url: ${result.publicBaseUrl}`
59
+ : `Config file already includes server.public_base_url: ${result.publicBaseUrl}`,
60
+ "",
61
+ "Service status:",
62
+ serviceState.ok
63
+ ? "PatchRelay service and config watcher are installed and reload-or-restart has been requested."
64
+ : `PatchRelay service units were installed, but the service could not be started yet: ${serviceState.error}`,
65
+ !serviceState.ok
66
+ ? "This is expected until the required env vars and at least one valid project workflow are in place. The watcher will retry when config or env files change."
67
+ : undefined,
68
+ "",
69
+ "Next steps:",
70
+ `1. Edit ${result.serviceEnvPath}`,
71
+ "2. Paste your Linear OAuth client id and client secret into service.env and keep the generated webhook secret and token encryption key",
72
+ "3. Paste LINEAR_WEBHOOK_SECRET from service.env into the Linear OAuth app webhook signing secret",
73
+ "4. Run `patchrelay project apply <id> <repo-path>`",
74
+ "5. Edit the generated project workflows if you want custom state names or workflow files, then add those workflow files to the repo",
75
+ "6. Run `patchrelay doctor`",
76
+ ]
77
+ .filter(Boolean)
78
+ .join("\n") + "\n");
79
+ return 0;
80
+ }
81
+ catch (error) {
82
+ writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
83
+ return 1;
84
+ }
85
+ }
86
+ export async function handleInstallServiceCommand(params) {
87
+ try {
88
+ const result = await installUserServiceUnits({ force: params.parsed.flags.get("force") === true });
89
+ const writeOnly = params.parsed.flags.get("write-only") === true;
90
+ if (!writeOnly) {
91
+ await runServiceCommands(params.runInteractive, installServiceCommands());
92
+ }
93
+ writeOutput(params.stdout, params.json
94
+ ? formatJson({ ...result, writeOnly })
95
+ : [
96
+ `Service unit: ${result.unitPath} (${result.serviceStatus})`,
97
+ `Reload unit: ${result.reloadUnitPath} (${result.reloadStatus})`,
98
+ `Watcher unit: ${result.pathUnitPath} (${result.pathStatus})`,
99
+ `Runtime env: ${result.runtimeEnvPath}`,
100
+ `Service env: ${result.serviceEnvPath}`,
101
+ `Config file: ${result.configPath}`,
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.",
105
+ "After package updates, run: patchrelay restart-service",
106
+ ].join("\n") + "\n");
107
+ return 0;
108
+ }
109
+ catch (error) {
110
+ writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
111
+ return 1;
112
+ }
113
+ }
114
+ export async function handleRestartServiceCommand(params) {
115
+ try {
116
+ await runServiceCommands(params.runInteractive, restartServiceCommands());
117
+ writeOutput(params.stdout, params.json
118
+ ? formatJson({
119
+ service: "patchrelay",
120
+ unitPath: getSystemdUserUnitPath(),
121
+ reloadUnitPath: getSystemdUserReloadUnitPath(),
122
+ pathUnitPath: getSystemdUserPathUnitPath(),
123
+ runtimeEnvPath: getDefaultRuntimeEnvPath(),
124
+ serviceEnvPath: getDefaultServiceEnvPath(),
125
+ configPath: getDefaultConfigPath(),
126
+ restarted: true,
127
+ })
128
+ : "Reloaded systemd user units and reload-or-restart was requested for PatchRelay.\n");
129
+ return 0;
130
+ }
131
+ catch (error) {
132
+ writeOutput(params.stderr, `${error instanceof Error ? error.message : String(error)}\n`);
133
+ return 1;
134
+ }
135
+ }
136
+ function normalizePublicBaseUrl(value) {
137
+ const candidate = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) ? value : `https://${value}`;
138
+ const url = new URL(candidate);
139
+ return url.origin;
140
+ }
@@ -0,0 +1,52 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { formatJson } from "./formatters/json.js";
3
+ import { writeOutput } from "./output.js";
4
+ export function parseTimeoutSeconds(value, command) {
5
+ const timeoutSeconds = typeof value === "string" ? Number(value) : 180;
6
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
7
+ throw new Error(`${command} --timeout must be a positive number of seconds.`);
8
+ }
9
+ return timeoutSeconds;
10
+ }
11
+ export async function runConnectFlow(params) {
12
+ const result = await params.data.connect(params.projectId);
13
+ if (params.json) {
14
+ writeOutput(params.stdout, formatJson(result));
15
+ return 0;
16
+ }
17
+ if ("completed" in result && result.completed) {
18
+ const label = result.installation.workspaceName ?? result.installation.actorName ?? `installation #${result.installation.id}`;
19
+ writeOutput(params.stdout, `Linked project ${result.projectId} to existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`);
20
+ return 0;
21
+ }
22
+ if ("completed" in result) {
23
+ throw new Error("Unexpected completed connect result.");
24
+ }
25
+ const opener = params.openExternal;
26
+ const opened = params.noOpen || !opener ? false : await opener(result.authorizeUrl);
27
+ writeOutput(params.stdout, `${result.projectId ? `Project: ${result.projectId}\n` : ""}${opened ? "Opened browser for Linear OAuth.\n" : "Open this URL in a browser:\n"}${opened ? result.authorizeUrl : `${result.authorizeUrl}\n`}Waiting for OAuth approval...\n`);
28
+ const deadline = Date.now() + (params.timeoutSeconds ?? 180) * 1000;
29
+ const pollIntervalMs = params.connectPollIntervalMs ?? 1000;
30
+ do {
31
+ const status = await params.data.connectStatus(result.state);
32
+ if (status.status === "completed") {
33
+ const label = status.installation?.workspaceName ?? status.installation?.actorName ?? `installation #${status.installation?.id ?? "unknown"}`;
34
+ writeOutput(params.stdout, [
35
+ `Connected ${label}${status.projectId ? ` for project ${status.projectId}` : ""}.${status.installation?.id ? ` Installation ${status.installation.id}.` : ""}`,
36
+ params.config.linear.oauth.actor === "app"
37
+ ? "If your Linear OAuth app webhook settings are configured, Linear has now provisioned the workspace webhook automatically."
38
+ : undefined,
39
+ ]
40
+ .filter(Boolean)
41
+ .join("\n") + "\n");
42
+ return 0;
43
+ }
44
+ if (status.status === "failed") {
45
+ throw new Error(status.errorMessage ?? "Linear OAuth failed.");
46
+ }
47
+ if (Date.now() >= deadline) {
48
+ throw new Error(`Timed out waiting for Linear OAuth after ${params.timeoutSeconds ?? 180} seconds.`);
49
+ }
50
+ await delay(pollIntervalMs);
51
+ } while (true);
52
+ }