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.

107 lines · 4.1 KBRaw History
1import { chmod, mkdir, readFile, rename, rm, 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 // mkdir's mode only applies to a directory it creates: an existing one may be readable by others.
53 await chmod(dir, 0o700);
54 const file = configPath(env);
55 const clean: StoredConfig = {};
56 if (cfg.url) clean.url = cfg.url;
57 if (cfg.token) clean.token = cfg.token;
58 // A new file that is never readable by others, then renamed over the old one, so the token is never
59 // in a loosely permissioned file, even for a moment, and a crash never leaves half a config.
60 const tmp = path.join(dir, `.config.json.${process.pid}.${Date.now()}.tmp`);
61 try {
62 await writeFile(tmp, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600, flag: "wx" });
63 await rename(tmp, file);
64 } catch (e) {
65 await rm(tmp, { force: true });
66 throw e;
67 }
68 return file;
69}
70
71/** localhost, *.localhost, 127.0.0.0/8 and ::1. */
72export function isLoopback(hostname: string): boolean {
73 const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
74 return h === "localhost" || h.endsWith(".localhost") || h === "::1" || /^127(\.\d{1,3}){3}$/.test(h);
75}
76
77/** Normalizes a user-typed server URL: adds https://, drops trailing slashes and any path. */
78export function normalizeUrl(u: string): string {
79 let s = u.trim();
80 if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `https://${s}`;
81 let parsed: URL;
82 try {
83 parsed = new URL(s);
84 } catch {
85 throw new CliError(`invalid URL: ${u}`);
86 }
87 if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new CliError(`URL must be http(s): ${u}`);
88 // Tokens go with every request: plain http only to this machine (a local development server).
89 if (parsed.protocol === "http:" && !isLoopback(parsed.hostname)) throw new CliError(`refusing ${parsed.protocol}//${parsed.host}: tokens would be sent unencrypted; use https (plain http works only for localhost)`);
90 return `${parsed.protocol}//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}`;
91}
92
93/** Env vars override the config file; the URL falls back to the public instance. */
94export async function resolveConfig(io: Io): Promise<ResolvedConfig> {
95 const stored = await readConfig(io.env);
96 const envUrl = io.env.DIFFINERY_URL?.trim();
97 const envToken = io.env.DIFFINERY_TOKEN?.trim();
98 const url = envUrl ? normalizeUrl(envUrl) : stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
99 const token = envToken || stored.token || null;
100 return {
101 url,
102 token,
103 tokenSource: envToken ? "env" : stored.token ? "config" : null,
104 urlSource: envUrl ? "env" : stored.url ? "config" : "default",
105 file: configPath(io.env),
106 };
107}