Skip to content

build

napt.build.manager

Build manager for PSADT package creation.

This module orchestrates the complete build process for creating PSADT packages from recipes and downloaded installers.

Design Principles
  • Filesystem is source of truth for version information
  • Entire PSADT Template_v4 structure copied pristine
  • Invoke-AppDeployToolkit.ps1 is generated from template (not copied)
  • Build directories are versioned: {app_id}/{version}/
  • Branding applied by replacing files in root Assets/ directory (v4 structure)
Example

Basic usage:

from pathlib import Path
from napt.build import build_package

result = build_package(
    recipe_path=Path("recipes/Google/chrome.yaml"),
    downloads_dir=Path("downloads"),
)

print(f"Built: {result.build_dir}")

build_package

build_package(recipe_path: Path, downloads_dir: Path | None = None, output_dir: Path | None = None) -> BuildResult

Build a PSADT package from a recipe and downloaded installer.

This is the main entry point for the build process. It:

  1. Loads the recipe configuration
  2. Finds the downloaded installer
  3. Extracts version from installer (filesystem is truth)
  4. Gets/downloads PSADT release
  5. Creates build directory structure
  6. Copies PSADT files (pristine)
  7. Generates Invoke-AppDeployToolkit.ps1 from template
  8. Copies installer to Files/
  9. Applies custom branding
  10. Generates detection script (always; used by App entry and by Update entry)
  11. Generates requirements script (when build_types is "both" or "update_only")

Parameters:

Name Type Description Default
recipe_path Path

Path to the recipe YAML file.

required
downloads_dir Path | None

Directory containing the downloaded installer. Default: Path("downloads")

None
output_dir Path | None

Base directory for build output. Default: From config or Path("builds")

None

Returns:

Type Description
BuildResult

Build result containing app metadata, build paths, PSADT version, and generated script paths.

Raises:

Type Description
FileNotFoundError

If recipe or installer doesn't exist.

PackagingError

If build process fails or script generation fails.

ConfigError

If required configuration is missing.

Example

Basic build:

result = build_package(Path("recipes/Google/chrome.yaml"))
print(result.build_dir)  # builds/napt-chrome/141.0.7390.123
print(result.build_types)  # "both"

Custom output directory:

result = build_package(
    Path("recipes/Google/chrome.yaml"),
    output_dir=Path("custom/builds")
)

Note

Requires installer to be downloaded first (run 'napt discover'). Version extracted from installer file, not state cache. Overwrites existing build directory if it exists. PSADT files are copied pristine from cache. Invoke-AppDeployToolkit.ps1 is generated (not copied). Scripts are generated as siblings to the packagefiles directory (not included in .intunewin package - must be uploaded separately to Intune). Detection script is always generated. The build_types setting controls requirements script only: "both" (default) generates detection and requirements, "app_only" generates only detection, "update_only" generates detection and requirements.

Source code in napt/build/manager.py
def build_package(
    recipe_path: Path,
    downloads_dir: Path | None = None,
    output_dir: Path | None = None,
) -> BuildResult:
    """Build a PSADT package from a recipe and downloaded installer.

    This is the main entry point for the build process. It:

    1. Loads the recipe configuration
    2. Finds the downloaded installer
    3. Extracts version from installer (filesystem is truth)
    4. Gets/downloads PSADT release
    5. Creates build directory structure
    6. Copies PSADT files (pristine)
    7. Generates Invoke-AppDeployToolkit.ps1 from template
    8. Copies installer to Files/
    9. Applies custom branding
    10. Generates detection script (always; used by App entry and by Update entry)
    11. Generates requirements script (when build_types is "both" or "update_only")

    Args:
        recipe_path: Path to the recipe YAML file.
        downloads_dir: Directory containing the downloaded
            installer. Default: Path("downloads")
        output_dir: Base directory for build output.
            Default: From config or Path("builds")

    Returns:
        Build result containing app metadata, build paths, PSADT version, and
            generated script paths.

    Raises:
        FileNotFoundError: If recipe or installer doesn't exist.
        PackagingError: If build process fails or script generation fails.
        ConfigError: If required configuration is missing.

    Example:
        Basic build:
            ```python
            result = build_package(Path("recipes/Google/chrome.yaml"))
            print(result.build_dir)  # builds/napt-chrome/141.0.7390.123
            print(result.build_types)  # "both"
            ```

        Custom output directory:
            ```python
            result = build_package(
                Path("recipes/Google/chrome.yaml"),
                output_dir=Path("custom/builds")
            )
            ```

    Note:
        Requires installer to be downloaded first (run 'napt discover').
        Version extracted from installer file, not state cache.
        Overwrites existing build directory if it exists.
        PSADT files are copied pristine from cache.
        Invoke-AppDeployToolkit.ps1 is generated (not copied).
        Scripts are generated as siblings to the packagefiles directory
        (not included in .intunewin package - must be uploaded separately to Intune).
        Detection script is always generated.
        The build_types setting controls requirements script only: "both" (default)
        generates detection and requirements, "app_only" generates only detection,
        "update_only" generates detection and requirements.
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()
    # Load configuration
    logger.step(1, 8, "Loading configuration...")
    config = load_effective_config(recipe_path)

    app = config["app"]
    app_id = app.get("id", "unknown-app")
    app_name = app.get("name", "Unknown App")

    # Set defaults
    if downloads_dir is None:
        downloads_dir = Path(config["defaults"]["discover"]["output_dir"])

    if output_dir is None:
        output_dir = Path(config["defaults"]["build"]["output_dir"])

    # Find installer file
    logger.step(2, 8, "Finding installer...")
    state_file = Path("state/versions.json")  # Default state file location
    installer_file = _find_installer_file(downloads_dir, config, state_file)

    # Extract version from installer or state (filesystem + state are truth)
    logger.step(3, 8, "Determining version...")
    version = _get_installer_version(installer_file, config, state_file)

    logger.info("BUILD", f"Building {app_name} v{version}")

    # Extract MSI metadata upfront (or validate non-MSI architecture from recipe)
    msi_metadata: MSIMetadata | None = None
    if installer_file.suffix.lower() == ".msi":
        msi_metadata = extract_msi_metadata(installer_file)
        architecture: str = msi_metadata.architecture
    else:
        installed_check = app.get("win32", {}).get("installed_check", {})
        architecture = installed_check.get("architecture") or ""
        if not architecture:
            raise ConfigError(
                "win32.installed_check.architecture is required for non-MSI installers. "
                "Set app.win32.installed_check.architecture in recipe configuration. "
                "Allowed values: x86, x64, arm64, any"
            )

    # Get PSADT release
    logger.step(4, 8, "Getting PSADT release...")
    psadt_config = config["defaults"]["psadt"]
    release_spec = psadt_config["release"]
    cache_dir = Path(psadt_config["cache_dir"])

    psadt_cache_dir = get_psadt_release(release_spec, cache_dir)
    psadt_version = psadt_cache_dir.name  # Directory name is the version

    logger.info("BUILD", f"Using PSADT {psadt_version}")

    # Create build directory
    logger.step(5, 8, "Creating build structure...")
    build_dir = _create_build_directory(output_dir, app_id, version)

    # Copy PSADT files (pristine)
    _copy_psadt_pristine(psadt_cache_dir, build_dir)

    # Generate Invoke-AppDeployToolkit.ps1
    from .template import generate_invoke_script

    template_path = psadt_cache_dir / "Invoke-AppDeployToolkit.ps1"
    invoke_script = generate_invoke_script(
        template_path, config, version, psadt_version, architecture
    )

    # Write generated script
    script_dest = build_dir / "Invoke-AppDeployToolkit.ps1"
    script_dest.write_text(invoke_script, encoding="utf-8")
    logger.verbose("BUILD", "[OK] Generated Invoke-AppDeployToolkit.ps1")

    # Copy installer
    _copy_installer(installer_file, build_dir)

    # Apply branding
    logger.step(6, 8, "Applying branding...")
    _apply_branding(config, build_dir)

    # Get build_types configuration
    defaults_win32 = config.get("defaults", {}).get("win32", {})
    app_win32 = app.get("win32", {})
    build_types = app_win32.get("build_types", defaults_win32["build_types"])

    detection_script_path = None
    requirements_script_path = None

    # Generate detection script (always; needed for App and Update entries)
    logger.step(7, 8, "Generating detection script...")
    detection_script_path = _generate_detection_script(
        installer_file, config, version, app_id, build_dir, msi_metadata
    )
    logger.verbose("BUILD", "[OK] Detection script generated")

    # Generate requirements script (for "both" or "update_only")
    if build_types in ("both", "update_only"):
        logger.step(8, 8, "Generating requirements script...")
        requirements_script_path = _generate_requirements_script(
            installer_file, config, version, app_id, build_dir, msi_metadata
        )
        logger.verbose("BUILD", "[OK] Requirements script generated")
    else:
        logger.step(8, 8, "Skipping requirements script (build_types=app_only)...")

    # Write build manifest
    _write_build_manifest(
        build_dir=build_dir,
        app_id=app_id,
        app_name=app_name,
        version=version,
        build_types=build_types,
        architecture=architecture,
        detection_script_path=detection_script_path,
        requirements_script_path=requirements_script_path,
    )

    logger.verbose("BUILD", f"[OK] Build complete: {build_dir}")

    return BuildResult(
        app_id=app_id,
        app_name=app_name,
        version=version,
        build_dir=build_dir,
        psadt_version=psadt_version,
        status="success",
        build_types=build_types,
        detection_script_path=detection_script_path,
        requirements_script_path=requirements_script_path,
    )

napt.build.template

Invoke-AppDeployToolkit.ps1 template generation for NAPT.

This module handles generating the Invoke-AppDeployToolkit.ps1 script by reading PSADT's template, substituting configuration values, and inserting recipe-specific install/uninstall code.

Design Principles
  • PSADT template remains pristine in cache
  • Generate script by substitution, not modification
  • Preserve PSADT's structure and comments
  • Support dynamic values (AppScriptDate, discovered version)
  • Merge org defaults with recipe overrides
Example

Basic usage:

from pathlib import Path
from napt.build.template import generate_invoke_script

script = generate_invoke_script(
    template_path=Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
    config=recipe_config,
    version="141.0.7390.123",
    psadt_version="4.1.7"
)

Path("builds/app/version/Invoke-AppDeployToolkit.ps1").write_text(script)

generate_invoke_script

generate_invoke_script(template_path: Path, config: dict[str, Any], version: str, psadt_version: str, architecture: str) -> str

Generate Invoke-AppDeployToolkit.ps1 from PSADT template and config.

Reads the PSADT template, replaces the $adtSession hashtable with values from the configuration, and inserts recipe-specific install/ uninstall code.

Parameters:

Name Type Description Default
template_path Path

Path to PSADT's Invoke-AppDeployToolkit.ps1 template.

required
config dict[str, Any]

Merged configuration (org + vendor + recipe).

required
version str

Application version (from filesystem).

required
psadt_version str

PSADT version being used.

required
architecture str

Resolved installer architecture (e.g., "x64", "x86", "arm64", "any"). Sets AppArch in the $adtSession hashtable; "any" leaves AppArch unset.

required

Returns:

Type Description
str

Generated PowerShell script text.

Raises:

Type Description
PackagingError

If template doesn't exist or template parsing fails.

Example

Generate deployment script from template:

from pathlib import Path

script = generate_invoke_script(
    Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
    config,
    "141.0.7390.123",
    "4.1.7",
    "x64",
)

Source code in napt/build/template.py
def generate_invoke_script(
    template_path: Path,
    config: dict[str, Any],
    version: str,
    psadt_version: str,
    architecture: str,
) -> str:
    """Generate Invoke-AppDeployToolkit.ps1 from PSADT template and config.

    Reads the PSADT template, replaces the $adtSession hashtable with
    values from the configuration, and inserts recipe-specific install/
    uninstall code.

    Args:
        template_path: Path to PSADT's Invoke-AppDeployToolkit.ps1 template.
        config: Merged configuration (org + vendor + recipe).
        version: Application version (from filesystem).
        psadt_version: PSADT version being used.
        architecture: Resolved installer architecture (e.g., "x64", "x86",
            "arm64", "any"). Sets AppArch in the $adtSession hashtable;
            "any" leaves AppArch unset.

    Returns:
        Generated PowerShell script text.

    Raises:
        PackagingError: If template doesn't exist or template parsing fails.

    Example:
        Generate deployment script from template:
            ```python
            from pathlib import Path

            script = generate_invoke_script(
                Path("cache/psadt/4.1.7/Invoke-AppDeployToolkit.ps1"),
                config,
                "141.0.7390.123",
                "4.1.7",
                "x64",
            )
            ```
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()
    if not template_path.exists():
        raise PackagingError(f"PSADT template not found: {template_path}")

    logger.verbose("BUILD", f"Reading PSADT template: {template_path.name}")

    # Read template
    template = template_path.read_text(encoding="utf-8")

    # Build $adtSession variables
    logger.verbose("BUILD", "Building $adtSession variables...")
    session_vars = _build_adtsession_vars(config, version, psadt_version, architecture)

    logger.debug("BUILD", "--- $adtSession Variables ---")
    for key, value in session_vars.items():
        logger.debug("BUILD", f"  {key} = {value}")

    # Replace $adtSession block
    script = _replace_session_block(template, session_vars)
    logger.verbose("BUILD", "[OK] Replaced $adtSession hashtable")

    # Insert recipe code
    app = config["app"]
    psadt_config = app.get("psadt", {})
    install_code = psadt_config.get("install")
    uninstall_code = psadt_config.get("uninstall")

    if install_code:
        logger.verbose("BUILD", "Inserting install code from recipe")
    if uninstall_code:
        logger.verbose("BUILD", "Inserting uninstall code from recipe")

    script = _insert_recipe_code(script, install_code, uninstall_code)

    logger.verbose("BUILD", "[OK] Script generation complete")

    return script

napt.build.packager

.intunewin package generation for NAPT.

This module handles creating .intunewin packages from built PSADT directories using Microsoft's IntuneWinAppUtil.exe tool.

Design Principles
  • IntuneWinAppUtil.exe is cached globally (not per-build)
  • Package output is named by IntuneWinAppUtil.exe: Invoke-AppDeployToolkit.intunewin
  • Build directory can optionally be cleaned after packaging
  • Tool is downloaded from Microsoft's official GitHub repository
Example

Basic usage:

from pathlib import Path
from napt.build.packager import create_intunewin

result = create_intunewin(
    build_dir=Path("builds/napt-chrome/141.0.7390.123"),
    output_dir=Path("packages")
)

print(f"Package: {result.package_path}")

create_intunewin

create_intunewin(build_dir: Path, output_dir: Path | None = None, clean_source: bool = False) -> PackageResult

Create a .intunewin package from a PSADT build version directory.

Uses Microsoft's IntuneWinAppUtil.exe tool to package the PSADT build into a .intunewin file for Intune deployment.

The output directory is versioned: packages/{app_id}/{version}/. Any previously packaged version for the same app is removed before the new one is created (single-slot: one package on disk per app at a time). Detection and requirements scripts are copied into the output directory so that 'napt upload' is self-contained and does not need the builds directory.

Parameters:

Name Type Description Default
build_dir Path

Path to the version directory produced by 'napt build' (e.g., builds/napt-chrome/144.0.7559.110/). Must contain a packagefiles/ subdirectory with a valid PSADT structure.

required
output_dir Path | None

Parent directory for package output. Default: packages/ (configurable via defaults.package.output_dir in org.yaml).

None
clean_source bool

If True, remove the build version directory after packaging. Default is False.

False

Returns:

Type Description
PackageResult

Package metadata including .intunewin path, app ID, and version.

Raises:

Type Description
ConfigError

If the build directory structure is invalid.

PackagingError

If packaging fails or build_dir is missing.

NetworkError

If IntuneWinAppUtil.exe download fails.

Example

Basic packaging:

result = create_intunewin(
    build_dir=Path("builds/napt-chrome/144.0.7559.110")
)
print(result.package_path)
# packages/napt-chrome/144.0.7559.110/Invoke-AppDeployToolkit.intunewin

With cleanup:

result = create_intunewin(
    build_dir=Path("builds/napt-chrome/144.0.7559.110"),
    clean_source=True
)
# Build directory is removed after packaging

Note

Requires build directory from 'napt build' command. IntuneWinAppUtil.exe is downloaded and cached on first use. Setup file is always "Invoke-AppDeployToolkit.exe". Output file is named by IntuneWinAppUtil.exe: packages/{app_id}/{version}/Invoke-AppDeployToolkit.intunewin

Source code in napt/build/packager.py
def create_intunewin(
    build_dir: Path,
    output_dir: Path | None = None,
    clean_source: bool = False,
) -> PackageResult:
    """Create a .intunewin package from a PSADT build version directory.

    Uses Microsoft's IntuneWinAppUtil.exe tool to package the PSADT build
    into a .intunewin file for Intune deployment.

    The output directory is versioned: packages/{app_id}/{version}/.
    Any previously packaged version for the same app is removed before the
    new one is created (single-slot: one package on disk per app at a time).
    Detection and requirements scripts are copied into the output directory
    so that 'napt upload' is self-contained and does not need the builds
    directory.

    Args:
        build_dir: Path to the version directory produced by 'napt build'
            (e.g., builds/napt-chrome/144.0.7559.110/). Must contain a
            packagefiles/ subdirectory with a valid PSADT structure.
        output_dir: Parent directory for package output.
            Default: packages/ (configurable via defaults.package.output_dir
            in org.yaml).
        clean_source: If True, remove the build version directory
            after packaging. Default is False.

    Returns:
        Package metadata including .intunewin path, app ID, and version.

    Raises:
        ConfigError: If the build directory structure is invalid.
        PackagingError: If packaging fails or build_dir is missing.
        NetworkError: If IntuneWinAppUtil.exe download fails.

    Example:
        Basic packaging:
            ```python
            result = create_intunewin(
                build_dir=Path("builds/napt-chrome/144.0.7559.110")
            )
            print(result.package_path)
            # packages/napt-chrome/144.0.7559.110/Invoke-AppDeployToolkit.intunewin
            ```

        With cleanup:
            ```python
            result = create_intunewin(
                build_dir=Path("builds/napt-chrome/144.0.7559.110"),
                clean_source=True
            )
            # Build directory is removed after packaging
            ```

    Note:
        Requires build directory from 'napt build' command. IntuneWinAppUtil.exe
        is downloaded and cached on first use. Setup file is always
        "Invoke-AppDeployToolkit.exe". Output file is named by IntuneWinAppUtil.exe:
        packages/{app_id}/{version}/Invoke-AppDeployToolkit.intunewin
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()

    build_dir = build_dir.resolve()

    if not build_dir.exists():
        raise PackagingError(f"Build directory not found: {build_dir}")

    # build_dir is the version directory: builds/{app_id}/{version}/
    version = build_dir.name
    app_id = build_dir.parent.name
    packagefiles_dir = build_dir / "packagefiles"

    logger.verbose("PACKAGE", f"Packaging {app_id} v{version}")

    # Verify PSADT structure inside packagefiles/
    logger.step(1, 5, "Verifying build structure...")
    _verify_build_structure(packagefiles_dir)

    # Determine versioned output directory: packages/{app_id}/{version}/
    packages_root = output_dir.resolve() if output_dir else Path("packages").resolve()
    app_package_dir = packages_root / app_id
    version_output_dir = app_package_dir / version

    # Remove any previous version dirs for this app (single-slot)
    if app_package_dir.exists():
        for existing in [d for d in app_package_dir.iterdir() if d.is_dir()]:
            if existing != version_output_dir:
                logger.info("PACKAGE", f"Removing previous package: {existing.name}")
                shutil.rmtree(existing)

    version_output_dir.mkdir(parents=True, exist_ok=True)

    # Get IntuneWinAppUtil tool
    logger.step(2, 5, "Getting IntuneWinAppUtil tool...")
    tool_cache = Path("cache/tools")
    tool_path = _get_intunewin_tool(tool_cache)

    # Create .intunewin package
    logger.step(3, 5, "Creating .intunewin package...")
    package_path = _execute_packaging(
        tool_path,
        packagefiles_dir,
        "Invoke-AppDeployToolkit.exe",
        version_output_dir,
    )

    # Copy detection/requirements scripts and build manifest into the package
    # output directory so napt upload is self-contained and does not need
    # the builds directory.
    logger.step(4, 5, "Copying detection scripts...")
    for script in sorted(build_dir.glob("*-Detection.ps1")):
        shutil.copy2(script, version_output_dir / script.name)
        logger.verbose("PACKAGE", f"Copied: {script.name}")
    for script in sorted(build_dir.glob("*-Requirements.ps1")):
        shutil.copy2(script, version_output_dir / script.name)
        logger.verbose("PACKAGE", f"Copied: {script.name}")
    manifest_src = build_dir / "build-manifest.json"
    if manifest_src.exists():
        shutil.copy2(manifest_src, version_output_dir / "build-manifest.json")
        logger.verbose("PACKAGE", "Copied: build-manifest.json")

    # Optionally clean source
    if clean_source:
        logger.step(5, 5, "Cleaning source build directory...")
        shutil.rmtree(build_dir)
        logger.verbose("PACKAGE", f"[OK] Removed build directory: {build_dir}")
    else:
        logger.step(5, 5, "Package complete")

    logger.verbose("PACKAGE", f"[OK] Package created: {package_path}")

    return PackageResult(
        build_dir=build_dir,
        package_path=package_path,
        app_id=app_id,
        version=version,
        status="success",
    )