@@ -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 ( / ^ h o m e a s s i s t a n t \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 ( / ^ p a c k a g e s \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 ( / ^ ! i n c l u d e _ d i r _ (?: n a m e d | m e r g e _ n a m e d ) \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 ( / ^ h o m e a s s i s t a n t \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