Skip to content

Commit b98535f

Browse files
jmcouffinCopilotCopilotcursoragent
authored
Installers: Fix config split, C# loader extension gating, and installer/CLI discovery (#3452)
* Implement configuration migration for admin installs and refactor config file handling. Added methods to migrate admin config files to ProgramData and check for clones. Updated IsInstallAllUsers method to use PyRevitInstallScope. Adjusted paths for config file retrieval in PyRevitConfig and PyRevitConfig.csproj. Enhanced pyRevit installation script to create all-users marker and execute post-install commands. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: update PyRevitConfig Load install-scope XML docs * fix(admin-installer): close RunPyRevitCommand and drop bin wxs noise Restore release/pyrevit-bin.wxs to develop; Heat output was unrelated to the admin config-path fix. Add the missing end; so ISCC compiles the admin installer script again. Co-authored-by: Cursor <cursoragent@cursor.com> * Add test path overrides for install-scope config paths * fix(admin-installer): resolve config path split for all-users install (#3441) Admin installer ran pyrevit post-install before the install_all_users marker existed, so clone registry landed in AppData while CLI and loader read ProgramData. - Create marker first, then run post-install CLI from ssPostInstall in pyrevit-admin.iss - Add PyRevitInstallScope shared helper; align loader, CLI, and Python config selection - Migrate split AppData clones to ProgramData for broken existing installs - Unit tests with path overrides; MsgBox on critical post-install failures Co-authored-by: Cursor <cursoragent@cursor.com> * feat(config): add seedshippeddefaults command and improve config handling - Introduced `seedshippeddefaults` command to write disabled flags for shipped extensions with `default_enabled=False`. - Enhanced `PyRevitInstallScope` to manage active configuration file paths and improve detection of install scope. - Updated various components to ensure consistent handling of configuration files across user and admin installs. - Added unit tests to validate new functionality and ensure proper migration of configuration settings. Co-authored-by: Cursor <cursoragent@cursor.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 66c29ad commit b98535f

20 files changed

Lines changed: 608 additions & 164 deletions

dev/pyRevitLabs/pyRevitCLI/PyRevitCLI.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,9 @@ private static void ProcessArguments() {
859859
else if (all("seed"))
860860
PyRevitConfigs.SeedConfig(lockSeedConfig: arguments["--lock"].IsTrue);
861861

862+
else if (all("seedshippeddefaults"))
863+
PyRevitConfigs.SeedShippedExtensionDefaults();
864+
862865
else if (any("enable", "disable")) {
863866
if (arguments["<option_path>"] != null) {
864867
// extract section and option names

dev/pyRevitLabs/pyRevitCLI/PyRevitCLIAppCmds.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ internal static void
157157
Environment.UserDomainName, Environment.UserName));
158158
Console.WriteLine(string.Format("Active User: {0}", UserEnv.GetLoggedInUserName()));
159159
Console.WriteLine(string.Format("Admin Access: {0}", UserEnv.IsRunAsAdmin() ? "Yes" : "No"));
160+
Console.WriteLine(string.Format("Install Scope: {0}",
161+
PyRevitInstallScope.IsAllUsersInstall() ? "AllUsers" : "PerUser"));
162+
Console.WriteLine(string.Format("Active Config: \"{0}\"",
163+
PyRevitInstallScope.GetActiveConfigFilePath()));
160164
Console.WriteLine(string.Format("%APPDATA%: \"{0}\"",
161165
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)));
162166
Console.WriteLine(string.Format("Latest Installed .Net Framework: {0}",

dev/pyRevitLabs/pyRevitCLI/PyRevitCLIAppHelps.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ internal static void PrintHelp(PyRevitCLICommandType commandType, int exitCode)
329329
header: "Manage pyRevit configurations",
330330
mgmtCommands: new Dictionary<string, string>() {
331331
{ "seed", "Seed existing configuration file to %PROGRAMDATA%" },
332+
{ "seedshippeddefaults", "Write disabled flags for shipped extensions with default_enabled=False" },
332333
{ "routes", "Routes configurations" },
333334
{ "telemetry", "Script Telemetry configurations" },
334335
{ "apptelemetry", "Application Telemetry configurations" },

dev/pyRevitLabs/pyRevitCLI/PyRevitCLIExtensionCmds.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ internal static void
175175
if (extName != null) {
176176
PyRevitClone clone = null;
177177
if (cloneName != null)
178-
clone = PyRevitClones.GetRegisteredClone(cloneName);
178+
clone = TryResolveShippedClone(cloneName);
179179

180180
if (enable) {
181181
if (clone != null)
@@ -192,6 +192,30 @@ internal static void
192192
}
193193
}
194194

195+
private static PyRevitClone TryResolveShippedClone(string cloneName) {
196+
try {
197+
return PyRevitClones.GetRegisteredClone(cloneName);
198+
}
199+
catch {
200+
PyRevitClone fallbackClone = null;
201+
foreach (var attachment in PyRevitAttachments.GetAttachments()) {
202+
try {
203+
var manifestClone = PyRevitClone.GetCloneFromManifest(attachment.Manifest);
204+
if (manifestClone.Matches(cloneName))
205+
return manifestClone;
206+
if (fallbackClone == null)
207+
fallbackClone = manifestClone;
208+
}
209+
catch {
210+
continue;
211+
}
212+
}
213+
if (fallbackClone != null)
214+
return new PyRevitClone(fallbackClone.ClonePath, name: cloneName);
215+
return null;
216+
}
217+
}
218+
195219
internal static void
196220
ForgetExtensionLookupSources(bool all, string lookupPath) {
197221
if (all)

dev/pyRevitLabs/pyRevitCLI/Resources/UsagePatterns.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ Usage:
102102
pyrevit configs apptelemetry server [<server_path>] [--log=<log_file>]
103103
pyrevit configs outputcss [<css_path>] [--log=<log_file>]
104104
pyrevit configs seed [--lock] [--log=<log_file>]
105+
pyrevit configs seedshippeddefaults [--log=<log_file>]
105106
pyrevit configs <option_path> [(enable | disable)] [--log=<log_file>]
106107
pyrevit configs <option_path> [<option_value>] [--log=<log_file>]
107108
pyrevit doctor (-h | --help)
Lines changed: 173 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,58 @@
11
using System;
22
using System.IO;
3+
using System.Reflection;
4+
using System.Text.RegularExpressions;
35

46
namespace pyRevitLabs.Common {
57
/// <summary>
6-
/// Resolves pyRevit install scope (per-user vs all-users admin install).
7-
/// The admin installer creates %ProgramData%\pyRevit\install_all_users before
8-
/// any post-install CLI commands so clone registry and config stay in ProgramData.
8+
/// Resolves pyRevit install scope (per-user vs machine-wide admin install) and
9+
/// the active configuration file path shared by CLI, Revit Python, and the C# loader.
910
/// </summary>
1011
public static class PyRevitInstallScope {
12+
public const string ConfigScopeEnvVar = "PYREVIT_CONFIG_SCOPE";
13+
public const string ConfigScopeAllUsers = "allusers";
14+
public const string ConfigScopePerUser = "peruser";
15+
16+
private const string PyRevitfileName = "pyRevitfile";
17+
private const string ConfigIniRegexPattern = @".*(pyrevit|config).*\.ini";
18+
1119
private static bool? _isInstallAllUsers;
20+
private static string _runtimeInstallRoot;
21+
22+
/// <summary>
23+
/// Optional install root set by the Revit session (attached clone path).
24+
/// Clears cached scope when updated.
25+
/// </summary>
26+
public static void SetRuntimeInstallRoot(string installRoot) {
27+
_runtimeInstallRoot = string.IsNullOrWhiteSpace(installRoot)
28+
? null
29+
: installRoot.Trim();
30+
ClearCachedInstallScope();
31+
}
1232

1333
public static bool IsAllUsersInstall() {
1434
if (_isInstallAllUsers.HasValue)
1535
return _isInstallAllUsers.Value;
16-
string markerPath = Path.Combine(
17-
PyRevitLabsConsts.PyRevitProgramDataPath,
18-
PyRevitLabsConsts.InstallAllUsersMarkerFileName);
19-
_isInstallAllUsers = File.Exists(markerPath);
36+
37+
var scopeEnv = Environment.GetEnvironmentVariable(ConfigScopeEnvVar);
38+
if (!string.IsNullOrWhiteSpace(scopeEnv)) {
39+
if (scopeEnv.Equals(ConfigScopeAllUsers, StringComparison.OrdinalIgnoreCase)) {
40+
_isInstallAllUsers = true;
41+
return true;
42+
}
43+
if (scopeEnv.Equals(ConfigScopePerUser, StringComparison.OrdinalIgnoreCase)) {
44+
_isInstallAllUsers = false;
45+
return false;
46+
}
47+
}
48+
49+
if (LegacyMarkerExists()) {
50+
_isInstallAllUsers = true;
51+
return true;
52+
}
53+
54+
var installRoot = ResolveInstallRoot();
55+
_isInstallAllUsers = IsMachineInstallPath(installRoot);
2056
return _isInstallAllUsers.Value;
2157
}
2258

@@ -26,9 +62,138 @@ public static string GetConfigDirectory() {
2662
: PyRevitLabsConsts.PyRevitPath;
2763
}
2864

29-
/// <summary>Clears cached install-scope detection (for tests).</summary>
65+
/// <summary>
66+
/// Active pyRevit configuration file for the current install scope.
67+
/// Creates an empty default file when missing so callers can persist settings.
68+
/// </summary>
69+
public static string GetActiveConfigFilePath(bool createIfMissing = true) {
70+
var configRoot = GetConfigDirectory();
71+
var discovered = FindConfigIniInDirectory(configRoot);
72+
var finalPath = discovered ?? Path.Combine(configRoot, PyRevitLabsConsts.DefaultConfigsFileName);
73+
74+
if (createIfMissing && !File.Exists(finalPath)) {
75+
var directory = Path.GetDirectoryName(finalPath);
76+
if (!string.IsNullOrEmpty(directory))
77+
Directory.CreateDirectory(directory);
78+
File.Create(finalPath).Dispose();
79+
}
80+
81+
return finalPath;
82+
}
83+
84+
public static string FindConfigIniInDirectory(string directory) {
85+
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
86+
return null;
87+
88+
try {
89+
var configMatcher = new Regex(ConfigIniRegexPattern, RegexOptions.IgnoreCase);
90+
foreach (var fullPath in Directory.GetFiles(directory, "*.ini", SearchOption.TopDirectoryOnly)) {
91+
if (configMatcher.IsMatch(Path.GetFileName(fullPath)))
92+
return fullPath;
93+
}
94+
}
95+
catch {
96+
// ignore
97+
}
98+
99+
return null;
100+
}
101+
102+
/// <summary>Clears cached install-scope detection (for tests and reload).</summary>
30103
public static void ClearCachedInstallScope() {
31104
_isInstallAllUsers = null;
32105
}
106+
107+
private static bool LegacyMarkerExists() {
108+
string markerPath = Path.Combine(
109+
PyRevitLabsConsts.PyRevitProgramDataPath,
110+
PyRevitLabsConsts.InstallAllUsersMarkerFileName);
111+
return File.Exists(markerPath);
112+
}
113+
114+
private static string ResolveInstallRoot() {
115+
if (!string.IsNullOrWhiteSpace(_runtimeInstallRoot))
116+
return _runtimeInstallRoot;
117+
118+
string assemblyPath = null;
119+
try {
120+
assemblyPath = Assembly.GetExecutingAssembly().Location;
121+
}
122+
catch {
123+
// ignore
124+
}
125+
126+
if (string.IsNullOrWhiteSpace(assemblyPath))
127+
return null;
128+
129+
return FindInstallRootFromPath(Path.GetDirectoryName(assemblyPath));
130+
}
131+
132+
internal static string FindInstallRootFromPath(string startDirectory) {
133+
if (string.IsNullOrWhiteSpace(startDirectory))
134+
return null;
135+
136+
var dir = startDirectory;
137+
for (int depth = 0; depth < 8 && !string.IsNullOrEmpty(dir); depth++) {
138+
if (File.Exists(Path.Combine(dir, PyRevitfileName)))
139+
return dir;
140+
141+
if (string.Equals(Path.GetFileName(dir), "bin", StringComparison.OrdinalIgnoreCase)) {
142+
var parent = Path.GetDirectoryName(dir);
143+
if (parent != null && File.Exists(Path.Combine(parent, PyRevitfileName)))
144+
return parent;
145+
}
146+
147+
dir = Path.GetDirectoryName(dir);
148+
}
149+
150+
if (string.Equals(Path.GetFileName(startDirectory), "bin", StringComparison.OrdinalIgnoreCase))
151+
return Path.GetDirectoryName(startDirectory);
152+
153+
return startDirectory;
154+
}
155+
156+
internal static bool IsMachineInstallPath(string installRoot) {
157+
if (string.IsNullOrWhiteSpace(installRoot))
158+
return false;
159+
160+
string normalized;
161+
try {
162+
normalized = Path.GetFullPath(installRoot)
163+
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
164+
}
165+
catch {
166+
return false;
167+
}
168+
169+
var machineRoots = new[] {
170+
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
171+
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
172+
Environment.GetFolderPath(Environment.SpecialFolder.CommonProgramFiles),
173+
};
174+
175+
foreach (var root in machineRoots) {
176+
if (string.IsNullOrWhiteSpace(root))
177+
continue;
178+
179+
string normRoot;
180+
try {
181+
normRoot = Path.GetFullPath(root)
182+
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
183+
}
184+
catch {
185+
continue;
186+
}
187+
188+
if (string.Equals(normalized, normRoot, StringComparison.OrdinalIgnoreCase))
189+
return true;
190+
191+
var prefix = normRoot + Path.DirectorySeparatorChar;
192+
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
193+
return true;
194+
}
195+
196+
return false;
197+
}
33198
}
34199
}

dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitAttachment.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ public override string ToString() {
4040
);
4141
}
4242
else {
43+
var manifestClone = Clone;
44+
if (manifestClone != null) {
45+
return string.Format(
46+
"{0} (unregistered) | Product: \"{1}\" | Path: \"{2}\" {3}",
47+
manifestClone.Name,
48+
Product.Name,
49+
manifestClone.ClonePath,
50+
AllUsers ? "| AllUsers" : ""
51+
);
52+
}
4353
return string.Format(
4454
"Unknown | Product: \"{0}\" | Manifest: \"{1}\"",
4555
Product.Name,

dev/pyRevitLabs/pyRevitLabs.PyRevit/PyRevitConfig.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,14 @@ public void SaveConfigFile() {
4848
return;
4949
}
5050

51-
logger.Debug("Saving config file \"{0}\"", PyRevitConsts.ConfigFilePath);
51+
logger.Debug("Saving config file \"{0}\"", ConfigFilePath);
5252
try {
53-
_config.Save(PyRevitConsts.ConfigFilePath);
53+
_config.Save(ConfigFilePath);
5454
}
5555
catch (Exception ex)
5656
{
5757
throw new PyRevitException(
58-
$"Failed to save config to \"{PyRevitConsts.ConfigFilePath}\". | {ex.Message}");
58+
$"Failed to save config to \"{ConfigFilePath}\". | {ex.Message}");
5959
}
6060
}
6161

@@ -152,5 +152,25 @@ public bool DeleteValue(string sectionName, string keyName) {
152152
return false;
153153
}
154154
}
155+
156+
public bool HasSection(string sectionName) {
157+
return _config.Sections.Contains(sectionName);
158+
}
159+
160+
public List<string> GetSectionNames() {
161+
var names = new List<string>();
162+
foreach (var section in _config.Sections)
163+
names.Add(section.Name);
164+
return names;
165+
}
166+
167+
public List<string> GetSectionKeyNames(string sectionName) {
168+
var keys = new List<string>();
169+
if (!_config.Sections.Contains(sectionName))
170+
return keys;
171+
foreach (var key in _config.Sections[sectionName].Keys)
172+
keys.Add(key.Name);
173+
return keys;
174+
}
155175
}
156176
}

0 commit comments

Comments
 (0)