napt 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napt/__init__.py +91 -0
- napt/build/__init__.py +47 -0
- napt/build/manager.py +1087 -0
- napt/build/packager.py +315 -0
- napt/build/template.py +301 -0
- napt/cli.py +602 -0
- napt/config/__init__.py +42 -0
- napt/config/loader.py +465 -0
- napt/core.py +385 -0
- napt/detection.py +630 -0
- napt/discovery/__init__.py +86 -0
- napt/discovery/api_github.py +445 -0
- napt/discovery/api_json.py +452 -0
- napt/discovery/base.py +244 -0
- napt/discovery/url_download.py +304 -0
- napt/discovery/web_scrape.py +467 -0
- napt/exceptions.py +149 -0
- napt/io/__init__.py +42 -0
- napt/io/download.py +357 -0
- napt/io/upload.py +37 -0
- napt/logging.py +230 -0
- napt/policy/__init__.py +50 -0
- napt/policy/updates.py +126 -0
- napt/psadt/__init__.py +43 -0
- napt/psadt/release.py +309 -0
- napt/requirements.py +566 -0
- napt/results.py +143 -0
- napt/state/__init__.py +58 -0
- napt/state/tracker.py +371 -0
- napt/validation.py +467 -0
- napt/versioning/__init__.py +115 -0
- napt/versioning/keys.py +309 -0
- napt/versioning/msi.py +725 -0
- napt-0.3.1.dist-info/METADATA +114 -0
- napt-0.3.1.dist-info/RECORD +38 -0
- napt-0.3.1.dist-info/WHEEL +4 -0
- napt-0.3.1.dist-info/entry_points.txt +3 -0
- napt-0.3.1.dist-info/licenses/LICENSE +202 -0
napt/build/packager.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# Copyright 2025 Roger Cibrian
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
""".intunewin package generation for NAPT.
|
|
16
|
+
|
|
17
|
+
This module handles creating .intunewin packages from built PSADT directories
|
|
18
|
+
using Microsoft's IntuneWinAppUtil.exe tool.
|
|
19
|
+
|
|
20
|
+
Design Principles:
|
|
21
|
+
- IntuneWinAppUtil.exe is cached globally (not per-build)
|
|
22
|
+
- Package output is named by IntuneWinAppUtil.exe: Invoke-AppDeployToolkit.intunewin
|
|
23
|
+
- Build directory can optionally be cleaned after packaging
|
|
24
|
+
- Tool is downloaded from Microsoft's official GitHub repository
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
Basic usage:
|
|
28
|
+
```python
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from napt.build.packager import create_intunewin
|
|
31
|
+
|
|
32
|
+
result = create_intunewin(
|
|
33
|
+
build_dir=Path("builds/napt-chrome/141.0.7390.123"),
|
|
34
|
+
output_dir=Path("packages")
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
print(f"Package: {result.package_path}")
|
|
38
|
+
```
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
import requests
|
|
48
|
+
|
|
49
|
+
from napt.exceptions import ConfigError, NetworkError, PackagingError
|
|
50
|
+
from napt.results import PackageResult
|
|
51
|
+
|
|
52
|
+
# TODO: Add version tracking for IntuneWinAppUtil.exe
|
|
53
|
+
# Currently downloads from master branch (always latest), with no version tracking.
|
|
54
|
+
# Future enhancements:
|
|
55
|
+
# - Track tool version in cache metadata
|
|
56
|
+
# - Allow pinning to specific commit/release
|
|
57
|
+
# - Auto-detect when tool updates are available
|
|
58
|
+
# - Optional: Add config setting for tool version/source
|
|
59
|
+
INTUNEWIN_TOOL_URL = (
|
|
60
|
+
"https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool"
|
|
61
|
+
"/raw/master/IntuneWinAppUtil.exe"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _verify_build_structure(build_dir: Path) -> None:
|
|
66
|
+
"""Verify that the build directory has a valid PSADT structure.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
build_dir: Build directory to verify.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If required files/directories are missing.
|
|
73
|
+
"""
|
|
74
|
+
required = [
|
|
75
|
+
"PSAppDeployToolkit",
|
|
76
|
+
"Files",
|
|
77
|
+
"Invoke-AppDeployToolkit.ps1",
|
|
78
|
+
"Invoke-AppDeployToolkit.exe",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
missing = []
|
|
82
|
+
for item in required:
|
|
83
|
+
if not (build_dir / item).exists():
|
|
84
|
+
missing.append(item)
|
|
85
|
+
|
|
86
|
+
if missing:
|
|
87
|
+
raise ConfigError(
|
|
88
|
+
f"Invalid PSADT build directory: {build_dir}\n"
|
|
89
|
+
f"Missing: {', '.join(missing)}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_intunewin_tool(cache_dir: Path) -> Path:
|
|
94
|
+
"""Download and cache IntuneWinAppUtil.exe.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
cache_dir: Directory to cache the tool.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Path to the IntuneWinAppUtil.exe tool.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
NetworkError: If download fails.
|
|
104
|
+
"""
|
|
105
|
+
from napt.logging import get_global_logger
|
|
106
|
+
|
|
107
|
+
logger = get_global_logger()
|
|
108
|
+
tool_path = cache_dir / "IntuneWinAppUtil.exe"
|
|
109
|
+
|
|
110
|
+
if tool_path.exists():
|
|
111
|
+
logger.verbose("PACKAGE", f"Using cached IntuneWinAppUtil: {tool_path}")
|
|
112
|
+
return tool_path
|
|
113
|
+
|
|
114
|
+
logger.verbose("PACKAGE", "Downloading IntuneWinAppUtil.exe...")
|
|
115
|
+
|
|
116
|
+
# Download the tool
|
|
117
|
+
try:
|
|
118
|
+
response = requests.get(INTUNEWIN_TOOL_URL, timeout=60)
|
|
119
|
+
response.raise_for_status()
|
|
120
|
+
except requests.RequestException as err:
|
|
121
|
+
raise NetworkError(f"Failed to download IntuneWinAppUtil.exe: {err}") from err
|
|
122
|
+
|
|
123
|
+
# Save to cache
|
|
124
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
tool_path.write_bytes(response.content)
|
|
126
|
+
|
|
127
|
+
logger.verbose("PACKAGE", f"[OK] IntuneWinAppUtil.exe cached: {tool_path}")
|
|
128
|
+
|
|
129
|
+
return tool_path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _execute_packaging(
|
|
133
|
+
tool_path: Path,
|
|
134
|
+
source_dir: Path,
|
|
135
|
+
setup_file: str,
|
|
136
|
+
output_dir: Path,
|
|
137
|
+
) -> Path:
|
|
138
|
+
"""Execute IntuneWinAppUtil.exe to create .intunewin package.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
tool_path: Path to IntuneWinAppUtil.exe.
|
|
142
|
+
source_dir: Source directory (build directory).
|
|
143
|
+
setup_file: Name of the setup file (e.g., "Invoke-AppDeployToolkit.exe").
|
|
144
|
+
output_dir: Output directory for .intunewin file.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Path to the created .intunewin file.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
PackagingError: If packaging fails.
|
|
151
|
+
"""
|
|
152
|
+
from napt.logging import get_global_logger
|
|
153
|
+
|
|
154
|
+
logger = get_global_logger()
|
|
155
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
|
|
157
|
+
# Build command
|
|
158
|
+
# IntuneWinAppUtil.exe -c <source> -s <setup file> -o <output> -q
|
|
159
|
+
cmd = [
|
|
160
|
+
str(tool_path),
|
|
161
|
+
"-c",
|
|
162
|
+
str(source_dir),
|
|
163
|
+
"-s",
|
|
164
|
+
setup_file,
|
|
165
|
+
"-o",
|
|
166
|
+
str(output_dir),
|
|
167
|
+
"-q", # Quiet mode
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
logger.verbose("PACKAGE", f"Running: {' '.join(cmd)}")
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
result = subprocess.run(
|
|
174
|
+
cmd,
|
|
175
|
+
capture_output=True,
|
|
176
|
+
text=True,
|
|
177
|
+
check=True,
|
|
178
|
+
timeout=300,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if result.stdout:
|
|
182
|
+
for line in result.stdout.strip().split("\n"):
|
|
183
|
+
logger.verbose("PACKAGE", f" {line}")
|
|
184
|
+
|
|
185
|
+
except subprocess.CalledProcessError as err:
|
|
186
|
+
error_msg = f"IntuneWinAppUtil.exe failed (exit code {err.returncode})"
|
|
187
|
+
if err.stderr:
|
|
188
|
+
error_msg += f"\n{err.stderr}"
|
|
189
|
+
raise PackagingError(error_msg) from err
|
|
190
|
+
except subprocess.TimeoutExpired as err:
|
|
191
|
+
raise PackagingError(
|
|
192
|
+
f"IntuneWinAppUtil.exe timed out after {err.timeout}s"
|
|
193
|
+
) from err
|
|
194
|
+
|
|
195
|
+
# Find the generated .intunewin file
|
|
196
|
+
intunewin_files = list(output_dir.glob("*.intunewin"))
|
|
197
|
+
|
|
198
|
+
if not intunewin_files:
|
|
199
|
+
raise PackagingError(
|
|
200
|
+
f"IntuneWinAppUtil.exe completed but no .intunewin file found in {output_dir}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Return the most recently created file
|
|
204
|
+
intunewin_path = max(intunewin_files, key=lambda p: p.stat().st_mtime)
|
|
205
|
+
logger.verbose("PACKAGE", f"[OK] Created: {intunewin_path.name}")
|
|
206
|
+
|
|
207
|
+
return intunewin_path
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_intunewin(
|
|
211
|
+
build_dir: Path,
|
|
212
|
+
output_dir: Path | None = None,
|
|
213
|
+
clean_source: bool = False,
|
|
214
|
+
) -> PackageResult:
|
|
215
|
+
"""Create a .intunewin package from a PSADT build directory.
|
|
216
|
+
|
|
217
|
+
Uses Microsoft's IntuneWinAppUtil.exe tool to package a PSADT build
|
|
218
|
+
directory into a .intunewin file suitable for Intune deployment.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
build_dir: Path to the built PSADT package directory.
|
|
222
|
+
output_dir: Directory for the .intunewin output.
|
|
223
|
+
Default: packages/{app_id}/
|
|
224
|
+
clean_source: If True, remove the build directory
|
|
225
|
+
after packaging. Default is False.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Package metadata including .intunewin path, app ID, and version.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ConfigError: If build directory structure is invalid.
|
|
232
|
+
PackagingError: If packaging fails.
|
|
233
|
+
NetworkError: If IntuneWinAppUtil.exe download fails.
|
|
234
|
+
|
|
235
|
+
Example:
|
|
236
|
+
Basic packaging:
|
|
237
|
+
```python
|
|
238
|
+
result = create_intunewin(
|
|
239
|
+
build_dir=Path("builds/napt-chrome/141.0.7390.123")
|
|
240
|
+
)
|
|
241
|
+
print(result.package_path)
|
|
242
|
+
# packages/napt-chrome/Invoke-AppDeployToolkit.intunewin
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
With cleanup:
|
|
246
|
+
```python
|
|
247
|
+
result = create_intunewin(
|
|
248
|
+
build_dir=Path("builds/napt-chrome/141.0.7390.123"),
|
|
249
|
+
clean_source=True
|
|
250
|
+
)
|
|
251
|
+
# Build directory is removed after packaging
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Note:
|
|
255
|
+
Requires build directory from 'napt build' command. IntuneWinAppUtil.exe
|
|
256
|
+
is downloaded and cached on first use. Setup file is always
|
|
257
|
+
"Invoke-AppDeployToolkit.exe". Output file is named by IntuneWinAppUtil.exe:
|
|
258
|
+
packages/{app_id}/Invoke-AppDeployToolkit.intunewin
|
|
259
|
+
"""
|
|
260
|
+
from napt.logging import get_global_logger
|
|
261
|
+
|
|
262
|
+
logger = get_global_logger()
|
|
263
|
+
|
|
264
|
+
build_dir = build_dir.resolve()
|
|
265
|
+
|
|
266
|
+
if not build_dir.exists():
|
|
267
|
+
raise PackagingError(f"Build directory not found: {build_dir}")
|
|
268
|
+
|
|
269
|
+
# Extract app_id and version from directory structure (app_id/version/)
|
|
270
|
+
version = build_dir.name
|
|
271
|
+
app_id = build_dir.parent.name
|
|
272
|
+
|
|
273
|
+
logger.verbose("PACKAGE", f"Packaging {app_id} v{version}")
|
|
274
|
+
|
|
275
|
+
# Verify build structure
|
|
276
|
+
logger.step(1, 4, "Verifying build structure...")
|
|
277
|
+
_verify_build_structure(build_dir)
|
|
278
|
+
|
|
279
|
+
# Determine output directory
|
|
280
|
+
if output_dir is None:
|
|
281
|
+
output_dir = Path("packages") / app_id
|
|
282
|
+
|
|
283
|
+
output_dir = output_dir.resolve()
|
|
284
|
+
|
|
285
|
+
# Get IntuneWinAppUtil tool
|
|
286
|
+
logger.step(2, 4, "Getting IntuneWinAppUtil tool...")
|
|
287
|
+
tool_cache = Path("cache/tools")
|
|
288
|
+
tool_path = _get_intunewin_tool(tool_cache)
|
|
289
|
+
|
|
290
|
+
# Create .intunewin package
|
|
291
|
+
logger.step(3, 4, "Creating .intunewin package...")
|
|
292
|
+
package_path = _execute_packaging(
|
|
293
|
+
tool_path,
|
|
294
|
+
build_dir,
|
|
295
|
+
"Invoke-AppDeployToolkit.exe",
|
|
296
|
+
output_dir,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Optionally clean source
|
|
300
|
+
if clean_source:
|
|
301
|
+
logger.step(4, 4, "Cleaning source build directory...")
|
|
302
|
+
shutil.rmtree(build_dir)
|
|
303
|
+
logger.verbose("PACKAGE", f"[OK] Removed build directory: {build_dir}")
|
|
304
|
+
else:
|
|
305
|
+
logger.step(4, 4, "Package complete")
|
|
306
|
+
|
|
307
|
+
logger.verbose("PACKAGE", f"[OK] Package created: {package_path}")
|
|
308
|
+
|
|
309
|
+
return PackageResult(
|
|
310
|
+
build_dir=build_dir,
|
|
311
|
+
package_path=package_path,
|
|
312
|
+
app_id=app_id,
|
|
313
|
+
version=version,
|
|
314
|
+
status="success",
|
|
315
|
+
)
|
napt/build/template.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Copyright 2025 Roger Cibrian
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Invoke-AppDeployToolkit.ps1 template generation for NAPT.
|
|
16
|
+
|
|
17
|
+
This module handles generating the Invoke-AppDeployToolkit.ps1 script by
|
|
18
|
+
reading PSADT's template, substituting configuration values, and inserting
|
|
19
|
+
recipe-specific install/uninstall code.
|
|
20
|
+
|
|
21
|
+
Design Principles:
|
|
22
|
+
- PSADT template remains pristine in cache
|
|
23
|
+
- Generate script by substitution, not modification
|
|
24
|
+
- Preserve PSADT's structure and comments
|
|
25
|
+
- Support dynamic values (AppScriptDate, discovered version)
|
|
26
|
+
- Merge org defaults with recipe overrides
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
Basic usage:
|
|
30
|
+
```python
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from napt.build.template import generate_invoke_script
|
|
33
|
+
|
|
34
|
+
script = generate_invoke_script(
|
|
35
|
+
template_path=Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
|
|
36
|
+
config=recipe_config,
|
|
37
|
+
version="141.0.7390.123",
|
|
38
|
+
psadt_version="4.1.7"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Path("builds/app/version/Invoke-AppDeployToolkit.ps1").write_text(script)
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from datetime import date
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
import re
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from napt.exceptions import PackagingError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_powershell_value(value: Any) -> str:
|
|
56
|
+
"""Format a Python value as a PowerShell literal.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
value: Python value to convert.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
PowerShell literal representation.
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
Format values for PowerShell:
|
|
66
|
+
```python
|
|
67
|
+
_format_powershell_value("hello") # Returns: "'hello'"
|
|
68
|
+
_format_powershell_value(True) # Returns: '$true'
|
|
69
|
+
_format_powershell_value([0, 1, 2]) # Returns: '@(0, 1, 2)'
|
|
70
|
+
```
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(value, bool):
|
|
73
|
+
return "$true" if value else "$false"
|
|
74
|
+
elif isinstance(value, str):
|
|
75
|
+
# Escape single quotes in strings
|
|
76
|
+
escaped = value.replace("'", "''")
|
|
77
|
+
return f"'{escaped}'"
|
|
78
|
+
elif isinstance(value, (int, float)):
|
|
79
|
+
return str(value)
|
|
80
|
+
elif isinstance(value, list):
|
|
81
|
+
# Format as PowerShell array
|
|
82
|
+
items = [_format_powershell_value(item) for item in value]
|
|
83
|
+
return f"@({', '.join(items)})"
|
|
84
|
+
elif value is None or value == "":
|
|
85
|
+
return "''"
|
|
86
|
+
else:
|
|
87
|
+
# Fallback: convert to string and quote
|
|
88
|
+
return f"'{str(value)}'"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_adtsession_vars(
|
|
92
|
+
config: dict[str, Any], version: str, psadt_version: str
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Build the $adtSession hashtable variables from configuration.
|
|
95
|
+
|
|
96
|
+
Merges organization defaults with recipe-specific overrides.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
config: Merged configuration (org + vendor + recipe).
|
|
100
|
+
version: Discovered application version.
|
|
101
|
+
psadt_version: PSADT version being used.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dictionary of variable name -> value mappings.
|
|
105
|
+
|
|
106
|
+
Note:
|
|
107
|
+
Organization defaults come from config['defaults']['psadt']['app_vars'].
|
|
108
|
+
Recipe overrides come from config['app']['psadt']['app_vars'].
|
|
109
|
+
Special handling for ${discovered_version} placeholder.
|
|
110
|
+
Auto-generates AppScriptDate if not set.
|
|
111
|
+
"""
|
|
112
|
+
app = config["app"]
|
|
113
|
+
|
|
114
|
+
# Get base variables from org defaults
|
|
115
|
+
org_defaults = config.get("defaults", {}).get("psadt", {}).get("app_vars", {})
|
|
116
|
+
|
|
117
|
+
# Get recipe overrides
|
|
118
|
+
recipe_overrides = app.get("psadt", {}).get("app_vars", {})
|
|
119
|
+
|
|
120
|
+
# Merge (recipe overrides org)
|
|
121
|
+
merged_vars = {**org_defaults, **recipe_overrides}
|
|
122
|
+
|
|
123
|
+
# Replace ${discovered_version} placeholder
|
|
124
|
+
for key, value in merged_vars.items():
|
|
125
|
+
if isinstance(value, str) and "${discovered_version}" in value:
|
|
126
|
+
merged_vars[key] = value.replace("${discovered_version}", version)
|
|
127
|
+
|
|
128
|
+
# Add auto-generated fields
|
|
129
|
+
merged_vars.setdefault("AppScriptDate", date.today().strftime("%Y-%m-%d"))
|
|
130
|
+
merged_vars["DeployAppScriptVersion"] = psadt_version
|
|
131
|
+
|
|
132
|
+
# Add vendor if available
|
|
133
|
+
vendor = config.get("vendor") or app.get("vendor", "")
|
|
134
|
+
if vendor:
|
|
135
|
+
merged_vars.setdefault("AppVendor", vendor)
|
|
136
|
+
|
|
137
|
+
return merged_vars
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _replace_session_block(template: str, vars_dict: dict[str, Any]) -> str:
|
|
141
|
+
"""Replace the $adtSession = @{...} block in the template.
|
|
142
|
+
|
|
143
|
+
Finds the hashtable initialization and replaces it with values from
|
|
144
|
+
vars_dict.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
template: PSADT template script text.
|
|
148
|
+
vars_dict: Variable name -> value mappings.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Script with replaced $adtSession block.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
RuntimeError: If $adtSession block cannot be found in template.
|
|
155
|
+
"""
|
|
156
|
+
# Find the $adtSession = @{ ... } block
|
|
157
|
+
# Pattern matches from $adtSession = @{ to the closing }
|
|
158
|
+
pattern = r"(\$adtSession = @\{)(.*?)(\n\})"
|
|
159
|
+
|
|
160
|
+
match = re.search(pattern, template, re.DOTALL)
|
|
161
|
+
if not match:
|
|
162
|
+
raise PackagingError(
|
|
163
|
+
"Could not find $adtSession hashtable in PSADT template. "
|
|
164
|
+
"Template may be from an unsupported PSADT version."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Build replacement hashtable
|
|
168
|
+
lines = []
|
|
169
|
+
for key, value in vars_dict.items():
|
|
170
|
+
ps_value = _format_powershell_value(value)
|
|
171
|
+
lines.append(f" {key} = {ps_value}")
|
|
172
|
+
|
|
173
|
+
replacement = "$adtSession = @{\n" + "\n".join(lines) + "\n}"
|
|
174
|
+
|
|
175
|
+
# Replace in template
|
|
176
|
+
result = re.sub(pattern, replacement, template, flags=re.DOTALL)
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _insert_recipe_code(
|
|
182
|
+
script: str, install_code: str | None, uninstall_code: str | None
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Insert recipe install/uninstall code at marker positions.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
script: Generated script with placeholders.
|
|
188
|
+
install_code: PowerShell code for installation.
|
|
189
|
+
uninstall_code: PowerShell code for uninstallation.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Script with recipe code inserted.
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
Replaces these PSADT markers:
|
|
196
|
+
- "## <Perform Installation tasks here>"
|
|
197
|
+
- "## <Perform Uninstallation tasks here>"
|
|
198
|
+
"""
|
|
199
|
+
if install_code:
|
|
200
|
+
# Ensure proper indentation (4 spaces to match PSADT style)
|
|
201
|
+
indented_install = "\n".join(
|
|
202
|
+
" " + line if line.strip() else line
|
|
203
|
+
for line in install_code.strip().split("\n")
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
script = script.replace(
|
|
207
|
+
" ## <Perform Installation tasks here>",
|
|
208
|
+
f" ## <Perform Installation tasks here>\n{indented_install}",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if uninstall_code:
|
|
212
|
+
# Ensure proper indentation
|
|
213
|
+
indented_uninstall = "\n".join(
|
|
214
|
+
" " + line if line.strip() else line
|
|
215
|
+
for line in uninstall_code.strip().split("\n")
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
script = script.replace(
|
|
219
|
+
" ## <Perform Uninstallation tasks here>",
|
|
220
|
+
f" ## <Perform Uninstallation tasks here>\n{indented_uninstall}",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return script
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def generate_invoke_script(
|
|
227
|
+
template_path: Path,
|
|
228
|
+
config: dict[str, Any],
|
|
229
|
+
version: str,
|
|
230
|
+
psadt_version: str,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Generate Invoke-AppDeployToolkit.ps1 from PSADT template and config.
|
|
233
|
+
|
|
234
|
+
Reads the PSADT template, replaces the $adtSession hashtable with
|
|
235
|
+
values from the configuration, and inserts recipe-specific install/
|
|
236
|
+
uninstall code.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
template_path: Path to PSADT's Invoke-AppDeployToolkit.ps1 template.
|
|
240
|
+
config: Merged configuration (org + vendor + recipe).
|
|
241
|
+
version: Application version (from filesystem).
|
|
242
|
+
psadt_version: PSADT version being used.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Generated PowerShell script text.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
PackagingError: If template doesn't exist or template parsing fails.
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
Generate deployment script from template:
|
|
252
|
+
```python
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
|
|
255
|
+
script = generate_invoke_script(
|
|
256
|
+
Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
|
|
257
|
+
config,
|
|
258
|
+
"141.0.7390.123",
|
|
259
|
+
"4.1.7"
|
|
260
|
+
)
|
|
261
|
+
```
|
|
262
|
+
"""
|
|
263
|
+
from napt.logging import get_global_logger
|
|
264
|
+
|
|
265
|
+
logger = get_global_logger()
|
|
266
|
+
if not template_path.exists():
|
|
267
|
+
raise PackagingError(f"PSADT template not found: {template_path}")
|
|
268
|
+
|
|
269
|
+
logger.verbose("BUILD", f"Reading PSADT template: {template_path.name}")
|
|
270
|
+
|
|
271
|
+
# Read template
|
|
272
|
+
template = template_path.read_text(encoding="utf-8")
|
|
273
|
+
|
|
274
|
+
# Build $adtSession variables
|
|
275
|
+
logger.verbose("BUILD", "Building $adtSession variables...")
|
|
276
|
+
session_vars = _build_adtsession_vars(config, version, psadt_version)
|
|
277
|
+
|
|
278
|
+
logger.debug("BUILD", "--- $adtSession Variables ---")
|
|
279
|
+
for key, value in session_vars.items():
|
|
280
|
+
logger.debug("BUILD", f" {key} = {value}")
|
|
281
|
+
|
|
282
|
+
# Replace $adtSession block
|
|
283
|
+
script = _replace_session_block(template, session_vars)
|
|
284
|
+
logger.verbose("BUILD", "[OK] Replaced $adtSession hashtable")
|
|
285
|
+
|
|
286
|
+
# Insert recipe code
|
|
287
|
+
app = config["app"]
|
|
288
|
+
psadt_config = app.get("psadt", {})
|
|
289
|
+
install_code = psadt_config.get("install")
|
|
290
|
+
uninstall_code = psadt_config.get("uninstall")
|
|
291
|
+
|
|
292
|
+
if install_code:
|
|
293
|
+
logger.verbose("BUILD", "Inserting install code from recipe")
|
|
294
|
+
if uninstall_code:
|
|
295
|
+
logger.verbose("BUILD", "Inserting uninstall code from recipe")
|
|
296
|
+
|
|
297
|
+
script = _insert_recipe_code(script, install_code, uninstall_code)
|
|
298
|
+
|
|
299
|
+
logger.verbose("BUILD", "[OK] Script generation complete")
|
|
300
|
+
|
|
301
|
+
return script
|