chore: update env example and gitignore, add implementation plan

- Update .env.example with documentation and default values
- Add .resources to gitignore (dummy test files)
- Add APK parsing fix implementation plan document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 14:06:44 +08:00
parent 7268b91ed3
commit 1eba2f5f39
3 changed files with 544 additions and 3 deletions

View File

@@ -1,3 +1,9 @@
AUTH_USERNAME= # Authentication credentials (change in production!)
AUTH_PASSWORD= AUTH_USERNAME=admin
RESOURCE_PATH= AUTH_PASSWORD=changeme
# Path to APK storage directory
RESOURCE_PATH=./resources
# Comma-separated list of tab names for APK filtering
APK_TABS=dev,sandbox

3
.gitignore vendored
View File

@@ -40,3 +40,6 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.env .env
# dumy files for download tests
.resources

View File

@@ -0,0 +1,532 @@
# APK Parsing Fix Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix APK filename parsing to use version number as delimiter and support configurable environment tabs.
**Architecture:** Parse APK filenames by splitting on `_<version>` to extract prefix. If prefix has no underscore → product. If prefix has underscore → extract environment from last segment. Match against configurable tabs (env var), unmatched → other.
**Tech Stack:** Next.js, TypeScript, environment variables
---
### Task 1: Add APK_TABS Environment Variable
**Files:**
- Modify: `lib/constants.ts`
**Step 1: Add APK_TABS constant**
```typescript
// Add to lib/constants.ts
export const APK_TABS = (process.env.APK_TABS || 'dev,sandbox')
.split(',')
.map(t => t.trim())
.filter(Boolean);
```
**Step 2: Verify no build errors**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Commit**
```bash
git add lib/constants.ts
git commit -m "feat: add APK_TABS environment variable for configurable tabs"
```
---
### Task 2: Rewrite APK Parser Logic
**Files:**
- Modify: `lib/apk-parser.ts`
- Modify: `lib/__tests__/apk-parser.test.ts`
**Step 1: Write failing tests for new parsing logic**
Replace `lib/__tests__/apk-parser.test.ts`:
```typescript
import { parseApkFilename, detectEnvironment, getApkTabs } from '../apk-parser';
describe('parseApkFilename', () => {
it('should parse product APK (no environment)', () => {
const result = parseApkFilename('ft_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('product');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse dev APK', () => {
const result = parseApkFilename('ft_dev_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should parse sandbox APK', () => {
const result = parseApkFilename('ft_sandbox_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('sandbox');
expect(result.commit).toBe('bfeddf2d0');
expect(result.isValid).toBe(true);
});
it('should map unknown environment to other', () => {
const result = parseApkFilename('ft_lan_0_44_bfeddf2d0.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBe('bfeddf2d0');
});
it('should map timeshift to other', () => {
const result = parseApkFilename('ft_timeshift_0_42_57ef3a60d.apk', '0_42');
expect(result.environment).toBe('other');
});
it('should handle invalid format without commit', () => {
const result = parseApkFilename('ft_dev_0_44.apk', '0_44');
expect(result.environment).toBe('dev');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
it('should handle completely invalid filename', () => {
const result = parseApkFilename('invalid_filename.apk', '0_44');
expect(result.environment).toBe('other');
expect(result.commit).toBeNull();
expect(result.isValid).toBe(false);
});
});
describe('detectEnvironment', () => {
it('should detect product when no underscore in prefix', () => {
expect(detectEnvironment('ft', ['dev', 'sandbox'])).toBe('product');
});
it('should detect configured environment', () => {
expect(detectEnvironment('ft_dev', ['dev', 'sandbox'])).toBe('dev');
expect(detectEnvironment('ft_sandbox', ['dev', 'sandbox'])).toBe('sandbox');
});
it('should return other for unconfigured environment', () => {
expect(detectEnvironment('ft_lan', ['dev', 'sandbox'])).toBe('other');
expect(detectEnvironment('ft_timeshift', ['dev', 'sandbox'])).toBe('other');
});
it('should return other for empty prefix', () => {
expect(detectEnvironment('', ['dev', 'sandbox'])).toBe('other');
});
});
```
**Step 2: Run tests to verify they fail**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: Tests fail with missing `getApkTabs` and wrong behavior
**Step 3: Implement new parsing logic**
Replace `lib/apk-parser.ts`:
```typescript
import { APK_TABS } from './constants';
export type ApkEnvironment = 'product' | 'dev' | 'sandbox' | 'other';
export interface ParsedApkMetadata {
environment: ApkEnvironment;
commit: string | null;
isValid: boolean;
}
export function getApkTabs(): string[] {
return APK_TABS;
}
export function detectEnvironment(prefix: string, configuredTabs: string[]): ApkEnvironment {
// Empty prefix → other
if (!prefix) {
return 'other';
}
// No underscore means product (e.g., "ft")
if (!prefix.includes('_')) {
return 'product';
}
// Extract environment from last segment after underscore
const lastUnderscoreIndex = prefix.lastIndexOf('_');
const env = prefix.substring(lastUnderscoreIndex + 1);
// Check if environment is configured
if (configuredTabs.includes(env)) {
return env as ApkEnvironment;
}
return 'other';
}
export function parseApkFilename(filename: string, version: string): ParsedApkMetadata {
// Use version as delimiter (with leading underscore)
const delimiter = `_${version}`;
const delimiterIndex = filename.indexOf(delimiter);
let prefix: string;
if (delimiterIndex > 0) {
prefix = filename.substring(0, delimiterIndex);
} else {
// Fallback: can't find version delimiter
prefix = '';
}
const environment = detectEnvironment(prefix, APK_TABS);
// Extract commit ID
const commitMatch = filename.match(/([a-f0-9]{7,})\.apk$/);
const commit = commitMatch ? commitMatch[1] : null;
return {
environment,
commit,
isValid: commit !== null,
};
}
```
**Step 4: Run tests to verify they pass**
Run: `npm test -- lib/__tests__/apk-parser.test.ts`
Expected: All tests pass
**Step 5: Commit**
```bash
git add lib/apk-parser.ts lib/__tests__/apk-parser.test.ts
git commit -m "feat: rewrite APK parser to use version as delimiter"
```
---
### Task 3: Update API Route to Pass Version
**Files:**
- Modify: `app/api/apks/route.ts`
- Modify: `app/api/__tests__/apks.test.ts`
**Step 1: Check existing API test**
Read: `app/api/__tests__/apks.test.ts`
**Step 2: Update API route to pass version**
Modify `app/api/apks/route.ts` line 22:
```typescript
// Change from:
const parsed = parseApkFilename(apk.name);
// To:
const parsed = parseApkFilename(apk.name, version);
```
**Step 3: Verify build succeeds**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/apks/route.ts
git commit -m "feat: pass version parameter to APK parser"
```
---
### Task 4: Update ApkList to Use Configured Tabs
**Files:**
- Modify: `lib/constants.ts`
- Modify: `app/download/ApkList.tsx`
- Add: `app/api/config/route.ts`
**Step 1: Create config API endpoint**
Create `app/api/config/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { APK_TABS } from '@/lib/constants';
export async function GET() {
return NextResponse.json({
tabs: APK_TABS,
});
}
```
**Step 2: Update ApkList to fetch and use configured tabs**
Modify `app/download/ApkList.tsx`:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface Apk {
name: string;
environment: ApkEnvironment;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
interface ApkListProps {
project: string;
version: string;
commitId: string;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500">
...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-red-600">
</div>
);
}
if (!apks || apks.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<p className="text-gray-500"> APK </p>
</div>
);
}
// Group APKs by environment
const groupedApks = apks.reduce((acc, apk) => {
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
return acc;
}, {} as Record<ApkEnvironment, Apk[]>);
return (
<div>
{/* Tab Navigation */}
<div className="bg-white rounded-t-lg border-b border-gray-200">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{TABS.map((tab) => {
const count = groupedApks[tab]?.length || 0;
return (
<button
key={tab}
onClick={() => {}}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
false // activeTab logic removed for simplicity
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab} ({count})
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-4">
<ApkTable apks={groupedApks['product'] || []} />
</div>
</div>
);
}
```
Wait, I need to preserve the activeTab state. Let me revise:
```typescript
'use client';
import { useState, useEffect } from 'react';
import useSWR from 'swr';
import { type ApkEnvironment } from '@/lib/apk-parser';
import ApkTable from './ApkTable';
const fetcher = (url: string) => fetch(url).then((res) => res.json());
interface Apk {
name: string;
environment: ApkEnvironment;
commit: string | null;
size: number;
modifiedAt: string;
downloadUrl: string;
}
interface ApkListProps {
project: string;
version: string;
commitId: string;
}
export default function ApkList({ project, version, commitId }: ApkListProps) {
const [configuredTabs, setConfiguredTabs] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<ApkEnvironment>('product');
// Fetch config on mount
useEffect(() => {
fetch('/api/config')
.then(res => res.json())
.then(data => setConfiguredTabs(data.tabs))
.catch(() => setConfiguredTabs(['dev', 'sandbox'])); // fallback
}, []);
const { data: apks, isLoading, error } = useSWR<Apk[]>(
`/api/apks?project=${project}&version=${version}&commit=${commitId}`,
fetcher
);
// Build tabs: product → configured → other
const TABS: ApkEnvironment[] = ['product', ...configuredTabs.filter(t => t !== 'product' && t !== 'other') as ApkEnvironment[], 'other'];
if (isLoading) {
return (
<div className="text-center py-8 text-gray-500">
...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-red-600">
</div>
);
}
if (!apks || apks.length === 0) {
return (
<div className="bg-white rounded-lg shadow-md p-12 text-center">
<p className="text-gray-500"> APK </p>
</div>
);
}
// Group APKs by environment
const groupedApks = apks.reduce((acc, apk) => {
acc[apk.environment] = [...(acc[apk.environment] || []), apk];
return acc;
}, {} as Record<ApkEnvironment, Apk[]>);
return (
<div>
{/* Tab Navigation */}
<div className="bg-white rounded-t-lg border-b border-gray-200">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{TABS.map((tab) => {
const count = groupedApks[tab]?.length || 0;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab} ({count})
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-4">
<ApkTable apks={groupedApks[activeTab] || []} />
</div>
</div>
);
}
```
**Step 3: Run build to verify**
Run: `npm run build`
Expected: Build succeeds
**Step 4: Commit**
```bash
git add app/api/config/route.ts app/download/ApkList.tsx
git commit -m "feat: add config API and update ApkList to use configured tabs"
```
---
### Task 5: Final Verification
**Step 1: Run all tests**
Run: `npm test`
Expected: All tests pass
**Step 2: Run build**
Run: `npm run build`
Expected: Build succeeds
**Step 3: Manual test with dev server**
Run: `npm run dev`
Test: Navigate to download page, select project and version, verify tabs display correctly
---
## Summary
| File | Change |
|------|--------|
| `lib/constants.ts` | Add `APK_TABS` env var |
| `lib/apk-parser.ts` | Rewrite parsing logic with version delimiter |
| `lib/__tests__/apk-parser.test.ts` | Update tests |
| `app/api/apks/route.ts` | Pass version to parser |
| `app/api/config/route.ts` | New endpoint for tab config |
| `app/download/ApkList.tsx` | Use configured tabs from API |