Highly ambitious ATProtocol AppView service and sdks
1import { resolve, dirname } from "@std/path";
2import { existsSync } from "@std/fs";
3import { logger } from "./logger.ts";
4
5export interface SlicesConfig {
6 slice?: string;
7 apiUrl?: string;
8 lexiconPath?: string;
9 clientOutputPath?: string;
10}
11
12export class SlicesConfigLoader {
13 private configCache: SlicesConfig | null = null;
14 private configPath: string | null = null;
15
16 /**
17 * Find and load slices.json config file, starting from current directory
18 * and walking up the directory tree
19 */
20 async load(startPath = Deno.cwd()): Promise<SlicesConfig> {
21 if (this.configCache && this.configPath) {
22 return this.configCache;
23 }
24
25 const configPath = this.findConfigFile(startPath);
26 if (!configPath) {
27 logger.debug("No slices.json config file found");
28 return {};
29 }
30
31 this.configPath = configPath;
32 logger.debug(`Loading config from: ${configPath}`);
33
34 try {
35 const configText = await Deno.readTextFile(configPath);
36 const config = JSON.parse(configText) as SlicesConfig;
37
38 // Validate config structure
39 this.validateConfig(config);
40
41 this.configCache = config;
42 return config;
43 } catch (error) {
44 const err = error as Error;
45 logger.warn(`Failed to load config file ${configPath}: ${err.message}`);
46 return {};
47 }
48 }
49
50 /**
51 * Get the directory containing the config file
52 */
53 getConfigDir(): string | null {
54 return this.configPath ? dirname(this.configPath) : null;
55 }
56
57 /**
58 * Clear the config cache (useful for testing)
59 */
60 clearCache(): void {
61 this.configCache = null;
62 this.configPath = null;
63 }
64
65 /**
66 * Find slices.json file by walking up the directory tree
67 */
68 private findConfigFile(startPath: string): string | null {
69 let currentPath = resolve(startPath);
70 let lastPath = "";
71
72 while (currentPath !== lastPath) {
73 const configPath = resolve(currentPath, "slices.json");
74 if (existsSync(configPath)) {
75 return configPath;
76 }
77
78 lastPath = currentPath;
79 currentPath = dirname(currentPath);
80 }
81
82 return null;
83 }
84
85 /**
86 * Validate the config file structure
87 */
88 private validateConfig(config: unknown): void {
89 if (typeof config !== "object" || config === null) {
90 throw new Error("Config must be an object");
91 }
92
93 const cfg = config as Record<string, unknown>;
94
95 if (cfg.slice !== undefined && typeof cfg.slice !== "string") {
96 throw new Error("Config 'slice' must be a string");
97 }
98
99 if (cfg.apiUrl !== undefined && typeof cfg.apiUrl !== "string") {
100 throw new Error("Config 'apiUrl' must be a string");
101 }
102
103 if (cfg.lexiconPath !== undefined && typeof cfg.lexiconPath !== "string") {
104 throw new Error("Config 'lexiconPath' must be a string");
105 }
106
107 if (cfg.clientOutputPath !== undefined && typeof cfg.clientOutputPath !== "string") {
108 throw new Error("Config 'clientOutputPath' must be a string");
109 }
110
111 // Validate slice URI format if provided
112 if (cfg.slice && typeof cfg.slice === "string") {
113 if (!cfg.slice.startsWith("at://")) {
114 throw new Error("Config 'slice' must be a valid AT URI (starting with 'at://')");
115 }
116 }
117 }
118}
119
120/**
121 * Merge command line arguments with config file values
122 * Command line arguments take precedence over config file
123 */
124export function mergeConfig(
125 config: SlicesConfig,
126 args: Record<string, unknown>
127): SlicesConfig {
128 return {
129 slice: (args.slice as string) || config.slice,
130 apiUrl: (args["api-url"] as string) || config.apiUrl || "https://api.slices.network",
131 lexiconPath: (args.path as string) || config.lexiconPath || "./lexicons",
132 clientOutputPath: (args.output as string) || config.clientOutputPath || "./generated_client.ts",
133 };
134}