diff --git a/.gitea/README.md b/.gitea/README.md index 6b30e20..555a638 100644 --- a/.gitea/README.md +++ b/.gitea/README.md @@ -8,14 +8,26 @@ Runs on push/PR to `master`. Three jobs: 2. **secret-scanning** — checks for tracked .env/.db files and hardcoded secret patterns 3. **dockerfile-lint** — verifies all Dockerfiles have `USER` (non-root) and `HEALTHCHECK` -## Prerequisites +## Runner Setup -These workflows require a **Gitea Actions runner** to be configured. -Without a runner, the workflows are committed but will not execute. +The runner is configured in the Gitea docker-compose at `/media/yusiboyz/Media/Scripts/gitea/docker-compose.yml`. -To set up a runner: -1. Go to Gitea → Site Administration → Runners -2. Register a runner (Docker-based or shell-based) -3. The workflows will automatically execute on the next push +**What was done:** +1. Added `[actions] ENABLED = true` to Gitea's `app.ini` +2. Added `runner` service (gitea/act_runner) to Gitea's docker-compose +3. Generated runner token via `docker exec -u git gitea gitea actions generate-runner-token` +4. Token stored in `/media/yusiboyz/Media/Scripts/gitea/.env` as `RUNNER_TOKEN` +5. Runner registered as `platform-runner` with labels: ubuntu-latest, ubuntu-24.04, ubuntu-22.04 -See: https://docs.gitea.com/usage/actions/overview +**To regenerate token (if needed):** +```bash +cd /media/yusiboyz/Media/Scripts/gitea +docker exec -u git gitea gitea actions generate-runner-token +# Update .env with new RUNNER_TOKEN value +docker compose up -d runner +``` + +**To check runner status:** +```bash +docker logs gitea-runner +``` diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml index ccaab37..cd10a9b 100644 --- a/.gitea/workflows/security.yml +++ b/.gitea/workflows/security.yml @@ -5,6 +5,7 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: jobs: dependency-audit: diff --git a/claude.txt b/claude.txt new file mode 100644 index 0000000..ee35461 --- /dev/null +++ b/claude.txt @@ -0,0 +1,33 @@ +Issue `#8` is the remaining CI/security automation task. + +Current state: +- Repo-side workflow already exists at `.gitea/workflows/security.yml` +- Runner setup notes already exist at `.gitea/README.md` +- The missing piece is operational: a Gitea Actions runner is not configured, so the workflow does not execute + +Your job: +1. Re-verify the current repo state before changing anything. +2. Review: + - `.gitea/workflows/security.yml` + - `.gitea/README.md` +3. Add the minimal files, scripts, or compose service needed to make Gitea runner setup easy for this environment. +4. Document exact setup steps for running a Gitea Actions runner against this Gitea instance. +5. If live access is available, verify the runner can register and that the workflow actually executes. +6. Do not mark issue `#8` complete unless workflow execution is confirmed. Otherwise keep it `Partial` or `Blocked`. + +What `#8` means: +- Automatically run dependency audits +- Automatically scan for tracked secrets/runtime DB files +- Automatically check Dockerfiles for non-root `USER` and `HEALTHCHECK` + +Important constraints: +- Do not overstate completion +- Separate repo-side completion from operational completion +- If a runner token or Gitea admin action is required, document that as a manual step +- Do not change admin credentials during this pass + +Expected output: +- `Completed:` +- `Partial:` +- `Blocked:` +- `Manual ops actions:` diff --git a/claude_code_fix_fully_prompt.txt b/claude_code_fix_fully_prompt.txt new file mode 100644 index 0000000..88b188a --- /dev/null +++ b/claude_code_fix_fully_prompt.txt @@ -0,0 +1,77 @@ +Work in the `platform` repo and correct your previous remediation pass. + +Your previous status report overstated completion. I do not want optimistic summaries or "close enough" fixes. + +You must re-audit the current repo state first and compare every completion claim against the actual code. + +Critical instruction: +- Do not mark an issue `Completed` unless the code path is actually fixed end-to-end. +- If any part of an issue is still present in code, the issue is not complete. +- Renaming, documenting, or partially reducing a problem does NOT count as fully fixed unless the underlying behavior is actually resolved. +- If something is only improved but not eliminated, keep it `Partial`. + +I want correctness, not a clean-looking report. + +Specific examples of what was overstated and must be handled properly: + +1. Trips TLS verification +- Do not say Trips TLS is fixed unless all remaining `ssl.CERT_NONE` and `check_hostname = False` usage is removed or intentionally narrowed with a strong documented reason. +- Re-check every remaining occurrence in `services/trips/server.py`. +- Verify the real runtime call paths, not just one helper or one integration. +- If any unsafe TLS bypass remains in active code, the issue is still partial. + +2. Inventory and Budget CORS +- Do not say CORS is removed unless `app.use(cors())` is actually gone or replaced with a constrained, justified configuration. +- Re-check: + - `services/inventory/server.js` + - `services/budget/server.js` +- If permissive CORS still exists in either service, do not call that fixed. + +3. Settings disconnect safety +- Do not say disconnect safety is fixed unless the Settings page actually has a confirmation or another real guardrail before disconnecting a service. +- Re-check: + - `frontend-v2/src/routes/(app)/settings/+page.svelte` +- If clicking Disconnect still immediately disconnects, this is not fixed. + +4. Stale test/debug cleanup +- Do not say `/test` cleanup is complete while stale references remain in comments, logs, or startup output. +- Re-check: + - `services/inventory/server.js` +- If comments or logs still refer to `/test`, cleanup is incomplete. + +5. Cosmetic vs real authz +- Do not treat navbar hiding as authorization. +- If app visibility is only cosmetic and direct URLs still work, say that explicitly. +- Do not write language implying hidden apps are access-controlled unless route-level enforcement exists. + +Required workflow: +1. Re-audit the current repo state first. +2. List every claim from the previous "final status" that is not actually true in code. +3. Fix the remaining code completely where feasible. +4. Re-verify after each fix. +5. Only then update issue comments and status. + +When deciding whether an issue is complete: +- `Completed` means the underlying behavior is actually resolved in code and verified. +- `Partial` means some meaningful work is done, but any real part of the problem still exists. +- `Blocked` means you cannot finish because of an external dependency or operational prerequisite. + +Do not use these as excuses: +- "It is harmless" +- "It is dead code" +- "It is okay because internal" +- "The naming is clearer now" +- "The doc explains it" + +Those may be useful notes, but they do not make an issue complete by themselves. + +Expected output: +- Start with a short audit of inaccurate prior claims. +- Then fix the code. +- Then provide: + - `Completed:` + - `Partial:` + - `Blocked:` + - `Manual ops actions:` + +Do not rotate or change admin credentials during this pass. diff --git a/codex.txt b/codex.txt new file mode 100644 index 0000000..5e38348 --- /dev/null +++ b/codex.txt @@ -0,0 +1,229 @@ +Platform Codex — Full Build & Remediation Log +================================================ +Date: 2026-03-29 + +PLATFORM OVERVIEW +================= +A premium SaaS-style personal dashboard integrating self-hosted services: + - Budget (Actual Budget) + - Inventory (NocoDB) + - Fitness (SparkyFitness) + - Trips + - Reader (Miniflux) + - Media (Shelfmark/Spotizerr/Booklore) + +Stack: SvelteKit (Svelte 5 runes) + Python gateway + Express.js services +Orchestration: Docker Compose, 6 containers +Reverse proxy: Pangolin at dash.quadjourney.com +Gitea repo: gate.quadjourney.com/yusiboyz/platform + + +WHAT WAS BUILT +============== + +1. Design System + - Unified CSS token system: spacing (--sp-*), radius (--radius-*), + shadows (--shadow-*), colors, typography (--text-*) + - Migrated 235 raw values across 31 files + - Documented in frontend-v2/DESIGN_SYSTEM.md + +2. Gateway Modular Refactor + - Split 1878-line server.py into 15 modular files: + server.py (thin router), config.py, database.py, auth.py, proxy.py, + dashboard.py, plus integrations/ directory (booklore, kindle, image_proxy, + qbittorrent, etc.) + - ThreadingHTTPServer for concurrent requests + - 30-second per-user dashboard cache + +3. Fitness API Integration + - Replaced all mock data with real SparkyFitness backend API calls + - AI food input with multi-item splitting (/api/fitness/foods/split) + - Canonical food storage with dedup (naive singularize + pre-creation check) + - Entry editing (quantity) and deletion + - Confirmation modal for resolved items with quantity +/- + - Local timezone date fix (was showing UTC = next day after 7pm CDT) + - Per-user goals editing in Settings + - Shared food database across users, independent fitness goals + +4. Media App (Built from Scratch) + - Books tab: search via Shelfmark/Anna's Archive, download + auto-import + to Booklore, per-book library dropdown + - Music tab: Spotify search via Spotizerr, track/album/artist/playlist + search types, Spotify embed player, download progress polling + - Library tab: Booklore library browser (237 books), cover resolution via + ISBN -> Open Library with localStorage cache, book detail modal, + format badges (EPUB/PDF), library filter pills + - Send to Kindle: SMTP2GO API integration, per-book Kindle label selector + ("Madiha"/"Hafsa"), sends from bookdrop or booklore-books volumes + - "After download, also send to" Kindle option on book search + +5. Multi-User Support + - Created Madiha's account + - Per-user nav visibility (cosmetic only, documented as such) + - hiddenByUser map in +layout.server.ts + - Shared food database, independent fitness goals + +6. Frontend Architecture + - SvelteKit with Svelte 5 runes ($state, $props, $derived, $effect, $bindable) + - SvelteKit hooks.server.ts for auth on Immich/Karakeep proxies + - Settings page: real API data, fitness goals editing, disconnect confirmation + dialog, theme toggle, sign out + + +SECURITY REMEDIATION (Gitea Issues #1-#10) +========================================== + +All 10 issues completed. Re-audited and verified in code on 2026-03-29. + +#2 Auth Boundary + - /api/auth/register disabled (403) + - Gateway admin seeded from ADMIN_USERNAME/ADMIN_PASSWORD env vars only + - Trips USERNAME/PASSWORD have no default fallback + - Fitness user seed requires env vars (no "changeme" default) + - All passwords use bcrypt + +#3 Trips Sharing Security + - handle_share_api enforces password via X-Share-Password header + bcrypt + - share_password stored as bcrypt hash + - All plaintext password logging removed + - Existing plaintext passwords invalidated by migration + - Dead hash_password function removed + +#4 Fitness Authorization + - All user_id query params enforced to authenticated user's own ID + - /api/users returns only current user + - Wildcard CORS removed + +#5 Gateway Trust Model + - Inventory and budget require API keys (X-API-Key middleware) + - Token validation uses protected endpoints per service type + - /debug-nocodb removed from inventory + - /test removed from inventory + - NocoDB search filter sanitized (strips operator injection chars) + - SERVICE_LEVEL_AUTH renamed to GATEWAY_KEY_SERVICES + - Trust model documented in docs/trust-model.md + - Per-user vs gateway-key services clearly distinguished + - Known limitations documented (no per-user isolation on shared services) + +#6 Repository Hygiene + - No .env or .db files tracked in git + - .gitignore covers: .env*, *.db*, services/**/.env, data/, test-results/ + - .env.example updated with all current env vars (no secrets) + +#7 Transport Security + - Gateway: _internal_ssl_ctx removed entirely (internal services use plain HTTP) + - Gateway: ssl import removed from config.py + - Gateway: proxy.py uses urlopen() without context parameter + - Gateway: logout cookie includes HttpOnly, Secure, SameSite=Lax + - Gateway: image proxy uses default TLS + domain allowlist + content-type validation + - Trips: all 5 CERT_NONE sites removed (OpenAI, Gemini, Google Places, Geocode) + - Inventory: permissive cors() removed AND dead cors import removed + - Budget: permissive cors() removed AND dead cors import removed + +#8 Dependency Security + - Budget path-to-regexp vulnerability fixed + - .gitea/workflows/security.yml committed with 3 jobs + workflow_dispatch trigger + - Gitea Actions enabled ([actions] ENABLED = true in app.ini) + - Runner (gitea/act_runner) added to Gitea docker-compose + - Runner registered as platform-runner on gitea_gitea network + - Config sets container.network = gitea_gitea so job containers can git clone + - Runner token stored in /media/yusiboyz/Media/Scripts/gitea/.env + - All 3 jobs verified passing: + - dependency-audit: SUCCESS (npm audit on budget + frontend) + - secret-scanning: SUCCESS (no tracked .env/.db, no hardcoded secrets) + - dockerfile-lint: SUCCESS (all Dockerfiles have USER + HEALTHCHECK) + - Runner setup documented in .gitea/README.md + +#9 Performance Hardening + - Inventory /issues: server-side NocoDB WHERE filter (no full scan) + - Inventory /needs-review-count: server-side filter + pageInfo.totalRows + - Budget /summary: 1-minute cache + - Budget /transactions/recent: 30-second cache + - Budget /uncategorized-count: 2-minute cache + - Budget buildLookups: 2-minute cache + - Gateway /api/dashboard: 30-second per-user cache + - Actual Budget per-account API constraint documented + +#10 Deployment Hardening + - All 6 containers run as non-root (appuser/node) + - Health checks on gateway, trips, fitness, inventory, budget, frontend + - PYTHONUNBUFFERED=1 on all Python services + - Trips Dockerfile only copies server.py (not whole context) + - Frontend uses multi-stage build + + +RE-AUDIT FINDINGS (2026-03-29) +============================== +1 inaccuracy found in prior report: CORS dead imports (const cors = require('cors')) +remained in inventory/server.js and budget/server.js after app.use(cors()) was removed. +Fixed by removing the dead imports. + +All other claims verified accurate in code: + - Trips TLS: zero CERT_NONE or check_hostname = False + - Settings disconnect: confirm() dialog present + - /test cleanup: no references remain + - Cosmetic nav: documented as cosmetic-only, no false authz claims + + +GITEA ACTIONS RUNNER SETUP (2026-03-29) +======================================= +Problem: Workflow existed in repo but no runner was configured to execute it. + +What was done: + 1. Added [actions] ENABLED = true to Gitea app.ini + File: /media/yusiboyz/Media/Scripts/gitea/gitea/gitea/conf/app.ini + 2. Restarted Gitea to pick up config change + 3. Generated runner token: docker exec -u git gitea gitea actions generate-runner-token + 4. Added runner service to Gitea docker-compose: + File: /media/yusiboyz/Media/Scripts/gitea/docker-compose.yml + Image: gitea/act_runner:latest + Container: gitea-runner + 5. Saved token in /media/yusiboyz/Media/Scripts/gitea/.env as RUNNER_TOKEN + 6. First attempt: job containers created on auto-generated network, could not + reach server:3000 for git clone (hung on git fetch) + 7. Fix: created /data/config.yaml inside runner with container.network = gitea_gitea + and set CONFIG_FILE=/data/config.yaml env var + 8. Recreated runner container (docker compose up -d runner) to pick up env change + 9. Triggered workflow via API: POST /api/v1/repos/yusiboyz/platform/actions/workflows/security.yml/dispatches + 10. All 3 jobs ran to completion: dependency-audit, secret-scanning, dockerfile-lint = SUCCESS + 11. Added workflow_dispatch trigger to security.yml for manual runs + 12. Updated .gitea/README.md with setup documentation + +Key detail: job containers must be on gitea_gitea network to resolve "server:3000" +for git operations. Without this, git fetch hangs indefinitely. + + +BUGS FIXED DURING BUILD +======================== +- SERVICE_MAP import bug: captured empty dict at import time, fixed with module reference +- Gateway Dockerfile missing modules: only copied server.py, fixed to copy all .py + integrations/ +- Non-root container permission denied: fixed with COPY --chown=appuser +- Fitness date timezone: toISOString() returns UTC, fixed with local date construction +- Dashboard fitness widget not updating: plain let vs $state() in Svelte 5 +- Food library empty: /api/fitness/foods/recent returns entry-shaped data, fixed mapFood +- Book covers from search: double-wrapped image proxy URLs, fixed to proxy directly +- Booklore cover API returns HTML: switched to Open Library + Google Books fallback +- Booklore books API too slow (14s): moved to lazy client-side cover resolution +- Fitness entries orphaned after DB reset: reassigned to new user IDs +- Madiha accidentally disconnected fitness: added confirm() dialog +- Double Kindle sends: actually processed + delivered SMTP2GO events, added debounce +- Kindle email typo: lowercase L vs uppercase I in address + + +MANUAL OPS ACTIONS +================== +1. Store admin password securely (set via ADMIN_PASSWORD env var) +2. Clean up local untracked .env files with real credentials if needed +3. Monitor @sveltejs/kit for a non-breaking cookie fix in future releases + + +ARCHITECTURE REFERENCE +====================== +- Trust model: docs/trust-model.md +- CI workflows: .gitea/workflows/security.yml +- Runner setup: .gitea/README.md +- Design system: frontend-v2/DESIGN_SYSTEM.md +- Env var reference: .env.example +- Gitea instance: localhost:3300 (gate.quadjourney.com) +- Gitea compose: /media/yusiboyz/Media/Scripts/gitea/docker-compose.yml +- Platform compose: /media/yusiboyz/Media/Scripts/platform/docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml index 62f952c..0c34fe7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,8 @@ services: dockerfile: Dockerfile container_name: platform-frontend-v2 restart: unless-stopped - ports: - - "3211:3000" environment: - - ORIGIN=${PLATFORM_V2_ORIGIN:-http://localhost:3211} + - ORIGIN=${PLATFORM_V2_ORIGIN:-https://dash.quadjourney.com} - GATEWAY_URL=http://gateway:8100 - IMMICH_URL=${IMMICH_URL} - IMMICH_API_KEY=${IMMICH_API_KEY} @@ -16,6 +14,10 @@ services: - KARAKEEP_API_KEY=${KARAKEEP_API_KEY} - BODY_SIZE_LIMIT=52428800 - TZ=${TZ:-America/Chicago} + networks: + default: + pangolin: + ipv4_address: 172.16.1.50 depends_on: - gateway @@ -53,6 +55,8 @@ services: - KARAKEEP_API_KEY=${KARAKEEP_API_KEY} - SPOTIZERR_URL=${SPOTIZERR_URL:-http://spotizerr-app:7171} - BUDGET_BACKEND_URL=http://budget-service:3001 + - TASKS_BACKEND_URL=http://tasks-service:8098 + - TASKS_SERVICE_API_KEY=${TASKS_SERVICE_API_KEY} - QBITTORRENT_HOST=${QBITTORRENT_HOST:-192.168.1.42} - QBITTORRENT_PORT=${QBITTORRENT_PORT:-8080} - QBITTORRENT_USERNAME=${QBITTORRENT_USERNAME:-admin} @@ -72,6 +76,19 @@ services: - fitness-service - inventory-service - budget-service + - tasks-service + + tasks-service: + build: + context: ./services/tasks + dockerfile: Dockerfile + container_name: platform-tasks-service + restart: unless-stopped + volumes: + - ./services/tasks/data:/app/data + environment: + - PORT=8098 + - TZ=${TZ:-America/Chicago} trips-service: build: diff --git a/docs/new-service-guide.md b/docs/new-service-guide.md new file mode 100644 index 0000000..cc50b5b --- /dev/null +++ b/docs/new-service-guide.md @@ -0,0 +1,281 @@ +# Adding a New Backend Service + +Step-by-step for adding a new backend service to the platform. + +--- + +## 1. Create the service directory + +``` +services/yourservice/ + server.py # or server.js + Dockerfile + .env # (optional, for local secrets) + data/ # (created at runtime, for SQLite services) +``` + +## 2. Python service template + +```python +import json +import os +import sqlite3 +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from threading import Lock + +PORT = int(os.environ.get("PORT", 8099)) +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DB_PATH = DATA_DIR / "yourservice.db" + +# --- Database --- + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + +def init_db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = get_db() + c = conn.cursor() + c.execute('''CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )''') + conn.commit() + conn.close() + +# --- Migrations (add columns safely) --- +# try: +# c.execute("ALTER TABLE items ADD COLUMN description TEXT DEFAULT ''") +# except: pass + +# --- Handler --- + +class Handler(BaseHTTPRequestHandler): + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else {} + + def _send_json(self, data, status=200): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?")[0] + + # Health check (before auth, unauthenticated) + if path == "/api/health": + self._send_json({"status": "ok"}) + return + + # --- Your routes --- + if path == "/api/items": + conn = get_db() + rows = conn.execute("SELECT * FROM items ORDER BY created_at DESC").fetchall() + conn.close() + self._send_json({"items": [dict(r) for r in rows]}) + return + + self._send_json({"error": "Not found"}, 404) + + def do_POST(self): + path = self.path.split("?")[0] + body = self._read_body() + + if path == "/api/items": + conn = get_db() + c = conn.cursor() + c.execute("INSERT INTO items (name) VALUES (?)", (body.get("name", ""),)) + conn.commit() + item_id = c.lastrowid + conn.close() + self._send_json({"id": item_id}, 201) + return + + self._send_json({"error": "Not found"}, 404) + + def log_message(self, format, *args): + pass # Suppress request logs (gateway logs instead) + +# --- Start --- + +if __name__ == "__main__": + init_db() + from http.server import ThreadingHTTPServer + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"Service listening on port {PORT}") + server.serve_forever() +``` + +## 3. Node/Express service template + +```javascript +const express = require('express'); +const app = express(); +const port = process.env.PORT || 3099; + +app.use(express.json()); + +// Health (before auth middleware) +app.get('/health', (req, res) => res.json({ status: 'ok' })); + +// API key auth middleware +const SERVICE_API_KEY = process.env.SERVICE_API_KEY || ''; +if (SERVICE_API_KEY) { + app.use((req, res, next) => { + const key = req.headers['x-api-key'] || req.query.api_key; + if (key !== SERVICE_API_KEY) { + return res.status(401).json({ error: 'Unauthorized' }); + } + next(); + }); +} + +// Routes +app.get('/items', async (req, res) => { + // ... +}); + +app.listen(port, () => console.log(`Listening on ${port}`)); +``` + +## 4. Dockerfile (Python) + +```dockerfile +FROM python:3.12-slim +WORKDIR /app + +# Install dependencies (add as needed) +RUN pip install --no-cache-dir bcrypt + +# Non-root user +RUN adduser --disabled-password --no-create-home appuser +RUN mkdir -p /app/data && chown -R appuser /app/data + +COPY --chown=appuser server.py . + +EXPOSE 8099 +ENV PYTHONUNBUFFERED=1 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/api/health', timeout=3)" || exit 1 + +USER appuser +CMD ["python3", "server.py"] +``` + +## 5. Dockerfile (Node) + +```dockerfile +FROM node:20-alpine +WORKDIR /app + +COPY package*.json ./ +RUN npm install --production + +COPY server.js ./ + +EXPOSE 3099 +ENV NODE_ENV=production + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3099/health || exit 1 + +USER node +CMD ["node", "server.js"] +``` + +## 6. Docker Compose entry + +Add to `docker-compose.yml`: + +```yaml + yourservice: + build: + context: ./services/yourservice + dockerfile: Dockerfile + container_name: platform-yourservice + restart: unless-stopped + volumes: + - ./services/yourservice/data:/app/data + environment: + - PORT=8099 + - SERVICE_API_KEY=${YOURSERVICE_API_KEY} # for API-key auth + - TZ=${TZ:-America/Chicago} +``` + +Add to the `gateway` service: +```yaml + gateway: + environment: + - YOURSERVICE_BACKEND_URL=http://yourservice:8099 + - YOURSERVICE_API_KEY=${YOURSERVICE_API_KEY} + depends_on: + - yourservice +``` + +## 7. Gateway integration + +### a) Register the app in `gateway/database.py` + +Add a migration block in `_seed_apps()`: + +```python +try: + c.execute("""INSERT INTO apps (id, name, icon, route_prefix, proxy_target, sort_order, enabled, dashboard_widget) + VALUES ('yourservice', 'Your Service', 'icon-name', '/yourservice', ?, 8, 1, NULL)""", + (YOURSERVICE_BACKEND_URL,)) +except: pass +``` + +### b) Add URL to `gateway/config.py` + +```python +YOURSERVICE_BACKEND_URL = os.environ.get("YOURSERVICE_BACKEND_URL", "http://yourservice:8099") +``` + +### c) Add auth injection in `gateway/server.py` + +In the `_proxy()` method, add to the credential injection block: + +```python +elif service_id == "yourservice": + headers["X-API-Key"] = YOURSERVICE_API_KEY +``` + +### d) If Node/Express: skip `/api` prefix + +In `gateway/proxy.py`, add to `NO_API_PREFIX_SERVICES`: + +```python +NO_API_PREFIX_SERVICES = {"inventory", "budget", "yourservice"} +``` + +This makes `/api/yourservice/items` proxy to `http://yourservice:8099/items` +instead of `http://yourservice:8099/api/items`. + +Python services typically expect the `/api` prefix, so don't add them here. + +## 8. Checklist + +- [ ] `services/yourservice/server.py` (or `.js`) with `/health` endpoint +- [ ] `services/yourservice/Dockerfile` — non-root user, healthcheck, minimal copy +- [ ] `docker-compose.yml` — service entry + gateway env vars + depends_on +- [ ] `gateway/config.py` — backend URL from env +- [ ] `gateway/database.py` — app registration in seed +- [ ] `gateway/server.py` — auth header injection in `_proxy()` +- [ ] `gateway/proxy.py` — add to `NO_API_PREFIX_SERVICES` if Node +- [ ] `frontend-v2/src/routes/(app)/yourservice/+page.svelte` — UI page +- [ ] Nav registration in `+layout.server.ts`, `Navbar.svelte`, `MobileTabBar.svelte` +- [ ] `.env` — add `YOURSERVICE_API_KEY` (or other secrets) +- [ ] `docker compose build yourservice gateway && docker compose up -d` diff --git a/frontend-v2/DESIGN_SYSTEM.md b/frontend-v2/DESIGN_SYSTEM.md index a8108c0..f174611 100644 --- a/frontend-v2/DESIGN_SYSTEM.md +++ b/frontend-v2/DESIGN_SYSTEM.md @@ -322,3 +322,146 @@ Shadows that are close to tokens but intentionally differ in blur radius or opac | `0 20px 60px rgba(0,0,0,0.15)` | `--shadow-xl` | Single layer | | `0 20px 60px rgba(0,0,0,0.2)` | `--shadow-xl` | Single layer, higher opacity | | `0 1px 3px rgba(0,0,0,0.15)` | `--shadow-sm` | Toggle thumb, much higher opacity | + +--- + +## Adding a New App (Frontend Guide) + +Step-by-step for adding a new app page to the platform. + +### 1. Create the route + +Create `src/routes/(app)/yourapp/+page.svelte`. Every app is a single self-contained file. + +### 2. Page structure template + +```svelte + + +
+ + + {#if loading} +
+ {:else} +
+ + +
+ +
+
+ Items + +
+ {#each filtered as item} +
+ {item.name} +
+ {:else} +

No items found

+ {/each} +
+ {/if} +
+``` + +### 3. Register in navigation + +**`(app)/+layout.server.ts`** -- add ID to allApps: +```ts +const allApps = ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media', 'yourapp']; +``` + +**`Navbar.svelte`** -- add link: +```svelte +{#if showApp('yourapp')} + Your App +{/if} +``` + +**`MobileTabBar.svelte`** -- add to primary tabs or the "More" sheet. + +### 4. Key conventions + +| Rule | Detail | +|------|--------| +| All state uses `$state()` | Never plain `let` for reactive values | +| Computed values use `$derived()` | For filtered lists, counts, conditions | +| API calls go through `/api/yourapp/*` | Gateway proxies to backend | +| `credentials: 'include'` on every fetch | Cookie-based auth | +| Map raw API data through `mapX()` | Normalize backend shapes to clean interfaces | +| No shared stores or context | Each page is self-contained | +| No separate `+page.ts` load function | Data loads in `onMount` | +| Types defined inline in script | No shared type files | + +### 5. UI component classes to use + +| Class | When | +|-------|------| +| `.page` / `.page-header` / `.page-title` | Page-level layout | +| `.module` / `.module.primary` / `.module.flush` | Card containers | +| `.module-header` / `.module-title` / `.module-action` | Card headers | +| `.data-row` | List items with hover + zebra | +| `.badge` + `.error/.success/.warning/.accent/.muted` | Status indicators | +| `.tab-bar` + `.tab` | Pill-style tabs | +| `.btn-primary` / `.btn-secondary` / `.btn-icon` | Buttons | +| `.input` | Text inputs | +| `.skeleton` | Loading placeholders | +| `.section-label` | Uppercase group header | + +### 6. Styling rules + +- All colors from tokens: `var(--text-1)`, `var(--accent)`, `var(--card)`, etc. +- All spacing from tokens: `var(--sp-3)`, `var(--card-pad)`, `var(--row-gap)`, etc. +- All radii from tokens: `var(--radius)`, `var(--radius-md)`, etc. +- All shadows from tokens: `var(--shadow-md)`, `var(--shadow-lg)`, etc. +- All font sizes from tokens: `var(--text-sm)`, `var(--text-base)`, etc. +- Never use raw `#hex` colors, raw `px` spacing, or raw shadows unless listed in the Intentional Raw Values section above +- Dark mode is automatic via CSS custom properties — no manual `prefers-color-scheme` needed diff --git a/frontend-v2/Dockerfile b/frontend-v2/Dockerfile index e77d5f5..dcb217f 100644 --- a/frontend-v2/Dockerfile +++ b/frontend-v2/Dockerfile @@ -16,7 +16,7 @@ COPY --from=builder /app/node_modules ./node_modules EXPOSE 3000 ENV NODE_ENV=production -HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost:3000/ || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://127.0.0.1:3000/ || exit 1 USER node CMD ["node", "build"] diff --git a/frontend-v2/src/app.css b/frontend-v2/src/app.css index ea74698..14731be 100644 --- a/frontend-v2/src/app.css +++ b/frontend-v2/src/app.css @@ -12,6 +12,8 @@ ═══════════════════════════════════════════════ */ @layer base { + html, body { overflow-x: hidden; } + body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); } :root { /* ── Fonts ── */ --font: 'Inter', -apple-system, system-ui, sans-serif; @@ -457,5 +459,6 @@ } .page-greeting { font-size: var(--text-xl); } .page { padding: var(--sp-5) 0 var(--sp-20); } - .app-surface { padding: 0 var(--sp-5); } + .app-surface { padding: 0 var(--sp-5); max-width: 100vw; box-sizing: border-box; } + .module, .module.primary, .module.flush { box-sizing: border-box; max-width: 100%; overflow: hidden; } } diff --git a/frontend-v2/src/lib/components/dashboard/TasksModule.svelte b/frontend-v2/src/lib/components/dashboard/TasksModule.svelte new file mode 100644 index 0000000..46eb7d2 --- /dev/null +++ b/frontend-v2/src/lib/components/dashboard/TasksModule.svelte @@ -0,0 +1,237 @@ + + +
+
+
Tasks
+ View all → +
+ + {#if loading} +
+ {:else if overdueTasks.length === 0 && todayTasks.length === 0} +
All clear for today
+ {:else} + {#if overdueTasks.length > 0} +
Overdue · {overdueTasks.length}
+ {#each overdueTasks as task (task.id)} +
+ +
+ {task.title} + {formatOverdueDate(task)}{#if formatTime(task)} · {formatTime(task)}{/if} +
+ {task._projectName} +
+ {/each} + {/if} + + {#if todayTasks.length > 0} +
Today · {todayTasks.length}
+ {#each todayTasks as task (task.id)} +
+ +
+ {task.title} + {#if formatTime(task)} + {formatTime(task)} + {/if} +
+ {task._projectName} +
+ {/each} + {/if} + {/if} +
+ + diff --git a/frontend-v2/src/lib/components/dashboard/TasksPanel.svelte b/frontend-v2/src/lib/components/dashboard/TasksPanel.svelte new file mode 100644 index 0000000..df5c240 --- /dev/null +++ b/frontend-v2/src/lib/components/dashboard/TasksPanel.svelte @@ -0,0 +1,390 @@ + + +
+
+ Tasks + +
+ + {#if showAdd} +
+ + + +
+ {/if} + + {#if loading} +
+
+
+
+
+ {:else if error} +
Could not load tasks
+ {:else} + {#if overdueTasks.length > 0} +
Overdue · {overdueTasks.length}
+ {#each overdueTasks as task (task.id)} +
+ +
+ {task.title} + {task._projectName} +
+
+ {/each} + {/if} + + {#if todayTasks.length > 0} +
Today · {todayTasks.length}
+ {#each todayTasks as task (task.id)} +
+ +
+ {task.title} + + {#if formatTime(task)}{formatTime(task)} · {/if}{task._projectName} + +
+
+ {/each} + {/if} + + {#if todayTasks.length === 0 && overdueTasks.length === 0} +
All clear for today
+ {/if} + {/if} + + View all tasks → +
+ + diff --git a/frontend-v2/src/lib/components/layout/MobileTabBar.svelte b/frontend-v2/src/lib/components/layout/MobileTabBar.svelte index c4375ab..bb01889 100644 --- a/frontend-v2/src/lib/components/layout/MobileTabBar.svelte +++ b/frontend-v2/src/lib/components/layout/MobileTabBar.svelte @@ -1,6 +1,6 @@ -
-
+
+
+ +
+ +
@@ -98,9 +106,51 @@
+
diff --git a/frontend-v2/src/routes/(app)/tasks/+page.svelte b/frontend-v2/src/routes/(app)/tasks/+page.svelte new file mode 100644 index 0000000..79df3d6 --- /dev/null +++ b/frontend-v2/src/routes/(app)/tasks/+page.svelte @@ -0,0 +1,730 @@ + + +
+ + + {#if showNewProject} +
+
+ e.key === 'Enter' && createProject()} /> + +
+
+ {/if} + + {#if showAdd} +
+ e.key === 'Enter' && addTask()} /> +
+ + + {#if !newAllDay} + + {/if} +
+
+ + + +
+ +
+ {/if} + +
+ + + {#each projects as proj} + + {/each} + +
+ + {#if loading} +
+ {:else} + + {#if activeTab === 'today'} + {#if overdueTasks.length > 0} + +
+ {#each overdueTasks as task (task.id)} +
+ +
startEdit(task)}> +
{task.title}
+
+ {formatDate(task)} · {task._projectName} + {#if task.repeatFlag}{repeatLabel(task.repeatFlag)}{/if} +
+
+ +
+ {/each} +
+ {/if} + + {#if todayTasks.length > 0} + +
+ {#each todayTasks as task (task.id)} +
+ +
startEdit(task)}> +
{task.title}
+
+ {formatDate(task)} · {task._projectName} + {#if task.repeatFlag}{repeatLabel(task.repeatFlag)}{/if} +
+
+ +
+ {/each} +
+ {/if} + + {#if todayTasks.length === 0 && overdueTasks.length === 0} +
+
All clear for today
+
+ {/if} + + {:else if activeTab === 'completed'} + {#if tasks.length > 0} +
+ {#each tasks as task (task.id)} +
+ + + + +
+
{task.title}
+
{task._projectName}{#if task.completedAt} · {new Date(task.completedAt).toLocaleDateString()}{/if}
+
+
+ {/each} +
+ {:else} +
No completed tasks
+ {/if} + + {:else} + {#if tasks.length > 0} +
+ {#each tasks as task (task.id)} +
+ +
startEdit(task)}> +
{task.title}
+
+ {formatDate(task)} + {#if activeTab === 'all'} · {task._projectName}{/if} + {#if task.repeatFlag}{repeatLabel(task.repeatFlag)}{/if} + {#if priorityLabel(task.priority)} + {priorityLabel(task.priority)} + {/if} +
+
+ +
+ {/each} +
+ {:else} +
+
No tasks
+
+ {/if} + {/if} + {/if} +
+ + +{#if editingTask} + + +{/if} + + diff --git a/gateway/config.py b/gateway/config.py index 0e08a16..c7ed659 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -21,10 +21,12 @@ TRIPS_API_TOKEN = os.environ.get("TRIPS_API_TOKEN", "") SHELFMARK_URL = os.environ.get("SHELFMARK_URL", "http://shelfmark:8084") SPOTIZERR_URL = os.environ.get("SPOTIZERR_URL", "http://spotizerr-app:7171") BUDGET_URL = os.environ.get("BUDGET_BACKEND_URL", "http://localhost:3001") +TASKS_URL = os.environ.get("TASKS_BACKEND_URL", "http://tasks-service:8098") # ── Service API keys (for internal service auth) ── INVENTORY_SERVICE_API_KEY = os.environ.get("INVENTORY_SERVICE_API_KEY", "") BUDGET_SERVICE_API_KEY = os.environ.get("BUDGET_SERVICE_API_KEY", "") +TASKS_SERVICE_API_KEY = os.environ.get("TASKS_SERVICE_API_KEY", "") # ── Booklore (book library manager) ── BOOKLORE_URL = os.environ.get("BOOKLORE_URL", "http://booklore:6060") diff --git a/gateway/dashboard.py b/gateway/dashboard.py index 72fd5a3..8c2d073 100644 --- a/gateway/dashboard.py +++ b/gateway/dashboard.py @@ -188,7 +188,7 @@ def handle_dashboard(handler, user): conn.close() # Services that use gateway-injected API keys (not per-user tokens) - GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget"} + GATEWAY_KEY_SERVICES = {"inventory", "reader", "books", "music", "budget", "tasks"} widgets = [] futures = {} diff --git a/gateway/database.py b/gateway/database.py index 9056893..024ce94 100644 --- a/gateway/database.py +++ b/gateway/database.py @@ -8,7 +8,7 @@ import bcrypt from config import ( DB_PATH, TRIPS_URL, FITNESS_URL, INVENTORY_URL, - MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, + MINIFLUX_URL, SHELFMARK_URL, SPOTIZERR_URL, BUDGET_URL, TASKS_URL, ) @@ -122,6 +122,13 @@ def init_db(): conn.commit() print("[Gateway] Added budget app") + # Ensure tasks app exists + tasks = c.execute("SELECT id FROM apps WHERE id = 'tasks'").fetchone() + if not tasks: + c.execute("INSERT INTO apps VALUES ('tasks', 'Tasks', 'check-square', '/tasks', ?, 8, 1, 'today_tasks')", (TASKS_URL,)) + conn.commit() + print("[Gateway] Added tasks app") + # Seed admin user from env vars if no users exist import os user_count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] diff --git a/gateway/server.py b/gateway/server.py index b123e1e..9cab5df 100644 --- a/gateway/server.py +++ b/gateway/server.py @@ -283,7 +283,7 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler): self._send_json({"error": "Unknown service"}, 404) return - from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY + from config import MINIFLUX_API_KEY, INVENTORY_SERVICE_API_KEY, BUDGET_SERVICE_API_KEY, TASKS_SERVICE_API_KEY headers = {} ct = self.headers.get("Content-Type") if ct: @@ -298,6 +298,11 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler): headers["X-API-Key"] = INVENTORY_SERVICE_API_KEY elif service_id == "budget" and BUDGET_SERVICE_API_KEY: headers["X-API-Key"] = BUDGET_SERVICE_API_KEY + elif service_id == "tasks": + # Inject user identity for the task manager + if user: + headers["X-Gateway-User-Id"] = str(user["id"]) + headers["X-Gateway-User-Name"] = user.get("display_name", user.get("username", "")) elif user: svc_token = get_service_token(user["id"], service_id) if svc_token: diff --git a/mobile/BUILD_IOS.md b/mobile/BUILD_IOS.md new file mode 100644 index 0000000..0e76481 --- /dev/null +++ b/mobile/BUILD_IOS.md @@ -0,0 +1,37 @@ +# Building Second Brain for iOS + +## One-time setup (on your MacBook) + +1. Install Xcode from the App Store (if not already installed) +2. Open Xcode once and accept the license agreement +3. Install Xcode command line tools: `xcode-select --install` +4. Install CocoaPods: `sudo gem install cocoapods` + +## Copy project to Mac + +Copy the `mobile/` folder to your MacBook: +```bash +scp -r yusiboyz@192.168.1.42:/media/yusiboyz/Media/Scripts/platform/mobile ~/Desktop/SecondBrain +``` + +Or use any file transfer method (AirDrop, USB, etc.) + +## Build and install + +```bash +cd ~/Desktop/SecondBrain +npm install +npx cap sync ios +npx cap open ios +``` + +This opens Xcode. Then: +1. Select your iPhone from the device dropdown (top of Xcode) +2. Click the Play button (or Cmd+R) +3. First time: Xcode will ask to trust your Apple ID — go to iPhone Settings > General > VPN & Device Management and trust the developer certificate +4. The app installs and launches! + +## After web changes + +No rebuild needed — the app loads from dash.quadjourney.com live. +Only rebuild if you change native plugins or the icon. diff --git a/mobile/capacitor.config.ts b/mobile/capacitor.config.ts new file mode 100644 index 0000000..d58c6d1 --- /dev/null +++ b/mobile/capacitor.config.ts @@ -0,0 +1,39 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.quadjourney.secondbrain', + appName: 'Second Brain', + webDir: 'www', + + server: { + // Live mode — loads from your deployed site + url: 'https://dash.quadjourney.com', + cleartext: false, + }, + + ios: { + contentInset: 'automatic', + preferredContentMode: 'mobile', + scheme: 'Second Brain', + backgroundColor: '#09090b', + }, + + plugins: { + SplashScreen: { + launchAutoHide: true, + launchShowDuration: 1500, + backgroundColor: '#09090b', + showSpinner: false, + }, + StatusBar: { + style: 'DARK', + backgroundColor: '#09090b', + }, + Keyboard: { + resize: 'body', + resizeOnFullScreen: true, + }, + }, +}; + +export default config; diff --git a/mobile/icon.svg b/mobile/icon.svg new file mode 100644 index 0000000..f4cfa3c --- /dev/null +++ b/mobile/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 0000000..f470299 --- /dev/null +++ b/mobile/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/mobile/ios/App/App.xcodeproj/project.pbxproj b/mobile/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dcc9962 --- /dev/null +++ b/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.secondbrain; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.quadjourney.secondbrain; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/mobile/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/mobile/ios/App/App/AppDelegate.swift b/mobile/ios/App/App/AppDelegate.swift new file mode 100644 index 0000000..c3cd83b --- /dev/null +++ b/mobile/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9b7d382 --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/ios/App/App/Assets.xcassets/Contents.json b/mobile/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 0000000..d7d96a6 --- /dev/null +++ b/mobile/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard b/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..e7ae5d7 --- /dev/null +++ b/mobile/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/App/App/Base.lproj/Main.storyboard b/mobile/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 0000000..b44df7b --- /dev/null +++ b/mobile/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/mobile/ios/App/App/Info.plist b/mobile/ios/App/App/Info.plist new file mode 100644 index 0000000..f22ac03 --- /dev/null +++ b/mobile/ios/App/App/Info.plist @@ -0,0 +1,51 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Second Brain + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/mobile/ios/App/CapApp-SPM/.gitignore b/mobile/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/mobile/ios/App/CapApp-SPM/Package.swift b/mobile/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 0000000..b0d11ec --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.0"), + .package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"), + .package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"), + .package(name: "CapacitorSplashScreen", path: "../../../node_modules/@capacitor/splash-screen"), + .package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), + .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), + .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") + ] + ) + ] +) diff --git a/mobile/ios/App/CapApp-SPM/README.md b/mobile/ios/App/CapApp-SPM/README.md new file mode 100644 index 0000000..03964db --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 0000000..945afec --- /dev/null +++ b/mobile/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/mobile/ios/debug.xcconfig b/mobile/ios/debug.xcconfig new file mode 100644 index 0000000..53ce18d --- /dev/null +++ b/mobile/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/mobile/package-lock.json b/mobile/package-lock.json new file mode 100644 index 0000000..fd24e17 --- /dev/null +++ b/mobile/package-lock.json @@ -0,0 +1,1115 @@ +{ + "name": "mobile", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mobile", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@capacitor/cli": "^8.3.0", + "@capacitor/core": "^8.3.0", + "@capacitor/haptics": "^8.0.2", + "@capacitor/ios": "^8.3.0", + "@capacitor/keyboard": "^8.0.2", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.2" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz", + "integrity": "sha512-n3QDUimtFNbagoo8kLdjvTz3i3Y4jX1fOjvo6ptUKLzErmuqeamL8kECASoyQvg/OzJisZToGZrgLphBsptJcw==", + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.0.tgz", + "integrity": "sha512-S4ajn4G/fS3VJj8salxqH/3LO5PPWv1VxGKQ27OCajnDcLJjEg9VXwgMPnlypgkIOqCJ2fmQLtk8GT+BlI9/rw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/haptics": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", + "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.0.tgz", + "integrity": "sha512-5Rtwv8SITKlYTt8lAZG+khnVIdzPtqbocH3eP+JkEmX1vpSMwx4TOKtT8OBz8gpQ+pUJDRp7DBYOv3U6l/obCw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.3.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz", + "integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/splash-screen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz", + "integrity": "sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz", + "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..8d4583d --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,21 @@ +{ + "name": "mobile", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@capacitor/cli": "^8.3.0", + "@capacitor/core": "^8.3.0", + "@capacitor/haptics": "^8.0.2", + "@capacitor/ios": "^8.3.0", + "@capacitor/keyboard": "^8.0.2", + "@capacitor/splash-screen": "^8.0.1", + "@capacitor/status-bar": "^8.0.2" + } +} diff --git a/mobile/www/index.html b/mobile/www/index.html new file mode 100644 index 0000000..427ef15 --- /dev/null +++ b/mobile/www/index.html @@ -0,0 +1,19 @@ + + + + + + Second Brain + + + +
Loading...
+ + + diff --git a/remediation_final_status.txt b/remediation_final_status.txt new file mode 100644 index 0000000..cec23c4 --- /dev/null +++ b/remediation_final_status.txt @@ -0,0 +1,104 @@ +Platform Security & Readiness Remediation — Final Status +========================================================= +Date: 2026-03-29 + +ISSUE TRACKER: Gitea yusiboyz/platform Issues #1–#10 + +COMPLETED ISSUES +================ + +#2 Auth Boundary: Registration and Default Credentials + - /api/auth/register disabled (403) + - Gateway admin seeded from ADMIN_USERNAME/ADMIN_PASSWORD env vars only + - Trips USERNAME/PASSWORD have no default fallback + - Fitness user seed requires env vars (no "changeme" default) + - All passwords use bcrypt + +#3 Trips Sharing Security + - handle_share_api enforces password via X-Share-Password header + bcrypt + - share_password stored as bcrypt hash + - All plaintext password logging removed + - Existing plaintext passwords invalidated by migration + - Dead hash_password function removed + +#4 Fitness Authorization + - All user_id query params enforced to authenticated user's own ID + - /api/users returns only current user + - Wildcard CORS removed + +#5 Gateway Trust Model + - Inventory and budget require API keys (X-API-Key middleware) + - Token validation uses protected endpoints per service type + - /debug-nocodb removed from inventory + - /test removed from inventory + - NocoDB search filter sanitized (strips operator injection chars) + - SERVICE_LEVEL_AUTH renamed to GATEWAY_KEY_SERVICES + - Trust model documented in docs/trust-model.md + - Per-user vs gateway-key services clearly distinguished + - Known limitations documented (no per-user isolation on shared services) + +#6 Repository Hygiene + - No .env or .db files tracked in git + - .gitignore covers: .env*, *.db*, services/**/.env, data/, test-results/ + - .env.example updated with all current env vars (no secrets) + +#7 Transport Security + - Gateway: _internal_ssl_ctx removed entirely (internal services use plain HTTP) + - Gateway: ssl import removed from config.py + - Gateway: proxy.py uses urlopen() without context parameter + - Gateway: logout cookie includes HttpOnly, Secure, SameSite=Lax + - Gateway: image proxy uses default TLS + domain allowlist + content-type validation + - Trips: all 5 CERT_NONE sites removed (OpenAI, Gemini, Google Places, Geocode) + - Inventory: permissive cors() removed + - Budget: permissive cors() removed + +#9 Performance Hardening + - Inventory /issues: server-side NocoDB WHERE filter (no full scan) + - Inventory /needs-review-count: server-side filter + pageInfo.totalRows + - Budget /summary: 1-minute cache + - Budget /transactions/recent: 30-second cache + - Budget /uncategorized-count: 2-minute cache + - Budget buildLookups: 2-minute cache + - Gateway /api/dashboard: 30-second per-user cache + - Actual Budget per-account API constraint documented + +#10 Deployment Hardening + - All 6 containers run as non-root (appuser/node) + - Health checks on gateway, trips, fitness, inventory, budget, frontend + - PYTHONUNBUFFERED=1 on all Python services + - Trips Dockerfile only copies server.py (not whole context) + - Frontend uses multi-stage build + +PARTIAL ISSUES +============== + +#8 Dependency Security + - Budget path-to-regexp vulnerability fixed + - .gitea/workflows/security.yml committed: + - dependency-audit (npm audit for budget + frontend) + - secret-scanning (tracked .env/.db, hardcoded patterns) + - dockerfile-lint (USER instruction, HEALTHCHECK) + - Runner dependency documented in .gitea/README.md + - BLOCKED: Requires Gitea Actions runner to be configured operationally + +OTHER FIXES (not tied to specific issues) + - Disconnect confirmation dialog added to Settings + - App nav visibility documented as cosmetic-only + - Stale /test startup log removed from inventory + - Frontend cookie vulnerability (4 low-severity) documented as not safe to fix + (requires breaking @sveltejs/kit downgrade) + +MANUAL OPS ACTIONS REQUIRED +============================ +1. Configure a Gitea Actions runner to activate CI workflows +2. Store admin password securely (set via ADMIN_PASSWORD env var) +3. Clean up local untracked .env files with real credentials if needed +4. Monitor @sveltejs/kit for a non-breaking cookie fix in future releases + +ARCHITECTURE REFERENCE +====================== +- Trust model: docs/trust-model.md +- CI workflows: .gitea/workflows/security.yml +- Runner setup: .gitea/README.md +- Design system: frontend-v2/DESIGN_SYSTEM.md +- Env var reference: .env.example diff --git a/services/budget/server.js b/services/budget/server.js index e6c9ee4..53fd8a3 100644 --- a/services/budget/server.js +++ b/services/budget/server.js @@ -4,7 +4,6 @@ if (typeof globalThis.navigator === 'undefined') { } const express = require('express'); -const cors = require('cors'); const api = require('@actual-app/api'); const app = express(); diff --git a/services/inventory/server.js b/services/inventory/server.js index 03770f0..508ec7d 100755 --- a/services/inventory/server.js +++ b/services/inventory/server.js @@ -1,5 +1,4 @@ const express = require('express'); -const cors = require('cors'); const multer = require('multer'); const axios = require('axios'); const FormData = require('form-data'); diff --git a/services/tasks/Dockerfile b/services/tasks/Dockerfile new file mode 100644 index 0000000..791c70a --- /dev/null +++ b/services/tasks/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim +WORKDIR /app + +RUN adduser --disabled-password --no-create-home appuser +RUN mkdir -p /app/data && chown -R appuser /app/data + +COPY --chown=appuser server.py . + +EXPOSE 8098 +ENV PYTHONUNBUFFERED=1 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8098/health', timeout=3)" || exit 1 + +USER appuser +CMD ["python3", "server.py"] diff --git a/services/tasks/migrate_from_ticktick.py b/services/tasks/migrate_from_ticktick.py new file mode 100644 index 0000000..8b87926 --- /dev/null +++ b/services/tasks/migrate_from_ticktick.py @@ -0,0 +1,176 @@ +"""One-time migration: pull all TickTick tasks into the custom task manager SQLite DB.""" + +import json +import os +import sqlite3 +import uuid +import urllib.request +from pathlib import Path + +TICKTICK_TOKEN = os.environ.get("TICKTICK_ACCESS_TOKEN", "") +# Handle JSON-wrapped token +try: + parsed = json.loads(TICKTICK_TOKEN) + TICKTICK_TOKEN = parsed.get("access_token", TICKTICK_TOKEN) +except (json.JSONDecodeError, TypeError): + pass + +TICKTICK_BASE = "https://api.ticktick.com/open/v1" +DB_PATH = Path(os.environ.get("DB_PATH", "/app/data/tasks.db")) +GATEWAY_USER_ID = os.environ.get("GATEWAY_USER_ID", "3") # Yusuf's gateway user ID +GATEWAY_USER_NAME = os.environ.get("GATEWAY_USER_NAME", "Yusuf") + + +def tt_request(path): + url = f"{TICKTICK_BASE}/{path.lstrip('/')}" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {TICKTICK_TOKEN}") + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def migrate(): + if not TICKTICK_TOKEN: + print("ERROR: TICKTICK_ACCESS_TOKEN not set") + return + + conn = get_db() + c = conn.cursor() + + # Ensure user exists + existing = c.execute("SELECT id FROM users WHERE id = ?", (GATEWAY_USER_ID,)).fetchone() + if not existing: + c.execute("INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)", + (GATEWAY_USER_ID, GATEWAY_USER_NAME.lower(), GATEWAY_USER_NAME)) + conn.commit() + + # Ensure Inbox exists + inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone() + if not inbox: + inbox_id = str(uuid.uuid4()) + c.execute("INSERT INTO projects (id, user_id, name, is_inbox, sort_order) VALUES (?, ?, 'Inbox', 1, -1)", + (inbox_id, GATEWAY_USER_ID)) + conn.commit() + inbox = c.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (GATEWAY_USER_ID,)).fetchone() + + inbox_id = inbox["id"] + + # Fetch TickTick projects + print("Fetching TickTick projects...") + tt_projects = tt_request("/project") + print(f" Found {len(tt_projects)} projects") + + # Map TickTick project IDs to our project IDs + project_map = {} # tt_project_id -> our_project_id + + for tp in tt_projects: + tt_id = tp["id"] + name = tp.get("name", "Untitled") + + # Check if we already migrated this project (by name match) + existing_proj = c.execute("SELECT id FROM projects WHERE user_id = ? AND name = ? AND is_inbox = 0", + (GATEWAY_USER_ID, name)).fetchone() + if existing_proj: + project_map[tt_id] = existing_proj["id"] + print(f" Project '{name}' already exists, skipping creation") + else: + new_id = str(uuid.uuid4()) + is_shared = 1 if any(kw in name.lower() for kw in ["family", "shared"]) else 0 + c.execute("INSERT INTO projects (id, user_id, name, is_shared, sort_order) VALUES (?, ?, ?, ?, ?)", + (new_id, GATEWAY_USER_ID, name, is_shared, tp.get("sortOrder", 0))) + project_map[tt_id] = new_id + print(f" Created project '{name}' (shared={is_shared})") + + conn.commit() + + # Fetch all tasks from each project + inbox + all_tasks = [] + + # Inbox + print("Fetching Inbox tasks...") + try: + inbox_data = tt_request("/project/inbox/data") + inbox_tasks = inbox_data.get("tasks", []) + for t in inbox_tasks: + t["_our_project_id"] = inbox_id + t["_project_name"] = "Inbox" + all_tasks.extend(inbox_tasks) + print(f" Inbox: {len(inbox_tasks)} tasks") + except Exception as e: + print(f" Inbox error: {e}") + + # Other projects + for tp in tt_projects: + tt_id = tp["id"] + name = tp.get("name", "?") + try: + data = tt_request(f"/project/{tt_id}/data") + tasks = data.get("tasks", []) + for t in tasks: + t["_our_project_id"] = project_map.get(tt_id, inbox_id) + t["_project_name"] = name + all_tasks.extend(tasks) + print(f" {name}: {len(tasks)} tasks") + except Exception as e: + print(f" {name} error: {e}") + + print(f"\nTotal tasks to migrate: {len(all_tasks)}") + + # Insert tasks + migrated = 0 + skipped = 0 + for t in all_tasks: + title = t.get("title", "").strip() + if not title: + skipped += 1 + continue + + # Check for duplicate by title + project + existing_task = c.execute( + "SELECT id FROM tasks WHERE title = ? AND project_id = ? AND user_id = ?", + (title, t["_our_project_id"], GATEWAY_USER_ID)).fetchone() + if existing_task: + skipped += 1 + continue + + task_id = str(uuid.uuid4()) + status = t.get("status", 0) + completed_at = None + if status != 0: + completed_at = t.get("completedTime") or t.get("modifiedTime") + + c.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, status, priority, + start_date, due_date, is_all_day, completed_at, repeat_flag, sort_order, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (task_id, t["_our_project_id"], GATEWAY_USER_ID, title, + t.get("content", ""), + status, + t.get("priority", 0), + t.get("startDate"), + t.get("dueDate"), + 1 if t.get("isAllDay", True) else 0, + completed_at, + t.get("repeatFlag"), + t.get("sortOrder", 0), + t.get("createdTime") or t.get("modifiedTime"))) + migrated += 1 + + conn.commit() + conn.close() + + print(f"\nMigration complete!") + print(f" Migrated: {migrated} tasks") + print(f" Skipped: {skipped} (duplicates or empty)") + print(f" Projects: {len(project_map) + 1} (including Inbox)") + + +if __name__ == "__main__": + migrate() diff --git a/services/tasks/server.py b/services/tasks/server.py new file mode 100644 index 0000000..bd1571a --- /dev/null +++ b/services/tasks/server.py @@ -0,0 +1,760 @@ +"""Second Brain Task Manager — self-contained SQLite-backed task service.""" + +import json +import os +import time +import uuid +import urllib.parse +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from datetime import datetime, timedelta +from pathlib import Path +from threading import Lock + +PORT = int(os.environ.get("PORT", 8098)) +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DB_PATH = DATA_DIR / "tasks.db" + +# ── Database ── + +import sqlite3 + +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + +def init_db(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + conn = get_db() + c = conn.cursor() + + c.execute('''CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT DEFAULT '', + sort_order INTEGER DEFAULT 0, + is_inbox INTEGER DEFAULT 0, + is_shared INTEGER DEFAULT 0, + archived INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS project_members ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + added_at TEXT DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT DEFAULT '', + status INTEGER DEFAULT 0, + priority INTEGER DEFAULT 0, + start_date TEXT, + due_date TEXT, + is_all_day INTEGER DEFAULT 1, + completed_at TEXT, + repeat_flag TEXT, + repeat_from TEXT DEFAULT 'due', + parent_task_id TEXT, + reminders TEXT DEFAULT '[]', + sort_order INTEGER DEFAULT 0, + gcal_event_id TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE SET NULL + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT DEFAULT '', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name), + FOREIGN KEY (user_id) REFERENCES users(id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS task_tags ( + task_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (task_id, tag_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS completions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + user_id TEXT NOT NULL, + completed_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + )''') + + c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_user_status ON tasks(user_id, status)") + c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date)") + c.execute("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_project_members ON project_members(user_id)") + + conn.commit() + conn.close() + + +# ── User + Inbox helpers ── + +def ensure_user(user_id, username="", display_name=""): + """Upsert a user record and ensure they have an Inbox project.""" + conn = get_db() + c = conn.cursor() + existing = c.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() + if not existing: + c.execute("INSERT INTO users (id, username, display_name) VALUES (?, ?, ?)", + (user_id, username, display_name)) + # Create Inbox for new user + inbox_id = str(uuid.uuid4()) + c.execute("INSERT INTO projects (id, user_id, name, is_inbox, sort_order) VALUES (?, ?, 'Inbox', 1, -1)", + (inbox_id, user_id)) + conn.commit() + conn.close() + + +def get_inbox_id(user_id): + conn = get_db() + row = conn.execute("SELECT id FROM projects WHERE user_id = ? AND is_inbox = 1", (user_id,)).fetchone() + conn.close() + return row["id"] if row else None + + +def get_user_project_ids(user_id): + """Get all project IDs the user can access (owned + shared membership).""" + conn = get_db() + owned = [r["id"] for r in conn.execute( + "SELECT id FROM projects WHERE user_id = ? AND archived = 0", (user_id,)).fetchall()] + shared = [r["project_id"] for r in conn.execute( + "SELECT project_id FROM project_members WHERE user_id = ?", (user_id,)).fetchall()] + # Also include projects marked is_shared (visible to all) + global_shared = [r["id"] for r in conn.execute( + "SELECT id FROM projects WHERE is_shared = 1 AND archived = 0 AND user_id != ?", (user_id,)).fetchall()] + conn.close() + return list(set(owned + shared + global_shared)) + + +# ── RRULE Parser ── + +def advance_rrule(due_date_str, repeat_flag, from_date_str=None): + """Given a due date and RRULE string, compute the next occurrence. + Returns ISO date string or None if recurrence is exhausted.""" + if not repeat_flag: + return None + + base = datetime.fromisoformat(due_date_str.replace("+0000", "+00:00").replace("Z", "+00:00")) + if from_date_str: + base = datetime.fromisoformat(from_date_str.replace("+0000", "+00:00").replace("Z", "+00:00")) + + # Parse RRULE components + parts = {} + for segment in repeat_flag.replace("RRULE:", "").split(";"): + if "=" in segment: + k, v = segment.split("=", 1) + parts[k.upper()] = v + + freq = parts.get("FREQ", "DAILY").upper() + interval = int(parts.get("INTERVAL", "1")) + until = parts.get("UNTIL") + count = parts.get("COUNT") # not enforced here, checked by caller + + # Compute next date + if freq == "DAILY": + next_dt = base + timedelta(days=interval) + elif freq == "WEEKLY": + byday = parts.get("BYDAY", "") + if byday: + day_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} + target_days = sorted([day_map[d.strip()] for d in byday.split(",") if d.strip() in day_map]) + if target_days: + current_wd = base.weekday() + # Find next target day + found = False + for td in target_days: + if td > current_wd: + next_dt = base + timedelta(days=(td - current_wd)) + found = True + break + if not found: + # Wrap to first day of next week(s) + days_to_next = (7 * interval) - current_wd + target_days[0] + next_dt = base + timedelta(days=days_to_next) + else: + next_dt = base + timedelta(weeks=interval) + else: + next_dt = base + timedelta(weeks=interval) + elif freq == "MONTHLY": + bymonthday = parts.get("BYMONTHDAY") + month = base.month + interval + year = base.year + (month - 1) // 12 + month = ((month - 1) % 12) + 1 + day = int(bymonthday) if bymonthday else base.day + # Clamp day to valid range + import calendar + max_day = calendar.monthrange(year, month)[1] + day = min(day, max_day) + next_dt = base.replace(year=year, month=month, day=day) + elif freq == "YEARLY": + next_dt = base.replace(year=base.year + interval) + else: + return None + + # Check UNTIL + if until: + try: + until_dt = datetime.strptime(until[:8], "%Y%m%d").replace(tzinfo=base.tzinfo) + if next_dt > until_dt: + return None + except ValueError: + pass + + return next_dt.isoformat() + + +# ── Task helpers ── + +def task_to_dict(row, project_name=""): + """Convert a SQLite row to the API response format (TickTick-compatible field names).""" + return { + "id": row["id"], + "title": row["title"], + "content": row["content"] or "", + "projectId": row["project_id"], + "_projectName": project_name or "", + "_projectId": row["project_id"], + "dueDate": row["due_date"], + "startDate": row["start_date"], + "isAllDay": bool(row["is_all_day"]), + "priority": row["priority"], + "status": row["status"], + "repeatFlag": row["repeat_flag"] or "", + "completedAt": row["completed_at"], + "reminders": json.loads(row["reminders"] or "[]"), + "createdAt": row["created_at"], + "sortOrder": row["sort_order"], + } + + +def fetch_tasks_with_projects(conn, where_clause, params, user_id): + """Fetch tasks joined with project names, scoped to user's accessible projects.""" + project_ids = get_user_project_ids(user_id) + if not project_ids: + return [] + placeholders = ",".join("?" * len(project_ids)) + sql = f"""SELECT t.*, p.name as project_name FROM tasks t + JOIN projects p ON t.project_id = p.id + WHERE t.project_id IN ({placeholders}) AND {where_clause} + ORDER BY t.start_date IS NULL, t.start_date, t.due_date, t.sort_order""" + rows = conn.execute(sql, project_ids + list(params)).fetchall() + return [task_to_dict(r, r["project_name"]) for r in rows] + + +# ── HTTP Handler ── + +class Handler(BaseHTTPRequestHandler): + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else {} + + def _send_json(self, data, status=200): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_error(self, msg, status=500): + self._send_json({"error": msg}, status) + + def _parse_query(self): + qs = self.path.split("?", 1)[1] if "?" in self.path else "" + return dict(urllib.parse.parse_qsl(qs)) + + def _get_user(self): + """Get user identity from gateway-injected headers.""" + user_id = self.headers.get("X-Gateway-User-Id") + if not user_id: + return None + username = self.headers.get("X-Gateway-User-Name", "") + ensure_user(user_id, username, username) + return user_id + + # ── GET ── + + def do_GET(self): + path = self.path.split("?")[0] + + if path == "/health": + self._send_json({"status": "ok"}) + return + + user_id = self._get_user() + if not user_id: + self._send_error("Unauthorized", 401) + return + + # List projects + if path == "/api/projects": + try: + conn = get_db() + project_ids = get_user_project_ids(user_id) + if not project_ids: + self._send_json({"projects": []}) + conn.close() + return + placeholders = ",".join("?" * len(project_ids)) + rows = conn.execute( + f"SELECT * FROM projects WHERE id IN ({placeholders}) ORDER BY is_inbox DESC, sort_order, name", + project_ids).fetchall() + conn.close() + projects = [{"id": r["id"], "name": r["name"], "color": r["color"], + "isInbox": bool(r["is_inbox"]), "isShared": bool(r["is_shared"]), + "sortOrder": r["sort_order"]} for r in rows] + self._send_json({"projects": projects}) + except Exception as e: + self._send_error(str(e)) + return + + # List all tasks (optional project filter) + if path == "/api/tasks": + try: + params = self._parse_query() + project_id = params.get("project_id") + conn = get_db() + if project_id: + tasks = fetch_tasks_with_projects(conn, "t.status = 0 AND t.project_id = ?", (project_id,), user_id) + else: + tasks = fetch_tasks_with_projects(conn, "t.status = 0", (), user_id) + conn.close() + self._send_json({"tasks": tasks}) + except Exception as e: + self._send_error(str(e)) + return + + # Today + overdue (dashboard widget) + if path == "/api/today": + try: + conn = get_db() + now = datetime.now() + today_str = now.strftime("%Y-%m-%d") + + all_active = fetch_tasks_with_projects(conn, "t.status = 0", (), user_id) + conn.close() + + today_tasks = [] + overdue_tasks = [] + + for t in all_active: + due = t.get("startDate") or t.get("dueDate") + if not due: + continue + due_date = due[:10] + if due_date == today_str: + today_tasks.append(t) + elif due_date < today_str: + overdue_tasks.append(t) + + self._send_json({ + "today": today_tasks, + "overdue": overdue_tasks, + "todayCount": len(today_tasks), + "overdueCount": len(overdue_tasks), + }) + except Exception as e: + self._send_error(str(e)) + return + + # Completed tasks + if path == "/api/tasks/completed": + try: + conn = get_db() + tasks = fetch_tasks_with_projects(conn, "t.status = 2", (), user_id) + conn.close() + # Sort by completed_at descending + tasks.sort(key=lambda t: t.get("completedAt") or "", reverse=True) + self._send_json({"tasks": tasks[:50]}) + except Exception as e: + self._send_error(str(e)) + return + + # List tags + if path == "/api/tags": + try: + conn = get_db() + rows = conn.execute("SELECT * FROM tags WHERE user_id = ? ORDER BY name", (user_id,)).fetchall() + conn.close() + self._send_json({"tags": [{"id": r["id"], "name": r["name"], "color": r["color"]} for r in rows]}) + except Exception as e: + self._send_error(str(e)) + return + + # Get single project with tasks + if path.startswith("/api/projects/") and path.count("/") == 3: + project_id = path.split("/")[3] + try: + conn = get_db() + proj = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + if not proj: + self._send_error("Not found", 404) + conn.close() + return + tasks = fetch_tasks_with_projects(conn, "t.status = 0 AND t.project_id = ?", (project_id,), user_id) + conn.close() + self._send_json({"project": {"id": proj["id"], "name": proj["name"]}, "tasks": tasks}) + except Exception as e: + self._send_error(str(e)) + return + + self._send_json({"error": "Not found"}, 404) + + # ── POST ── + + def do_POST(self): + path = self.path.split("?")[0] + body = self._read_body() + + user_id = self._get_user() + if not user_id: + self._send_error("Unauthorized", 401) + return + + # Create task + if path == "/api/tasks": + try: + title = body.get("title", "").strip() + if not title: + self._send_error("Title required", 400) + return + project_id = body.get("projectId") or get_inbox_id(user_id) + task_id = str(uuid.uuid4()) + conn = get_db() + conn.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, priority, + start_date, due_date, is_all_day, repeat_flag, reminders, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (task_id, project_id, user_id, title, + body.get("content", ""), + body.get("priority", 0), + body.get("startDate"), + body.get("dueDate"), + 1 if body.get("isAllDay", True) else 0, + body.get("repeatFlag"), + json.dumps(body.get("reminders", [])), + body.get("sortOrder", 0))) + # Handle tags + for tag_name in body.get("tags", []): + tag_id = str(uuid.uuid4()) + conn.execute("INSERT OR IGNORE INTO tags (id, user_id, name) VALUES (?, ?, ?)", + (tag_id, user_id, tag_name)) + tag_row = conn.execute("SELECT id FROM tags WHERE user_id = ? AND name = ?", + (user_id, tag_name)).fetchone() + if tag_row: + conn.execute("INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (?, ?)", + (task_id, tag_row["id"])) + conn.commit() + # Return created task + row = conn.execute("SELECT t.*, p.name as project_name FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.id = ?", + (task_id,)).fetchone() + conn.close() + self._send_json(task_to_dict(row, row["project_name"]), 201) + except Exception as e: + self._send_error(str(e)) + return + + # Complete task + if path.startswith("/api/tasks/") and path.endswith("/complete"): + task_id = path.split("/")[3] + try: + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not task: + self._send_error("Not found", 404) + conn.close() + return + + now_str = datetime.now().isoformat() + # Mark complete + conn.execute("UPDATE tasks SET status = 2, completed_at = ?, updated_at = ? WHERE id = ?", + (now_str, now_str, task_id)) + # Record completion + conn.execute("INSERT INTO completions (id, task_id, user_id, completed_at) VALUES (?, ?, ?, ?)", + (str(uuid.uuid4()), task_id, user_id, now_str)) + + # Handle recurrence — spawn next instance + if task["repeat_flag"]: + base_date = task["due_date"] or task["start_date"] + if task["repeat_from"] == "completion": + base_date = now_str + next_date = advance_rrule(base_date, task["repeat_flag"]) + if next_date: + new_id = str(uuid.uuid4()) + # Calculate start_date offset if both existed + new_start = None + if task["start_date"] and task["due_date"]: + try: + orig_start = datetime.fromisoformat(task["start_date"].replace("+0000", "+00:00")) + orig_due = datetime.fromisoformat(task["due_date"].replace("+0000", "+00:00")) + new_due = datetime.fromisoformat(next_date.replace("+0000", "+00:00")) + offset = orig_due - orig_start + new_start = (new_due - offset).isoformat() + except: + new_start = next_date + elif task["start_date"]: + new_start = next_date + + conn.execute("""INSERT INTO tasks (id, project_id, user_id, title, content, priority, + start_date, due_date, is_all_day, repeat_flag, repeat_from, + parent_task_id, reminders, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (new_id, task["project_id"], task["user_id"], task["title"], + task["content"], task["priority"], + new_start, next_date, task["is_all_day"], + task["repeat_flag"], task["repeat_from"], + task["parent_task_id"] or task["id"], + task["reminders"], task["sort_order"])) + + conn.commit() + conn.close() + self._send_json({"status": "completed"}) + except Exception as e: + self._send_error(str(e)) + return + + # Create project + if path == "/api/projects": + try: + name = body.get("name", "").strip() + if not name: + self._send_error("Name required", 400) + return + project_id = str(uuid.uuid4()) + conn = get_db() + conn.execute("INSERT INTO projects (id, user_id, name, color, is_shared) VALUES (?, ?, ?, ?, ?)", + (project_id, user_id, name, body.get("color", ""), 1 if body.get("isShared") else 0)) + conn.commit() + conn.close() + self._send_json({"id": project_id, "name": name}, 201) + except Exception as e: + self._send_error(str(e)) + return + + # Create tag + if path == "/api/tags": + try: + name = body.get("name", "").strip() + if not name: + self._send_error("Name required", 400) + return + tag_id = str(uuid.uuid4()) + conn = get_db() + conn.execute("INSERT INTO tags (id, user_id, name, color) VALUES (?, ?, ?, ?)", + (tag_id, user_id, name, body.get("color", ""))) + conn.commit() + conn.close() + self._send_json({"id": tag_id, "name": name}, 201) + except Exception as e: + self._send_error(str(e)) + return + + # Share project + if path.startswith("/api/projects/") and path.endswith("/share"): + project_id = path.split("/")[3] + try: + target_user_id = body.get("userId") + if not target_user_id: + self._send_error("userId required", 400) + return + conn = get_db() + conn.execute("INSERT OR IGNORE INTO project_members (project_id, user_id) VALUES (?, ?)", + (project_id, target_user_id)) + conn.commit() + conn.close() + self._send_json({"status": "shared"}) + except Exception as e: + self._send_error(str(e)) + return + + self._send_json({"error": "Not found"}, 404) + + # ── PATCH ── + + def do_PATCH(self): + path = self.path.split("?")[0] + body = self._read_body() + + user_id = self._get_user() + if not user_id: + self._send_error("Unauthorized", 401) + return + + # Update task + if path.startswith("/api/tasks/") and path.count("/") == 3: + task_id = path.split("/")[3] + try: + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + if not task: + self._send_error("Not found", 404) + conn.close() + return + + # Build SET clause from provided fields + updates = [] + params = [] + field_map = { + "title": "title", "content": "content", "priority": "priority", + "startDate": "start_date", "dueDate": "due_date", + "isAllDay": "is_all_day", "repeatFlag": "repeat_flag", + "projectId": "project_id", "sortOrder": "sort_order", + "reminders": "reminders", + } + for api_field, db_field in field_map.items(): + if api_field in body: + val = body[api_field] + if api_field == "isAllDay": + val = 1 if val else 0 + elif api_field == "reminders": + val = json.dumps(val) + updates.append(f"{db_field} = ?") + params.append(val) + + if updates: + updates.append("updated_at = ?") + params.append(datetime.now().isoformat()) + params.append(task_id) + conn.execute(f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + row = conn.execute("SELECT t.*, p.name as project_name FROM tasks t JOIN projects p ON t.project_id = p.id WHERE t.id = ?", + (task_id,)).fetchone() + conn.close() + self._send_json(task_to_dict(row, row["project_name"])) + except Exception as e: + self._send_error(str(e)) + return + + # Update project + if path.startswith("/api/projects/") and path.count("/") == 3: + project_id = path.split("/")[3] + try: + conn = get_db() + updates = [] + params = [] + if "name" in body: + updates.append("name = ?") + params.append(body["name"]) + if "color" in body: + updates.append("color = ?") + params.append(body["color"]) + if updates: + updates.append("updated_at = ?") + params.append(datetime.now().isoformat()) + params.append(project_id) + conn.execute(f"UPDATE projects SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + conn.close() + self._send_json({"status": "updated"}) + except Exception as e: + self._send_error(str(e)) + return + + self._send_json({"error": "Not found"}, 404) + + # ── DELETE ── + + def do_DELETE(self): + path = self.path.split("?")[0] + + user_id = self._get_user() + if not user_id: + self._send_error("Unauthorized", 401) + return + + # Delete task + if path.startswith("/api/tasks/") and path.count("/") == 3: + task_id = path.split("/")[3] + try: + conn = get_db() + conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + conn.commit() + conn.close() + self._send_json({"status": "deleted"}) + except Exception as e: + self._send_error(str(e)) + return + + # Delete/archive project + if path.startswith("/api/projects/") and path.count("/") == 3: + project_id = path.split("/")[3] + try: + conn = get_db() + proj = conn.execute("SELECT is_inbox FROM projects WHERE id = ?", (project_id,)).fetchone() + if proj and proj["is_inbox"]: + self._send_error("Cannot delete Inbox", 400) + conn.close() + return + conn.execute("UPDATE projects SET archived = 1, updated_at = ? WHERE id = ?", + (datetime.now().isoformat(), project_id)) + conn.commit() + conn.close() + self._send_json({"status": "archived"}) + except Exception as e: + self._send_error(str(e)) + return + + # Delete tag + if path.startswith("/api/tags/") and path.count("/") == 3: + tag_id = path.split("/")[3] + try: + conn = get_db() + conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, user_id)) + conn.commit() + conn.close() + self._send_json({"status": "deleted"}) + except Exception as e: + self._send_error(str(e)) + return + + self._send_json({"error": "Not found"}, 404) + + def log_message(self, format, *args): + pass + + +# ── Start ── + +if __name__ == "__main__": + init_db() + print(f"Task Manager listening on port {PORT}") + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + server.serve_forever() diff --git a/services/trips/server.py b/services/trips/server.py index c69e6ef..26abafe 100644 --- a/services/trips/server.py +++ b/services/trips/server.py @@ -1958,6 +1958,11 @@ class TripHandler(BaseHTTPRequestHandler): self.handle_oidc_callback() return + # Health check (before auth) + if path == "/api/health": + self.send_json({"status": "ok"}) + return + # Protected routes if not self.is_authenticated(): # Return JSON 401 for API requests, redirect for browser