packages/cli/src/lib/config.ts 3.5 K raw
1
import * as fs from "fs/promises";
2
import * as path from "path";
3
import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
4
5
const CONFIG_FILENAME = "sequoia.json";
6
const STATE_FILENAME = ".sequoia-state.json";
7
8
async function fileExists(filePath: string): Promise<boolean> {
9
	try {
10
		await fs.access(filePath);
11
		return true;
12
	} catch {
13
		return false;
14
	}
15
}
16
17
export async function findConfig(
18
	startDir: string = process.cwd(),
19
): Promise<string | null> {
20
	let currentDir = startDir;
21
22
	while (true) {
23
		const configPath = path.join(currentDir, CONFIG_FILENAME);
24
25
		if (await fileExists(configPath)) {
26
			return configPath;
27
		}
28
29
		const parentDir = path.dirname(currentDir);
30
		if (parentDir === currentDir) {
31
			// Reached root
32
			return null;
33
		}
34
		currentDir = parentDir;
35
	}
36
}
37
38
export async function loadConfig(
39
	configPath?: string,
40
): Promise<PublisherConfig> {
41
	const resolvedPath = configPath || (await findConfig());
42
43
	if (!resolvedPath) {
44
		throw new Error(
45
			`Could not find ${CONFIG_FILENAME}. Run 'sequoia init' to create one.`,
46
		);
47
	}
48
49
	try {
50
		const content = await fs.readFile(resolvedPath, "utf-8");
51
		const config = JSON.parse(content) as PublisherConfig;
52
53
		// Validate required fields
54
		if (!config.siteUrl) throw new Error("siteUrl is required in config");
55
		if (!config.contentDir) throw new Error("contentDir is required in config");
56
		if (!config.publicationUri)
57
			throw new Error("publicationUri is required in config");
58
59
		return config;
60
	} catch (error) {
61
		if (error instanceof Error && error.message.includes("required")) {
62
			throw error;
63
		}
64
		throw new Error(`Failed to load config from ${resolvedPath}: ${error}`);
65
	}
66
}
67
68
export function generateConfigTemplate(options: {
69
	siteUrl: string;
70
	contentDir: string;
71
	imagesDir?: string;
72
	publicDir?: string;
73
	outputDir?: string;
74
	pathPrefix?: string;
75
	publicationUri: string;
76
	pdsUrl?: string;
77
	frontmatter?: FrontmatterMapping;
78
	ignore?: string[];
79
	bluesky?: BlueskyConfig;
80
}): string {
81
	const config: Record<string, unknown> = {
82
		siteUrl: options.siteUrl,
83
		contentDir: options.contentDir,
84
	};
85
86
	if (options.imagesDir) {
87
		config.imagesDir = options.imagesDir;
88
	}
89
90
	if (options.publicDir && options.publicDir !== "./public") {
91
		config.publicDir = options.publicDir;
92
	}
93
94
	if (options.outputDir) {
95
		config.outputDir = options.outputDir;
96
	}
97
98
	if (options.pathPrefix && options.pathPrefix !== "/posts") {
99
		config.pathPrefix = options.pathPrefix;
100
	}
101
102
	config.publicationUri = options.publicationUri;
103
104
	if (options.pdsUrl && options.pdsUrl !== "https://bsky.social") {
105
		config.pdsUrl = options.pdsUrl;
106
	}
107
108
	if (options.frontmatter && Object.keys(options.frontmatter).length > 0) {
109
		config.frontmatter = options.frontmatter;
110
	}
111
112
	if (options.ignore && options.ignore.length > 0) {
113
		config.ignore = options.ignore;
114
	}
115
116
	if (options.bluesky) {
117
		config.bluesky = options.bluesky;
118
	}
119
120
	return JSON.stringify(config, null, 2);
121
}
122
123
export async function loadState(configDir: string): Promise<PublisherState> {
124
	const statePath = path.join(configDir, STATE_FILENAME);
125
126
	if (!(await fileExists(statePath))) {
127
		return { posts: {} };
128
	}
129
130
	try {
131
		const content = await fs.readFile(statePath, "utf-8");
132
		return JSON.parse(content) as PublisherState;
133
	} catch {
134
		return { posts: {} };
135
	}
136
}
137
138
export async function saveState(
139
	configDir: string,
140
	state: PublisherState,
141
): Promise<void> {
142
	const statePath = path.join(configDir, STATE_FILENAME);
143
	await fs.writeFile(statePath, JSON.stringify(state, null, 2));
144
}
145
146
export function getStatePath(configDir: string): string {
147
	return path.join(configDir, STATE_FILENAME);
148
}