Skip to content

Commit ef01079

Browse files
committed
Use HA packages for config in add-on mode (#61)
Write SpoolmanSync configuration to an isolated package file instead of appending to configuration.yaml. This prevents conflicts with users who split their HA config using !include directives. Detects existing packages configuration style (directory-based, named entries, or none) and writes to the appropriate location. Backs up configuration.yaml before modifications and validates via HA's check_config API, auto-reverting on failure. Embedded and external modes are unchanged.
1 parent 249a8f0 commit ef01079

3 files changed

Lines changed: 271 additions & 5 deletions

File tree

app/src/app/api/automations/route.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import prisma from '@/lib/db';
33
import { HomeAssistantClient, isEmbeddedMode, isAddonMode } from '@/lib/api/homeassistant';
4-
import { generateHAConfig, mergeConfiguration, mergeAutomations } from '@/lib/ha-config-generator';
4+
import {
5+
generateHAConfig, mergeConfiguration, mergeAutomations,
6+
detectPackagesConfig, addPackagesDirective, addPackageEntry,
7+
stripSpoolmanSyncConfig, toPackageFileContent,
8+
} from '@/lib/ha-config-generator';
59
import { createActivityLog } from '@/lib/activity-log';
610
import { getHiddenPrinters } from '@/app/api/printers/setup/route';
711
import * as fs from 'fs/promises';
@@ -198,17 +202,99 @@ export async function POST(request: NextRequest) {
198202
await fs.writeFile(automationsPath, mergedAutomationsContent, 'utf-8');
199203
console.log('Wrote automations.yaml');
200204

201-
// Read existing configuration.yaml and merge
205+
// Read existing configuration.yaml
202206
let existingConfig = '';
203207
try {
204208
existingConfig = await fs.readFile(configPath, 'utf-8');
205209
} catch {
206210
console.log('No existing configuration.yaml found');
207211
}
208212

209-
const mergedConfig = mergeConfiguration(existingConfig, config.configurationAdditions);
210-
await fs.writeFile(configPath, mergedConfig, 'utf-8');
211-
console.log('Wrote configuration.yaml');
213+
if (isAddonMode()) {
214+
// === ADD-ON MODE: Use HA packages to avoid conflicting top-level keys ===
215+
// This prevents issues when users split their config with !include directives.
216+
217+
// Step 1: Strip any legacy SpoolmanSync block from configuration.yaml
218+
// (from previous versions that appended directly)
219+
const cleanedConfig = stripSpoolmanSyncConfig(existingConfig);
220+
if (cleanedConfig !== existingConfig) {
221+
console.log('Stripped legacy SpoolmanSync config block from configuration.yaml');
222+
}
223+
224+
// Step 2: Detect current packages configuration style
225+
const packagesConfig = detectPackagesConfig(cleanedConfig);
226+
console.log(`Detected packages style: ${packagesConfig.style}`);
227+
228+
// Step 3: Determine package file path and write it
229+
let packageFilePath: string;
230+
if (packagesConfig.style === 'directory') {
231+
const dirPath = `${haConfigPath}/${packagesConfig.directoryPath}`;
232+
await fs.mkdir(dirPath, { recursive: true });
233+
packageFilePath = `${dirPath}/spoolmansync.yaml`;
234+
} else if (packagesConfig.style === 'named') {
235+
packageFilePath = `${haConfigPath}/spoolmansync_package.yaml`;
236+
} else {
237+
// No packages — we'll create the directory and add the directive
238+
const dirPath = `${haConfigPath}/packages`;
239+
await fs.mkdir(dirPath, { recursive: true });
240+
packageFilePath = `${dirPath}/spoolmansync.yaml`;
241+
}
242+
243+
const packageContent = toPackageFileContent(config.configurationAdditions);
244+
await fs.writeFile(packageFilePath, packageContent, 'utf-8');
245+
console.log(`Wrote package file: ${packageFilePath}`);
246+
247+
// Step 4: Modify configuration.yaml if needed (scenarios A and C)
248+
let finalConfig = cleanedConfig;
249+
let configModified = cleanedConfig !== existingConfig; // true if legacy block was stripped
250+
251+
if (packagesConfig.style === 'none') {
252+
// Scenario A: add packages directive
253+
finalConfig = addPackagesDirective(finalConfig);
254+
configModified = true;
255+
} else if (packagesConfig.style === 'named' && !packagesConfig.hasSpoolmansync) {
256+
// Scenario C: add spoolmansync entry under existing packages block
257+
finalConfig = addPackageEntry(finalConfig, packagesConfig);
258+
configModified = true;
259+
}
260+
261+
if (configModified) {
262+
// Back up before writing
263+
await fs.writeFile(`${configPath}.bak`, existingConfig, 'utf-8');
264+
console.log('Backed up configuration.yaml to configuration.yaml.bak');
265+
266+
await fs.writeFile(configPath, finalConfig, 'utf-8');
267+
console.log('Wrote modified configuration.yaml');
268+
269+
// Validate configuration via HA API
270+
try {
271+
const checkResult = await haClient.checkConfig();
272+
if (checkResult.result === 'invalid') {
273+
console.error('Configuration validation failed:', checkResult.errors);
274+
275+
// Revert configuration.yaml from backup
276+
await fs.writeFile(configPath, existingConfig, 'utf-8');
277+
console.log('Reverted configuration.yaml from backup');
278+
279+
// Clean up package file
280+
try { await fs.unlink(packageFilePath); } catch { /* ignore */ }
281+
282+
return NextResponse.json({
283+
error: `Configuration validation failed. Your configuration.yaml has been restored from backup. Error: ${checkResult.errors}`,
284+
}, { status: 400 });
285+
}
286+
console.log('Configuration validated successfully');
287+
} catch (validationError) {
288+
console.warn('Could not validate configuration (HA may not support check_config):', validationError);
289+
// Continue anyway — the config was written and backed up
290+
}
291+
}
292+
} else {
293+
// === EMBEDDED MODE: Append directly to configuration.yaml (SpoolmanSync controls this HA) ===
294+
const mergedConfig = mergeConfiguration(existingConfig, config.configurationAdditions);
295+
await fs.writeFile(configPath, mergedConfig, 'utf-8');
296+
console.log('Wrote configuration.yaml');
297+
}
212298

213299
// YAML-configured entities (input_number, utility_meter, template, rest_command)
214300
// require a restart to be created - automation.reload is not sufficient.

app/src/lib/api/homeassistant.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,15 @@ export class HomeAssistantClient {
10821082
// We'll use the automation config entry instead
10831083
}
10841084

1085+
/**
1086+
* Validate Home Assistant configuration without restarting.
1087+
* Returns { result: 'valid', errors: null } on success,
1088+
* or { result: 'invalid', errors: '...' } on failure.
1089+
*/
1090+
async checkConfig(): Promise<{ result: 'valid' | 'invalid'; errors: string | null }> {
1091+
return this.fetch('/config/core/check_config', { method: 'POST' });
1092+
}
1093+
10851094
/**
10861095
* Call a webhook
10871096
*/

app/src/lib/ha-config-generator.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,3 +910,174 @@ export function mergeConfiguration(existingConfig: string, additions: string): s
910910
// Append the new configuration
911911
return existingConfig.trim() + '\n' + additions;
912912
}
913+
914+
// =============================================================================
915+
// HA Packages support for add-on mode
916+
// =============================================================================
917+
918+
export interface PackagesConfig {
919+
style: 'none' | 'directory' | 'named';
920+
directoryPath?: string; // For 'directory' style — the path from the !include_dir directive
921+
insertAfterLineIndex?: number; // For 'named' style — line index to insert after
922+
entryIndent?: string; // For 'named' style — indentation string for entries
923+
hasSpoolmansync?: boolean; // Whether a spoolmansync entry already exists
924+
}
925+
926+
/**
927+
* Detect how packages are configured in configuration.yaml.
928+
* Parses as text (not YAML) since !include directives aren't standard YAML.
929+
*/
930+
export function detectPackagesConfig(configContent: string): PackagesConfig {
931+
const lines = configContent.split('\n');
932+
933+
// Find the homeassistant: block and packages: line within it
934+
let inHomeassistant = false;
935+
let homeassistantIndent = -1;
936+
937+
for (let i = 0; i < lines.length; i++) {
938+
const line = lines[i];
939+
const trimmed = line.trimStart();
940+
941+
// Detect homeassistant: top-level key
942+
if (/^homeassistant\s*:/.test(trimmed) && (line.length - trimmed.length) === 0) {
943+
inHomeassistant = true;
944+
homeassistantIndent = 0;
945+
continue;
946+
}
947+
948+
if (!inHomeassistant) continue;
949+
950+
// Check if we've left the homeassistant block (same or lower indentation, non-empty, non-comment)
951+
const currentIndent = line.length - trimmed.length;
952+
if (trimmed && !trimmed.startsWith('#') && currentIndent <= homeassistantIndent) {
953+
inHomeassistant = false;
954+
continue;
955+
}
956+
957+
// Look for packages: within homeassistant:
958+
const packagesMatch = trimmed.match(/^packages\s*:\s*(.*)$/);
959+
if (!packagesMatch) continue;
960+
961+
const packagesIndent = currentIndent;
962+
const restOfLine = packagesMatch[1].trim();
963+
964+
// Style B: !include_dir_named or !include_dir_merge_named
965+
const dirMatch = restOfLine.match(/^!include_dir_(?:named|merge_named)\s+(.+)$/);
966+
if (dirMatch) {
967+
return {
968+
style: 'directory',
969+
directoryPath: dirMatch[1].trim(),
970+
};
971+
}
972+
973+
// Style C (or A with no value): named entries on subsequent lines
974+
// Scan forward to find entries and the end of the packages block
975+
const entryIndentLevel = packagesIndent + 2;
976+
const entryIndent = ' '.repeat(entryIndentLevel);
977+
let lastEntryEndIndex = i; // default: right after packages: line
978+
let hasSpoolmansync = false;
979+
980+
for (let j = i + 1; j < lines.length; j++) {
981+
const entryLine = lines[j];
982+
const entryTrimmed = entryLine.trimStart();
983+
const entryCurrentIndent = entryLine.length - entryTrimmed.length;
984+
985+
// Skip empty lines and comments
986+
if (!entryTrimmed || entryTrimmed.startsWith('#')) {
987+
continue;
988+
}
989+
990+
// If indentation is at or below packages level, we've left the block
991+
if (entryCurrentIndent <= packagesIndent) {
992+
break;
993+
}
994+
995+
// This line is inside the packages block
996+
lastEntryEndIndex = j;
997+
998+
// Check for existing spoolmansync entry
999+
if (entryTrimmed.startsWith('spoolmansync:') || entryTrimmed.startsWith('spoolmansync :')) {
1000+
hasSpoolmansync = true;
1001+
}
1002+
}
1003+
1004+
// If rest of line is empty and no entries found → treat as 'none' (empty packages block)
1005+
if (!restOfLine && lastEntryEndIndex === i) {
1006+
return { style: 'none' };
1007+
}
1008+
1009+
return {
1010+
style: 'named',
1011+
insertAfterLineIndex: lastEntryEndIndex,
1012+
entryIndent,
1013+
hasSpoolmansync,
1014+
};
1015+
}
1016+
1017+
return { style: 'none' };
1018+
}
1019+
1020+
/**
1021+
* Add `packages: !include_dir_named packages` under homeassistant: in configuration.yaml.
1022+
* If homeassistant: doesn't exist, adds it at the top.
1023+
*/
1024+
export function addPackagesDirective(configContent: string): string {
1025+
const lines = configContent.split('\n');
1026+
1027+
// Find homeassistant: line
1028+
for (let i = 0; i < lines.length; i++) {
1029+
if (/^homeassistant\s*:/.test(lines[i].trimStart()) && (lines[i].length - lines[i].trimStart().length) === 0) {
1030+
// Insert packages directive after homeassistant: line
1031+
lines.splice(i + 1, 0, ' packages: !include_dir_named packages');
1032+
return lines.join('\n');
1033+
}
1034+
}
1035+
1036+
// No homeassistant: key found — add it at the top
1037+
return 'homeassistant:\n packages: !include_dir_named packages\n\n' + configContent;
1038+
}
1039+
1040+
/**
1041+
* Add a spoolmansync package entry under an existing named packages: block.
1042+
*/
1043+
export function addPackageEntry(configContent: string, config: PackagesConfig): string {
1044+
if (config.style !== 'named' || config.insertAfterLineIndex === undefined || !config.entryIndent) {
1045+
return configContent;
1046+
}
1047+
1048+
const lines = configContent.split('\n');
1049+
const newLine = `${config.entryIndent}spoolmansync: !include spoolmansync_package.yaml`;
1050+
lines.splice(config.insertAfterLineIndex + 1, 0, newLine);
1051+
return lines.join('\n');
1052+
}
1053+
1054+
/**
1055+
* Remove any existing SpoolmanSync block from configuration.yaml.
1056+
* Uses the existing mergeConfiguration logic with empty additions.
1057+
*/
1058+
export function stripSpoolmanSyncConfig(configContent: string): string {
1059+
if (!configContent.includes('# SpoolmanSync Configuration')) {
1060+
return configContent;
1061+
}
1062+
// mergeConfiguration with empty additions removes the old block and appends nothing meaningful
1063+
return mergeConfiguration(configContent, '').trim() + '\n';
1064+
}
1065+
1066+
/**
1067+
* Convert configurationAdditions output into a standalone package file.
1068+
* Strips the leading comment block and adds a package header.
1069+
*/
1070+
export function toPackageFileContent(configurationAdditions: string): string {
1071+
const lines = configurationAdditions.split('\n');
1072+
let firstKeyIndex = 0;
1073+
for (let i = 0; i < lines.length; i++) {
1074+
const trimmed = lines[i].trim();
1075+
if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('=')) {
1076+
firstKeyIndex = i;
1077+
break;
1078+
}
1079+
}
1080+
1081+
return '# SpoolmanSync package — auto-generated, do not edit manually\n' +
1082+
lines.slice(firstKeyIndex).join('\n');
1083+
}

0 commit comments

Comments
 (0)