Skip to content

Commit 610ae1a

Browse files
adityapatwardhandaxian-dbw
authored andcommitted
Update 'Update-Help' to save help content in user scope by default (PowerShell#6352)
Add the parameter `-Scope` to `Update-Help`, which takes `AllUsers` or `CurrentUser`. The default value is `CurrentUser`.
1 parent 260c16f commit 610ae1a

13 files changed

Lines changed: 573 additions & 193 deletions

src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5919,7 +5919,20 @@ private static string GetNamespaceToRemove(CompletionContext context, TypeComple
59195919
internal static List<CompletionResult> CompleteHelpTopics(CompletionContext context)
59205920
{
59215921
var results = new List<CompletionResult>();
5922-
var dirPath = Utils.GetApplicationBase(Utils.DefaultPowerShellShellID) + StringLiterals.DefaultPathSeparator + CultureInfo.CurrentCulture.Name;
5922+
var searchPaths = new List<string>();
5923+
var currentCulture = CultureInfo.CurrentCulture.Name;
5924+
5925+
// Add the user scope path first, since it is searched in order.
5926+
var userHelpRoot = Path.Combine(HelpUtils.GetUserHomeHelpSearchPath(), currentCulture);
5927+
5928+
if(Directory.Exists(userHelpRoot))
5929+
{
5930+
searchPaths.Add(userHelpRoot);
5931+
}
5932+
5933+
var dirPath = Path.Combine(Utils.GetApplicationBase(Utils.DefaultPowerShellShellID), currentCulture);
5934+
searchPaths.Add(dirPath);
5935+
59235936
var wordToComplete = context.WordToComplete + "*";
59245937
var topicPattern = WildcardPattern.Get("about_*.help.txt", WildcardOptions.IgnoreCase);
59255938
List<string> files = new List<string>();
@@ -5928,11 +5941,14 @@ internal static List<CompletionResult> CompleteHelpTopics(CompletionContext cont
59285941
{
59295942
var wildcardPattern = WildcardPattern.Get(wordToComplete, WildcardOptions.IgnoreCase);
59305943

5931-
foreach(var file in Directory.GetFiles(dirPath))
5944+
foreach (var dir in searchPaths)
59325945
{
5933-
if(wildcardPattern.IsMatch(Path.GetFileName(file)))
5946+
foreach (var file in Directory.GetFiles(dir))
59345947
{
5935-
files.Add(file);
5948+
if (wildcardPattern.IsMatch(Path.GetFileName(file)))
5949+
{
5950+
files.Add(file);
5951+
}
59365952
}
59375953
}
59385954
}

src/System.Management.Automation/help/CabinetNativeApi.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,19 @@ internal delegate IntPtr FdiOpenDelegate(
225225
internal static IntPtr FdiOpen(string filename, int oflag, int pmode)
226226
{
227227
FileMode mode = CabinetNativeApi.ConvertOpflagToFileMode(oflag);
228+
228229
FileAccess access = CabinetNativeApi.ConvertPermissionModeToFileAccess(pmode);
229230
FileShare share = CabinetNativeApi.ConvertPermissionModeToFileShare(pmode);
230231

232+
// This method is used for opening the cab file as well as saving the extracted files.
233+
// When we are opening the cab file we only need read permissions.
234+
// We force read permissions so that non-elevated users can extract cab files.
235+
if(mode == FileMode.Open || mode == FileMode.OpenOrCreate)
236+
{
237+
access = FileAccess.Read;
238+
share = FileShare.Read;
239+
}
240+
231241
try
232242
{
233243
FileStream stream = new FileStream(filename, mode, access, share);

src/System.Management.Automation/help/CommandHelpProvider.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,8 @@ private HelpInfo GetHelpInfo(CommandInfo commandInfo, bool reportErrors, bool se
317317
// and the nested module that implements the command
318318
GetModulePaths(commandInfo, out moduleName, out moduleDir, out nestedModulePath);
319319

320-
Collection<String> searchPaths = new Collection<String>();
320+
Collection<String> searchPaths = new Collection<String>(){ HelpUtils.GetUserHomeHelpSearchPath() };
321+
321322
if (!String.IsNullOrEmpty(moduleDir))
322323
{
323324
searchPaths.Add(moduleDir);
@@ -511,14 +512,18 @@ private string GetHelpFile(string helpFile, CmdletInfo cmdletInfo)
511512
// we have to search only in the application base for a mshsnapin...
512513
// if you create an absolute path for helpfile, then MUIFileSearcher
513514
// will look only in that path.
514-
helpFileToLoad = Path.Combine(mshSnapInInfo.ApplicationBase, helpFile);
515+
516+
searchPaths.Add(HelpUtils.GetUserHomeHelpSearchPath());
517+
searchPaths.Add(mshSnapInInfo.ApplicationBase);
515518
}
516519
else if (cmdletInfo.Module != null && !string.IsNullOrEmpty(cmdletInfo.Module.Path))
517520
{
518-
helpFileToLoad = Path.Combine(cmdletInfo.Module.ModuleBase, helpFile);
521+
searchPaths.Add(HelpUtils.GetModuleBaseForUserHelp(cmdletInfo.Module.ModuleBase, cmdletInfo.Module.Name));
522+
searchPaths.Add(cmdletInfo.Module.ModuleBase);
519523
}
520524
else
521525
{
526+
searchPaths.Add(HelpUtils.GetUserHomeHelpSearchPath());
522527
searchPaths.Add(GetDefaultShellSearchPath());
523528
searchPaths.Add(GetCmdletAssemblyPath(cmdletInfo));
524529
}

src/System.Management.Automation/help/HelpFileHelpProvider.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,28 @@ private Collection<string> FilterToLatestModuleVersion(Collection<string> filesM
164164
}
165165
}
166166

167+
// Deduplicate by filename to compensate for two sources, currentuser scope and allusers scope.
168+
// This is done after the version check filtering to ensure we do not remove later version files.
169+
HashSet<string> fileNameHash = new HashSet<string>();
170+
171+
foreach(var file in filesMatched)
172+
{
173+
string fileName = Path.GetFileName(file);
174+
175+
if(!fileNameHash.Contains(fileName))
176+
{
177+
fileNameHash.Add(fileName);
178+
}
179+
else
180+
{
181+
// If the file need to be removed, add it to matchedFilesToRemove, if not already present.
182+
if(!matchedFilesToRemove.Contains(file))
183+
{
184+
matchedFilesToRemove.Add(file);
185+
}
186+
}
187+
}
188+
167189
return matchedFilesToRemove;
168190
}
169191

@@ -323,6 +345,7 @@ internal Collection<string> GetExtendedSearchPaths()
323345

324346
// Add $pshome at the top of the list
325347
String defaultShellSearchPath = GetDefaultShellSearchPath();
348+
326349
int index = searchPaths.IndexOf(defaultShellSearchPath);
327350
if (index != 0)
328351
{
@@ -333,6 +356,9 @@ internal Collection<string> GetExtendedSearchPaths()
333356
searchPaths.Insert(0, defaultShellSearchPath);
334357
}
335358

359+
// Add the CurrentUser help path.
360+
searchPaths.Add(HelpUtils.GetUserHomeHelpSearchPath());
361+
336362
// Add modules that are not loaded. Since using 'get-module -listavailable' is very expensive,
337363
// we load all the directories (which are not empty) under the module path.
338364
foreach (string psModulePath in ModuleIntrinsics.GetModulePath(false, this.HelpSystem.ExecutionContext))
@@ -366,6 +392,7 @@ internal Collection<string> GetExtendedSearchPaths()
366392
catch (System.Security.SecurityException) { }
367393
}
368394
}
395+
369396
return searchPaths;
370397
}
371398

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.IO;
5+
using System.Management.Automation;
6+
using System.Management.Automation.Help;
7+
using Microsoft.PowerShell.Commands;
8+
9+
namespace System.Management.Automation
10+
{
11+
internal class HelpUtils
12+
{
13+
private static string userHomeHelpPath = null;
14+
15+
/// <summary>
16+
/// Get the path to $HOME
17+
/// </summary>
18+
internal static string GetUserHomeHelpSearchPath()
19+
{
20+
if (userHomeHelpPath == null)
21+
{
22+
#if UNIX
23+
var userModuleFolder = Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES);
24+
string userScopeRootPath = System.IO.Path.GetDirectoryName(userModuleFolder);
25+
#else
26+
string userScopeRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell");
27+
#endif
28+
userHomeHelpPath = Path.Combine(userScopeRootPath, "Help");
29+
}
30+
31+
return userHomeHelpPath;
32+
}
33+
34+
internal static string GetModuleBaseForUserHelp(string moduleBase, string moduleName)
35+
{
36+
string newModuleBase = moduleBase;
37+
38+
// In case of inbox modules, the help is put under $PSHOME/<current_culture>,
39+
// since the dlls are not published under individual module folders, but under $PSHome.
40+
// In case of other modules, the help is under moduleBase/<current_culture> or
41+
// under moduleBase/<Version>/<current_culture>.
42+
// The code below creates a similar layout for CurrentUser scope.
43+
// If the the scope is AllUsers, then the help goes under moduleBase.
44+
45+
var userHelpPath = GetUserHomeHelpSearchPath();
46+
string moduleBaseParent = Directory.GetParent(moduleBase).Name;
47+
48+
if (moduleBase.EndsWith(moduleName, StringComparison.OrdinalIgnoreCase))
49+
{
50+
//This module is not an inbox module, so help goes under <userHelpPath>/<moduleName>
51+
newModuleBase = Path.Combine(userHelpPath, moduleName);
52+
}
53+
else if (String.Equals(moduleBaseParent, moduleName, StringComparison.OrdinalIgnoreCase))
54+
{
55+
//This module has version folder.
56+
var moduleVersion = Path.GetFileName(moduleBase);
57+
newModuleBase = Path.Combine(userHelpPath, moduleName, moduleVersion);
58+
}
59+
else
60+
{
61+
//This module is inbox module, help should be under <userHelpPath>
62+
newModuleBase = userHelpPath;
63+
}
64+
65+
return newModuleBase;
66+
}
67+
}
68+
}

src/System.Management.Automation/help/UpdatableHelpCommandBase.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ public SwitchParameter Force
115115
}
116116
internal bool _force;
117117

118+
/// <summary>
119+
/// Sets the scope to which help is saved.
120+
/// </summary>
121+
[Parameter(Mandatory = false, ValueFromPipelineByPropertyName = true)]
122+
public UpdateHelpScope Scope
123+
{
124+
get;
125+
set;
126+
}
127+
118128
#endregion
119129

120130
#region Events
@@ -862,4 +872,19 @@ internal void ProcessException(string moduleName, string culture, Exception e)
862872

863873
#endregion
864874
}
875+
876+
/// <summary>
877+
/// Scope to which the help should be saved.
878+
/// </summary>
879+
public enum UpdateHelpScope
880+
{
881+
/// <summary>
882+
/// Save the help content to the user directory.
883+
CurrentUser,
884+
885+
/// <summary>
886+
/// Save the help content to the module directory. This is the default behavior.
887+
/// </summary>
888+
AllUsers
889+
}
865890
}

src/System.Management.Automation/help/UpdateHelpCommand.cs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,10 +212,17 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module,
212212
UpdatableHelpInfo newHelpInfo = null;
213213
string helpInfoUri = null;
214214

215+
string moduleBase = module.ModuleBase;
216+
217+
if(this.Scope == UpdateHelpScope.CurrentUser)
218+
{
219+
moduleBase = HelpUtils.GetModuleBaseForUserHelp(moduleBase, module.ModuleName);
220+
}
221+
215222
// reading the xml file even if force is specified
216223
// Reason: we need the current version for ShouldProcess
217224
string xml = UpdatableHelpSystem.LoadStringFromPath(this,
218-
SessionState.Path.Combine(module.ModuleBase, module.GetHelpInfoName()),
225+
SessionState.Path.Combine(moduleBase, module.GetHelpInfoName()),
219226
null);
220227

221228
if (xml != null)
@@ -230,7 +237,7 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module,
230237
}
231238

232239
// Don't update too frequently
233-
if (!_alreadyCheckedOncePerDayPerModule && !CheckOncePerDayPerModule(module.ModuleName, module.ModuleBase, module.GetHelpInfoName(), DateTime.UtcNow, _force))
240+
if (!_alreadyCheckedOncePerDayPerModule && !CheckOncePerDayPerModule(module.ModuleName, moduleBase, module.GetHelpInfoName(), DateTime.UtcNow, _force))
234241
{
235242
return true;
236243
}
@@ -347,7 +354,7 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module,
347354
continue;
348355
}
349356

350-
if (Utils.IsUnderProductFolder(module.ModuleBase) && (!Utils.IsAdministrator()))
357+
if (Utils.IsUnderProductFolder(moduleBase) && (!Utils.IsAdministrator()))
351358
{
352359
string message = StringUtil.Format(HelpErrors.UpdatableHelpRequiresElevation);
353360
ProcessException(module.ModuleName, null, new UpdatableHelpSystemException("UpdatableHelpSystemRequiresElevation",
@@ -375,7 +382,12 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module,
375382
// Gather destination paths
376383
Collection<string> destPaths = new Collection<string>();
377384

378-
destPaths.Add(module.ModuleBase);
385+
if(!Directory.Exists(moduleBase))
386+
{
387+
Directory.CreateDirectory(moduleBase);
388+
}
389+
390+
destPaths.Add(moduleBase);
379391

380392
#if !CORECLR // Side-By-Side directories are not present in OneCore environments.
381393
if (IsSystemModule(module.ModuleName) && Environment.Is64BitOperatingSystem)
@@ -442,7 +454,7 @@ internal override bool ProcessModuleWithCulture(UpdatableHelpModuleInfo module,
442454
}
443455

444456
_helpSystem.GenerateHelpInfo(module.ModuleName, module.ModuleGuid, newHelpInfo.UnresolvedUri, contentUri.Culture.Name, newHelpInfo.GetCultureVersion(contentUri.Culture),
445-
module.ModuleBase, module.GetHelpInfoName(), _force);
457+
moduleBase, module.GetHelpInfoName(), _force);
446458

447459
foreach (string fileInstalled in filesInstalled)
448460
{

test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -938,22 +938,29 @@ dir -Recurse `
938938
$completionOptions | Should -Be ([string]::Join("", $expected))
939939
}
940940
}
941-
}
942941

943-
Describe "Tab completion help test" -Tags @('RequireAdminOnWindows', 'CI') {
944-
It 'Should complete about help topic' {
942+
Context "Tab completion help test" {
943+
BeforeAll {
944+
if ([System.Management.Automation.Platform]::IsWindows) {
945+
$userHelpRoot = Join-Path $HOME "Documents/PowerShell/Help/"
946+
} else {
947+
$userModulesRoot = [System.Management.Automation.Platform]::SelectProductNameForDirectory([System.Management.Automation.Platform+XDG_Type]::USER_MODULES)
948+
$userHelpRoot = Join-Path $userModulesRoot -ChildPath ".." -AdditionalChildPath "Help"
949+
}
950+
}
945951

946-
$aboutHelpPath = Join-Path $PSHOME (Get-Culture).Name
952+
It 'Should complete about help topic' {
953+
$aboutHelpPath = Join-Path $userHelpRoot (Get-Culture).Name
947954

948-
## If help content does not exist, tab completion will not work. So update it first.
949-
if (-not (Test-Path (Join-Path $aboutHelpPath "about_Splatting.help.txt")))
950-
{
951-
Update-Help -Force -ErrorAction SilentlyContinue
952-
}
955+
## If help content does not exist, tab completion will not work. So update it first.
956+
if (-not (Test-Path (Join-Path $aboutHelpPath "about_Splatting.help.txt"))) {
957+
Update-Help -Force -ErrorAction SilentlyContinue -Scope 'CurrentUser'
958+
}
953959

954-
$res = TabExpansion2 -inputScript 'get-help about_spla' -cursorColumn 'get-help about_spla'.Length
955-
$res.CompletionMatches.Count | Should -Be 1
956-
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'about_Splatting'
960+
$res = TabExpansion2 -inputScript 'get-help about_spla' -cursorColumn 'get-help about_spla'.Length
961+
$res.CompletionMatches.Count | Should -Be 1
962+
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'about_Splatting'
963+
}
957964
}
958965
}
959966

0 commit comments

Comments
 (0)