@@ -5,11 +5,6 @@ import * as os from "os";
55import * as jsonc from "jsonc-parser" ;
66import type { WorkspaceMetadata } from "./types/workspace" ;
77
8- export const CONFIG_DIR = path . join ( os . homedir ( ) , ".cmux" ) ;
9- const CONFIG_FILE = path . join ( CONFIG_DIR , "config.json" ) ;
10- const PROVIDERS_FILE = path . join ( CONFIG_DIR , "providers.jsonc" ) ;
11- export const SESSIONS_DIR = path . join ( CONFIG_DIR , "sessions" ) ;
12-
138export interface Workspace {
149 branch : string ;
1510 path : string ;
@@ -20,7 +15,7 @@ export interface ProjectConfig {
2015 workspaces : Workspace [ ] ;
2116}
2217
23- export interface Config {
18+ export interface ProjectsConfig {
2419 projects : Map < string , ProjectConfig > ;
2520}
2621
@@ -32,164 +27,187 @@ export interface ProviderConfig {
3227
3328export type ProvidersConfig = Record < string , ProviderConfig > ;
3429
35- export function load_config_or_default ( ) : Config {
36- try {
37- if ( fs . existsSync ( CONFIG_FILE ) ) {
38- const data = fs . readFileSync ( CONFIG_FILE , "utf-8" ) ;
39- const parsed = JSON . parse ( data ) as { projects ?: unknown } ;
40-
41- // Config is stored as array of [path, config] pairs
42- if ( parsed . projects && Array . isArray ( parsed . projects ) ) {
43- const projectsMap = new Map < string , ProjectConfig > (
44- parsed . projects as Array < [ string , ProjectConfig ] >
45- ) ;
46- return {
47- projects : projectsMap ,
48- } ;
49- }
50- }
51- } catch ( error ) {
52- console . error ( "Error loading config:" , error ) ;
30+ /**
31+ * Config - Centralized configuration management
32+ *
33+ * Encapsulates all config paths and operations, making them dependency-injectable
34+ * and testable. Pass a custom rootDir for tests to avoid polluting ~/.cmux
35+ */
36+ export class Config {
37+ readonly rootDir : string ;
38+ readonly sessionsDir : string ;
39+ readonly srcDir : string ;
40+ private readonly configFile : string ;
41+ private readonly providersFile : string ;
42+
43+ constructor ( rootDir ?: string ) {
44+ this . rootDir = rootDir ?? path . join ( os . homedir ( ) , ".cmux" ) ;
45+ this . sessionsDir = path . join ( this . rootDir , "sessions" ) ;
46+ this . srcDir = path . join ( this . rootDir , "src" ) ;
47+ this . configFile = path . join ( this . rootDir , "config.json" ) ;
48+ this . providersFile = path . join ( this . rootDir , "providers.jsonc" ) ;
5349 }
5450
55- // Return default config
56- return {
57- projects : new Map ( ) ,
58- } ;
59- }
60-
61- export function save_config ( config : Config ) : void {
62- try {
63- if ( ! fs . existsSync ( CONFIG_DIR ) ) {
64- fs . mkdirSync ( CONFIG_DIR , { recursive : true } ) ;
51+ loadConfigOrDefault ( ) : ProjectsConfig {
52+ try {
53+ if ( fs . existsSync ( this . configFile ) ) {
54+ const data = fs . readFileSync ( this . configFile , "utf-8" ) ;
55+ const parsed = JSON . parse ( data ) as { projects ?: unknown } ;
56+
57+ // Config is stored as array of [path, config] pairs
58+ if ( parsed . projects && Array . isArray ( parsed . projects ) ) {
59+ const projectsMap = new Map < string , ProjectConfig > (
60+ parsed . projects as Array < [ string , ProjectConfig ] >
61+ ) ;
62+ return {
63+ projects : projectsMap ,
64+ } ;
65+ }
66+ }
67+ } catch ( error ) {
68+ console . error ( "Error loading config:" , error ) ;
6569 }
6670
67- const data = {
68- projects : Array . from ( config . projects . entries ( ) ) ,
71+ // Return default config
72+ return {
73+ projects : new Map ( ) ,
6974 } ;
70-
71- fs . writeFileSync ( CONFIG_FILE , JSON . stringify ( data , null , 2 ) ) ;
72- } catch ( error ) {
73- console . error ( "Error saving config:" , error ) ;
7475 }
75- }
76-
77- export function getProjectName ( projectPath : string ) : string {
78- return projectPath . split ( "/" ) . pop ( ) ?? projectPath . split ( "\\" ) . pop ( ) ?? "unknown" ;
79- }
8076
81- export function getWorkspacePath ( projectPath : string , branch : string ) : string {
82- const projectName = getProjectName ( projectPath ) ;
83- return path . join ( CONFIG_DIR , "src" , projectName , branch ) ;
84- }
85-
86- /**
87- * Find a workspace path by project name and branch
88- * @returns The workspace path or null if not found
89- */
90- export function findWorkspacePath ( projectName : string , branch : string ) : string | null {
91- const config = load_config_or_default ( ) ;
77+ saveConfig ( config : ProjectsConfig ) : void {
78+ try {
79+ if ( ! fs . existsSync ( this . rootDir ) ) {
80+ fs . mkdirSync ( this . rootDir , { recursive : true } ) ;
81+ }
9282
93- for ( const [ projectPath , project ] of config . projects ) {
94- const currentProjectName = path . basename ( projectPath ) ;
83+ const data = {
84+ projects : Array . from ( config . projects . entries ( ) ) ,
85+ } ;
9586
96- if ( currentProjectName === projectName ) {
97- const workspace = project . workspaces . find ( ( w : Workspace ) => w . branch === branch ) ;
98- if ( workspace ) {
99- return workspace . path ;
100- }
87+ fs . writeFileSync ( this . configFile , JSON . stringify ( data , null , 2 ) ) ;
88+ } catch ( error ) {
89+ console . error ( "Error saving config:" , error ) ;
10190 }
10291 }
10392
104- return null ;
105- }
106-
107- /**
108- * WARNING: Never try to derive workspace path from workspace ID!
109- * This is a code smell that leads to bugs.
110- *
111- * The workspace path should always:
112- * 1. Be stored in WorkspaceMetadata when the workspace is created
113- * 2. Be retrieved from WorkspaceMetadata when needed
114- * 3. Be passed through the call stack explicitly
115- *
116- * Parsing workspaceId strings to derive paths is fragile and error-prone.
117- * The workspace path is established when the git worktree is created,
118- * and that canonical path should be preserved and used throughout.
119- */
93+ private getProjectName ( projectPath : string ) : string {
94+ return projectPath . split ( "/" ) . pop ( ) ?? projectPath . split ( "\\" ) . pop ( ) ?? "unknown" ;
95+ }
12096
121- /**
122- * Get the session directory for a specific workspace
123- */
124- export function getSessionDir ( workspaceId : string ) : string {
125- return path . join ( SESSIONS_DIR , workspaceId ) ;
126- }
97+ getWorkspacePath ( projectPath : string , branch : string ) : string {
98+ const projectName = this . getProjectName ( projectPath ) ;
99+ return path . join ( this . srcDir , projectName , branch ) ;
100+ }
127101
128- /**
129- * Get all workspace metadata by scanning sessions directory and loading metadata files
130- * This centralizes the logic for workspace discovery and metadata loading
131- */
132- export async function getAllWorkspaceMetadata ( ) : Promise <
133- Array < { workspaceId : string ; metadata : WorkspaceMetadata } >
134- > {
135- try {
136- // Scan sessions directory for workspace directories
137- await fsPromises . access ( SESSIONS_DIR ) ;
138- const entries = await fsPromises . readdir ( SESSIONS_DIR , { withFileTypes : true } ) ;
139- const workspaceIds = entries . filter ( ( entry ) => entry . isDirectory ( ) ) . map ( ( entry ) => entry . name ) ;
140-
141- const workspaceMetadata : Array < { workspaceId : string ; metadata : WorkspaceMetadata } > = [ ] ;
142-
143- for ( const workspaceId of workspaceIds ) {
144- try {
145- const metadataPath = path . join ( getSessionDir ( workspaceId ) , "metadata.json" ) ;
146- const data = await fsPromises . readFile ( metadataPath , "utf-8" ) ;
147- const metadata = JSON . parse ( data ) as WorkspaceMetadata ;
148- workspaceMetadata . push ( { workspaceId, metadata } ) ;
149- } catch ( error ) {
150- // Skip workspaces with missing or invalid metadata
151- console . warn ( `Failed to load metadata for workspace ${ workspaceId } :` , error ) ;
102+ /**
103+ * Find a workspace path by project name and branch
104+ * @returns The workspace path or null if not found
105+ */
106+ findWorkspacePath ( projectName : string , branch : string ) : string | null {
107+ const config = this . loadConfigOrDefault ( ) ;
108+
109+ for ( const [ projectPath , project ] of config . projects ) {
110+ const currentProjectName = path . basename ( projectPath ) ;
111+
112+ if ( currentProjectName === projectName ) {
113+ const workspace = project . workspaces . find ( ( w : Workspace ) => w . branch === branch ) ;
114+ if ( workspace ) {
115+ return workspace . path ;
116+ }
152117 }
153118 }
154119
155- return workspaceMetadata ;
156- } catch {
157- return [ ] ; // Sessions directory doesn't exist yet
120+ return null ;
158121 }
159- }
160122
161- /**
162- * Load providers configuration from JSONC file
163- * Supports comments in JSONC format
164- */
165- export function loadProvidersConfig ( ) : ProvidersConfig | null {
166- try {
167- if ( fs . existsSync ( PROVIDERS_FILE ) ) {
168- const data = fs . readFileSync ( PROVIDERS_FILE , "utf-8" ) ;
169- return jsonc . parse ( data ) as ProvidersConfig ;
170- }
171- } catch ( error ) {
172- console . error ( "Error loading providers config:" , error ) ;
123+ /**
124+ * WARNING: Never try to derive workspace path from workspace ID!
125+ * This is a code smell that leads to bugs.
126+ *
127+ * The workspace path should always:
128+ * 1. Be stored in WorkspaceMetadata when the workspace is created
129+ * 2. Be retrieved from WorkspaceMetadata when needed
130+ * 3. Be passed through the call stack explicitly
131+ *
132+ * Parsing workspaceId strings to derive paths is fragile and error-prone.
133+ * The workspace path is established when the git worktree is created,
134+ * and that canonical path should be preserved and used throughout.
135+ */
136+
137+ /**
138+ * Get the session directory for a specific workspace
139+ */
140+ getSessionDir ( workspaceId : string ) : string {
141+ return path . join ( this . sessionsDir , workspaceId ) ;
173142 }
174143
175- return null ;
176- }
144+ /**
145+ * Get all workspace metadata by scanning sessions directory and loading metadata files
146+ * This centralizes the logic for workspace discovery and metadata loading
147+ */
148+ async getAllWorkspaceMetadata ( ) : Promise <
149+ Array < { workspaceId : string ; metadata : WorkspaceMetadata } >
150+ > {
151+ try {
152+ // Scan sessions directory for workspace directories
153+ await fsPromises . access ( this . sessionsDir ) ;
154+ const entries = await fsPromises . readdir ( this . sessionsDir , { withFileTypes : true } ) ;
155+ const workspaceIds = entries
156+ . filter ( ( entry ) => entry . isDirectory ( ) )
157+ . map ( ( entry ) => entry . name ) ;
158+
159+ const workspaceMetadata : Array < { workspaceId : string ; metadata : WorkspaceMetadata } > = [ ] ;
160+
161+ for ( const workspaceId of workspaceIds ) {
162+ try {
163+ const metadataPath = path . join ( this . getSessionDir ( workspaceId ) , "metadata.json" ) ;
164+ const data = await fsPromises . readFile ( metadataPath , "utf-8" ) ;
165+ const metadata = JSON . parse ( data ) as WorkspaceMetadata ;
166+ workspaceMetadata . push ( { workspaceId, metadata } ) ;
167+ } catch ( error ) {
168+ // Skip workspaces with missing or invalid metadata
169+ console . warn ( `Failed to load metadata for workspace ${ workspaceId } :` , error ) ;
170+ }
171+ }
177172
178- /**
179- * Save providers configuration to JSONC file
180- * @param config The providers configuration to save
181- */
182- export function saveProvidersConfig ( config : ProvidersConfig ) : void {
183- try {
184- if ( ! fs . existsSync ( CONFIG_DIR ) ) {
185- fs . mkdirSync ( CONFIG_DIR , { recursive : true } ) ;
173+ return workspaceMetadata ;
174+ } catch {
175+ return [ ] ; // Sessions directory doesn't exist yet
176+ }
177+ }
178+
179+ /**
180+ * Load providers configuration from JSONC file
181+ * Supports comments in JSONC format
182+ */
183+ loadProvidersConfig ( ) : ProvidersConfig | null {
184+ try {
185+ if ( fs . existsSync ( this . providersFile ) ) {
186+ const data = fs . readFileSync ( this . providersFile , "utf-8" ) ;
187+ return jsonc . parse ( data ) as ProvidersConfig ;
188+ }
189+ } catch ( error ) {
190+ console . error ( "Error loading providers config:" , error ) ;
186191 }
187192
188- // Format with 2-space indentation for readability
189- const jsonString = JSON . stringify ( config , null , 2 ) ;
193+ return null ;
194+ }
195+
196+ /**
197+ * Save providers configuration to JSONC file
198+ * @param config The providers configuration to save
199+ */
200+ saveProvidersConfig ( config : ProvidersConfig ) : void {
201+ try {
202+ if ( ! fs . existsSync ( this . rootDir ) ) {
203+ fs . mkdirSync ( this . rootDir , { recursive : true } ) ;
204+ }
190205
191- // Add a comment header to the file
192- const contentWithComments = `// Providers configuration for cmux
206+ // Format with 2-space indentation for readability
207+ const jsonString = JSON . stringify ( config , null , 2 ) ;
208+
209+ // Add a comment header to the file
210+ const contentWithComments = `// Providers configuration for cmux
193211// Configure your AI providers here
194212// Example:
195213// {
@@ -200,9 +218,13 @@ export function saveProvidersConfig(config: ProvidersConfig): void {
200218// }
201219${ jsonString } `;
202220
203- fs . writeFileSync ( PROVIDERS_FILE , contentWithComments ) ;
204- } catch ( error ) {
205- console . error ( "Error saving providers config:" , error ) ;
206- throw error ; // Re-throw to let caller handle
221+ fs . writeFileSync ( this . providersFile , contentWithComments ) ;
222+ } catch ( error ) {
223+ console . error ( "Error saving providers config:" , error ) ;
224+ throw error ; // Re-throw to let caller handle
225+ }
207226 }
208227}
228+
229+ // Default instance for application use
230+ export const defaultConfig = new Config ( ) ;
0 commit comments