Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion src/daemon/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -31,6 +31,82 @@ describe('config', () => {
expect(config.projects.default).toBe(join(homedir(), 'projects'));
expect(config.projects.additional).toEqual([]);
expect(existsSync(join(testDir, 'config.json'))).toBe(true);
expect(existsSync(join(testDir, 'machine.json'))).toBe(true);
});

it('persists machine identity across config.json deletion', async () => {
const { loadConfig } = await import('./config.js');
const first = loadConfig();
const originalId = first.machine.id;
const originalName = first.machine.name;

// Simulate user deleting (or regenerating) config.json — machine.json remains
unlinkSync(join(testDir, 'config.json'));
expect(existsSync(join(testDir, 'machine.json'))).toBe(true);

// Second load: config.json regenerated, identity preserved from machine.json
const second = loadConfig();
expect(second.machine.id).toBe(originalId);
expect(second.machine.name).toBe(originalName);
expect(existsSync(join(testDir, 'config.json'))).toBe(true);
});

it('migrates legacy machine block from config.json into machine.json', async () => {
const legacyId = '260d7044-b229-4afb-bbf3-40af745c3926';
writeFileSync(
join(testDir, 'config.json'),
JSON.stringify({
machine: { id: legacyId, name: 'legacy-host' },
daemon: { port: 4243 },
agents: { default: '/a', additional: [] },
projects: { default: '/p', additional: [] },
sync: { events: {} },
}) + '\n'
);

const { loadConfig } = await import('./config.js');
const config = loadConfig();

expect(config.machine.id).toBe(legacyId);
expect(config.machine.name).toBe('legacy-host');

const machineJson = JSON.parse(readFileSync(join(testDir, 'machine.json'), 'utf-8'));
expect(machineJson.id).toBe(legacyId);
expect(machineJson.name).toBe('legacy-host');
});

it('machine.json is authoritative over config.json when both exist', async () => {
writeFileSync(
join(testDir, 'machine.json'),
JSON.stringify({ id: 'from-machine-json', name: 'auth-host' }) + '\n'
);
writeFileSync(
join(testDir, 'config.json'),
JSON.stringify({
machine: { id: 'from-config-json', name: 'stale-host' },
daemon: { port: 4243 },
agents: { default: '/a', additional: [] },
projects: { default: '/p', additional: [] },
sync: { events: {} },
}) + '\n'
);

const { loadConfig } = await import('./config.js');
const config = loadConfig();

expect(config.machine.id).toBe('from-machine-json');
expect(config.machine.name).toBe('auth-host');
});

it('saveConfig writes machine.json too', async () => {
const { loadConfig, saveConfig } = await import('./config.js');
const config = loadConfig();
config.machine.name = 'renamed';
saveConfig(config);

const machineJson = JSON.parse(readFileSync(join(testDir, 'machine.json'), 'utf-8'));
expect(machineJson.name).toBe('renamed');
expect(machineJson.id).toBe(config.machine.id);
});

it('generates machine ID as UUID v4', async () => {
Expand Down
74 changes: 59 additions & 15 deletions src/daemon/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ export interface DirConfig {
additional: string[];
}

export interface MachineIdentity {
id: string;
name: string;
}

export interface DaemonConfig {
machine: {
id: string;
name: string;
};
machine: MachineIdentity;
daemon: {
port: number;
};
Expand Down Expand Up @@ -58,11 +60,47 @@ export const getProjectsDirs = (config: DaemonConfig): string[] => {
return Array.from(new Set(all));
};

const createDefaultConfig = (): DaemonConfig => ({
machine: {
id: randomUUID(),
name: hostname(),
},
const getMachinePath = (): string => join(getConfigDir(), 'machine.json');

const readMachineFile = (): MachineIdentity | undefined => {
const path = getMachinePath();
if (!existsSync(path)) return undefined;
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<MachineIdentity>;
if (typeof parsed.id === 'string' && typeof parsed.name === 'string') {
return { id: parsed.id, name: parsed.name };
}
} catch {
// fall through
}
return undefined;
};

const writeMachineFile = (machine: MachineIdentity): void => {
const configDir = getConfigDir();
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
writeFileSync(getMachinePath(), JSON.stringify(machine, null, 2) + '\n', 'utf-8');
};

/**
* Resolve machine identity with this priority:
* 1. machine.json (authoritative — survives config.json regen)
* 2. legacy config.json `machine` block (migrated into machine.json)
* 3. freshly minted identity (UUID + hostname)
*/
const resolveMachine = (fromLegacyConfig: MachineIdentity | undefined): MachineIdentity => {
const fromFile = readMachineFile();
if (fromFile) return fromFile;

const adopted = fromLegacyConfig ?? { id: randomUUID(), name: hostname() };
writeMachineFile(adopted);
return adopted;
};

const createDefaultConfig = (machine: MachineIdentity): DaemonConfig => ({
machine,
daemon: {
port: 4243,
},
Expand Down Expand Up @@ -91,13 +129,18 @@ const createDefaultConfig = (): DaemonConfig => ({
export const loadConfig = (): DaemonConfig => {
const configPath = join(getConfigDir(), 'config.json');

let config: DaemonConfig;

let rawConfig: Partial<DaemonConfig> | undefined;
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8');
config = JSON.parse(raw) as DaemonConfig;
} else {
config = createDefaultConfig();
rawConfig = JSON.parse(readFileSync(configPath, 'utf-8')) as Partial<DaemonConfig>;
}

const machine = resolveMachine(rawConfig?.machine);

const config: DaemonConfig = rawConfig
? ({ ...rawConfig, machine } as DaemonConfig)
: createDefaultConfig(machine);

if (!existsSync(configPath)) {
saveConfig(config);
}

Expand All @@ -124,6 +167,7 @@ export const saveConfig = (config: DaemonConfig): void => {
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
writeMachineFile(config.machine);
const configPath = join(configDir, 'config.json');
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
};