1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Redacted from "effect/Redacted";
6import * as Schedule from "effect/Schedule";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { isResolved } from "../Diff.ts";
9import { dedent } from "../Util/dedent.ts";
10import { exec } from "../Util/exec.ts";
11import { GitHubCredentials } from "./Credentials.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import { effectiveGitHubBaseUrl, gitHubBaseUrlChanged } from "./Octokit.ts";
15import type * as GitHub from "./Providers.ts";
16
17export interface WikiPageProps {
18 /**
19 * Repository owner (user or organization).
20 */
21 owner: string;
22
23 /**
24 * Repository name.
25 */
26 repository: string;
27
28 /**
29 * Page title (e.g. `Home`, `Getting Started`). Whitespace becomes hyphens
30 * in the page name. Changing that name replaces the page (creates a new
31 * page and deletes the old one if opted in via `allowDelete`).
32 */
33 title: string;
34
35 /**
36 * Page content (supports GitHub Markdown, AsciiDoc, MediaWiki, and more
37 * depending on the file extension derived from `format`).
38 *
39 * The content is automatically dedented, so you can use indented template
40 * literals without worrying about leading whitespace. Accepts
41 * `Output<string>` at the call site via `Output.interpolate` to embed
42 * resource attributes that are not yet resolved.
43 */
44 content: string;
45
46 /**
47 * Commit message for creating or updating the page.
48 * @default "Update {title}"
49 */
50 message?: string;
51
52 /**
53 * Page format/markup language. The format is appended to the page name as
54 * an extension (e.g. `markdown` → `Page.md`, `asciidoc` → `Page.asciidoc`).
55 * @default "markdown"
56 */
57 format?:
58 | "markdown"
59 | "asciidoc"
60 | "mediawiki"
61 | "org"
62 | "pod"
63 | "rdoc"
64 | "rest"
65 | "textile";
66
67 /**
68 * Whether to allow deletion of the page when the resource is destroyed.
69 * By default, wiki pages are never deleted to preserve documentation history.
70 * @default false
71 */
72 allowDelete?: boolean;
73
74 /**
75 * Override the GitHub host or API base URL for this resource only (e.g.
76 * `github.example.com` for GitHub Enterprise). Falls back to
77 * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth
78 * provider. Changing it replaces the resource — the same name on a
79 * different GitHub instance is a different physical resource. Authenticated
80 * wiki Git operations require HTTPS.
81 */
82 baseUrl?: string;
83}
84
85export interface WikiPage extends Resource<
86 "GitHub.WikiPage",
87 WikiPageProps,
88 {
89 /**
90 * The page title.
91 */
92 title: string;
93
94 /**
95 * The page name in URL form (e.g. `Home`, `Getting-Started`).
96 */
97 pageName: string;
98
99 /**
100 * URL to view the page in a browser.
101 */
102 htmlUrl: string;
103
104 /**
105 * SHA hash of the current page revision.
106 */
107 sha: string;
108 },
109 never,
110 GitHub.Providers
111> {}
112
113/**
114 * A GitHub wiki page.
115 *
116 * `WikiPage` manages the lifecycle of a page in a repository's wiki. Wiki
117 * pages are created on first deploy and updated in place on subsequent
118 * deploys when the `content` changes. By default, pages are never deleted to
119 * preserve documentation history — set `allowDelete: true` to opt in.
120 *
121 * The repository's wiki must be enabled (`hasWiki: true` on the Repository
122 * resource) and initialized by creating its first page in the GitHub web UI.
123 * Enabling the wiki or setting `autoInit` on the repository does not initialize
124 * the separate wiki Git repository. An inaccessible or uninitialized wiki
125 * produces `WikiRepositoryUnavailable` with setup instructions.
126 *
127 * Git must be installed on the deployment machine. Pages are managed through
128 * the wiki's Git repository, not the GitHub REST API. Each content or format
129 * change creates a commit; unchanged pages do not. Concurrent pushes are
130 * retried against a fresh checkout without force-pushing. Deletion removes the
131 * current page file, not its Git history. Authentication uses a transient
132 * process-environment header; tokens are not stored in clone URLs or Git config.
133 *
134 * Authentication is resolved via the `GitHubCredentials` service supplied by
135 * `GitHub.providers()` (env, stored PAT, `gh` CLI, or OAuth). The token needs
136 * `repo` scope for private repositories or `public_repo` for public ones.
137 * ### Creating Wiki Pages
138 * **Example:** Basic Wiki Page
139 * ```typescript
140 * const home = yield* GitHub.WikiPage("home", {
141 * owner: "my-org",
142 * repository: "my-repo",
143 * title: "Home",
144 * content: "Welcome to the wiki!",
145 * });
146 * ```
147 *
148 * **Example:** Formatted Wiki Page
149 * ```typescript
150 * yield* GitHub.WikiPage("getting-started", {
151 * owner: "my-org",
152 * repository: "my-repo",
153 * title: "Getting Started",
154 * content: `
155 * # Getting Started
156 *
157 * Install the package:
158 *
159 * \`\`\`bash
160 * npm install my-package
161 * \`\`\`
162 * `,
163 * format: "markdown",
164 * message: "Add getting started guide",
165 * });
166 * ```
167 *
168 * ### Updating Wiki Pages
169 * Deploy with the same logical ID and a different `content` to update the
170 * existing page in place rather than creating a new one.
171 *
172 * **Example:** Update Page Content
173 * ```typescript
174 * yield* GitHub.WikiPage("api-docs", {
175 * owner: "my-org",
176 * repository: "my-repo",
177 * title: "API Documentation",
178 * content: "Updated API documentation content",
179 * });
180 * ```
181 *
182 * ### Alternative Formats
183 * **Example:** AsciiDoc Page
184 * ```typescript
185 * yield* GitHub.WikiPage("architecture", {
186 * owner: "my-org",
187 * repository: "my-repo",
188 * title: "Architecture",
189 * content: `
190 * = System Architecture
191 *
192 * == Overview
193 *
194 * The system is built with...
195 * `,
196 * format: "asciidoc",
197 * });
198 * ```
199 *
200 * ### Deleting Wiki Pages
201 * **Example:** Allow Page Deletion
202 * ```typescript
203 * const temp = yield* GitHub.WikiPage("temp-page", {
204 * owner: "my-org",
205 * repository: "my-repo",
206 * title: "Temporary Page",
207 * content: "This page can be deleted",
208 * allowDelete: true,
209 * });
210 * ```
211 *
212 * ### Wiring with Other Resources
213 * **Example:** Create Wiki Pages for a Repository
214 * Deploy the repository first and create its first wiki page in the GitHub
215 * web UI before adding `WikiPage` to the stack.
216 * ```typescript
217 * import * as Output from "alchemy/Output";
218 *
219 * const repo = yield* GitHub.Repository("docs", {
220 * owner: "my-org",
221 * name: "docs",
222 * hasWiki: true,
223 * autoInit: true,
224 * });
225 *
226 * yield* GitHub.WikiPage("home", {
227 * owner: repo.owner!,
228 * repository: Output.map(repo.fullName, (fullName) => fullName.split("/")[1]!),
229 * title: "Home",
230 * content: "Welcome to the documentation wiki!",
231 * });
232 * ```
233 *
234 * @resource
235 * @product Repository
236 */
237export const WikiPage = Resource<WikiPage>("GitHub.WikiPage");
238
239export class WikiRepositoryUnavailable extends Data.TaggedError(
240 "WikiRepositoryUnavailable",
241)<{ readonly message: string }> {}
242
243export class WikiGitError extends Data.TaggedError("WikiGitError")<{
244 readonly operation: string;
245 readonly reason: "missing" | "conflict" | "command";
246 readonly message: string;
247}> {}
248
249export class InvalidWikiPage extends Data.TaggedError("InvalidWikiPage")<{
250 readonly message: string;
251}> {}
252
253const extensions = {
254 markdown: ["md", "markdown", "mdown", "mkdn", "mkd"],
255 asciidoc: ["asciidoc", "adoc", "asc"],
256 mediawiki: ["mediawiki", "wiki"],
257 org: ["org"],
258 pod: ["pod"],
259 rdoc: ["rdoc"],
260 rest: ["rst", "rest"],
261 textile: ["textile"],
262} as const;
263
264export interface WikiRepository {
265 readonly remote: string;
266 readonly htmlUrl: string;
267 readonly token: Redacted.Redacted<string>;
268}
269
270const pageNameFor = (title: string) =>
271 Effect.gen(function* () {
272 if (
273 title.trim() !== title ||
274 title.length === 0 ||
275 /[\x00-\x1f\x7f/\\]/.test(title) ||
276 title === "." ||
277 title === ".."
278 ) {
279 return yield* new InvalidWikiPage({
280 message:
281 "Wiki page titles must be nonempty, trimmed names without path separators or control characters.",
282 });
283 }
284 return title.replace(/\s+/g, "-");
285 });
286
287export const wikiRepository = Effect.fn(function* (props: WikiPageProps) {
288 yield* pageNameFor(props.title);
289 if (
290 ![props.owner, props.repository].every(
291 (part) => /^[A-Za-z0-9_.-]+$/.test(part) && part !== "." && part !== "..",
292 )
293 ) {
294 return yield* new InvalidWikiPage({
295 message:
296 "Wiki owner and repository must be GitHub names, not paths or URLs.",
297 });
298 }
299 const baseUrl = yield* effectiveGitHubBaseUrl(props.baseUrl);
300 const origin = yield* Effect.sync(() => {
301 const url = new URL(baseUrl ?? "https://github.com");
302 if (url.hostname.startsWith("api.") && url.hostname.endsWith(".ghe.com")) {
303 url.hostname = url.hostname.slice(4);
304 }
305 return url.origin;
306 });
307 if (!origin.startsWith("https://")) {
308 return yield* new InvalidWikiPage({
309 message: "Wiki Git authentication requires an HTTPS GitHub host.",
310 });
311 }
312 const credentials = yield* yield* GitHubCredentials;
313 const repositoryUrl = `${origin}/${props.owner}/${props.repository}`;
314 return {
315 remote: `${repositoryUrl}.wiki.git`,
316 htmlUrl: `${repositoryUrl}/wiki`,
317 token: credentials.token,
318 } satisfies WikiRepository;
319});
320
321// Only the transient process environment contains the authorization header.
322const gitEnvironment = Effect.fn(function* (
323 repository: WikiRepository,
324 directory: string,
325) {
326 const path = yield* Path.Path;
327 const fs = yield* FileSystem.FileSystem;
328 const config = path.join(directory, "gitconfig");
329 yield* fs.writeFileString(config, "");
330 return yield* Effect.sync(() => ({
331 PATH: process.env.PATH,
332 SystemRoot: process.env.SystemRoot,
333 HOME: directory,
334 LC_ALL: "C",
335 GIT_TERMINAL_PROMPT: "0",
336 GIT_CONFIG_NOSYSTEM: "1",
337 GIT_CONFIG_GLOBAL: config,
338 GIT_CONFIG_COUNT: "1",
339 GIT_CONFIG_KEY_0: `http.${repository.remote}.extraHeader`,
340 GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`x-access-token:${Redacted.value(repository.token)}`).toString("base64")}`,
341 GIT_AUTHOR_NAME: "Alchemy",
342 GIT_AUTHOR_EMAIL: "alchemy@users.noreply.github.com",
343 GIT_COMMITTER_NAME: "Alchemy",
344 GIT_COMMITTER_EMAIL: "alchemy@users.noreply.github.com",
345 }));
346});
347
348const git = Effect.fn(
349 function* (
350 directory: string,
351 env: Record<string, string | undefined>,
352 ...args: string[]
353 ) {
354 const operation = args[0]!;
355 const result = yield* exec(
356 ChildProcess.make(
357 "git",
358 [
359 "--literal-pathspecs",
360 "-c",
361 "credential.helper=",
362 "-c",
363 "commit.gpgSign=false",
364 "-c",
365 "http.followRedirects=false",
366 ...args,
367 ],
368 { cwd: directory, env, extendEnv: false },
369 ),
370 ).pipe(
371 Effect.mapError(
372 () =>
373 new WikiGitError({
374 operation,
375 reason: "command",
376 message: `Unable to run git ${operation}. Ensure Git is installed and executable.`,
377 }),
378 ),
379 );
380 if (result.exitCode !== 0) {
381 const reason =
382 /Repository not found|repository '.+' (?:not found|does not exist)|does not appear to be a git repository/i.test(
383 result.stderr,
384 )
385 ? "missing"
386 : operation === "push" &&
387 /\[rejected\]|cannot lock ref|failed to update ref/i.test(
388 result.stderr,
389 )
390 ? "conflict"
391 : "command";
392 // Git diagnostics can include server-controlled text or credentials.
393 return yield* new WikiGitError({
394 operation,
395 reason,
396 message: `git ${operation} failed (exit ${result.exitCode}). Check repository access and GitHub token permissions.`,
397 });
398 }
399 return result.stdout;
400 },
401 Effect.scoped,
402 Effect.timeout("20 seconds"),
403);
404
405const checkout = Effect.fn(function* (repository: WikiRepository) {
406 const fs = yield* FileSystem.FileSystem;
407 const path = yield* Path.Path;
408 const directory = yield* fs.makeTempDirectoryScoped({
409 prefix: "alchemy-wiki-",
410 });
411 const env = yield* gitEnvironment(repository, directory);
412 yield* git(
413 directory,
414 env,
415 "clone",
416 "--quiet",
417 "--",
418 repository.remote,
419 "wiki",
420 ).pipe(
421 Effect.catchTag("WikiGitError", (error) =>
422 Effect.fail(
423 error.reason === "missing"
424 ? new WikiRepositoryUnavailable({
425 message: `Cannot access ${repository.htmlUrl}. Enable the repository wiki and create its first page in the GitHub web UI before deploying WikiPage. Git cannot initialize a GitHub wiki that has never had a page. If it is already initialized, verify the repository name and token's repository access.`,
426 })
427 : error,
428 ),
429 ),
430 );
431 const cwd = path.join(directory, "wiki");
432 const run = (...args: string[]) => git(cwd, env, ...args);
433 const files = (yield* run("ls-files", "-z")).split("\0").filter(Boolean);
434 return { cwd, run, files };
435});
436
437type Checkout = Effect.Success<ReturnType<typeof checkout>>;
438
439const pageFiles = (files: string[], pageName: string) =>
440 Object.values(extensions)
441 .flatMap((formats) =>
442 formats.map((extension) => `${pageName}.${extension}`),
443 )
444 .filter((file) => files.includes(file));
445
446const attributes = Effect.fn(function* (
447 repository: WikiRepository,
448 props: WikiPageProps,
449 wiki: Checkout,
450 file: string,
451) {
452 return {
453 title: props.title,
454 pageName: props.title.replace(/\s+/g, "-"),
455 htmlUrl: `${repository.htmlUrl}/${encodeURIComponent(props.title.replace(/\s+/g, "-"))}`,
456 sha: (yield* wiki.run("log", "-1", "--format=%H", "--", file)).trim(),
457 } satisfies WikiPage["Attributes"];
458});
459
460export const readWikiPage = Effect.fn(
461 function* (repository: WikiRepository, props: WikiPageProps) {
462 const pageName = yield* pageNameFor(props.title);
463 const wiki = yield* checkout(repository);
464 const files = pageFiles(wiki.files, pageName);
465 const desired = `${pageName}.${extensions[props.format ?? "markdown"][0]}`;
466 const file = files.includes(desired) ? desired : files[0];
467 return file === undefined
468 ? undefined
469 : yield* attributes(repository, props, wiki, file);
470 },
471 Effect.scoped,
472 (effect) =>
473 effect.pipe(
474 Effect.catchTag("WikiRepositoryUnavailable", () =>
475 Effect.succeed(undefined),
476 ),
477 ),
478);
479
480const retryConcurrentPush = <A, E, R>(
481 effect: Effect.Effect<A, E | WikiGitError, R>,
482) =>
483 effect.pipe(
484 Effect.retry({
485 while: (error) =>
486 error instanceof WikiGitError && error.reason === "conflict",
487 schedule: Schedule.spaced("200 millis"),
488 times: 3,
489 }),
490 Effect.timeout("60 seconds"),
491 );
492
493export const syncWikiPage = Effect.fn(
494 function* (repository: WikiRepository, props: WikiPageProps) {
495 const pageName = yield* pageNameFor(props.title);
496 const wiki = yield* checkout(repository);
497 const fs = yield* FileSystem.FileSystem;
498 const path = yield* Path.Path;
499 const files = pageFiles(wiki.files, pageName);
500 const file = `${pageName}.${extensions[props.format ?? "markdown"][0]}`;
501 const content = dedent(props.content);
502 const observed = wiki.files.includes(file)
503 ? yield* wiki.run("show", `HEAD:${file}`)
504 : undefined;
505 const mode = wiki.files.includes(file)
506 ? yield* wiki.run("ls-files", "--stage", "--", file)
507 : "";
508 if (
509 observed !== content ||
510 files.some((existing) => existing !== file) ||
511 mode.startsWith("120000")
512 ) {
513 if (files.length > 0) yield* wiki.run("rm", "--", ...files);
514 yield* fs.writeFileString(path.join(wiki.cwd, file), content);
515 yield* wiki.run("add", "--", file);
516 yield* wiki.run(
517 "commit",
518 "--quiet",
519 "-m",
520 props.message ?? `Update ${props.title}`,
521 );
522 yield* wiki.run("push", "--quiet", "origin", "HEAD");
523 }
524 return yield* attributes(repository, props, wiki, file);
525 },
526 Effect.scoped,
527 retryConcurrentPush,
528);
529
530export const deleteWikiPage = Effect.fn(
531 function* (repository: WikiRepository, props: WikiPageProps) {
532 if (!props.allowDelete) return;
533 const pageName = yield* pageNameFor(props.title);
534 const wiki = yield* checkout(repository);
535 const files = pageFiles(wiki.files, pageName);
536 if (files.length === 0) return;
537 yield* wiki.run("rm", "--", ...files);
538 yield* wiki.run("commit", "--quiet", "-m", `Delete ${props.title}`);
539 yield* wiki.run("push", "--quiet", "origin", "HEAD");
540 },
541 Effect.scoped,
542 retryConcurrentPush,
543 (effect) =>
544 effect.pipe(
545 Effect.catchTag("WikiRepositoryUnavailable", () => Effect.void),
546 ),
547);
548
549export const WikiPageProvider = () =>
550 Provider.succeed(WikiPage, {
551 stables: ["pageName"],
552
553 // Wiki pages have no account-wide enumeration API.
554 list: () => Effect.succeed([]),
555
556 // Replacement must not delete the successor when titles normalize identically.
557 diff: Effect.fn(function* ({ news, olds }) {
558 if (!isResolved(news)) return;
559 if (olds === undefined) return;
560 if (
561 news.owner !== olds.owner ||
562 news.repository !== olds.repository ||
563 news.title.replace(/\s+/g, "-") !== olds.title.replace(/\s+/g, "-") ||
564 (yield* gitHubBaseUrlChanged(olds, news))
565 ) {
566 return { action: "replace" };
567 }
568 }),
569
570 read: Effect.fn(function* ({ olds }) {
571 return yield* readWikiPage(yield* wikiRepository(olds), olds);
572 }),
573
574 reconcile: Effect.fn(function* ({ news }) {
575 return yield* syncWikiPage(yield* wikiRepository(news), news);
576 }),
577
578 delete: Effect.fn(function* ({ olds }) {
579 if (!olds.allowDelete) return;
580 yield* deleteWikiPage(yield* wikiRepository(olds), olds);
581 }),
582 });