Files
Ready2Blob/.planning/phases/01-foundation/01-02-PLAN.md
T
kawa 4514fc9033 docs(01-foundation): create phase plan
4 plans across 3 waves: scaffold, registry+test stubs, Zod schemas and WizardState store (parallel wave 3).
2026-03-26 10:05:01 +01:00

440 lines
17 KiB
Markdown

---
phase: 01-foundation
plan: 02
type: execute
wave: 2
depends_on:
- 01-01
files_modified:
- src/schemas/registry.ts
- src/schemas/registry.test.ts
- src/schemas/index.test.ts
- src/store/reducer.test.ts
autonomous: true
requirements: []
must_haves:
truths:
- "BACKEND_REGISTRY exports entries for 'azureblob', 's3', and 's3-compatible'"
- "Each registry entry is an array of FieldDef objects with the correct rclone config keys"
- "Test stubs exist for all three success criteria (SC-2, SC-3, SC-4) and fail with informative messages"
- "npx vitest run fails on registry.test.ts (RED — implementation not yet written for index.ts and reducer)"
artifacts:
- path: "src/schemas/registry.ts"
provides: "BackendType union, FieldDef interface, BACKEND_REGISTRY constant"
exports: ["BackendType", "FieldDef", "BACKEND_REGISTRY"]
- path: "src/schemas/registry.test.ts"
provides: "Test stubs for SC-2 (registry structure verification)"
contains: "BACKEND_REGISTRY"
- path: "src/schemas/index.test.ts"
provides: "Test stubs for SC-3 (Zod schema safeParse verification)"
contains: "BACKEND_SCHEMAS"
- path: "src/store/reducer.test.ts"
provides: "Test stubs for SC-4 (WizardState reducer action verification)"
contains: "wizardReducer"
key_links:
- from: "src/schemas/registry.ts"
to: "rclone config keys"
via: "FieldDef.key values must match rclone's INI key names exactly"
pattern: "key:.*'account'|key:.*'access_key_id'|key:.*'secret_access_key'"
---
<objective>
Create the Backend Schema Registry — the single source of truth for all backend field definitions — and write the Wave 0 test stubs that will drive implementation in Plans 03 and 04.
Purpose: The registry is architecturally critical: Phase 2 generators use its keys to build rclone.conf, Phase 3 uses it to render dynamic forms. Wrong keys here cause silently broken configs. Writing test stubs first establishes the acceptance criteria before any implementation.
Output: src/schemas/registry.ts with typed field definitions for three backends, plus three test stub files (failing, per TDD RED phase).
</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/phases/01-foundation/01-01-SUMMARY.md
<!-- CRITICAL: rclone config key names (from research — MEDIUM confidence, verify vs rclone.org before Phase 2) -->
<!-- Azure Blob: type=azureblob, account (storage account name), key (access key), sas_url (SAS URL) -->
<!-- S3: type=s3, provider=AWS, access_key_id, secret_access_key, region -->
<!-- S3-compatible: type=s3, provider=Other, access_key_id, secret_access_key, endpoint (required), region (optional) -->
<!-- Note: s3 and s3-compatible both use type=s3 in rclone.conf; the provider field differentiates them -->
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create the Backend Schema Registry</name>
<files>src/schemas/registry.ts</files>
<behavior>
- BackendType is a union: 'azureblob' | 's3' | 's3-compatible'
- FieldDef has: key (string), label (string), inputType ('text'|'password'|'select'|'toggle'), required (boolean), and optional placeholder, helpText, options
- BACKEND_REGISTRY['azureblob'] has at least 3 fields: account (required), key (optional), sas_url (optional)
- BACKEND_REGISTRY['s3'] has at least 4 fields: provider (required, select, value='AWS'), access_key_id (required), secret_access_key (required, password), region (required)
- BACKEND_REGISTRY['s3-compatible'] has at least 5 fields: provider (required, select, value='Other'), access_key_id (required), secret_access_key (required, password), endpoint (required), region (optional)
- All FieldDef.key values use snake_case matching rclone's actual config key names (not camelCase)
</behavior>
<action>
Create src/schemas/registry.ts:
```typescript
// src/schemas/registry.ts
// Backend Schema Registry — single source of truth for all rclone backend field definitions.
// IMPORTANT: FieldDef.key values MUST match rclone config key names exactly.
// These keys are used by Phase 2 generators to build rclone.conf INI content.
// Verify against https://rclone.org/azureblob/ and https://rclone.org/s3/ before Phase 2.
export type BackendType = 'azureblob' | 's3' | 's3-compatible';
export interface FieldDef {
key: string; // MUST match rclone config key exactly (snake_case)
label: string;
inputType: 'text' | 'password' | 'select' | 'toggle';
required: boolean;
placeholder?: string;
helpText?: string;
options?: { value: string; label: string }[]; // for inputType: 'select'
}
export const BACKEND_REGISTRY: Record<BackendType, FieldDef[]> = {
azureblob: [
{
key: 'account',
label: 'Storage Account Name',
inputType: 'text',
required: true,
placeholder: 'mystorageaccount',
helpText: 'The storage account name (not the full URL)',
},
{
key: 'key',
label: 'Access Key',
inputType: 'password',
required: false,
helpText: 'Base64-encoded storage account key. Provide either this or a SAS URL, not both.',
},
{
key: 'sas_url',
label: 'SAS URL',
inputType: 'password',
required: false,
placeholder: 'https://mystorageaccount.blob.core.windows.net/?sv=...',
helpText: 'Full SAS URL including account and container. Provide either this or an access key, not both.',
},
],
s3: [
{
key: 'provider',
label: 'Provider',
inputType: 'select',
required: true,
options: [{ value: 'AWS', label: 'Amazon S3' }],
},
{
key: 'access_key_id',
label: 'Access Key ID',
inputType: 'text',
required: true,
placeholder: 'AKIAIOSFODNN7EXAMPLE',
},
{
key: 'secret_access_key',
label: 'Secret Access Key',
inputType: 'password',
required: true,
},
{
key: 'region',
label: 'Region',
inputType: 'text',
required: true,
placeholder: 'us-east-1',
},
],
's3-compatible': [
{
key: 'provider',
label: 'Provider',
inputType: 'select',
required: true,
options: [{ value: 'Other', label: 'S3-Compatible' }],
helpText: 'Covers Wasabi, MinIO, Cloudflare R2, and any S3-compatible storage',
},
{
key: 'access_key_id',
label: 'Access Key ID',
inputType: 'text',
required: true,
},
{
key: 'secret_access_key',
label: 'Secret Access Key',
inputType: 'password',
required: true,
},
{
key: 'endpoint',
label: 'Endpoint URL',
inputType: 'text',
required: true,
placeholder: 'https://s3.wasabisys.com',
helpText: 'The S3-compatible endpoint URL for your storage provider',
},
{
key: 'region',
label: 'Region',
inputType: 'text',
required: false,
placeholder: 'us-east-1',
helpText: 'Optional for most S3-compatible providers',
},
],
};
```
</action>
<verify>
<automated>npx vitest run src/schemas/registry.test.ts 2>&1; echo "exit: $?"</automated>
</verify>
<done>registry.ts exports BackendType, FieldDef, and BACKEND_REGISTRY. All three backend entries have the correct rclone-compatible key names. registry.test.ts passes.</done>
</task>
<task type="auto">
<name>Task 2: Write Wave 0 test stubs for SC-2, SC-3, SC-4</name>
<files>src/schemas/registry.test.ts, src/schemas/index.test.ts, src/store/reducer.test.ts</files>
<action>
Create three test stub files. These are the Wave 0 test scaffolds — they define acceptance criteria now so Plans 03 and 04 implement against them (TDD RED phase). Tests for registry.test.ts should PASS; tests for index.test.ts and reducer.test.ts will FAIL (the implementation files don't exist yet).
**src/schemas/registry.test.ts** (SC-2 — verifies registry structure; should PASS after Task 1):
```typescript
import { describe, it, expect } from 'vitest';
import { BACKEND_REGISTRY, BackendType } from './registry';
const EXPECTED_BACKENDS: BackendType[] = ['azureblob', 's3', 's3-compatible'];
describe('Backend Schema Registry', () => {
it('exports all three required backend types', () => {
for (const backend of EXPECTED_BACKENDS) {
expect(BACKEND_REGISTRY[backend]).toBeDefined();
}
});
it('each backend has at least one field definition', () => {
for (const backend of EXPECTED_BACKENDS) {
expect(BACKEND_REGISTRY[backend].length).toBeGreaterThan(0);
}
});
it('each FieldDef has a non-empty key (snake_case, no camelCase)', () => {
for (const backend of EXPECTED_BACKENDS) {
for (const field of BACKEND_REGISTRY[backend]) {
expect(field.key).toBeTruthy();
// Reject camelCase — rclone keys are snake_case or lowercase
expect(field.key).not.toMatch(/[A-Z]/);
}
}
});
it('each FieldDef has a non-empty label', () => {
for (const backend of EXPECTED_BACKENDS) {
for (const field of BACKEND_REGISTRY[backend]) {
expect(field.label).toBeTruthy();
}
}
});
it('Azure Blob has account field (required)', () => {
const accountField = BACKEND_REGISTRY.azureblob.find(f => f.key === 'account');
expect(accountField).toBeDefined();
expect(accountField!.required).toBe(true);
});
it('S3 has access_key_id, secret_access_key, and region fields', () => {
const keys = BACKEND_REGISTRY.s3.map(f => f.key);
expect(keys).toContain('access_key_id');
expect(keys).toContain('secret_access_key');
expect(keys).toContain('region');
});
it('S3-compatible has endpoint field (required)', () => {
const endpointField = BACKEND_REGISTRY['s3-compatible'].find(f => f.key === 'endpoint');
expect(endpointField).toBeDefined();
expect(endpointField!.required).toBe(true);
});
});
```
**src/schemas/index.test.ts** (SC-3 — will FAIL until Plan 03 creates src/schemas/index.ts):
```typescript
import { describe, it, expect } from 'vitest';
import { BACKEND_SCHEMAS } from './index';
describe('Azure Blob Zod schema', () => {
it('accepts valid account + access key', () => {
const result = BACKEND_SCHEMAS.azureblob.safeParse({
account: 'mystorageaccount',
key: 'dGVzdGtleQ==',
});
expect(result.success).toBe(true);
});
it('accepts valid account + sas_url', () => {
const result = BACKEND_SCHEMAS.azureblob.safeParse({
account: 'mystorageaccount',
sas_url: 'https://mystorageaccount.blob.core.windows.net/?sv=2021-01-01',
});
expect(result.success).toBe(true);
});
it('rejects empty account (required field)', () => {
const result = BACKEND_SCHEMAS.azureblob.safeParse({ account: '' });
expect(result.success).toBe(false);
});
it('rejects missing account (required field)', () => {
const result = BACKEND_SCHEMAS.azureblob.safeParse({ key: 'dGVzdGtleQ==' });
expect(result.success).toBe(false);
});
});
describe('S3 Zod schema', () => {
it('accepts valid S3 credentials', () => {
const result = BACKEND_SCHEMAS.s3.safeParse({
provider: 'AWS',
access_key_id: 'AKIAIOSFODNN7EXAMPLE',
secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
region: 'us-east-1',
});
expect(result.success).toBe(true);
});
it('rejects missing access_key_id (required field)', () => {
const result = BACKEND_SCHEMAS.s3.safeParse({
provider: 'AWS',
secret_access_key: 'secret',
region: 'us-east-1',
});
expect(result.success).toBe(false);
});
});
describe('S3-compatible Zod schema', () => {
it('accepts valid S3-compatible credentials with endpoint', () => {
const result = BACKEND_SCHEMAS['s3-compatible'].safeParse({
provider: 'Other',
access_key_id: 'mykey',
secret_access_key: 'mysecret',
endpoint: 'https://s3.wasabisys.com',
});
expect(result.success).toBe(true);
});
it('rejects missing endpoint (required for s3-compatible)', () => {
const result = BACKEND_SCHEMAS['s3-compatible'].safeParse({
provider: 'Other',
access_key_id: 'mykey',
secret_access_key: 'mysecret',
});
expect(result.success).toBe(false);
});
});
```
**src/store/reducer.test.ts** (SC-4 — will FAIL until Plan 04 creates reducer.ts and types.ts):
```typescript
import { describe, it, expect } from 'vitest';
import { wizardReducer } from './reducer';
import { INITIAL_STATE, WizardState } from './types';
describe('wizardReducer', () => {
it('returns INITIAL_STATE on first call', () => {
// @ts-expect-error intentional undefined action for initialization test
const state = wizardReducer(undefined, { type: '@@INIT' });
expect(state.currentStep).toBe(0);
expect(state.remote.backendType).toBeNull();
expect(state.remote.name).toBe('');
expect(state.remote.params).toEqual({});
expect(state.deployment.includeInstall).toBe(false);
expect(state.deployment.configPath).toBe('machine-wide');
});
it('SET_STEP updates currentStep', () => {
const state = wizardReducer(INITIAL_STATE, { type: 'SET_STEP', payload: 2 });
expect(state.currentStep).toBe(2);
});
it('SET_BACKEND_TYPE updates remote.backendType', () => {
const state = wizardReducer(INITIAL_STATE, { type: 'SET_BACKEND_TYPE', payload: 'azureblob' });
expect(state.remote.backendType).toBe('azureblob');
});
it('SET_REMOTE_NAME updates remote.name', () => {
const state = wizardReducer(INITIAL_STATE, { type: 'SET_REMOTE_NAME', payload: 'my-blob' });
expect(state.remote.name).toBe('my-blob');
});
it('SET_REMOTE_PARAMS updates remote.params', () => {
const state = wizardReducer(INITIAL_STATE, {
type: 'SET_REMOTE_PARAMS',
payload: { account: 'myaccount', key: 'mykey' },
});
expect(state.remote.params).toEqual({ account: 'myaccount', key: 'mykey' });
});
it('SET_DEPLOYMENT partially updates deployment', () => {
const state = wizardReducer(INITIAL_STATE, {
type: 'SET_DEPLOYMENT',
payload: { includeInstall: true },
});
expect(state.deployment.includeInstall).toBe(true);
expect(state.deployment.configPath).toBe('machine-wide'); // unchanged
});
it('RESET returns to INITIAL_STATE', () => {
const modified: WizardState = {
...INITIAL_STATE,
currentStep: 3,
remote: { name: 'test', backendType: 's3', params: { region: 'us-east-1' } },
};
const state = wizardReducer(modified, { type: 'RESET' });
expect(state).toEqual(INITIAL_STATE);
});
it('is a pure function — does not mutate input state', () => {
const before = { ...INITIAL_STATE };
wizardReducer(INITIAL_STATE, { type: 'SET_STEP', payload: 5 });
expect(INITIAL_STATE.currentStep).toBe(before.currentStep);
});
});
```
After creating all three files, run the test suite. registry.test.ts should pass; index.test.ts and reducer.test.ts will fail with "Cannot find module" errors — this is correct (RED phase).
</action>
<verify>
<automated>npx vitest run src/schemas/registry.test.ts 2>&1; echo "registry exit: $?"</automated>
</verify>
<done>All three test stub files exist. registry.test.ts passes (GREEN). index.test.ts and reducer.test.ts fail with module-not-found errors (RED — expected). Test stubs provide complete acceptance criteria for Plans 03 and 04.</done>
</task>
</tasks>
<verification>
1. `npx vitest run src/schemas/registry.test.ts` — all tests pass
2. `npx vitest run src/schemas/index.test.ts` — fails with "Cannot find module './index'" (expected at this stage)
3. `npx vitest run src/store/reducer.test.ts` — fails with "Cannot find module './reducer'" (expected at this stage)
4. `cat src/schemas/registry.ts` — confirm no camelCase keys (grep for uppercase in key values)
</verification>
<success_criteria>
- src/schemas/registry.ts exports BackendType, FieldDef, BACKEND_REGISTRY
- All FieldDef.key values are snake_case matching rclone config key names
- BACKEND_REGISTRY contains entries for all three backends with correct field definitions
- registry.test.ts passes (7 tests green)
- index.test.ts and reducer.test.ts exist and fail with module-not-found (RED phase — correct)
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` using the summary template.
</output>