---
phase: 02-generators
plan: 03
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- src/generators/intune-install.ts
- src/generators/intune-detection.ts
- src/generators/ps-helpers.ts
autonomous: true
requirements:
- DEPL-01
- DEPL-02
- DEPL-04
- DEPL-05
must_haves:
truths:
- "buildIntuneInstall() produces a PowerShell script containing the UTF-8 no-BOM write idiom"
- "buildIntuneInstall() with includeInstall=true adds rclone download block; with false, omits it"
- "buildIntuneInstall() uses C:\\ProgramData\\rclone when configPath=machine-wide and %APPDATA%\\rclone when user-profile"
- "buildIntuneDetection() does NOT contain $ErrorActionPreference (STDERR contamination guard)"
- "buildIntuneDetection() exits 0 with Write-Output signal; exits 1 otherwise"
- "buildIntuneDetection() uses the same config path as the install script for the same configPath value"
- "All intune-install.test.ts and intune-detection.test.ts assertions pass GREEN"
artifacts:
- path: "src/generators/ps-helpers.ts"
provides: "Shared PowerShell snippet builders (path map, install block)"
exports:
- CONFIG_DIR
- buildRcloneInstallBlock
- path: "src/generators/intune-install.ts"
provides: "buildIntuneInstall pure function"
exports: ["buildIntuneInstall"]
min_lines: 40
- path: "src/generators/intune-detection.ts"
provides: "buildIntuneDetection pure function"
exports: ["buildIntuneDetection"]
min_lines: 20
key_links:
- from: "src/generators/intune-install.ts"
to: "src/generators/ps-helpers.ts"
via: "import { CONFIG_DIR, buildRcloneInstallBlock }"
pattern: "from './ps-helpers'"
- from: "src/generators/intune-detection.ts"
to: "src/generators/ps-helpers.ts"
via: "import { CONFIG_DIR }"
pattern: "from './ps-helpers'"
- from: "ps-helpers CONFIG_DIR"
to: "WizardState.deployment.configPath"
via: "Record<'machine-wide'|'user-profile', string> lookup"
pattern: "machine-wide.*ProgramData|user-profile.*APPDATA"
---
Implement buildIntuneInstall() and buildIntuneDetection() with a shared ps-helpers.ts for the config path map and conditional rclone install block.
Purpose: DEPL-01 and DEPL-02. The shared helper ensures the install and detection scripts always reference the same paths — a mismatch would cause detection to fail even when the app is correctly installed.
Output: Three files — ps-helpers.ts (shared), intune-install.ts, intune-detection.ts — with all Intune test assertions green.
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
@.planning/ROADMAP.md
@.planning/phases/02-generators/02-01-SUMMARY.md
@src/store/types.ts
@src/generators/intune-install.test.ts
@src/generators/intune-detection.test.ts
From src/store/types.ts:
```typescript
export interface WizardState {
remote: {
name: string;
backendType: BackendType | null;
params: Record;
};
deployment: {
includeInstall: boolean; // controls rclone download block
configPath: 'machine-wide' | 'user-profile'; // controls Windows path used
scriptTargets: ('intune' | 'rmm')[];
};
}
```
Path mapping (from research):
```
'machine-wide' → C:\ProgramData\rclone
'user-profile' → %APPDATA%\rclone
(note: under SYSTEM, %APPDATA% = C:\Windows\system32\config\systemprofile\AppData\Roaming)
```
Intune detection contract (from Microsoft Learn, HIGH confidence):
- Exit 0 + Write-Output → app detected
- Any STDERR output → app NOT detected (even with exit 0)
- Therefore: NO $ErrorActionPreference = 'Stop' in detection scripts
Task 1: Create ps-helpers.ts shared module
src/generators/ps-helpers.ts
- CONFIG_DIR['machine-wide'] === 'C:\\ProgramData\\rclone'
- CONFIG_DIR['user-profile'] === '%APPDATA%\\rclone'
- buildRcloneInstallBlock('C:\\ProgramData\\rclone') returns string containing 'downloads.rclone.org/rclone-current-windows-amd64.zip'
- buildRcloneInstallBlock return value contains '-UseBasicParsing' (SYSTEM context safety)
- buildRcloneInstallBlock return value contains 'rclone.exe'
Create src/generators/ps-helpers.ts with two exports:
```typescript
import type { WizardState } from '../store/types';
// Canonical Windows paths for each configPath option.
// Both generators (install + detection) import this map to guarantee path consistency.
// Note: %APPDATA% under SYSTEM resolves to C:\Windows\system32\config\systemprofile\AppData\Roaming\
// The generated script will contain an inline comment warning about this.
export const CONFIG_DIR: Record = {
'machine-wide': 'C:\\ProgramData\\rclone',
'user-profile': '%APPDATA%\\rclone',
};
// Conditional rclone binary download block.
// Returns the PowerShell snippet if includeInstall = true, empty string if false.
export function buildRcloneInstallBlock(installPath: string): string {
return `
# Download rclone binary (idempotent — skips if already present)
$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
}`;
}
```
Keep this module free of generator logic. It is purely a shared constants + helper module.
npm test -- src/generators/intune-install.test.ts 2>&1 | head -30
ps-helpers.ts exists with CONFIG_DIR exported and buildRcloneInstallBlock exported. TypeScript compiles without errors.
Task 2: Implement buildIntuneInstall and buildIntuneDetection
src/generators/intune-install.ts, src/generators/intune-detection.ts
buildIntuneInstall:
- starts with '$ErrorActionPreference = ''Stop'''
- uses CONFIG_DIR from ps-helpers for the config directory path
- creates directory idempotently: 'if (-not (Test-Path $configDir))'
- writes config using '[System.IO.File]::WriteAllText' with '[System.Text.UTF8Encoding]::new($false)'
- the config content is embedded as a PowerShell here-string (@'...'@) — NO $variable interpolation inside the here-string block
- includeInstall=true: script body contains 'downloads.rclone.org'
- includeInstall=false: script body does NOT contain 'downloads.rclone.org'
- ends with 'exit 0'
buildIntuneDetection:
- does NOT contain '$ErrorActionPreference' anywhere in output
- does NOT contain 'Write-Error' or 'Write-Host'
- uses CONFIG_DIR from ps-helpers (same path as install script for the same configPath value)
- checks: '(Test-Path $rclonePath) -and (Test-Path $configPath)'
- on success: 'Write-Output "rclone and config detected"' then 'exit 0'
- on failure: 'exit 1'
Create intune-install.ts:
```typescript
import type { WizardState } from '../store/types';
import { CONFIG_DIR, buildRcloneInstallBlock } from './ps-helpers';
export function buildIntuneInstall(state: WizardState): string {
const configDir = CONFIG_DIR[state.deployment.configPath];
const configPath = `${configDir}\\rclone.conf`;
const confContent = buildRcloneConfContent(state);
const installBlock = state.deployment.includeInstall
? buildRcloneInstallBlock(configDir)
: '';
return [
`$ErrorActionPreference = 'Stop'`,
``,
`$configDir = '${configDir}'`,
`$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`,
`# Note: Set-Content -Encoding UTF8 in PS 5.1 writes UTF-8 WITH BOM — do not use`,
`$confContent = @'`,
confContent.trimEnd(),
`'@`,
`[System.IO.File]::WriteAllText($configPath, $confContent, [System.Text.UTF8Encoding]::new($false))`,
installBlock,
``,
`exit 0`,
].join('\n') + '\n';
}
// Build the rclone.conf content string to embed in the here-string.
// Import buildRcloneConf to avoid duplicating the INI logic.
function buildRcloneConfContent(state: WizardState): string {
// Inline the INI format here rather than importing buildRcloneConf,
// to keep the dependency graph simple and avoid circular imports.
const RCLONE_TYPE_MAP: Record = {
azureblob: 'azureblob',
s3: 's3',
's3-compatible': 's3',
};
const { name, backendType, params } = state.remote;
const rcloneType = backendType ? RCLONE_TYPE_MAP[backendType] : 'unknown';
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';
}
```
IMPORTANT: The here-string block (@'...'@) embeds literal credential values directly as a JavaScript string. There is NO PowerShell variable interpolation inside the here-string content. The single-quoted @'...'@ PS here-string syntax also prevents PS-level interpolation. This satisfies the credential non-echo requirement.
Create intune-detection.ts:
```typescript
import type { WizardState } from '../store/types';
import { CONFIG_DIR } from './ps-helpers';
export function buildIntuneDetection(state: WizardState): string {
const configDir = CONFIG_DIR[state.deployment.configPath];
// CRITICAL: No $ErrorActionPreference = 'Stop' here.
// Unhandled exceptions write to STDERR.
// Intune detection contract: any STDERR output = app not detected, regardless of exit code.
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';
}
```
npm test -- src/generators/intune-install.test.ts src/generators/intune-detection.test.ts
All intune-install.test.ts and intune-detection.test.ts assertions pass GREEN. The install script contains the UTF-8 no-BOM write idiom, correct path for both configPath values, and conditional rclone download block. The detection script has no $ErrorActionPreference and uses Write-Output + exit 0/1.
npm test -- src/generators/intune-install.test.ts src/generators/intune-detection.test.ts — all GREEN.
npm test (full suite) — passes.
Verify: grep for '$ErrorActionPreference' in intune-detection.ts returns nothing.
Verify: grep for 'downloads.rclone.org' absent from buildIntuneInstall with includeInstall=false state.
DEPL-01: Intune install script writes config at correct path with UTF-8 no-BOM encoding idiom.
DEPL-02: Detection script exits 0+STDOUT when files present, no STDERR contamination risk.
DEPL-04: includeInstall toggle correctly adds/removes rclone download block.
DEPL-05: Both scripts resolve to C:\ProgramData\rclone or %APPDATA%\rclone based on configPath.