Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

89 lines · 3.1 KBRaw History
1import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2import { homedir } from "node:os";
3import path from "node:path";
4import { CliError, type Io } from "./io.js";
5
6export const DEFAULT_URL = "https://diffinery.com";
7
8export interface StoredConfig {
9 url?: string;
10 token?: string;
11}
12
13export interface ResolvedConfig {
14 url: string;
15 token: string | null;
16 /** Where the token came from, for messages. */
17 tokenSource: "env" | "config" | null;
18 urlSource: "env" | "config" | "default";
19 file: string;
20}
21
22export function configDir(env: NodeJS.ProcessEnv): string {
23 const xdg = env.XDG_CONFIG_HOME;
24 const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || homedir(), ".config");
25 return path.join(base, "diffinery");
26}
27
28export const configPath = (env: NodeJS.ProcessEnv) => path.join(configDir(env), "config.json");
29
30export async function readConfig(env: NodeJS.ProcessEnv): Promise<StoredConfig> {
31 const file = configPath(env);
32 let text: string;
33 try {
34 text = await readFile(file, "utf8");
35 } catch (e) {
36 if ((e as NodeJS.ErrnoException).code === "ENOENT") return {};
37 throw e;
38 }
39 try {
40 const v = JSON.parse(text) as unknown;
41 if (!v || typeof v !== "object") return {};
42 const o = v as Record<string, unknown>;
43 return { url: typeof o.url === "string" ? o.url : undefined, token: typeof o.token === "string" ? o.token : undefined };
44 } catch {
45 throw new CliError(`${file} is not valid JSON; fix or delete it`);
46 }
47}
48
49export async function writeConfig(env: NodeJS.ProcessEnv, cfg: StoredConfig): Promise<string> {
50 const dir = configDir(env);
51 await mkdir(dir, { recursive: true, mode: 0o700 });
52 const file = configPath(env);
53 const clean: StoredConfig = {};
54 if (cfg.url) clean.url = cfg.url;
55 if (cfg.token) clean.token = cfg.token;
56 await writeFile(file, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600 });
57 await chmod(file, 0o600); // writeFile's mode only applies on create
58 return file;
59}
60
61/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */
62export function normalizeUrl(u: string): string {
63 let s = u.trim();
64 if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`;
65 let parsed: URL;
66 try {
67 parsed = new URL(s);
68 } catch {
69 throw new CliError(`invalid URL: ${u}`);
70 }
71 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`);
72 return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`;
73}
74
75/** Env vars override the config file; the URL falls back to the public instance. */
76export async function resolveConfig(io: Io): Promise<ResolvedConfig> {
77 const stored = await readConfig(io.env);
78 const envUrl = io.env.DIFFINERY_URL?.trim();
79 const envToken = io.env.DIFFINERY_TOKEN?.trim();
80 const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
81 const token = envToken || stored.token || null;
82 return {
83 url,
84 token,
85 tokenSource: envToken ? "env" : stored.token ? "config" : null,
86 urlSource: envUrl ? "env" : stored.url ? "config" : "default",
87 file: configPath(io.env),
88 };
89}