docs(02-generators): create phase 2 plan — 4 plans in 2 waves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:45:15 +01:00
co-authored by Claude Sonnet 4.6
parent 0d4c9bd37e
commit 0722046c32
5 changed files with 902 additions and 2 deletions
+8 -2
View File
@@ -46,7 +46,13 @@ Plans:
3. The Intune detection script exits 0 when both rclone.exe and the config file are present, and non-zero otherwise
4. The RMM script is self-contained, idempotent, and runs correctly in a generic SYSTEM context
5. Toggling "include rclone installation" adds a download step to all generated scripts; disabling it removes it
**Plans**: TBD
**Plans**: 4 plans
Plans:
- [ ] 02-01-PLAN.md — Wave 0 test stubs for all four generators + index.ts barrel
- [ ] 02-02-PLAN.md — Implement buildRcloneConf (CONF-01)
- [ ] 02-03-PLAN.md — Implement buildIntuneInstall + buildIntuneDetection + ps-helpers (DEPL-01, DEPL-02, DEPL-04, DEPL-05)
- [ ] 02-04-PLAN.md — Implement buildRmmScript (DEPL-03, DEPL-04, DEPL-05)
### Phase 3: Wizard UI
**Goal**: An IT admin can navigate the full wizard from backend selection through deployment options without losing data
@@ -80,6 +86,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 4/4 | Complete | 2026-03-26 |
| 2. Generators | 0/? | Not started | - |
| 2. Generators | 0/4 | Not started | - |
| 3. Wizard UI | 0/? | Not started | - |
| 4. Review, Download & Security | 0/? | Not started | - |
@@ -0,0 +1,236 @@
---
phase: 02-generators
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/generators/rclone-conf.test.ts
- src/generators/intune-install.test.ts
- src/generators/intune-detection.test.ts
- src/generators/rmm-script.test.ts
- src/generators/index.ts
autonomous: true
requirements:
- CONF-01
- DEPL-01
- DEPL-02
- DEPL-03
- DEPL-04
- DEPL-05
must_haves:
truths:
- "All four generator test files exist and fail RED (imports resolve, implementations missing)"
- "The index.ts barrel file exports all four generator functions"
- "npm test passes (passWithNoTests configured; stubs may skip or fail)"
artifacts:
- path: "src/generators/rclone-conf.test.ts"
provides: "Failing unit tests for CONF-01"
contains: "buildRcloneConf"
- path: "src/generators/intune-install.test.ts"
provides: "Failing unit tests for DEPL-01, DEPL-04, DEPL-05"
contains: "buildIntuneInstall"
- path: "src/generators/intune-detection.test.ts"
provides: "Failing unit tests for DEPL-02, DEPL-05"
contains: "buildIntuneDetection"
- path: "src/generators/rmm-script.test.ts"
provides: "Failing unit tests for DEPL-03, DEPL-04, DEPL-05"
contains: "buildRmmScript"
- path: "src/generators/index.ts"
provides: "Re-export barrel for all generators"
exports:
- buildRcloneConf
- buildIntuneInstall
- buildIntuneDetection
- buildRmmScript
key_links:
- from: "src/generators/rclone-conf.test.ts"
to: "src/generators/rclone-conf.ts"
via: "import { buildRcloneConf } from './rclone-conf'"
pattern: "from './rclone-conf'"
- from: "src/generators/index.ts"
to: "src/generators/*.ts"
via: "named re-exports"
pattern: "export.*from"
---
<objective>
Write all four generator test files (failing) and the index.ts barrel before any implementation exists.
Purpose: Nyquist rule — tests must exist before implementations. Plans 02, 03, 04 implement against these stubs. The barrel establishes the public API that Phase 4 will import from.
Output: Four test files (RED), one barrel file, all four generator module paths defined.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/store/types.ts
@src/schemas/registry.ts
</context>
<interfaces>
<!-- Key types the test files will import. Extracted from Phase 1 artifacts. -->
From src/store/types.ts:
```typescript
export interface WizardState {
currentStep: number;
remote: {
name: string;
backendType: BackendType | null;
params: Record<string, string>;
};
deployment: {
includeInstall: boolean;
configPath: 'machine-wide' | 'user-profile';
scriptTargets: ('intune' | 'rmm')[];
};
}
export const INITIAL_STATE: WizardState = {
currentStep: 0,
remote: { name: '', backendType: null, params: {} },
deployment: {
includeInstall: false,
configPath: 'machine-wide',
scriptTargets: ['intune', 'rmm'],
},
};
```
From src/schemas/registry.ts:
```typescript
export type BackendType = 'azureblob' | 's3' | 's3-compatible';
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Write test stubs for all four generators</name>
<files>
src/generators/rclone-conf.test.ts,
src/generators/intune-install.test.ts,
src/generators/intune-detection.test.ts,
src/generators/rmm-script.test.ts
</files>
<behavior>
rclone-conf.test.ts:
- imports buildRcloneConf from './rclone-conf' (file does not exist yet — RED)
- azureblob: output contains '[my-azure]', 'type = azureblob', 'account = mystorageaccount', 'key = BASE64KEY', does NOT contain 'sas_url' (empty field omitted)
- s3: output contains '[my-s3]', 'type = s3', 'provider = AWS', 'access_key_id = AKID', 'secret_access_key = SKEY', 'region = us-east-1'
- s3-compatible: output contains 'type = s3' (NOT 'type = s3-compatible'), 'provider = Other', 'endpoint = https://s3.wasabisys.com', does NOT contain 'region' (empty field omitted)
- throws if backendType is null
- throws if remote name is empty string
intune-install.test.ts:
- imports buildIntuneInstall from './intune-install' (file does not exist yet — RED)
- machine-wide path: output contains 'C:\ProgramData\rclone'
- user-profile path: output contains '%APPDATA%\rclone'
- includeInstall=true: output contains 'https://downloads.rclone.org/rclone-current-windows-amd64.zip'
- includeInstall=false: output does NOT contain 'downloads.rclone.org'
- output contains '[System.IO.File]::WriteAllText' (UTF-8 no-BOM idiom present)
- output contains 'exit 0'
- output does NOT contain credential variable interpolation inside config content (no $account, $key inside here-string block)
intune-detection.test.ts:
- imports buildIntuneDetection from './intune-detection' (file does not exist yet — RED)
- machine-wide path: output contains 'C:\ProgramData\rclone'
- user-profile path: output contains '%APPDATA%\rclone'
- output contains 'exit 0' and 'exit 1'
- output contains 'Write-Output' (STDOUT signal)
- output does NOT contain '$ErrorActionPreference' (STDERR contamination risk)
- output does NOT contain 'Write-Error' or 'Write-Host' (no STDERR leakage)
rmm-script.test.ts:
- imports buildRmmScript from './rmm-script' (file does not exist yet — RED)
- machine-wide path: output contains 'C:\ProgramData\rclone'
- user-profile path: output contains '%APPDATA%\rclone'
- includeInstall=true: output contains 'https://downloads.rclone.org/rclone-current-windows-amd64.zip'
- includeInstall=false: output does NOT contain 'downloads.rclone.org'
- output contains '$ErrorActionPreference = ''Stop''' (RMM scripts DO use Stop — unlike detection)
- output contains '[System.IO.File]::WriteAllText' (UTF-8 no-BOM)
</behavior>
<action>
Create all four test files. Use INITIAL_STATE + overrides to build typed WizardState fixtures. Import from '../store/types' for WizardState and INITIAL_STATE. Use Vitest globals (describe, it, expect — no import needed, globals: true in vitest.config.ts).
Representative fixture pattern (replicate across all four files):
```typescript
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: '' },
},
};
```
Tests will fail at import (module not found) until Plans 02-04 create implementations. This is intentional RED state. Vitest with passWithNoTests:true will still pass overall suite; individual test files will error. That is correct behavior for Wave 0 stubs.
Keep test descriptions concise and specific to the substring being asserted. No mocking needed — these are pure string functions.
</action>
<verify>
<automated>npm test 2>&1 | head -50</automated>
</verify>
<done>
All four test files exist. npm test runs without crashing the runner (passWithNoTests:true). Individual generator test files show import errors or failing tests — RED state is correct. TypeScript compilation of test files is the only requirement at this stage.
</done>
</task>
<task type="auto">
<name>Task 2: Create index.ts barrel</name>
<files>src/generators/index.ts</files>
<action>
Create `src/generators/index.ts` that re-exports all four generator functions. The module files do not exist yet — use type-only re-exports or forward-compatible syntax that will resolve once Plans 02-04 create the implementations.
Since the generator files don't exist yet, write the barrel with the final export shape. Vitest will see the import error from test files; the barrel itself need not be tested.
```typescript
// src/generators/index.ts
// Re-exports all generator functions.
// Phase 4 imports generators from this barrel — do not remove exports.
export { buildRcloneConf } from './rclone-conf';
export { buildIntuneInstall } from './intune-install';
export { buildIntuneDetection } from './intune-detection';
export { buildRmmScript } from './rmm-script';
```
No implementation logic in this file. The barrel is purely structural.
</action>
<verify>
<automated>test -f src/generators/index.ts && echo "exists"</automated>
</verify>
<done>
src/generators/index.ts exists with all four named re-exports. File contains no implementation logic.
</done>
</task>
</tasks>
<verification>
- All four test files exist in src/generators/
- src/generators/index.ts exists with four re-export lines
- npm test runs without runner crash
- Test files correctly import from sibling modules (not yet implemented — RED is expected)
</verification>
<success_criteria>
Wave 0 is complete: test stubs define every assertion that Plans 02, 03, 04 must satisfy. The barrel defines the public API contract. No implementation exists yet — all generator imports are RED.
</success_criteria>
<output>
After completion, create `.planning/phases/02-generators/02-01-SUMMARY.md`
</output>
@@ -0,0 +1,166 @@
---
phase: 02-generators
plan: 02
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- src/generators/rclone-conf.ts
autonomous: true
requirements:
- CONF-01
must_haves:
truths:
- "buildRcloneConf(azureState) produces a valid INI block with type = azureblob and non-empty fields"
- "buildRcloneConf(s3State) produces type = s3 with provider = AWS"
- "buildRcloneConf(s3CompatState) produces type = s3 (not s3-compatible) with provider = Other"
- "Empty optional fields are omitted from the output (no 'sas_url =' line when sas_url is empty)"
- "All rclone-conf.test.ts assertions pass GREEN"
artifacts:
- path: "src/generators/rclone-conf.ts"
provides: "buildRcloneConf pure function"
exports: ["buildRcloneConf"]
min_lines: 25
key_links:
- from: "src/generators/rclone-conf.ts"
to: "src/store/types.ts"
via: "import type { WizardState } from '../store/types'"
pattern: "WizardState"
- from: "RCLONE_TYPE_MAP"
to: "rclone backend type strings"
via: "Record<string, string> lookup"
pattern: "'s3-compatible': 's3'"
---
<objective>
Implement buildRcloneConf() — the pure TypeScript function that converts WizardState into a valid rclone.conf INI string.
Purpose: CONF-01 — the foundational output artifact. Every downstream phase depends on this being correct. Wrong type values or included empty fields silently break rclone remotes.
Output: src/generators/rclone-conf.ts with all rclone-conf.test.ts assertions green.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/02-generators/02-01-SUMMARY.md
@src/store/types.ts
@src/schemas/registry.ts
@src/generators/rclone-conf.test.ts
</context>
<interfaces>
<!-- Types consumed by buildRcloneConf. -->
From src/store/types.ts:
```typescript
export interface WizardState {
currentStep: number;
remote: {
name: string;
backendType: BackendType | null; // null must throw
params: Record<string, string>; // iterate, skip empty values
};
deployment: {
includeInstall: boolean;
configPath: 'machine-wide' | 'user-profile';
scriptTargets: ('intune' | 'rmm')[];
};
}
```
From src/schemas/registry.ts:
```typescript
export type BackendType = 'azureblob' | 's3' | 's3-compatible';
```
Critical type mapping (from research — verified against rclone.org):
```typescript
// BackendType → rclone config 'type' value
// 'azureblob' → 'azureblob'
// 's3' → 's3'
// 's3-compatible' → 's3' (NOT 's3-compatible' — rclone does not recognize that string)
// S3-compatible backends use type = s3, differentiated by provider = Other
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement buildRcloneConf</name>
<files>src/generators/rclone-conf.ts</files>
<behavior>
- azureblob: '[my-azure]\ntype = azureblob\naccount = mystorageaccount\nkey = BASE64KEY\n' (sas_url omitted — empty)
- s3: contains 'type = s3', 'provider = AWS', 'access_key_id = AKID', 'region = us-east-1'
- s3-compatible: contains 'type = s3', NOT 'type = s3-compatible', 'provider = Other', 'endpoint = https://s3.wasabisys.com', NOT 'region' line (empty optional field)
- throws Error if backendType is null
- throws Error if name is empty string
- output ends with '\n' (trailing newline — rclone convention)
- no empty value lines: `if (value !== '') { lines.push(...) }`
</behavior>
<action>
Create src/generators/rclone-conf.ts implementing buildRcloneConf(state: WizardState): string.
Follow this exact structure:
```typescript
import type { WizardState } from '../store/types';
// Maps our BackendType to rclone's internal type string.
// IMPORTANT: s3-compatible uses 'type = s3' — rclone does not recognize 's3-compatible' as a type.
// S3-compatible backends are differentiated by provider = Other in params.
const RCLONE_TYPE_MAP: Record<string, string> = {
azureblob: 'azureblob',
s3: 's3',
's3-compatible': 's3',
};
export function buildRcloneConf(state: WizardState): string {
const { name, backendType, params } = state.remote;
if (!backendType) throw new Error('buildRcloneConf: backendType is required');
if (!name) throw new Error('buildRcloneConf: 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';
}
```
Do NOT import BACKEND_REGISTRY — the function iterates over state.remote.params directly (already contains the right keys from the registry-driven form). Do NOT add any console.log or Write-Host equivalent — credentials must not be logged.
Anti-pattern to avoid: Do not write `type = s3-compatible`. The RCLONE_TYPE_MAP lookup handles the translation.
</action>
<verify>
<automated>npm test -- src/generators/rclone-conf.test.ts</automated>
</verify>
<done>
All rclone-conf.test.ts assertions pass GREEN. buildRcloneConf produces correct INI output for all three backends. Empty fields are omitted. Type mapping is correct (s3-compatible → type = s3).
</done>
</task>
</tasks>
<verification>
npm test -- src/generators/rclone-conf.test.ts is fully green.
npm test (full suite) still passes.
src/generators/rclone-conf.ts is importable from index.ts without TypeScript errors.
</verification>
<success_criteria>
CONF-01 is satisfied: buildRcloneConf produces valid INI-format rclone.conf strings with correct key/value pairs for all three supported backends (azureblob, s3, s3-compatible).
</success_criteria>
<output>
After completion, create `.planning/phases/02-generators/02-02-SUMMARY.md`
</output>
@@ -0,0 +1,297 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- Types and constraints for the Intune generators. -->
From src/store/types.ts:
```typescript
export interface WizardState {
remote: {
name: string;
backendType: BackendType | null;
params: Record<string, string>;
};
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
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create ps-helpers.ts shared module</name>
<files>src/generators/ps-helpers.ts</files>
<behavior>
- 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'
</behavior>
<action>
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<WizardState['deployment']['configPath'], string> = {
'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.
</action>
<verify>
<automated>npm test -- src/generators/intune-install.test.ts 2>&1 | head -30</automated>
</verify>
<done>
ps-helpers.ts exists with CONFIG_DIR exported and buildRcloneInstallBlock exported. TypeScript compiles without errors.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement buildIntuneInstall and buildIntuneDetection</name>
<files>src/generators/intune-install.ts, src/generators/intune-detection.ts</files>
<behavior>
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'
</behavior>
<action>
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<string, string> = {
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';
}
```
</action>
<verify>
<automated>npm test -- src/generators/intune-install.test.ts src/generators/intune-detection.test.ts</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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.
</verification>
<success_criteria>
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.
</success_criteria>
<output>
After completion, create `.planning/phases/02-generators/02-03-SUMMARY.md`
</output>
@@ -0,0 +1,195 @@
---
phase: 02-generators
plan: 04
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- src/generators/rmm-script.ts
autonomous: true
requirements:
- DEPL-03
- DEPL-04
- DEPL-05
must_haves:
truths:
- "buildRmmScript() produces a self-contained PowerShell script with $ErrorActionPreference = 'Stop'"
- "buildRmmScript() creates the config directory idempotently and writes rclone.conf with UTF-8 no-BOM"
- "buildRmmScript() with includeInstall=true adds rclone download block; with false, omits it"
- "buildRmmScript() uses C:\\ProgramData\\rclone for machine-wide and %APPDATA%\\rclone for user-profile"
- "All rmm-script.test.ts assertions pass GREEN"
artifacts:
- path: "src/generators/rmm-script.ts"
provides: "buildRmmScript pure function"
exports: ["buildRmmScript"]
min_lines: 35
key_links:
- from: "src/generators/rmm-script.ts"
to: "src/generators/ps-helpers.ts"
via: "import { CONFIG_DIR, buildRcloneInstallBlock }"
pattern: "from './ps-helpers'"
- from: "CONFIG_DIR lookup"
to: "WizardState.deployment.configPath"
via: "Record<'machine-wide'|'user-profile', string>"
pattern: "machine-wide|user-profile"
---
<objective>
Implement buildRmmScript() — the generic RMM PowerShell installer using shared ps-helpers.ts from Plan 03.
Purpose: DEPL-03. RMM script is structurally similar to the Intune install script but has no separate detection artifact, no Intune packaging constraints, and explicitly uses $ErrorActionPreference = 'Stop' (RMM platforms surface exit codes, not STDERR).
Output: src/generators/rmm-script.ts with all rmm-script.test.ts assertions green.
</objective>
<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/02-generators/02-01-SUMMARY.md
@.planning/phases/02-generators/02-03-SUMMARY.md
@src/store/types.ts
@src/generators/ps-helpers.ts
@src/generators/rmm-script.test.ts
</context>
<interfaces>
<!-- Shared helpers available from Plan 03. -->
From src/generators/ps-helpers.ts (created in Plan 03):
```typescript
export const CONFIG_DIR: Record<'machine-wide' | 'user-profile', string> = {
'machine-wide': 'C:\\ProgramData\\rclone',
'user-profile': '%APPDATA%\\rclone',
};
export function buildRcloneInstallBlock(installPath: string): string;
// Returns PowerShell snippet with Invoke-WebRequest -UseBasicParsing and zip extraction.
// Returns empty string if called conditionally — caller decides whether to include.
```
From src/store/types.ts:
```typescript
export interface WizardState {
remote: { name: string; backendType: BackendType | null; params: Record<string, string> };
deployment: {
includeInstall: boolean;
configPath: 'machine-wide' | 'user-profile';
scriptTargets: ('intune' | 'rmm')[];
};
}
```
Key difference from Intune install:
- RMM scripts DO use $ErrorActionPreference = 'Stop' (RMM platforms capture exit codes; no STDERR-breaks-detection contract)
- No exit 0 / exit 1 convention required (but including for clarity is fine)
- No separate detection script — the RMM platform handles existence checks natively
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement buildRmmScript</name>
<files>src/generators/rmm-script.ts</files>
<behavior>
- output contains '$ErrorActionPreference = ''Stop''' (RMM scripts use Stop — unlike detection)
- machine-wide path: output contains 'C:\ProgramData\rclone'
- user-profile path: output contains '%APPDATA%\rclone'
- includeInstall=true: output contains 'downloads.rclone.org/rclone-current-windows-amd64.zip'
- includeInstall=false: output does NOT contain 'downloads.rclone.org'
- output contains '[System.IO.File]::WriteAllText' (UTF-8 no-BOM write)
- output contains '@'' / ''@' or equivalent here-string (credentials not interpolated at PS runtime)
- config directory creation is idempotent: 'if (-not (Test-Path $configDir))'
</behavior>
<action>
Create src/generators/rmm-script.ts importing from ps-helpers.ts. Structure mirrors buildIntuneInstall but with explicit comments that this is for generic RMM tools (NinjaRMM, Datto, ConnectWise).
```typescript
import type { WizardState } from '../store/types';
import { CONFIG_DIR, buildRcloneInstallBlock } from './ps-helpers';
export function buildRmmScript(state: WizardState): string {
const configDir = CONFIG_DIR[state.deployment.configPath];
const confContent = buildRcloneConfContent(state);
const installBlock = state.deployment.includeInstall
? buildRcloneInstallBlock(configDir)
: '';
return [
`# RMM Deployment Script — compatible with NinjaRMM, Datto, ConnectWise`,
`# Runs as SYSTEM. Idempotent (safe to re-run).`,
`$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';
}
function buildRcloneConfContent(state: WizardState): string {
const RCLONE_TYPE_MAP: Record<string, string> = {
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';
}
```
Note: The buildRcloneConfContent helper is duplicated from intune-install.ts intentionally — both files are self-contained. If this feels wrong, the alternative is to import buildRcloneConf from './rclone-conf', which is also fine (no circular dependency). Use whichever approach keeps the code clear. Either satisfies the tests.
Anti-patterns to avoid:
- Do NOT use 'Write-Error' or 'Write-Host' for logging (leaks to STDERR / logs)
- Do NOT interpolate credential values in the here-string using PowerShell variables
- Do NOT use 'Set-Content -Encoding UTF8' (writes BOM in PS 5.1)
</action>
<verify>
<automated>npm test -- src/generators/rmm-script.test.ts</automated>
</verify>
<done>
All rmm-script.test.ts assertions pass GREEN. The RMM script is self-contained, idempotent, uses UTF-8 no-BOM write, and conditionally includes the rclone binary download block.
</done>
</task>
</tasks>
<verification>
npm test -- src/generators/rmm-script.test.ts — all GREEN.
npm test (full suite) — all GREEN (Plans 01, 02, 03, 04 all passing).
src/generators/index.ts re-exports buildRmmScript without TypeScript errors.
</verification>
<success_criteria>
DEPL-03: RMM script is self-contained, idempotent, runs correctly in a generic SYSTEM context.
DEPL-04: includeInstall toggle adds/removes rclone download block in RMM script.
DEPL-05: configPath correctly maps to C:\ProgramData\rclone or %APPDATA%\rclone in RMM script.
Phase 2 is complete: all six requirements (CONF-01, DEPL-01 through DEPL-05) have green unit tests.
</success_criteria>
<output>
After completion, create `.planning/phases/02-generators/02-04-SUMMARY.md`
</output>