diff --git a/.env.example b/.env.example index 30d737f..793e4bc 100644 --- a/.env.example +++ b/.env.example @@ -2,16 +2,34 @@ # Project paths/categories belong in sourcedraft.config.json instead SOURCEDRAFT_ADMIN_PASSWORD= -# Set to true to run Studio in demo mode (no GitHub commits) +# Set to true to run Studio in demo mode (no remote commits) SOURCEDRAFT_DEMO_MODE= + +# Publisher: github (default), gitlab, or bitbucket — also set in sourcedraft.config.json +CMS_PUBLISHER=github + +# GitHub (when CMS_PUBLISHER=github) GITHUB_TOKEN= GITHUB_OWNER= GITHUB_REPO= GITHUB_BRANCH=main +# GitLab (when CMS_PUBLISHER=gitlab) +GITLAB_TOKEN= +GITLAB_PROJECT_ID= +GITLAB_PROJECT_PATH= +GITLAB_BRANCH=main +GITLAB_BASE_URL=https://gitlab.com + +# Bitbucket Cloud (when CMS_PUBLISHER=bitbucket) +BITBUCKET_TOKEN= +BITBUCKET_WORKSPACE= +BITBUCKET_REPO_SLUG= +BITBUCKET_BRANCH=main +BITBUCKET_USERNAME= + # Optional overrides for sourcedraft.config.json CMS_CONTENT_DIR= CMS_MEDIA_DIR= CMS_PUBLIC_MEDIA_PATH= CMS_ADAPTER= -CMS_PUBLISHER= diff --git a/README.md b/README.md index 3729ad9..0f7d049 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ Your static site — Astro today, others later — still builds and deploys exac - List and edit existing posts from your GitHub `contentDir` - Validate fields against a universal article schema - Preview Markdown or Astro MDX output and target file path before publishing -- Publish to GitHub (create or update a file on a branch) -- Upload images to GitHub (`mediaDir`) from Studio +- Publish to GitHub, GitLab, or Bitbucket (create or update a file on a branch) +- Upload images to the configured remote (`mediaDir`) from Studio - Configure paths, adapter, and categories in `sourcedraft.config.json` - Protect Studio with a server-side admin password - **Demo mode** — explore Studio with sample posts without GitHub credentials @@ -52,19 +52,19 @@ Your static site — Astro today, others later — still builds and deploys exac See [docs/project-status.md](docs/project-status.md). -## How GitHub publishing works +## How publishing works -1. You finish a valid article in Studio and click **Publish to GitHub**. +1. You finish a valid article in Studio and click **Publish**. 2. The **publish API** (server only) validates the article again. 3. The configured **adapter** builds the file (YAML frontmatter + body) as `.mdx` or `.md`. -4. The **GitHub publisher** checks whether the file exists in your repo, then creates or updates it via the GitHub API. +4. The configured **publisher** (`github`, `gitlab`, or `bitbucket`) commits the file to your repository. 5. Your existing CI or build step picks up the new file from `contentDir`. -The GitHub token never reaches the browser. It is read from `.env` on the server when you publish or upload media. +API tokens never reach the browser. They are read from `.env` on the server when you publish or upload media. -Details: [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md) +Details: [docs/git-publishers.md](docs/git-publishers.md) · [docs/github-publishing.md](docs/github-publishing.md) · [docs/media.md](docs/media.md) -v0.1 uses the GitHub Contents API — suitable for typical blogs; very large content folders are a known MVP limitation. +**Compatibility:** GitHub (Contents API), GitLab (Repository Files API), and Bitbucket Cloud (commit-upload). GitHub and GitLab support listing/editing existing posts; Bitbucket supports publish and media upload only today. ## Quickstart @@ -147,6 +147,7 @@ Issues and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) fo - [Getting started](docs/getting-started.md) - [Demo mode](docs/demo-mode.md) - [Non-technical overview](docs/non-technical-overview.md) — for writers +- [Git publishing (GitHub, GitLab, Bitbucket)](docs/git-publishers.md) - [GitHub publishing](docs/github-publishing.md) - [Media uploads](docs/media.md) - [Configuration](docs/configuration.md) diff --git a/apps/studio/server/auth.ts b/apps/studio/server/auth.ts index 8d437ea..bd375f1 100644 --- a/apps/studio/server/auth.ts +++ b/apps/studio/server/auth.ts @@ -1,6 +1,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import type { NextFunction, Request, Response } from "express"; -import { isDemoModeAvailable, isDemoModeForced, isGitHubConfigured } from "./demoMode.js"; +import { + isDemoModeAvailable, + isDemoModeForced, + isPublisherConfigured, +} from "./demoMode.js"; const SESSION_COOKIE = "sourcedraft_session"; /** 24 hours — in-memory MVP sessions, not durable account auth. */ @@ -168,7 +172,7 @@ export function isDemoSession(token: string | null): boolean { } export function isRequestDemoSession(req: Request): boolean { - if (isDemoModeForced() || !isGitHubConfigured()) { + if (isDemoModeForced() || !isPublisherConfigured()) { return true; } diff --git a/apps/studio/server/config.ts b/apps/studio/server/config.ts index c29231c..aeceab4 100644 --- a/apps/studio/server/config.ts +++ b/apps/studio/server/config.ts @@ -33,6 +33,9 @@ export type PublishEnvConfig = { adapterOptions?: Record; publisherOptions?: Record; categories: string[]; + gitlabProjectRef?: string; + gitlabBaseUrl?: string; + bitbucketUsername?: string; }; export type PublishEnvResult = @@ -75,22 +78,87 @@ function resolvePublicMediaPath( return derivePublicMediaPath(mediaDir); } -export function loadPublishEnv(): PublishEnvResult { - const project = loadProjectConfig(); +type PublisherCredentialsResult = + | { + ok: true; + token: string; + owner: string; + repo: string; + branch: string; + gitlabProjectRef?: string; + gitlabBaseUrl?: string; + bitbucketUsername?: string; + } + | { ok: false; error: string }; + +function resolvePublisherCredentials( + publisher: SupportedPublisher, + defaultBranch: string, +): PublisherCredentialsResult { + if (publisher === "gitlab") { + const token = process.env.GITLAB_TOKEN?.trim(); + const projectId = process.env.GITLAB_PROJECT_ID?.trim(); + const projectPath = process.env.GITLAB_PROJECT_PATH?.trim(); + const gitlabProjectRef = projectId || projectPath; + const branch = process.env.GITLAB_BRANCH?.trim() || defaultBranch; + const gitlabBaseUrl = + process.env.GITLAB_BASE_URL?.trim() || "https://gitlab.com"; + + if (!token) { + return { ok: false, error: "GITLAB_TOKEN is not configured." }; + } + + if (!gitlabProjectRef) { + return { + ok: false, + error: "GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH is not configured.", + }; + } + + return { + ok: true, + token, + owner: projectPath || projectId || "", + repo: "", + branch, + gitlabProjectRef, + gitlabBaseUrl, + }; + } + + if (publisher === "bitbucket") { + const token = process.env.BITBUCKET_TOKEN?.trim(); + const owner = process.env.BITBUCKET_WORKSPACE?.trim(); + const repo = process.env.BITBUCKET_REPO_SLUG?.trim(); + const branch = process.env.BITBUCKET_BRANCH?.trim() || defaultBranch; + const bitbucketUsername = process.env.BITBUCKET_USERNAME?.trim(); + + if (!token) { + return { ok: false, error: "BITBUCKET_TOKEN is not configured." }; + } + + if (!owner) { + return { ok: false, error: "BITBUCKET_WORKSPACE is not configured." }; + } + + if (!repo) { + return { ok: false, error: "BITBUCKET_REPO_SLUG is not configured." }; + } + + return { + ok: true, + token, + owner, + repo, + branch, + ...(bitbucketUsername ? { bitbucketUsername } : {}), + }; + } const token = process.env.GITHUB_TOKEN?.trim(); const owner = process.env.GITHUB_OWNER?.trim(); const repo = process.env.GITHUB_REPO?.trim(); - const branch = - process.env.GITHUB_BRANCH?.trim() || project.defaultBranch; - const contentDir = - process.env.CMS_CONTENT_DIR?.trim() || project.contentDir; - const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; - const publicMediaPath = resolvePublicMediaPath(mediaDir, project); - const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; - const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; - const adapter = resolveAdapter(rawAdapter); - const publisher = resolvePublisher(rawPublisher); + const branch = process.env.GITHUB_BRANCH?.trim() || defaultBranch; if (!token) { return { ok: false, error: "GITHUB_TOKEN is not configured." }; @@ -104,6 +172,21 @@ export function loadPublishEnv(): PublishEnvResult { return { ok: false, error: "GITHUB_REPO is not configured." }; } + return { ok: true, token, owner, repo, branch }; +} + +export function loadPublishEnv(): PublishEnvResult { + const project = loadProjectConfig(); + + const contentDir = + process.env.CMS_CONTENT_DIR?.trim() || project.contentDir; + const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; + const publicMediaPath = resolvePublicMediaPath(mediaDir, project); + const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter; + const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; + const adapter = resolveAdapter(rawAdapter); + const publisher = resolvePublisher(rawPublisher); + if (adapter === null) { return { ok: false, @@ -118,13 +201,18 @@ export function loadPublishEnv(): PublishEnvResult { }; } + const credentials = resolvePublisherCredentials(publisher, project.defaultBranch); + if (!credentials.ok) { + return credentials; + } + return { ok: true, config: { - token, - owner, - repo, - branch, + token: credentials.token, + owner: credentials.owner, + repo: credentials.repo, + branch: credentials.branch, contentDir, mediaDir, publicMediaPath, @@ -136,6 +224,15 @@ export function loadPublishEnv(): PublishEnvResult { ...(project.publisherOptions !== undefined ? { publisherOptions: project.publisherOptions } : {}), + ...(credentials.gitlabProjectRef !== undefined + ? { gitlabProjectRef: credentials.gitlabProjectRef } + : {}), + ...(credentials.gitlabBaseUrl !== undefined + ? { gitlabBaseUrl: credentials.gitlabBaseUrl } + : {}), + ...(credentials.bitbucketUsername !== undefined + ? { bitbucketUsername: credentials.bitbucketUsername } + : {}), categories: project.categories, }, }; @@ -151,10 +248,30 @@ export function loadPublicConfig(): PublicStudioConfig { const publisher = resolvePublisher(rawPublisher) ?? "github"; const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir; + let owner = ""; + let repo = ""; + let branch = project.defaultBranch; + + if (publisher === "gitlab") { + owner = + process.env.GITLAB_PROJECT_PATH?.trim() || + process.env.GITLAB_PROJECT_ID?.trim() || + ""; + branch = process.env.GITLAB_BRANCH?.trim() || project.defaultBranch; + } else if (publisher === "bitbucket") { + owner = process.env.BITBUCKET_WORKSPACE?.trim() || ""; + repo = process.env.BITBUCKET_REPO_SLUG?.trim() || ""; + branch = process.env.BITBUCKET_BRANCH?.trim() || project.defaultBranch; + } else { + owner = process.env.GITHUB_OWNER?.trim() || ""; + repo = process.env.GITHUB_REPO?.trim() || ""; + branch = process.env.GITHUB_BRANCH?.trim() || project.defaultBranch; + } + return { - owner: process.env.GITHUB_OWNER?.trim() || "", - repo: process.env.GITHUB_REPO?.trim() || "", - branch: process.env.GITHUB_BRANCH?.trim() || project.defaultBranch, + owner, + repo, + branch, contentDir: process.env.CMS_CONTENT_DIR?.trim() || project.contentDir, mediaDir, publicMediaPath: resolvePublicMediaPath(mediaDir, project), diff --git a/apps/studio/server/demoMode.ts b/apps/studio/server/demoMode.ts index c07f59f..87ff7d6 100644 --- a/apps/studio/server/demoMode.ts +++ b/apps/studio/server/demoMode.ts @@ -1,7 +1,16 @@ +import { loadSourceDraftConfig } from "@sourcedraft/config"; +import { isPublisherId } from "@sourcedraft/publishers"; + export function isDemoModeForced(): boolean { return process.env.SOURCEDRAFT_DEMO_MODE?.trim().toLowerCase() === "true"; } +export function resolveActivePublisher(): string { + const project = loadSourceDraftConfig(); + const raw = process.env.CMS_PUBLISHER?.trim() || project.publisher; + return isPublisherId(raw) ? raw : "github"; +} + export function isGitHubTokenConfigured(): boolean { return (process.env.GITHUB_TOKEN?.trim().length ?? 0) > 0; } @@ -22,10 +31,55 @@ export function isGitHubConfigured(): boolean { ); } +export function isGitLabTokenConfigured(): boolean { + return (process.env.GITLAB_TOKEN?.trim().length ?? 0) > 0; +} + +export function isGitLabProjectConfigured(): boolean { + const projectId = process.env.GITLAB_PROJECT_ID?.trim(); + const projectPath = process.env.GITLAB_PROJECT_PATH?.trim(); + return (projectId?.length ?? 0) > 0 || (projectPath?.length ?? 0) > 0; +} + +export function isGitLabConfigured(): boolean { + return isGitLabTokenConfigured() && isGitLabProjectConfigured(); +} + +export function isBitbucketTokenConfigured(): boolean { + return (process.env.BITBUCKET_TOKEN?.trim().length ?? 0) > 0; +} + +export function isBitbucketWorkspaceConfigured(): boolean { + return (process.env.BITBUCKET_WORKSPACE?.trim().length ?? 0) > 0; +} + +export function isBitbucketRepoConfigured(): boolean { + return (process.env.BITBUCKET_REPO_SLUG?.trim().length ?? 0) > 0; +} + +export function isBitbucketConfigured(): boolean { + return ( + isBitbucketTokenConfigured() && + isBitbucketWorkspaceConfigured() && + isBitbucketRepoConfigured() + ); +} + +export function isPublisherConfigured(): boolean { + switch (resolveActivePublisher()) { + case "gitlab": + return isGitLabConfigured(); + case "bitbucket": + return isBitbucketConfigured(); + default: + return isGitHubConfigured(); + } +} + export function isDemoModeAvailable(): boolean { if (isDemoModeForced()) { return true; } - return !isGitHubConfigured(); + return !isPublisherConfigured(); } diff --git a/apps/studio/server/publisherRuntime.ts b/apps/studio/server/publisherRuntime.ts index c5706fa..bdfe455 100644 --- a/apps/studio/server/publisherRuntime.ts +++ b/apps/studio/server/publisherRuntime.ts @@ -21,6 +21,13 @@ export function toPublisherRuntimeConfig( ...(env.publisherOptions !== undefined ? { publisherOptions: env.publisherOptions } : {}), + ...(env.gitlabProjectRef !== undefined + ? { gitlabProjectRef: env.gitlabProjectRef } + : {}), + ...(env.gitlabBaseUrl !== undefined ? { gitlabBaseUrl: env.gitlabBaseUrl } : {}), + ...(env.bitbucketUsername !== undefined + ? { bitbucketUsername: env.bitbucketUsername } + : {}), }; } diff --git a/apps/studio/server/runtimeConfig.test.ts b/apps/studio/server/runtimeConfig.test.ts index 6c6f4e1..e7c2dcf 100644 --- a/apps/studio/server/runtimeConfig.test.ts +++ b/apps/studio/server/runtimeConfig.test.ts @@ -60,4 +60,46 @@ describe("runtime config resolution", () => { assert.equal(result.config.publisher, "github"); } }); + + it("accepts gitlab publisher when credentials are set", () => { + process.env.CMS_PUBLISHER = "gitlab"; + process.env.GITLAB_TOKEN = "gl-token"; + process.env.GITLAB_PROJECT_PATH = "group/site"; + process.env.GITLAB_BRANCH = "main"; + + const result = loadPublishEnv(); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.config.publisher, "gitlab"); + assert.equal(result.config.gitlabProjectRef, "group/site"); + } + }); + + it("accepts bitbucket publisher when credentials are set", () => { + process.env.CMS_PUBLISHER = "bitbucket"; + process.env.BITBUCKET_TOKEN = "bb-token"; + process.env.BITBUCKET_WORKSPACE = "acme"; + process.env.BITBUCKET_REPO_SLUG = "blog"; + + const result = loadPublishEnv(); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.config.publisher, "bitbucket"); + assert.equal(result.config.owner, "acme"); + assert.equal(result.config.repo, "blog"); + } + }); + + it("rejects gitlab publisher without project reference", () => { + process.env.CMS_PUBLISHER = "gitlab"; + process.env.GITLAB_TOKEN = "gl-token"; + delete process.env.GITLAB_PROJECT_ID; + delete process.env.GITLAB_PROJECT_PATH; + + const result = loadPublishEnv(); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GITLAB_PROJECT/); + } + }); }); diff --git a/apps/studio/server/setupHealth.ts b/apps/studio/server/setupHealth.ts index 434338c..d433169 100644 --- a/apps/studio/server/setupHealth.ts +++ b/apps/studio/server/setupHealth.ts @@ -3,12 +3,21 @@ import { isPublisherId } from "@sourcedraft/publishers"; import { isAuthConfigured } from "./auth.js"; import { loadProjectConfig, loadPublicConfig } from "./config.js"; import { + isBitbucketConfigured, + isBitbucketRepoConfigured, + isBitbucketTokenConfigured, + isBitbucketWorkspaceConfigured, isDemoModeAvailable, isDemoModeForced, isGitHubConfigured, isGitHubOwnerConfigured, isGitHubRepoConfigured, isGitHubTokenConfigured, + isGitLabConfigured, + isGitLabProjectConfigured, + isGitLabTokenConfigured, + isPublisherConfigured, + resolveActivePublisher, } from "./demoMode.js"; export type SetupHealthCheck = { @@ -36,6 +45,103 @@ export type SetupHealthReport = { nextAction: string | null; }; +function publisherCredentialChecks(activePublisher: string): SetupHealthCheck[] { + if (activePublisher === "gitlab") { + const tokenOk = isGitLabTokenConfigured(); + const projectOk = isGitLabProjectConfigured(); + return [ + { + id: "gitlab-token", + label: "GitLab token (server-side)", + ok: tokenOk, + detail: tokenOk + ? "GITLAB_TOKEN is present on the server. The value is never sent to the browser." + : "Set GITLAB_TOKEN in .env for GitLab publishing.", + }, + { + id: "gitlab-project", + label: "GitLab project", + ok: projectOk, + detail: projectOk + ? "GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH is configured." + : "Set GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH in .env.", + }, + ]; + } + + if (activePublisher === "bitbucket") { + const tokenOk = isBitbucketTokenConfigured(); + const workspaceOk = isBitbucketWorkspaceConfigured(); + const repoOk = isBitbucketRepoConfigured(); + return [ + { + id: "bitbucket-token", + label: "Bitbucket token (server-side)", + ok: tokenOk, + detail: tokenOk + ? "BITBUCKET_TOKEN is present on the server. The value is never sent to the browser." + : "Set BITBUCKET_TOKEN in .env for Bitbucket publishing.", + }, + { + id: "bitbucket-workspace", + label: "Bitbucket workspace", + ok: workspaceOk, + detail: workspaceOk + ? "BITBUCKET_WORKSPACE is configured." + : "Set BITBUCKET_WORKSPACE in .env.", + }, + { + id: "bitbucket-repo", + label: "Bitbucket repository", + ok: repoOk, + detail: repoOk + ? "BITBUCKET_REPO_SLUG is configured." + : "Set BITBUCKET_REPO_SLUG in .env.", + }, + ]; + } + + const ownerOk = isGitHubOwnerConfigured(); + const repoOk = isGitHubRepoConfigured(); + const tokenOk = isGitHubTokenConfigured(); + return [ + { + id: "github-owner", + label: "GitHub owner", + ok: ownerOk, + detail: ownerOk + ? "GITHUB_OWNER is configured." + : "Set GITHUB_OWNER in .env.", + }, + { + id: "github-repo", + label: "GitHub repository", + ok: repoOk, + detail: repoOk ? "GITHUB_REPO is configured." : "Set GITHUB_REPO in .env.", + }, + { + id: "github-token", + label: "GitHub token (server-side)", + ok: tokenOk, + detail: tokenOk + ? "GITHUB_TOKEN is present on the server. The value is never sent to the browser." + : "Set GITHUB_TOKEN in .env for GitHub publishing.", + }, + ]; +} + +function publisherSetupMessage(activePublisher: string): string { + if (activePublisher === "gitlab") { + return "Complete GitLab setup in .env (GITLAB_TOKEN, GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH) or use demo mode to explore without GitLab."; + } + + if (activePublisher === "bitbucket") { + return "Complete Bitbucket setup in .env (BITBUCKET_TOKEN, BITBUCKET_WORKSPACE, BITBUCKET_REPO_SLUG) or use demo mode to explore without Bitbucket."; + } + + return "Complete GitHub setup in .env (GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO) or use demo mode to explore without GitHub."; +} + export function getSetupHealth(): SetupHealthReport { const project = loadProjectConfig(); const runtime = loadPublicConfig(); @@ -43,6 +149,7 @@ export function getSetupHealth(): SetupHealthReport { const rawPublisher = process.env.CMS_PUBLISHER?.trim() || project.publisher; const adapter = isAdapterId(rawAdapter) ? rawAdapter : null; const publisher = isPublisherId(rawPublisher) ? rawPublisher : null; + const activePublisher = resolveActivePublisher(); const contentDir = runtime.contentDir.trim(); const mediaDir = runtime.mediaDir.trim(); const publicMediaPath = runtime.publicMediaPath.trim(); @@ -58,12 +165,8 @@ export function getSetupHealth(): SetupHealthReport { const publisherValid = publisher !== null; const demoModeForced = isDemoModeForced(); const demoModeAvailable = isDemoModeAvailable(); - const githubReady = - githubOwnerConfigured && - githubRepoConfigured && - githubTokenConfigured && - adapterValid && - publisherValid; + const publisherReady = + isPublisherConfigured() && adapterValid && publisherValid; const checks: SetupHealthCheck[] = [ { @@ -74,30 +177,7 @@ export function getSetupHealth(): SetupHealthReport { ? "SOURCEDRAFT_ADMIN_PASSWORD is set on the server." : "Set SOURCEDRAFT_ADMIN_PASSWORD in .env for normal sign-in.", }, - { - id: "github-owner", - label: "GitHub owner", - ok: githubOwnerConfigured, - detail: githubOwnerConfigured - ? "GITHUB_OWNER is configured." - : "Set GITHUB_OWNER in .env.", - }, - { - id: "github-repo", - label: "GitHub repository", - ok: githubRepoConfigured, - detail: githubRepoConfigured - ? "GITHUB_REPO is configured." - : "Set GITHUB_REPO in .env.", - }, - { - id: "github-token", - label: "GitHub token (server-side)", - ok: githubTokenConfigured, - detail: githubTokenConfigured - ? "GITHUB_TOKEN is present on the server. The value is never sent to the browser." - : "Set GITHUB_TOKEN in .env for GitHub publishing.", - }, + ...publisherCredentialChecks(activePublisher), { id: "content-dir", label: "Content directory", @@ -143,10 +223,10 @@ export function getSetupHealth(): SetupHealthReport { label: "Demo mode", ok: true, detail: demoModeForced - ? "SOURCEDRAFT_DEMO_MODE=true — GitHub commits are disabled." - : !isGitHubConfigured() - ? "GitHub is not fully configured — Studio uses demo content and simulated publish." - : "Demo mode is off. GitHub publishing is enabled when credentials are valid.", + ? "SOURCEDRAFT_DEMO_MODE=true — remote commits are disabled." + : !isPublisherConfigured() + ? "Publisher is not fully configured — Studio uses demo content and simulated publish." + : "Demo mode is off. Publishing is enabled when credentials are valid.", }, ]; @@ -154,21 +234,20 @@ export function getSetupHealth(): SetupHealthReport { if (demoModeForced) { nextAction = - "Demo mode is active. Explore Studio locally or configure GitHub and disable SOURCEDRAFT_DEMO_MODE for real publishing."; + "Demo mode is active. Explore Studio locally or configure your publisher and disable SOURCEDRAFT_DEMO_MODE for real publishing."; } else if (!adminPasswordConfigured && demoModeAvailable) { nextAction = "Enter demo mode from the sign-in screen or set SOURCEDRAFT_ADMIN_PASSWORD for password sign-in."; } else if (!adminPasswordConfigured) { nextAction = "Set SOURCEDRAFT_ADMIN_PASSWORD in .env and restart the API server."; - } else if (!githubReady) { - nextAction = - "Complete GitHub setup in .env (GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO) or use demo mode to explore without GitHub."; + } else if (!publisherReady) { + nextAction = publisherSetupMessage(activePublisher); } else { nextAction = null; } return { - ok: githubReady || demoModeAvailable, + ok: publisherReady || demoModeAvailable, adminPasswordConfigured, githubOwnerConfigured, githubRepoConfigured, @@ -180,12 +259,12 @@ export function getSetupHealth(): SetupHealthReport { publisherValid, demoModeForced, demoModeAvailable, - githubReady, + githubReady: publisherReady, checks, nextAction, }; } export function isRequestInDemoMode(sessionDemo: boolean): boolean { - return isDemoModeForced() || !isGitHubConfigured() || sessionDemo; + return isDemoModeForced() || !isPublisherConfigured() || sessionDemo; } diff --git a/docs/configuration.md b/docs/configuration.md index 38eb2cb..51049f5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,7 +10,7 @@ SourceDraft uses two files on purpose: - Content paths (`contentDir`, `mediaDir`, `publicMediaPath`) - Adapter name — see [adapters.md](adapters.md) compatibility matrix -- Publisher name (`github` today) +- Publisher name (`github`, `gitlab`, or `bitbucket`) - Category list for Studio - Default branch name when `GITHUB_BRANCH` is unset @@ -81,11 +81,15 @@ Set in `sourcedraft.config.json`, or override with `CMS_ADAPTER` in `.env`. | Value | Package | Capabilities | |-------|---------|--------------| | `github` | `@sourcedraft/publishers` → `@sourcedraft/github-publisher` | Publish posts, upload media, list/read files via GitHub Contents API | +| `gitlab` | `@sourcedraft/publishers` | Publish posts, upload media, list/read files via GitLab Repository Files API | +| `bitbucket` | `@sourcedraft/publishers` | Publish posts and upload media via Bitbucket commit-upload API (no list/read yet) | Set in `sourcedraft.config.json`, or override with `CMS_PUBLISHER` in `.env`. Default: `github`. Studio resolves publishers through `publisherRegistry`. Unknown publisher ids return a clear configuration error before any API call. +Publisher-specific env vars and API behavior: [git-publishers.md](git-publishers.md). + ### `mediaDir` and `publicMediaPath` **`mediaDir`** is the folder inside your **site repository** where image uploads are committed (for example `public/images` or `src/assets/images`). @@ -105,19 +109,19 @@ SourceDraft searches for `sourcedraft.config.json` in the working directory, up Missing file → built-in defaults matching the example above. -Wrong `contentDir` or `mediaDir` values produce clear GitHub errors in Studio when listing posts, opening a post, publishing, or uploading media. See [github-publishing.md](github-publishing.md#common-failures). +Wrong `contentDir` or `mediaDir` values produce clear publisher errors in Studio when listing posts, opening a post, publishing, or uploading media. See [git-publishers.md](git-publishers.md) and [github-publishing.md](github-publishing.md#common-failures). ## Environment variables -```env -SOURCEDRAFT_ADMIN_PASSWORD= -GITHUB_TOKEN= -GITHUB_OWNER= -GITHUB_REPO= -GITHUB_BRANCH=main -``` +Copy from `.env.example`. Set credentials for the publisher selected in `sourcedraft.config.json` (`publisher` or `CMS_PUBLISHER`). + +**GitHub** (default): `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`, optional `GITHUB_BRANCH` -Optional overrides: +**GitLab:** `GITLAB_TOKEN`, `GITLAB_PROJECT_ID` or `GITLAB_PROJECT_PATH`, optional `GITLAB_BRANCH`, `GITLAB_BASE_URL` + +**Bitbucket:** `BITBUCKET_TOKEN`, `BITBUCKET_WORKSPACE`, `BITBUCKET_REPO_SLUG`, optional `BITBUCKET_BRANCH`, `BITBUCKET_USERNAME` + +Shared optional overrides: ```env CMS_CONTENT_DIR= @@ -130,16 +134,17 @@ CMS_PUBLISHER= | Variable | Required | Purpose | |----------|----------|---------| | `SOURCEDRAFT_ADMIN_PASSWORD` | Yes for Studio | Server-side login password | -| `GITHUB_TOKEN` | Yes to publish/upload | GitHub API token (server only) | -| `GITHUB_OWNER` | Yes to publish/upload | Repository owner | -| `GITHUB_REPO` | Yes to publish/upload | Repository name | -| `GITHUB_BRANCH` | No | Overrides `defaultBranch` | +| `GITHUB_*` | When `publisher` is `github` | GitHub API credentials (server only) | +| `GITLAB_*` | When `publisher` is `gitlab` | GitLab API credentials (server only) | +| `BITBUCKET_*` | When `publisher` is `bitbucket` | Bitbucket API credentials (server only) | | `CMS_CONTENT_DIR` | No | Overrides `contentDir` | | `CMS_MEDIA_DIR` | No | Overrides `mediaDir` | | `CMS_PUBLIC_MEDIA_PATH` | No | Overrides `publicMediaPath` | | `CMS_ADAPTER` | No | Overrides `adapter` | | `CMS_PUBLISHER` | No | Overrides `publisher` (default `github`) | +Full publisher reference: [git-publishers.md](git-publishers.md). + ## Precedence Non-secret settings: diff --git a/docs/git-publishers.md b/docs/git-publishers.md new file mode 100644 index 0000000..6b03c6b --- /dev/null +++ b/docs/git-publishers.md @@ -0,0 +1,93 @@ +# Git publishing (GitHub, GitLab, Bitbucket) + +SourceDraft publishes **files**, not live websites. When you click **Publish** in Studio, a server-side process commits content into the repository you configure. + +Set the publisher in `sourcedraft.config.json` (`publisher`) or override with `CMS_PUBLISHER` in `.env`. Secrets always stay in `.env` — they never reach the browser. + +| Publisher | Env prefix | API style | +|-----------|------------|-----------| +| `github` | `GITHUB_*` | [GitHub Contents API](github-publishing.md) | +| `gitlab` | `GITLAB_*` | GitLab Repository Files API | +| `bitbucket` | `BITBUCKET_*` | Bitbucket commit-upload (`POST …/src`) | + +## GitLab + +### Environment variables + +| Variable | Required | Role | +|----------|----------|------| +| `GITLAB_TOKEN` | Yes | Personal access token with `api` scope and repository write access | +| `GITLAB_PROJECT_ID` or `GITLAB_PROJECT_PATH` | Yes | Numeric project id or `namespace/project` path | +| `GITLAB_BRANCH` | No | Target branch; defaults to `defaultBranch` in config | +| `GITLAB_BASE_URL` | No | Self-managed GitLab URL; defaults to `https://gitlab.com` | + +### How it works + +1. Studio sends article JSON to `POST /api/publish` (cookie session required). +2. The server validates the article and renders file content through the selected adapter. +3. The GitLab publisher checks whether the file exists with `GET /projects/:id/repository/files/:file_path`. +4. If missing → `POST` to create. If present → `PUT` to update. +5. Project id/path and file path are URL-encoded correctly (`group%2Fproject`, `src%2Fcontent%2Fblog%2Fpost.mdx`). + +Optional commit author fields can be set in `publisherOptions` (`authorName`, `authorEmail`). + +### Capabilities + +- Publish posts (create/update) +- Upload media (base64 via Repository Files API) +- List and read existing posts under `contentDir` (repository tree + files API) + +### Common failures + +| Symptom | Likely cause | +|---------|----------------| +| `GITLAB_TOKEN` / 401 | Missing, expired, or revoked token | +| Project not found | Wrong `GITLAB_PROJECT_ID` or `GITLAB_PROJECT_PATH` | +| Branch not found | Wrong `GITLAB_BRANCH` | +| Identical content | Treated as a successful no-op (no empty commit error) | + +## Bitbucket Cloud + +### Environment variables + +| Variable | Required | Role | +|----------|----------|------| +| `BITBUCKET_TOKEN` | Yes | API token or app password | +| `BITBUCKET_WORKSPACE` | Yes | Workspace slug | +| `BITBUCKET_REPO_SLUG` | Yes | Repository slug | +| `BITBUCKET_BRANCH` | No | Target branch; defaults to `defaultBranch` in config | +| `BITBUCKET_USERNAME` | Sometimes | Required with app passwords (Basic auth: `username:token`) | + +### How it works + +Bitbucket uses a **commit-upload** model, not a per-file Contents API like GitHub: + +- All creates and updates go through `POST /2.0/repositories/{workspace}/{repo_slug}/src` +- Text files use `application/x-www-form-urlencoded` (`message`, `branch`, and `path/to/file` fields) +- Binary media uses `multipart/form-data` + +There is no separate “update file” endpoint — each publish uploads the file path and content in a new commit. + +### Capabilities + +- Publish posts (create/update via commit upload) +- Upload media (multipart commit upload) + +**Not supported yet:** listing or reading existing posts in Studio (`listPosts` / `readPost`). Use GitHub or GitLab if you need the Posts sidebar against a remote repo. + +### Common failures + +| Symptom | Likely cause | +|---------|----------------| +| 401 Unauthorized | Invalid token; set `BITBUCKET_USERNAME` when using an app password | +| Repository not found | Wrong `BITBUCKET_WORKSPACE` or `BITBUCKET_REPO_SLUG` | +| Branch not found | Wrong `BITBUCKET_BRANCH` | +| No changes to commit | Identical content — treated as a successful no-op | + +## Shared behavior + +- `publishArticle(article, options)` renders through the selected adapter, then publishes the output path and content. +- `uploadMedia` commits to `mediaDir` when the publisher supports it. +- All HTTP calls run server-side only; tokens are read from `.env` at request time. + +See also: [configuration.md](configuration.md) · [media.md](media.md) · [github-publishing.md](github-publishing.md) diff --git a/docs/github-publishing.md b/docs/github-publishing.md index bee2998..1e2be91 100644 --- a/docs/github-publishing.md +++ b/docs/github-publishing.md @@ -1,6 +1,8 @@ # GitHub publishing -SourceDraft publishes **files**, not live websites. When you click **Publish to GitHub** in Studio, a server-side process commits one content file into the repository you configure. +SourceDraft also supports [GitLab and Bitbucket](git-publishers.md). This page covers GitHub only. + +SourceDraft publishes **files**, not live websites. When you click **Publish** in Studio, a server-side process commits one content file into the repository you configure. The file format depends on your adapter: `.mdx` for `astro-mdx`, `.md` for `markdown`. Both use YAML frontmatter plus the article body. diff --git a/packages/publishers/package.json b/packages/publishers/package.json index 24aa383..ae2ad3c 100644 --- a/packages/publishers/package.json +++ b/packages/publishers/package.json @@ -16,12 +16,13 @@ "scripts": { "build": "tsc", "check": "tsc --noEmit", - "test": "node --import tsx --test src/**/*.test.ts" + "test": "node --import tsx --test 'src/**/*.test.ts' 'src/**/**/*.test.ts'" }, "dependencies": { "@sourcedraft/github-publisher": "workspace:*" }, "devDependencies": { + "@types/node": "^22.15.30", "tsx": "^4.20.3", "typescript": "^5.8.3" } diff --git a/packages/publishers/src/bitbucket/bitbucketErrors.ts b/packages/publishers/src/bitbucket/bitbucketErrors.ts new file mode 100644 index 0000000..ad9a742 --- /dev/null +++ b/packages/publishers/src/bitbucket/bitbucketErrors.ts @@ -0,0 +1,94 @@ +export type BitbucketOperation = "publish" | "uploadMedia" | "listPosts" | "readPost"; + +export type BitbucketErrorContext = { + workspace?: string; + repoSlug?: string; + path?: string; + branch?: string; + contentDir?: string; + mediaDir?: string; +}; + +type BitbucketErrorBody = { + error?: { message?: string }; + message?: string; +}; + +export function bitbucketErrorMessage(body: BitbucketErrorBody | null, fallback: string): string { + if (body?.error?.message && body.error.message.trim().length > 0) { + return body.error.message.trim(); + } + + if (body?.message && body.message.trim().length > 0) { + return body.message.trim(); + } + + return fallback; +} + +export function isIdenticalContentError(message: string): boolean { + return ( + /no changes/i.test(message) || + /nothing to commit/i.test(message) || + /identical/i.test(message) || + /same content/i.test(message) + ); +} + +export function isMissingBranchError(message: string): boolean { + return ( + /branch.*not found/i.test(message) || + /unknown branch/i.test(message) || + /does not exist.*branch/i.test(message) + ); +} + +export function repoLabel(context: BitbucketErrorContext): string { + if (context.workspace && context.repoSlug) { + return `${context.workspace}/${context.repoSlug}`; + } + + return "the configured Bitbucket repository"; +} + +export function formatBitbucketApiError( + status: number, + rawMessage: string, + operation: BitbucketOperation, + context: BitbucketErrorContext = {}, +): string { + const message = rawMessage.trim(); + const target = repoLabel(context); + const branch = context.branch ?? "the configured branch"; + + if (status === 401) { + return "Bitbucket rejected the credentials (401). Check BITBUCKET_TOKEN in .env. If you use an app password, set BITBUCKET_USERNAME as well."; + } + + if (status === 403) { + return `Bitbucket denied access to ${target} (403). The token needs repository write permission.`; + } + + if (status === 404) { + if (/repository/i.test(message)) { + return `Bitbucket repository ${target} was not found. Check BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG in .env.`; + } + + if (operation === "uploadMedia") { + const folder = context.mediaDir ?? context.path ?? "mediaDir"; + return `Could not upload to "${folder}" in ${target}. Check mediaDir in config and that parent folders exist.`; + } + + return `Bitbucket could not find the requested path in ${target} (404).`; + } + + if (status === 400 && isMissingBranchError(message)) { + return `Bitbucket branch "${branch}" was not found in ${target}. Check BITBUCKET_BRANCH in .env.`; + } + + if (message.length > 0) { + return `Bitbucket API error (${status}): ${message}`; + } + + return `Bitbucket API request failed (${status}).`; +} diff --git a/packages/publishers/src/bitbucket/bitbucketPaths.ts b/packages/publishers/src/bitbucket/bitbucketPaths.ts new file mode 100644 index 0000000..615a827 --- /dev/null +++ b/packages/publishers/src/bitbucket/bitbucketPaths.ts @@ -0,0 +1,3 @@ +export function normalizeRepoPath(path: string): string { + return path.replace(/^\/+/, "").replace(/\/+/g, "/").trim(); +} diff --git a/packages/publishers/src/bitbucket/bitbucketPublisher.test.ts b/packages/publishers/src/bitbucket/bitbucketPublisher.test.ts new file mode 100644 index 0000000..b9c0e0d --- /dev/null +++ b/packages/publishers/src/bitbucket/bitbucketPublisher.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createBitbucketPublisher } from "./bitbucketPublisher.js"; + +type MockResponse = { + status: number; + body: string; +}; + +function mockFetch(handler: (url: string, init?: RequestInit) => MockResponse) { + return (async (url: string, init?: RequestInit) => { + const result = handler(url, init); + return new Response(result.body, { status: result.status }); + }) as typeof fetch; +} + +const config = { + token: "bb-test", + workspace: "acme", + repoSlug: "blog", + branch: "main", +}; + +describe("Bitbucket publisher", () => { + it("uploads a text file via commit-upload src API", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch((url, init) => { + assert.match(url, /repositories\/acme\/blog\/src$/); + assert.equal(init?.method, "POST"); + const headers = new Headers(init?.headers); + assert.equal(headers.get("Content-Type"), "application/x-www-form-urlencoded"); + const body = init?.body?.toString() ?? ""; + assert.match(body, /message=Add\+post/); + assert.match(body, /branch=main/); + assert.match(body, /src%2Fcontent%2Fblog%2Fnew-post.mdx=/); + + return { + status: 201, + body: JSON.stringify({ hash: "commit-abc" }), + }; + }), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/new-post.mdx", + content: "---\ntitle: Hi\n---\n\nBody", + message: "Add post", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.commitSha, "commit-abc"); + } + }); + + it("updates via the same commit-upload endpoint", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch((_url, init) => { + const body = init?.body?.toString() ?? ""; + assert.match(body, /updated\+content/); + return { + status: 201, + body: JSON.stringify({ hash: "commit-def" }), + }; + }), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "updated content", + message: "Update post", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + } + }); + + it("returns actionable error on auth failure", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch(() => ({ + status: 401, + body: JSON.stringify({ error: { message: "Unauthorized" } }), + })), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /BITBUCKET_TOKEN/); + assert.equal(result.status, 401); + } + }); + + it("returns actionable error when repository is not found", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch(() => ({ + status: 404, + body: JSON.stringify({ error: { message: "Repository not found" } }), + })), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /BITBUCKET_WORKSPACE/); + } + }); + + it("returns actionable error when branch is missing", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch(() => ({ + status: 400, + body: JSON.stringify({ error: { message: "Branch main not found" } }), + })), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /BITBUCKET_BRANCH/); + } + }); + + it("treats identical content as a successful no-op", async () => { + const publisher = createBitbucketPublisher({ + ...config, + fetch: mockFetch(() => ({ + status: 400, + body: JSON.stringify({ error: { message: "No changes to commit" } }), + })), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "same", + message: "No-op", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.sha, "unchanged"); + } + }); + + it("uses Basic auth when username is configured", async () => { + const publisher = createBitbucketPublisher({ + ...config, + username: "writer", + fetch: mockFetch((_url, init) => { + const headers = new Headers(init?.headers); + const auth = headers.get("Authorization") ?? ""; + assert.match(auth, /^Basic /); + const decoded = Buffer.from(auth.replace("Basic ", ""), "base64").toString("utf8"); + assert.equal(decoded, "writer:bb-test"); + return { status: 201, body: JSON.stringify({ hash: "commit-basic" }) }; + }), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, true); + }); +}); diff --git a/packages/publishers/src/bitbucket/bitbucketPublisher.ts b/packages/publishers/src/bitbucket/bitbucketPublisher.ts new file mode 100644 index 0000000..1b56b63 --- /dev/null +++ b/packages/publishers/src/bitbucket/bitbucketPublisher.ts @@ -0,0 +1,186 @@ +/** + * Bitbucket Cloud publisher using the commit-upload src API. + * + * Unlike GitHub/GitLab file APIs, Bitbucket create/update is always a commit + * upload to POST /2.0/repositories/{workspace}/{repo_slug}/src — not a + * per-file Contents API with separate create/update endpoints. + */ +import { + type HttpFetcher, + parseJsonBody, + readResponseBody, + resolveFetcher, +} from "../http.js"; +import { + bitbucketErrorMessage, + formatBitbucketApiError, + isIdenticalContentError, + type BitbucketErrorContext, + type BitbucketOperation, +} from "./bitbucketErrors.js"; +import { normalizeRepoPath } from "./bitbucketPaths.js"; + +const BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0"; + +export type BitbucketPublisherConfig = { + token: string; + workspace: string; + repoSlug: string; + branch: string; + username?: string; + fetch?: HttpFetcher; +}; + +type PublishFileInput = { + path: string; + message: string; +} & ( + | { content: string; contentBase64?: never } + | { contentBase64: string; content?: never } +); + +export type PublishFileSuccess = { + ok: true; + created: boolean; + path: string; + sha: string; + commitSha: string; +}; + +export type PublishFileError = { + ok: false; + error: string; + status?: number; +}; + +export type PublishFileResult = PublishFileSuccess | PublishFileError; + +export type BitbucketPublisher = { + publishFile: (input: PublishFileInput) => Promise; +}; + +function authHeaders(config: BitbucketPublisherConfig): HeadersInit { + if (config.username) { + const credentials = Buffer.from(`${config.username}:${config.token}`).toString("base64"); + return { + Authorization: `Basic ${credentials}`, + Accept: "application/json", + }; + } + + return { + Authorization: `Bearer ${config.token}`, + Accept: "application/json", + }; +} + +function errorContext( + config: BitbucketPublisherConfig, + extras: BitbucketErrorContext = {}, +): BitbucketErrorContext { + return { + workspace: config.workspace, + repoSlug: config.repoSlug, + branch: config.branch, + ...extras, + }; +} + +function identicalContentSuccess(path: string): PublishFileSuccess { + return { + ok: true, + created: false, + path, + sha: "unchanged", + commitSha: "unchanged", + }; +} + +type CommitResponse = { + hash?: string; +}; + +export function createBitbucketPublisher(config: BitbucketPublisherConfig): BitbucketPublisher { + const fetchImpl = resolveFetcher(config.fetch); + const repoPath = `${config.workspace}/${config.repoSlug}`; + + async function commitUpload( + filePath: string, + body: BodyInit, + headers: HeadersInit, + operation: BitbucketOperation, + ): Promise { + const response = await fetchImpl( + `${BITBUCKET_API_BASE}/repositories/${repoPath}/src`, + { + method: "POST", + headers: { + ...authHeaders(config), + ...headers, + }, + body, + }, + ); + + const bodyText = await readResponseBody(response); + + if (!response.ok) { + const parsed = parseJsonBody<{ error?: { message?: string }; message?: string }>(bodyText); + const rawMessage = bitbucketErrorMessage(parsed, bodyText); + + if (response.status === 400 && isIdenticalContentError(rawMessage)) { + return identicalContentSuccess(filePath); + } + + return { + ok: false, + error: formatBitbucketApiError( + response.status, + rawMessage, + operation, + errorContext(config, { path: filePath }), + ), + status: response.status, + }; + } + + const commit = parseJsonBody(bodyText); + const commitSha = commit?.hash ?? "unknown"; + + return { + ok: true, + created: true, + path: filePath, + sha: commitSha, + commitSha, + }; + } + + return { + async publishFile(input: PublishFileInput): Promise { + const filePath = normalizeRepoPath(input.path); + const params = new URLSearchParams(); + params.set("message", input.message); + params.set("branch", config.branch); + params.set(filePath, input.content ?? ""); + + if ("contentBase64" in input && input.contentBase64 !== undefined) { + const form = new FormData(); + form.set("message", input.message); + form.set("branch", config.branch); + const bytes = Buffer.from(input.contentBase64, "base64"); + const blob = new Blob([bytes]); + form.set(filePath, blob, filePath.split("/").pop() ?? "upload.bin"); + + return commitUpload(filePath, form, {}, "uploadMedia"); + } + + return commitUpload( + filePath, + params.toString(), + { "Content-Type": "application/x-www-form-urlencoded" }, + "publish", + ); + }, + }; +} diff --git a/packages/publishers/src/bitbucketPublisherAdapter.ts b/packages/publishers/src/bitbucketPublisherAdapter.ts new file mode 100644 index 0000000..48cb698 --- /dev/null +++ b/packages/publishers/src/bitbucketPublisherAdapter.ts @@ -0,0 +1,143 @@ +import { createBitbucketPublisher } from "./bitbucket/bitbucketPublisher.js"; +import type { + Publisher, + PublisherFactory, + PublisherRuntimeConfig, + PublishArticleInput, + PublishArticleResult, + UploadMediaInput, + UploadMediaResult, +} from "./types.js"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; + +const BITBUCKET_CAPABILITIES = { + publishArticle: true, + uploadMedia: true, + listPosts: false, + readPost: false, +} as const; + +function resolveBitbucketConfig(config: PublisherRuntimeConfig) { + const workspace = config.owner?.trim(); + const repoSlug = config.repo?.trim(); + + if (!workspace) { + throw new Error("Bitbucket publisher requires BITBUCKET_WORKSPACE in .env."); + } + + if (!repoSlug) { + throw new Error("Bitbucket publisher requires BITBUCKET_REPO_SLUG in .env."); + } + + if (!config.token) { + throw new Error("Bitbucket publisher requires BITBUCKET_TOKEN in .env."); + } + + return createBitbucketPublisher({ + token: config.token, + workspace, + repoSlug, + branch: config.branch, + ...(config.bitbucketUsername?.trim() + ? { username: config.bitbucketUsername.trim() } + : {}), + }); +} + +function createBitbucketPublisherInstance(config: PublisherRuntimeConfig): Publisher { + const bitbucket = resolveBitbucketConfig(config); + + return { + id: "bitbucket", + capabilities: BITBUCKET_CAPABILITIES, + async publishArticle(input: PublishArticleInput): Promise { + const result = await bitbucket.publishFile({ + path: input.path, + content: input.content, + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async uploadMedia(input: UploadMediaInput): Promise { + const result = await bitbucket.publishFile({ + path: input.repoPath, + contentBase64: input.contentBase64, + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async listPosts() { + return unsupportedListPosts("bitbucket"); + }, + async readPost() { + return unsupportedReadPost("bitbucket"); + }, + }; +} + +function wrapPublisherWithCapabilities( + factory: PublisherFactory, + publisher: Publisher, +): Publisher { + return { + id: factory.id, + capabilities: factory.capabilities, + publishArticle: factory.capabilities.publishArticle + ? (input) => publisher.publishArticle(input) + : () => Promise.resolve(unsupportedPublishArticle(factory.id)), + uploadMedia: factory.capabilities.uploadMedia + ? (input) => publisher.uploadMedia(input) + : () => Promise.resolve(unsupportedUploadMedia(factory.id)), + listPosts: factory.capabilities.listPosts + ? (input) => publisher.listPosts(input) + : () => Promise.resolve(unsupportedListPosts(factory.id)), + readPost: factory.capabilities.readPost + ? (input) => publisher.readPost(input) + : () => Promise.resolve(unsupportedReadPost(factory.id)), + }; +} + +export const bitbucketPublisherFactory: PublisherFactory = { + id: "bitbucket", + capabilities: BITBUCKET_CAPABILITIES, + createPublisher(config: PublisherRuntimeConfig): Publisher { + return wrapPublisherWithCapabilities( + bitbucketPublisherFactory, + createBitbucketPublisherInstance(config), + ); + }, +}; diff --git a/packages/publishers/src/gitlab/gitlabErrors.ts b/packages/publishers/src/gitlab/gitlabErrors.ts new file mode 100644 index 0000000..b581490 --- /dev/null +++ b/packages/publishers/src/gitlab/gitlabErrors.ts @@ -0,0 +1,115 @@ +export type GitLabOperation = + | "publish" + | "uploadMedia" + | "listPosts" + | "readPost" + | "checkFile"; + +export type GitLabErrorContext = { + projectRef?: string; + path?: string; + branch?: string; + contentDir?: string; + mediaDir?: string; +}; + +type GitLabErrorBody = { + message?: string; + error?: string; +}; + +export function gitLabErrorMessage(body: GitLabErrorBody | null, fallback: string): string { + if (body?.message && body.message.trim().length > 0) { + return body.message.trim(); + } + + if (body?.error && body.error.trim().length > 0) { + return body.error.trim(); + } + + return fallback; +} + +export function isIdenticalContentError(message: string): boolean { + return ( + /identical/i.test(message) || + /nothing to commit/i.test(message) || + /no changes/i.test(message) || + /same content/i.test(message) || + /already exists.*branch/i.test(message) + ); +} + +export function isMissingBranchError(message: string): boolean { + return ( + /branch.*not found/i.test(message) || + /invalid branch/i.test(message) || + /unknown revision/i.test(message) || + /does not exist.*branch/i.test(message) + ); +} + +export function projectLabel(context: GitLabErrorContext): string { + return context.projectRef ?? "the configured GitLab project"; +} + +export function formatGitLabApiError( + status: number, + rawMessage: string, + operation: GitLabOperation, + context: GitLabErrorContext = {}, +): string { + const message = rawMessage.trim(); + const target = projectLabel(context); + const branch = context.branch ?? "the configured branch"; + + if (status === 401) { + return "GitLab rejected the token (401). Check GITLAB_TOKEN in .env — it may be missing, expired, or revoked."; + } + + if (status === 403) { + return `GitLab denied access to ${target} (403). The token needs maintainer/developer access with repository file write permission.`; + } + + if (status === 404) { + if (operation === "checkFile") { + return ""; + } + + if (/project.*not found/i.test(message) || /could not be found/i.test(message)) { + return `GitLab project ${target} was not found. Check GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH in .env.`; + } + + if (operation === "listPosts") { + const folder = context.contentDir ?? context.path ?? "contentDir"; + return `Could not find the posts folder "${folder}" in ${target} on branch ${branch}. Check contentDir and GITLAB_BRANCH.`; + } + + if (operation === "uploadMedia") { + const folder = context.mediaDir ?? context.path ?? "mediaDir"; + return `Could not find the media folder "${folder}" in ${target}. Check mediaDir in config. Parent folders must already exist in the repo.`; + } + + if (operation === "readPost") { + const postPath = context.path ?? "the requested path"; + return `Could not open post "${postPath}". It may have been moved, renamed, or deleted in GitLab.`; + } + + if (operation === "publish") { + const filePath = context.path ?? "the target path"; + return `GitLab could not find "${filePath}" in ${target} (404). Check contentDir and the post path.`; + } + + return `GitLab could not find the requested resource in ${target} (404).`; + } + + if (status === 400 && isMissingBranchError(message)) { + return `GitLab branch "${branch}" was not found in ${target}. Check GITLAB_BRANCH in .env.`; + } + + if (message.length > 0) { + return `GitLab API error (${status}): ${message}`; + } + + return `GitLab API request failed (${status}).`; +} diff --git a/packages/publishers/src/gitlab/gitlabPaths.ts b/packages/publishers/src/gitlab/gitlabPaths.ts new file mode 100644 index 0000000..66a0aa6 --- /dev/null +++ b/packages/publishers/src/gitlab/gitlabPaths.ts @@ -0,0 +1,11 @@ +export function encodeGitLabProjectRef(projectRef: string): string { + return encodeURIComponent(projectRef.trim()); +} + +export function encodeGitLabFilePath(filePath: string): string { + return encodeURIComponent(filePath.replace(/^\/+/, "").trim()); +} + +export function normalizeRepoPath(path: string): string { + return path.replace(/^\/+/, "").replace(/\/+/g, "/").trim(); +} diff --git a/packages/publishers/src/gitlab/gitlabPublisher.test.ts b/packages/publishers/src/gitlab/gitlabPublisher.test.ts new file mode 100644 index 0000000..95076e0 --- /dev/null +++ b/packages/publishers/src/gitlab/gitlabPublisher.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createGitLabPublisher } from "./gitlabPublisher.js"; + +type MockResponse = { + status: number; + body: string; +}; + +function requestMethod(init?: RequestInit): string { + return init?.method?.toUpperCase() ?? "GET"; +} + +function mockFetch(handlers: Array<(url: string, init?: RequestInit) => MockResponse | null>) { + return (async (url: string, init?: RequestInit) => { + for (const handler of handlers) { + const result = handler(url, init); + if (result !== null) { + return new Response(result.body, { status: result.status }); + } + } + + return new Response(`unexpected request: ${requestMethod(init)} ${url}`, { status: 500 }); + }) as typeof fetch; +} + +const config = { + token: "gl-test", + projectRef: "group/site", + branch: "main", + baseUrl: "https://gitlab.com", +}; + +describe("GitLab publisher", () => { + it("creates a new file with POST", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + (url, init) => { + if (url.includes("/repository/files/") && requestMethod(init) === "GET") { + return { status: 404, body: JSON.stringify({ message: "404 File Not Found" }) }; + } + + if (url.includes("/repository/files/") && requestMethod(init) === "POST") { + return { + status: 201, + body: JSON.stringify({ + file_path: "src/content/blog/new-post.mdx", + blob_id: "blob-new", + commit_id: "commit-new", + }), + }; + } + + return null; + }, + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/new-post.mdx", + content: "---\ntitle: Hi\n---\n\nBody", + message: "Add post", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, true); + assert.equal(result.sha, "blob-new"); + assert.equal(result.commitSha, "commit-new"); + } + }); + + it("updates an existing file with PUT", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + (url, init) => { + if (url.includes("/repository/files/") && requestMethod(init) === "GET") { + return { + status: 200, + body: JSON.stringify({ blob_id: "blob-old", file_path: "src/content/blog/post.mdx" }), + }; + } + + if (url.includes("/repository/files/") && requestMethod(init) === "PUT") { + return { + status: 200, + body: JSON.stringify({ + file_path: "src/content/blog/post.mdx", + blob_id: "blob-updated", + commit_id: "commit-updated", + }), + }; + } + + return null; + }, + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "updated", + message: "Update post", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.created, false); + assert.equal(result.sha, "blob-updated"); + } + }); + + it("returns actionable error on auth failure", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + () => ({ status: 401, body: JSON.stringify({ message: "401 Unauthorized" }) }), + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GITLAB_TOKEN/); + assert.equal(result.status, 401); + } + }); + + it("returns actionable error when project is not found", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + (url, init) => { + if (requestMethod(init) === "GET") { + return { status: 404, body: JSON.stringify({ message: "404 File Not Found" }) }; + } + + return { status: 404, body: JSON.stringify({ message: "404 Project Not Found" }) }; + }, + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GITLAB_PROJECT/); + } + }); + + it("returns actionable error when branch is missing", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + (url, init) => { + if (requestMethod(init) === "GET") { + return { status: 404, body: JSON.stringify({ message: "404 File Not Found" }) }; + } + + return { + status: 400, + body: JSON.stringify({ message: "Branch main not found for project" }), + }; + }, + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "body", + message: "Publish", + }); + + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.error, /GITLAB_BRANCH/); + } + }); + + it("treats identical content as a successful no-op", async () => { + const publisher = createGitLabPublisher({ + ...config, + fetch: mockFetch([ + (url, init) => { + if (requestMethod(init) === "GET") { + return { + status: 200, + body: JSON.stringify({ blob_id: "blob-old", file_path: "src/content/blog/post.mdx" }), + }; + } + + return { + status: 400, + body: JSON.stringify({ message: "A commit with identical contents already exists" }), + }; + }, + ]), + }); + + const result = await publisher.publishFile({ + path: "src/content/blog/post.mdx", + content: "same", + message: "No-op", + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.sha, "unchanged"); + assert.equal(result.commitSha, "unchanged"); + } + }); +}); diff --git a/packages/publishers/src/gitlab/gitlabPublisher.ts b/packages/publishers/src/gitlab/gitlabPublisher.ts new file mode 100644 index 0000000..3aff0e7 --- /dev/null +++ b/packages/publishers/src/gitlab/gitlabPublisher.ts @@ -0,0 +1,397 @@ +import { + type HttpFetcher, + parseJsonBody, + readResponseBody, + resolveFetcher, +} from "../http.js"; +import { + formatGitLabApiError, + gitLabErrorMessage, + isIdenticalContentError, + type GitLabErrorContext, + type GitLabOperation, +} from "./gitlabErrors.js"; +import { + encodeGitLabFilePath, + encodeGitLabProjectRef, + normalizeRepoPath, +} from "./gitlabPaths.js"; + +export type GitLabPublisherConfig = { + token: string; + projectRef: string; + branch: string; + baseUrl: string; + authorName?: string; + authorEmail?: string; + fetch?: HttpFetcher; +}; + +type GitLabFileBody = { + file_path?: string; + blob_id?: string; + commit_id?: string; + last_commit_id?: string; + content?: string; + encoding?: string; +}; + +type GitLabCommitBody = { + file_path?: string; + blob_id?: string; + commit_id?: string; + branch?: string; +}; + +type GitLabTreeEntry = { + type?: string; + path?: string; + name?: string; + id?: string; +}; + +type PublishFileInput = { + path: string; + message: string; +} & ( + | { content: string; contentBase64?: never } + | { contentBase64: string; content?: never } +); + +export type PublishFileSuccess = { + ok: true; + created: boolean; + path: string; + sha: string; + commitSha: string; +}; + +export type PublishFileError = { + ok: false; + error: string; + status?: number; +}; + +export type PublishFileResult = PublishFileSuccess | PublishFileError; + +export type ListedFile = { + path: string; + name: string; + sha: string; + size: number; +}; + +export type ListFilesSuccess = { + ok: true; + files: ListedFile[]; +}; + +export type ListFilesResult = ListFilesSuccess | PublishFileError; + +export type ReadFileSuccess = { + ok: true; + path: string; + content: string; + sha: string; +}; + +export type ReadFileResult = ReadFileSuccess | PublishFileError; + +export type GitLabPublisher = { + publishFile: (input: PublishFileInput) => Promise; + listFiles: (input: { path: string; contentDir?: string }) => Promise; + readFile: (input: { path: string }) => Promise; +}; + +function trimBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +function authHeaders(token: string): HeadersInit { + return { + "PRIVATE-TOKEN": token, + Accept: "application/json", + }; +} + +function errorContext( + config: GitLabPublisherConfig, + extras: GitLabErrorContext = {}, +): GitLabErrorContext { + return { + projectRef: config.projectRef, + branch: config.branch, + ...extras, + }; +} + +function identicalContentSuccess(path: string, created: boolean): PublishFileSuccess { + return { + ok: true, + created, + path, + sha: "unchanged", + commitSha: "unchanged", + }; +} + +export function createGitLabPublisher(config: GitLabPublisherConfig): GitLabPublisher { + const fetchImpl = resolveFetcher(config.fetch); + const apiBase = `${trimBaseUrl(config.baseUrl)}/api/v4`; + const projectSegment = encodeGitLabProjectRef(config.projectRef); + + async function gitLabRequest( + method: string, + urlPath: string, + operation: GitLabOperation, + context: GitLabErrorContext, + init?: RequestInit, + ): Promise<{ ok: true; response: Response; bodyText: string } | PublishFileError> { + const response = await fetchImpl(`${apiBase}${urlPath}`, { + ...init, + method, + headers: { + ...authHeaders(config.token), + ...(init?.headers ?? {}), + }, + }); + + const bodyText = await readResponseBody(response); + + if (!response.ok) { + const body = parseJsonBody<{ message?: string; error?: string }>(bodyText); + const rawMessage = gitLabErrorMessage(body, bodyText); + const formatted = formatGitLabApiError( + response.status, + rawMessage, + operation, + errorContext(config, context), + ); + + if (operation === "checkFile" && response.status === 404) { + return { ok: false, error: formatted, status: 404 }; + } + + return { + ok: false, + error: formatted, + status: response.status, + }; + } + + return { ok: true, response, bodyText }; + } + + async function fileExists( + filePath: string, + ): Promise<{ exists: true; blobId: string } | { exists: false } | PublishFileError> { + const encodedPath = encodeGitLabFilePath(filePath); + const result = await gitLabRequest( + "GET", + `/projects/${projectSegment}/repository/files/${encodedPath}?ref=${encodeURIComponent(config.branch)}`, + "checkFile", + { path: filePath }, + ); + + if (!result.ok) { + if (result.status === 404) { + return { exists: false }; + } + + return result; + } + + const body = parseJsonBody(result.bodyText); + const blobId = body?.blob_id ?? body?.commit_id ?? ""; + + if (blobId.length === 0) { + return { + ok: false, + error: "GitLab returned a file response without a blob id.", + }; + } + + return { exists: true, blobId }; + } + + function commitBody( + filePath: string, + content: string, + encoding: "text" | "base64", + message: string, + ): Record { + const body: Record = { + branch: config.branch, + commit_message: message, + content, + encoding, + }; + + if (config.authorName) { + body.author_name = config.authorName; + } + + if (config.authorEmail) { + body.author_email = config.authorEmail; + } + + return body; + } + + async function writeFile( + filePath: string, + content: string, + encoding: "text" | "base64", + message: string, + created: boolean, + ): Promise { + const encodedPath = encodeGitLabFilePath(filePath); + const method = created ? "POST" : "PUT"; + const operation: GitLabOperation = "publish"; + const body = commitBody(filePath, content, encoding, message); + + const result = await gitLabRequest( + method, + `/projects/${projectSegment}/repository/files/${encodedPath}`, + operation, + { path: filePath }, + { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + + if (!result.ok) { + if (result.status === 400 && isIdenticalContentError(result.error)) { + return identicalContentSuccess(filePath, created); + } + + return result; + } + + const commitBodyJson = parseJsonBody(result.bodyText); + const sha = commitBodyJson?.blob_id ?? commitBodyJson?.file_path ?? filePath; + const commitSha = commitBodyJson?.commit_id ?? "unknown"; + + return { + ok: true, + created, + path: filePath, + sha, + commitSha, + }; + } + + return { + async publishFile(input: PublishFileInput): Promise { + const filePath = normalizeRepoPath(input.path); + const existsResult = await fileExists(filePath); + + if ("ok" in existsResult && existsResult.ok === false) { + return existsResult; + } + + const created = !("exists" in existsResult) || !existsResult.exists; + const payload = + "contentBase64" in input && input.contentBase64 !== undefined + ? { content: input.contentBase64, encoding: "base64" as const } + : { content: input.content, encoding: "text" as const }; + + let result = await writeFile( + filePath, + payload.content, + payload.encoding, + input.message, + created, + ); + + if ( + !result.ok && + created && + result.status === 400 && + /already exists/i.test(result.error) + ) { + result = await writeFile( + filePath, + payload.content, + payload.encoding, + input.message, + false, + ); + } + + return result; + }, + + async listFiles(input: { + path: string; + contentDir?: string; + }): Promise { + const folder = normalizeRepoPath(input.path); + const result = await gitLabRequest( + "GET", + `/projects/${projectSegment}/repository/tree?path=${encodeURIComponent(folder)}&ref=${encodeURIComponent(config.branch)}&recursive=true&per_page=100`, + "listPosts", + { path: folder, contentDir: input.contentDir ?? folder }, + ); + + if (!result.ok) { + return result; + } + + const entries = parseJsonBody(result.bodyText) ?? []; + const files = entries + .filter( + (entry) => + entry.type === "blob" && + typeof entry.path === "string" && + (entry.path.endsWith(".md") || entry.path.endsWith(".mdx")), + ) + .map((entry) => ({ + path: entry.path as string, + name: entry.name ?? entry.path?.split("/").pop() ?? entry.path ?? "", + sha: entry.id ?? "", + size: 0, + })); + + return { ok: true, files }; + }, + + async readFile(input: { path: string }): Promise { + const filePath = normalizeRepoPath(input.path); + const encodedPath = encodeGitLabFilePath(filePath); + const result = await gitLabRequest( + "GET", + `/projects/${projectSegment}/repository/files/${encodedPath}?ref=${encodeURIComponent(config.branch)}`, + "readPost", + { path: filePath }, + ); + + if (!result.ok) { + return result; + } + + const body = parseJsonBody(result.bodyText); + + if (!body?.content) { + return { + ok: false, + error: "GitLab did not return file content.", + }; + } + + const encoding = body.encoding ?? "base64"; + const content = + encoding === "base64" + ? Buffer.from(body.content, "base64").toString("utf8") + : body.content; + + return { + ok: true, + path: filePath, + content, + sha: body.blob_id ?? body.commit_id ?? "", + }; + }, + }; +} diff --git a/packages/publishers/src/gitlabPublisherAdapter.ts b/packages/publishers/src/gitlabPublisherAdapter.ts new file mode 100644 index 0000000..9f9a880 --- /dev/null +++ b/packages/publishers/src/gitlabPublisherAdapter.ts @@ -0,0 +1,178 @@ +import { createGitLabPublisher } from "./gitlab/gitlabPublisher.js"; +import type { + Publisher, + PublisherFactory, + PublisherRuntimeConfig, + PublishArticleInput, + PublishArticleResult, + ReadPostInput, + ReadPostResult, + ListPostsInput, + ListPostsResult, + UploadMediaInput, + UploadMediaResult, +} from "./types.js"; +import { + unsupportedListPosts, + unsupportedPublishArticle, + unsupportedReadPost, + unsupportedUploadMedia, +} from "./unsupported.js"; + +const GITLAB_CAPABILITIES = { + publishArticle: true, + uploadMedia: true, + listPosts: true, + readPost: true, +} as const; + +const DEFAULT_GITLAB_BASE_URL = "https://gitlab.com"; + +function resolveGitLabConfig(config: PublisherRuntimeConfig) { + const projectRef = config.gitlabProjectRef?.trim(); + if (!projectRef) { + throw new Error( + "GitLab publisher requires gitlabProjectRef (set GITLAB_PROJECT_ID or GITLAB_PROJECT_PATH in .env).", + ); + } + + if (!config.token) { + throw new Error("GitLab publisher requires GITLAB_TOKEN in .env."); + } + + const options = config.publisherOptions ?? {}; + const authorName = + typeof options.authorName === "string" ? options.authorName : undefined; + const authorEmail = + typeof options.authorEmail === "string" ? options.authorEmail : undefined; + + return createGitLabPublisher({ + token: config.token, + projectRef, + branch: config.branch, + baseUrl: config.gitlabBaseUrl?.trim() || DEFAULT_GITLAB_BASE_URL, + ...(authorName ? { authorName } : {}), + ...(authorEmail ? { authorEmail } : {}), + }); +} + +function createGitLabPublisherInstance(config: PublisherRuntimeConfig): Publisher { + const gitlab = resolveGitLabConfig(config); + + return { + id: "gitlab", + capabilities: GITLAB_CAPABILITIES, + async publishArticle(input: PublishArticleInput): Promise { + const result = await gitlab.publishFile({ + path: input.path, + content: input.content, + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + created: result.created, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async uploadMedia(input: UploadMediaInput): Promise { + const result = await gitlab.publishFile({ + path: input.repoPath, + contentBase64: input.contentBase64, + message: input.message, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + sha: result.sha, + commitSha: result.commitSha, + }; + }, + async listPosts(input: ListPostsInput): Promise { + const result = await gitlab.listFiles({ + path: input.contentDir, + contentDir: input.contentDir, + }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { ok: true, files: result.files }; + }, + async readPost(input: ReadPostInput): Promise { + const result = await gitlab.readFile({ path: input.path }); + + if (!result.ok) { + return { + ok: false, + error: result.error, + ...(result.status !== undefined ? { status: result.status } : {}), + }; + } + + return { + ok: true, + path: result.path, + content: result.content, + sha: result.sha, + }; + }, + }; +} + +function wrapPublisherWithCapabilities( + factory: PublisherFactory, + publisher: Publisher, +): Publisher { + return { + id: factory.id, + capabilities: factory.capabilities, + publishArticle: factory.capabilities.publishArticle + ? (input) => publisher.publishArticle(input) + : () => Promise.resolve(unsupportedPublishArticle(factory.id)), + uploadMedia: factory.capabilities.uploadMedia + ? (input) => publisher.uploadMedia(input) + : () => Promise.resolve(unsupportedUploadMedia(factory.id)), + listPosts: factory.capabilities.listPosts + ? (input) => publisher.listPosts(input) + : () => Promise.resolve(unsupportedListPosts(factory.id)), + readPost: factory.capabilities.readPost + ? (input) => publisher.readPost(input) + : () => Promise.resolve(unsupportedReadPost(factory.id)), + }; +} + +export const gitlabPublisherFactory: PublisherFactory = { + id: "gitlab", + capabilities: GITLAB_CAPABILITIES, + createPublisher(config: PublisherRuntimeConfig): Publisher { + return wrapPublisherWithCapabilities( + gitlabPublisherFactory, + createGitLabPublisherInstance(config), + ); + }, +}; diff --git a/packages/publishers/src/http.ts b/packages/publishers/src/http.ts new file mode 100644 index 0000000..0736718 --- /dev/null +++ b/packages/publishers/src/http.ts @@ -0,0 +1,25 @@ +export type HttpFetcher = typeof globalThis.fetch; + +export function resolveFetcher(fetchImpl?: HttpFetcher): HttpFetcher { + return fetchImpl ?? globalThis.fetch; +} + +export async function readResponseBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} + +export function parseJsonBody(text: string): T | null { + if (text.trim().length === 0) { + return null; + } + + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} diff --git a/packages/publishers/src/registerBuiltInPublishers.ts b/packages/publishers/src/registerBuiltInPublishers.ts index 8a83ef3..2796fb3 100644 --- a/packages/publishers/src/registerBuiltInPublishers.ts +++ b/packages/publishers/src/registerBuiltInPublishers.ts @@ -1,8 +1,12 @@ +import { bitbucketPublisherFactory } from "./bitbucketPublisherAdapter.js"; import { githubPublisherFactory } from "./githubPublisherAdapter.js"; +import { gitlabPublisherFactory } from "./gitlabPublisherAdapter.js"; import { registerPublisher } from "./publisherRegistry.js"; export function registerBuiltInPublishers(): void { registerPublisher(githubPublisherFactory); + registerPublisher(gitlabPublisherFactory); + registerPublisher(bitbucketPublisherFactory); } registerBuiltInPublishers(); diff --git a/packages/publishers/src/registry.test.ts b/packages/publishers/src/registry.test.ts index ebc0b0f..52f257f 100644 --- a/packages/publishers/src/registry.test.ts +++ b/packages/publishers/src/registry.test.ts @@ -10,7 +10,7 @@ import type { PublisherRuntimeConfig } from "./types.js"; import "./registerBuiltInPublishers.js"; -const runtimeConfig: PublisherRuntimeConfig = { +const githubRuntimeConfig: PublisherRuntimeConfig = { token: "test-token", owner: "owner", repo: "repo", @@ -19,15 +19,29 @@ const runtimeConfig: PublisherRuntimeConfig = { mediaDir: "public/images", }; +const gitlabRuntimeConfig: PublisherRuntimeConfig = { + ...githubRuntimeConfig, + gitlabProjectRef: "group/site", + gitlabBaseUrl: "https://gitlab.com", +}; + +const bitbucketRuntimeConfig: PublisherRuntimeConfig = { + ...githubRuntimeConfig, + owner: "acme", + repo: "blog", +}; + describe("publisher registry", () => { - it("defaults to github publisher", () => { - assert.deepEqual(listPublisherIds(), ["github"]); + it("registers built-in publishers", () => { + assert.deepEqual(listPublisherIds(), ["github", "gitlab", "bitbucket"]); assert.equal(isPublisherId("github"), true); + assert.equal(isPublisherId("gitlab"), true); + assert.equal(isPublisherId("bitbucket"), true); assert.equal(isPublisherId("wordpress"), false); }); it("creates github publisher with full capabilities", () => { - const publisher = createPublisher("github", runtimeConfig); + const publisher = createPublisher("github", githubRuntimeConfig); assert.equal(publisher.id, "github"); assert.equal(publisher.capabilities.publishArticle, true); @@ -36,8 +50,30 @@ describe("publisher registry", () => { assert.equal(publisher.capabilities.readPost, true); }); + it("creates gitlab publisher with repository file API capabilities", () => { + const publisher = createPublisher("gitlab", gitlabRuntimeConfig); + + assert.equal(publisher.id, "gitlab"); + assert.equal(publisher.capabilities.publishArticle, true); + assert.equal(publisher.capabilities.uploadMedia, true); + assert.equal(publisher.capabilities.listPosts, true); + assert.equal(publisher.capabilities.readPost, true); + }); + + it("creates bitbucket publisher with commit-upload capabilities", () => { + const publisher = createPublisher("bitbucket", bitbucketRuntimeConfig); + + assert.equal(publisher.id, "bitbucket"); + assert.equal(publisher.capabilities.publishArticle, true); + assert.equal(publisher.capabilities.uploadMedia, true); + assert.equal(publisher.capabilities.listPosts, false); + assert.equal(publisher.capabilities.readPost, false); + }); + it("exposes registry helpers", () => { assert.equal(publisherRegistry.isKnown("github"), true); assert.match(publisherRegistry.supportedSummary(), /github/); + assert.match(publisherRegistry.supportedSummary(), /gitlab/); + assert.match(publisherRegistry.supportedSummary(), /bitbucket/); }); }); diff --git a/packages/publishers/src/types.ts b/packages/publishers/src/types.ts index 95d2cb7..aa1aae5 100644 --- a/packages/publishers/src/types.ts +++ b/packages/publishers/src/types.ts @@ -1,4 +1,4 @@ -export const PUBLISHER_IDS = ["github"] as const; +export const PUBLISHER_IDS = ["github", "gitlab", "bitbucket"] as const; export type PublisherId = (typeof PUBLISHER_IDS)[number]; @@ -10,6 +10,12 @@ export type PublisherRuntimeConfig = { contentDir: string; mediaDir: string; publisherOptions?: Record; + /** GitLab project id or namespace/project path */ + gitlabProjectRef?: string; + /** GitLab API base URL (default https://gitlab.com) */ + gitlabBaseUrl?: string; + /** Bitbucket app-password username when Basic auth is required */ + bitbucketUsername?: string; }; export type PublisherCapabilities = { diff --git a/packages/publishers/tsconfig.json b/packages/publishers/tsconfig.json index c68a6a8..c050866 100644 --- a/packages/publishers/tsconfig.json +++ b/packages/publishers/tsconfig.json @@ -3,7 +3,8 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], + "types": ["node"], "rootDir": "src", "outDir": "dist", "declaration": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efcfd8e..2598c6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,9 @@ importers: specifier: workspace:* version: link:../github-publisher devDependencies: + '@types/node': + specifier: ^22.15.30 + version: 22.19.19 tsx: specifier: ^4.20.3 version: 4.22.4