diff --git a/.planning/phases/02-generators/02-RESEARCH.md b/.planning/phases/02-generators/02-RESEARCH.md new file mode 100644 index 0000000..689e1da --- /dev/null +++ b/.planning/phases/02-generators/02-RESEARCH.md @@ -0,0 +1,564 @@ +# Phase 2: Generators - Research + +**Researched:** 2026-03-26 +**Domain:** Pure TypeScript generator functions (rclone INI conf), PowerShell script templating (Intune install, Intune detection, RMM), PowerShell encoding (UTF-8 no-BOM in SYSTEM context) +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| CONF-01 | App generates a valid rclone.conf file (INI format, correct key/value pairs per backend type) | rclone INI format verified from official docs; backend type values and field keys verified from rclone.org | +| DEPL-01 | App generates a PowerShell install script for MS Intune (runs as SYSTEM, places config at machine-wide path, idempotent, correct exit codes) | SYSTEM context encoding requirements verified from Microsoft Learn; idempotency patterns documented | +| DEPL-02 | App generates a separate Intune detection script (checks rclone.exe presence at install path AND config file presence; exits 0 if both found) | Intune detection script contract (exit 0 + STDOUT) verified from Microsoft Learn; STDERR-breaks-detection pitfall documented | +| DEPL-03 | App generates a PowerShell script for RMM tools (generic SYSTEM-context, idempotent, works across NinjaRMM/Datto/ConnectWise) | Self-contained idempotent PowerShell pattern documented; no RMM-specific SDK required | +| DEPL-04 | User can toggle "Include rclone installation" to add rclone binary download step to generated scripts | rclone official download URL pattern verified; conditional script section design documented | +| DEPL-05 | User can choose config deployment path: machine-wide (`C:\ProgramData\rclone\`) vs user profile (`%APPDATA%\rclone\`) | Both Windows path conventions documented; SYSTEM context caveat for %APPDATA% documented | + + +--- + +## Summary + +Phase 2 implements pure generator functions: given a completed `WizardState`, produce correct string content for rclone.conf and all PowerShell scripts. These are the most correctness-critical deliverables in the entire project — a wrong rclone config key silently breaks authentication, a wrong PowerShell encoding breaks Intune script upload, and a detection script that writes to STDERR fails to detect even when the app is installed. + +All generators are pure TypeScript functions with no React dependencies. They take `WizardState` as input and return a string. This makes them trivially unit-testable and completely decoupled from UI. The rclone.conf generator reads `remote.backendType`, `remote.name`, and `remote.params` from state; the PowerShell generators additionally read `deployment.includeInstall`, `deployment.configPath`, and `deployment.scriptTargets`. The `configPath` option maps to literal Windows paths: `machine-wide` → `C:\ProgramData\rclone\`, `user-profile` → `%APPDATA%\rclone\`. + +The single most complex research finding is PowerShell encoding: Intune recommends UTF-8 **with** BOM for detection scripts (so Windows PowerShell 5.1 correctly handles non-ASCII characters), but many community sources say no-BOM. For scripts that contain only ASCII characters (which all generated scripts do — all credential values are treated as opaque strings), this distinction is moot. The generator outputs plain JavaScript strings; the encoding is only relevant if Phase 4 downloads the script as a file. Phase 2 does not handle file saving — it only produces string content. The encoding concern is therefore a Phase 4 (download manager) concern, not a Phase 2 concern. + +**Primary recommendation:** Implement all generators as pure TypeScript functions in `src/generators/`. Each function takes `WizardState` and returns `string`. Test with Vitest unit tests that assert specific substrings are present or absent in the output. No templating library is needed — template literals are sufficient and produce readable, auditable script content. + +--- + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| TypeScript template literals | Built-in | String generation for all outputs | Pure functions, no dep, fully auditable — you can read the exact PS code that will be generated | +| WizardState (Phase 1) | Already built | Input to all generators | Phase 1 types.ts already defines the full state shape; generators consume it directly | +| Vitest | 2.x (already installed) | Unit testing generator output | Already configured in vitest.config.ts with node environment — ideal for pure string functions | + +### No Additional Libraries Needed +Phase 2 adds zero new npm dependencies. All generation is string manipulation over the existing `WizardState` type. The BACKEND_REGISTRY from Phase 1 (`src/schemas/registry.ts`) provides the field-to-key mapping that the rclone.conf generator iterates over. + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Template literals | Handlebars / Mustache | Template engines add a dependency and obscure the output. With template literals, the generated script is plainly readable in source code — critical for a security-sensitive tool. | +| Template literals | Tagged template literals | Unnecessary complexity for this use case; plain template literals are cleaner | +| Inline type assertions | Zod parse of WizardState before generation | Overkill — WizardState already typed; generators can assume valid input (wizard validates before reaching review step) | + +--- + +## Architecture Patterns + +### Recommended Project Structure +``` +src/ +├── generators/ +│ ├── rclone-conf.ts # buildRcloneConf(state) → string +│ ├── intune-install.ts # buildIntuneInstall(state) → string +│ ├── intune-detection.ts # buildIntuneDetection(state) → string +│ ├── rmm-script.ts # buildRmmScript(state) → string +│ ├── index.ts # Re-exports all four generators +│ ├── rclone-conf.test.ts # Unit tests for CONF-01 +│ ├── intune-install.test.ts +│ ├── intune-detection.test.ts +│ └── rmm-script.test.ts +├── schemas/ # Phase 1 — unchanged +└── store/ # Phase 1 — unchanged +``` + +### Pattern 1: rclone.conf INI Generator + +**What:** Produces a valid INI-format rclone.conf string. The INI format is: `[remoteName]` section header, then `key = value` pairs, one per line, blank line between sections (only one remote in v1). The mandatory `type` field maps `BackendType` to rclone's internal backend name. + +**BackendType → rclone `type` mapping (HIGH confidence — verified from rclone.org):** +- `'azureblob'` → `type = azureblob` +- `'s3'` → `type = s3` +- `'s3-compatible'` → `type = s3` (NOT `s3-compatible`; rclone uses `type = s3` with `provider = Other` for all S3-compatible storage) + +**When to use:** Always first — the rclone.conf content is the foundation; scripts reference the config path. + +**Example:** +```typescript +// src/generators/rclone-conf.ts +import type { WizardState } from '../store/types'; + +const BACKEND_TYPE_MAP: Record = { + azureblob: 'azureblob', + s3: 's3', + 's3-compatible': 's3', +}; + +export function buildRcloneConf(state: WizardState): string { + const { name, backendType, params } = state.remote; + if (!backendType || !name) { + throw new Error('buildRcloneConf: remote name and backendType are required'); + } + + const rcloneType = BACKEND_TYPE_MAP[backendType]; + const lines: string[] = [`[${name}]`, `type = ${rcloneType}`]; + + for (const [key, value] of Object.entries(params)) { + if (value !== '') { + lines.push(`${key} = ${value}`); + } + } + + return lines.join('\n') + '\n'; +} +``` + +**Correct rclone.conf output for Azure Blob (account + SAS URL):** +```ini +[my-azure] +type = azureblob +account = mystorageaccount +sas_url = https://mystorageaccount.blob.core.windows.net/?sv=... +``` + +**Correct rclone.conf output for S3:** +```ini +[my-s3] +type = s3 +provider = AWS +access_key_id = AKIAIOSFODNN7EXAMPLE +secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +region = us-east-1 +``` + +**Correct rclone.conf output for S3-compatible (Wasabi example):** +```ini +[my-wasabi] +type = s3 +provider = Other +access_key_id = mykey +secret_access_key = mysecret +endpoint = https://s3.wasabisys.com +``` + +### Pattern 2: Intune Install Script Generator + +**What:** A PowerShell script that runs as SYSTEM, creates the config directory, writes the rclone.conf content, and optionally downloads rclone.exe. Must be idempotent (safe to run twice). Must exit with code 0 on success, non-zero on error. + +**Key requirements (HIGH confidence — verified from Microsoft Learn):** +- Runs as SYSTEM — no user context, no `$env:USERPROFILE`, no interactive prompts +- Machine-wide path: `C:\ProgramData\rclone\` — accessible by SYSTEM account +- User profile path: `%APPDATA%\rclone\` — **caution**: `%APPDATA%` resolves to `C:\Windows\system32\config\systemprofile\AppData\Roaming` when running as SYSTEM, NOT the logged-in user's APPDATA. This is documented behavior and should be surfaced as a UI warning in Phase 3. +- `[System.IO.File]::WriteAllText()` with explicit `System.Text.UTF8Encoding($false)` for UTF-8 no-BOM file writing (required because PowerShell 5.1's `Set-Content -Encoding UTF8` writes UTF-8 **with** BOM) +- `exit 0` / `exit 1` at end of script — Intune reads the process exit code + +**rclone download URL (HIGH confidence — verified from rclone.org/install/):** +``` +https://downloads.rclone.org/rclone-current-windows-amd64.zip +``` +This always points to the latest stable release. It is the official URL from rclone.org. + +**Example:** +```powershell +# Generated by buildIntuneInstall() +$ErrorActionPreference = 'Stop' + +$configDir = 'C:\ProgramData\rclone' +$configPath = "$configDir\rclone.conf" + +# Idempotent: create directory if it doesn't exist +if (-not (Test-Path $configDir)) { + New-Item -ItemType Directory -Path $configDir -Force | Out-Null +} + +# Write config using UTF-8 no-BOM encoding +$confContent = @' +[my-azure] +type = azureblob +account = mystorageaccount +key = BASE64KEY +'@ +[System.IO.File]::WriteAllText($configPath, $confContent, [System.Text.UTF8Encoding]::new($false)) + +# Optional: download rclone.exe (only if includeInstall = true) +$rclonePath = "$configDir\rclone.exe" +if (-not (Test-Path $rclonePath)) { + $zipPath = "$env:TEMP\rclone.zip" + Invoke-WebRequest -Uri 'https://downloads.rclone.org/rclone-current-windows-amd64.zip' -OutFile $zipPath -UseBasicParsing + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + $entry = $zip.Entries | Where-Object { $_.Name -eq 'rclone.exe' } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $rclonePath, $true) + $zip.Dispose() + Remove-Item $zipPath -Force +} + +exit 0 +``` + +### Pattern 3: Intune Detection Script Generator + +**What:** A PowerShell script that checks whether rclone.exe AND rclone.conf both exist at the expected paths. Exits 0 and writes to STDOUT when both are found. Exits non-zero when either is missing. + +**Intune detection contract (HIGH confidence — verified from Microsoft Learn docs updated 2026-02-06):** +- Exit code 0 + STDOUT output → app is detected (installed) +- Exit code non-zero → app is not detected +- Any output to STDERR → detection result is "not installed", even if exit code is 0 and STDOUT has data + +**Critical:** The detection script must NEVER write to STDERR. Use `try/catch` and handle errors silently or exit non-zero rather than letting PowerShell write an exception to STDERR. + +**Example:** +```powershell +# Generated by buildIntuneDetection() +$configDir = 'C:\ProgramData\rclone' +$rclonePath = "$configDir\rclone.exe" +$configPath = "$configDir\rclone.conf" + +if ((Test-Path $rclonePath) -and (Test-Path $configPath)) { + Write-Output "rclone and config detected" + exit 0 +} else { + exit 1 +} +``` + +Note: No `$ErrorActionPreference = 'Stop'` here — exceptions would go to STDERR and break detection. + +### Pattern 4: RMM Script Generator + +**What:** A self-contained PowerShell script for generic RMM tools (NinjaRMM, Datto, ConnectWise). Same logic as the Intune install script but without Intune-specific packaging concerns. Must be idempotent. + +**RMM context considerations (MEDIUM confidence — community knowledge, no single authoritative source):** +- All major RMM platforms run scripts as SYSTEM +- Scripts must be self-contained — no external file dependencies, no IntuneWinAppUtil packaging +- Path behavior under SYSTEM is the same as Intune (same `%APPDATA%` caveat applies) +- No specific exit code contract (unlike Intune) — use `exit 0` / `exit 1` as standard practice +- Include `$ErrorActionPreference = 'Stop'` so failures are surfaced to the RMM platform + +**Structural difference from Intune install:** The RMM script has no separate "detection" artifact — detection is handled by the RMM platform's own file/registry check capability. The generated script only installs. + +### Pattern 5: Conditional rclone Install Block + +**What:** When `deployment.includeInstall = true`, all three script generators (Intune install, RMM) include a download + extract section. When `false`, that section is omitted. + +**Implementation:** Each generator function receives the full `WizardState`. A helper function `buildRcloneInstallBlock(installPath: string): string` returns the download/extract PowerShell block or empty string, and is called conditionally. + +```typescript +function buildRcloneInstallBlock(installPath: string): string { + return ` +# Download rclone binary +$rclonePath = "${installPath}\\rclone.exe" +if (-not (Test-Path $rclonePath)) { + $zipPath = "$env:TEMP\\rclone.zip" + Invoke-WebRequest -Uri 'https://downloads.rclone.org/rclone-current-windows-amd64.zip' -OutFile $zipPath -UseBasicParsing + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + $entry = $zip.Entries | Where-Object { $_.Name -eq 'rclone.exe' } + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $rclonePath, $true) + $zip.Dispose() + Remove-Item $zipPath -Force +}`; +} +``` + +### Pattern 6: Config Path Resolution + +**What:** `deployment.configPath` maps to absolute Windows paths in all generated scripts. + +```typescript +const CONFIG_PATHS: Record = { + 'machine-wide': 'C:\\ProgramData\\rclone', + 'user-profile': '%APPDATA%\\rclone', +}; +``` + +The Intune detection script must use the same path the install script wrote to. Both generators must share this mapping (import from a shared constants file or directly from the config path mapping). + +### Anti-Patterns to Avoid + +- **Echoing credential values in scripts:** The install script must write the config file content using a PowerShell here-string (`@'...'@`), not by echoing individual field values with `Write-Host` or `echo`. A `Write-Host "account = $account"` would leak credentials to the Intune management extension log. +- **Using `Set-Content -Encoding UTF8` in PowerShell 5.1:** This writes UTF-8 **with BOM**. Use `[System.IO.File]::WriteAllText()` with an explicit `UTF8Encoding($false)` instance instead. +- **Using `$ErrorActionPreference = 'Stop'` in detection scripts:** Unhandled exceptions write to STDERR, which causes Intune to report the app as "not installed" even when it is. +- **Hardcoding `C:\ProgramData\rclone` in the detection script independently from the install script:** If the install path is user-profile, the detection script must check `%APPDATA%\rclone`, not the machine-wide path. Both scripts must derive their path from the same `deployment.configPath` value. +- **Using `Invoke-WebRequest` without `-UseBasicParsing` in SYSTEM context:** IE's first-run wizard may not be completed for the SYSTEM account, causing `Invoke-WebRequest` to hang without this flag on Windows Server and some Windows 10 builds. +- **s3-compatible using `type = s3-compatible` in rclone.conf:** The correct rclone type value is `s3` with `provider = Other`. Using `s3-compatible` as the type string will cause rclone to fail to find the backend. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| INI file serialization | Custom INI writer | Template literal line-by-line | rclone.conf INI is trivially simple — one section, flat key=value. No library needed and any library adds unnecessary complexity. | +| PowerShell templating | Handlebars / Mustache | Template literals | Scripts are short and fully auditable. Template engines obscure the generated output. | +| UTF-8 no-BOM file writing | Custom byte manipulation | `[System.IO.File]::WriteAllText(path, content, [System.Text.UTF8Encoding]::new($false))` | This is the canonical PowerShell 5.1 idiom for no-BOM UTF-8 writing. | +| rclone binary distribution | Bundle rclone.exe | Download from `downloads.rclone.org` at deploy time | Bundling adds binary to the web app; download URL is official and always points to latest stable. | +| Script zip/archive | JSZip at generation time | Return plain strings; Phase 4 handles zipping | Separating string generation from file operations keeps generators pure and easily testable. | + +**Key insight:** All generation complexity in this domain comes from correctness constraints (right keys, right encoding, right exit codes), not from algorithmic complexity. Keep generators as simple readable template-literal functions. Every character of the generated PowerShell should be visible in the TypeScript source. + +--- + +## Common Pitfalls + +### Pitfall 1: s3-compatible type value in rclone.conf + +**What goes wrong:** Generator writes `type = s3-compatible` for S3-compatible backends. rclone does not recognize this type string; the remote fails silently or throws "didn't find section in config file". + +**Why it happens:** The BackendType in our TypeScript is `'s3-compatible'`, which is the UI identifier. The rclone config `type` value is different: all S3 and S3-compatible remotes use `type = s3`, differentiated by the `provider` field (`AWS` for AWS S3, `Other` for S3-compatible). + +**How to avoid:** The generator must have an explicit mapping: `{ 's3-compatible': 's3' }`. The registry already sets `provider = Other` for S3-compatible backends — the generator just needs to map the type value correctly. + +**Warning signs:** Test that asserts output contains `type = s3` for an s3-compatible state, and `provider = Other`. + +### Pitfall 2: Detection script STDERR contamination + +**What goes wrong:** The detection script includes `$ErrorActionPreference = 'Stop'`. An exception (e.g., access denied, path not found on an alternate drive) writes to STDERR. Intune sees STDERR output and reports the app as "not installed" even though it checked correctly and returned exit 0. + +**Why it happens:** The Intune detection contract is: exit 0 + STDOUT → installed. Any STDERR → not installed, regardless of exit code. + +**How to avoid:** Detection scripts must not set `$ErrorActionPreference = 'Stop'`. Use `Test-Path` which never throws — it returns `$false` if a path is inaccessible or doesn't exist. + +**Warning signs:** Detection script passes local tests but Intune always shows "not installed" in the portal. + +### Pitfall 3: %APPDATA% resolves differently under SYSTEM + +**What goes wrong:** User selects "user profile" config path. The install script uses `%APPDATA%\rclone\` and the path resolves to `C:\Windows\system32\config\systemprofile\AppData\Roaming\rclone\` under the SYSTEM account. The user expects the config to appear in their own `C:\Users\\AppData\Roaming\rclone\`. + +**Why it happens:** `%APPDATA%` is a per-user environment variable. Under SYSTEM, it resolves to the SYSTEM account's roaming profile, not the logged-in user's profile. + +**How to avoid:** This is a documentation/UX concern more than a generator concern. The generator correctly uses `%APPDATA%` when `configPath = 'user-profile'`. Phase 3 should surface a warning when the user selects user-profile path with Intune as a target. For Phase 2, document this in the test for the user-profile path. + +**Warning signs:** Config file not found at expected user path after Intune deployment. + +### Pitfall 4: Empty optional fields included in rclone.conf + +**What goes wrong:** For Azure Blob, user fills in `account` and `key` but leaves `sas_url` empty. The generator includes `sas_url = ` (empty value) in the output. rclone may interpret an empty `sas_url` key differently from an absent one, potentially causing auth failures. + +**Why it happens:** Generator iterates over all `params` entries without filtering empty strings. + +**How to avoid:** The generator must skip key/value pairs where value is empty string: `if (value !== '') { lines.push(...) }`. This is included in the Pattern 1 code example above. + +**Warning signs:** Generated config contains lines like `sas_url = ` or `region = `. + +### Pitfall 5: Credential values visible in PowerShell transcript logs + +**What goes wrong:** The install script uses `Write-Host "Writing config..."` and then interpolates the credential value into a string that gets logged. Intune Management Extension logs can be read by any local admin. + +**Why it happens:** Developer writes `$confContent = "account = $account"` and the variable contains the actual access key. + +**How to avoid:** Write all credential content inside a PowerShell here-string literal (`@'...'@` — single-quoted, no interpolation). Better yet: the generator embeds the literal credential values directly into the here-string, which is a string in JavaScript. The PowerShell script receives the conf content as a verbatim string with no variable interpolation happening at run time. + +**Warning signs:** Any `$` variable interpolation inside the config content block of the generated script. + +--- + +## Code Examples + +### buildRcloneConf — complete implementation pattern +```typescript +// src/generators/rclone-conf.ts +import type { WizardState } from '../store/types'; + +// Maps our BackendType to rclone's internal type string +// IMPORTANT: s3-compatible uses 'type = s3' — not 's3-compatible' +const RCLONE_TYPE_MAP: Record = { + azureblob: 'azureblob', + s3: 's3', + 's3-compatible': 's3', +}; + +export function buildRcloneConf(state: WizardState): string { + const { name, backendType, params } = state.remote; + if (!backendType) throw new Error('backendType is required'); + if (!name) throw new Error('remote name is required'); + + const rcloneType = RCLONE_TYPE_MAP[backendType]; + const lines: string[] = [`[${name}]`, `type = ${rcloneType}`]; + + for (const [key, value] of Object.entries(params)) { + if (value !== '') { + lines.push(`${key} = ${value}`); + } + } + + return lines.join('\n') + '\n'; +} +``` + +### buildIntuneDetection — complete implementation pattern +```typescript +// src/generators/intune-detection.ts +import type { WizardState } from '../store/types'; + +const CONFIG_DIR: Record = { + 'machine-wide': 'C:\\ProgramData\\rclone', + 'user-profile': '%APPDATA%\\rclone', +}; + +export function buildIntuneDetection(state: WizardState): string { + const configDir = CONFIG_DIR[state.deployment.configPath]; + // Note: No $ErrorActionPreference = 'Stop' — exceptions go to STDERR and break detection + return [ + `$configDir = '${configDir}'`, + `$rclonePath = "$configDir\\rclone.exe"`, + `$configPath = "$configDir\\rclone.conf"`, + ``, + `if ((Test-Path $rclonePath) -and (Test-Path $configPath)) {`, + ` Write-Output "rclone and config detected"`, + ` exit 0`, + `} else {`, + ` exit 1`, + `}`, + ].join('\n') + '\n'; +} +``` + +### Vitest test pattern for generator output +```typescript +// src/generators/rclone-conf.test.ts +import { describe, it, expect } from 'vitest'; +import { buildRcloneConf } from './rclone-conf'; +import { INITIAL_STATE } from '../store/types'; +import type { WizardState } from '../store/types'; + +const azureState: WizardState = { + ...INITIAL_STATE, + remote: { + name: 'my-azure', + backendType: 'azureblob', + params: { account: 'mystorageaccount', key: 'BASE64KEY', sas_url: '' }, + }, +}; + +describe('buildRcloneConf', () => { + it('produces [remoteName] section header', () => { + expect(buildRcloneConf(azureState)).toContain('[my-azure]'); + }); + + it('produces type = azureblob for azureblob backend', () => { + expect(buildRcloneConf(azureState)).toContain('type = azureblob'); + }); + + it('produces type = s3 for s3-compatible backend (not s3-compatible)', () => { + const s3CompatState: WizardState = { + ...INITIAL_STATE, + remote: { + name: 'my-wasabi', + backendType: 's3-compatible', + params: { provider: 'Other', access_key_id: 'k', secret_access_key: 's', endpoint: 'https://s3.wasabisys.com', region: '' }, + }, + }; + const conf = buildRcloneConf(s3CompatState); + expect(conf).toContain('type = s3'); + expect(conf).not.toContain('type = s3-compatible'); + }); + + it('omits empty optional fields', () => { + const conf = buildRcloneConf(azureState); // sas_url = '' + expect(conf).not.toContain('sas_url'); + }); +}); +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `Set-Content -Encoding UTF8` for UTF-8 file writing | `[System.IO.File]::WriteAllText(path, content, [System.Text.UTF8Encoding]::new($false))` | PS 5.1 limitation (unchanged) | `Set-Content -Encoding UTF8` always adds BOM in PS 5.1; the .NET method is the reliable no-BOM path | +| `Invoke-WebRequest` without flags | `Invoke-WebRequest -UseBasicParsing` | Consistent guidance for years | Without `-UseBasicParsing`, the IE engine is invoked; on SYSTEM accounts this can hang | +| Intune PowerShell detection: any output format | Exit 0 + Write-Output + NO Write-Error / no STDERR | Consistent Intune behavior | STDERR output (even with exit 0) marks app as not detected | + +**Deprecated/outdated:** +- `Add-Content` for file writing in PS 5.1: auto-detects encoding from existing file; unreliable for new files, defaults to ANSI. Do not use. +- `Out-File -Encoding UTF8` in PS 5.1: writes UTF-8 with BOM. Avoid. + +--- + +## Open Questions + +1. **`%APPDATA%` user-profile path in SYSTEM context** + - What we know: Under SYSTEM, `%APPDATA%` resolves to `C:\Windows\system32\config\systemprofile\AppData\Roaming\` — not the logged-in user's profile. This is confirmed Windows behavior. + - What's unclear: Whether the user-profile option is intended for manual deployment (user runs the script themselves) vs SYSTEM deployment. The REQUIREMENTS.md does not specify. + - Recommendation: Generate the script with `%APPDATA%\rclone` as the path when `configPath = 'user-profile'`, and add an inline comment in the generated script warning that under SYSTEM context this path resolves to the SYSTEM profile, not the current user. Leave the UX warning for Phase 3. + +2. **Intune: UTF-8 BOM vs no-BOM recommendation contradiction** + - What we know: Microsoft Learn (2026-02-06) says "We recommend encoding your script as UTF-8 BOM" for detection scripts. Other Intune documentation says no-BOM. For Phase 2, generators produce plain JavaScript strings — encoding is irrelevant at this layer. + - What's unclear: Whether BOM matters for scripts containing only ASCII characters. (It does not — BOM only matters when non-ASCII characters are present.) + - Recommendation: All generated scripts use only ASCII characters (credential values are opaque strings, path names are ASCII). The BOM question is a Phase 4 (file download) concern. Mark for Phase 4 research. + +3. **rclone.exe download: 32-bit vs 64-bit architecture** + - What we know: The URL `https://downloads.rclone.org/rclone-current-windows-amd64.zip` targets x64. Most Windows endpoints managed by Intune/RMM are x64. + - What's unclear: Whether to auto-detect architecture or hardcode x64. + - Recommendation: Hardcode `amd64` in v1. The vast majority of managed Windows endpoints are x64. Add architecture selection as a v2 enhancement if needed. + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Vitest 4.1.1 (already installed) | +| Config file | `vitest.config.ts` — exists, `environment: 'node'`, `globals: true` | +| Quick run command | `npm test` (runs `vitest run`) | +| Full suite command | `npm test` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| CONF-01 | `buildRcloneConf()` produces valid INI with correct type and key/value pairs for all three backends | unit | `npm test -- src/generators/rclone-conf.test.ts` | ❌ Wave 0 | +| DEPL-01 | Intune install script contains config directory creation, UTF-8 no-BOM write idiom, correct path, exit 0 | unit (substring assertions) | `npm test -- src/generators/intune-install.test.ts` | ❌ Wave 0 | +| DEPL-02 | Detection script exits 0 with Write-Output when both files present; exits 1 otherwise; no $ErrorActionPreference Stop | unit (substring assertions) | `npm test -- src/generators/intune-detection.test.ts` | ❌ Wave 0 | +| DEPL-03 | RMM script is self-contained, contains directory creation and config write, no Intune-specific constructs | unit (substring assertions) | `npm test -- src/generators/rmm-script.test.ts` | ❌ Wave 0 | +| DEPL-04 | With `includeInstall=true`: all three scripts contain rclone download URL; with `false`: URL is absent | unit (presence/absence assertions) | `npm test -- src/generators/` | ❌ Wave 0 | +| DEPL-05 | With `configPath='machine-wide'`: scripts contain `C:\ProgramData\rclone`; with `user-profile`: `%APPDATA%\rclone` | unit | `npm test -- src/generators/` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `npm test` +- **Per wave merge:** `npm test` +- **Phase gate:** All generator unit tests green before marking Phase 2 complete + +### Wave 0 Gaps +- [ ] `src/generators/rclone-conf.test.ts` — covers CONF-01 (all three backends, type mapping, empty field omission) +- [ ] `src/generators/intune-install.test.ts` — covers DEPL-01, DEPL-04, DEPL-05 +- [ ] `src/generators/intune-detection.test.ts` — covers DEPL-02, DEPL-05 +- [ ] `src/generators/rmm-script.test.ts` — covers DEPL-03, DEPL-04, DEPL-05 +- [ ] `src/generators/index.ts` — re-exports (no tests needed; but file must exist for Phase 4 imports) + +--- + +## Sources + +### Primary (HIGH confidence) +- https://rclone.org/azureblob/ — Azure Blob type value (`azureblob`), field keys (`account`, `key`, `sas_url`) +- https://rclone.org/s3/ — S3 type value (`s3`), provider values (`AWS`, `Other`), field keys (`access_key_id`, `secret_access_key`, `region`, `endpoint`) +- https://rclone.org/install/ — Official rclone Windows download URL (`downloads.rclone.org/rclone-current-windows-amd64.zip`) +- https://learn.microsoft.com/en-us/intune/intune-service/apps/apps-win32-add (updated 2026-02-06) — Win32 app detection script contract: exit 0 + STDOUT = installed; STDERR = not installed; UTF-8 BOM recommendation +- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_character_encoding?view=powershell-7.4 — PS 5.1 encoding behavior: `Set-Content -Encoding UTF8` always adds BOM; `UTF8Encoding($false)` is the no-BOM path + +### Secondary (MEDIUM confidence) +- Phase 1 RESEARCH.md — rclone.conf INI format, field key verification status (MEDIUM from Phase 1, now upgraded to HIGH after live rclone.org verification in this research) +- Phase 1 `src/schemas/registry.ts` — confirmed field keys: `account`, `key`, `sas_url` (azureblob); `provider`, `access_key_id`, `secret_access_key`, `region` (s3); adds `endpoint` (s3-compatible) +- https://debay.blog/2019/10/03/powershell-utf8-and-bom/ — PowerShell UTF-8 BOM behavior in 5.1 +- https://www.spguides.com/powershell-write-to-file-utf8/ — `[System.IO.File]::WriteAllText` with `UTF8Encoding($false)` as canonical PS 5.1 no-BOM pattern + +### Tertiary (LOW confidence) +- Community knowledge on RMM platform SYSTEM context behavior — multiple sources confirm all major RMM tools run scripts as SYSTEM; no single authoritative Microsoft/NinjaRMM/Datto source verified + +--- + +## Metadata + +**Confidence breakdown:** +- rclone.conf INI format and field keys: HIGH — verified from live rclone.org docs (azureblob, s3 pages) +- PowerShell encoding (UTF-8 no-BOM): HIGH — verified from Microsoft Learn official PS encoding docs +- Intune detection script contract: HIGH — verified from Microsoft Learn Win32 app docs (2026-02-06) +- RMM SYSTEM context behavior: MEDIUM — community consensus, no single authoritative source +- rclone download URL: HIGH — verified from rclone.org/install/ official docs + +**Research date:** 2026-03-26 +**Valid until:** 2026-06-26 (rclone backend field names are stable; Intune detection contract is stable; PowerShell 5.1 encoding behavior is stable)