# PackPilot

A Ninite-style bulk software installer — but you pick the software **inside the program**, you
can **save your selection for deployment later**, and it now runs on **both Windows and macOS**.

* Tick the apps you want from a shared catalogue of **124 applications** across 13 categories.
* **Install now** on the machine you're sitting at, with live per-package progress.
* **Save a profile** (`*.ppprofile`, plain JSON) and reuse it on any other machine.
* **Windows**: build a **deployment EXE** — a single self-contained executable that carries your
  selection, exactly like a Ninite custom installer. Double-click it, or run it with `--silent`
  from any RMM / Intune / GPO / SCCM job. Or export a **PowerShell script**, a **batch file**, or
  a **`winget import`** file if you'd rather drive it with your own tooling.
* **macOS**: export a standalone **shell script** or a **`Brewfile`** instead.

Windows installs are performed by the **Windows Package Manager (winget)**, falling back to
**Chocolatey** and then to a **direct download**. macOS installs are performed by **Homebrew**
(`brew install --cask` for applications, `brew install` for command-line packages). Nothing is
bundled and nothing is repackaged — PackPilot only ever asks an official package manager for a
package by its official identifier.

This repository holds the Windows edition (**2.0.2**) and the macOS edition (**1.0.1**), sharing
one catalogue. This README covers the Windows edition and the catalogue they share; see
[`mac/README.md`](mac/README.md) for the macOS edition in full.

Prebuilt artifacts are available from the
[v2.0.2 GitHub release](https://github.com/phamid/packpilot/releases/tag/v2.0.2).

---

## Quick start

```bash
./build.sh                 # verifies the catalogue, builds + tests, output: dist/PackPilot.exe
```

Copy `dist/PackPilot.exe` to a Windows 10/11 machine and run it. Nothing else is required —
the .NET runtime is inside the executable.

```
dist/PackPilot.exe         self-contained, no prerequisites
```

The Windows artifact is **unsigned**, so SmartScreen will warn on first run. Verify the published
SHA-256 before running it if you didn't build it yourself. The published macOS artifact is signed
with **Developer ID Application: Patrick Hamid (E4DUZ8FY37)**, notarised by Apple and stapled, so
it opens without a Gatekeeper warning. A local `cd mac && ./build.sh --no-sign` build is ad-hoc
signed instead and is for testing only — see
[Installing the macOS edition](#installing-the-macos-edition) below.

## Using the app

### 1. Pick software
Tick the checkboxes. Click a **category heading** to select or clear that whole category.
`Ctrl+F` jumps to the search box (searches names, ids, winget ids, categories, summaries and
licences). `Selection ▸ Select recommended set` ticks every app the catalogue flags
`"recommended": true`.

Anything not in the catalogue can be added by pasting its identifier into
**Extra winget package IDs** (one per line):

* `Mozilla.Firefox` — a bare winget id.
* `winget:Mozilla.Firefox` — the same, with an explicit source.
* `msstore:9NBLGGH4NNS1` — routed through the Microsoft Store source.
* `9NBLGGH4NNS1` — a bare twelve-character upper-case identifier is recognised as a Microsoft
  Store product ID on its own.

### 2. Choose install options
| Option | Meaning |
| --- | --- |
| Install for | All users (machine scope), current user, or the package default |
| Silent / unattended | Pass `--silent` to winget where the package supports it |
| Skip software already installed | Don't touch packages winget reports as present |
| Upgrade when an older version exists | Run `winget upgrade` instead of skipping |
| Fall back to Chocolatey | Bootstrap and use Chocolatey when winget can't serve a package |
| Install winget if it is missing | Bootstraps App Installer from Microsoft |
| Write a log file for each run | `%ProgramData%\PackPilot\Logs\PackPilot-<PC>-<time>.log` |
| Restart the PC when finished | For imaging / provisioning runs |
| Timeout per package | Guards against an installer that hangs |

### 3. Deploy now, or save it for later

| What you want | Do this |
| --- | --- |
| Install on this PC | **Install now** |
| Reuse the selection later | **Save profile…** → `*.ppprofile` |
| A one-click installer for other PCs | **Build deployment EXE…** |
| Drive it from your own scripts | `Deploy ▸ Export PowerShell script / batch file` |
| Feed it to winget directly | `Deploy ▸ Export winget import file` |

PackPilot asks for administrator rights only when a run actually starts, and only when the
profile uses machine scope. If you decline, it offers to continue in user scope instead.

### Licence badges

Every application carries an explicit licence, shown as a badge beside its name and searchable
by text:

| Badge | Meaning |
| --- | --- |
| open source | Released under an open-source licence. |
| freeware | Free of charge, closed source. |
| free personal | Free for personal or non-commercial use; a licence is required at work. |
| free tier | Free account or free edition of a commercial product. |

### About window

`Help ▸ About` reports the product and version, build date, publisher, the **SHA-256 of the
running executable**, and the Authenticode signature state — useful for confirming exactly which
build is on a machine before you trust it.

---

## Deployment EXE

`Deploy ▸ Build deployment EXE…` clones PackPilot and appends your profile to the end of the
file, so the result is a **single, dependency-free executable** that already knows what to
install. (A matching `.ppprofile` is written next to it as a fallback and for auditing.)

```cmd
Office-standard-Installer.exe                 :: opens the progress window, click Install
Office-standard-Installer.exe --install       :: opens the window and starts straight away
Office-standard-Installer.exe --silent        :: unattended, exit code = number of failures
Office-standard-Installer.exe --silent --reboot
```

Because the exit code is the failure count, it drops straight into any orchestrator:

```powershell
# Intune Win32 app / SCCM program / RMM script
.\Office-standard-Installer.exe --silent
if ($LASTEXITCODE -ne 0) { throw "PackPilot: $LASTEXITCODE package(s) failed" }
```

---

## Command line

```
PackPilot.exe                             Open the picker UI
PackPilot.exe <profile.ppprofile>         Open the UI with a profile loaded
PackPilot.exe --profile <file> --silent   Unattended install from a profile
PackPilot.exe --apps chrome,7zip --install

  -p, --profile <file>      Deployment profile to load (.ppprofile)
      --apps <a,b,c>        Catalogue ids or winget ids to install
      --install             Start installing immediately (shows progress window)
  -s, --silent              Unattended: no UI, console output, exit code = failures
      --scope machine|user  Override the install scope
      --reboot              Restart the machine when the run finishes
      --log <folder>        Where to write the run transcript
      --build-exe <file>    Write a self-contained deployment EXE for the profile
      --export-ps1 <file>   Write a PowerShell deployment script
      --export-cmd <file>   Write a batch deployment script
      --export-winget <f>   Write a 'winget import' package file
      --list                Print the software catalogue
      --no-elevate          Never prompt for administrator rights
  -h, --help                Show this help

Exit codes: 0 = success, n = number of failed packages, 2 = bad arguments / nothing to do
```

Everything the UI can do is available headlessly, so profiles can be built on a build agent:

```cmd
PackPilot.exe --profile office.ppprofile --build-exe Office-Installer.exe
PackPilot.exe --apps chrome,firefox,7zip,vlc --export-ps1 Deploy.ps1
```

---

## Profiles

Profiles are readable JSON — commit them to source control, template them, generate them.

```json
{
  "schema": 2,
  "name": "Office standard build",
  "description": "Baseline software for reception, admin and sales PCs.",
  "apps": ["7zip", "acrobat", "chrome", "libreoffice", "teams", "vlc", "zoom"],
  "customWingetIds": ["Microsoft.PowerToys"],
  "options": {
    "silent": true,
    "scope": "Machine",
    "skipInstalled": true,
    "upgradeIfPresent": true,
    "allowChocoFallback": true,
    "autoInstallWinget": true,
    "writeLog": true,
    "logFolder": "",
    "timeoutMinutes": 30,
    "rebootWhenDone": false,
    "closeWhenDone": false
  }
}
```

Two ready-made examples live in [`samples/`](samples). The default location for saved profiles is
`%ProgramData%\PackPilot\Profiles`. Profiles that name a catalogue id renamed in 2.0.0 (see
[Breaking changes](#breaking-changes-in-200) below) will skip those entries until re-saved.

---

## The shared catalogue

`catalog/catalog.json` is the single source of truth: both the Windows and macOS editions embed a
copy of this exact file at build time, and each build fails if the embedded copy doesn't match it
byte-for-byte. It currently holds:

* **124 applications** across **13 categories**.
* **110** installable on Windows, **90** on macOS, **76** on both.

The catalogue schema is **2**. Each entry looks like this:

```json
{
  "id": "chrome",
  "name": "Google Chrome",
  "category": "Web Browsers",
  "licence": "freeware",
  "homepage": "https://www.google.com/chrome/",
  "summary": "Google's Chromium-based browser.",
  "windows": {
    "winget": "Google.Chrome",
    "choco": "googlechrome"
  },
  "macos": {
    "brew": "google-chrome",
    "type": "cask"
  },
  "recommended": true
}
```

* `windows.winget` is preferred, `windows.choco` is the fallback. Either or both may be absent;
  an app with no `windows` block simply isn't offered on Windows.
* `macos.brew` is the Homebrew token, and `macos.type` is `cask` (a GUI application, installed
  with `brew install --cask`) or `formula` (a command-line package, installed with
  `brew install`). An app with no `macos` block isn't offered on macOS.
* `licence` must be one of `open-source`, `freeware`, `free-personal` or `free-tier` (see the
  badges above); `summary` is the one-line description shown in the picker; `recommended` drives
  `Selection ▸ Select recommended set`.
* At least one of `windows` or `macos` must be present.

### Adding an application, or supplying your own catalogue

Drop a `catalog.json` next to `PackPilot.exe` (or in `%ProgramData%\PackPilot\`) and it replaces
the built-in list — handy for in-house line-of-business software. `Help ▸ Catalog source` shows
which file is in use. It must follow the schema 2 shape above; a schema-1 file (the pre-2.0.0
layout, with `winget`/`choco` at the top level) will not populate the picker.

```json
{
  "categories": ["Line of business"],
  "apps": [
    {
      "id": "acme-erp",
      "name": "ACME ERP Client",
      "category": "Line of business",
      "licence": "freeware",
      "homepage": "https://files.acme.local/erp/",
      "summary": "In-house ERP client.",
      "windows": {
        "winget": "Acme.ErpClient",
        "choco": "acme-erp"
      }
    }
  ]
}
```

### Verifying the catalogue against the vendors

A package identifier that doesn't exist looks perfectly valid JSON and only fails on the
technician's machine, halfway through a deployment. `tools/verify-catalog.py` checks every
identifier against the vendor's own index before each build:

* **Windows** — the winget community source index that `winget` itself downloads,
  `https://cdn.winget.microsoft.com/cache/source.msix`.
* **macOS** — the official Homebrew cask and formula API, `https://formulae.brew.sh`.

```bash
./tools/verify-catalog.py                 # download the indexes and check
./tools/verify-catalog.py --cache DIR     # reuse indexes already downloaded
./tools/verify-catalog.py --offline       # fail rather than reach the network
```

It runs as the first step of `build.sh` (and of `mac/build.sh`'s catalogue check) and **blocks
the build** on any failure — an unresolvable identifier, a duplicate or missing id, an
undeclared category, an invalid licence, a missing summary, or an app that serves neither
platform.

---

## How an install is attempted (Windows)

For each selected package, in order:

1. **winget** — `winget install --id <id> --exact --silent --accept-*-agreements
   --disable-interactivity [--scope …]`
   * already installed + *upgrade* enabled → `winget upgrade`
   * already installed + *skip* enabled → skipped
   * machine scope rejected by the package → automatically retried in user scope
2. **Chocolatey** — `choco install <id> -y --no-progress` (Chocolatey is bootstrapped if allowed)
3. **Direct download** — fetch `url` and run it with `silentArgs` (or `msiexec /qn` for `.msi`)

Exit codes `1641` / `3010` are reported as *restart required* rather than as failures.
Everything is written to the in-window log and to the log file.

macOS installs work the same way in spirit but through Homebrew only — see
[`mac/README.md`](mac/README.md) for the details of that engine.

## Project layout

```
catalog/catalog.json          shared catalogue, single source of truth (schema 2)
tools/verify-catalog.py       verifies every identifier against winget / Homebrew before a build
src/PackPilot/                Windows edition (.NET 7 WinForms, self-contained single file)
  Program.cs                  entry point, CLI dispatch
  Models/                     AppEntry, Catalog, DeploymentProfile, ProfileOptions
  Services/
    CatalogService.cs         embedded + external catalogue loading
    PackageManagers.cs        winget / Chocolatey discovery and bootstrap
    ProcessRunner.cs          process execution, output streaming, ANSI stripping
    InstallEngine.cs          install orchestration and result classification
    EmbeddedProfile.cs        profile ⇄ EXE packing (deployment installers)
    ScriptExporter.cs         PowerShell / batch / winget-import generation
    CliOptions.cs             argument parsing
    AppPaths.cs               paths and UAC elevation
    ConsoleHost.cs            console attachment for unattended mode
  UI/
    MainForm.cs               the picker
    DeployForm.cs             progress window
    Theme.cs                  colours and button styling
  Resources/catalog.json      embedded copy of the shared catalogue
mac/                          macOS edition (Swift 5.9 / SwiftUI, universal) — see mac/README.md
tests/PackPilot.Tests/        dependency-free test runner for the Windows edition (105 checks)
samples/                      example profiles
build.sh                      Windows build (verify catalogue → build → publish → test)
mac/build.sh                  macOS build (verify catalogue → build → bundle → sign)
```

## Building

Requires the .NET SDK (7.0 or newer). The Windows binary can be produced from **macOS, Linux or
Windows** thanks to `EnableWindowsTargeting`.

```bash
./build.sh                 # win-x64, verifies the catalogue, builds + tests
./build.sh --arm64         # also Windows on ARM
./build.sh --x86           # also Windows on x86
./build.sh --no-tests      # skip the test suite
./build.sh --no-catalog    # skip verifying the catalogue against the official package indexes
build.cmd                  # on Windows (also embeds icon/version resources)
```

The build compares the SHA-256 of the catalogue embedded in the published executable against
`catalog/catalog.json` and fails if they differ, so the shipped binary can never drift from the
shared file.

Tests run anywhere — they cover the catalogue, profile round-tripping, deployment-EXE packing
(including against the real published binary), script generation (validated with a genuine
PowerShell parser when `pwsh` is available) and CLI parsing.

```bash
dotnet run --project tests/PackPilot.Tests -c Release -- dist/PackPilot.exe   # 105 checks
```

> Building on macOS/Linux emits `NETSDK1074`: the Windows *icon and version resources* can only
> be stamped into the executable on Windows. The binary is fully functional either way — run
> `build.cmd` on Windows if you want those resources.

For the macOS edition:

```bash
cd mac
./build.sh              # universal .app, signed with Developer ID if present, zipped
./build.sh --no-sign    # ad-hoc signature, for local testing
./build.sh --notarize   # also submits to Apple's notary service and staples the ticket
swift test              # 40 tests
```

See [`mac/README.md`](mac/README.md) for the full build, testing and design details.

## Breaking changes in 2.0.0

* **Catalogue schema 1 → 2.** `winget` and `choco` moved into a `windows` block, a `macos` block
  was added, and `licence`, `summary` and `recommended` are now expected. A hand-written schema-1
  `catalog.json` override placed next to the executable will no longer populate the picker and
  must be migrated to the shape shown above.
* Five catalogue ids were renamed for consistency across platforms: `azcli` → `azurecli`,
  `githubcli` → `gh`, `nodelts` → `node`, `pwsh` → `powershell`, `wt` → `windowsterminal`.
  Existing `*.ppprofile` files that name them will skip those entries; re-save the profile.

Full details, including bug fixes and known issues for this release, are in
[`RELEASE_NOTES.md`](RELEASE_NOTES.md).

## Installing

**Windows 10/11 (x64).** Download `PackPilot-2.0.2-win-x64.zip`, extract, run `PackPilot.exe`.
No installer and no .NET runtime are required. The artifact is **unsigned** — SmartScreen will
warn on first run — and is not notarised or code-signed by any authority; verify the published
SHA-256 if you didn't build it yourself. It asks for administrator rights so it can install for
all users; decline and it installs for the current user instead.

### Installing the macOS edition

**macOS 13 or newer (Apple silicon and Intel).** Download
`PackPilot-1.0.1-macos-universal.zip`, extract, drag `PackPilot.app` to Applications. The build is
Developer ID signed and notarised, so it opens without a Gatekeeper warning and needs no `xattr`
step. Confirm it yourself with:

```bash
spctl --assess --type execute --verbose=2 "/Applications/PackPilot.app"
codesign -dv --verbose=4 "/Applications/PackPilot.app"
```

Homebrew is required; PackPilot offers to install it from the official script if it is missing.
See [`mac/README.md`](mac/README.md) for everything specific to that edition.

## Notes and limits

* Windows 10 1809+ / Windows 11. winget is bootstrapped automatically if missing.
* Packages install sequentially; a hung installer is killed after the configured timeout.
* Machine-scope installs need administrator rights; PackPilot elevates on demand.
* Some vendors (Microsoft Teams, OneDrive, Spotify) only support per-user installs — those are
  retried in user scope automatically.
* Applications the running platform cannot install (no `windows` or `macos` block) are filtered
  out of the picker entirely rather than offered and then failing.
* The Windows edition is unsigned. The macOS edition is Developer ID signed, notarised and
  stapled; see [Installing](#installing) above for platform-specific verification.

## Source licence

PackPilot is freeware, not open source. The source is published for transparency and reproducible
builds. Copyright remains with Patrick Hamid; see [`LICENSE`](LICENSE).
