@getrefino/github 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Refino contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @getrefino/github
2
+
3
+ GitHub persistence adapter for **Refino**. Reads and commits the canonical copy
4
+ file through the GitHub Contents API with a fine-grained token (Contents
5
+ read/write on one repository), so an edit made in the browser becomes a commit
6
+ in the website's own repository.
7
+
8
+ Installed for you by `npx @getrefino/cli init` in self-hosted mode. Sites
9
+ connected to hosted Refino do not need this package: Refino commits on their
10
+ behalf and the site holds no credentials.
11
+
12
+ ```ts
13
+ createGitHubContentAdapter(githubOptionsFromEnv(process.env))
14
+ ```
15
+
16
+ Environment: `COPY_GITHUB_TOKEN`, `COPY_GITHUB_REPO` (owner/name),
17
+ `COPY_GITHUB_BRANCH`, `COPY_FILE_PATH`. **Server-side only** — never import this
18
+ package from client code.
19
+
20
+ <https://refino.dev>
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GitHub persistence through the REST Contents API
3
+ * (GET/PUT /repos/{owner}/{repo}/contents/{path}).
4
+ *
5
+ * Why the Contents API: it reads a file with its blob SHA and writes a new
6
+ * version as a single commit only if the SHA still matches. That gives us
7
+ * load, conflict detection, and an understandable commit without a custom
8
+ * git implementation.
9
+ *
10
+ * Docs: https://docs.github.com/en/rest/repos/contents
11
+ */
12
+ import type { ContentAdapter } from "@getrefino/core";
13
+ import type { GitHubContentAdapterOptions } from "./options.js";
14
+ export declare function createGitHubContentAdapter(options: GitHubContentAdapterOptions): ContentAdapter;
@@ -0,0 +1,116 @@
1
+ import { ContentConflictError, ContentError, buildCommitMessage, parseCopy, planSave } from "@getrefino/core";
2
+ import { resolveOptions } from "./options.js";
3
+ function encodePath(path) {
4
+ return path.split("/").map(encodeURIComponent).join("/");
5
+ }
6
+ function decodeBase64(text) {
7
+ return Buffer.from(text.replace(/\s/g, ""), "base64").toString("utf8");
8
+ }
9
+ function encodeBase64(text) {
10
+ return Buffer.from(text, "utf8").toString("base64");
11
+ }
12
+ export function createGitHubContentAdapter(options) {
13
+ const resolved = resolveOptions(options);
14
+ const doFetch = resolved.fetch ?? ((...args) => globalThis.fetch(...args));
15
+ const fileUrl = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(resolved.owner)}/${encodeURIComponent(resolved.repo)}/contents/${encodePath(resolved.path)}`;
16
+ const location = `${resolved.owner}/${resolved.repo}@${resolved.branch}:${resolved.path}`;
17
+ function headers(extra = {}) {
18
+ return {
19
+ accept: "application/vnd.github+json",
20
+ authorization: `Bearer ${resolved.token}`,
21
+ "x-github-api-version": resolved.apiVersion,
22
+ "user-agent": "refino-github-adapter",
23
+ ...extra,
24
+ };
25
+ }
26
+ /** Never let the token leak through an error message. */
27
+ function scrub(text) {
28
+ return text.split(resolved.token).join("[redacted]");
29
+ }
30
+ async function readError(response) {
31
+ try {
32
+ const body = (await response.json());
33
+ return scrub(body.message ?? `HTTP ${response.status}`);
34
+ }
35
+ catch {
36
+ return `HTTP ${response.status}`;
37
+ }
38
+ }
39
+ async function readCurrent() {
40
+ const url = `${fileUrl}?ref=${encodeURIComponent(resolved.branch)}`;
41
+ const response = await doFetch(url, { method: "GET", headers: headers(), cache: "no-store" });
42
+ if (response.status === 404) {
43
+ throw new ContentError("NOT_FOUND", `Copy file not found at ${location}. Check the repository, branch and path.`);
44
+ }
45
+ if (response.status === 401 || response.status === 403) {
46
+ throw new ContentError("ADAPTER_ERROR", `GitHub rejected the token (HTTP ${response.status}). It needs Contents read/write on ${resolved.owner}/${resolved.repo}.`);
47
+ }
48
+ if (!response.ok) {
49
+ throw new ContentError("ADAPTER_ERROR", `GitHub read failed: ${await readError(response)}`);
50
+ }
51
+ const file = (await response.json());
52
+ if (file.type !== "file" || typeof file.sha !== "string") {
53
+ throw new ContentError("ADAPTER_ERROR", `${location} is not a file.`);
54
+ }
55
+ if (file.encoding !== "base64" || typeof file.content !== "string") {
56
+ throw new ContentError("ADAPTER_ERROR", `${location} is too large to edit through the Contents API (1 MB limit).`);
57
+ }
58
+ const text = decodeBase64(file.content);
59
+ return { text, content: parseCopy(text), revision: file.sha };
60
+ }
61
+ return {
62
+ name: "github",
63
+ async load() {
64
+ const { content, revision } = await readCurrent();
65
+ return { content, revision };
66
+ },
67
+ async save(request) {
68
+ const current = await readCurrent();
69
+ const plan = planSave(current, request);
70
+ if (plan.kind === "unchanged") {
71
+ return { status: "unchanged", snapshot: { content: current.content, revision: current.revision } };
72
+ }
73
+ const message = resolved.commitMessage
74
+ ? resolved.commitMessage({ changedIds: plan.changedIds })
75
+ : buildCommitMessage(plan.changedIds);
76
+ const body = {
77
+ message,
78
+ content: encodeBase64(plan.text),
79
+ sha: current.revision,
80
+ branch: resolved.branch,
81
+ };
82
+ if (resolved.committer)
83
+ body.committer = resolved.committer;
84
+ if (resolved.author)
85
+ body.author = resolved.author;
86
+ const response = await doFetch(fileUrl, {
87
+ method: "PUT",
88
+ headers: headers({ "content-type": "application/json" }),
89
+ body: JSON.stringify(body),
90
+ });
91
+ if (response.status === 409) {
92
+ // GitHub compared the SHA we sent with the file and found it moved.
93
+ const latest = await readCurrent();
94
+ throw new ContentConflictError("The copy file changed in the repository while you were editing. Reload the latest copy and reapply your edits.", { content: latest.content, revision: latest.revision });
95
+ }
96
+ if (response.status === 401 || response.status === 403) {
97
+ throw new ContentError("ADAPTER_ERROR", `GitHub refused the commit (HTTP ${response.status}). The token needs Contents read/write on ${resolved.owner}/${resolved.repo}, and the branch must not block direct pushes.`);
98
+ }
99
+ if (!response.ok) {
100
+ throw new ContentError("ADAPTER_ERROR", `GitHub commit failed: ${await readError(response)}`);
101
+ }
102
+ const result = (await response.json());
103
+ const newSha = result.content?.sha;
104
+ if (typeof newSha !== "string") {
105
+ throw new ContentError("ADAPTER_ERROR", "GitHub commit succeeded but returned no file SHA.");
106
+ }
107
+ const commitSha = result.commit?.sha ?? "";
108
+ const commitUrl = result.commit?.html_url;
109
+ return {
110
+ status: "saved",
111
+ snapshot: { content: plan.content, revision: newSha },
112
+ commit: commitUrl ? { sha: commitSha, message, url: commitUrl } : { sha: commitSha, message },
113
+ };
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,3 @@
1
+ export { createGitHubContentAdapter } from "./adapter.js";
2
+ export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
3
+ export type { GitHubCommitIdentity, GitHubContentAdapterOptions } from "./options.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createGitHubContentAdapter } from "./adapter.js";
2
+ export { DEFAULT_API_BASE_URL, DEFAULT_API_VERSION, GITHUB_ENV, githubOptionsFromEnv, validateRepoPath, } from "./options.js";
@@ -0,0 +1,54 @@
1
+ export interface GitHubCommitIdentity {
2
+ readonly name: string;
3
+ readonly email: string;
4
+ }
5
+ export interface GitHubContentAdapterOptions {
6
+ /**
7
+ * Fine-grained personal access token (or GitHub App installation token)
8
+ * with **Contents: Read and write** on this one repository and nothing else.
9
+ * Server-side only. Never ship it to the browser.
10
+ */
11
+ readonly token: string;
12
+ readonly owner: string;
13
+ readonly repo: string;
14
+ /** Branch to read from and commit to, e.g. "main". Explicit on purpose. */
15
+ readonly branch: string;
16
+ /** Repository-relative path of the copy file, e.g. "content/copy.json". */
17
+ readonly path: string;
18
+ /** Defaults to https://api.github.com. Set for GitHub Enterprise Server. */
19
+ readonly apiBaseUrl?: string;
20
+ /** X-GitHub-Api-Version header. Defaults to the current documented version. */
21
+ readonly apiVersion?: string;
22
+ /** Injectable for tests. Defaults to global fetch. */
23
+ readonly fetch?: typeof fetch;
24
+ /** Override the commit message. Receives the IDs that actually changed. */
25
+ readonly commitMessage?: (context: {
26
+ readonly changedIds: readonly string[];
27
+ }) => string;
28
+ readonly committer?: GitHubCommitIdentity;
29
+ readonly author?: GitHubCommitIdentity;
30
+ }
31
+ export declare const DEFAULT_API_BASE_URL = "https://api.github.com";
32
+ export declare const DEFAULT_API_VERSION = "2026-03-10";
33
+ /** Validate a repository-relative file path. Rejects traversal and absolute paths. */
34
+ export declare function validateRepoPath(path: string): string;
35
+ export interface ResolvedGitHubOptions extends GitHubContentAdapterOptions {
36
+ readonly apiBaseUrl: string;
37
+ readonly apiVersion: string;
38
+ }
39
+ export declare function resolveOptions(options: GitHubContentAdapterOptions): ResolvedGitHubOptions;
40
+ /**
41
+ * Environment variable names used by `githubOptionsFromEnv`.
42
+ *
43
+ * COPY_GITHUB_TOKEN fine-grained PAT, Contents read/write on one repo
44
+ * COPY_GITHUB_REPO "owner/name"
45
+ * COPY_GITHUB_BRANCH branch to commit to (default "main")
46
+ * COPY_FILE_PATH repository path of the copy file (default "content/copy.json")
47
+ */
48
+ export declare const GITHUB_ENV: {
49
+ readonly token: "COPY_GITHUB_TOKEN";
50
+ readonly repo: "COPY_GITHUB_REPO";
51
+ readonly branch: "COPY_GITHUB_BRANCH";
52
+ readonly path: "COPY_FILE_PATH";
53
+ };
54
+ export declare function githubOptionsFromEnv(env: Readonly<Record<string, string | undefined>>): GitHubContentAdapterOptions;
@@ -0,0 +1,79 @@
1
+ import { ContentError } from "@getrefino/core";
2
+ export const DEFAULT_API_BASE_URL = "https://api.github.com";
3
+ export const DEFAULT_API_VERSION = "2026-03-10";
4
+ const NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
5
+ function requireString(value, label) {
6
+ if (typeof value !== "string" || value.trim().length === 0) {
7
+ throw new ContentError("NOT_CONFIGURED", `GitHub adapter: ${label} is required.`);
8
+ }
9
+ return value.trim();
10
+ }
11
+ /** Validate a repository-relative file path. Rejects traversal and absolute paths. */
12
+ export function validateRepoPath(path) {
13
+ const trimmed = requireString(path, "path");
14
+ if (trimmed.startsWith("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
15
+ throw new ContentError("NOT_CONFIGURED", "GitHub adapter: path must be repository-relative.");
16
+ }
17
+ const segments = trimmed.split("/");
18
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
19
+ throw new ContentError("NOT_CONFIGURED", "GitHub adapter: path contains invalid segments.");
20
+ }
21
+ return trimmed;
22
+ }
23
+ export function resolveOptions(options) {
24
+ const token = requireString(options.token, "token");
25
+ const owner = requireString(options.owner, "owner");
26
+ const repo = requireString(options.repo, "repo");
27
+ const branch = requireString(options.branch, "branch");
28
+ if (!NAME_PATTERN.test(owner) || !NAME_PATTERN.test(repo)) {
29
+ throw new ContentError("NOT_CONFIGURED", "GitHub adapter: owner and repo contain invalid characters.");
30
+ }
31
+ if (branch.includes("..") || /\s/.test(branch)) {
32
+ throw new ContentError("NOT_CONFIGURED", "GitHub adapter: branch name is invalid.");
33
+ }
34
+ return {
35
+ ...options,
36
+ token,
37
+ owner,
38
+ repo,
39
+ branch,
40
+ path: validateRepoPath(options.path),
41
+ apiBaseUrl: (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, ""),
42
+ apiVersion: options.apiVersion ?? DEFAULT_API_VERSION,
43
+ };
44
+ }
45
+ /**
46
+ * Environment variable names used by `githubOptionsFromEnv`.
47
+ *
48
+ * COPY_GITHUB_TOKEN fine-grained PAT, Contents read/write on one repo
49
+ * COPY_GITHUB_REPO "owner/name"
50
+ * COPY_GITHUB_BRANCH branch to commit to (default "main")
51
+ * COPY_FILE_PATH repository path of the copy file (default "content/copy.json")
52
+ */
53
+ export const GITHUB_ENV = {
54
+ token: "COPY_GITHUB_TOKEN",
55
+ repo: "COPY_GITHUB_REPO",
56
+ branch: "COPY_GITHUB_BRANCH",
57
+ path: "COPY_FILE_PATH",
58
+ };
59
+ export function githubOptionsFromEnv(env) {
60
+ const token = env[GITHUB_ENV.token];
61
+ const repoSpec = env[GITHUB_ENV.repo];
62
+ if (!token) {
63
+ throw new ContentError("NOT_CONFIGURED", `Set ${GITHUB_ENV.token} to a fine-grained token with Contents read/write.`);
64
+ }
65
+ if (!repoSpec || !repoSpec.includes("/")) {
66
+ throw new ContentError("NOT_CONFIGURED", `Set ${GITHUB_ENV.repo} to "owner/name".`);
67
+ }
68
+ const [owner, repo, ...extra] = repoSpec.split("/");
69
+ if (!owner || !repo || extra.length > 0) {
70
+ throw new ContentError("NOT_CONFIGURED", `Set ${GITHUB_ENV.repo} to "owner/name".`);
71
+ }
72
+ return {
73
+ token,
74
+ owner,
75
+ repo,
76
+ branch: env[GITHUB_ENV.branch] || "main",
77
+ path: env[GITHUB_ENV.path] || "content/copy.json",
78
+ };
79
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@getrefino/github",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "GitHub persistence adapter for Refino: reads and commits the canonical copy file through the GitHub Contents API, so the repository stays the source of truth.",
5
+ "keywords": [
6
+ "refino",
7
+ "github",
8
+ "copy",
9
+ "content",
10
+ "git"
11
+ ],
12
+ "license": "MIT",
13
+ "homepage": "https://refino.dev",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/forant/inline-copy.git",
17
+ "directory": "packages/github"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "provenance": false
22
+ },
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "files": [
26
+ "dist/**/*.js",
27
+ "dist/**/*.d.ts",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "dependencies": {
39
+ "@getrefino/core": "0.1.0-rc.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "22.20.2",
43
+ "typescript": "6.0.3",
44
+ "vitest": "5.0.0"
45
+ },
46
+ "scripts": {
47
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
48
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
49
+ "typecheck": "tsc -p tsconfig.json --noEmit",
50
+ "test": "vitest run",
51
+ "clean": "rm -rf dist"
52
+ }
53
+ }