Skip to content

manager

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 unmodified
  • 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}")

sanitize_filename

sanitize_filename(name: str, app_id: str = '') -> str

Sanitize string for use in Windows filename.

Rules
  • Replace spaces with hyphens
  • Remove invalid Windows filename characters (< > : " | ? * \ /)
  • Normalize multiple consecutive hyphens to single hyphen
  • Remove leading/trailing hyphens and dots
  • If result is empty, fallback to app_id (or "app" if app_id is empty)

Parameters:

Name Type Description Default
name str

String to sanitize (e.g., "Google Chrome").

required
app_id str

Fallback identifier if name becomes empty after sanitization.

''

Returns:

Type Description
str

Sanitized filename-safe string (e.g., "Google-Chrome").

Example

Basic sanitization:

sanitize_filename("Google Chrome")  # Returns: "Google-Chrome"
sanitize_filename("My App v2.0")    # Returns: "My-App-v2.0"
sanitize_filename("Test<>App")      # Returns: "TestApp"

Fallback behavior:

sanitize_filename("  ", "my-app")   # Returns: "my-app"
sanitize_filename("", "test")       # Returns: "test"

Source code in napt/build/manager.py
def sanitize_filename(name: str, app_id: str = "") -> str:
    """Sanitize string for use in Windows filename.

    Rules:
        - Replace spaces with hyphens
        - Remove invalid Windows filename characters (< > : " | ? * \\ /)
        - Normalize multiple consecutive hyphens to single hyphen
        - Remove leading/trailing hyphens and dots
        - If result is empty, fallback to app_id (or "app" if app_id is empty)

    Args:
        name: String to sanitize (e.g., "Google Chrome").
        app_id: Fallback identifier if name becomes empty after sanitization.

    Returns:
        Sanitized filename-safe string (e.g., "Google-Chrome").

    Example:
        Basic sanitization:
            ```python
            sanitize_filename("Google Chrome")  # Returns: "Google-Chrome"
            sanitize_filename("My App v2.0")    # Returns: "My-App-v2.0"
            sanitize_filename("Test<>App")      # Returns: "TestApp"
            ```

        Fallback behavior:
            ```python
            sanitize_filename("  ", "my-app")   # Returns: "my-app"
            sanitize_filename("", "test")       # Returns: "test"
            ```

    """
    sanitized = name.replace(" ", "-")
    invalid_chars = '<>:"|?*\\/'
    for char in invalid_chars:
        sanitized = sanitized.replace(char, "")
    sanitized = re.sub(r"-+", "-", sanitized)
    sanitized = sanitized.strip(".-")
    if not sanitized:
        sanitized = app_id if app_id else "app"
    return sanitized

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 unmodified
  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 unmodified 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 unmodified
    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 unmodified 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_id = config["id"]
    app_name = config["name"]

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

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

    # 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 installer metadata upfront (or validate EXE architecture from recipe)
    installer_ext = installer_file.suffix.lower()
    msi_metadata: MSIMetadata | None = None
    msix_metadata: MSIXMetadata | None = None

    if installer_ext == ".msi":
        msi_metadata = extract_msi_metadata(installer_file)
        architecture: str = msi_metadata.architecture
    elif installer_ext == ".msix":
        msix_metadata = extract_msix_metadata(installer_file)
        architecture = msix_metadata.architecture
    else:
        detection_settings = config["intune"]["detection"]
        architecture = detection_settings.get("architecture") or ""
        if not architecture:
            raise ConfigError(
                "intune.detection.architecture is required for EXE "
                "installers. Set intune.detection.architecture in the "
                "recipe. Allowed values: x86, x64, arm64, any"
            )

    # Get PSADT release
    logger.step(4, 8, "Getting PSADT release...")
    psadt_config = config["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
    _copy_psadt_template(psadt_cache_dir, build_dir)

    # Auto-generate MSIX install/uninstall commands (or warn if overridden)
    if installer_ext == ".msix":
        assert msix_metadata is not None
        _apply_msix_commands(config, msix_metadata, installer_file, logger)

    # 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
    build_types = config["intune"]["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, msix_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, msix_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,
    )