mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Merge pull request #271 from zapstore/ai-guidelines
Spec-first AI guardrails and lightweight task workflow
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../tools/content/agent.json
|
||||
+1
-1
@@ -1 +1 @@
|
||||
tools/content/CONTEXT.md
|
||||
CONTEXT.md
|
||||
@@ -1 +0,0 @@
|
||||
../.cursor/mcp.json
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Zapstore — Project Context
|
||||
|
||||
This document is the entry point for AI assistants.
|
||||
All behavioral authority lives in the project spec, not here.
|
||||
|
||||
If anything in this file conflicts with files under `spec/guidelines/`,
|
||||
this file is wrong.
|
||||
|
||||
## What This Repository Is
|
||||
|
||||
Zapstore is a local-first, Nostr-native app store for Android.
|
||||
It discovers, downloads, verifies, and installs APKs signed by developers the user trusts.
|
||||
|
||||
Users can support developers directly via Lightning zaps.
|
||||
|
||||
## Project Spec Structure
|
||||
|
||||
spec/
|
||||
guidelines/ # Permanent rules (human-owned, never AI-modified)
|
||||
ARCHITECTURE.md # Package boundaries, dependencies, key patterns
|
||||
INVARIANTS.md # Non-negotiable behavioral guarantees
|
||||
QUALITY_BAR.md # Standards, when to create specs
|
||||
VISION.md # Product goals and non-goals
|
||||
|
||||
features/ # Feature specs (behavioral contracts)
|
||||
_TEMPLATE.md # Template with example
|
||||
FEAT-001-*.md # Actual feature specs
|
||||
|
||||
work/ # Active work packets (temporary, delete after merge)
|
||||
_TEMPLATE.md # Template with example
|
||||
WORK-001-*.md # Actual work packets
|
||||
|
||||
## How to Work
|
||||
|
||||
1. Before implementing, check `spec/features/` for a relevant feature spec
|
||||
2. For non-trivial work, create a work packet in `work/`
|
||||
3. Every code change must trace to a task in the work packet
|
||||
4. If a spec is unclear or incorrect, report a Spec Issue—do not guess
|
||||
|
||||
See `spec/guidelines/QUALITY_BAR.md` for what qualifies as "non-trivial."
|
||||
|
||||
## File Ownership
|
||||
|
||||
**Never modify** files in `spec/guidelines/`.
|
||||
If a guideline seems wrong or incomplete, report it as a Spec Issue.
|
||||
|
||||
| Path | Owner | AI May Modify |
|
||||
|------|-------|---------------|
|
||||
| `spec/guidelines/*` | Human | No |
|
||||
| `spec/features/*` | Human | No (unless explicitly asked) |
|
||||
| `work/*.md` | AI | Yes |
|
||||
| `lib/**` | Shared | Yes |
|
||||
| `test/**` | Shared | Yes |
|
||||
| `CONTEXT.md` | Human | No |
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Prefer small, localized changes. Avoid unrelated refactors.
|
||||
- After dependency changes, run: `fvm flutter pub get`
|
||||
- Fix any analyze/lint errors introduced by your changes.
|
||||
- Assume Android as default target unless instructed otherwise.
|
||||
+14
-30
@@ -51,7 +51,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: "direct dev"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
@@ -59,7 +59,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: "direct dev"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
@@ -114,14 +114,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
bm25:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: bm25
|
||||
sha256: "75f9f627fcdb884db420510d34906fb5e7ec7caef9d9cbdc35be17317b637f7b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -589,26 +581,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -657,22 +649,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
mcp_dart:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mcp_dart
|
||||
sha256: "4bc05574bc8bda0ae779e177ab5639f580b37059b5cb1248288b7a79979dd5e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.3"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1157,10 +1141,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1277,10 +1261,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -83,10 +83,6 @@ dev_dependencies:
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^5.0.0
|
||||
mcp_dart: ^0.5.3
|
||||
bm25: ^2.2.3
|
||||
archive: ^4.0.7
|
||||
args: ^2.7.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# FEAT-001 — Package Manager
|
||||
|
||||
## Goal
|
||||
|
||||
Single source of truth for installed packages and active install operations.
|
||||
Manages the complete lifecycle: download → verify → install, with pause/resume/cancel support.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Managing non-APK file types
|
||||
- Auto-updating without user awareness
|
||||
- Installing from sources other than Nostr-published releases
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
### Download Phase
|
||||
|
||||
- User taps "Install" → download begins, progress shown
|
||||
- User can pause/resume/cancel active downloads
|
||||
- Multiple downloads queue automatically (max 3 concurrent)
|
||||
- "Update All" queues all updates immediately with visual feedback
|
||||
|
||||
### Verification Phase
|
||||
|
||||
- After download completes, hash verification runs
|
||||
- Verification state is visible (not hidden)
|
||||
- Hash mismatch blocks install with clear error
|
||||
|
||||
### Permission Phase
|
||||
|
||||
- If "Install unknown apps" permission not granted, user is prompted
|
||||
- Permission state is explicit in UI
|
||||
- Once granted, all waiting installs advance automatically
|
||||
|
||||
### Install Phase
|
||||
|
||||
- Native Android install dialog shown
|
||||
- One install dialog at a time (serialized)
|
||||
- If user dismisses dialog, install shows "Tap to retry" state
|
||||
- Success updates installed list immediately (no stale UI)
|
||||
|
||||
### Failure States
|
||||
|
||||
- Download failed → clear error, can retry
|
||||
- Hash mismatch → error, cannot proceed
|
||||
- Certificate mismatch → offer "Uninstall and reinstall" option
|
||||
- Permission denied → guidance to enable in Settings
|
||||
|
||||
## State Machine
|
||||
|
||||
Operations follow this sealed class hierarchy (`install_operation.dart`):
|
||||
|
||||
```
|
||||
DownloadQueued → Downloading ↔ DownloadPaused
|
||||
↓
|
||||
Verifying
|
||||
↓
|
||||
AwaitingPermission (if needed)
|
||||
↓
|
||||
ReadyToInstall
|
||||
↓
|
||||
Installing → AwaitingUserAction (if dismissed)
|
||||
↓
|
||||
[cleared] or OperationFailed
|
||||
```
|
||||
|
||||
State transitions are unidirectional except Downloading ↔ DownloadPaused.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Network drops mid-download → download pauses or fails gracefully, can retry
|
||||
- App backgrounded during install → install completes, UI updates on return
|
||||
- 404 from origin server → automatic CDN fallback before failing
|
||||
- Stale operations (>7 days) → garbage collected on app restart
|
||||
- Android package DB race condition → state updated from target metadata, not sync
|
||||
|
||||
## Invariants
|
||||
|
||||
These are non-negotiable. Violations mean the implementation is broken.
|
||||
|
||||
1. **UI never blocks** — `install()` returns immediately; events drive state via EventChannel
|
||||
2. **One install dialog at a time** — Android PackageInstaller limitation, enforced by serialization
|
||||
3. **Hash verification before install** — Native side verifies before install session opens
|
||||
4. **Permission flow is explicit** — `AwaitingPermission` state exists for UI feedback
|
||||
5. **Downloaded files are cleaned up** — Deleted after success or dismissal
|
||||
6. **No polling** — All state changes via callbacks/events, never periodic checks
|
||||
|
||||
## Integration Boundaries
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PackageManager (Dart) │
|
||||
│ - State machine owner │
|
||||
│ - Download management (background_downloader) │
|
||||
│ - Orchestrates flow │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ MethodChannel / EventChannel
|
||||
┌─────────────────────────▼───────────────────────────────────┐
|
||||
│ AndroidPackageManagerPlugin (Kotlin) │
|
||||
│ - Hash verification │
|
||||
│ - PackageInstaller session │
|
||||
│ - Permission checks │
|
||||
│ - Emits: verifying/started/success/failed/cancelled │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can download, pause, resume, cancel downloads
|
||||
- [ ] User can install apps with proper verification
|
||||
- [ ] Multiple downloads queue correctly (max 3 concurrent)
|
||||
- [ ] Install failures show actionable error messages
|
||||
- [ ] Certificate mismatch offers force-update option
|
||||
- [ ] UI remains responsive throughout all operations
|
||||
- [ ] No operations block the UI thread
|
||||
|
||||
## Files
|
||||
|
||||
- `lib/services/package_manager/package_manager.dart` — Base class, state machine
|
||||
- `lib/services/package_manager/install_operation.dart` — State definitions
|
||||
- `lib/services/package_manager/android_package_manager.dart` — Android implementation
|
||||
- `android/.../AndroidPackageManagerPlugin.kt` — Native side
|
||||
@@ -0,0 +1,75 @@
|
||||
# FEAT-XXX — Short Name
|
||||
|
||||
## Goal
|
||||
|
||||
1–2 sentences describing what this feature/bugfix achieves for the user.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Explicitly list what is out of scope
|
||||
- Prevents scope creep and AI drift
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
- What the user sees or can do
|
||||
- States: loading, success, error, empty (where relevant)
|
||||
- Offline behavior
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Degraded or no network
|
||||
- Cancellation / retry
|
||||
- Invalid or partial data
|
||||
- Permission denied
|
||||
- Other relevant risks
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Observable outcome 1
|
||||
- [ ] Observable outcome 2
|
||||
- [ ] Observable outcome 3
|
||||
|
||||
## Notes (optional)
|
||||
|
||||
- Anything that needs human decision
|
||||
- Open questions
|
||||
|
||||
---
|
||||
|
||||
# Example: FEAT-002 — NWC Zaps
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to zap app developers directly from the app detail screen using Nostr Wallet Connect.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- In-app wallet management (just NWC connection)
|
||||
- Zapping comments or reviews (only developers)
|
||||
- Recurring zaps or subscriptions
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
- Zap button visible on app detail screen when NWC connected
|
||||
- Tapping opens amount selection dialog (21, 100, 500, 1000 sats, custom)
|
||||
- Success: toast confirmation with amount
|
||||
- Failure: error dialog with reason
|
||||
- Button disabled with tooltip when developer has no lightning address
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Developer has no lightning address → button hidden or disabled with explanation
|
||||
- NWC connection drops mid-zap → graceful error, suggest reconnect
|
||||
- Insufficient wallet balance → clear error from wallet
|
||||
- App backgrounded during zap → completes, toast on return
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] User can connect NWC from profile settings
|
||||
- [ ] User can zap developer from app detail screen
|
||||
- [ ] Zap fails gracefully with clear error message
|
||||
- [ ] Zap button correctly disabled when NWC not connected
|
||||
|
||||
## Notes
|
||||
|
||||
- Consider whether to show cumulative zaps received by developer
|
||||
@@ -0,0 +1,98 @@
|
||||
# Zapstore — Architecture
|
||||
|
||||
## Core Principle
|
||||
|
||||
Architecture exists to prevent accidental coupling and hidden ownership.
|
||||
Each layer has clear responsibilities and must not exceed them.
|
||||
|
||||
## Layers
|
||||
|
||||
### zapstore (this Flutter app)
|
||||
|
||||
- Flutter UI and application orchestration
|
||||
- Navigation, presentation, and user interaction
|
||||
- Coordinates use cases across dependencies
|
||||
- Must not contain domain rules or persistence logic
|
||||
|
||||
### Dart dependencies
|
||||
|
||||
#### models
|
||||
|
||||
- Domain models for Nostr events, kinds, zaps, releases, and NWC
|
||||
- Parsing, validation, signing, encryption, and verification
|
||||
- Pure domain logic only
|
||||
- Must not depend on storage, networking, isolates, or UI
|
||||
|
||||
#### purplebase
|
||||
|
||||
- Local-first storage and indexing (SQLite)
|
||||
- Relay synchronization and subscription lifecycle management
|
||||
- Background work and isolate execution
|
||||
- Must not depend on UI or presentation logic
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
- zapstore → purplebase → models
|
||||
- Reverse dependencies are forbidden
|
||||
- UI widgets must not manage relay connections, storage, or background jobs
|
||||
|
||||
## Ownership & Orchestration
|
||||
|
||||
- Relay pools and subscriptions are owned by purplebase
|
||||
- Background work lifecycle is explicit and cancellable
|
||||
- zapstore orchestrates flows but does not own low-level resources
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Widget watching data
|
||||
|
||||
```dart
|
||||
// Watch a query provider — reactive, auto-disposes
|
||||
final state = ref.watch(
|
||||
query<Profile>(
|
||||
authors: {pubkey},
|
||||
source: const LocalAndRemoteSource(relays: {'social'}),
|
||||
),
|
||||
);
|
||||
|
||||
return switch (state) {
|
||||
StorageLoading() => CircularProgressIndicator(),
|
||||
StorageError(:final exception) => Text('Error: $exception'),
|
||||
StorageData(:final models) => ProfileWidget(models.first),
|
||||
};
|
||||
|
||||
// Nested queries with `and` — loads relationships
|
||||
final appState = ref.watch(
|
||||
query<App>(
|
||||
tags: {'#d': {identifier}},
|
||||
and: (app) => {
|
||||
app.latestRelease.query(
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
and: (release) => {release.latestMetadata.query()},
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||
subscriptionPrefix: 'app-detail',
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Imperative queries (notifiers/services)
|
||||
|
||||
```dart
|
||||
// One-shot query via storage extension
|
||||
final apps = await ref.storage.query(
|
||||
RequestFilter<App>(authors: {pubkey}, limit: 20).toRequest(),
|
||||
);
|
||||
```
|
||||
|
||||
### Saving and publishing (from callbacks)
|
||||
|
||||
```dart
|
||||
onPressed: () async {
|
||||
await ref.storage.save({signedModel});
|
||||
await ref.storage.publish({signedModel});
|
||||
}
|
||||
```
|
||||
|
||||
For detailed API, see models/purplebase READMEs in pub cache.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Zapstore — Invariants
|
||||
|
||||
The following guarantees are non-negotiable.
|
||||
If any invariant is violated, the implementation is incorrect.
|
||||
|
||||
## UI Safety
|
||||
|
||||
- UI rendering must remain responsive under partial or total network failure.
|
||||
- The UI must never block on I/O, cryptography, disk access, or network/relay operations.
|
||||
- All background or asynchronous work must be cancellable and lifecycle-safe.
|
||||
- Local data must be sufficient to render meaningful UI state; network access must enhance UX, not gate it.
|
||||
- No operation may assume continuous network availability.
|
||||
|
||||
## Async Discipline
|
||||
|
||||
- No polling or artificial delays (e.g., Future.delayed for timing).
|
||||
- Background work must surface results asynchronously and non-blockingly.
|
||||
- All async work must be cancellable.
|
||||
|
||||
## Local-First Guarantees
|
||||
|
||||
- Cached data must be preferred over network data when available.
|
||||
- Installed apps and metadata must be accessible offline.
|
||||
- Network failures must degrade gracefully.
|
||||
|
||||
## Security & Verification
|
||||
|
||||
- APKs must never be installed unless their hash matches the expected value.
|
||||
- Signed Nostr events must be verified before use.
|
||||
- NWC secrets must be stored securely and must never be logged or exposed.
|
||||
|
||||
## Data Robustness
|
||||
|
||||
- Parsing unknown, missing, or future tags must not crash the app.
|
||||
- Partial or invalid data must degrade gracefully.
|
||||
|
||||
## Lifecycle Safety
|
||||
|
||||
- Subscriptions must always be cancellable.
|
||||
- Isolates and background jobs must not leak resources.
|
||||
- Reconnection or retries must not duplicate events or actions.
|
||||
|
||||
## UX Safety
|
||||
|
||||
- All user-visible processes must have explicit states (loading, empty, success, error).
|
||||
- Silent failures are unacceptable.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Zapstore — Quality Bar
|
||||
|
||||
## General Expectations
|
||||
|
||||
- Correct behavior matters more than coverage numbers.
|
||||
- Happy-path-only implementations are insufficient.
|
||||
- Failures must be explicit and observable.
|
||||
|
||||
## When to Create a Feature Spec
|
||||
|
||||
Create a spec if the work:
|
||||
|
||||
- Touches async/lifecycle code (risk of UI blocking or resource leaks)
|
||||
- Modifies security-sensitive flows (verification, permissions, signing, secrets)
|
||||
- Changes state machine behavior (package manager, auth, subscriptions)
|
||||
- Affects multiple screens or services
|
||||
- Could regress existing UX
|
||||
|
||||
**Skip the spec** if:
|
||||
|
||||
- Pure UI cosmetics (colors, spacing, copy changes)
|
||||
- Adding a field to an existing model with no behavioral change
|
||||
- Bug fix with obvious cause and obvious solution
|
||||
- Dependency update with no API changes
|
||||
|
||||
When in doubt, create a spec. The overhead is low.
|
||||
|
||||
## Work Packet Lifecycle
|
||||
|
||||
1. Create `WORK-XXX-*.md` when starting non-trivial work
|
||||
2. Update tasks and decisions as you work
|
||||
3. **Delete after PR merges** — the feature spec remains as the contract
|
||||
|
||||
If multiple phases: `WORK-005-a.md`, `WORK-005-b.md` (same feature number).
|
||||
|
||||
## Layer Expectations
|
||||
|
||||
### models
|
||||
|
||||
- Parsing and serialization behavior must be tested.
|
||||
- Unknown or future fields must be tolerated.
|
||||
|
||||
### purplebase
|
||||
|
||||
- Storage and query behavior must be testable without network access.
|
||||
- Subscription, cancellation, and isolate behavior must be validated.
|
||||
|
||||
### zapstore UI
|
||||
|
||||
- UI state machines must be explicit and testable.
|
||||
- Loading, empty, error, and retry states are mandatory.
|
||||
- UI must remain usable under degraded network conditions.
|
||||
|
||||
## Implementation Expectations
|
||||
|
||||
- Follow existing patterns in the nearest module.
|
||||
- Avoid introducing new architectural layers unless required by a spec.
|
||||
- Do not perform broad or stylistic refactors.
|
||||
- Prefer clarity and locality over abstraction.
|
||||
- Prefer extending or reusing existing abstractions over introducing new ones.
|
||||
- Code must be structured for human review first, not for AI generation convenience.
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
- Tests must validate behavior, not implementation details.
|
||||
- Failure, cancellation, and degraded-network paths must be covered.
|
||||
- Tests that only assert the happy path are insufficient.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Silent failures
|
||||
- Blocking the UI thread
|
||||
- Artificial delays or polling
|
||||
- Large refactors unrelated to the task
|
||||
|
||||
## Working With AI
|
||||
|
||||
This project uses a spec-first workflow to collaborate safely with AI.
|
||||
|
||||
### Documentation Discipline
|
||||
|
||||
- Markdown files must remain small, focused, and human-readable.
|
||||
- Prefer extending existing documents over creating new ones.
|
||||
- The goal is to do more with less, not to document everything.
|
||||
|
||||
### What Humans Own
|
||||
|
||||
- Guidelines under `spec/guidelines/` (never AI-modified)
|
||||
- Feature specs under `spec/features/`
|
||||
- Decisions to change behavior or architecture
|
||||
|
||||
### What AI Owns
|
||||
|
||||
- Work packets under `work/`
|
||||
- Refinement of task plans during implementation
|
||||
|
||||
### Spec-First Rule
|
||||
|
||||
- Behavior changes require a feature spec first.
|
||||
- During implementation, specs are read-only.
|
||||
- If a spec is unclear or incorrect, AI must stop and report a "Spec Issue".
|
||||
|
||||
### Task Completeness
|
||||
|
||||
For non-trivial work, changes are not complete unless:
|
||||
|
||||
- Work packet reflects the actual work performed
|
||||
- No significant code exists outside the task plan
|
||||
- Edge cases and failure modes are addressed
|
||||
|
||||
This workflow exists to prevent AI drift, accidental refactors, and UX regressions.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Zapstore — Vision
|
||||
|
||||
## What Zapstore Is
|
||||
Zapstore is a local-first, Nostr-native app distribution platform focused on trust,
|
||||
verification, and user sovereignty.
|
||||
|
||||
It enables discovering, installing, and supporting apps using open protocols
|
||||
(Nostr + Bitcoin/Lightning), without centralized app store control.
|
||||
|
||||
## Who It Is For
|
||||
- Users who value open networks and self-custody
|
||||
- Developers distributing apps via Nostr
|
||||
- Communities curating and funding software directly
|
||||
|
||||
## What Success Means
|
||||
- Users can reliably discover and install apps offline-first
|
||||
- Installs and updates are verifiable and safe
|
||||
- Developers can be supported directly via zaps
|
||||
|
||||
## Non-Goals
|
||||
- Zapstore is not a general-purpose app marketplace
|
||||
- Zapstore does not optimize for engagement or growth metrics
|
||||
- Zapstore does not require real-world identity or accounts
|
||||
- Zapstore does not hide trust, verification, or provenance from users
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"model": "claude-sonnet-4",
|
||||
"temperature": 0.2,
|
||||
"mcpServers": {
|
||||
"purplestack": {
|
||||
"type": "stdio",
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"tools/scripts/run-mcp.sh"
|
||||
]
|
||||
},
|
||||
"nostr": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@nostrbook/mcp@latest"
|
||||
]
|
||||
},
|
||||
"developer": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@soapbox.pub/developer-mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
echo "Generating MCP content..."
|
||||
cd "$SCRIPT_DIR/../../../../purplebase/purplestack-context"
|
||||
./generate-content.sh
|
||||
|
||||
echo "Copying MCP content to project..."
|
||||
cp "$SCRIPT_DIR/../../../../purplebase/purplestack/tools/content/mcp-content.zip" "$SCRIPT_DIR/mcp-content.zip"
|
||||
|
||||
echo "MCP content updated successfully!"
|
||||
|
||||
Binary file not shown.
@@ -1,372 +0,0 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:bm25/bm25.dart';
|
||||
import 'package:mcp_dart/mcp_dart.dart';
|
||||
|
||||
/// Main entry point for the MCP server
|
||||
void main() async {
|
||||
final server = PurpleStackMcpServer();
|
||||
|
||||
try {
|
||||
await server.initialize();
|
||||
server.start();
|
||||
} catch (e) {
|
||||
stderr.writeln('Failed to initialize server: $e');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP server that serves recipes and API documentation
|
||||
class PurpleStackMcpServer {
|
||||
static String get contentZipPath {
|
||||
final scriptUri = Platform.script;
|
||||
final scriptDir = path.dirname(scriptUri.toFilePath());
|
||||
return path.normalize(
|
||||
path.join(scriptDir, '..', 'content', 'mcp-content.zip'),
|
||||
);
|
||||
}
|
||||
|
||||
late McpServer _server;
|
||||
|
||||
// Content storage - now using full paths as keys
|
||||
final Map<String, String> _recipes = {};
|
||||
final Map<String, String> _docs = {};
|
||||
|
||||
// Search indexes
|
||||
BM25? _recipeSearchIndex;
|
||||
BM25? _docSearchIndex;
|
||||
List<String> _recipePaths = [];
|
||||
List<String> _docPaths = [];
|
||||
|
||||
PurpleStackMcpServer();
|
||||
|
||||
/// Initialize the server and load content
|
||||
Future<void> initialize() async {
|
||||
// Create MCP server
|
||||
_server = McpServer(
|
||||
Implementation(name: 'Purplestack Context Server', version: '1.0.0'),
|
||||
options: ServerOptions(
|
||||
capabilities: ServerCapabilities(tools: ServerCapabilitiesTools()),
|
||||
),
|
||||
);
|
||||
|
||||
// Load content from zip file
|
||||
await _loadContent();
|
||||
|
||||
// Build search indexes
|
||||
await _buildSearchIndexes();
|
||||
|
||||
// Register tools
|
||||
_registerTools();
|
||||
}
|
||||
|
||||
/// Start the MCP server
|
||||
void start() {
|
||||
_server.connect(StdioServerTransport());
|
||||
}
|
||||
|
||||
/// Load content from the zip file
|
||||
Future<void> _loadContent() async {
|
||||
final file = File(contentZipPath);
|
||||
if (!file.existsSync()) {
|
||||
throw Exception('Content zip file not found: $contentZipPath');
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
|
||||
for (final file in archive) {
|
||||
if (file.isFile &&
|
||||
(file.name.endsWith('.md') || file.name.endsWith('.html'))) {
|
||||
final content = utf8.decode(file.content as List<int>);
|
||||
|
||||
if (file.name.startsWith('recipes/')) {
|
||||
// Use full path as key, removing only the 'recipes/' prefix
|
||||
final recipePath = file.name.substring('recipes/'.length);
|
||||
_recipes[recipePath] = content;
|
||||
} else if (file.name.startsWith('api-docs/')) {
|
||||
// Use full path as key, removing only the 'api-docs/' prefix
|
||||
final docPath = file.name.substring('api-docs/'.length);
|
||||
_docs[docPath] = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build search indexes for recipes and docs
|
||||
Future<void> _buildSearchIndexes() async {
|
||||
if (_recipes.isNotEmpty) {
|
||||
// Build recipe search index
|
||||
_recipePaths = _recipes.keys.toList();
|
||||
final recipeDocuments = _recipes.entries.map((entry) {
|
||||
return '${entry.key} ${entry.value}';
|
||||
}).toList();
|
||||
_recipeSearchIndex = await BM25.build(recipeDocuments);
|
||||
}
|
||||
|
||||
if (_docs.isNotEmpty) {
|
||||
// Build docs search index
|
||||
_docPaths = _docs.keys.toList();
|
||||
final docDocuments = _docs.entries.map((entry) {
|
||||
return '${entry.key} ${entry.value}';
|
||||
}).toList();
|
||||
_docSearchIndex = await BM25.build(docDocuments);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all tools with the MCP server
|
||||
void _registerTools() {
|
||||
// List recipes tool
|
||||
_server.tool(
|
||||
'list_recipes',
|
||||
description: 'List all available recipes',
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _listRecipes({});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
|
||||
// Read recipe tool
|
||||
_server.tool(
|
||||
'read_recipe',
|
||||
description: 'Read a specific recipe by name',
|
||||
inputSchemaProperties: {
|
||||
'name': {'type': 'string', 'description': 'Name of the recipe to read'},
|
||||
},
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _readRecipe(args ?? {});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
|
||||
// Search recipes tool
|
||||
_server.tool(
|
||||
'search_recipes',
|
||||
description: 'Search recipes by query',
|
||||
inputSchemaProperties: {
|
||||
'query': {'type': 'string', 'description': 'Search query for recipes'},
|
||||
},
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _searchRecipes(args ?? {});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
|
||||
// List docs tool
|
||||
_server.tool(
|
||||
'list_docs',
|
||||
description: 'List all available documentation',
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _listDocs({});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
|
||||
// Read doc tool
|
||||
_server.tool(
|
||||
'read_doc',
|
||||
description: 'Read a specific document by name',
|
||||
inputSchemaProperties: {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Name of the document to read',
|
||||
},
|
||||
},
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _readDoc(args ?? {});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
|
||||
// Search docs tool
|
||||
_server.tool(
|
||||
'search_docs',
|
||||
description: 'Search documentation by query',
|
||||
inputSchemaProperties: {
|
||||
'query': {
|
||||
'type': 'string',
|
||||
'description': 'Search query for documentation',
|
||||
},
|
||||
},
|
||||
callback: ({args, extra}) async {
|
||||
final result = await _searchDocs(args ?? {});
|
||||
return CallToolResult.fromContent(content: [TextContent(text: result)]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Tool handlers
|
||||
|
||||
Future<String> _listRecipes(Map<String, dynamic> arguments) async {
|
||||
if (_recipes.isEmpty) {
|
||||
return 'No recipes available.';
|
||||
}
|
||||
|
||||
final recipeList = _recipes.keys.toList()..sort();
|
||||
return 'Available recipes:\n${recipeList.map((path) => '- $path').join('\n')}';
|
||||
}
|
||||
|
||||
Future<String> _readRecipe(Map<String, dynamic> arguments) async {
|
||||
final name = arguments['name'] as String?;
|
||||
if (name == null) {
|
||||
return 'Error: Recipe name is required';
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
var recipe = _recipes[name];
|
||||
if (recipe != null) {
|
||||
return recipe;
|
||||
}
|
||||
|
||||
// Try partial match for backwards compatibility
|
||||
final matchingKeys = _recipes.keys
|
||||
.where(
|
||||
(key) =>
|
||||
key.toLowerCase().contains(name.toLowerCase()) ||
|
||||
path.basenameWithoutExtension(key).toLowerCase() ==
|
||||
name.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (matchingKeys.length == 1) {
|
||||
return _recipes[matchingKeys.first]!;
|
||||
} else if (matchingKeys.length > 1) {
|
||||
return 'Multiple recipes found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}';
|
||||
}
|
||||
|
||||
final suggestions = _findSimilarKeys(name, _recipes.keys.toList());
|
||||
final suggestionText = suggestions.isNotEmpty
|
||||
? '\n\nDid you mean: ${suggestions.join(', ')}?'
|
||||
: '';
|
||||
return 'Recipe "$name" not found.$suggestionText';
|
||||
}
|
||||
|
||||
Future<String> _searchRecipes(Map<String, dynamic> arguments) async {
|
||||
final query = arguments['query'] as String?;
|
||||
if (query == null || query.isEmpty) {
|
||||
return 'Error: Search query is required';
|
||||
}
|
||||
|
||||
if (_recipeSearchIndex == null) {
|
||||
return 'Search index not available';
|
||||
}
|
||||
|
||||
final results = await _recipeSearchIndex!.search(query);
|
||||
if (results.isEmpty) {
|
||||
return 'No recipes found for query: "$query"';
|
||||
}
|
||||
|
||||
// Build documents list for index lookup
|
||||
final recipeDocuments = _recipes.entries.map((entry) {
|
||||
return '${entry.key} ${entry.value}';
|
||||
}).toList();
|
||||
|
||||
final resultText = StringBuffer('Search results for "$query":\n\n');
|
||||
for (final result in results.take(5)) {
|
||||
final index = recipeDocuments.indexOf(result.doc.text);
|
||||
if (index != -1) {
|
||||
final recipePath = _recipePaths[index];
|
||||
final score = result.score.toStringAsFixed(2);
|
||||
resultText.writeln('$recipePath ($score)');
|
||||
}
|
||||
}
|
||||
|
||||
return resultText.toString().trim();
|
||||
}
|
||||
|
||||
Future<String> _listDocs(Map<String, dynamic> arguments) async {
|
||||
if (_docs.isEmpty) {
|
||||
return 'No documentation available.';
|
||||
}
|
||||
|
||||
final docList = _docs.keys.toList()..sort();
|
||||
return 'Available documentation:\n${docList.map((path) => '- $path').join('\n')}';
|
||||
}
|
||||
|
||||
Future<String> _readDoc(Map<String, dynamic> arguments) async {
|
||||
final name = arguments['name'] as String?;
|
||||
if (name == null) {
|
||||
return 'Error: Document name is required';
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
var doc = _docs[name];
|
||||
if (doc != null) {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// Try partial match for backwards compatibility
|
||||
final matchingKeys = _docs.keys
|
||||
.where(
|
||||
(key) =>
|
||||
key.toLowerCase().contains(name.toLowerCase()) ||
|
||||
path.basenameWithoutExtension(key).toLowerCase() ==
|
||||
name.toLowerCase(),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (matchingKeys.length == 1) {
|
||||
return _docs[matchingKeys.first]!;
|
||||
} else if (matchingKeys.length > 1) {
|
||||
return 'Multiple documents found. Please be more specific:\n${matchingKeys.map((key) => '- $key').join('\n')}';
|
||||
}
|
||||
|
||||
final suggestions = _findSimilarKeys(name, _docs.keys.toList());
|
||||
final suggestionText = suggestions.isNotEmpty
|
||||
? '\n\nDid you mean: ${suggestions.join(', ')}?'
|
||||
: '';
|
||||
return 'Document "$name" not found.$suggestionText';
|
||||
}
|
||||
|
||||
Future<String> _searchDocs(Map<String, dynamic> arguments) async {
|
||||
final query = arguments['query'] as String?;
|
||||
if (query == null || query.isEmpty) {
|
||||
return 'Error: Search query is required';
|
||||
}
|
||||
|
||||
if (_docSearchIndex == null) {
|
||||
return 'Search index not available';
|
||||
}
|
||||
|
||||
final results = await _docSearchIndex!.search(query);
|
||||
if (results.isEmpty) {
|
||||
return 'No documentation found for query: "$query"';
|
||||
}
|
||||
|
||||
// Build documents list for index lookup
|
||||
final docDocuments = _docs.entries.map((entry) {
|
||||
return '${entry.key} ${entry.value}';
|
||||
}).toList();
|
||||
|
||||
final resultText = StringBuffer('Search results for "$query":\n\n');
|
||||
for (final result in results.take(5)) {
|
||||
final index = docDocuments.indexOf(result.doc.text);
|
||||
if (index != -1) {
|
||||
final docPath = _docPaths[index];
|
||||
final score = result.score.toStringAsFixed(2);
|
||||
resultText.writeln('$docPath ($score)');
|
||||
}
|
||||
}
|
||||
|
||||
return resultText.toString().trim();
|
||||
}
|
||||
|
||||
/// Find similar keys for suggestions
|
||||
List<String> _findSimilarKeys(String input, List<String> keys) {
|
||||
final inputLower = input.toLowerCase();
|
||||
return keys
|
||||
.where(
|
||||
(key) =>
|
||||
key.toLowerCase().contains(inputLower) ||
|
||||
inputLower.contains(key.toLowerCase()) ||
|
||||
path
|
||||
.basenameWithoutExtension(key)
|
||||
.toLowerCase()
|
||||
.contains(inputLower),
|
||||
)
|
||||
.take(3)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,545 +0,0 @@
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:args/args.dart';
|
||||
|
||||
const String kOriginalAppId = 'com.example.purplestack';
|
||||
const String kOriginalAppName = 'Purplestack';
|
||||
const String kOriginalAppNameSnakeCase = 'purplestack';
|
||||
|
||||
/// Converts a string to PascalCase (removes spaces and capitalizes each word)
|
||||
String _toPascalCase(String input) {
|
||||
return input
|
||||
.split(RegExp(r'[\s_-]+')) // Split on spaces, underscores, and hyphens
|
||||
.where((word) => word.isNotEmpty)
|
||||
.map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
void main(List<String> args) async {
|
||||
final parser = ArgParser()
|
||||
..addOption('name', abbr: 'n', help: 'App display name')
|
||||
..addOption(
|
||||
'app-id',
|
||||
abbr: 'i',
|
||||
help: 'App ID in reverse domain notation (e.g., com.company.app)',
|
||||
)
|
||||
..addOption(
|
||||
'description',
|
||||
abbr: 'd',
|
||||
help: 'App description for pubspec.yaml',
|
||||
)
|
||||
..addOption(
|
||||
'version',
|
||||
abbr: 'v',
|
||||
help: 'App version for pubspec.yaml (default: 0.1.0)',
|
||||
)
|
||||
..addOption('icon', help: 'Path to main app icon image')
|
||||
..addOption(
|
||||
'adaptive-background',
|
||||
help: 'Path to adaptive background image (Android)',
|
||||
)
|
||||
..addOption(
|
||||
'adaptive-foreground',
|
||||
help: 'Path to adaptive foreground image (Android)',
|
||||
)
|
||||
..addOption(
|
||||
'adaptive-monochrome',
|
||||
help: 'Path to adaptive monochrome image (Android)',
|
||||
)
|
||||
..addOption(
|
||||
'notification-icon',
|
||||
help: 'Path to notification icon image (Android)',
|
||||
)
|
||||
..addFlag(
|
||||
'help',
|
||||
abbr: 'h',
|
||||
help: 'Show usage information',
|
||||
negatable: false,
|
||||
);
|
||||
|
||||
late ArgResults results;
|
||||
try {
|
||||
results = parser.parse(args);
|
||||
} catch (e) {
|
||||
print('Error: $e\n');
|
||||
_printUsage(parser);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (results['help'] as bool) {
|
||||
_printUsage(parser);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Check for mandatory parameters
|
||||
if (results['name'] == null) {
|
||||
print('Error: --name is required\n');
|
||||
_printUsage(parser);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (results['app-id'] == null) {
|
||||
print('Error: --app-id is required\n');
|
||||
_printUsage(parser);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
final appName = results['name'] as String;
|
||||
final appId = results['app-id'] as String;
|
||||
final appDescription = results['description'] as String?;
|
||||
final appVersion = results['version'] as String? ?? '0.1.0';
|
||||
|
||||
// Create PascalCase version for Dart code (removes spaces, capitalizes each word)
|
||||
final appNamePascalCase = _toPascalCase(appName);
|
||||
final iconPath = results['icon'] as String?;
|
||||
final adaptiveBackground = results['adaptive-background'] as String?;
|
||||
final adaptiveForeground = results['adaptive-foreground'] as String?;
|
||||
final adaptiveMonochrome = results['adaptive-monochrome'] as String?;
|
||||
final notificationIcon = results['notification-icon'] as String?;
|
||||
|
||||
// Validate app ID format
|
||||
if (!RegExp(r'^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$').hasMatch(appId)) {
|
||||
print(
|
||||
'Error: App ID must be in reverse domain notation (e.g., com.company.app)',
|
||||
);
|
||||
print(
|
||||
'Use only lowercase letters, numbers, and dots. Each segment must start with a letter.',
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Validate version format
|
||||
if (!RegExp(r'^\d+\.\d+\.\d+(\+\d+)?$').hasMatch(appVersion)) {
|
||||
print(
|
||||
'Error: Version must be in format x.y.z or x.y.z+build (e.g., 1.0.0 or 1.0.0+1)',
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Validate icon paths exist if provided
|
||||
final iconPaths = [
|
||||
iconPath,
|
||||
adaptiveBackground,
|
||||
adaptiveForeground,
|
||||
adaptiveMonochrome,
|
||||
notificationIcon,
|
||||
].where((path) => path != null).cast<String>();
|
||||
|
||||
for (final iconPathToCheck in iconPaths) {
|
||||
if (!File(iconPathToCheck).existsSync()) {
|
||||
print('Error: Icon file not found: $iconPathToCheck');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
final appNameSnakeCase = appName.toLowerCase().replaceAll(
|
||||
RegExp(r'[^a-z0-9]'),
|
||||
'_',
|
||||
);
|
||||
|
||||
print('Renaming Electric app...');
|
||||
print('Original App ID: $kOriginalAppId → $appId');
|
||||
print('Original App Name: $kOriginalAppName → $appName');
|
||||
print('App Name (PascalCase): $appNamePascalCase');
|
||||
print('Original Snake Case: $kOriginalAppNameSnakeCase → $appNameSnakeCase');
|
||||
if (appDescription != null) print('Description: $appDescription');
|
||||
print('Version: $appVersion');
|
||||
if (iconPath != null) print('Main Icon: $iconPath');
|
||||
if (adaptiveBackground != null) {
|
||||
print('Adaptive Background: $adaptiveBackground');
|
||||
}
|
||||
if (adaptiveForeground != null) {
|
||||
print('Adaptive Foreground: $adaptiveForeground');
|
||||
}
|
||||
if (adaptiveMonochrome != null) {
|
||||
print('Adaptive Monochrome: $adaptiveMonochrome');
|
||||
}
|
||||
if (notificationIcon != null) print('Notification Icon: $notificationIcon');
|
||||
print('');
|
||||
|
||||
final renamer = AppRenamer(
|
||||
appName,
|
||||
appNamePascalCase,
|
||||
appId,
|
||||
appNameSnakeCase,
|
||||
appDescription,
|
||||
appVersion,
|
||||
iconPath: iconPath,
|
||||
adaptiveBackground: adaptiveBackground,
|
||||
adaptiveForeground: adaptiveForeground,
|
||||
adaptiveMonochrome: adaptiveMonochrome,
|
||||
notificationIcon: notificationIcon,
|
||||
);
|
||||
|
||||
try {
|
||||
await renamer.renameApp();
|
||||
|
||||
// Clean and get dependencies
|
||||
await _cleanAndGetDependencies();
|
||||
|
||||
// Generate icons if any icon paths were provided
|
||||
if (renamer._hasIconPaths()) {
|
||||
await renamer._generateIcons();
|
||||
}
|
||||
|
||||
print('\n✅ Electric app renamed successfully!');
|
||||
print('\nNext steps:');
|
||||
print('1. Test the app on your target platforms');
|
||||
print('2. Commit your changes to version control');
|
||||
} catch (e) {
|
||||
print('\n❌ Error renaming app: $e');
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void _printUsage(ArgParser parser) {
|
||||
print(
|
||||
'Electric App Renamer - Rename your Electric app across all platforms\n',
|
||||
);
|
||||
print(
|
||||
'Usage: dart rename_app.dart --name "App Name" --app-id "com.company.app" [options]\n',
|
||||
);
|
||||
print(
|
||||
'This tool will search all files in android/, ios/, lib/, linux/, macos/, test/, windows/ and update pubspec.yaml',
|
||||
);
|
||||
print('(excludes tools/ directory)');
|
||||
print('and replace:');
|
||||
print('- "$kOriginalAppId" with your new app ID (FIRST)');
|
||||
print(
|
||||
'- "$kOriginalAppName" with your new app name in PascalCase (for code)',
|
||||
);
|
||||
print('- "$kOriginalAppNameSnakeCase" with your new app name in snake_case');
|
||||
print('- "com.example" with your new app name (LAST)');
|
||||
print('- pubspec.yaml name, description, and version fields specifically');
|
||||
print('- version defaults to 0.1.0 if not provided\n');
|
||||
print('Options:');
|
||||
print(parser.usage);
|
||||
print('\nExamples:');
|
||||
print(' # Basic rename (spaces in name are allowed)');
|
||||
print(
|
||||
' dart rename_app.dart --name "My Super App" --app-id "com.mycompany.myapp"',
|
||||
);
|
||||
print('');
|
||||
print(' # With description and version');
|
||||
print(
|
||||
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --description "A Flutter app for managing tasks" --version "1.0.0"',
|
||||
);
|
||||
print('');
|
||||
print(' # With main icon');
|
||||
print(
|
||||
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" --icon "assets/icon.png"',
|
||||
);
|
||||
print('');
|
||||
print(' # With Android adaptive icons');
|
||||
print(
|
||||
' dart rename_app.dart --name "My App" --app-id "com.mycompany.myapp" \\',
|
||||
);
|
||||
print(' --icon "assets/icon.png" \\');
|
||||
print(' --adaptive-background "assets/adaptive-bg.png" \\');
|
||||
print(' --adaptive-foreground "assets/adaptive-fg.png" \\');
|
||||
print(' --adaptive-monochrome "assets/adaptive-mono.png"');
|
||||
}
|
||||
|
||||
class AppRenamer {
|
||||
final String appName;
|
||||
final String appNamePascalCase;
|
||||
final String appId;
|
||||
final String appNameSnakeCase;
|
||||
final String? appDescription;
|
||||
final String appVersion;
|
||||
final String? iconPath;
|
||||
final String? adaptiveBackground;
|
||||
final String? adaptiveForeground;
|
||||
final String? adaptiveMonochrome;
|
||||
final String? notificationIcon;
|
||||
|
||||
AppRenamer(
|
||||
this.appName,
|
||||
this.appNamePascalCase,
|
||||
this.appId,
|
||||
this.appNameSnakeCase,
|
||||
this.appDescription,
|
||||
this.appVersion, {
|
||||
this.iconPath,
|
||||
this.adaptiveBackground,
|
||||
this.adaptiveForeground,
|
||||
this.adaptiveMonochrome,
|
||||
this.notificationIcon,
|
||||
});
|
||||
|
||||
Future<void> renameApp() async {
|
||||
print('🔍 Searching and replacing in all files...');
|
||||
|
||||
// Directories to search (excluding tools)
|
||||
final searchDirs = [
|
||||
'android',
|
||||
'ios',
|
||||
'lib',
|
||||
'linux',
|
||||
'macos',
|
||||
'test',
|
||||
'windows',
|
||||
];
|
||||
|
||||
int filesProcessed = 0;
|
||||
int filesChanged = 0;
|
||||
|
||||
// Process pubspec.yaml specifically
|
||||
final pubspecFile = File('pubspec.yaml');
|
||||
if (pubspecFile.existsSync()) {
|
||||
final result = await _processPubspecFile(pubspecFile);
|
||||
filesProcessed++;
|
||||
if (result) filesChanged++;
|
||||
}
|
||||
|
||||
// Process all files in search directories
|
||||
for (final dirName in searchDirs) {
|
||||
final dir = Directory(dirName);
|
||||
if (!dir.existsSync()) {
|
||||
print('⚠️ Directory $dirName not found, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
await for (final entity in dir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final result = await _processFile(entity);
|
||||
filesProcessed++;
|
||||
if (result) filesChanged++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print('✅ Processed $filesProcessed files, changed $filesChanged files');
|
||||
}
|
||||
|
||||
Future<bool> _processPubspecFile(File file) async {
|
||||
try {
|
||||
String content = await file.readAsString();
|
||||
String originalContent = content;
|
||||
|
||||
// Replace the exact snake_case project name
|
||||
content = content.replaceAll(
|
||||
RegExp('^name:\\s+$kOriginalAppNameSnakeCase\$', multiLine: true),
|
||||
'name: $appNameSnakeCase',
|
||||
);
|
||||
|
||||
// Replace description if provided
|
||||
if (appDescription != null) {
|
||||
content = content.replaceAll(
|
||||
RegExp(r'^description:.*$', multiLine: true),
|
||||
'description: $appDescription',
|
||||
);
|
||||
}
|
||||
|
||||
// Replace version (always overwrite with default 0.1.0 or provided value)
|
||||
content = content.replaceAll(
|
||||
RegExp(r'^version:\s+[\d\.]+(\+\d+)?$', multiLine: true),
|
||||
'version: $appVersion',
|
||||
);
|
||||
|
||||
// Write back if content changed
|
||||
if (content != originalContent) {
|
||||
await file.writeAsString(content);
|
||||
print(' ✓ Updated ${file.path}');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
print(' ⚠️ Error updating ${file.path}: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _processFile(File file) async {
|
||||
try {
|
||||
String content = await file.readAsString();
|
||||
String originalContent = content;
|
||||
|
||||
// Perform exact string replacements - APP ID FIRST!
|
||||
content = content.replaceAll(kOriginalAppId, appId);
|
||||
|
||||
// Use PascalCase for Dart code identifiers (no spaces)
|
||||
content = content.replaceAll(kOriginalAppName, appNamePascalCase);
|
||||
content = content.replaceAll(kOriginalAppNameSnakeCase, appNameSnakeCase);
|
||||
|
||||
// For display names in platform files, we might want the full name with spaces
|
||||
// Check if this is a platform-specific file that needs display names
|
||||
if (_shouldUseDisplayName(file.path)) {
|
||||
// In platform files, replace PascalCase back with display name for labels
|
||||
content = content.replaceAll(appNamePascalCase, appName);
|
||||
}
|
||||
|
||||
content = content.replaceAll('com.example', appName);
|
||||
|
||||
// Write back if content changed
|
||||
if (content != originalContent) {
|
||||
await file.writeAsString(content);
|
||||
print(' ✓ Updated ${file.path}');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
// Skip binary files or files we can't read/write
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines if a file should use the display name (with spaces) instead of PascalCase
|
||||
/// Platform files like AndroidManifest.xml and Info.plist need display names for labels
|
||||
bool _shouldUseDisplayName(String filePath) {
|
||||
return filePath.contains('AndroidManifest.xml') ||
|
||||
filePath.contains('Info.plist') ||
|
||||
filePath.contains('strings.xml') ||
|
||||
filePath.contains('.xml') && filePath.contains('android') ||
|
||||
filePath.endsWith('.plist');
|
||||
}
|
||||
|
||||
bool _hasIconPaths() {
|
||||
return iconPath != null ||
|
||||
adaptiveBackground != null ||
|
||||
adaptiveForeground != null ||
|
||||
adaptiveMonochrome != null ||
|
||||
notificationIcon != null;
|
||||
}
|
||||
|
||||
Future<void> _generateIcons() async {
|
||||
print('🎨 Generating app icons...');
|
||||
|
||||
final pubspecFile = File('pubspec.yaml');
|
||||
if (!pubspecFile.existsSync()) {
|
||||
throw Exception('pubspec.yaml not found');
|
||||
}
|
||||
|
||||
String originalContent = await pubspecFile.readAsString();
|
||||
String modifiedContent = originalContent;
|
||||
|
||||
try {
|
||||
// Add icons_launcher configuration to pubspec.yaml
|
||||
final iconsConfig = _buildIconsLauncherConfig();
|
||||
modifiedContent = '$originalContent\n$iconsConfig';
|
||||
await pubspecFile.writeAsString(modifiedContent);
|
||||
|
||||
// Run icons_launcher
|
||||
final result = await Process.run('dart', [
|
||||
'run',
|
||||
'icons_launcher:create',
|
||||
]);
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
print('✅ Icons generated successfully');
|
||||
if (result.stdout.toString().isNotEmpty) {
|
||||
print('Icons output: ${result.stdout}');
|
||||
}
|
||||
} else {
|
||||
print('❌ Failed to generate icons');
|
||||
print('Error: ${result.stderr}');
|
||||
throw Exception('Icon generation failed: ${result.stderr}');
|
||||
}
|
||||
} finally {
|
||||
// Restore original pubspec.yaml
|
||||
await pubspecFile.writeAsString(originalContent);
|
||||
}
|
||||
}
|
||||
|
||||
String _buildIconsLauncherConfig() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('icons_launcher:');
|
||||
|
||||
// Main icon path (required by icons_launcher)
|
||||
final mainIcon = iconPath ?? adaptiveForeground ?? adaptiveBackground;
|
||||
if (mainIcon != null) {
|
||||
buffer.writeln(' image_path: "$mainIcon"');
|
||||
}
|
||||
|
||||
buffer.writeln(' platforms:');
|
||||
|
||||
// Android configuration
|
||||
buffer.writeln(' android:');
|
||||
buffer.writeln(' enable: true');
|
||||
if (iconPath != null) {
|
||||
buffer.writeln(' image_path: "$iconPath"');
|
||||
}
|
||||
if (notificationIcon != null) {
|
||||
buffer.writeln(' notification_image: "$notificationIcon"');
|
||||
} else if (iconPath != null) {
|
||||
buffer.writeln(' notification_image: "$iconPath"');
|
||||
}
|
||||
if (adaptiveBackground != null) {
|
||||
buffer.writeln(' adaptive_background_image: "$adaptiveBackground"');
|
||||
}
|
||||
if (adaptiveForeground != null) {
|
||||
buffer.writeln(' adaptive_foreground_image: "$adaptiveForeground"');
|
||||
}
|
||||
if (adaptiveMonochrome != null) {
|
||||
buffer.writeln(' adaptive_monochrome_image: "$adaptiveMonochrome"');
|
||||
}
|
||||
|
||||
// iOS configuration
|
||||
buffer.writeln(' ios:');
|
||||
buffer.writeln(' enable: true');
|
||||
if (iconPath != null) {
|
||||
buffer.writeln(' image_path: "$iconPath"');
|
||||
}
|
||||
|
||||
// macOS configuration
|
||||
buffer.writeln(' macos:');
|
||||
buffer.writeln(' enable: true');
|
||||
if (iconPath != null) {
|
||||
buffer.writeln(' image_path: "$iconPath"');
|
||||
}
|
||||
|
||||
// Windows configuration
|
||||
buffer.writeln(' windows:');
|
||||
buffer.writeln(' enable: true');
|
||||
if (iconPath != null) {
|
||||
buffer.writeln(' image_path: "$iconPath"');
|
||||
}
|
||||
|
||||
// Linux configuration
|
||||
buffer.writeln(' linux:');
|
||||
buffer.writeln(' enable: true');
|
||||
if (iconPath != null) {
|
||||
buffer.writeln(' image_path: "$iconPath"');
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanAndGetDependencies() async {
|
||||
print('\n🧹 Cleaning pub cache and getting dependencies...');
|
||||
|
||||
try {
|
||||
// Check if we have flutter command available
|
||||
print('Running flutter clean...');
|
||||
final cleanResult = await Process.run('flutter', ['clean']);
|
||||
if (cleanResult.exitCode != 0) {
|
||||
print('⚠️ Flutter clean failed, but continuing...');
|
||||
}
|
||||
|
||||
print('Running dart pub cache clean...');
|
||||
final cacheResult = await Process.run('dart', ['pub', 'cache', 'clean']);
|
||||
if (cacheResult.exitCode != 0) {
|
||||
print('⚠️ Pub cache clean failed, but continuing...');
|
||||
}
|
||||
|
||||
print('Running flutter pub get...');
|
||||
final pubGetResult = await Process.run('flutter', ['pub', 'get']);
|
||||
if (pubGetResult.exitCode != 0) {
|
||||
print('❌ Flutter pub get failed:');
|
||||
print(pubGetResult.stderr);
|
||||
throw Exception('Failed to run flutter pub get');
|
||||
}
|
||||
|
||||
print('✓ Dependencies updated successfully');
|
||||
} catch (e) {
|
||||
print('⚠️ Error cleaning dependencies: $e');
|
||||
print('Please run the following commands manually:');
|
||||
print(' flutter clean');
|
||||
print(' dart pub cache clean');
|
||||
print(' flutter pub get');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run the purplestack MCP server
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
(fvm flutter pub get || flutter pub get) && dart run "$SCRIPT_DIR/purplestack_mcp.dart"
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
UPSTREAM_URL="https://github.com/purplebase/purplestack.git"
|
||||
UPSTREAM_BRANCH="main"
|
||||
UPSTREAM_LOCAL_DIR=/tmp/purplestack-tmp
|
||||
FOLDER="tools"
|
||||
|
||||
# 1. shallow clone upstream into a hidden mirror
|
||||
if [ ! -d "$UPSTREAM_LOCAL_DIR" ]; then
|
||||
git clone --depth 1 --branch "$UPSTREAM_BRANCH" "$UPSTREAM_URL" "$UPSTREAM_LOCAL_DIR"
|
||||
fi
|
||||
|
||||
# 2. fetch latest
|
||||
git -C "$UPSTREAM_LOCAL_DIR" fetch --depth 1 origin "$UPSTREAM_BRANCH"
|
||||
git -C "$UPSTREAM_LOCAL_DIR" reset --hard "origin/$UPSTREAM_BRANCH"
|
||||
|
||||
# 3. copy only the folder we care about
|
||||
rm -rf "$FOLDER"
|
||||
cp -a "$UPSTREAM_LOCAL_DIR/$FOLDER" "$(dirname "$FOLDER")"
|
||||
|
||||
fvm flutter pub upgrade || flutter pub upgrade
|
||||
@@ -0,0 +1,91 @@
|
||||
# WORK-XXX — Short Name
|
||||
|
||||
**Feature:** FEAT-XXX-short-name.md
|
||||
**Status:** In Progress | Complete
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] 1. Task description
|
||||
- Files: `lib/path/to/file.dart`
|
||||
- Notes: any relevant context
|
||||
- [ ] 2. Task description
|
||||
- [ ] 3. Handle edge cases per spec
|
||||
- [ ] 4. Self-review against INVARIANTS.md
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Scenario | Expected | Status |
|
||||
|----------|----------|--------|
|
||||
| Happy path | Describe expected behavior | [ ] |
|
||||
| Edge case: network failure | Graceful degradation | [ ] |
|
||||
| Edge case: cancellation | Clean cleanup | [ ] |
|
||||
|
||||
## Decisions
|
||||
|
||||
### YYYY-MM-DD — Decision title
|
||||
|
||||
**Context:** Why this decision came up.
|
||||
**Options:** A, B, C considered.
|
||||
**Decision:** Chosen option.
|
||||
**Rationale:** Why.
|
||||
|
||||
## Spec Issues
|
||||
|
||||
Report blockers here instead of guessing. Format:
|
||||
|
||||
- **Issue:** Description of unclear/incorrect spec
|
||||
- **Question:** What clarification is needed
|
||||
|
||||
## Progress Notes
|
||||
|
||||
Brief updates as work proceeds.
|
||||
|
||||
---
|
||||
|
||||
# Example: WORK-002 — NWC Zaps
|
||||
|
||||
**Feature:** FEAT-002-nwc-zaps.md
|
||||
**Status:** In Progress
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Add nwc_wallet package dependency
|
||||
- [x] 2. Create NwcService in lib/services/
|
||||
- Files: `lib/services/nwc_service.dart`
|
||||
- Stores connection string in SecureStorageService
|
||||
- [x] 3. Add NWC settings UI in profile screen
|
||||
- Files: `lib/screens/profile_screen.dart`, `lib/widgets/nwc_widgets.dart`
|
||||
- [ ] 4. Create ZapButton widget
|
||||
- Files: `lib/widgets/zap_button.dart`
|
||||
- [ ] 5. Create ZapDialog for amount selection
|
||||
- [ ] 6. Integrate into AppDetailScreen
|
||||
- [ ] 7. Implement zap request flow (kind 9734)
|
||||
- [ ] 8. Self-review against INVARIANTS.md
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Scenario | Expected | Status |
|
||||
|----------|----------|--------|
|
||||
| Connect valid NWC | Connection saved, status updated | [x] |
|
||||
| Connect invalid NWC | Error shown, nothing saved | [x] |
|
||||
| Zap with sufficient balance | Success toast | [ ] |
|
||||
| Zap with insufficient balance | Wallet error shown | [ ] |
|
||||
| Developer has no LN address | Button disabled with tooltip | [ ] |
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-01-15 — NWC string storage
|
||||
|
||||
**Context:** Need to persist NWC connection string securely.
|
||||
**Options:** New encrypted file, SecureStorageService, Nostr event.
|
||||
**Decision:** SecureStorageService.
|
||||
**Rationale:** Already used for nsec, platform-native secure storage.
|
||||
|
||||
## Spec Issues
|
||||
|
||||
_None_
|
||||
|
||||
## Progress Notes
|
||||
|
||||
**2026-01-15:** Completed NWC connection flow. Reused SecureStorageService.
|
||||
**2026-01-17:** ZapButton done. Added edge case for missing LN address to test matrix.
|
||||
Reference in New Issue
Block a user