mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48: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
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
archive:
|
archive:
|
||||||
dependency: "direct dev"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
@@ -59,7 +59,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.7"
|
version: "4.0.7"
|
||||||
args:
|
args:
|
||||||
dependency: "direct dev"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: args
|
name: args
|
||||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
@@ -114,14 +114,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.0"
|
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:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -589,26 +581,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -657,22 +649,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
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:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1157,10 +1141,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.7"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1277,10 +1261,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ dev_dependencies:
|
|||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^5.0.0
|
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
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# 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