From 35a138a33562454f7c23723b421fba79e03ed352 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 11 May 2026 14:09:17 -0400 Subject: [PATCH 1/2] feature: CI fat-script build pipeline + new OEM pattern docs Lands the foundation for the OEM redo design (#74): - GHA workflow + build script that walks src/ for .ps1 files, processes `# %INCLUDE ` markers, writes fat self-contained scripts to a separate `published` branch. Push to main moves @release tag; push to development moves @dev tag. Recursive includes rejected loudly. - src/ stub with README explaining the modular-source convention and marker syntax. - CLAUDE.md: new "Source vs Published" section (build pipeline + URL convention) and "OEM Vendor Scripts" section (internet-check at install step only, `configure` not `baseline` naming, canonical $env:BIOS_* translation pattern, standard OEM layout). - README.md: "Script Delivery" section with the canonical jsDelivr URL. - script-template-powershell.ps1: commented-out `# %INCLUDE` example block + commented-out internet-check stub. No OEM scripts move in this PR ... Dell prototype is a follow-up branched from this PR. --- .github/scripts/build-fat-scripts.ps1 | 114 ++++++++++++++++++ .github/workflows/build-fat-scripts.yml | 145 ++++++++++++++++++++++ CLAUDE.md | 152 ++++++++++++++++++++++++ README.md | 37 ++++++ script-template-powershell.ps1 | 35 ++++++ src/README.md | 77 ++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 .github/scripts/build-fat-scripts.ps1 create mode 100644 .github/workflows/build-fat-scripts.yml create mode 100644 src/README.md diff --git a/.github/scripts/build-fat-scripts.ps1 b/.github/scripts/build-fat-scripts.ps1 new file mode 100644 index 0000000..f3dfc14 --- /dev/null +++ b/.github/scripts/build-fat-scripts.ps1 @@ -0,0 +1,114 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Builds fat (self-contained) PowerShell scripts from modular sources in src/. + +.DESCRIPTION + Walks the SourceRoot recursively, finds every .ps1 file, processes any + `# %INCLUDE ` marker lines by inlining the + referenced file's content in place of the marker, and writes the resulting + fat script to the matching relative path under OutputRoot. + + Marker syntax: + + # %INCLUDE oem-shared/lib/oem-manufacturer-detect.ps1 + # %INCLUDE oem-dell/lib/dell-detection.ps1 + + Recursive includes are NOT supported in V1. Lib files cannot themselves + contain `# %INCLUDE` markers ... the build will fail loudly if it sees one. + +.PARAMETER RepoRoot + Repository root directory. Used to resolve include paths. + +.PARAMETER SourceRoot + Directory containing modular source .ps1 files. Typically /src. + +.PARAMETER OutputRoot + Directory where fat scripts will be written. The directory is created if + it does not exist; existing files at the same path are overwritten. + +.PARAMETER StripSrcPrefix + If set, the leading "src/" segment is stripped from the output path so + `src/oem-dell/dell-configure.ps1` becomes `oem-dell/dell-configure.ps1` + in the output. Default: $true. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $RepoRoot, + [Parameter(Mandatory)] [string] $SourceRoot, + [Parameter(Mandatory)] [string] $OutputRoot, + [bool] $StripSrcPrefix = $true +) + +$ErrorActionPreference = 'Stop' + +function Expand-Includes { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [string] $RepoRoot, + [switch] $IsIncluded + ) + + $content = Get-Content -Path $Path -Raw + if ($null -eq $content) { $content = '' } + $lines = $content -split "`r?`n" + $output = New-Object 'System.Collections.Generic.List[string]' + + foreach ($line in $lines) { + if ($line -match '^\s*#\s*%INCLUDE\s+(.+?)\s*$') { + if ($IsIncluded) { + throw "Recursive %INCLUDE detected in '$Path'. Lib files cannot contain `# %INCLUDE` markers in V1." + } + $rel = $matches[1].Trim() + $includePath = Join-Path -Path $RepoRoot -ChildPath $rel + if (-not (Test-Path -LiteralPath $includePath)) { + throw "Include not found: '$rel' (resolved to '$includePath'), referenced from '$Path'." + } + $inlined = Expand-Includes -Path $includePath -RepoRoot $RepoRoot -IsIncluded + $output.Add("# === inlined from $rel ===") + foreach ($l in ($inlined -split "`r?`n")) { + $output.Add($l) + } + $output.Add("# === end inline ===") + } else { + $output.Add($line) + } + } + + return ($output -join "`n") +} + +if (-not (Test-Path -LiteralPath $SourceRoot)) { + throw "SourceRoot '$SourceRoot' does not exist." +} + +if (-not (Test-Path -LiteralPath $OutputRoot)) { + New-Item -Path $OutputRoot -ItemType Directory -Force | Out-Null +} + +$sourceFiles = Get-ChildItem -LiteralPath $SourceRoot -Recurse -Filter '*.ps1' -File +$built = 0 + +foreach ($file in $sourceFiles) { + $relFromRepo = $file.FullName.Substring($RepoRoot.Length).TrimStart('\','/') + $relFromRepo = $relFromRepo -replace '\\','/' + + $outRel = $relFromRepo + if ($StripSrcPrefix -and $outRel -match '^src/(.+)$') { + $outRel = $matches[1] + } + + $outPath = Join-Path -Path $OutputRoot -ChildPath ($outRel -replace '/','\') + $outDir = Split-Path -Path $outPath -Parent + if (-not (Test-Path -LiteralPath $outDir)) { + New-Item -Path $outDir -ItemType Directory -Force | Out-Null + } + + $fat = Expand-Includes -Path $file.FullName -RepoRoot $RepoRoot + Set-Content -LiteralPath $outPath -Value $fat -Encoding UTF8 -NoNewline:$false + + Write-Host "built: $relFromRepo -> $outRel" + $built++ +} + +Write-Host "fat-script build complete: $built file(s)." diff --git a/.github/workflows/build-fat-scripts.yml b/.github/workflows/build-fat-scripts.yml new file mode 100644 index 0000000..512b159 --- /dev/null +++ b/.github/workflows/build-fat-scripts.yml @@ -0,0 +1,145 @@ +name: build-fat-scripts + +on: + push: + branches: + - development + - main + +permissions: + contents: write + +concurrency: + group: build-fat-scripts-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build and publish fat scripts + runs-on: windows-latest + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + path: source + + - name: Resolve source commit metadata + id: src + shell: pwsh + run: | + $sha = git -C "${{ github.workspace }}\source" rev-parse HEAD + $shortSha = git -C "${{ github.workspace }}\source" rev-parse --short HEAD + $subject = git -C "${{ github.workspace }}\source" log -1 --pretty=%s + $branch = "${{ github.ref_name }}" + "sha=$sha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "short_sha=$shortSha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "subject=$subject" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "branch=$branch" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Skip if no src/ directory + id: gate + shell: pwsh + run: | + $srcPath = "${{ github.workspace }}\source\src" + if (-not (Test-Path -LiteralPath $srcPath)) { + Write-Host "No src/ directory at source root ... nothing to build." + "has_src=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + $ps1Count = (Get-ChildItem -LiteralPath $srcPath -Recurse -Filter '*.ps1' -File -ErrorAction SilentlyContinue | Measure-Object).Count + if ($ps1Count -eq 0) { + Write-Host "src/ exists but contains no .ps1 files ... nothing to build." + "has_src=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + Write-Host "Found $ps1Count .ps1 file(s) under src/." + "has_src=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + } + + - name: Checkout published branch (or initialize) + if: steps.gate.outputs.has_src == 'true' + shell: pwsh + run: | + $repo = "${{ github.repository }}" + $token = "${{ secrets.GITHUB_TOKEN }}" + $remoteUrl = "https://x-access-token:$token@github.com/$repo.git" + + New-Item -Path "${{ github.workspace }}\published" -ItemType Directory -Force | Out-Null + Set-Location "${{ github.workspace }}\published" + + git init -b published | Out-Null + git remote add origin $remoteUrl + + $exists = $false + try { + git fetch --depth=1 origin published 2>$null + if ($LASTEXITCODE -eq 0) { $exists = $true } + } catch { $exists = $false } + + if ($exists) { + git reset --hard FETCH_HEAD + Write-Host "Loaded existing published branch tip." + } else { + Write-Host "published branch does not exist yet ... will be created on first push." + } + + # Wipe the working tree (preserve .git) so removed src files do not linger. + Get-ChildItem -Force | Where-Object { $_.Name -ne '.git' } | Remove-Item -Recurse -Force + + - name: Build fat scripts + if: steps.gate.outputs.has_src == 'true' + shell: pwsh + run: | + & "${{ github.workspace }}\source\.github\scripts\build-fat-scripts.ps1" ` + -RepoRoot "${{ github.workspace }}\source" ` + -SourceRoot "${{ github.workspace }}\source\src" ` + -OutputRoot "${{ github.workspace }}\published" + + - name: Configure git identity + if: steps.gate.outputs.has_src == 'true' + shell: pwsh + run: | + Set-Location "${{ github.workspace }}\published" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and push published branch + if: steps.gate.outputs.has_src == 'true' + id: publish + shell: pwsh + run: | + Set-Location "${{ github.workspace }}\published" + git add -A + $diff = git diff --cached --name-only + if ([string]::IsNullOrWhiteSpace($diff)) { + Write-Host "No changes to publish." + "published_sha=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + return + } + + $shortSha = "${{ steps.src.outputs.short_sha }}" + $subject = "${{ steps.src.outputs.subject }}" + $msg = "build: from $shortSha ""$subject""" + + git commit -m "$msg" + git push origin HEAD:published + + $publishedSha = git rev-parse HEAD + "published_sha=$publishedSha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Move release tag (push to main) + if: steps.gate.outputs.has_src == 'true' && steps.src.outputs.branch == 'main' && steps.publish.outputs.published_sha != '' + shell: pwsh + run: | + Set-Location "${{ github.workspace }}\published" + git tag -f release "${{ steps.publish.outputs.published_sha }}" + git push --force origin refs/tags/release + + - name: Move dev tag (push to development) + if: steps.gate.outputs.has_src == 'true' && steps.src.outputs.branch == 'development' && steps.publish.outputs.published_sha != '' + shell: pwsh + run: | + Set-Location "${{ github.workspace }}\published" + git tag -f dev "${{ steps.publish.outputs.published_sha }}" + git push --force origin refs/tags/dev diff --git a/CLAUDE.md b/CLAUDE.md index d397830..f1c8bee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is an MSP (Managed Service Provider) script library containing PowerShell scripts for automation, deployment, configuration, and management tasks across various platforms and vendors. Scripts are designed to be executed both interactively and via RMM (Remote Monitoring and Management) platforms. +## Source vs Published + +Scripts in this library are authored as **modular source** under `src/` and CI assembles them into **self-contained fat scripts** on the `published` branch for delivery to RMM endpoints. Two distinct trees, one direction of flow. + +``` +src/ (authored by humans, lives on development + main) + oem-shared/lib/oem-manufacturer-detect.ps1 + oem-dell/dell-configure.ps1 + oem-dell/lib/dell-detection.ps1 + | + | .github/workflows/build-fat-scripts.yml on push + v +published/ (CI output, lives on the `published` branch) + oem-dell/dell-configure.ps1 <- fat, self-contained, served via jsDelivr +``` + +**`# %INCLUDE` marker syntax.** Leaf scripts pull in shared lib code by adding a comment marker on its own line: + +```powershell +# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1 +# %INCLUDE src/oem-dell/lib/dell-detection.ps1 +``` + +Rules: +- Path is **relative to the repo root**. +- Build inlines the marker line with the referenced file's content, framed by `# === inlined from ===` / `# === end inline ===`. +- **Recursive includes are not supported in V1.** Lib files cannot themselves contain `# %INCLUDE` markers. Build will fail loudly if it sees one. +- Each include is per-occurrence ... two leaves that both include the same lib each get their own inlined copy. That's the point. + +**Why fat scripts, not runtime fetch.** Endpoints don't dot-source from the internet at runtime ... no hash placeholders, no fetch-and-verify dance, no invisible failure when a customer endpoint loses its route to jsDelivr. NinjaRMM downloads exactly one fat script, runs it, done. + +**Build triggers.** The GHA workflow at `.github/workflows/build-fat-scripts.yml` runs on push to `development` and `main`. It writes fat scripts to the matching relative path on the `published` branch (the leading `src/` is stripped, so `src/oem-dell/dell-configure.ps1` becomes `oem-dell/dell-configure.ps1` on `published`). Commit message names the source short SHA and subject (`build: from ""`). On push to `main` the `release` tag is moved to the new published commit; on push to `development` the `dev` tag is moved. + +**Canonical script URL convention.** NinjaRMM script presets reference scripts on `published` via jsDelivr: + +``` +https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@release/ +``` + +- `@release` ... production-pinned tag on `published`. Moves on each push to `main`. +- `@dev` ... staging tag on `published`. Moves on each push to `development`. +- `@` ... immutable per-build pin. Recommended for anything customer-facing where you want to lock the exact build at deploy time. + +The `published` branch is never authored by hand. Treat it as read-only build output. + +See `src/README.md` for the source-tree layout and full marker rules. + ## Code Architecture ### Script Structure Standard @@ -512,6 +559,111 @@ if ($veeamVersion -ge $requiredVersion) { } ``` +## OEM Vendor Scripts + +OEM scripts (`oem-dell/`, `oem-hp/`, `oem-lenovo/`, and the shared `oem-shared/`) follow patterns specific to the hardware-vendor space. The conventions below are canonical ... every OEM script in the library follows them. + +### Internet check at the install step only + +The internet check belongs **only** on leaves that pull a vendor binary from the internet (DCU install, HPIA install, LSU install). Configure / BIOS / debloat leaves operate on already-installed tooling and don't need a route to the vendor ... they should never gate on internet, because the configure step should still run on an endpoint that's offline at the moment but already has the OEM tooling installed. + +When you do need it, the check skips cleanly on offline so RMM doesn't log a hard failure for what is genuinely a no-op: + +```powershell +function Test-InternetAvailable { + try { + $resp = Invoke-WebRequest -Uri 'https://www.msftconnecttest.com/connecttest.txt' ` + -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + return ($resp.StatusCode -eq 200) + } catch { + return $false + } +} + +if (-not (Test-InternetAvailable)) { + Write-Host "No internet connectivity detected ... skipping vendor download. Exit 0." + Stop-Transcript + exit 0 +} +``` + +The Microsoft connect-test endpoint (`msftconnecttest.com`) is the same one Windows uses for its own connectivity probe; it's reachable from anywhere a modern Windows endpoint can reach the internet at all, and a captive-portal redirect will fail the status-code check so it doesn't fool the probe. + +### `configure` not `baseline` + +OEM scripts that apply BIOS settings, drive debloat, or otherwise set desired state are named `-configure.ps1`, not `-baseline.ps1`. + +**Why:** "baseline" implied static, repo-side configuration ... a single fixed policy file checked in for everyone. The actual model is the opposite: the RMM operator's env-var set is the desired state, per-customer and per-endpoint. There is no canonical policy file in the repo. Calling it `configure` makes that explicit; calling it `baseline` led people to look for the missing policy file. + +| Old name | New name | +|---|---| +| `dell-baseline.ps1` | `dell-configure.ps1` | +| `hp-baseline.ps1` | `hp-configure.ps1` | +| `lenovo-baseline.ps1` | `lenovo-configure.ps1` | + +### BIOS settings ... `$env:BIOS_*` translation table + +BIOS configuration is **operator-set via RMM env vars**, not a static `.cctk` / `.repset` / WMI policy file in the repo. Each setting is its own variable; a shared translation table maps the canonical name to the OEM-native syntax that the OEM's BIOS tool expects. + +**Required `$env:BIOS_*` variables (canonical names):** + +| Variable | Type | Meaning | +|---|---|---| +| `$env:BIOS_AdminPassword` | string | Current BIOS admin password. Required to apply settings on most platforms. Empty string means none set. | +| `$env:BIOS_AdminPasswordNew` | string | Set/change admin password. Empty string means "don't touch". | +| `$env:BIOS_TPMEnabled` | `Enabled` / `Disabled` | TPM module state. | +| `$env:BIOS_TPMActivation` | `Activated` / `Deactivated` | TPM activation state. | +| `$env:BIOS_SecureBoot` | `Enabled` / `Disabled` | UEFI Secure Boot. | +| `$env:BIOS_VirtualizationCPU` | `Enabled` / `Disabled` | CPU virtualization extensions (VT-x / AMD-V). | +| `$env:BIOS_VirtualizationIOMMU` | `Enabled` / `Disabled` | IOMMU (VT-d / AMD-Vi). | +| `$env:BIOS_WakeOnLAN` | `Enabled` / `Disabled` / `LANOnly` / `LANWLAN` | WoL behavior. Not every OEM supports every value. | +| `$env:BIOS_BootMode` | `UEFI` / `Legacy` | Boot mode. | + +**Translation table layout.** Each OEM's lib carries the mapping from canonical name to native syntax. The table is a hashtable keyed by canonical name; the value is a record that describes how to render the setting for that OEM's tooling: + +```powershell +# In src/oem-dell/lib/dell-bios-translation.ps1 (illustrative): +$Script:BiosSettingsMap = @{ + BIOS_TPMEnabled = @{ + Native = 'tpm' + Values = @{ Enabled = 'on'; Disabled = 'off' } + } + BIOS_SecureBoot = @{ + Native = 'secureboot' + Values = @{ Enabled = 'enabled'; Disabled = 'disabled' } + } + # ... etc +} +``` + +The `-configure.ps1` leaf iterates `$env:BIOS_*` variables, looks each up in the map, skips ones the platform doesn't support (with a log line), and emits the OEM-native command (e.g. `cctk --tpm=on` for Dell). Unrecognized canonical settings log a warning and continue ... a missing translation entry is not fatal because operators may set vars the script doesn't know about yet. + +**Why this shape:** +- No static policy files in the repo ... per-customer / per-endpoint config is operator-set, which matches how the RMM is actually used. +- Same canonical variable name across OEMs ... operators don't relearn the matrix per vendor. +- Translation tables are small, lib-local, and easy to extend. Adding a new setting is "add a row to each OEM's map". +- A setting that only exists on one OEM is supported by having an entry in only that OEM's map ... unsupported elsewhere falls through and logs. + +### OEM script layout + +Standard layout for each vendor: + +``` +src/oem-shared/ + lib/oem-manufacturer-detect.ps1 # Get-OEMManufacturer +src/oem-dell/ + dell-configure.ps1 # BIOS + other configure + dell-command-update-install.ps1 # idempotent install (needs internet check) + dell-command-update-run.ps1 # DCU scan + apply + dell-debloat.ps1 # remove Dell consumer software + lib/dell-detection.ps1 + lib/dell-bios-translation.ps1 +src/oem-hp/ (parallel) +src/oem-lenovo/ (parallel) +``` + +Every OEM leaf starts with `# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1` and exits cleanly (`exit 0`) on a non-matching manufacturer, so the same scripts can be deployed fleet-wide and self-skip on the wrong hardware. + ## Testing Scripts - **Interactive Testing**: Run script directly in PowerShell without setting `$env:RMM`. The script will prompt for `$env:Description` via `Read-Host`. diff --git a/README.md b/README.md index c18a9af..907c433 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,43 @@ $env:OrgName = "DTC" 3. Add any per-script variables (custom field names, paths, etc.) listed in the script's header comment block 4. Schedule or run on demand +## Script Delivery + +Scripts are authored as modular source under `src/` and CI assembles them into self-contained "fat" scripts on a separate `published` branch. RMM endpoints download the fat scripts from jsDelivr ... no runtime lib fetch, no hash placeholders, no invisible failures when an endpoint can't reach the lib server. + +``` +src/ (authored, lives on development + main) + oem-shared/lib/oem-manufacturer-detect.ps1 + oem-dell/dell-configure.ps1 + oem-dell/lib/dell-detection.ps1 + | + | CI: .github/workflows/build-fat-scripts.yml + v +published/ (CI output, lives on the `published` branch) + oem-dell/dell-configure.ps1 <- fat, self-contained, served via jsDelivr +``` + +**`# %INCLUDE` marker syntax** ... leaf scripts pull shared lib code with a comment marker: + +```powershell +# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1 +# %INCLUDE src/oem-dell/lib/dell-detection.ps1 +``` + +Build inlines each marker with the referenced file's content. Recursive includes are not supported in V1 (lib files cannot themselves contain `# %INCLUDE` markers). See `src/README.md`. + +**Canonical NinjaRMM script URL:** + +``` +https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@release/ +``` + +- `@release` ... production tag on `published`, advanced on each push to `main`. +- `@dev` ... staging tag on `published`, advanced on each push to `development`. +- `@` ... immutable per-build pin, recommended for anything customer-facing. + +The `published` branch is read-only build output ... never authored by hand. For deeper docs see `src/README.md` and the "Source vs Published" section of [`CLAUDE.md`](CLAUDE.md). + ## Repository Structure Scripts are organized by category prefix: diff --git a/script-template-powershell.ps1 b/script-template-powershell.ps1 index b8deeeb..1bc0ff0 100644 --- a/script-template-powershell.ps1 +++ b/script-template-powershell.ps1 @@ -28,6 +28,18 @@ # detection patterns, NinjaRMM custom field types, and the cross-context # detection pattern (user + system split with shared JSON state). +# --- Shared lib includes (optional) -------------------------------------- +# If this script needs helpers from oem-shared/lib/ or any other lib folder, +# author the script under src// and add `# %INCLUDE` marker +# lines below ... the CI build inlines the referenced files into the fat +# script that ships to RMM endpoints. The marker path is relative to the +# repo root. Recursive includes are NOT supported (lib files cannot +# themselves contain `# %INCLUDE` markers). See src/README.md. +# +# Example: +# # %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1 +# # %INCLUDE src/oem-dell/lib/dell-detection.ps1 + $ScriptLogName = "EnterLogNameHere.log" # --- Default optional RMM environment variables -------------------------- @@ -87,6 +99,29 @@ Write-Host "Description: $env:Description" Write-Host "Log path: $LogPath" Write-Host "RMM: $env:RMM" +# --- Internet check (optional) ------------------------------------------- +# Uncomment if this script needs internet for a vendor download (e.g. an +# OEM tooling installer like DCU, HPIA, LSU). Configure / BIOS / debloat +# leaves that operate on already-installed tooling should NOT include this +# check. Skip cleanly on offline so RMM doesn't flag a hard failure when +# the endpoint just doesn't have a route to the vendor. +# +# function Test-InternetAvailable { +# try { +# $resp = Invoke-WebRequest -Uri 'https://www.msftconnecttest.com/connecttest.txt' ` +# -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop +# return ($resp.StatusCode -eq 200) +# } catch { +# return $false +# } +# } +# +# if (-not (Test-InternetAvailable)) { +# Write-Host "No internet connectivity detected ... skipping vendor download. Exit 0." +# Stop-Transcript +# exit 0 +# } + # Your script logic goes here. Stop-Transcript diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..a65f870 --- /dev/null +++ b/src/README.md @@ -0,0 +1,77 @@ +# src/ + +Modular source for scripts that get assembled into self-contained "fat" scripts by CI before they're served to RMM endpoints. + +## Why this exists + +NinjaRMM (and every other RMM we deploy through) downloads exactly one PowerShell file when a script runs. Endpoints don't dot-source from the internet at runtime ... that adds a fetch-and-verify dance, hash placeholders, and a failure mode that's invisible until a customer endpoint silently breaks. + +So we author small, focused, reusable source files here under `src/`, and a CI job inlines them into one fat script per leaf at build time. The fat scripts get committed to the `published` branch and served via jsDelivr. + +``` +src/ (you author here) + oem-shared/lib/oem-manufacturer-detect.ps1 + oem-dell/dell-configure.ps1 + oem-dell/lib/dell-detection.ps1 + | + | GHA workflow: push to development or main + v +published/ (CI writes here, on a separate branch) + oem-dell/dell-configure.ps1 <-- fat, self-contained, served via jsDelivr +``` + +The `published` branch is never authored by hand. Treat it as read-only build output. + +## `# %INCLUDE` marker syntax + +A leaf script pulls in shared lib code by adding a comment marker on its own line: + +```powershell +# %INCLUDE oem-shared/lib/oem-manufacturer-detect.ps1 +# %INCLUDE oem-dell/lib/dell-detection.ps1 +``` + +Rules: + +- The path is **relative to the repo root** (not relative to `src/`). Example: `# %INCLUDE oem-shared/lib/oem-manufacturer-detect.ps1` resolves to `/oem-shared/lib/oem-manufacturer-detect.ps1` ... but in practice all lib sources live under `src/`, so the include path will start with `src/` for in-source libs (e.g. `# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1`). +- The match is strict: the line must begin with optional whitespace, then `#`, then optional whitespace, then `%INCLUDE`, then whitespace, then the path. Anything else is left alone. +- The marker line is replaced at build time by the included file's content, framed with `# === inlined from ===` / `# === end inline ===` so the fat output is greppable when something breaks. +- **Recursive includes are not supported in V1.** Lib files cannot themselves contain `# %INCLUDE` markers. The build will fail loudly if it sees one. If you want a lib to depend on another lib, the leaf should `# %INCLUDE` both, in dependency order. +- Each include is inlined per occurrence. Two leaves that both `# %INCLUDE` the same lib each get their own inlined copy ... that's the point of fat scripts. + +## How the build runs + +The workflow at `.github/workflows/build-fat-scripts.yml` triggers on push to `development` and `main`: + +1. Walks `src/` recursively, finds every `.ps1`. +2. Calls `.github/scripts/build-fat-scripts.ps1` to expand the `# %INCLUDE` markers. +3. Writes the fat output to the matching relative path on the `published` branch, with the leading `src/` stripped (so `src/oem-dell/dell-configure.ps1` becomes `oem-dell/dell-configure.ps1` in the published tree). +4. Commits with a message that names the source short SHA and subject (`build: from ""`). +5. On push to `main`, moves the `release` tag to the new published commit; on push to `development`, moves the `dev` tag. + +`release` is the production-pinned tag NinjaRMM URLs reference; `dev` is for staging script presets. + +## Layout convention + +Same `category-vendor` / `category-app` folder convention as the rest of the repo, just nested under `src/`: + +``` +src/oem-shared/lib/ +src/oem-dell/ +src/oem-dell/lib/ +src/oem-hp/ +src/oem-hp/lib/ +``` + +Lib files conventionally live in a `lib/` subfolder. Leaf scripts (the ones RMM actually invokes) live one level up. Same kebab-case file naming as the rest of the repo. + +## Source vs Published, at a glance + +| Concern | `src/` (this directory) | `published/` (branch, CI output) | +|---|---|---| +| Edited by | Humans, via PR | CI, never by hand | +| Contains | Small modular source files with `# %INCLUDE` markers | Self-contained fat scripts | +| Branch | `development` and `main` | `published` (separate orphan branch) | +| URL | Not served to RMM directly | `https://cdn.jsdelivr.net/gh/dtc-inc/msp-script-library@release/` | + +Production NinjaRMM script presets reference `@release` (a tag on `published`); staging or dev presets can reference `@dev`. Immutable per-version pins (`@`) are also valid and recommended for anything customer-facing where you want to lock the exact build. From 9ebf4263ca4cf4ac6c055ba6eec394f1b45bec45 Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Mon, 11 May 2026 16:36:14 -0400 Subject: [PATCH 2/2] improvement(docs): flatten lib layout to src/lib/ (no per-category lib subfolders) --- CLAUDE.md | 30 +++++++++++++++++------------- src/README.md | 25 ++++++++++++------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1c8bee..0f42d95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,9 @@ Scripts in this library are authored as **modular source** under `src/` and CI a ``` src/ (authored by humans, lives on development + main) - oem-shared/lib/oem-manufacturer-detect.ps1 + lib/oem-manufacturer-detect.ps1 + lib/dell-detection.ps1 oem-dell/dell-configure.ps1 - oem-dell/lib/dell-detection.ps1 | | .github/workflows/build-fat-scripts.yml on push v @@ -25,8 +25,8 @@ published/ (CI output, lives on the `published` branch) **`# %INCLUDE` marker syntax.** Leaf scripts pull in shared lib code by adding a comment marker on its own line: ```powershell -# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1 -# %INCLUDE src/oem-dell/lib/dell-detection.ps1 +# %INCLUDE src/lib/oem-manufacturer-detect.ps1 +# %INCLUDE src/lib/dell-detection.ps1 ``` Rules: @@ -622,7 +622,7 @@ BIOS configuration is **operator-set via RMM env vars**, not a static `.cctk` / **Translation table layout.** Each OEM's lib carries the mapping from canonical name to native syntax. The table is a hashtable keyed by canonical name; the value is a record that describes how to render the setting for that OEM's tooling: ```powershell -# In src/oem-dell/lib/dell-bios-translation.ps1 (illustrative): +# In src/lib/dell-bios-translation.ps1 (illustrative): $Script:BiosSettingsMap = @{ BIOS_TPMEnabled = @{ Native = 'tpm' @@ -646,23 +646,27 @@ The `-configure.ps1` leaf iterates `$env:BIOS_*` variables, looks each up i ### OEM script layout -Standard layout for each vendor: +Standard layout for each vendor. Libs live in flat `src/lib/`, not in per-vendor `lib/` subfolders: ``` -src/oem-shared/ - lib/oem-manufacturer-detect.ps1 # Get-OEMManufacturer +src/lib/ + oem-manufacturer-detect.ps1 # Get-OEMManufacturer + dell-detection.ps1 # Dell-specific helpers + dell-bios-translation.ps1 + hp-detection.ps1 (future) + lenovo-detection.ps1 (future) src/oem-dell/ dell-configure.ps1 # BIOS + other configure dell-command-update-install.ps1 # idempotent install (needs internet check) dell-command-update-run.ps1 # DCU scan + apply dell-debloat.ps1 # remove Dell consumer software - lib/dell-detection.ps1 - lib/dell-bios-translation.ps1 -src/oem-hp/ (parallel) -src/oem-lenovo/ (parallel) +src/oem-hp/ (future, parallel) +src/oem-lenovo/ (future, parallel) ``` -Every OEM leaf starts with `# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1` and exits cleanly (`exit 0`) on a non-matching manufacturer, so the same scripts can be deployed fleet-wide and self-skip on the wrong hardware. +Naming in `src/lib/` carries scope: `dell-*` is Dell-specific, no-prefix names like `oem-manufacturer-detect` are cross-cutting. Domain sub-folders (`lib/oem/`, `lib/m365/`) only when `lib/` grows enough to need them. + +Every OEM leaf starts with `# %INCLUDE src/lib/oem-manufacturer-detect.ps1` and exits cleanly (`exit 0`) on a non-matching manufacturer, so the same scripts can be deployed fleet-wide and self-skip on the wrong hardware. ## Testing Scripts diff --git a/src/README.md b/src/README.md index a65f870..416dca8 100644 --- a/src/README.md +++ b/src/README.md @@ -10,9 +10,9 @@ So we author small, focused, reusable source files here under `src/`, and a CI j ``` src/ (you author here) - oem-shared/lib/oem-manufacturer-detect.ps1 + lib/oem-manufacturer-detect.ps1 + lib/dell-detection.ps1 oem-dell/dell-configure.ps1 - oem-dell/lib/dell-detection.ps1 | | GHA workflow: push to development or main v @@ -27,13 +27,13 @@ The `published` branch is never authored by hand. Treat it as read-only build ou A leaf script pulls in shared lib code by adding a comment marker on its own line: ```powershell -# %INCLUDE oem-shared/lib/oem-manufacturer-detect.ps1 -# %INCLUDE oem-dell/lib/dell-detection.ps1 +# %INCLUDE src/lib/oem-manufacturer-detect.ps1 +# %INCLUDE src/lib/dell-detection.ps1 ``` Rules: -- The path is **relative to the repo root** (not relative to `src/`). Example: `# %INCLUDE oem-shared/lib/oem-manufacturer-detect.ps1` resolves to `/oem-shared/lib/oem-manufacturer-detect.ps1` ... but in practice all lib sources live under `src/`, so the include path will start with `src/` for in-source libs (e.g. `# %INCLUDE src/oem-shared/lib/oem-manufacturer-detect.ps1`). +- The path is **relative to the repo root** (not relative to `src/`). All lib sources live under `src/lib/`, so include paths start with `src/lib/` (e.g. `# %INCLUDE src/lib/oem-manufacturer-detect.ps1`). - The match is strict: the line must begin with optional whitespace, then `#`, then optional whitespace, then `%INCLUDE`, then whitespace, then the path. Anything else is left alone. - The marker line is replaced at build time by the included file's content, framed with `# === inlined from ===` / `# === end inline ===` so the fat output is greppable when something breaks. - **Recursive includes are not supported in V1.** Lib files cannot themselves contain `# %INCLUDE` markers. The build will fail loudly if it sees one. If you want a lib to depend on another lib, the leaf should `# %INCLUDE` both, in dependency order. @@ -53,17 +53,16 @@ The workflow at `.github/workflows/build-fat-scripts.yml` triggers on push to `d ## Layout convention -Same `category-vendor` / `category-app` folder convention as the rest of the repo, just nested under `src/`: - ``` -src/oem-shared/lib/ -src/oem-dell/ -src/oem-dell/lib/ -src/oem-hp/ -src/oem-hp/lib/ +src/lib/ one flat directory for ALL shared helpers +src/oem-dell/ leaf scripts per category (no lib/ subfolder) +src/oem-hp/ (future) +src/oem-lenovo/ (future) ``` -Lib files conventionally live in a `lib/` subfolder. Leaf scripts (the ones RMM actually invokes) live one level up. Same kebab-case file naming as the rest of the repo. +**Lib files all live in `src/lib/`**, flat. Naming carries scope: `dell-detection.ps1` is Dell-specific, `oem-manufacturer-detect.ps1` is cross-OEM, `m365-graph-auth.ps1` would be M365-specific. Domain sub-folders (`lib/oem/`, `lib/m365/`) only when `lib/` grows enough to need them ... don't add hierarchy ahead of need. + +Leaf scripts (the ones RMM actually invokes) live in category folders without `lib/` subfolders. Same `category-vendor` / `category-app` folder convention as the rest of the repo, just nested under `src/`. ## Source vs Published, at a glance