This commit is contained in:
ordinarthur 2026-04-15 16:41:19 +02:00
commit bc0c15873f
65 changed files with 2029 additions and 0 deletions

View File

@ -0,0 +1,43 @@
# Squelette à aligner sur le skill /deploy d'Arthur.
# - Build images api + pwa
# - Push vers Gitea Container Registry
# - kubectl set image (ou apply via kustomize plus tard)
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- name: Login Gitea Container Registry
run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
- name: Build & push images
run: |
API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
docker build -f apps/api/Dockerfile -t "$API_TAG" .
docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" .
docker push "$API_TAG"
docker push "$PWA_TAG"
echo "API_TAG=$API_TAG" >> $GITHUB_ENV
echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV
- name: Deploy on k3s
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
kubectl -n ordinarthur-os set image deploy/api api=$API_TAG
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
kubectl -n ordinarthur-os rollout status deploy/pwa --timeout=120s

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules
dist
.turbo
.vite
.next
.env
.env.*
!.env.example
*.log
.DS_Store
coverage

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20

541
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,541 @@
# ordinarthur-os — Architecture
## 1. Topologie haut niveau
```
┌──────────────────────────────┐ ┌──────────────────────────┐
│ PWA (Vite+React) │ HTTPS │ NestJS API (BFF unique) │
│ os.arthurbarre.fr │──────▶ │ api.os.arthurbarre.fr │
│ Installable iOS / offline │ │ │
└──────────────────────────────┘ └─────────────┬────────────┘
┌──────────────┬──────────────────┼────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌────────────┐
│ Supabase │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
│ self-host │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
│ schema │ │ function- │ │ │ │ │ │ │
│ ordinarthur│ │ calling │ │ │ │ │ │ │
│ _os │ │ │ │ │ │ │ │ │
└────────────┘ └─────────────┘ └──────────────┘ └────────────┘ └────────────┘
```
La PWA ne parle **jamais** directement à Supabase ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token.
## 2. Monorepo
```
ordinarthur-os/
├── apps/
│ ├── pwa/ # Vite + React + TanStack Router/Query + Tailwind + shadcn
│ │ ├── public/
│ │ │ ├── manifest.webmanifest
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
│ │ └── src/
│ │ ├── routes/ # / /jobs /todos /projects /agenda /health /settings/*
│ │ ├── components/ # Composants métier
│ │ ├── design/ # Tokens + primitives éditoriales
│ │ ├── api/ # Client HTTP typé, zod parse
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
│ │ ├── lib/ # Utils
│ │ └── main.tsx
│ │
│ └── api/ # NestJS
│ └── src/
│ ├── modules/
│ │ ├── auth/ # BearerGuard, AuthController
│ │ ├── jobs/
│ │ ├── todos/
│ │ ├── projects/
│ │ ├── agenda/
│ │ ├── health/
│ │ ├── ai/ # command, voice, function dispatcher
│ │ ├── telegram/
│ │ └── sync/ # replay client_mutations, dedup
│ ├── db/ # supabase client factory
│ ├── config/ # env schema (zod)
│ └── main.ts
├── packages/
│ ├── shared/ # Types + zod DTOs partagés pwa/api
│ └── db/
│ └── migrations/ # 0001_schema.sql, 0002_jobs.sql, …
├── deploy/
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
│ ├── api.deployment.yaml
│ ├── pwa.deployment.yaml
│ ├── ingress.yaml
│ ├── secrets.template.yaml
│ └── backup.cronjob.yaml
├── .gitea/workflows/
│ └── deploy.yml # À aligner sur le skill /deploy d'Arthur
├── pnpm-workspace.yaml
├── turbo.json
├── CLAUDE.md # Pointe vers PLAN.md + ARCHITECTURE.md
└── README.md
```
## 3. Design system (Tailwind + primitives)
### Tokens (tailwind.config.ts)
```ts
theme: {
extend: {
colors: {
bg: '#F5F1EA',
ink: '#0F0F0F',
muted: '#8A8579',
accent: '#FF4A1C',
surface: '#FFFFFF',
},
fontFamily: {
sans: ['"Inter Tight"', 'system-ui', 'sans-serif'],
mono: ['"Space Mono"', '"JetBrains Mono"', 'monospace'],
},
letterSpacing: {
tightest: '-0.03em',
label: '0.06em',
},
},
},
```
### Primitives éditoriales
- `<Label prefix="[ 01 ]">À PROPOS</Label>` → mono, uppercase, 11px, tracking-label, muted
- `<SectionHeader number="01" label="À PROPOS" title="Le titre" />` → grid bordé
- `<GridFrame cols={12} children={...} />` → wrapper avec `border border-ink` et `divide-ink`
- `<DataChip dotColor="accent|ink">REACT</DataChip> ` → les cases de stack du portfolio
- `<MetaRow label="RÔLE" value="FULLSTACK · REACT · NODE" />` → clé en muted, valeur en ink
- `<BigHeading>Développeur <em>qui construit</em> des outils.</BigHeading>` → font-sans font-light, clamp responsive, italic = orange
### Règles visuelles
- Bordures partout : sections, grid cells, nav
- Pas d'ombre portée (shadow-none)
- Imagery grayscale par défaut
- Italique = accent orange (convention portfolio)
- Motion minimal (fade ≤ 200ms), pas d'animation flashy
- `accent-pulse` : petit dot orange qui pulse doucement pour marquer "live/disponible" (voir header portfolio)
## 4. Schéma Postgres `ordinarthur_os`
```sql
create schema if not exists ordinarthur_os;
set search_path to ordinarthur_os;
-- ============================================================
-- JOBS
-- ============================================================
create table jobs (
id uuid primary key default gen_random_uuid(),
source text not null, -- 'Indeed','WeLoveDevs',...
source_url text not null unique, -- clé de dedup
title text not null,
company text,
description text,
location text,
remote_type text check (remote_type in ('remote','hybrid','onsite')),
salary_min int,
salary_max int,
stack text[] default '{}',
apply_url text,
first_seen_at timestamptz default now(),
last_seen_at timestamptz default now(),
archived boolean default false,
starred boolean default false,
applied_at timestamptz,
notes text
);
create index on jobs(last_seen_at desc);
create index on jobs(archived);
create table job_search_criteria (
id uuid primary key default gen_random_uuid(),
name text,
titles text[] default '{}',
locations text[] default '{}',
stack text[] default '{}',
remote_types text[] default '{}',
salary_min int,
active boolean default true,
updated_at timestamptz default now()
);
-- ============================================================
-- PROJECTS
-- ============================================================
create table projects (
id uuid primary key default gen_random_uuid(),
name text not null,
kind text check (kind in ('freelance','perso')) default 'perso',
status text default 'active',
description text,
created_at timestamptz default now()
);
create table project_steps (
id uuid primary key default gen_random_uuid(),
project_id uuid references projects(id) on delete cascade,
title text not null,
description text,
status text default 'backlog'
check (status in ('backlog','todo','doing','review','done')),
position int default 0,
created_at timestamptz default now()
);
create index on project_steps(project_id, status, position);
create table project_ideas (
id uuid primary key default gen_random_uuid(),
project_id uuid references projects(id) on delete cascade,
content text not null,
created_at timestamptz default now()
);
-- ============================================================
-- TODOS
-- ============================================================
create table todos (
id uuid primary key default gen_random_uuid(),
title text not null,
description text, -- markdown
status text default 'inbox'
check (status in ('inbox','todo','doing','done','archived')),
priority smallint check (priority between 0 and 3),
due_at timestamptz,
tags text[] default '{}',
project_id uuid references projects(id) on delete set null,
checklist jsonb default '[]'::jsonb, -- [{text, done}]
energy text check (energy in ('low','med','high')),
context text, -- '@home','@laptop',…
recurrence text, -- rrule string
ticket_url text,
verification_steps text[] default '{}',
ai_enriched boolean default false,
created_at timestamptz default now(),
completed_at timestamptz
);
create index on todos(status);
create index on todos(due_at);
-- ============================================================
-- AGENDA
-- ============================================================
create table calendar_events (
id uuid primary key default gen_random_uuid(),
google_event_id text unique,
title text not null,
description text,
location text,
starts_at timestamptz not null,
ends_at timestamptz not null,
all_day boolean default false,
source text default 'ordinarthur-os', -- 'ordinarthur-os' | 'google'
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create index on calendar_events(starts_at);
create table google_oauth_tokens ( -- single-row pour Arthur
id smallint primary key default 1 check (id = 1),
access_token text not null,
refresh_token text not null,
expires_at timestamptz not null,
calendar_id text -- id du calendar "ordinarthur-os"
);
-- ============================================================
-- HEALTH
-- ============================================================
create table daily_checkins (
day date primary key,
meds_taken boolean default false,
note text,
updated_at timestamptz default now()
);
-- ============================================================
-- AI AUDIT
-- ============================================================
create table ai_actions (
id uuid primary key default gen_random_uuid(),
input_text text,
transcript text, -- si voice
function_name text,
function_args jsonb,
result jsonb,
status text check (status in ('proposed','confirmed','cancelled','failed')),
created_at timestamptz default now()
);
-- ============================================================
-- OFFLINE SYNC
-- ============================================================
create table client_mutations (
client_mutation_id text primary key, -- uuid généré côté PWA
applied_at timestamptz default now(),
result jsonb
);
```
**RLS** : activée partout avec policy unique `using (auth.role() = 'service_role')`. Seul le Nest (avec la service key) peut lire/écrire. La PWA est derrière le bearer Nest.
## 5. API NestJS — routes
Middleware global : `BearerGuard` sauf `/health`, `/telegram/webhook` (signé autrement), `/agenda/ical/:secret.ics` (secret URL).
```
GET /health { ok: true, version }
POST /auth/verify
# JOBS
POST /jobs/ingest { jobs: JobIngestDto[] } → { inserted, updated }
GET /jobs ?archived&remote_type&stack[]&since
PATCH /jobs/:id { starred?, archived?, applied_at? }
GET /jobs/criteria
PUT /jobs/criteria/:id
POST /jobs/criteria
DELETE /jobs/criteria/:id
# TODOS
GET /todos
POST /todos
PATCH /todos/:id
DELETE /todos/:id
POST /todos/:id/ai-enrich → renvoie un draft (non sauvé)
POST /todos/:id/ai-enrich/apply { draft } → applique
# PROJECTS
GET /projects
POST /projects
PATCH /projects/:id
DELETE /projects/:id
GET /projects/:id/steps
POST /projects/:id/steps
PATCH /projects/:id/steps/:stepId
DELETE /projects/:id/steps/:stepId
POST /projects/:id/steps/reorder { order: uuid[] }
GET /projects/:id/ideas
POST /projects/:id/ideas
DELETE /projects/:id/ideas/:ideaId
# AGENDA
GET /agenda/events ?from&to
POST /agenda/events
PATCH /agenda/events/:id
DELETE /agenda/events/:id
GET /agenda/google/oauth/start
GET /agenda/google/oauth/callback
POST /agenda/google/sync
GET /agenda/ical/:secret.ics → text/calendar
# HEALTH
GET /health-tab/today { day, meds_taken, note }
POST /health-tab/today/toggle → flips meds_taken, returns row
GET /health-tab/history?days=30
# AI
POST /ai/command { text } → { actions: ProposedAction[] }
POST /ai/voice multipart audio → { transcript, actions }
POST /ai/command/confirm { actions: ProposedAction[] } → { results }
# TELEGRAM
POST /telegram/webhook (header X-Telegram-Bot-Api-Secret-Token)
POST /telegram/set-webhook (admin, one-shot)
# SYNC (offline)
POST /sync/replay { mutations: ClientMutation[] } → { acked, errors }
```
### AI function calling — fonctions exposées à Mistral
```ts
type ProposedAction =
| { fn: 'create_todo', args: { title, description?, due_at?, priority?, project_id?, tags? } }
| { fn: 'add_project_idea', args: { project_id, content } }
| { fn: 'add_project_step', args: { project_id, title, status? } }
| { fn: 'create_calendar_event', args: { title, starts_at, ends_at, location?, description? } }
| { fn: 'toggle_daily_checkin', args: { note? } };
```
Flow garanti : l'API **ne jamais** exécute une action directement. Elle renvoie un `ProposedAction[]` à la PWA, qui affiche une modal de confirmation. Seul `/ai/command/confirm` écrit en DB.
## 6. PWA — routing & pages
```
/ Dashboard (events du jour, todos due today, médocs, bouton 🎤, Cmd-K)
/jobs Liste des offres (filtres toutes/remote/hybrid/marseille)
/jobs/:id Détail + actions (star, archive, applied)
/todos Inbox + filtres
/projects Liste
/projects/:id Détail + kanban + idées
/agenda Vue semaine / jour
/health Toggle du jour + heatmap 30j
/settings/jobs Édition des critères (lus par le scheduled task)
/settings/account Bearer token, health checks, connect Google, connect Telegram
```
### Offline
- Service worker (Workbox) : précache le shell, stale-while-revalidate sur GET `/api/*`
- Dexie tables : `todos_cache`, `projects_cache`, `pendingMutations`
- Toute mutation offline génère un `client_mutation_id` (uuid v4) et s'empile dans `pendingMutations`
- Au retour online : `POST /sync/replay` séquentiel, l'API déduplique via `client_mutations` (idempotence)
- UI optimiste via `useMutation` TanStack Query (`onMutate` update cache, `onError` rollback)
## 7. Voice magic button
1. `MediaRecorder` dans le navigateur → blob WebM/Opus
2. `POST /ai/voice` (multipart) vers NestJS
3. Nest → Groq Whisper (`whisper-large-v3-turbo`, lang=fr) → transcript
4. Nest → Mistral chat completions avec `tools` (les fonctions ci-dessus) + system prompt FR
5. Mistral renvoie 1..n `tool_calls` → Nest wrappe en `ProposedAction[]`, log dans `ai_actions` avec status=`proposed`
6. Réponse API : `{ transcript, actions }`
7. PWA affiche modal : "Tu veux faire : …" avec boutons **Confirmer** / **Annuler** / **Éditer**
8. Sur Confirmer → `POST /ai/command/confirm` → Nest exécute + update `ai_actions.status='confirmed'`
## 8. Déploiement k3s
### Ingress
```yaml
# deploy/k8s/ingress.yaml (exemple, à aligner sur conf Traefik d'Arthur)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ordinarthur-os
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
tls:
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
secretName: ordinarthur-os-tls
rules:
- host: os.arthurbarre.fr
http:
paths: [{ path: /, pathType: Prefix, backend: { service: { name: pwa, port: { number: 80 } } } }]
- host: api.os.arthurbarre.fr
http:
paths: [{ path: /, pathType: Prefix, backend: { service: { name: api, port: { number: 3000 } } } }]
```
### Secrets k8s attendus
`ordinarthur-os-secrets` :
- `API_BEARER_TOKEN`
- `SUPABASE_URL` (`https://supabase.arthurbarre.fr`)
- `SUPABASE_SERVICE_ROLE_KEY`
- `MISTRAL_API_KEY`
- `GROQ_API_KEY`
- `GOOGLE_OAUTH_CLIENT_ID`
- `GOOGLE_OAUTH_CLIENT_SECRET`
- `GOOGLE_OAUTH_REDIRECT_URI` (`https://api.os.arthurbarre.fr/agenda/google/oauth/callback`)
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_WEBHOOK_SECRET`
- `ICAL_FEED_SECRET`
### CronJob backup
```yaml
# deploy/k8s/backup.cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata: { name: ordinarthur-os-backup }
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: pgdump
image: postgres:16-alpine
envFrom: [{ secretRef: { name: ordinarthur-os-secrets } }]
command: ["/bin/sh","-c"]
args:
- |
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
# upload vers S3/B2 via rclone (à confirmer avec Arthur)
rclone copy /tmp/dump.gz remote:ordinarthur-os-backups/$(date +%F).gz
```
### Pipeline Gitea (à aligner avec le skill `/deploy` d'Arthur)
```yaml
# .gitea/workflows/deploy.yml — squelette
on: { push: { branches: [main] } }
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- name: Build images
run: |
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }} apps/api
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }} apps/pwa
- name: Push
run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
- name: Deploy
run: |
kubectl set image deploy/api api=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
kubectl set image deploy/pwa pwa=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
```
## 9. Sécurité
- TLS everywhere via cert-manager
- Bearer token Nest : stocké seulement dans les secrets k8s, jamais dans le bundle PWA → la PWA demande le token à l'utilisateur (écran d'onboarding) et le stocke dans `localStorage` (accès par Arthur uniquement sur son device)
- Webhook Telegram : vérification de `X-Telegram-Bot-Api-Secret-Token`
- Feed iCal : path contient un secret rotable (`ICAL_FEED_SECRET`)
- Supabase : schema dédié + RLS service-role-only
- Backups : quotidien, chiffrés au repos côté bucket, rétention 30 jours
## 10. Observabilité (phase ultérieure)
- Logs Nest en JSON → si Arthur a déjà Loki/Grafana, les envoyer là
- Health probes Liveness / Readiness sur `/health`
- Sentry optionnel plus tard
---
## Annexe — variables d'environnement
### `apps/api` (.env.example)
```
NODE_ENV=production
PORT=3000
API_BEARER_TOKEN=
SUPABASE_URL=https://supabase.arthurbarre.fr
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_SCHEMA=ordinarthur_os
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
GROQ_API_KEY=
GROQ_STT_MODEL=whisper-large-v3-turbo
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
ICAL_FEED_SECRET=
```
### `apps/pwa` (.env.example)
```
VITE_API_BASE_URL=https://api.os.arthurbarre.fr
```

29
CLAUDE.md Normal file
View File

@ -0,0 +1,29 @@
# CLAUDE.md — ordinarthur-os
Avant toute action, lire dans cet ordre :
1. [`README.md`](./README.md)
2. [`PLAN.md`](./PLAN.md)
3. [`ARCHITECTURE.md`](./ARCHITECTURE.md)
## Règles non-négociables
- **Pas de Next.js, pas de Vercel.** Stack = Vite/React + NestJS, déploiement k3s.
- **L'IA ne mute jamais la DB sans clic de confirmation utilisateur.** Le backend renvoie des `ProposedAction[]`, la PWA confirme via modal, puis `/ai/command/confirm` exécute.
- **Single-user.** Bearer token statique, pas de multi-tenant.
- **Design = portfolio arthurbarre.fr** (cream `#F5F1EA`, ink `#0F0F0F`, accent orange `#FF4A1C`, mono labels, bordures, italique = orange). PAS le violet/cyan du HTML jobs.
- **FR only** côté IA (prompts + STT lang=fr).
- **Schéma Postgres dédié `ordinarthur_os`** dans Supabase self-hosted (`supabase.arthurbarre.fr`).
## Phases
Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) reportée.
## Conventions repo
- Monorepo pnpm + Turborepo
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM)
- `packages/shared` types + zod DTOs partagés PWA ↔ API
- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant
- Pas de fichier `.env` commité, juste `.env.example`

119
PLAN.md Normal file
View File

@ -0,0 +1,119 @@
# ordinarthur-os — Plan d'implémentation
> **Status** : planning terminé 2026-04-15. À implémenter via Claude Code (Sonnet).
## Vision produit
Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabiliser**.
- Dashboard clair de ce qu'il fait / veut faire
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
- Pas de "weekly review" automatique, pas de nudges
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → transcription → création d'une todo / idée / étape projet / événement agenda, avec validation explicite avant écriture en base
## Principes directeurs
1. **Self-hosted, open-source**. Pas de Vercel, pas de Next.js. Tout tourne sur le k3s d'Arthur.
2. **Single-user**. Pas de multi-tenant, pas d'invitations, pas de partage. Bearer token unique pour protéger l'API.
3. **PWA installable iOS**. Vite + React, pas de SSR. Service worker + mutation queue pour l'offline.
4. **BFF unique**. La PWA ne parle qu'au NestJS. Le Nest parle à Supabase, Mistral, Groq, Google Calendar, Telegram.
5. **Design éditorial / Swiss-brutalist** — mirror du portfolio arthurbarre.fr (cream, ink, orange, borders, mono labels).
## Stack verrouillée
| Couche | Choix |
| --- | --- |
| Monorepo | pnpm workspaces + Turborepo |
| Frontend | Vite + React 18 + TanStack Router + TanStack Query + Tailwind + shadcn/ui |
| Backend | NestJS + `@supabase/supabase-js` (pas d'ORM) |
| DB | Postgres via Supabase self-hosted, schéma dédié `ordinarthur_os` |
| Auth | Bearer token statique (single-user), middleware Nest |
| IA LLM | Mistral `mistral-small-latest` (low-cost) via API |
| STT | Groq `whisper-large-v3-turbo` |
| Bot | Telegram Bot API (webhook) |
| Calendar | Google Calendar API (Apple souscrit au calendar Google via webcal) |
| Langue IA | FR only |
| Déploiement | Images Docker → Gitea Container Registry → pipeline Gitea Actions → k3s (Traefik + cert-manager supposés présents) |
| Backups | CronJob k8s `pg_dump --schema=ordinarthur_os` quotidien → stockage S3-compatible (à confirmer avec Arthur) |
## Roadmap — ordre d'implémentation
### Phase 0 — Scaffold (prio immédiate)
- Monorepo `pnpm-workspace.yaml`, `turbo.json`
- `apps/pwa` : Vite + React + Tailwind + shadcn + manifest PWA + service worker placeholder + routing TanStack
- `apps/api` : NestJS + module `health` + middleware bearer + client supabase initialisé
- `packages/shared` : types et zod schemas partagés
- `packages/db` : premier fichier de migration SQL créant le schéma `ordinarthur_os`
- `deploy/k8s` : manifests génériques (à adapter ensuite à la conf Gitea/Traefik d'Arthur)
- Design system : composants primitifs (`<Label>`, `<SectionHeader>`, `<GridFrame>`, `<DataChip>`, `<MetaRow>`) qui reproduisent le style arthurbarre.fr
- Routes `GET /health` et `POST /auth/verify`
### Phase 1 — Jobs (prio haute, remontée)
- Migration : tables `jobs`, `job_search_criteria`
- API : `POST /jobs/ingest` (bearer), `GET /jobs`, `PATCH /jobs/:id`, `GET/PUT /jobs/criteria`
- PWA : route `/jobs` avec filtres (toutes / remote / hybrid / marseille), rendu éditorial façon portfolio (lignes tableau, pas des cards violettes)
- PWA : route `/settings/jobs` pour éditer les critères (titres, localisations, stack[], remote_types[], salary_min, active)
- Dedup : clé unique `source_url`, update `last_seen_at` si déjà vu
- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`)
- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h
### Phase 2 — Todos riches
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet)
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation)
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations`
### Phase 3 — Projets + Kanban
- Migrations : `projects`, `project_steps`, `project_ideas`
- API : CRUD projets, CRUD steps, reorder, CRUD ideas
- PWA : route `/projects`, détail projet avec kanban (colonnes backlog/todo/doing/review/done), zone idées
### Phase 4 — Agenda + Google Calendar sync
- Migration : `calendar_events`
- API : OAuth Google (scope calendar), CRUD events avec sync bi-directionnelle, endpoint `/agenda/ical/:secret.ics` pour souscription Apple
- PWA : route `/agenda` vue semaine + jour
### Phase 5 — IA : bouton texte + voice magic button
- Migration : `ai_actions` (log d'audit)
- API : `POST /ai/command` (texte → function calling Mistral → plan), `POST /ai/voice` (audio → Groq Whisper → texte → Mistral → plan), `POST /ai/command/confirm` (applique après clic user)
- Fonctions exposées au LLM : `create_todo`, `add_project_idea`, `add_project_step`, `create_calendar_event`, `toggle_daily_checkin`
- PWA : bouton "🎤 Parler" sur le dashboard, `Cmd-K` pour la barre de commande texte, modal de confirmation avant exécution
### Phase 6 — Telegram bot
- Webhook Nest `/telegram/webhook` signé (header `X-Telegram-Bot-Api-Secret-Token`)
- Commandes : `/today` (events + todos du jour), `/todo <texte>` (crée une todo), messages vocaux traités comme le voice magic button
- Rappel quotidien optionnel à une heure configurable (simple message, pas d'action auto)
### Phase 7 — Health tab
- Migration : `daily_checkins (day date PK, meds_taken boolean, note text?)`
- API : `GET /health/today`, `POST /health/today/toggle`
- PWA : slider/toggle simple "médocs pris aujourd'hui" + historique 30j minimaliste
### Phase 8 — Finance (reporté)
- Revolut perso n'a pas d'API → passer par GoCardless Bank Account Data (ex-Nordigen)
- À traiter uniquement quand les phases 07 sont stables
## Handoff Claude Code
Pour reprendre ce projet avec Claude Code (Sonnet) :
1. Lire `README.md`, `PLAN.md`, `ARCHITECTURE.md` dans cet ordre
2. Pointer `CLAUDE.md` du repo vers ces docs
3. Respecter les règles de collaboration d'Arthur :
- Pas de Next.js, pas de Vercel
- L'IA ne mute jamais la DB sans clic de confirmation
- Design = portfolio arthurbarre.fr (pas le violet/cyan du HTML jobs)
4. Avant de scaffolder, récupérer de l'utilisateur :
- Le dossier `/Users/arthurbarre/dev/perso/proxmox` (conf k3s) pour aligner les manifests
- Le skill `/deploy` ou `/create-deployment` qu'Arthur utilise pour ses autres déploiements Gitea
- Les secrets nécessaires : `MISTRAL_API_KEY`, `GROQ_API_KEY`, `TELEGRAM_BOT_TOKEN`, `GOOGLE_OAUTH_CLIENT_ID/SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `API_BEARER_TOKEN`
- Le choix du stockage backup S3-compatible (B2 / Scaleway / autre)
5. Attaquer par la Phase 0 (scaffold), puis Phase 1 (jobs) — c'est explicitement prioritaire dans la tête d'Arthur.
## Points ouverts à trancher avec Arthur
- Stockage backups (quel bucket S3-compatible ?)
- Gitea Container Registry vs GHCR (défaut proposé : Gitea CR, déjà présent dans sa stack)
- STT Groq → clé à créer côté Arthur
- Google OAuth → app Google Cloud à créer, redirect URI `https://api.os.arthurbarre.fr/agenda/google/oauth/callback`
- Bot Telegram → @BotFather → récupérer le token, configurer le webhook vers `https://api.os.arthurbarre.fr/telegram/webhook`

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# ordinarthur-os
Assistant personnel self-hosted d'Arthur Barré. PWA installable + backend NestJS, déployés sur k3s personnel, branchés sur Supabase self-hosted (`supabase.arthurbarre.fr`).
**But** : aider Arthur à être rigoureux (todos, projets, agenda, recherche d'emploi, santé) **sans le déresponsabiliser**. Toutes les actions IA passent par une confirmation explicite.
## Documents
- [`PLAN.md`](./PLAN.md) — roadmap, phases, ordre des tâches.
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — stack, schéma Postgres, routes API, PWA, design system, déploiement.
## Handoff
Ces docs ont été produits en session de planning. L'implémentation est ensuite reprise par Claude Code (Sonnet) sur la machine locale d'Arthur. Voir la section "Handoff Claude Code" dans `PLAN.md`.

26
apps/api/.env.example Normal file
View File

@ -0,0 +1,26 @@
NODE_ENV=development
PORT=3000
# Single-user bearer (génère via `openssl rand -hex 32`)
API_BEARER_TOKEN=
# Supabase self-hosted
SUPABASE_URL=https://supabase.arthurbarre.fr
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_SCHEMA=ordinarthur_os
# Phase 5+
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
GROQ_API_KEY=
GROQ_STT_MODEL=whisper-large-v3-turbo
# Phase 4
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback
ICAL_FEED_SECRET=
# Phase 6
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=

24
apps/api/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Multi-stage build : build avec pnpm puis runtime minimal
FROM node:20-alpine AS deps
RUN corepack enable
WORKDIR /repo
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY apps/api/package.json apps/api/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile || pnpm install
FROM deps AS build
COPY . .
RUN pnpm --filter @ordinarthur-os/api build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /repo/apps/api/dist ./dist
COPY --from=build /repo/apps/api/package.json ./package.json
COPY --from=build /repo/node_modules ./node_modules
COPY --from=build /repo/packages ./packages
EXPOSE 3000
CMD ["node", "dist/main.js"]

8
apps/api/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

30
apps/api/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@ordinarthur-os/api",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"@ordinarthur-os/shared": "workspace:*",
"@supabase/supabase-js": "^2.45.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@types/express": "^4.17.21",
"@types/node": "^20.16.10",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,23 @@
import { Module, MiddlewareConsumer, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "./config/config.module";
import { DbModule } from "./db/db.module";
import { HealthModule } from "./modules/health/health.module";
import { AuthModule } from "./modules/auth/auth.module";
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
@Module({
imports: [ConfigModule, DbModule, HealthModule, AuthModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(BearerMiddleware)
.exclude(
{ path: "health", method: RequestMethod.GET },
// Endpoints publics (signés autrement) ajoutés en Phase 4/6 :
// { path: "telegram/webhook", method: RequestMethod.POST },
// { path: "agenda/ical/:secret.ics", method: RequestMethod.GET },
)
.forRoutes("*");
}
}

View File

@ -0,0 +1,16 @@
import { Global, Module } from "@nestjs/common";
import { loadConfig } from "./env";
export const APP_CONFIG = Symbol("APP_CONFIG");
@Global()
@Module({
providers: [
{
provide: APP_CONFIG,
useFactory: () => loadConfig(),
},
],
exports: [APP_CONFIG],
})
export class ConfigModule {}

View File

@ -0,0 +1,42 @@
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
API_BEARER_TOKEN: z.string().min(16, "API_BEARER_TOKEN must be at least 16 chars"),
SUPABASE_URL: z.string().url(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
// Phase 5+ — optionnels jusque-là
MISTRAL_API_KEY: z.string().optional(),
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
GROQ_API_KEY: z.string().optional(),
GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"),
GOOGLE_OAUTH_CLIENT_ID: z.string().optional(),
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(),
ICAL_FEED_SECRET: z.string().optional(),
TELEGRAM_BOT_TOKEN: z.string().optional(),
TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
});
export type AppConfig = z.infer<typeof EnvSchema>;
let cached: AppConfig | null = null;
export function loadConfig(): AppConfig {
if (cached) return cached;
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
// eslint-disable-next-line no-console
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
throw new Error("Invalid environment configuration");
}
cached = parsed.data;
return cached;
}

View File

@ -0,0 +1,27 @@
import { Global, Module, Inject } from "@nestjs/common";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { APP_CONFIG } from "../config/config.module";
import type { AppConfig } from "../config/env";
export const SUPABASE = Symbol("SUPABASE");
export type Supabase = SupabaseClient;
@Global()
@Module({
providers: [
{
provide: SUPABASE,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): SupabaseClient =>
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
db: { schema: config.SUPABASE_SCHEMA },
}),
},
],
exports: [SUPABASE],
})
export class DbModule {}
// Convenience decorator : `constructor(@InjectSupabase() private db: Supabase) {}`
export const InjectSupabase = () => Inject(SUPABASE);

17
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Logger } from "@nestjs/common";
import { AppModule } from "./app.module";
import { loadConfig } from "./config/env";
async function bootstrap() {
const config = loadConfig();
const app = await NestFactory.create(AppModule, {
logger: ["log", "warn", "error"],
});
app.enableCors({ origin: true, credentials: false });
await app.listen(config.PORT);
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");
}
bootstrap();

View File

@ -0,0 +1,14 @@
import { Controller, Post, HttpCode } from "@nestjs/common";
@Controller("auth")
export class AuthController {
/**
* Le bearer middleware tourne déjà avant cette route.
* Si on arrive ici, le token est valide.
*/
@Post("verify")
@HttpCode(200)
verify() {
return { ok: true as const };
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { BearerMiddleware } from "./bearer.middleware";
import { AuthController } from "./auth.controller";
@Module({
providers: [BearerMiddleware],
controllers: [AuthController],
exports: [BearerMiddleware],
})
export class AuthModule {}

View File

@ -0,0 +1,21 @@
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
import type { Request, Response, NextFunction } from "express";
import { APP_CONFIG } from "../../config/config.module";
import type { AppConfig } from "../../config/env";
@Injectable()
export class BearerMiddleware implements NestMiddleware {
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
use(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedException("Missing bearer token");
}
const token = header.slice("Bearer ".length).trim();
if (token !== this.config.API_BEARER_TOKEN) {
throw new UnauthorizedException("Invalid bearer token");
}
next();
}
}

View File

@ -0,0 +1,15 @@
import { Controller, Get } from "@nestjs/common";
@Controller("health")
export class HealthController {
private readonly bootedAt = Date.now();
@Get()
health() {
return {
ok: true as const,
version: process.env.npm_package_version ?? "0.0.0",
uptime: Math.floor((Date.now() - this.bootedAt) / 1000),
};
}
}

View File

@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller";
@Module({
controllers: [HealthController],
})
export class HealthModule {}

15
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "dist",
"rootDir": "src",
"verbatimModuleSyntax": false,
"isolatedModules": false
},
"include": ["src"]
}

1
apps/pwa/.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000

15
apps/pwa/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM node:20-alpine AS build
RUN corepack enable
WORKDIR /repo
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
COPY apps/pwa/package.json apps/pwa/
COPY packages/shared/package.json packages/shared/
COPY packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile || pnpm install
COPY . .
RUN pnpm --filter @ordinarthur-os/pwa build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

16
apps/pwa/index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#F5F1EA" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>ordinarthur-os</title>
</head>
<body class="bg-bg text-ink font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/pwa/nginx.conf Normal file
View File

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Service worker : jamais en cache long
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Assets immuables (Vite hash dans le nom)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

34
apps/pwa/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "@ordinarthur-os/pwa",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173 --host",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"@ordinarthur-os/shared": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.58.7",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@tanstack/router-vite-plugin": "^1.58.7",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.4",
"vite": "^5.4.8",
"vite-plugin-pwa": "^0.20.5"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
apps/pwa/public/.gitkeep Normal file
View File

View File

@ -0,0 +1,10 @@
# Icons à fournir
Placer ici (formats PNG) :
- `icon-192.png` — 192×192
- `icon-512.png` — 512×512
- `icon-maskable.png` — 512×512, safe area centrée
- `apple-touch-icon.png` — 180×180
Référencés depuis `vite.config.ts` (manifest PWA) et `index.html`.

View File

@ -0,0 +1,47 @@
/**
* Client HTTP minimal vers le BFF NestJS.
* Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding).
*/
const BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const TOKEN_KEY = "ordinarthur.bearer";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
export async function api<T = unknown>(
path: string,
init: RequestInit & { auth?: boolean } = {},
): Promise<T> {
const { auth = true, headers, ...rest } = init;
const finalHeaders: Record<string, string> = {
"Content-Type": "application/json",
...(headers as Record<string, string> | undefined),
};
if (auth) {
const token = getToken();
if (token) finalHeaders.Authorization = `Bearer ${token}`;
}
const res = await fetch(`${BASE}${path}`, { ...rest, headers: finalHeaders });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiError(res.status, text || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/cn";
/** Point orange qui pulse — marqueur "live/disponible". */
export function AccentDot({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block h-2 w-2 rounded-full bg-accent animate-accent-pulse",
className,
)}
/>
);
}

View File

@ -0,0 +1,25 @@
import { cn } from "@/lib/cn";
interface BigHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
as?: "h1" | "h2";
}
/**
* Gros titre éditorial. Italique = orange (convention portfolio).
* Wrap n'importe quel texte en <em> pour qu'il passe en accent.
*/
export function BigHeading({ as: Tag = "h1", className, children, ...props }: BigHeadingProps) {
return (
<Tag
className={cn(
"font-sans font-light tracking-tightest text-ink",
"text-[clamp(2.5rem,8vw,6rem)] leading-[0.95]",
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
className,
)}
{...props}
>
{children}
</Tag>
);
}

View File

@ -0,0 +1,25 @@
import { cn } from "@/lib/cn";
interface DataChipProps extends React.HTMLAttributes<HTMLSpanElement> {
dotColor?: "accent" | "ink";
}
export function DataChip({ dotColor = "ink", className, children, ...props }: DataChipProps) {
return (
<span
className={cn(
"inline-flex items-center gap-2 border border-ink px-2 py-1 font-mono text-[11px] uppercase tracking-label",
className,
)}
{...props}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
dotColor === "accent" ? "bg-accent" : "bg-ink",
)}
/>
{children}
</span>
);
}

View File

@ -0,0 +1,20 @@
import { cn } from "@/lib/cn";
interface GridFrameProps extends React.HTMLAttributes<HTMLDivElement> {
cols?: number;
}
export function GridFrame({ cols = 12, className, children, ...props }: GridFrameProps) {
return (
<div
className={cn(
"grid border border-ink divide-x divide-ink",
className,
)}
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
{...props}
>
{children}
</div>
);
}

View File

@ -0,0 +1,24 @@
import { cn } from "@/lib/cn";
interface LabelProps extends React.HTMLAttributes<HTMLSpanElement> {
prefix?: string;
}
/**
* Mini-label éditorial : mono, uppercase, 11px, tracking-label, muted.
* Ex: <Label prefix="[ 01 ]">À PROPOS</Label>
*/
export function Label({ prefix, className, children, ...props }: LabelProps) {
return (
<span
className={cn(
"font-mono text-[11px] uppercase tracking-label text-muted",
className,
)}
{...props}
>
{prefix ? <span className="mr-2 text-ink">{prefix}</span> : null}
{children}
</span>
);
}

View File

@ -0,0 +1,17 @@
import { cn } from "@/lib/cn";
import { Label } from "./Label";
interface MetaRowProps {
label: string;
value: React.ReactNode;
className?: string;
}
export function MetaRow({ label, value, className }: MetaRowProps) {
return (
<div className={cn("flex items-baseline gap-6 border-b border-ink py-3", className)}>
<Label className="w-32 shrink-0">{label}</Label>
<div className="flex-1 font-sans text-sm text-ink">{value}</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { Label } from "./Label";
import { cn } from "@/lib/cn";
interface SectionHeaderProps {
number: string;
label: string;
title: React.ReactNode;
className?: string;
}
export function SectionHeader({ number, label, title, className }: SectionHeaderProps) {
return (
<header
className={cn(
"border border-ink divide-y divide-ink",
className,
)}
>
<div className="flex items-center justify-between px-4 py-3">
<Label prefix={`[ ${number} ]`}>{label}</Label>
</div>
<div className="px-4 py-6">
<h2 className="font-sans font-light tracking-tightest text-3xl md:text-5xl text-ink">
{title}
</h2>
</div>
</header>
);
}

View File

@ -0,0 +1,7 @@
export { Label } from "./Label";
export { SectionHeader } from "./SectionHeader";
export { GridFrame } from "./GridFrame";
export { DataChip } from "./DataChip";
export { MetaRow } from "./MetaRow";
export { BigHeading } from "./BigHeading";
export { AccentDot } from "./AccentDot";

6
apps/pwa/src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import clsx, { type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

26
apps/pwa/src/main.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
});
const router = createRouter({ routeTree, defaultPreload: "intent" });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1,26 @@
/* prettier-ignore-start */
/* eslint-disable */
// @ts-nocheck
// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`.
// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run.
import { Route as rootRoute } from "./routes/__root";
import { Route as IndexRoute } from "./routes/index";
const IndexRouteWithParent = IndexRoute.update({
path: "/",
getParentRoute: () => rootRoute,
} as any);
export const routeTree = rootRoute.addChildren({
IndexRoute: IndexRouteWithParent,
} as any);
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": { parentRoute: typeof rootRoute };
}
}
/* prettier-ignore-end */

View File

@ -0,0 +1,46 @@
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
import { AccentDot, Label } from "@/design";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-ink">
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
<Link to="/" className="flex items-center gap-2">
<AccentDot />
<Label className="text-ink">ORDINARTHUR-OS</Label>
</Link>
<nav className="flex items-center gap-4">
{/* Routes activées au fur et à mesure des phases */}
<NavLink to="/">Dashboard</NavLink>
</nav>
</div>
</header>
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-8">
<Outlet />
</main>
<footer className="border-t border-ink">
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
<Label>v0.0.0 · phase 0</Label>
<Label>arthurbarre.fr</Label>
</div>
</footer>
</div>
);
}
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
return (
<Link
to={to}
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink [&.active]:text-ink"
activeProps={{ className: "active" }}
>
{children}
</Link>
);
}

View File

@ -0,0 +1,72 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/api/client";
import { BigHeading, DataChip, GridFrame, Label, MetaRow, SectionHeader } from "@/design";
export const Route = createFileRoute("/")({ component: Dashboard });
function Dashboard() {
const health = useQuery({
queryKey: ["health"],
queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }),
refetchInterval: 30_000,
});
return (
<div className="space-y-12">
<section>
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label>
<BigHeading className="mt-4">
Un assistant <em>qui n'agit jamais</em> sans ton clic.
</BigHeading>
</section>
<SectionHeader
number="01"
label="STATUS"
title="Backend handshake"
/>
<GridFrame cols={2}>
<div className="p-6 space-y-3">
<Label>API HEALTH</Label>
<div className="flex items-center gap-3">
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
</DataChip>
{health.data && (
<span className="font-mono text-[11px] text-muted">
v{health.data.version} · uptime {health.data.uptime}s
</span>
)}
</div>
</div>
<div className="p-6 space-y-3">
<Label>STACK</Label>
<div className="flex flex-wrap gap-2">
<DataChip>VITE · REACT</DataChip>
<DataChip>NESTJS</DataChip>
<DataChip>SUPABASE</DataChip>
<DataChip>K3S</DataChip>
</div>
</div>
</GridFrame>
<section className="border border-ink">
<div className="px-4 py-3 border-b border-ink">
<Label prefix="[ 02 ]">ROADMAP</Label>
</div>
<div className="px-4">
<MetaRow label="PHASE 0" value="Scaffold (en cours)" />
<MetaRow label="PHASE 1" value="Jobs · prio remontée" />
<MetaRow label="PHASE 2" value="Todos riches" />
<MetaRow label="PHASE 3" value="Projets + Kanban" />
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
<MetaRow label="PHASE 5" value="IA texte + voice magic button" />
<MetaRow label="PHASE 6" value="Telegram bot" />
<MetaRow label="PHASE 7" value="Health tab" />
</div>
</section>
</div>
);
}

16
apps/pwa/src/styles.css Normal file
View File

@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Polices : à servir self-hosted plus tard. Fallback system stack pour l'instant. */
@layer base {
html, body, #root { height: 100%; }
body {
background-color: theme(colors.bg);
color: theme(colors.ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
*::selection { background: theme(colors.accent); color: theme(colors.bg); }
}

View File

@ -0,0 +1,34 @@
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "#F5F1EA",
ink: "#0F0F0F",
muted: "#8A8579",
accent: "#FF4A1C",
surface: "#FFFFFF",
},
fontFamily: {
sans: ['"Inter Tight"', "system-ui", "sans-serif"],
mono: ['"Space Mono"', '"JetBrains Mono"', "monospace"],
},
letterSpacing: {
tightest: "-0.03em",
label: "0.06em",
},
keyframes: {
"accent-pulse": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.35" },
},
},
animation: {
"accent-pulse": "accent-pulse 1.6s ease-in-out infinite",
},
},
},
plugins: [],
} satisfies Config;

16
apps/pwa/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"outDir": "dist"
},
"include": ["src", "vite.config.ts"]
}

38
apps/pwa/vite.config.ts Normal file
View File

@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { VitePWA } from "vite-plugin-pwa";
import path from "node:path";
export default defineConfig({
plugins: [
TanStackRouterVite(),
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "icons/*.png"],
manifest: {
name: "ordinarthur-os",
short_name: "ordinarthur",
description: "Assistant personnel d'Arthur Barré",
theme_color: "#F5F1EA",
background_color: "#F5F1EA",
display: "standalone",
start_url: "/",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icons/icon-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
},
workbox: {
// Phase 2 : ajouter stale-while-revalidate sur GET /api/*
navigateFallback: "/index.html",
},
}),
],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
server: { port: 5173, host: true },
});

24
deploy/k8s/README.md Normal file
View File

@ -0,0 +1,24 @@
# deploy/k8s
Manifests Kubernetes pour le k3s perso d'Arthur.
## Ordre d'application initial
```bash
kubectl apply -f namespace.yaml
# Copier secrets.template.yaml -> secrets.yaml, remplir, puis :
kubectl apply -f secrets.yaml
kubectl apply -f api.deployment.yaml
kubectl apply -f pwa.deployment.yaml
kubectl apply -f ingress.yaml
# Une fois le bucket S3 choisi :
kubectl apply -f backup.cronjob.yaml
```
## Points à confirmer avec Arthur avant déploiement réel
- Cluster issuer cert-manager (`letsencrypt-prod` ?)
- Entrée Traefik (`websecure` ?)
- DNS : `os.arthurbarre.fr` et `api.os.arthurbarre.fr` doivent pointer sur l'IP du load-balancer k3s
- Bucket S3-compatible pour les backups (B2 / Scaleway / autre)
- Image registry : Gitea CR (défaut) — credentials pull peuvent nécessiter un `imagePullSecrets`

View File

@ -0,0 +1,41 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: ordinarthur-os
spec:
replicas: 1
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
containers:
- name: api
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
imagePullPolicy: Always
ports: [{ containerPort: 3000 }]
envFrom:
- secretRef: { name: ordinarthur-os-secrets }
env:
- { name: NODE_ENV, value: production }
- { name: PORT, value: "3000" }
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 15
periodSeconds: 30
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: ordinarthur-os
spec:
selector: { app: api }
ports: [{ port: 3000, targetPort: 3000 }]

View File

@ -0,0 +1,29 @@
# Backup quotidien du schéma ordinarthur_os.
# `PGURL` et `RCLONE_REMOTE` à fournir via secret séparé `ordinarthur-os-backup-secrets`
# (voir secrets.template.yaml — à splitter quand le bucket S3 est choisi avec Arthur).
apiVersion: batch/v1
kind: CronJob
metadata:
name: ordinarthur-os-backup
namespace: ordinarthur-os
spec:
schedule: "0 3 * * *"
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: pgdump
image: postgres:16-alpine
envFrom:
- secretRef: { name: ordinarthur-os-backup-secrets }
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
apk add --no-cache rclone
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
rclone copy /tmp/dump.gz "$RCLONE_REMOTE/$(date +%F).gz"

27
deploy/k8s/ingress.yaml Normal file
View File

@ -0,0 +1,27 @@
# À aligner sur la conf Traefik / cert-manager d'Arthur (cf. /Users/arthurbarre/dev/perso/proxmox).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ordinarthur-os
namespace: ordinarthur-os
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
tls:
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
secretName: ordinarthur-os-tls
rules:
- host: os.arthurbarre.fr
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: pwa, port: { number: 80 } } }
- host: api.os.arthurbarre.fr
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: api, port: { number: 3000 } } }

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: ordinarthur-os

View File

@ -0,0 +1,31 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: pwa
namespace: ordinarthur-os
spec:
replicas: 1
selector: { matchLabels: { app: pwa } }
template:
metadata: { labels: { app: pwa } }
spec:
containers:
- name: pwa
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
imagePullPolicy: Always
ports: [{ containerPort: 80 }]
readinessProbe:
httpGet: { path: /, port: 80 }
initialDelaySeconds: 3
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { cpu: 100m, memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: pwa
namespace: ordinarthur-os
spec:
selector: { app: pwa }
ports: [{ port: 80, targetPort: 80 }]

View File

@ -0,0 +1,24 @@
# NE PAS COMMITER LES VRAIES VALEURS.
# Ce fichier est un template — appliquer une copie remplie via :
# kubectl -n ordinarthur-os apply -f secrets.yaml
apiVersion: v1
kind: Secret
metadata:
name: ordinarthur-os-secrets
namespace: ordinarthur-os
type: Opaque
stringData:
API_BEARER_TOKEN: ""
SUPABASE_URL: "https://supabase.arthurbarre.fr"
SUPABASE_SERVICE_ROLE_KEY: ""
SUPABASE_SCHEMA: "ordinarthur_os"
MISTRAL_API_KEY: ""
MISTRAL_MODEL: "mistral-small-latest"
GROQ_API_KEY: ""
GROQ_STT_MODEL: "whisper-large-v3-turbo"
GOOGLE_OAUTH_CLIENT_ID: ""
GOOGLE_OAUTH_CLIENT_SECRET: ""
GOOGLE_OAUTH_REDIRECT_URI: "https://api.os.arthurbarre.fr/agenda/google/oauth/callback"
ICAL_FEED_SECRET: ""
TELEGRAM_BOT_TOKEN: ""
TELEGRAM_WEBHOOK_SECRET: ""

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "ordinarthur-os",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.12.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck"
},
"devDependencies": {
"turbo": "^2.1.3",
"typescript": "^5.5.4"
},
"engines": {
"node": ">=20.11"
}
}

15
packages/db/README.md Normal file
View File

@ -0,0 +1,15 @@
# @ordinarthur-os/db
Migrations SQL versionnées pour le schéma `ordinarthur_os` sur Supabase self-hosted.
Pas d'outil de migration automatique pour l'instant : appliquer manuellement via le SQL editor de Supabase, dans l'ordre numérique. Chaque fichier doit être idempotent autant que possible (`create … if not exists`).
## Ordre
- `0001_schema.sql` — création du schéma + RLS service-role-only (Phase 0)
- `0002_jobs.sql` — tables `jobs`, `job_search_criteria` (Phase 1)
- `0003_todos.sql` — table `todos` + `client_mutations` (Phase 2)
- `0004_projects.sql``projects`, `project_steps`, `project_ideas` (Phase 3)
- `0005_agenda.sql``calendar_events`, `google_oauth_tokens` (Phase 4)
- `0006_ai.sql``ai_actions` (Phase 5)
- `0007_health.sql``daily_checkins` (Phase 7)

View File

@ -0,0 +1,20 @@
-- 0001_schema.sql — Phase 0
-- Crée le schéma dédié `ordinarthur_os`. Les tables métier arrivent dans
-- les migrations suivantes (0002+). On pose ici uniquement le socle.
create schema if not exists ordinarthur_os;
-- pgcrypto pour gen_random_uuid()
create extension if not exists pgcrypto;
comment on schema ordinarthur_os is
'Assistant personnel single-user d''Arthur. Accès via service_role uniquement.';
-- Helper appliqué à chaque table créée dans les migrations suivantes :
--
-- alter table ordinarthur_os.<t> enable row level security;
-- create policy "<t>_service_role" on ordinarthur_os.<t>
-- for all using (auth.role() = 'service_role')
-- with check (auth.role() = 'service_role');
--
-- (Repris explicitement dans chaque migration de table.)

8
packages/db/package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "@ordinarthur-os/db",
"version": "0.0.0",
"private": true,
"scripts": {
"migrate:print": "ls migrations"
}
}

View File

@ -0,0 +1,20 @@
{
"name": "@ordinarthur-os/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

View File

@ -0,0 +1,74 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Common
// ---------------------------------------------------------------------------
export const HealthResponse = z.object({
ok: z.literal(true),
version: z.string(),
uptime: z.number(),
});
export type HealthResponse = z.infer<typeof HealthResponse>;
export const AuthVerifyResponse = z.object({
ok: z.literal(true),
});
export type AuthVerifyResponse = z.infer<typeof AuthVerifyResponse>;
// ---------------------------------------------------------------------------
// Phase 1 placeholders — fully fleshed out in Phase 1
// ---------------------------------------------------------------------------
export const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
export type RemoteType = z.infer<typeof RemoteType>;
// ---------------------------------------------------------------------------
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
// ---------------------------------------------------------------------------
export const ProposedAction = z.discriminatedUnion("fn", [
z.object({
fn: z.literal("create_todo"),
args: z.object({
title: z.string(),
description: z.string().optional(),
due_at: z.string().datetime().optional(),
priority: z.number().int().min(0).max(3).optional(),
project_id: z.string().uuid().optional(),
tags: z.array(z.string()).optional(),
}),
}),
z.object({
fn: z.literal("add_project_idea"),
args: z.object({
project_id: z.string().uuid(),
content: z.string(),
}),
}),
z.object({
fn: z.literal("add_project_step"),
args: z.object({
project_id: z.string().uuid(),
title: z.string(),
status: z.enum(["backlog", "todo", "doing", "review", "done"]).optional(),
}),
}),
z.object({
fn: z.literal("create_calendar_event"),
args: z.object({
title: z.string(),
starts_at: z.string().datetime(),
ends_at: z.string().datetime(),
location: z.string().optional(),
description: z.string().optional(),
}),
}),
z.object({
fn: z.literal("toggle_daily_checkin"),
args: z.object({
note: z.string().optional(),
}),
}),
]);
export type ProposedAction = z.infer<typeof ProposedAction>;

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist"
},
"include": ["src"]
}

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

17
tsconfig.base.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": false,
"jsx": "preserve"
}
}

17
turbo.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".vite/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"typecheck": {
"dependsOn": ["^build"]
}
}
}