4 plans across 3 waves: scaffold, registry+test stubs, Zod schemas and WizardState store (parallel wave 3).
17 KiB
17 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-foundation | 02 | execute | 2 |
|
|
true |
|
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).
<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>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/01-foundation/01-01-SUMMARY.md Task 1: Create the Backend Schema Registry src/schemas/registry.ts - 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) 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',
},
],
};
```
**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).
<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>