docs(01-foundation): create phase plan
4 plans across 3 waves: scaffold, registry+test stubs, Zod schemas and WizardState store (parallel wave 3).
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user