1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as Result from "effect/Result";
6import * as Clock from "effect/Clock";
7import * as FileSystem from "effect/FileSystem";
8import * as Semaphore from "effect/Semaphore";
9// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform.
10import * as NodePath from "node:path";
11import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
12import { ChildProcessSpawner } from "effect/process";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import * as HostProcess from "@t3tools/shared/HostProcess";
15import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
16import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
17import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
18
19const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
20
21export class ForgejoCliError extends Schema.TaggedError<ForgejoCliError>()("ForgejoCliError", {
22 command: Schema.Literals(["fj", "tea"]),
23 cwd: Schema.String,
24 detail: Schema.String,
25 reason: Schema.optional(
26 Schema.Literals([
27 "missing-cli",
28 "authentication",
29 "forbidden",
30 "not-found",
31 "rate-limit",
32 "invalid-response",
33 ]),
34 ),
35 httpStatus: Schema.optional(Schema.Int),
36 cause: Schema.optional(Schema.Defect()),
37}) {}
38
39export const ForgejoLoginSchema = Schema.Struct({
40 name: Schema.String,
41 url: Schema.String,
42 ssh_host: Schema.optional(Schema.String),
43 valid: Schema.optional(Schema.String),
44 user: Schema.String,
45 default: Schema.String,
46});
47
48export function parseForgejoLogins(raw: string) {
49 const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw);
50 return Result.isSuccess(decoded) ? decoded.success : [];
51}
52
53export const ForgejoKeysSchema = Schema.Struct({
54 hosts: Schema.Record(
55 Schema.String,
56 Schema.Struct({
57 type: Schema.Literals(["Application", "OAuth"]),
58 token: Schema.String,
59 }),
60 ),
61 aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)),
62});
63
64const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema);
65
66/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */
67function forgejoKeysPaths(input: {
68 readonly platform: string;
69 readonly home: string;
70 readonly dataHome?: string;
71 readonly appData?: string;
72}) {
73 if (input.platform === "darwin")
74 return ["forgejo-cli", "Cyborus"].map((organization) =>
75 NodePath.join(
76 input.home,
77 "Library",
78 "Application Support",
79 `${organization}.forgejo-cli`,
80 "keys.json",
81 ),
82 );
83 if (input.platform === "win32")
84 return ["forgejo-cli", "Cyborus"].map((organization) =>
85 NodePath.win32.join(
86 input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"),
87 organization,
88 "forgejo-cli",
89 "data",
90 "keys.json",
91 ),
92 );
93 return [
94 NodePath.join(
95 input.dataHome && NodePath.isAbsolute(input.dataHome)
96 ? input.dataHome
97 : NodePath.join(input.home, ".local", "share"),
98 "forgejo-cli",
99 "keys.json",
100 ),
101 ];
102}
103
104export interface ForgejoRepositoryInput {
105 readonly cwd: string;
106 readonly context?: SourceControlProviderContext;
107 readonly repository?: string;
108 readonly reference?: string;
109 readonly host?: string;
110}
111
112export interface ForgejoRepository {
113 readonly command?: "fj" | "tea";
114 readonly login: string;
115 readonly repository: string;
116 readonly baseUrl: string;
117}
118
119export interface ForgejoApiInput extends ForgejoRepositoryInput {
120 readonly path: string;
121 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
122 readonly body?: unknown;
123}
124
125export class ForgejoCli extends Context.Service<
126 ForgejoCli,
127 {
128 readonly execute: (input: {
129 readonly command?: "fj" | "tea";
130 readonly cwd: string;
131 readonly args: ReadonlyArray<string>;
132 readonly stdin?: string;
133 readonly timeoutMs?: number;
134 readonly maxOutputBytes?: number;
135 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>;
136 readonly listLogins?: (input: {
137 readonly cwd: string;
138 readonly command: "fj" | "tea";
139 readonly remoteUrl?: string;
140 }) => Effect.Effect<ReturnType<typeof parseForgejoLogins>, ForgejoCliError>;
141 readonly getAccount?: (input: {
142 readonly cwd: string;
143 readonly baseUrl: string;
144 }) => Effect.Effect<string, ForgejoCliError>;
145 readonly resolveRepository: (
146 input: ForgejoRepositoryInput,
147 ) => Effect.Effect<ForgejoRepository, ForgejoCliError>;
148 readonly api: (
149 input: ForgejoApiInput,
150 ) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>;
151 }
152>()("@t3tools/source-control-forgejo/server/ForgejoCli") {}
153
154export function parseForgejoRemote(value: string) {
155 if (/^(?:https?|ssh):\/\//i.test(value)) {
156 try {
157 const url = new URL(value);
158 return {
159 host: url.host.toLowerCase(),
160 hostname: url.hostname.toLowerCase(),
161 ssh: url.protocol === "ssh:",
162 path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""),
163 };
164 } catch {
165 return null;
166 }
167 }
168 // SCP remotes may omit the username; URL treats these as a custom scheme.
169 const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value);
170 return ssh?.[1] && ssh[2]
171 ? {
172 host: ssh[1].toLowerCase(),
173 hostname: ssh[1].toLowerCase(),
174 ssh: true,
175 path: ssh[2].replace(/\.git$/, ""),
176 }
177 : null;
178}
179
180export function matchForgejoLogin(
181 logins: ReturnType<typeof parseForgejoLogins>,
182 remote: NonNullable<ReturnType<typeof parseForgejoRemote>>,
183 requestedHost?: string,
184 hostOnly = false,
185) {
186 const matches = [
187 ...new Map(
188 logins
189 .filter((login) => {
190 const url = parseForgejoRemote(login.url);
191 if (!url) return false;
192 if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false;
193 return remote.ssh
194 ? login.ssh_host?.toLowerCase() === remote.host ||
195 login.ssh_host?.toLowerCase() === remote.hostname ||
196 url.hostname === remote.hostname
197 : url.host === remote.host &&
198 ((hostOnly && !remote.path) ||
199 !url.path ||
200 remote.path === url.path ||
201 remote.path.startsWith(`${url.path}/`));
202 })
203 .map((login) => [login.name, login]),
204 ).values(),
205 ];
206 return matches.length === 1
207 ? matches[0]
208 : new Set(matches.map((login) => login.url)).size === 1
209 ? matches.find((login) => login.default === "true")
210 : undefined;
211}
212
213export const make = Effect.gen(function* () {
214 const { process } = yield* SourceControlHost.SourceControlHost;
215 const fileSystem = yield* FileSystem.FileSystem;
216 const httpClient = yield* HttpClient.HttpClient;
217 const authLock = yield* Semaphore.make(1);
218 const authenticated = new Map<string, { token: string; time: number }>();
219 const execute: ForgejoCli["Service"]["execute"] = (input) =>
220 process
221 .run({
222 ...input,
223 operation: "ForgejoCli.execute",
224 command: input.command ?? "tea",
225 timeoutMs: input.timeoutMs ?? 30_000,
226 })
227 .pipe(
228 Effect.mapError(
229 (cause) =>
230 new ForgejoCliError({
231 command: input.command ?? "tea",
232 cwd: input.cwd,
233 ...(input.command === "fj" ? {} : { cause }),
234 ...(cause._tag === "VcsProcessSpawnError"
235 ? { reason: "missing-cli" as const }
236 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
237 ? { reason: "authentication" as const }
238 : {}),
239 detail:
240 cause._tag === "VcsProcessSpawnError"
241 ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry."
242 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
243 ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`."
244 : "Forgejo CLI command failed.",
245 }),
246 ),
247 );
248
249 const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) {
250 for (const path of forgejoKeysPaths({
251 platform: yield* HostProcess.Platform,
252 home: yield* HostProcess.HomeDirectory,
253 ...(globalThis.process.env.XDG_DATA_HOME
254 ? { dataHome: globalThis.process.env.XDG_DATA_HOME }
255 : {}),
256 ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}),
257 })) {
258 const exists = yield* fileSystem.exists(path).pipe(
259 Effect.mapError(
260 () =>
261 new ForgejoCliError({
262 command: "fj",
263 cwd,
264 reason: "authentication",
265 detail: "Could not read fj authentication storage.",
266 }),
267 ),
268 );
269 if (!exists) continue;
270 const raw = yield* fileSystem.readFileString(path).pipe(
271 Effect.mapError(
272 () =>
273 new ForgejoCliError({
274 command: "fj",
275 cwd,
276 reason: "authentication",
277 detail: "Could not read fj authentication storage.",
278 }),
279 ),
280 );
281 const decoded = parseForgejoKeys(raw);
282 if (Result.isFailure(decoded))
283 return yield* new ForgejoCliError({
284 command: "fj",
285 cwd,
286 reason: "authentication",
287 detail: "fj authentication storage is invalid. Authenticate again with fj.",
288 });
289 return decoded.success;
290 }
291 const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} };
292 return empty;
293 });
294
295 const publicLogins = (
296 keys: typeof ForgejoKeysSchema.Type,
297 remoteUrl?: string,
298 ): ReturnType<typeof parseForgejoLogins> => {
299 const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
300 return Object.keys(keys.hosts).flatMap((host) => {
301 const url = parseForgejoRemote(`https://${host}`);
302 // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them.
303 if (!url || url.path) return [];
304 // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP.
305 const scheme =
306 remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "")
307 ? "http"
308 : "https";
309 const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" };
310 const aliases = Object.entries(keys.aliases ?? {})
311 .filter(([, target]) => target === host)
312 .map(([alias]) => ({ ...login, ssh_host: alias }));
313 return aliases.length ? aliases : [login];
314 });
315 };
316
317 const listLogins: NonNullable<ForgejoCli["Service"]["listLogins"]> = Effect.fn(
318 "ForgejoCli.listLogins",
319 )(function* (input) {
320 if (input.command === "fj") {
321 const keys = yield* readKeys(input.cwd).pipe(Effect.result);
322 if (Result.isFailure(keys)) {
323 // Stale credentials from an uninstalled fj must not disable an available tea login.
324 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
325 Effect.result,
326 );
327 if (Result.isFailure(available) && available.failure.reason === "missing-cli") return [];
328 return yield* keys.failure;
329 }
330 return publicLogins(keys.success, input.remoteUrl);
331 }
332 return parseForgejoLogins(
333 (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout,
334 );
335 });
336
337 const requestFj = Effect.fn("ForgejoCli.requestFj")(
338 function* (input: {
339 readonly cwd: string;
340 readonly baseUrl: string;
341 readonly token: string;
342 readonly path: string;
343 readonly method?: ForgejoApiInput["method"];
344 readonly body?: string;
345 }) {
346 const base = new URL(`${input.baseUrl}/api/v1/`);
347 const url = new URL(input.path, base);
348 if (
349 url.origin !== base.origin ||
350 !url.pathname.startsWith(base.pathname) ||
351 url.username ||
352 url.password
353 )
354 return yield* new ForgejoCliError({
355 command: "fj",
356 cwd: input.cwd,
357 detail: "Invalid Forgejo API path.",
358 });
359 let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe(
360 HttpClientRequest.setHeader("Authorization", `token ${input.token}`),
361 );
362 if (input.body !== undefined)
363 request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
364 const response = yield* httpClient
365 .execute(request)
366 .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
367 const status = response.status;
368 const body =
369 status === 204 || status === 205
370 ? { text: "", truncated: false, invalidUtf8: false }
371 : yield* collectUint8StreamText({
372 stream: response.stream,
373 maxBytes: 8 * 1024 * 1024,
374 });
375 if (body.truncated || body.invalidUtf8)
376 return yield* new ForgejoCliError({
377 command: "fj",
378 cwd: input.cwd,
379 reason: "invalid-response",
380 detail: "Forgejo returned an oversized or invalid response.",
381 });
382 if (status < 200 || status >= 300) {
383 const detail =
384 status === 404
385 ? "Forgejo repository or pull request was not found."
386 : body.text
387 ? `Forgejo API request failed (HTTP ${status}): ${body.text}`
388 : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`;
389 return yield* new ForgejoCliError({
390 command: "fj",
391 cwd: input.cwd,
392 httpStatus: status,
393 ...(status === 401
394 ? { reason: "authentication" as const }
395 : status === 403
396 ? { reason: "forbidden" as const }
397 : status === 404
398 ? { reason: "not-found" as const }
399 : status === 429
400 ? { reason: "rate-limit" as const }
401 : {}),
402 detail,
403 });
404 }
405 return {
406 exitCode: ChildProcessSpawner.ExitCode(0),
407 stdout: body.text,
408 stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
409 stdoutTruncated: false,
410 stderrTruncated: false,
411 };
412 },
413 (effect, input) =>
414 effect.pipe(
415 Effect.timeout(30_000),
416 Effect.mapError((error) =>
417 error._tag === "ForgejoCliError"
418 ? error
419 : new ForgejoCliError({
420 command: "fj",
421 cwd: input.cwd,
422 detail: "Forgejo API request failed or timed out.",
423 }),
424 ),
425 ),
426 );
427
428 const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
429 cwd: string,
430 login: typeof ForgejoLoginSchema.Type,
431 ) {
432 const keys = yield* readKeys(cwd);
433 const token = keys.hosts[login.name]?.token;
434 const now = yield* Clock.currentTimeMillis;
435 const cached = authenticated.get(login.url);
436 if (token && cached?.token === token && now - cached.time < 30_000) return token;
437 yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
438 // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
439 const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
440 if (!refreshed)
441 return yield* new ForgejoCliError({
442 command: "fj",
443 cwd,
444 reason: "authentication",
445 detail: "fj has no credentials for this server. Authenticate again with fj.",
446 });
447 authenticated.set(login.url, { token: refreshed, time: now });
448 return refreshed;
449 }, authLock.withPermits(1));
450
451 const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn(
452 "ForgejoCli.getAccount",
453 )(function* (input) {
454 const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
455 const login = logins.find(
456 (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
457 );
458 if (!login)
459 return yield* new ForgejoCliError({
460 command: "fj",
461 cwd: input.cwd,
462 reason: "authentication",
463 detail: "fj has no credentials for this server.",
464 });
465 const token = yield* authenticateFj(input.cwd, login);
466 const currentUser = yield* requestFj({
467 cwd: input.cwd,
468 baseUrl: login.url.replace(/\/+$/, ""),
469 token,
470 path: "user",
471 });
472 const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
473 if (Result.isFailure(user) || !user.success.login.trim())
474 return yield* new ForgejoCliError({
475 command: "fj",
476 cwd: input.cwd,
477 reason: "invalid-response",
478 detail: "Forgejo returned an invalid account response.",
479 });
480 return user.success.login;
481 });
482
483 const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
484 input: ForgejoRepositoryInput,
485 hostOnly = false,
486 ) {
487 const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
488 let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
489 (value) => value && parseForgejoRemote(value),
490 );
491 let remote =
492 referenceRemote ??
493 (input.repository ? parseForgejoRemote(input.repository) : null) ??
494 (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
495 if (!remote && (!input.repository || input.host)) {
496 const result = yield* process
497 .run({
498 operation: "ForgejoCli.remote",
499 command: "git",
500 args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
501 cwd: input.cwd,
502 allowNonZeroExit: true,
503 })
504 .pipe(
505 Effect.mapError(
506 (cause) =>
507 new ForgejoCliError({
508 command: "tea",
509 cwd: input.cwd,
510 detail: "Could not resolve the Forgejo repository remote.",
511 cause,
512 }),
513 ),
514 );
515 if (input.host) {
516 const matchingUrls = [
517 ...new Set(
518 result.stdout.split("\n").flatMap((line) => {
519 const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
520 return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
521 ? [url]
522 : [];
523 }),
524 ),
525 ];
526 const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
527 remoteUrl =
528 matchingUrls.length === 1
529 ? matchingUrls[0]
530 : origins.length === 1
531 ? origins[0]
532 : undefined;
533 } else {
534 remoteUrl = result.stdout.trim();
535 }
536 remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
537 }
538 if (
539 input.host &&
540 !remote?.ssh &&
541 remote?.host !== input.host.toLowerCase() &&
542 remote?.hostname !== input.host.toLowerCase()
543 )
544 remote = {
545 host: input.host.toLowerCase(),
546 hostname: input.host.split(":")[0] ?? input.host,
547 ssh: false,
548 path: remote?.path ?? "",
549 };
550 const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
551 const fjLogins = yield* listLogins({
552 cwd: input.cwd,
553 command: "fj",
554 ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
555 });
556 const requestedHost = input.host ?? input.context?.requestedHost;
557 const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
558 const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) =>
559 remote
560 ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
561 : (logins.find((item) => item.default === "true") ??
562 (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
563 let login = selectLogin(fjLogins);
564 let command: "fj" | "tea" = "fj";
565 if (
566 !login &&
567 fjLogins.some(
568 (item) =>
569 !remote ||
570 matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
571 )
572 ) {
573 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
574 Effect.result,
575 );
576 if (Result.isSuccess(available))
577 return yield* new ForgejoCliError({
578 command: "fj",
579 cwd: input.cwd,
580 reason: "authentication",
581 detail: "Multiple fj logins match this repository. Specify its full server URL.",
582 });
583 if (available.failure.reason !== "missing-cli") return yield* available.failure;
584 }
585 if (login) {
586 const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
587 if (Result.isFailure(auth)) {
588 if (auth.failure.reason === "missing-cli") login = undefined;
589 else return yield* auth.failure;
590 }
591 }
592 if (!login) {
593 command = "tea";
594 login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
595 }
596 if (!login)
597 return yield* new ForgejoCliError({
598 command: "tea",
599 cwd: input.cwd,
600 reason: "authentication",
601 detail:
602 "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.",
603 });
604 if (hostOnly)
605 return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
606 const path =
607 referenceRemote?.path ??
608 (input.repository && !parseForgejoRemote(input.repository)
609 ? input.repository
610 : remote?.path) ??
611 "";
612 const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
613 const relativePath =
614 basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
615 ? path.slice(basePath.length + 1)
616 : path;
617 const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
618 if (command === "fj" && !repositoryPath.includes("/")) {
619 login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
620 }
621 const repository = repositoryPath.includes("/")
622 ? repositoryPath
623 : `${login.user}/${repositoryPath}`;
624 if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
625 return yield* new ForgejoCliError({
626 command,
627 cwd: input.cwd,
628 detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
629 });
630 return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
631 });
632 const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
633 const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
634 const repository = yield* resolveTarget(
635 input,
636 input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
637 );
638 const stdin =
639 input.body === undefined
640 ? undefined
641 : yield* encodeApiBody(input.body).pipe(
642 Effect.mapError(
643 (cause) =>
644 new ForgejoCliError({
645 command: "tea",
646 cwd: input.cwd,
647 detail: "Could not encode the Forgejo request body.",
648 cause,
649 }),
650 ),
651 );
652 let path = input.path.replace(/^\/+/, "");
653 if (input.repository && input.repository !== repository.repository) {
654 // Repository identities retain the server mount path; API routes do not.
655 const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
656 if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
657 path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
658 }
659 }
660 if (repository.command === "fj") {
661 const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
662 if (!token)
663 return yield* new ForgejoCliError({
664 command: "fj",
665 cwd: input.cwd,
666 reason: "authentication",
667 detail: "fj has no credentials for this server.",
668 });
669 return yield* requestFj({
670 cwd: input.cwd,
671 baseUrl: repository.baseUrl,
672 token,
673 path,
674 ...(input.method === undefined ? {} : { method: input.method }),
675 ...(stdin === undefined ? {} : { body: stdin }),
676 });
677 }
678 const result = yield* execute({
679 cwd: input.cwd,
680 args: [
681 "api",
682 "--include",
683 "--login",
684 repository.login,
685 ...(repository.repository ? ["--repo", repository.repository] : []),
686 "--method",
687 input.method ?? "GET",
688 ...(input.body === undefined ? [] : ["--data", "@-"]),
689 `${repository.baseUrl}/api/v1/${path}`,
690 ],
691 ...(stdin === undefined ? {} : { stdin }),
692 });
693 // tea reports HTTP failures with exit code zero; use its response status.
694 const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
695 if (!status || status >= 400)
696 return yield* new ForgejoCliError({
697 command: "tea",
698 cwd: input.cwd,
699 ...(status ? { httpStatus: status } : {}),
700 ...(status === 401
701 ? { reason: "authentication" as const }
702 : status === 403
703 ? { reason: "forbidden" as const }
704 : status === 404
705 ? { reason: "not-found" as const }
706 : status === 429
707 ? { reason: "rate-limit" as const }
708 : {}),
709 detail:
710 status === 401 || status === 403
711 ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
712 : status === 404
713 ? "Forgejo repository or pull request was not found."
714 : status === 429
715 ? "Forgejo API rate limit exceeded."
716 : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
717 });
718 return result;
719 });
720 return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
721});
722
723export const layer = Layer.effect(ForgejoCli, make);