diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dc2a84c..20af19f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/02-generators/02-01-PLAN.md b/.planning/phases/02-generators/02-01-PLAN.md new file mode 100644 index 0000000..9ed0901 --- /dev/null +++ b/.planning/phases/02-generators/02-01-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md + +@src/store/types.ts +@src/schemas/registry.ts + + + + + +From src/store/types.ts: +```typescript +export interface WizardState { + currentStep: number; + remote: { + name: string; + backendType: BackendType | null; + params: Record; + }; + 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'; +``` + + + + + + Task 1: Write test stubs for all four generators + + src/generators/rclone-conf.test.ts, + src/generators/intune-install.test.ts, + src/generators/intune-detection.test.ts, + src/generators/rmm-script.test.ts + + + 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) + + + 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. + + + npm test 2>&1 | head -50 + + + 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. + + + + + Task 2: Create index.ts barrel + src/generators/index.ts + + 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. + + + test -f src/generators/index.ts && echo "exists" + + + src/generators/index.ts exists with all four named re-exports. File contains no implementation logic. + + + + + + +- 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) + + + +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. + + + +After completion, create `.planning/phases/02-generators/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-generators/02-02-PLAN.md b/.planning/phases/02-generators/02-02-PLAN.md new file mode 100644 index 0000000..989d6c7 --- /dev/null +++ b/.planning/phases/02-generators/02-02-PLAN.md @@ -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 lookup" + pattern: "'s3-compatible': 's3'" +--- + + +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. + + + +@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/schemas/registry.ts +@src/generators/rclone-conf.test.ts + + + + + +From src/store/types.ts: +```typescript +export interface WizardState { + currentStep: number; + remote: { + name: string; + backendType: BackendType | null; // null must throw + params: Record; // 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 +``` + + + + + + Task 1: Implement buildRcloneConf + src/generators/rclone-conf.ts + + - 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(...) }` + + + 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 = { + 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. + + + npm test -- src/generators/rclone-conf.test.ts + + + 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). + + + + + + +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. + + + +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). + + + +After completion, create `.planning/phases/02-generators/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-generators/02-03-PLAN.md b/.planning/phases/02-generators/02-03-PLAN.md new file mode 100644 index 0000000..3e39c94 --- /dev/null +++ b/.planning/phases/02-generators/02-03-PLAN.md @@ -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" +--- + + +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. + + + +After completion, create `.planning/phases/02-generators/02-03-SUMMARY.md` + diff --git a/.planning/phases/02-generators/02-04-PLAN.md b/.planning/phases/02-generators/02-04-PLAN.md new file mode 100644 index 0000000..8d14c6a --- /dev/null +++ b/.planning/phases/02-generators/02-04-PLAN.md @@ -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" +--- + + +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. + + + +@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 +@.planning/phases/02-generators/02-03-SUMMARY.md +@src/store/types.ts +@src/generators/ps-helpers.ts +@src/generators/rmm-script.test.ts + + + + + +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 }; + 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 + + + + + + Task 1: Implement buildRmmScript + src/generators/rmm-script.ts + + - 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))' + + + 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 = { + 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) + + + npm test -- src/generators/rmm-script.test.ts + + + 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. + + + + + + +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. + + + +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. + + + +After completion, create `.planning/phases/02-generators/02-04-SUMMARY.md` +