Skip to content
Source
Sign inSign up
Open History to see the last commit

ForgejoCli.ts

771 lines28.2 KB

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