docs(06-new-backends): create phase plan
4 plans across 3 waves: Wave 0 TDD stubs, Wave 1 data layer (parallel: OneDrive/GCS/B2 + SFTP/SftpAuthToggle), Wave 2 RemoteConfigStep wiring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,13 @@ Plans:
|
||||
3. User can select Google Cloud Storage as a backend, fill in the required fields, and generate a valid rclone.conf containing `[type = google cloud storage]`
|
||||
4. User can select Backblaze B2 as a backend, enter their application key credentials, and generate a valid rclone.conf containing `[type = b2]`
|
||||
5. All four new backends appear in the BackendSelectionStep list and each produces a downloadable config+scripts bundle
|
||||
**Plans**: TBD
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 06-00-PLAN.md — Wave 0 TDD stubs: failing tests for all four new backends across registry, rclone-conf, and RemoteConfigStep
|
||||
- [ ] 06-01-PLAN.md — Registry + schema + RCLONE_TYPE_MAP for OneDrive, GCS, Backblaze B2
|
||||
- [ ] 06-02-PLAN.md — Registry + schema + RCLONE_TYPE_MAP for SFTP + SftpAuthToggle component
|
||||
- [ ] 06-03-PLAN.md — Wire all four backends into RemoteConfigStep + human verification
|
||||
|
||||
### Phase 7: Validation & UX Polish
|
||||
**Goal**: Users receive immediate inline feedback when they enter incorrectly formatted values, and can access plain-language explanations on confusing credential fields without leaving the wizard
|
||||
@@ -79,5 +85,5 @@ Plans:
|
||||
| 3. Wizard UI | v1.0 | 5/5 | Complete | 2026-03-27 |
|
||||
| 4. Review, Download & Security | v1.0 | 5/5 | Complete | 2026-03-27 |
|
||||
| 5. Tech Debt | 4/4 | Complete | 2026-03-30 | - |
|
||||
| 6. New Backends | v1.1 | 0/TBD | Not started | - |
|
||||
| 6. New Backends | v1.1 | 0/4 | Not started | - |
|
||||
| 7. Validation & UX Polish | v1.1 | 0/TBD | Not started | - |
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
---
|
||||
phase: 06-new-backends
|
||||
plan: "00"
|
||||
type: tdd
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/schemas/registry.test.ts
|
||||
- src/generators/rclone-conf.test.ts
|
||||
- src/components/wizard/RemoteConfigStep.test.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BACK-01
|
||||
- BACK-02
|
||||
- BACK-03
|
||||
- BACK-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "registry.test.ts fails (red) with 4 new backend assertions before implementation"
|
||||
- "rclone-conf.test.ts fails (red) with fixtures for onedrive, sftp-password, sftp-key, gcs, b2"
|
||||
- "RemoteConfigStep.test.tsx fails (red) with describe blocks for OneDrive, SFTP, GCS, B2"
|
||||
- "All test stubs reference real field names per rclone docs (token, drive_id, drive_type, host, user, pass, key_pem, project_number, service_account_credentials, account, key)"
|
||||
artifacts:
|
||||
- path: "src/schemas/registry.test.ts"
|
||||
provides: "Failing assertions for all 7 backends including 4 new ones"
|
||||
contains: "EXPECTED_BACKENDS"
|
||||
- path: "src/generators/rclone-conf.test.ts"
|
||||
provides: "Failing fixtures for onedrive, sftp (password), sftp (key), gcs, b2"
|
||||
contains: "type = google cloud storage"
|
||||
- path: "src/components/wizard/RemoteConfigStep.test.tsx"
|
||||
provides: "Failing describe blocks for BACK-01 through BACK-04"
|
||||
contains: "BACK-04: Backblaze B2"
|
||||
key_links:
|
||||
- from: "src/schemas/registry.test.ts"
|
||||
to: "src/schemas/registry.ts"
|
||||
via: "EXPECTED_BACKENDS array import"
|
||||
pattern: "EXPECTED_BACKENDS.*BackendType"
|
||||
- from: "src/generators/rclone-conf.test.ts"
|
||||
to: "src/generators/rclone-conf.ts"
|
||||
via: "buildRcloneConf fixtures with new WizardState shapes"
|
||||
pattern: "backendType.*gcs|b2|onedrive|sftp"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Write Wave 0 failing test stubs for all four new backends across three test files.
|
||||
|
||||
Purpose: Establish RED state before any implementation — ensures tests drive and verify the work in Plans 01-03.
|
||||
Output: Three updated test files with failing assertions for OneDrive, SFTP, GCS, and Backblaze B2.
|
||||
</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
|
||||
@.planning/phases/06-new-backends/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing test file shapes the executor must follow. -->
|
||||
|
||||
From src/schemas/registry.test.ts:
|
||||
```typescript
|
||||
const EXPECTED_BACKENDS: BackendType[] = ['azureblob', 's3', 's3-compatible'];
|
||||
// Pattern: loops over EXPECTED_BACKENDS for generic assertions,
|
||||
// then individual describe blocks for backend-specific field assertions
|
||||
```
|
||||
|
||||
From src/generators/rclone-conf.test.ts:
|
||||
```typescript
|
||||
// Fixture pattern:
|
||||
const azureState: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: { name: 'my-azure', backendType: 'azureblob', params: { account: 'mystorageaccount', key: 'BASE64KEY', sas_url: '' } },
|
||||
};
|
||||
// describe('buildRcloneConf — azureblob', () => { ... })
|
||||
```
|
||||
|
||||
From src/components/wizard/RemoteConfigStep.test.tsx:
|
||||
```typescript
|
||||
// renderWithBackend helper:
|
||||
function renderWithBackend(backendType: BackendType) {
|
||||
function Setup() {
|
||||
const { dispatch } = useWizard();
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_BACKEND_TYPE', payload: backendType });
|
||||
dispatch({ type: 'SET_STEP', payload: 1 });
|
||||
}, []);
|
||||
return <RemoteConfigStep key={backendType} />;
|
||||
}
|
||||
return render(<WizardProvider><Setup /></WizardProvider>);
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Update registry.test.ts with 7-backend assertions (RED)</name>
|
||||
<files>src/schemas/registry.test.ts</files>
|
||||
<behavior>
|
||||
- EXPECTED_BACKENDS now includes all 7 types: ['azureblob', 's3', 's3-compatible', 'onedrive', 'sftp', 'gcs', 'b2']
|
||||
- Generic loop tests (has fields, has displayName/description, snake_case keys) run over all 7
|
||||
- New describe 'OneDrive': token field (required), drive_id field (required), drive_type field (required, select)
|
||||
- New describe 'SFTP': host field (required), user field (required), pass field (optional), key_pem field (optional)
|
||||
- New describe 'GCS': project_number field (required), service_account_credentials field (required)
|
||||
- New describe 'Backblaze B2': account field (required), key field (required)
|
||||
- Running `npx vitest run src/schemas/registry.test.ts` MUST fail (RED) because registry.ts still has only 3 BackendType values
|
||||
</behavior>
|
||||
<action>
|
||||
Replace the content of src/schemas/registry.test.ts with the updated version.
|
||||
|
||||
1. Change EXPECTED_BACKENDS to:
|
||||
`const EXPECTED_BACKENDS: BackendType[] = ['azureblob', 's3', 's3-compatible', 'onedrive', 'sftp', 'gcs', 'b2'];`
|
||||
Note: This line will fail TypeScript because BackendType in registry.ts does not yet include the 4 new values. That is the intended RED state.
|
||||
|
||||
2. Keep all existing generic loop tests and existing backend-specific describe blocks unchanged.
|
||||
|
||||
3. Append four new describe blocks at the bottom:
|
||||
|
||||
```typescript
|
||||
describe('OneDrive backend', () => {
|
||||
it('has token field (required)', () => {
|
||||
const f = BACKEND_REGISTRY.onedrive.fields.find(f => f.key === 'token');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
expect(f!.inputType).toBe('password');
|
||||
});
|
||||
it('has drive_id field (required)', () => {
|
||||
const f = BACKEND_REGISTRY.onedrive.fields.find(f => f.key === 'drive_id');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
});
|
||||
it('has drive_type field as select', () => {
|
||||
const f = BACKEND_REGISTRY.onedrive.fields.find(f => f.key === 'drive_type');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.inputType).toBe('select');
|
||||
expect(f!.options).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SFTP backend', () => {
|
||||
it('has host field (required)', () => {
|
||||
const f = BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'host');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
});
|
||||
it('has user field (required)', () => {
|
||||
const f = BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'user');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
});
|
||||
it('has pass field (optional — SftpAuthToggle handles display)', () => {
|
||||
const f = BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'pass');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(false);
|
||||
});
|
||||
it('has key_pem field (optional — SftpAuthToggle handles display)', () => {
|
||||
const f = BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'key_pem');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Cloud Storage backend', () => {
|
||||
it('has project_number field (required)', () => {
|
||||
const f = BACKEND_REGISTRY.gcs.fields.find(f => f.key === 'project_number');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
});
|
||||
it('has service_account_credentials field (required, password)', () => {
|
||||
const f = BACKEND_REGISTRY.gcs.fields.find(f => f.key === 'service_account_credentials');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
expect(f!.inputType).toBe('password');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backblaze B2 backend', () => {
|
||||
it('has account field (required) — applicationKeyId', () => {
|
||||
const f = BACKEND_REGISTRY.b2.fields.find(f => f.key === 'account');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
});
|
||||
it('has key field (required) — application key secret', () => {
|
||||
const f = BACKEND_REGISTRY.b2.fields.find(f => f.key === 'key');
|
||||
expect(f).toBeDefined();
|
||||
expect(f!.required).toBe(true);
|
||||
expect(f!.inputType).toBe('password');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Do NOT suppress TypeScript errors. The RED state is the goal.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>MISSING — run after Wave 0 to confirm RED: npx vitest run src/schemas/registry.test.ts 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>registry.test.ts references all 7 backends and asserts new field requirements; file committed; test suite fails (RED) because registry.ts has not been updated yet</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Update rclone-conf.test.ts with new backend fixtures (RED)</name>
|
||||
<files>src/generators/rclone-conf.test.ts</files>
|
||||
<behavior>
|
||||
- Five new WizardState fixtures: onedriveState, sftpPasswordState, sftpKeyState, gcsState, b2State
|
||||
- describe 'buildRcloneConf — onedrive': type = onedrive, contains token, drive_id, drive_type
|
||||
- describe 'buildRcloneConf — sftp (password)': type = sftp, contains host, user, pass; does NOT contain key_pem
|
||||
- describe 'buildRcloneConf — sftp (key)': type = sftp, contains host, user, key_pem; does NOT contain pass
|
||||
- describe 'buildRcloneConf — gcs': type = google cloud storage (WITH spaces), contains project_number, service_account_credentials
|
||||
- describe 'buildRcloneConf — b2': type = b2, contains account (applicationKeyId), key (application key)
|
||||
- Running `npx vitest run src/generators/rclone-conf.test.ts` MUST fail (RED) because RCLONE_TYPE_MAP has no entries for onedrive/sftp/gcs/b2
|
||||
</behavior>
|
||||
<action>
|
||||
Append the following fixtures and describe blocks to the END of src/generators/rclone-conf.test.ts (after the existing error cases block). Do not modify existing content.
|
||||
|
||||
```typescript
|
||||
// --- New backend fixtures (Phase 6) ---
|
||||
|
||||
const onedriveState: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: {
|
||||
name: 'my-onedrive',
|
||||
backendType: 'onedrive',
|
||||
params: {
|
||||
token: '{"access_token":"TOKEN","token_type":"Bearer","refresh_token":"REFRESH","expiry":"2026-01-01T00:00:00Z"}',
|
||||
drive_id: 'b!TESTDRIVEID',
|
||||
drive_type: 'business',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sftpPasswordState: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: {
|
||||
name: 'my-sftp-pass',
|
||||
backendType: 'sftp',
|
||||
params: { host: 'sftp.example.com', user: 'admin', pass: 'secretpass', key_pem: '' },
|
||||
},
|
||||
};
|
||||
|
||||
const sftpKeyState: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: {
|
||||
name: 'my-sftp-key',
|
||||
backendType: 'sftp',
|
||||
params: { host: 'sftp.example.com', user: 'admin', pass: '', key_pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEo...\n-----END RSA PRIVATE KEY-----' },
|
||||
},
|
||||
};
|
||||
|
||||
const gcsState: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: {
|
||||
name: 'my-gcs',
|
||||
backendType: 'gcs',
|
||||
params: {
|
||||
project_number: '123456789',
|
||||
service_account_credentials: '{"type":"service_account","project_id":"my-project"}',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const b2State: WizardState = {
|
||||
...INITIAL_STATE,
|
||||
remote: {
|
||||
name: 'my-b2',
|
||||
backendType: 'b2',
|
||||
params: { account: 'APP_KEY_ID', key: 'APP_KEY_SECRET' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('buildRcloneConf — onedrive', () => {
|
||||
it('contains type = onedrive', () => {
|
||||
expect(buildRcloneConf(onedriveState)).toContain('type = onedrive');
|
||||
});
|
||||
it('contains token value', () => {
|
||||
expect(buildRcloneConf(onedriveState)).toContain('token = ');
|
||||
});
|
||||
it('contains drive_id', () => {
|
||||
expect(buildRcloneConf(onedriveState)).toContain('drive_id = b!TESTDRIVEID');
|
||||
});
|
||||
it('contains drive_type = business', () => {
|
||||
expect(buildRcloneConf(onedriveState)).toContain('drive_type = business');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRcloneConf — sftp (password auth)', () => {
|
||||
it('contains type = sftp', () => {
|
||||
expect(buildRcloneConf(sftpPasswordState)).toContain('type = sftp');
|
||||
});
|
||||
it('contains host', () => {
|
||||
expect(buildRcloneConf(sftpPasswordState)).toContain('host = sftp.example.com');
|
||||
});
|
||||
it('contains user', () => {
|
||||
expect(buildRcloneConf(sftpPasswordState)).toContain('user = admin');
|
||||
});
|
||||
it('contains pass', () => {
|
||||
expect(buildRcloneConf(sftpPasswordState)).toContain('pass = secretpass');
|
||||
});
|
||||
it('omits key_pem when empty', () => {
|
||||
expect(buildRcloneConf(sftpPasswordState)).not.toContain('key_pem');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRcloneConf — sftp (key auth)', () => {
|
||||
it('contains type = sftp', () => {
|
||||
expect(buildRcloneConf(sftpKeyState)).toContain('type = sftp');
|
||||
});
|
||||
it('contains key_pem', () => {
|
||||
expect(buildRcloneConf(sftpKeyState)).toContain('key_pem = -----BEGIN RSA PRIVATE KEY-----');
|
||||
});
|
||||
it('omits pass when empty', () => {
|
||||
expect(buildRcloneConf(sftpKeyState)).not.toContain('pass =');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRcloneConf — gcs', () => {
|
||||
it('contains type = google cloud storage (with spaces)', () => {
|
||||
expect(buildRcloneConf(gcsState)).toContain('type = google cloud storage');
|
||||
});
|
||||
it('does NOT contain type = gcs', () => {
|
||||
expect(buildRcloneConf(gcsState)).not.toContain('type = gcs');
|
||||
});
|
||||
it('contains project_number', () => {
|
||||
expect(buildRcloneConf(gcsState)).toContain('project_number = 123456789');
|
||||
});
|
||||
it('contains service_account_credentials', () => {
|
||||
expect(buildRcloneConf(gcsState)).toContain('service_account_credentials = ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRcloneConf — b2', () => {
|
||||
it('contains type = b2', () => {
|
||||
expect(buildRcloneConf(b2State)).toContain('type = b2');
|
||||
});
|
||||
it('contains account (applicationKeyId)', () => {
|
||||
expect(buildRcloneConf(b2State)).toContain('account = APP_KEY_ID');
|
||||
});
|
||||
it('contains key (application key secret)', () => {
|
||||
expect(buildRcloneConf(b2State)).toContain('key = APP_KEY_SECRET');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Note: WizardState type accepts `backendType: 'onedrive' | 'sftp' | 'gcs' | 'b2'` only after Plan 01/02 extend BackendType. In RED state TypeScript will error on these — that is expected. If needed, cast as `backendType: 'onedrive' as BackendType` to allow the file to parse while staying RED on the runtime assertions.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>MISSING — run after Wave 0 to confirm RED: npx vitest run src/generators/rclone-conf.test.ts 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>rclone-conf.test.ts has fixtures and assertions for all five new backend scenarios; tests fail (RED) because RCLONE_TYPE_MAP does not yet contain the new types</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Update RemoteConfigStep.test.tsx with new backend describe blocks (RED)</name>
|
||||
<files>src/components/wizard/RemoteConfigStep.test.tsx</files>
|
||||
<behavior>
|
||||
- New describe 'BACK-01 (Phase 6): OneDrive form': renders OAuth Token field (password), renders Drive ID field, renders Drive Type select
|
||||
- New describe 'BACK-02 (Phase 6): SFTP form': renders Host field, renders Username field, renders Password tab button, renders Private Key tab button, Password tab shows pass field, Private Key tab shows key_pem field, switching tabs preserves hidden field value (CSS-hidden pattern, same as Azure toggle test)
|
||||
- New describe 'BACK-03 (Phase 6): GCS form': renders Project Number field, renders Service Account JSON field
|
||||
- New describe 'BACK-04 (Phase 6): Backblaze B2 form': renders Application Key ID field, renders Application Key field
|
||||
- Running `npx vitest run src/components/wizard/RemoteConfigStep.test.tsx` MUST fail (RED) because RemoteConfigStep.tsx does not yet handle these backends and BackendType does not include them
|
||||
</behavior>
|
||||
<action>
|
||||
Append the following describe blocks to the BOTTOM of the existing describe('RemoteConfigStep') block in src/components/wizard/RemoteConfigStep.test.tsx (inside the outermost describe, after the BACK-03 S3-Compatible block):
|
||||
|
||||
```typescript
|
||||
describe('BACK-01 (Phase 6): OneDrive form', () => {
|
||||
it('renders OAuth Token field', async () => {
|
||||
renderWithBackend('onedrive' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/oauth token/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('renders Drive ID field', async () => {
|
||||
renderWithBackend('onedrive' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/drive id/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('renders Drive Type select', async () => {
|
||||
renderWithBackend('onedrive' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/drive type/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACK-02 (Phase 6): SFTP form', () => {
|
||||
it('renders Host field', async () => {
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/host/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('renders Username field', async () => {
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/username/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('shows Password tab button by default', async () => {
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/^password$/i, { selector: 'button' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('shows Private Key tab button', async () => {
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/private key/i, { selector: 'button' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('switching to Private Key tab CSS-hides password field and shows key_pem field', async () => {
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/private key/i, { selector: 'button' })).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText(/private key/i, { selector: 'button' }));
|
||||
await waitFor(() => {
|
||||
const passInput = screen.getByLabelText(/^password$/i);
|
||||
const passWrapper = passInput.closest('div.hidden');
|
||||
expect(passWrapper).toBeDefined();
|
||||
const keyInput = screen.getByLabelText(/private key.*pem/i);
|
||||
const keyWrapper = keyInput.closest('div.block');
|
||||
expect(keyWrapper).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('switching SFTP auth tabs preserves hidden field value', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithBackend('sftp' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/^password$/i)).toBeDefined();
|
||||
});
|
||||
await user.type(screen.getByLabelText(/^password$/i), 'mysftppass');
|
||||
fireEvent.click(screen.getByText(/private key/i, { selector: 'button' }));
|
||||
fireEvent.click(screen.getByText(/^password$/i, { selector: 'button' }));
|
||||
await waitFor(() => {
|
||||
const input = screen.getByLabelText(/^password$/i) as HTMLInputElement;
|
||||
expect(input.value).toBe('mysftppass');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACK-03 (Phase 6): Google Cloud Storage form', () => {
|
||||
it('renders Project Number field', async () => {
|
||||
renderWithBackend('gcs' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/project number/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('renders Service Account JSON field', async () => {
|
||||
renderWithBackend('gcs' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/service account json/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BACK-04 (Phase 6): Backblaze B2 form', () => {
|
||||
it('renders Application Key ID field', async () => {
|
||||
renderWithBackend('b2' as BackendType);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/application key id/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
it('renders Application Key field', async () => {
|
||||
renderWithBackend('b2' as BackendType);
|
||||
await waitFor(() => {
|
||||
// Matches "Application Key" but not "Application Key ID"
|
||||
expect(screen.getByLabelText(/application key$/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The `as BackendType` casts allow the file to parse. Tests will fail at runtime (RED) because RemoteConfigStep does not yet know about these backends and TypeScript errors on the union.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>MISSING — run after Wave 0 to confirm RED: npx vitest run src/components/wizard/RemoteConfigStep.test.tsx 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>RemoteConfigStep.test.tsx has describe blocks for OneDrive, SFTP (with toggle), GCS, and B2; tests fail (RED) because implementation does not exist yet</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all three tasks: run the full suite to confirm RED state.
|
||||
`npx vitest run src/schemas/registry.test.ts src/generators/rclone-conf.test.ts src/components/wizard/RemoteConfigStep.test.tsx`
|
||||
Expected: failures for all new backend assertions. Existing tests (azureblob, s3, s3-compatible) must still pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three test files updated with new backend assertions
|
||||
- Existing passing tests remain green
|
||||
- New backend assertions are red (implementation not yet done)
|
||||
- No test stubs use vague matchers — all assertions reference exact field labels and rclone keys
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-new-backends/06-00-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,288 @@
|
||||
---
|
||||
phase: 06-new-backends
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on:
|
||||
- "06-00"
|
||||
files_modified:
|
||||
- src/schemas/registry.ts
|
||||
- src/schemas/index.ts
|
||||
- src/generators/rclone-conf.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BACK-01
|
||||
- BACK-03
|
||||
- BACK-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "BackendType union includes 'onedrive', 'gcs', 'b2' (and 'sftp' added by Plan 02)"
|
||||
- "BACKEND_REGISTRY has onedrive, gcs, b2 entries with correct field keys matching rclone docs"
|
||||
- "BACKEND_SCHEMAS has onedrive, gcs, b2 entries built via buildZodSchema"
|
||||
- "RCLONE_TYPE_MAP maps gcs to 'google cloud storage' (with spaces)"
|
||||
- "buildRcloneConf(gcsState) outputs 'type = google cloud storage'"
|
||||
- "buildRcloneConf(b2State) outputs 'type = b2'"
|
||||
- "buildRcloneConf(onedriveState) outputs 'type = onedrive'"
|
||||
artifacts:
|
||||
- path: "src/schemas/registry.ts"
|
||||
provides: "BackendType union with 6 types (onedrive, gcs, b2 added); registry entries for all three"
|
||||
contains: "onedrive.*gcs.*b2"
|
||||
- path: "src/schemas/index.ts"
|
||||
provides: "BACKEND_SCHEMAS with onedrive, gcs, b2"
|
||||
contains: "buildZodSchema('onedrive')"
|
||||
- path: "src/generators/rclone-conf.ts"
|
||||
provides: "RCLONE_TYPE_MAP with gcs mapped to 'google cloud storage'"
|
||||
contains: "google cloud storage"
|
||||
key_links:
|
||||
- from: "src/schemas/index.ts"
|
||||
to: "src/schemas/registry.ts"
|
||||
via: "buildZodSchema calls — BackendType must include new types before BACKEND_SCHEMAS can reference them"
|
||||
pattern: "buildZodSchema\\('(onedrive|gcs|b2)'\\)"
|
||||
- from: "src/generators/rclone-conf.ts"
|
||||
to: "RCLONE_TYPE_MAP"
|
||||
via: "gcs key maps to string with spaces — critical for correct rclone.conf output"
|
||||
pattern: "gcs.*google cloud storage"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add OneDrive, GCS, and Backblaze B2 to the data layer — BackendType union, registry entries, Zod schemas, and RCLONE_TYPE_MAP.
|
||||
|
||||
Purpose: Three simple backends (no custom toggle UI) that follow the pure registry + FieldRenderer pattern. Implementing them first keeps SFTP (Plan 02) isolated.
|
||||
Output: registry.ts, index.ts, rclone-conf.ts updated; registry and rclone-conf tests go green for these three backends.
|
||||
</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
|
||||
@.planning/phases/06-new-backends/06-RESEARCH.md
|
||||
@.planning/phases/06-new-backends/06-00-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Current state of files before modification -->
|
||||
|
||||
From src/schemas/registry.ts (current):
|
||||
```typescript
|
||||
export type BackendType = 'azureblob' | 's3' | 's3-compatible';
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
inputType: 'text' | 'password' | 'select' | 'toggle';
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const BACKEND_REGISTRY: Record<BackendType, {
|
||||
displayName: string;
|
||||
description: string;
|
||||
fields: FieldDef[];
|
||||
}> = { azureblob: {...}, s3: {...}, 's3-compatible': {...} };
|
||||
```
|
||||
|
||||
From src/schemas/index.ts (current):
|
||||
```typescript
|
||||
function buildZodSchema(backendType: BackendType): z.ZodObject<Record<string, z.ZodTypeAny>>
|
||||
export const BACKEND_SCHEMAS = {
|
||||
azureblob: buildZodSchema('azureblob'),
|
||||
s3: buildZodSchema('s3'),
|
||||
's3-compatible': buildZodSchema('s3-compatible'),
|
||||
} as const;
|
||||
```
|
||||
|
||||
From src/generators/rclone-conf.ts (current):
|
||||
```typescript
|
||||
const RCLONE_TYPE_MAP: Record<string, string> = {
|
||||
azureblob: 'azureblob',
|
||||
s3: 's3',
|
||||
's3-compatible': 's3',
|
||||
};
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Extend registry.ts — BackendType union + onedrive/gcs/b2 entries</name>
|
||||
<files>src/schemas/registry.ts</files>
|
||||
<behavior>
|
||||
- BackendType union becomes: 'azureblob' | 's3' | 's3-compatible' | 'onedrive' | 'sftp' | 'gcs' | 'b2'
|
||||
(include 'sftp' in union now — Plan 02 adds its registry entry; including it in the union prevents TypeScript errors in Plan 02's parallel work)
|
||||
- BACKEND_REGISTRY gains onedrive entry: token (password, required), drive_id (text, required), drive_type (select, required, options: personal/business/documentLibrary)
|
||||
- BACKEND_REGISTRY gains gcs entry: project_number (text, required), service_account_credentials (password, required)
|
||||
- BACKEND_REGISTRY gains b2 entry: account (text, required), key (password, required)
|
||||
- sftp entry NOT added here — Plan 02 adds it. TypeScript will error on BACKEND_REGISTRY type until Plan 02 adds sftp. This is acceptable during parallel execution.
|
||||
- `npx vitest run src/schemas/registry.test.ts` passes for onedrive, gcs, b2 assertions; sftp assertions still fail (Plan 02 fixes those)
|
||||
</behavior>
|
||||
<action>
|
||||
Edit src/schemas/registry.ts:
|
||||
|
||||
1. Replace BackendType line with:
|
||||
`export type BackendType = 'azureblob' | 's3' | 's3-compatible' | 'onedrive' | 'sftp' | 'gcs' | 'b2';`
|
||||
|
||||
2. Add three new entries to BACKEND_REGISTRY after the 's3-compatible' entry:
|
||||
|
||||
```typescript
|
||||
onedrive: {
|
||||
displayName: 'OneDrive',
|
||||
description: 'Microsoft OneDrive (paste pre-obtained rclone token)',
|
||||
fields: [
|
||||
{
|
||||
key: 'token',
|
||||
label: 'OAuth Token (JSON)',
|
||||
inputType: 'password',
|
||||
required: true,
|
||||
placeholder: '{"access_token":"...","token_type":"Bearer","refresh_token":"...","expiry":"..."}',
|
||||
helpText: 'Paste the JSON token from: rclone authorize "onedrive"',
|
||||
},
|
||||
{
|
||||
key: 'drive_id',
|
||||
label: 'Drive ID',
|
||||
inputType: 'text',
|
||||
required: true,
|
||||
placeholder: 'b!...',
|
||||
helpText: 'The drive ID from your OneDrive. Found in the rclone authorize output.',
|
||||
},
|
||||
{
|
||||
key: 'drive_type',
|
||||
label: 'Drive Type',
|
||||
inputType: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'personal', label: 'Personal' },
|
||||
{ value: 'business', label: 'Business' },
|
||||
{ value: 'documentLibrary', label: 'SharePoint Document Library' },
|
||||
],
|
||||
helpText: 'Personal for consumer OneDrive, Business for Microsoft 365',
|
||||
},
|
||||
],
|
||||
},
|
||||
gcs: {
|
||||
displayName: 'Google Cloud Storage',
|
||||
description: 'Google Cloud Storage',
|
||||
fields: [
|
||||
{
|
||||
key: 'project_number',
|
||||
label: 'Project Number',
|
||||
inputType: 'text',
|
||||
required: true,
|
||||
placeholder: '123456789',
|
||||
helpText: 'Your GCP project number (not project ID)',
|
||||
},
|
||||
{
|
||||
key: 'service_account_credentials',
|
||||
label: 'Service Account JSON',
|
||||
inputType: 'password',
|
||||
required: true,
|
||||
placeholder: '{"type":"service_account","project_id":"..."}',
|
||||
helpText: 'Paste the full content of your service account JSON key file',
|
||||
},
|
||||
],
|
||||
},
|
||||
b2: {
|
||||
displayName: 'Backblaze B2',
|
||||
description: 'Backblaze B2 Cloud Storage',
|
||||
fields: [
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Application Key ID',
|
||||
inputType: 'text',
|
||||
required: true,
|
||||
placeholder: 'your-application-key-id',
|
||||
helpText: 'The applicationKeyId — not the master account ID',
|
||||
},
|
||||
{
|
||||
key: 'key',
|
||||
label: 'Application Key',
|
||||
inputType: 'password',
|
||||
required: true,
|
||||
helpText: 'The application key (secret value from the B2 dashboard)',
|
||||
},
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
Note: BACKEND_REGISTRY type is `Record<BackendType, ...>` which requires ALL BackendType values. After this task, TypeScript will error because 'sftp' is in the union but not yet in the BACKEND_REGISTRY object. Plan 02 adds the sftp entry. If running `npx tsc --noEmit` between plans, expect this error temporarily. The Vitest tests will still run.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/schemas/registry.test.ts 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>BackendType includes all 7 types; onedrive, gcs, b2 registry entries present with correct field keys; registry tests pass for those three backends</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Extend index.ts and rclone-conf.ts — schemas + type map for onedrive/gcs/b2</name>
|
||||
<files>src/schemas/index.ts, src/generators/rclone-conf.ts</files>
|
||||
<behavior>
|
||||
- BACKEND_SCHEMAS gains onedrive, gcs, b2 entries using buildZodSchema
|
||||
- RCLONE_TYPE_MAP gains: onedrive → 'onedrive', gcs → 'google cloud storage', b2 → 'b2'
|
||||
- buildRcloneConf(gcsState) produces a line 'type = google cloud storage' (with spaces — NOT 'type = gcs')
|
||||
- buildRcloneConf(b2State) produces 'type = b2' with account and key lines
|
||||
- buildRcloneConf(onedriveState) produces 'type = onedrive' with token, drive_id, drive_type lines
|
||||
- `npx vitest run src/generators/rclone-conf.test.ts` passes for onedrive, gcs, b2 describe blocks
|
||||
</behavior>
|
||||
<action>
|
||||
**src/schemas/index.ts:** Add three entries to BACKEND_SCHEMAS:
|
||||
```typescript
|
||||
export const BACKEND_SCHEMAS = {
|
||||
azureblob: buildZodSchema('azureblob'),
|
||||
s3: buildZodSchema('s3'),
|
||||
's3-compatible': buildZodSchema('s3-compatible'),
|
||||
onedrive: buildZodSchema('onedrive'),
|
||||
gcs: buildZodSchema('gcs'),
|
||||
b2: buildZodSchema('b2'),
|
||||
} as const;
|
||||
```
|
||||
Do NOT add sftp yet — Plan 02 adds it after adding the sftp registry entry.
|
||||
|
||||
**src/generators/rclone-conf.ts:** Extend RCLONE_TYPE_MAP:
|
||||
```typescript
|
||||
const RCLONE_TYPE_MAP: Record<string, string> = {
|
||||
azureblob: 'azureblob',
|
||||
s3: 's3',
|
||||
's3-compatible': 's3',
|
||||
onedrive: 'onedrive',
|
||||
gcs: 'google cloud storage', // NOTE: spaces in rclone type value — do NOT shorten
|
||||
b2: 'b2',
|
||||
};
|
||||
```
|
||||
Do NOT add sftp yet — Plan 02 adds it.
|
||||
|
||||
After editing, run full suite to confirm existing tests still pass and new backend tests pass.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/schemas/registry.test.ts src/generators/rclone-conf.test.ts 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>BACKEND_SCHEMAS covers onedrive/gcs/b2; RCLONE_TYPE_MAP maps gcs to 'google cloud storage'; rclone-conf tests for onedrive/gcs/b2 pass; no regressions on existing backends</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
`npx vitest run src/schemas/registry.test.ts src/generators/rclone-conf.test.ts`
|
||||
Expected: onedrive/gcs/b2 tests GREEN; sftp tests still RED (Plan 02 fixes those); no regressions on azureblob/s3/s3-compatible.
|
||||
|
||||
`npx tsc --noEmit` — expect one error: BACKEND_REGISTRY missing 'sftp' key (fixed by Plan 02). All other TypeScript must be clean.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- BackendType union has all 7 types
|
||||
- onedrive, gcs, b2 registry entries with correct rclone key names
|
||||
- BACKEND_SCHEMAS covers onedrive, gcs, b2
|
||||
- gcs maps to 'google cloud storage' in RCLONE_TYPE_MAP
|
||||
- rclone-conf tests pass for onedrive, gcs, b2
|
||||
- Zero TypeScript errors outside the known sftp-missing-key error
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-new-backends/06-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
phase: 06-new-backends
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on:
|
||||
- "06-00"
|
||||
files_modified:
|
||||
- src/schemas/registry.ts
|
||||
- src/schemas/index.ts
|
||||
- src/generators/rclone-conf.ts
|
||||
- src/components/wizard/SftpAuthToggle.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- BACK-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "BACKEND_REGISTRY has sftp entry with host (required), user (required), pass (optional), key_pem (optional)"
|
||||
- "SftpAuthToggle renders a Password tab and Private Key tab; inactive tab's field is CSS-hidden (not unmounted)"
|
||||
- "Switching SFTP auth tabs does not clear the hidden field value (CSS-hidden, not conditional render)"
|
||||
- "buildRcloneConf(sftpPasswordState) outputs 'type = sftp' with pass, omits key_pem"
|
||||
- "buildRcloneConf(sftpKeyState) outputs 'type = sftp' with key_pem, omits pass"
|
||||
artifacts:
|
||||
- path: "src/schemas/registry.ts"
|
||||
provides: "sftp entry in BACKEND_REGISTRY"
|
||||
contains: "key_pem"
|
||||
- path: "src/components/wizard/SftpAuthToggle.tsx"
|
||||
provides: "SFTP password vs private-key toggle following AzureAuthToggle CSS-hidden pattern"
|
||||
exports: ["SftpAuthToggle"]
|
||||
- path: "src/generators/rclone-conf.ts"
|
||||
provides: "RCLONE_TYPE_MAP sftp entry"
|
||||
contains: "sftp.*sftp"
|
||||
key_links:
|
||||
- from: "src/components/wizard/SftpAuthToggle.tsx"
|
||||
to: "src/components/ui/PasswordField.tsx"
|
||||
via: "PasswordField component for both pass and key_pem fields"
|
||||
pattern: "PasswordField.*pass|PasswordField.*key_pem"
|
||||
- from: "src/schemas/registry.ts"
|
||||
to: "BACKEND_REGISTRY sftp entry"
|
||||
via: "pass and key_pem fields required: false — SftpAuthToggle registers them directly"
|
||||
pattern: "key_pem.*required.*false"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add SFTP to the data layer and create the SftpAuthToggle component.
|
||||
|
||||
Purpose: SFTP is the only new backend requiring a custom auth-method toggle (password vs private key). Isolating SFTP work in its own plan keeps Plan 01 clean and allows parallel execution.
|
||||
Output: sftp entry in registry.ts/index.ts/rclone-conf.ts; SftpAuthToggle.tsx component following the AzureAuthToggle CSS-hidden pattern.
|
||||
</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
|
||||
@.planning/phases/06-new-backends/06-RESEARCH.md
|
||||
@.planning/phases/06-new-backends/06-00-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- AzureAuthToggle.tsx — the exact pattern SftpAuthToggle must follow -->
|
||||
|
||||
From src/components/wizard/AzureAuthToggle.tsx:
|
||||
```typescript
|
||||
import { useState } from 'react';
|
||||
import type { UseFormRegister, FieldError } from 'react-hook-form';
|
||||
import { PasswordField } from '../ui/PasswordField';
|
||||
|
||||
type AuthMethod = 'sas' | 'key';
|
||||
|
||||
interface AzureAuthToggleProps {
|
||||
register: UseFormRegister<any>;
|
||||
errors: { key?: FieldError; sas_url?: FieldError; };
|
||||
}
|
||||
|
||||
export function AzureAuthToggle({ register, errors }: AzureAuthToggleProps) {
|
||||
const [authMethod, setAuthMethod] = useState<AuthMethod>('sas');
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex rounded-md border border-gray-300 overflow-hidden">
|
||||
<button type="button" onClick={() => setAuthMethod('sas')} className={[
|
||||
'flex-1 py-1.5 text-sm font-medium transition-colors',
|
||||
authMethod === 'sas' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50',
|
||||
].join(' ')}>SAS URL</button>
|
||||
<button type="button" onClick={() => setAuthMethod('key')} className={...}>Access Key</button>
|
||||
</div>
|
||||
{/* Both fields always registered — CSS toggling only */}
|
||||
<div className={authMethod === 'sas' ? 'block' : 'hidden'}>
|
||||
<PasswordField id="sas_url" label="SAS URL" error={errors.sas_url} registration={register('sas_url')} ... />
|
||||
</div>
|
||||
<div className={authMethod === 'key' ? 'block' : 'hidden'}>
|
||||
<PasswordField id="key" label="Access Key" error={errors.key} registration={register('key')} ... />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
From src/schemas/registry.ts (current sftp fields — to add):
|
||||
```typescript
|
||||
// sftp registry entry (add to BACKEND_REGISTRY):
|
||||
sftp: {
|
||||
displayName: 'SFTP',
|
||||
description: 'SSH File Transfer Protocol',
|
||||
fields: [
|
||||
{ key: 'host', label: 'Host', inputType: 'text', required: true, placeholder: 'sftp.example.com', helpText: 'The SSH server hostname or IP address' },
|
||||
{ key: 'user', label: 'Username', inputType: 'text', required: true, placeholder: 'admin' },
|
||||
{ key: 'pass', label: 'Password', inputType: 'password', required: false, helpText: 'SFTP password. Note: rclone may require the password to be obscured using `rclone obscure <password>`. If authentication fails, use the obscured value.' },
|
||||
{ key: 'key_pem', label: 'Private Key (PEM)', inputType: 'password', required: false, helpText: 'Paste your private key in PEM format (-----BEGIN ... PRIVATE KEY-----)' },
|
||||
],
|
||||
},
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add sftp entry to registry.ts, index.ts, and rclone-conf.ts</name>
|
||||
<files>src/schemas/registry.ts, src/schemas/index.ts, src/generators/rclone-conf.ts</files>
|
||||
<behavior>
|
||||
- BACKEND_REGISTRY gains sftp entry with host (text, required), user (text, required), pass (password, optional), key_pem (password, optional)
|
||||
- pass helpText mentions rclone obscure requirement (security note, does not block generation)
|
||||
- BACKEND_SCHEMAS gains sftp entry via buildZodSchema('sftp')
|
||||
- RCLONE_TYPE_MAP gains sftp → 'sftp'
|
||||
- `npx vitest run src/schemas/registry.test.ts` passes all sftp assertions (host, user, pass, key_pem)
|
||||
- `npx vitest run src/generators/rclone-conf.test.ts` passes sftp password and sftp key describe blocks
|
||||
- `npx tsc --noEmit` is clean (no more "sftp missing from BACKEND_REGISTRY" error)
|
||||
</behavior>
|
||||
<action>
|
||||
**src/schemas/registry.ts:** Add sftp entry to BACKEND_REGISTRY (after 's3-compatible', before or after the entries added by Plan 01 — order does not matter):
|
||||
|
||||
```typescript
|
||||
sftp: {
|
||||
displayName: 'SFTP',
|
||||
description: 'SSH File Transfer Protocol',
|
||||
fields: [
|
||||
{
|
||||
key: 'host',
|
||||
label: 'Host',
|
||||
inputType: 'text',
|
||||
required: true,
|
||||
placeholder: 'sftp.example.com',
|
||||
helpText: 'The SSH server hostname or IP address',
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: 'Username',
|
||||
inputType: 'text',
|
||||
required: true,
|
||||
placeholder: 'admin',
|
||||
},
|
||||
{
|
||||
key: 'pass',
|
||||
label: 'Password',
|
||||
inputType: 'password',
|
||||
required: false,
|
||||
helpText: 'SFTP password. Note: rclone may require the password to be obscured using `rclone obscure <password>`. If authentication fails, use the obscured value instead of the plain password.',
|
||||
},
|
||||
{
|
||||
key: 'key_pem',
|
||||
label: 'Private Key (PEM)',
|
||||
inputType: 'password',
|
||||
required: false,
|
||||
helpText: 'Paste your private key in PEM format (-----BEGIN ... PRIVATE KEY-----)',
|
||||
},
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
**src/schemas/index.ts:** Add sftp to BACKEND_SCHEMAS:
|
||||
```typescript
|
||||
sftp: buildZodSchema('sftp'),
|
||||
```
|
||||
|
||||
**src/generators/rclone-conf.ts:** Add sftp to RCLONE_TYPE_MAP:
|
||||
```typescript
|
||||
sftp: 'sftp',
|
||||
```
|
||||
|
||||
Note: BackendType union already includes 'sftp' (added by Plan 01 Task 1). This plan only adds the registry/schema/map entries. If Plan 01 and Plan 02 run in parallel on separate branches, merge Plan 01 first then add sftp entries on top.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/schemas/registry.test.ts src/generators/rclone-conf.test.ts 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>sftp entry in registry with all four field keys; sftp in BACKEND_SCHEMAS and RCLONE_TYPE_MAP; registry and rclone-conf tests fully green for all 7 backends; zero TypeScript errors</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Create SftpAuthToggle.tsx</name>
|
||||
<files>src/components/wizard/SftpAuthToggle.tsx</files>
|
||||
<behavior>
|
||||
- Component renders a segmented control with two buttons: "Password" (left) and "Private Key" (right)
|
||||
- Default auth method is 'password' — Password tab is active on first render
|
||||
- Password tab active: pass field wrapper has class 'block', key_pem field wrapper has class 'hidden'
|
||||
- Private Key tab active: pass field wrapper has class 'hidden', key_pem field wrapper has class 'block'
|
||||
- Both fields are ALWAYS registered with react-hook-form (CSS-hidden, not conditional render)
|
||||
- Pass field: id="pass", label="Password", uses PasswordField
|
||||
- Key field: id="key_pem", label="Private Key (PEM)", uses PasswordField with multiline hint in helpText
|
||||
- Active button has class 'bg-blue-600 text-white', inactive has 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
- Component accepts register and errors props matching SftpAuthToggleProps interface
|
||||
</behavior>
|
||||
<action>
|
||||
Create src/components/wizard/SftpAuthToggle.tsx following AzureAuthToggle.tsx exactly, with these substitutions:
|
||||
- AuthMethod type: 'password' | 'key' (was 'sas' | 'key')
|
||||
- Initial state: 'password' (was 'sas')
|
||||
- Tab 1 button text: "Password" (was "SAS URL"), onClick: setAuthMethod('password')
|
||||
- Tab 2 button text: "Private Key" (was "Access Key"), onClick: setAuthMethod('key')
|
||||
- Active condition for tab 1: authMethod === 'password'
|
||||
- Active condition for tab 2: authMethod === 'key'
|
||||
- Field 1 wrapper: className={authMethod === 'password' ? 'block' : 'hidden'}
|
||||
PasswordField: id="pass", label="Password", error={errors.pass}, registration={register('pass')},
|
||||
helpText="SFTP password. Note: rclone may require the password to be obscured using `rclone obscure <password>`. If authentication fails, use the obscured value."
|
||||
- Field 2 wrapper: className={authMethod === 'key' ? 'block' : 'hidden'}
|
||||
PasswordField: id="key_pem", label="Private Key (PEM)", error={errors.key_pem}, registration={register('key_pem')},
|
||||
helpText="Paste your private key in PEM format (-----BEGIN ... PRIVATE KEY-----)"
|
||||
|
||||
Props interface:
|
||||
```typescript
|
||||
interface SftpAuthToggleProps {
|
||||
register: UseFormRegister<any>;
|
||||
errors: {
|
||||
pass?: FieldError;
|
||||
key_pem?: FieldError;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The SFTP toggle test in RemoteConfigStep.test.tsx asserts:
|
||||
- getByText(/^password$/i, { selector: 'button' }) — button text must be exactly "Password"
|
||||
- getByText(/private key/i, { selector: 'button' }) — button text must contain "Private Key"
|
||||
- getByLabelText(/^password$/i) — PasswordField label must be exactly "Password"
|
||||
- getByLabelText(/private key.*pem/i) — PasswordField label must match "Private Key (PEM)"
|
||||
Ensure button text and label text match these patterns exactly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run src/components/wizard/RemoteConfigStep.test.tsx 2>&1 | grep -E "(BACK-02|SFTP|PASS|FAIL)" | head -20</automated>
|
||||
</verify>
|
||||
<done>SftpAuthToggle.tsx created; component matches AzureAuthToggle pattern; SFTP toggle tests in RemoteConfigStep.test.tsx will pass after Plan 03 wires it into RemoteConfigStep (component exists and is importable)</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
`npx vitest run src/schemas/registry.test.ts src/generators/rclone-conf.test.ts`
|
||||
Expected: fully green — all 7 backends pass in both files.
|
||||
|
||||
`npx tsc --noEmit`
|
||||
Expected: zero errors (sftp now present in BACKEND_REGISTRY satisfying the exhaustive Record type).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- sftp registry entry with host, user, pass, key_pem (correct rclone key names)
|
||||
- SftpAuthToggle.tsx exists, exports SftpAuthToggle, follows AzureAuthToggle CSS-hidden pattern
|
||||
- sftp in BACKEND_SCHEMAS and RCLONE_TYPE_MAP
|
||||
- registry and rclone-conf tests fully green for all 7 backends
|
||||
- Zero TypeScript errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-new-backends/06-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,291 @@
|
||||
---
|
||||
phase: 06-new-backends
|
||||
plan: "03"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "06-01"
|
||||
- "06-02"
|
||||
files_modified:
|
||||
- src/components/wizard/RemoteConfigStep.tsx
|
||||
autonomous: false
|
||||
requirements:
|
||||
- BACK-01
|
||||
- BACK-02
|
||||
- BACK-03
|
||||
- BACK-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can select OneDrive in BackendSelectionStep and see token, drive_id, drive_type fields in RemoteConfigStep"
|
||||
- "User can select SFTP and see host, user, and the Password/Private Key toggle in RemoteConfigStep"
|
||||
- "User can select GCS and see project_number and service_account_credentials fields"
|
||||
- "User can select Backblaze B2 and see Application Key ID and Application Key fields"
|
||||
- "All four new backends appear in BackendSelectionStep (automatic — driven by BACKEND_REGISTRY)"
|
||||
- "Full Vitest suite passes with zero failures and zero TypeScript errors"
|
||||
artifacts:
|
||||
- path: "src/components/wizard/RemoteConfigStep.tsx"
|
||||
provides: "Extended component handling all 7 backends; sftp branch with SftpAuthToggle"
|
||||
contains: "SftpAuthToggle"
|
||||
key_links:
|
||||
- from: "src/components/wizard/RemoteConfigStep.tsx"
|
||||
to: "src/components/wizard/SftpAuthToggle.tsx"
|
||||
via: "sftp branch import and render"
|
||||
pattern: "backendType === 'sftp'"
|
||||
- from: "src/components/wizard/RemoteConfigStep.tsx"
|
||||
to: "src/schemas/index.ts BACKEND_SCHEMAS"
|
||||
via: "schema lookup — all 7 BackendType values must be keys in BACKEND_SCHEMAS"
|
||||
pattern: "BACKEND_SCHEMAS\\[backendType\\]"
|
||||
- from: "src/components/wizard/RemoteConfigStep.tsx"
|
||||
to: "src/schemas/registry.ts BACKEND_REGISTRY"
|
||||
via: "backendLabel record must be exhaustive over all 7 BackendType values"
|
||||
pattern: "backendLabel.*onedrive|sftp|gcs|b2"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire all four new backends into RemoteConfigStep — extend backendLabel, add the sftp branch with SftpAuthToggle, and ensure gcs/b2/onedrive render via the existing registry loop.
|
||||
|
||||
Purpose: The only UI wiring step. After Plans 01 and 02, the data layer is complete. This plan makes it visible and interactive in the wizard.
|
||||
Output: RemoteConfigStep.tsx updated; all RemoteConfigStep tests green; full suite 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/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-new-backends/06-RESEARCH.md
|
||||
@.planning/phases/06-new-backends/06-01-SUMMARY.md
|
||||
@.planning/phases/06-new-backends/06-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Current RemoteConfigStep.tsx — full file for reference -->
|
||||
|
||||
From src/components/wizard/RemoteConfigStep.tsx (current — pre-modification):
|
||||
```typescript
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useWizard } from '../../store/context';
|
||||
import { BACKEND_REGISTRY } from '../../schemas/registry';
|
||||
import { BACKEND_SCHEMAS } from '../../schemas';
|
||||
import { FieldRenderer } from '../ui/FieldRenderer';
|
||||
import { AzureAuthToggle } from './AzureAuthToggle';
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
|
||||
export function RemoteConfigStep() {
|
||||
const { state, dispatch } = useWizard();
|
||||
const backendType = state.remote.backendType;
|
||||
|
||||
useEffect(() => {
|
||||
if (!backendType) dispatch({ type: 'SET_STEP', payload: 0 });
|
||||
}, [backendType, dispatch]);
|
||||
|
||||
const schema = backendType ? BACKEND_SCHEMAS[backendType] : BACKEND_SCHEMAS['azureblob'];
|
||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
mode: 'onSubmit',
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: state.remote.params,
|
||||
});
|
||||
|
||||
if (!backendType) return null;
|
||||
|
||||
const onNext = (values: Record<string, string>) => {
|
||||
dispatch({ type: 'SET_REMOTE_PARAMS', payload: values });
|
||||
dispatch({ type: 'SET_STEP', payload: 2 });
|
||||
};
|
||||
|
||||
const backendLabel: Record<NonNullable<typeof backendType>, string> = {
|
||||
azureblob: 'Azure Blob Storage',
|
||||
s3: 'Amazon S3',
|
||||
's3-compatible': 'S3-Compatible Storage',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Step 2: Configure {backendLabel[backendType]}</h2>
|
||||
<form onSubmit={handleSubmit(onNext)}>
|
||||
{backendType === 'azureblob' ? (
|
||||
<>
|
||||
<FieldRenderer field={BACKEND_REGISTRY.azureblob.fields.find(f => f.key === 'account')!} register={register} error={errors.account as FieldError | undefined} />
|
||||
<AzureAuthToggle register={register} errors={{ key: errors.key as FieldError | undefined, sas_url: errors.sas_url as FieldError | undefined }} />
|
||||
</>
|
||||
) : (
|
||||
BACKEND_REGISTRY[backendType].fields.map(field => (
|
||||
<FieldRenderer key={field.key} field={field} register={register} error={errors[field.key] as FieldError | undefined} />
|
||||
))
|
||||
)}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button type="button" onClick={() => dispatch({ type: 'SET_STEP', payload: 0 })} className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50">Back</button>
|
||||
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700">Next</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
From src/components/wizard/SftpAuthToggle.tsx (created by Plan 02):
|
||||
```typescript
|
||||
export function SftpAuthToggle({ register, errors }: SftpAuthToggleProps)
|
||||
// Props: { register: UseFormRegister<any>; errors: { pass?: FieldError; key_pem?: FieldError } }
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Extend RemoteConfigStep.tsx — backendLabel + sftp branch + all four new backends</name>
|
||||
<files>src/components/wizard/RemoteConfigStep.tsx</files>
|
||||
<behavior>
|
||||
- Import SftpAuthToggle from './SftpAuthToggle'
|
||||
- backendLabel record covers all 7 BackendType values: adds onedrive/'OneDrive', sftp/'SFTP', gcs/'Google Cloud Storage', b2/'Backblaze B2'
|
||||
- The ternary in JSX becomes a three-branch chain:
|
||||
1. backendType === 'azureblob' → existing AzureAuthToggle branch (unchanged)
|
||||
2. backendType === 'sftp' → host field + user field via FieldRenderer, then SftpAuthToggle for pass/key_pem
|
||||
3. all others (onedrive, gcs, b2, s3, s3-compatible) → registry loop (unchanged)
|
||||
- SFTP branch renders host and user fields from BACKEND_REGISTRY.sftp.fields using FieldRenderer (same as azureblob renders account), then SftpAuthToggle
|
||||
- onedrive, gcs, b2 render entirely via the registry loop — no custom branch needed
|
||||
- `npx vitest run src/components/wizard/RemoteConfigStep.test.tsx` fully green for all 7 backends
|
||||
- `npx vitest run` (full suite) fully green
|
||||
- `npx tsc --noEmit` zero errors
|
||||
</behavior>
|
||||
<action>
|
||||
Edit src/components/wizard/RemoteConfigStep.tsx:
|
||||
|
||||
1. Add import at top (after AzureAuthToggle import):
|
||||
`import { SftpAuthToggle } from './SftpAuthToggle';`
|
||||
|
||||
2. Extend backendLabel record:
|
||||
```typescript
|
||||
const backendLabel: Record<NonNullable<typeof backendType>, string> = {
|
||||
azureblob: 'Azure Blob Storage',
|
||||
s3: 'Amazon S3',
|
||||
's3-compatible': 'S3-Compatible Storage',
|
||||
onedrive: 'OneDrive',
|
||||
sftp: 'SFTP',
|
||||
gcs: 'Google Cloud Storage',
|
||||
b2: 'Backblaze B2',
|
||||
};
|
||||
```
|
||||
|
||||
3. Replace the ternary block in JSX (inside the form, before the button div):
|
||||
```tsx
|
||||
{backendType === 'azureblob' ? (
|
||||
<>
|
||||
{/* Account field via FieldRenderer */}
|
||||
<FieldRenderer
|
||||
field={BACKEND_REGISTRY.azureblob.fields.find(f => f.key === 'account')!}
|
||||
register={register}
|
||||
error={errors.account as FieldError | undefined}
|
||||
/>
|
||||
{/* Auth toggle handles key + sas_url — both always registered */}
|
||||
<AzureAuthToggle
|
||||
register={register}
|
||||
errors={{
|
||||
key: errors.key as FieldError | undefined,
|
||||
sas_url: errors.sas_url as FieldError | undefined,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : backendType === 'sftp' ? (
|
||||
<>
|
||||
{/* host and user via FieldRenderer */}
|
||||
<FieldRenderer
|
||||
field={BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'host')!}
|
||||
register={register}
|
||||
error={errors.host as FieldError | undefined}
|
||||
/>
|
||||
<FieldRenderer
|
||||
field={BACKEND_REGISTRY.sftp.fields.find(f => f.key === 'user')!}
|
||||
register={register}
|
||||
error={errors.user as FieldError | undefined}
|
||||
/>
|
||||
{/* Auth toggle handles pass + key_pem — both always registered */}
|
||||
<SftpAuthToggle
|
||||
register={register}
|
||||
errors={{
|
||||
pass: errors.pass as FieldError | undefined,
|
||||
key_pem: errors.key_pem as FieldError | undefined,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* All others (onedrive, gcs, b2, s3, s3-compatible): full registry loop */
|
||||
BACKEND_REGISTRY[backendType].fields.map(field => (
|
||||
<FieldRenderer
|
||||
key={field.key}
|
||||
field={field}
|
||||
register={register}
|
||||
error={errors[field.key] as FieldError | undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
```
|
||||
|
||||
After editing, run the full suite immediately. Confirm all 7 backend describe blocks in RemoteConfigStep.test.tsx are green.
|
||||
|
||||
Note on OneDrive drive_type field: BACKEND_REGISTRY.onedrive has drive_type with inputType: 'select'. FieldRenderer already handles 'select' type — no changes to FieldRenderer needed. The test asserts getByLabelText(/drive type/i) — ensure FieldRenderer renders a label matching that text.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>RemoteConfigStep handles all 7 backends; SFTP branch uses SftpAuthToggle; onedrive/gcs/b2 render via registry loop; full Vitest suite green; zero TypeScript errors from `npx tsc --noEmit`</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Human verify all four new backends in the wizard UI</name>
|
||||
<files>src/components/wizard/RemoteConfigStep.tsx</files>
|
||||
<action>Run `npm run dev` and manually test each new backend through the wizard UI flow.</action>
|
||||
<verify>
|
||||
<automated>npx vitest run 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>All four backends visually verified in the running app by the user</done>
|
||||
<what-built>
|
||||
All four new backends fully wired into the wizard:
|
||||
- OneDrive: token (password field), Drive ID, Drive Type (select: Personal/Business/SharePoint)
|
||||
- SFTP: Host, Username, Password/Private Key toggle (CSS-hidden, preserves values on switch)
|
||||
- Google Cloud Storage: Project Number, Service Account JSON (password field)
|
||||
- Backblaze B2: Application Key ID, Application Key (password field)
|
||||
All four backends appear as cards in BackendSelectionStep. Automated tests are fully green.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. `npm run dev` — open http://localhost:5173
|
||||
2. Verify BackendSelectionStep shows 7 backend cards including OneDrive, SFTP, Google Cloud Storage, Backblaze B2
|
||||
3. Select OneDrive → confirm OAuth Token (JSON), Drive ID, Drive Type fields appear
|
||||
4. Go back, select SFTP → confirm Host, Username fields + Password/Private Key tab buttons
|
||||
- Type a value in Password field, switch to Private Key, switch back — value must still be there
|
||||
5. Go back, select GCS → confirm Project Number and Service Account JSON fields appear
|
||||
6. Go back, select B2 → confirm Application Key ID and Application Key fields appear
|
||||
7. For any backend: fill all required fields, click Next — wizard must advance without errors
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" to complete Phase 6, or describe any visual issues found</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
`npx vitest run` — all tests green including all 4 new backend describe blocks in RemoteConfigStep.test.tsx
|
||||
`npx tsc --noEmit` — zero TypeScript errors
|
||||
Manual: all 4 new backends visible and functional in the wizard UI
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- RemoteConfigStep handles all 7 BackendType values without TypeScript errors
|
||||
- SFTP branch uses SftpAuthToggle with CSS-hidden pattern
|
||||
- onedrive, gcs, b2 render entirely via the registry loop
|
||||
- All 4 new backends visible in BackendSelectionStep UI (automatic via registry)
|
||||
- Full Vitest suite green
|
||||
- Zero TypeScript errors
|
||||
- Human verification: all backends visually correct and functional
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-new-backends/06-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user