@accounter/scraper-app
Version:
Scraper app with Fastify server and React UI
857 lines (671 loc) • 32.8 kB
Markdown
# Vault Download & Upload — Implementation Prompts
Reference plan: [`vault-download-upload.md`](./vault-download-upload.md)
These prompts are ordered so each one compiles and passes tests before the next begins. No step
leaves orphaned code. Each prompt is self-contained — paste it verbatim into the LLM.
---
## Blueprint
```
Step 1 — Server: GET /api/vault/env-path (trivial, no auth, safe foundation)
Step 2 — Server: GET /api/vault/download (stream file, no auth)
Step 3 — Server: POST /api/vault/upload (write file, lock vault, 409/force)
Step 4 — vault.ts: add vaultPath to SettingsSchema
Step 5 — vault-store.ts: _vaultPath cache + move-on-save
Step 6 — settings-routes.ts: wire path-move into PUT /api/vault/settings
Step 7 — UI api.ts: vaultUpload, vaultDownload, getEnvVaultPath
Step 8 — vault-context.tsx: add upload()
Step 9 — VaultUnlock: upload-a-different-vault section
Step 10 — VaultSetup: upload-existing-vault alternative
Step 11 — SettingsTab: editable vaultPath + Download button
```
---
## Prompt 1 — Server: `GET /api/vault/env-path`
> **Goal:** Add the simplest new endpoint first — no auth, no file I/O beyond reading an env var.
> This gives us a tested route registration pattern to follow for the harder endpoints.
```
You are working in packages/scraper-app/src/server/.
TASK
Add GET /api/vault/env-path to vault-routes.ts.
BEHAVIOUR
- No authentication required.
- Returns JSON: { path: string } where path is process.env['VAULT_PATH'] ?? '.vault'.
- Register the route inside the existing registerVaultRoutes function, before the closing brace.
TESTS (add to src/server/__tests__/vault-routes.test.ts)
Add a new describe block: 'GET /api/vault/env-path'
- it 'returns the VAULT_PATH env var when set'
Set process.env['VAULT_PATH'] = '/tmp/custom.vault' in beforeEach (alongside the existing
vaultPath override), call the route, expect { path: '/tmp/custom.vault' }.
- it 'returns ".vault" when VAULT_PATH is not set'
delete process.env['VAULT_PATH'] before the request, expect { path: '.vault' }.
Use the same buildApp / makeTmpPath / afterEach(lockVault + rm + delete env) pattern already in
the file. Ensure afterEach restores process.env['VAULT_PATH'] to whatever buildApp set.
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
All existing tests must still pass.
```
---
## Prompt 2 — Server: `GET /api/vault/download`
> **Goal:** Serve the raw encrypted vault file as a binary download. No auth needed — the blob is
> useless without the password. This is pure file I/O; vault-store is not involved.
```
You are working in packages/scraper-app/src/server/.
TASK
Add GET /api/vault/download to vault-routes.ts.
BEHAVIOUR
- No authentication required.
- Reads the file at getVaultPath() using fs/promises readFile.
- If the file does not exist (ENOENT), return reply.status(404).send({ error: 'no-vault-file' }).
- On success:
reply.header('Content-Type', 'application/octet-stream');
reply.header('Content-Disposition', 'attachment; filename=".vault"');
return reply.send(fileBuffer); // send the Buffer directly
Do NOT import vault-store or check isLocked(). This endpoint intentionally skips auth.
IMPORTS needed: import { readFile } from 'node:fs/promises';
getVaultPath is already imported from './vault.js'.
TESTS (add to src/server/__tests__/vault-routes.test.ts)
New describe block: 'GET /api/vault/download'
Setup (beforeEach): create a real vault file with saveVaultFile(vaultPath, defaultVault(), PASSWORD)
so the file exists on disk.
- it 'returns 200 with octet-stream content-type when vault file exists'
inject GET /api/vault/download
expect statusCode 200
expect headers['content-type'] to match /application\/octet-stream/
expect headers['content-disposition'] to equal 'attachment; filename=".vault"'
expect res.rawPayload to be a non-empty Buffer (res.rawPayload.length > 0)
- it 'returns 404 when no vault file exists'
Use a separate describe/beforeEach that does NOT create the file.
inject GET /api/vault/download
expect statusCode 404
expect res.json().error to equal 'no-vault-file'
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 3 — Server: `POST /api/vault/upload`
> **Goal:** Accept a vault file upload, write it atomically, and lock the in-memory vault. This is
> the most complex server step — handle the 409 / `?force` conflict flow here.
```
You are working in packages/scraper-app/src/server/.
TASK
Add POST /api/vault/upload to vault-routes.ts.
BEHAVIOUR
- Accepts a raw binary body (the encrypted vault blob). Content-Type is application/octet-stream.
Use Fastify's built-in body handling — add the route with:
{ config: { rawBody: false } }
and read the body as req.body (Fastify exposes it as a Buffer when Content-Type is octet-stream).
- Query param: force (string, optional). Treat any truthy value as "overwrite allowed".
- Logic:
1. const exists = await hasVaultFile();
2. If exists && !force → return reply.status(409).send({ error: 'vault-already-exists' });
3. Write the body to a temp file path (vaultPath + '.tmp') using writeFile.
4. Rename the temp file to getVaultPath() using rename (from node:fs/promises).
This is the atomic write — partial writes never replace the live file.
5. lockVault() ← always lock after writing so the user must re-unlock.
6. return reply.status(200).send({ ok: true });
- On any fs error, return reply.status(500).send({ error: 'write-failed' }).
IMPORTS needed:
import { rename, writeFile } from 'node:fs/promises';
hasVaultFile and lockVault are already available via vault-store imports.
TESTS (add to src/server/__tests__/vault-routes.test.ts)
New describe block: 'POST /api/vault/upload'
Use makeTmpPath() for vaultPath. afterEach: lockVault, app.close, rm(vaultPath, { force: true }),
rm(vaultPath + '.tmp', { force: true }).
Helper: async function uploadVault(app, vaultPath, opts = {}) — creates a real vault file in memory
via encryptVault(defaultVault(), PASSWORD) (import from vault.ts), then injects:
method: 'POST', url: '/api/vault/upload' + (opts.force ? '?force=true' : ''),
payload: Buffer.from(blob, 'base64'),
headers: { 'content-type': 'application/octet-stream' }
Tests:
- it 'returns 200 and writes the vault file when no file exists'
const res = await uploadVault(app, vaultPath)
expect statusCode 200, res.json() { ok: true }
const exists = await access(vaultPath).then(()=>true).catch(()=>false)
expect exists to be true
- it 'returns 409 when vault already exists and force is not set'
saveVaultFile(vaultPath, defaultVault(), PASSWORD) in beforeEach to pre-create
const res = await uploadVault(app, vaultPath)
expect statusCode 409, error 'vault-already-exists'
- it 'overwrites and returns 200 when force=true and vault exists'
pre-create the file, then uploadVault(app, vaultPath, { force: true })
expect statusCode 200
- it 'locks the vault after successful upload even if it was unlocked'
unlock the vault first (POST /api/vault/unlock), confirm isLocked()===false,
then uploadVault, then inject GET /api/vault/status and expect locked: true
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 4 — `vault.ts`: add `vaultPath` to `SettingsSchema`
> **Goal:** Persist the vault's own file path inside the encrypted vault, so moving the file is a
> user-controlled setting. Small, isolated schema change — no behavior change yet.
```
You are working in packages/scraper-app/src/server/vault.ts.
TASK
Add an optional vaultPath field to SettingsSchema.
CHANGE
In the SettingsSchema z.object({...}) definition, add:
vaultPath: z.string().optional(),
That is the only change to vault.ts. Do not change getVaultPath() yet — that comes in the next step.
WHY this ordering: vault-store.ts reads vault.ts types, so the schema must be updated before
vault-store.ts can use the new field.
TESTS (add to src/server/__tests__/vault.test.ts — check the existing file structure first)
In the existing vault.test.ts, add a case inside whichever describe covers SettingsSchema or
VaultSchema parsing:
- it 'accepts vaultPath in settings'
const v = VaultSchema.parse({ settings: { vaultPath: '/custom/path/.vault' } })
expect(v.settings.vaultPath).toBe('/custom/path/.vault')
- it 'accepts settings without vaultPath'
const v = VaultSchema.parse({ settings: {} })
expect(v.settings.vaultPath).toBeUndefined()
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 5 — `vault-store.ts`: `_vaultPath` cache and `moveVaultFile`
> **Goal:** The store must track the resolved vault path independently of the env var so it can move
> the file when the user changes `settings.vaultPath`. Introduce the module-level variable and the
> move helper here — not yet wired into settings-routes.
```
You are working in packages/scraper-app/src/server/vault-store.ts.
TASK
Add a cached _vaultPath variable and a moveVaultFile helper.
CHANGES
1. Add at the top of the module (after existing let declarations):
let _vaultPath: string | null = null;
2. Export a function getCurrentVaultPath(): string that returns _vaultPath ?? getVaultPath().
This is now the single source of truth for the active path. Callers that currently call
getVaultPath() directly should be updated to call getCurrentVaultPath() — but only within
vault-store.ts itself. (vault-routes.ts still calls getVaultPath() for the env-path endpoint,
which is correct.)
3. In unlockVault(): after setting _vault and _password, also set:
_vaultPath = getVaultPath();
This captures the path at the moment of unlock (env var / default).
4. In lockVault(): also reset _vaultPath = null.
5. Export async function moveVaultFile(newPath: string): Promise<void>
- If _vault === null || _password === null, throw new Error('Vault is locked').
- const oldPath = getCurrentVaultPath();
- if (oldPath === newPath) return; // no-op
- await rename(oldPath, newPath); // from node:fs/promises
- _vaultPath = newPath;
Import rename from 'node:fs/promises'.
6. In the existing updateVault() function, replace saveVaultFile(getVaultPath(), ...) with
saveVaultFile(getCurrentVaultPath(), ...).
TESTS (add to src/server/__tests__/vault.test.ts or create vault-store.test.ts if none exists)
Use the same makeTmpPath / saveVaultFile / lockVault afterEach pattern from vault-routes.test.ts.
- it 'getCurrentVaultPath returns env default before unlock'
delete process.env['VAULT_PATH']; const p = getCurrentVaultPath(); expect(p).toBe('.vault')
- it 'getCurrentVaultPath returns the path set at unlock'
process.env['VAULT_PATH'] = vaultPath; await unlockVault(PASSWORD)
expect(getCurrentVaultPath()).toBe(vaultPath)
- it 'moveVaultFile moves the file and updates getCurrentVaultPath'
await unlockVault(PASSWORD) (with existing vault file at vaultPath)
const newPath = makeTmpPath()
await moveVaultFile(newPath)
expect(getCurrentVaultPath()).toBe(newPath)
const exists = await access(newPath).then(()=>true).catch(()=>false)
expect(exists).toBe(true)
cleanup: rm(newPath, { force: true })
- it 'moveVaultFile is a no-op when paths are the same'
await unlockVault(PASSWORD)
await expect(moveVaultFile(vaultPath)).resolves.not.toThrow()
- it 'moveVaultFile throws when vault is locked'
// do not unlock
await expect(moveVaultFile('/some/path')).rejects.toThrow('Vault is locked')
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 6 — `settings-routes.ts`: move vault file when `vaultPath` changes
> **Goal:** Wire the `moveVaultFile` helper into the settings `PUT` route so saving a new
> `vaultPath` atomically moves the file on disk. This completes the server-side feature.
```
You are working in packages/scraper-app/src/server/settings-routes.ts.
TASK
When a PUT /api/vault/settings request includes a vaultPath change, move the vault file.
CHANGES
1. Import moveVaultFile and getCurrentVaultPath from './vault-store.js'.
2. In the PUT handler, after validating parsed.data with SettingsSchema.partial().safeParse():
a. Extract the requested new path:
const newPath = parsed.data.vaultPath;
b. If newPath is defined and newPath !== getCurrentVaultPath():
try { await moveVaultFile(newPath); }
catch { return reply.status(500).send({ error: 'move-failed' }); }
c. Then continue with the existing updateVault call.
Order matters: move the file BEFORE updateVault writes the new vault content, so the vault is
always written to the correct (new) location.
TESTS (add to src/server/__tests__/settings-routes.test.ts)
Check the existing file for the setup pattern. Add a new describe block:
'PUT /api/vault/settings — vaultPath change'
beforeEach: create vault file at vaultPath, unlock vault (POST /api/vault/unlock).
afterEach: lockVault, app.close, rm both paths with { force: true }.
- it 'moves the vault file and returns updated settings when vaultPath changes'
const newPath = makeTmpPath()
const res = await app.inject({
method: 'PUT', url: '/api/vault/settings',
payload: { vaultPath: newPath }
})
expect statusCode 200
expect res.json().vaultPath to equal newPath
const oldExists = await access(vaultPath).then(()=>true).catch(()=>false)
expect(oldExists).toBe(false)
const newExists = await access(newPath).then(()=>true).catch(()=>false)
expect(newExists).toBe(true)
cleanup rm(newPath) in afterEach
- it 'returns 200 without moving anything when vaultPath is not in the payload'
await app.inject({ method: 'PUT', url: '/api/vault/settings',
payload: { showBrowser: true } })
const exists = await access(vaultPath).then(()=>true).catch(()=>false)
expect(exists).toBe(true) // original file untouched
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 7 — UI `api.ts`: `vaultUpload`, `vaultDownload`, `getEnvVaultPath`
> **Goal:** Add the three new client-side API functions. Pure data-layer; no UI yet. This step also
> updates the existing `getVaultPath` to be consistent with the new naming.
```
You are working in packages/scraper-app/src/ui/lib/api.ts.
TASK
Add three new API helper functions.
ADDITIONS (append after the existing getVaultPath function):
export function getEnvVaultPath(): Promise<{ path: string }> {
return apiFetch('/api/vault/env-path');
}
export async function vaultUpload(file: File, force = false): Promise<{ ok: boolean }> {
const url = force ? '/api/vault/upload?force=true' : '/api/vault/upload';
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}
export async function vaultDownload(suggestedName = '.vault'): Promise<void> {
const res = await fetch('/api/vault/download');
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = suggestedName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
No test file currently exists for api.ts. Create
src/ui/__tests__/api.test.ts with @vitest-environment happy-dom:
import { describe, it, expect, vi, afterEach } from 'vitest';
import { vaultUpload, vaultDownload, getEnvVaultPath, ApiError } from '../lib/api.js';
afterEach(() => vi.unstubAllGlobals());
describe('getEnvVaultPath', () => {
it('returns the path from the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, json: async () => ({ path: '/custom/.vault' })
}));
const result = await getEnvVaultPath();
expect(result.path).toBe('/custom/.vault');
});
});
describe('vaultUpload', () => {
it('POSTs to /api/vault/upload with octet-stream', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
vi.stubGlobal('fetch', fetchMock);
const file = new File(['data'], 'vault');
await vaultUpload(file);
expect(fetchMock).toHaveBeenCalledWith('/api/vault/upload', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'Content-Type': 'application/octet-stream' }),
}));
});
it('appends ?force=true when force=true', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
vi.stubGlobal('fetch', fetchMock);
await vaultUpload(new File(['d'], 'v'), true);
expect(fetchMock.mock.calls[0][0]).toContain('force=true');
});
it('throws ApiError with status 409 on conflict', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false, status: 409, json: async () => ({ error: 'vault-already-exists' })
}));
await expect(vaultUpload(new File(['d'], 'v'))).rejects.toBeInstanceOf(ApiError);
});
});
describe('vaultDownload', () => {
it('creates and clicks a download link', async () => {
const blob = new Blob(['data']);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: async () => blob }));
const clickSpy = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(
Object.assign(document.createElement('a'), { click: clickSpy })
);
await vaultDownload('.vault');
expect(clickSpy).toHaveBeenCalled();
});
});
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 8 — `vault-context.tsx`: add `upload()`
> **Goal:** Surface the upload function in the vault context so screens can call it without directly
> importing api.ts. Follows the exact same pattern as `unlock` and `create`.
```
You are working in packages/scraper-app/src/ui/contexts/vault-context.tsx.
TASK
Add upload(file: File, force?: boolean): Promise<void> to the vault context.
CHANGES
1. In the VaultContextValue type, add:
upload(file: File, force?: boolean): Promise<void>;
2. Import vaultUpload and ApiError from '../lib/api.js'.
3. Inside VaultProvider, add a useCallback for upload:
const upload = useCallback(async (file: File, force = false) => {
setError(null);
try {
await vaultUpload(file, force);
setStatus('locked');
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
// Re-throw so the caller can handle the conflict (show confirm dialog)
throw e;
}
setError('Failed to upload vault. Please try again.');
}
}, []);
4. Add upload to the VaultContext.Provider value prop.
5. Update the makeCtx helper in src/ui/__tests__/vault-unlock.test.tsx and
src/ui/__tests__/vault-setup.test.tsx to include upload: vi.fn() so existing tests
still compile. (The contexts in those files spread overrides, so just add upload to the
base object in makeCtx.)
TESTS (add to a new describe block in src/ui/__tests__/vault-unlock.test.tsx
or create src/ui/__tests__/vault-context.test.tsx)
The simplest approach: add to vault-unlock.test.tsx since it already has the VaultContext
infrastructure.
- it 'calls upload with the file and sets status to locked on success'
Use a stateful wrapper:
function UploadWrapper() {
const [status, setStatus] = useState<VaultStatus>('locked');
const upload = vi.fn().mockImplementation(async () => setStatus('locked'));
return (
<VaultContext.Provider value={{ status, error: null, unlock: vi.fn(), create: vi.fn(), upload }}>
<VaultUnlock />
</VaultContext.Provider>
);
}
(VaultUnlock will gain a file input in the next step — this test is a forward stub,
just verify the context value shape compiles and renders without error for now.)
render(<UploadWrapper />)
expect(screen.getByLabelText(/master password/i)).toBeTruthy()
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 9 — `VaultUnlock`: upload-a-different-vault section
> **Goal:** When the vault exists but the user wants to use a different vault, they can upload it
> from this screen. Handles the 409 conflict with a window.confirm fallback.
```
You are working in packages/scraper-app/src/ui/screens/vault-unlock.tsx.
TASK
Add an "or upload a different vault" file input section below the unlock form.
CURRENT SHAPE: password input + Unlock button.
FINAL SHAPE:
[Unlock Vault]
<form> password + Unlock button </form>
<hr />
<section> file input "or upload a different vault" </section>
CHANGES
1. Import useRef from 'react'.
2. Import { useVault } from '../contexts/vault-context.js' (already imported — keep it).
3. Add state: const [uploadError, setUploadError] = useState<string | null>(null);
4. Add a ref: const fileInputRef = useRef<HTMLInputElement>(null);
5. Add handler:
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadError(null);
try {
await vault.upload(file);
} catch {
// 409: vault exists, ask to overwrite
const confirmed = window.confirm(
'A vault already exists. Replace it with the uploaded file?'
);
if (confirmed) {
try {
await vault.upload(file, true);
} catch {
setUploadError('Failed to replace vault. Please try again.');
}
}
}
// Reset so the same file can be re-selected
if (fileInputRef.current) fileInputRef.current.value = '';
}
6. Below the existing </form>, add:
<hr />
<div>
<p>or upload a different vault</p>
<input
ref={fileInputRef}
id="vault-upload"
type="file"
aria-label="Upload vault file"
onChange={handleFileChange}
/>
{uploadError && <p role="alert">{uploadError}</p>}
</div>
TESTS (add to src/ui/__tests__/vault-unlock.test.tsx)
Update makeCtx to include upload: vi.fn().mockResolvedValue(undefined) in the base object.
New describe block: 'VaultUnlock — file upload'
- it 'renders the upload file input'
renderUnlock()
expect(screen.getByLabelText(/upload vault file/i)).toBeTruthy()
- it 'calls vault.upload when a file is selected'
const upload = vi.fn().mockResolvedValue(undefined)
renderUnlock(makeCtx({ upload }))
const input = screen.getByLabelText(/upload vault file/i)
const file = new File(['blob'], 'test.vault')
await userEvent.upload(input, file)
expect(upload).toHaveBeenCalledWith(file)
- it 'shows confirm dialog and calls upload with force=true on 409'
const ApiError = (await import('../lib/api.js')).ApiError
const upload = vi.fn()
.mockRejectedValueOnce(new ApiError(409, 'vault-already-exists'))
.mockResolvedValueOnce(undefined)
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
renderUnlock(makeCtx({ upload }))
await userEvent.upload(screen.getByLabelText(/upload vault file/i), new File(['b'], 'v'))
await waitFor(() => expect(upload).toHaveBeenCalledTimes(2))
expect(upload).toHaveBeenLastCalledWith(expect.any(File), true)
vi.unstubAllGlobals()
- it 'shows uploadError when force upload also fails'
const upload = vi.fn().mockRejectedValue(new (await import('../lib/api.js')).ApiError(409, 'x'))
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
renderUnlock(makeCtx({ upload }))
await userEvent.upload(screen.getByLabelText(/upload vault file/i), new File(['b'], 'v'))
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Failed to replace'))
vi.unstubAllGlobals()
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 10 — `VaultSetup`: upload-existing-vault alternative
> **Goal:** When there is no vault file at all, the user should be able to upload one instead of
> going through the 3-step creation wizard.
```
You are working in packages/scraper-app/src/ui/screens/vault-setup.tsx.
TASK
Add an "Upload an existing vault" section above the creation wizard.
CURRENT SHAPE: 3-step form.
FINAL SHAPE:
[Setup Vault]
<section> upload existing vault </section>
<hr />
<section> step 1 / 2 / 3 wizard (unchanged) </section>
CHANGES
1. Import useRef from 'react'.
2. Import { useVault } from '../contexts/vault-context.js' (already imported — keep it).
3. Add state: const [uploadError, setUploadError] = useState<string | null>(null);
4. Add ref: const fileInputRef = useRef<HTMLInputElement>(null);
5. Add handler (simpler than VaultUnlock — no vault exists, so 409 cannot happen):
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploadError(null);
try {
await vault.upload(file);
} catch {
setUploadError('Failed to upload vault. Please try again.');
}
if (fileInputRef.current) fileInputRef.current.value = '';
}
6. At the top of the returned JSX, before the step === 1 block, insert:
<div>
<p>Upload an existing vault</p>
<input
ref={fileInputRef}
id="vault-upload-setup"
type="file"
aria-label="Upload existing vault file"
onChange={handleFileChange}
/>
{uploadError && <p role="alert">{uploadError}</p>}
</div>
<hr />
TESTS (add to src/ui/__tests__/vault-setup.test.tsx)
Update makeCtx to add upload: vi.fn().mockResolvedValue(undefined) to the base context object.
New describe block: 'VaultSetup — file upload'
- it 'renders the upload file input alongside the wizard'
renderSetup()
expect(screen.getByLabelText(/upload existing vault file/i)).toBeTruthy()
expect(screen.getByText(/step 1/i)).toBeTruthy() // wizard still visible
- it 'calls vault.upload when a file is selected'
const upload = vi.fn().mockResolvedValue(undefined)
renderSetup(makeCtx(vi.fn(), upload)) // adjust makeCtx signature to accept upload
const file = new File(['blob'], 'test.vault')
await userEvent.upload(screen.getByLabelText(/upload existing vault file/i), file)
expect(upload).toHaveBeenCalledWith(file)
Note: update makeCtx to accept a second argument upload = vi.fn():
function makeCtx(create = vi.fn()..., upload = vi.fn()...) {
return { ..., upload }
}
- it 'shows uploadError when upload fails'
const upload = vi.fn().mockRejectedValue(new Error('fail'))
renderSetup(makeCtx(vi.fn(), upload))
await userEvent.upload(screen.getByLabelText(/upload existing vault file/i), new File(['b'], 'v'))
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('Failed to upload'))
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
```
---
## Prompt 11 — `SettingsTab`: editable vault path + Download button
> **Goal:** The final UI piece. Replace the read-only vault path display with an editable input that
> moves the file on save, and add a Download button that triggers a browser save dialog.
```
You are working in packages/scraper-app/src/ui/screens/config/settings-tab.tsx.
TASK
1. Make the vault path input editable (saves vaultPath to vault settings on blur).
2. Add a Download vault button next to it.
CONTEXT
Currently the vault path section renders:
<input readOnly value={vaultPath} aria-label="Vault file path" />
<button>Copy path</button>
The vaultPath state is loaded from GET /api/vault/path (requires unlock). The env-path endpoint
returns the path to use as download filename hint when no saved path exists.
CHANGES
1. Import vaultDownload from '../../lib/api.js'.
Import { basename } from — note: this is a browser context, node:path is not available.
Instead, inline the basename extraction:
function getBasename(p: string) {
return p.split('/').pop()?.split('\\').pop() ?? '.vault';
}
2. Add state: const [downloading, setDownloading] = useState(false);
3. The vaultPath state already exists and is loaded. Change the display input from readOnly to
editable, following the same onBlur auto-save pattern as historyFilePath:
Replace:
<input readOnly value={vaultPath} ... aria-label="Vault file path" />
With:
<input
id="vaultFilePath"
type="text"
value={vaultPath}
onChange={e => setVaultPath(e.target.value)}
onBlur={async e => {
const val = e.target.value.trim();
if (!val || val === vaultPath) return;
await autoSave({ vaultPath: val });
// autoSave updates settings; update local vaultPath display too
setVaultPath(val);
}}
aria-label="Vault file path"
style={{ padding: '4px 8px', width: '100%', maxWidth: 360 }}
placeholder=".vault"
/>
4. Add a Download button after the Copy path button:
<button
type="button"
disabled={downloading}
onClick={async () => {
setDownloading(true);
try {
await vaultDownload(getBasename(vaultPath || '.vault'));
} catch {
setError('Failed to download vault.');
} finally {
setDownloading(false);
}
}}
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
>
{downloading ? 'Downloading…' : 'Download'}
</button>
5. The existing getVaultPath() call in useEffect should remain — it still populates the initial
vaultPath display. No change needed there.
TESTS (add to src/ui/__tests__/settings-tab.test.tsx)
Update mockFetch to also handle '/api/vault/path' returning { path: '/data/.vault' } and
'/api/vault/env-path' returning { path: '.vault' }. The current mock returns ok: false for
unknown URLs — extend it:
if (url === '/api/vault/path') return { ok: true, json: async () => ({ path: '/data/.vault' }) }
if (url === '/api/vault/env-path') return { ok: true, json: async () => ({ path: '.vault' }) }
New tests:
- it 'renders the vault path input as editable'
render(<SettingsTab />)
await waitFor(() => screen.getByLabelText(/vault file path/i))
const input = screen.getByLabelText(/vault file path/i) as HTMLInputElement
expect(input.readOnly).toBe(false)
- it 'saves vaultPath to settings on blur'
const fetchMock = mockFetch()
render(<SettingsTab />)
await waitFor(() => screen.getByLabelText(/vault file path/i))
const input = screen.getByLabelText(/vault file path/i)
await userEvent.clear(input)
await userEvent.type(input, '/new/path/.vault')
await userEvent.tab() // triggers blur
await waitFor(() => {
const putCalls = fetchMock.mock.calls.filter(([, opts]) => opts?.method === 'PUT')
const bodies = putCalls.map(([, opts]) => JSON.parse(opts!.body as string))
expect(bodies.some(b => b.vaultPath === '/new/path/.vault')).toBe(true)
})
- it 'renders the Download button'
render(<SettingsTab />)
await waitFor(() => screen.getByRole('button', { name: /download/i }))
expect(screen.getByRole('button', { name: /download/i })).toBeTruthy()
- it 'calls vaultDownload when Download is clicked'
vi.mock('../../lib/api.js', async (importOriginal) => {
const mod = await importOriginal<typeof import('../../lib/api.js')>()
return { ...mod, vaultDownload: vi.fn().mockResolvedValue(undefined) }
})
render(<SettingsTab />)
await waitFor(() => screen.getByRole('button', { name: /download/i }))
await userEvent.click(screen.getByRole('button', { name: /download/i }))
const { vaultDownload } = await import('../../lib/api.js')
expect(vaultDownload).toHaveBeenCalled()
Run: yarn workspace @accounter/scraper-app test --reporter=verbose
All tests across the project must pass: yarn workspace @accounter/scraper-app test
```
---
## Final verification
After all 11 prompts are complete, run the full test suite to confirm nothing regressed:
```bash
yarn workspace @accounter/scraper-app test --reporter=verbose
```
And confirm TypeScript compiles cleanly:
```bash
yarn workspace @accounter/scraper-app build
```