@@ -787,17 +787,32 @@ func (s *service) applyGitInstallExisting(
787787 files []gitresolver.FileEntry ,
788788) (* skills.InstallResult , error ) {
789789 if existing .Digest != opts .Digest {
790- return s .gitWriteMultiAndPersist (ctx , opts , scope , clientTypes , clientDirs , files ,
791- clientTypes , existing .Clients , true , true )
790+ allClients , allDirs , err := s .expandToExistingClients (
791+ existing .Clients , clientTypes , clientDirs , opts .Name , scope , opts .ProjectRoot )
792+ if err != nil {
793+ return nil , err
794+ }
795+ return s .gitWriteMultiAndPersist (ctx , opts , scope , allClients , allDirs , files ,
796+ allClients , nil , true , true )
792797 }
798+ clientsExplicit := len (opts .Clients ) > 0
793799 if clientsContainAll (existing .Clients , clientTypes ) ||
794- (len (existing .Clients ) == 0 && len (clientTypes ) <= 1 ) {
800+ (len (existing .Clients ) == 0 && len (clientTypes ) <= 1 && ! clientsExplicit ) {
795801 return & skills.InstallResult {Skill : existing }, nil
796802 }
797803 toWrite := missingClients (existing .Clients , clientTypes )
798804 if len (toWrite ) == 0 {
799805 return & skills.InstallResult {Skill : existing }, nil
800806 }
807+ for _ , ct := range toWrite {
808+ dir := clientDirs [ct ]
809+ if _ , statErr := os .Stat (dir ); statErr == nil && ! opts .Force {
810+ return nil , httperr .WithCode (
811+ fmt .Errorf ("directory %q exists but is not managed by ToolHive; use force to overwrite" , dir ),
812+ http .StatusConflict ,
813+ )
814+ }
815+ }
801816 return s .gitWriteMultiAndPersist (ctx , opts , scope , clientTypes , clientDirs , files ,
802817 toWrite , existing .Clients , true , false )
803818}
@@ -1240,6 +1255,35 @@ func (s *service) resolveAndValidateClients(
12401255 return requested , paths , nil
12411256}
12421257
1258+ // expandToExistingClients merges existingClients into requestedClients and
1259+ // resolves paths for any existing client not already in clientDirs. This
1260+ // ensures upgrades write new files to all clients, not just the requested set.
1261+ func (s * service ) expandToExistingClients (
1262+ existingClients , requestedClients []string ,
1263+ clientDirs map [string ]string ,
1264+ skillName string , scope skills.Scope , projectRoot string ,
1265+ ) ([]string , map [string ]string , error ) {
1266+ allClients := mergeClientLists (requestedClients , existingClients )
1267+ allDirs := make (map [string ]string , len (allClients ))
1268+ for k , v := range clientDirs {
1269+ allDirs [k ] = v
1270+ }
1271+ for _ , ct := range allClients {
1272+ if _ , ok := allDirs [ct ]; ok {
1273+ continue
1274+ }
1275+ dir , err := s .pathResolver .GetSkillPath (ct , skillName , scope , projectRoot )
1276+ if err != nil {
1277+ return nil , nil , fmt .Errorf ("resolving skill path for existing client %q: %w" , ct , err )
1278+ }
1279+ if err := validateResolvedDir (dir ); err != nil {
1280+ return nil , nil , fmt .Errorf ("resolved path for client %q is unsafe: %w" , ct , err )
1281+ }
1282+ allDirs [ct ] = dir
1283+ }
1284+ return allClients , allDirs , nil
1285+ }
1286+
12431287// validateResolvedDir ensures a directory path returned by the PathResolver is
12441288// clean, absolute, and free of path-traversal segments. This provides
12451289// defense-in-depth against tainted paths reaching filesystem operations.
@@ -1316,28 +1360,36 @@ func (s *service) installWithExtraction(
13161360 return nil , fmt .Errorf ("checking existing skill: %w" , storeErr )
13171361 }
13181362
1319- digestMatches := storeErr == nil && existing .Digest == opts .Digest
1320- // Legacy rows may have an empty Clients slice; treat as satisfied for a
1321- // single-target install (previous behavior). Multi-client requests must
1322- // still merge onto disk when the store has no client list.
1323- allRequestedPresent := digestMatches && (clientsContainAll (existing .Clients , clientTypes ) ||
1324- (len (existing .Clients ) == 0 && len (clientTypes ) <= 1 ))
1325-
1326- if digestMatches && allRequestedPresent {
1363+ if isExtractionNoOp (existing , storeErr , opts , clientTypes ) {
13271364 return & skills.InstallResult {Skill : existing }, nil
13281365 }
13291366
1367+ digestMatches := storeErr == nil && existing .Digest == opts .Digest
13301368 if digestMatches && storeErr == nil {
13311369 return s .installExtractionSameDigestNewClients (ctx , opts , scope , existing , clientTypes , clientDirs )
13321370 }
13331371
1334- if storeErr == nil && ! digestMatches {
1372+ if storeErr == nil {
13351373 return s .installExtractionUpgradeDigest (ctx , opts , scope , existing , clientTypes , clientDirs )
13361374 }
13371375
13381376 return s .installExtractionFresh (ctx , opts , scope , clientTypes , clientDirs )
13391377}
13401378
1379+ // isExtractionNoOp reports whether the install can be short-circuited because
1380+ // the same digest and all requested clients are already present. Legacy store
1381+ // rows (empty Clients slice) are treated as satisfied only when the user did
1382+ // not explicitly specify --clients.
1383+ func isExtractionNoOp (existing skills.InstalledSkill , storeErr error , opts skills.InstallOptions , clientTypes []string ) bool {
1384+ if storeErr != nil || existing .Digest != opts .Digest {
1385+ return false
1386+ }
1387+ if clientsContainAll (existing .Clients , clientTypes ) {
1388+ return true
1389+ }
1390+ return len (existing .Clients ) == 0 && len (clientTypes ) <= 1 && len (opts .Clients ) == 0
1391+ }
1392+
13411393func (s * service ) installExtractionSameDigestNewClients (
13421394 ctx context.Context ,
13431395 opts skills.InstallOptions ,
@@ -1388,20 +1440,23 @@ func (s *service) installExtractionUpgradeDigest(
13881440 clientTypes []string ,
13891441 clientDirs map [string ]string ,
13901442) (* skills.InstallResult , error ) {
1443+ allClients , allDirs , err := s .expandToExistingClients (
1444+ existing .Clients , clientTypes , clientDirs , opts .Name , scope , opts .ProjectRoot )
1445+ if err != nil {
1446+ return nil , err
1447+ }
13911448 var written []string
1392- for _ , ct := range clientTypes {
1393- dir := clientDirs [ct ]
1449+ for _ , ct := range allClients {
1450+ dir := allDirs [ct ]
13941451 if _ , exErr := s .installer .Extract (opts .LayerData , dir , true ); exErr != nil {
1395- removeSkillDirs (s .installer , clientDirs , written )
1452+ removeSkillDirs (s .installer , allDirs , written )
13961453 return nil , fmt .Errorf ("extracting skill upgrade: %w" , exErr )
13971454 }
13981455 written = append (written , ct )
13991456 }
1400- sk := buildInstalledSkill (opts , scope , clientTypes , existing . Clients )
1457+ sk := buildInstalledSkill (opts , scope , allClients , nil )
14011458 if err := s .store .Update (ctx , sk ); err != nil {
1402- for _ , ct := range clientTypes {
1403- _ = s .installer .Remove (clientDirs [ct ])
1404- }
1459+ removeSkillDirs (s .installer , allDirs , allClients )
14051460 return nil , err
14061461 }
14071462 return & skills.InstallResult {Skill : sk }, nil
0 commit comments