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:
@@ -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>
|
||||
Reference in New Issue
Block a user