From 5088a27607745260c1747eea0b2c4fb050f98e8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:17:21 +0100 Subject: [PATCH 01/10] Use Google Tasks scope instead of Calendar scope --- src/lib/auth.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3b0c463..89aac4f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -8,14 +8,13 @@ export const authOptions: NextAuthOptions = { clientSecret: process.env.GOOGLE_CLIENT_SECRET!, authorization: { params: { - // Request offline access so we get a refresh token access_type: 'offline', prompt: 'consent', scope: [ 'openid', 'email', 'profile', - 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/tasks', ].join(' '), }, }, @@ -23,7 +22,6 @@ export const authOptions: NextAuthOptions = { ], callbacks: { async jwt({ token, account }) { - // Persist the OAuth access_token and refresh_token on first sign-in if (account) { token.accessToken = account.access_token token.refreshToken = account.refresh_token @@ -32,7 +30,6 @@ export const authOptions: NextAuthOptions = { return token }, async session({ session, token }) { - // Expose tokens to the client session session.accessToken = token.accessToken as string return session }, From 3f3e2f98556766c10d4089f5889332eb28f6abce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:17:32 +0100 Subject: [PATCH 02/10] Replace googleCalendar.ts with googleTasks.ts logic --- src/lib/googleTasks.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/lib/googleTasks.ts diff --git a/src/lib/googleTasks.ts b/src/lib/googleTasks.ts new file mode 100644 index 0000000..13c786e --- /dev/null +++ b/src/lib/googleTasks.ts @@ -0,0 +1,60 @@ +import { google } from 'googleapis' +import dayjs from 'dayjs' + +/** + * Returns an authenticated Google Tasks client using the user's access token. + */ +export function getTasksClient(accessToken: string) { + const auth = new google.auth.OAuth2() + auth.setCredentials({ access_token: accessToken }) + return google.tasks({ version: 'v1', auth }) +} + +/** + * Fetch all task lists for the authenticated user. + */ +export async function listTaskLists(accessToken: string) { + const tasks = getTasksClient(accessToken) + const res = await tasks.tasklists.list({ maxResults: 100 }) + return res.data.items ?? [] +} + +export interface TaskPart { + title: string + /** Offset in minutes relative to the anchor date (negative = before, positive = after) */ + offsetMinutes: number + description?: string +} + +/** + * Creates multiple tasks from a list of TaskParts and an anchor date. + * Returns the created task IDs. + */ +export async function createTasksFromParts( + accessToken: string, + taskListId: string, + anchorDate: string, // ISO 8601 + parts: TaskPart[] +): Promise { + const tasks = getTasksClient(accessToken) + const anchor = dayjs(anchorDate) + const createdIds: string[] = [] + + for (const part of parts) { + const due = anchor.add(part.offsetMinutes, 'minute') + + const res = await tasks.tasks.insert({ + tasklist: taskListId, + requestBody: { + title: part.title, + notes: part.description, + // Google Tasks due date must be in RFC 3339 format with time set to 00:00:00Z + due: due.toDate().toISOString().replace(/T.*/, 'T00:00:00.000Z'), + }, + }) + + if (res.data.id) createdIds.push(res.data.id) + } + + return createdIds +} From 2f6fac83c00d892a2568d04614d9a4ca7e8e46d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:17:40 +0100 Subject: [PATCH 03/10] Replace calendars API route with task lists --- src/pages/api/calendars.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/api/calendars.ts b/src/pages/api/calendars.ts index 9110a1f..613c799 100644 --- a/src/pages/api/calendars.ts +++ b/src/pages/api/calendars.ts @@ -1,13 +1,13 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' -import { listCalendars } from '@/lib/googleCalendar' +import { listTaskLists } from '@/lib/googleTasks' export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getServerSession(req, res, authOptions) if (!session?.user?.email || !session.accessToken) return res.status(401).json({ error: 'Unauthorized' }) - const calendars = await listCalendars(session.accessToken as string) - return res.status(200).json(calendars) + const taskLists = await listTaskLists(session.accessToken as string) + return res.status(200).json(taskLists) } From 968480b4a5b68fcfaed3f6f2e559f000b1020651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:17:51 +0100 Subject: [PATCH 04/10] Use createTasksFromParts instead of createEventsFromParts --- src/pages/api/event-groups/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/pages/api/event-groups/index.ts b/src/pages/api/event-groups/index.ts index 83fabf6..ff72a13 100644 --- a/src/pages/api/event-groups/index.ts +++ b/src/pages/api/event-groups/index.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { db } from '@/lib/firestore' -import { createEventsFromParts } from '@/lib/googleCalendar' +import { createTasksFromParts } from '@/lib/googleTasks' import { Template, EventGroup } from '@/types' import { v4 as uuidv4 } from 'uuid' @@ -24,7 +24,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === 'POST') { - const { templateId, anchorDate, calendarId } = req.body as { + const { templateId, anchorDate, calendarId: taskListId } = req.body as { templateId: string anchorDate: string calendarId: string @@ -35,15 +35,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (!tDoc.exists) return res.status(404).json({ error: 'Template not found' }) const template = tDoc.data() as Template - // Create all events in Google Calendar - const eventIds = await createEventsFromParts( + // Create all tasks in Google Tasks + const eventIds = await createTasksFromParts( accessToken, - calendarId, + taskListId, anchorDate, template.parts.map(p => ({ title: p.title, offsetMinutes: p.offsetMinutes, - durationMinutes: p.durationMinutes, description: p.description, })) ) @@ -54,7 +53,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) templateId, templateName: template.name, anchorDate, - calendarId, + calendarId: taskListId, eventIds, createdAt: new Date().toISOString(), } From 27d459db850e50a80859f7a8716abc45ed80c3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:18:21 +0100 Subject: [PATCH 05/10] =?UTF-8?q?Update=20Schedule=20UI:=20calendars=20?= =?UTF-8?q?=E2=86=92=20task=20lists,=20events=20=E2=86=92=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/schedule.tsx | 44 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/pages/schedule.tsx b/src/pages/schedule.tsx index e2a81b7..7d7cb75 100644 --- a/src/pages/schedule.tsx +++ b/src/pages/schedule.tsx @@ -4,9 +4,9 @@ import Head from 'next/head' import Layout from '@/components/Layout' import { Template } from '@/types' -interface Calendar { +interface TaskList { id: string - summary: string + title: string } export default function SchedulePage() { @@ -14,9 +14,9 @@ export default function SchedulePage() { const { templateId: preselected } = router.query const [templates, setTemplates] = useState([]) - const [calendars, setCalendars] = useState([]) + const [taskLists, setTaskLists] = useState([]) const [templateId, setTemplateId] = useState('') - const [calendarId, setCalendarId] = useState('') + const [taskListId, setTaskListId] = useState('') const [anchorDate, setAnchorDate] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) @@ -27,10 +27,10 @@ export default function SchedulePage() { Promise.all([ fetch('/api/templates').then(r => r.json()), fetch('/api/calendars').then(r => r.json()), - ]).then(([tmpl, cals]) => { + ]).then(([tmpl, lists]) => { setTemplates(tmpl) - setCalendars(cals) - if (cals.length > 0) setCalendarId(cals[0].id) + setTaskLists(lists) + if (lists.length > 0) setTaskListId(lists[0].id) setLoading(false) }) }, []) @@ -43,7 +43,7 @@ export default function SchedulePage() { async function schedule() { if (!templateId) { setError('Please select a template'); return } - if (!calendarId) { setError('Please select a calendar'); return } + if (!taskListId) { setError('Please select a task list'); return } if (!anchorDate) { setError('Please pick an anchor date'); return } setSaving(true) setError('') @@ -51,7 +51,7 @@ export default function SchedulePage() { const res = await fetch('/api/event-groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ templateId, calendarId, anchorDate }), + body: JSON.stringify({ templateId, calendarId: taskListId, anchorDate }), }) if (res.ok) { @@ -70,16 +70,16 @@ export default function SchedulePage() {

Schedule

- Pick a template, set the anchor date, and create all events at once + Pick a template, set the anchor date, and create all tasks at once

{done ? (
-

Events created!

+

Tasks created!

- All parts of {selectedTemplate?.name} have been added to your calendar. + All parts of {selectedTemplate?.name} have been added to your task list.

)} From 4b04311eeaf2be6c53497517e0c360512e0f0a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:26:36 +0100 Subject: [PATCH 06/10] Update README for feature/tasks branch --- README.md | 49 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b9f6836..e60b94e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ -# 🦑 Kalamari +# 🦑 Kalamari – Tasks variant -Calendar event template manager. Create reusable multi-part event templates and instantly populate your Google Calendar. +> **Branch: `feature/tasks`** +> This branch uses **Google Tasks** instead of Google Calendar events. +> For the Calendar events version, see the [`main`](https://github.com/mortengf/kalamari/tree/main) branch. + +Task template manager. Create reusable multi-part task templates and instantly populate your Google Tasks. ## Concept -Many real-world appointments consist of several related calendar events, e.g.: +Many real-world appointments consist of several related tasks, e.g.: - **Squash / haircut**: "Book!", "The appointment", "Book next" - **Tickets**: "Buy", "Print", "The event" @@ -12,14 +16,14 @@ Many real-world appointments consist of several related calendar events, e.g.: - **Travel**: "Check in online", "Departure", "Return" - **Meetings**: "Read material", "The meeting", "Write follow-up" -Kalamari lets you define these as reusable templates and create all related events in one click. +Kalamari lets you define these as reusable templates and create all related tasks in one click, each with a due date calculated relative to an anchor date. ## Tech Stack - [Next.js 14](https://nextjs.org/) (Pages Router) - [NextAuth.js](https://next-auth.js.org/) with Google OAuth -- [Google Calendar API](https://developers.google.com/calendar) -- [Firebase Firestore](https://firebase.google.com/docs/firestore) (template + event group storage) +- [Google Tasks API](https://developers.google.com/tasks) +- [Firebase Firestore](https://firebase.google.com/docs/firestore) (template + task group storage) - [Tailwind CSS](https://tailwindcss.com/) ## Getting Started @@ -29,6 +33,7 @@ Kalamari lets you define these as reusable templates and create all related even ```bash git clone https://github.com/mortengf/kalamari.git cd kalamari +git checkout feature/tasks npm install ``` @@ -36,7 +41,7 @@ npm install 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project -3. Enable the **Google Calendar API** +3. Enable the **Google Tasks API** 4. Create **OAuth 2.0 credentials** (Web application) 5. Add `http://localhost:3000/api/auth/callback/google` as an authorized redirect URI @@ -44,14 +49,18 @@ npm install 1. Create a project in [Firebase Console](https://console.firebase.google.com/) 2. Enable **Firestore** in Native mode -3. Go to Project Settings → Service Accounts → Generate new private key -4. Base64-encode the JSON: `base64 -i serviceAccount.json` +3. Authenticate locally via Google Cloud CLI: + +```bash +gcloud auth application-default login +gcloud auth application-default set-quota-project YOUR_PROJECT_ID +``` ### 4. Configure environment ```bash cp .env.example .env.local -# Fill in all values +# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, NEXT_PUBLIC_FIREBASE_PROJECT_ID ``` ### 5. Run @@ -62,6 +71,15 @@ npm run dev Open [http://localhost:3000](http://localhost:3000). +## How tasks work + +Each template part gets a **due date** calculated by adding its `offsetMinutes` to the anchor date. Since Google Tasks only support due dates (not times), the time component is ignored — only the date matters. + +Example with anchor date 2026-03-15: +- Part with `offsetMinutes: -4320` (−3 days) → due 2026-03-12 +- Part with `offsetMinutes: 0` (anchor) → due 2026-03-15 +- Part with `offsetMinutes: 1440` (+1 day) → due 2026-03-16 + ## Data Model ### `templates` collection @@ -72,7 +90,6 @@ Open [http://localhost:3000](http://localhost:3000). userId: string // Google email name: string color: string // hex - defaultCalendarId?: string parts: [ { id: string @@ -96,8 +113,8 @@ Open [http://localhost:3000](http://localhost:3000). templateId: string templateName: string anchorDate: string // ISO 8601 - calendarId: string - eventIds: string[] // Google Calendar event IDs + calendarId: string // Google Tasks task list ID + eventIds: string[] // Google Tasks task IDs createdAt: string } ``` @@ -111,6 +128,6 @@ Open [http://localhost:3000](http://localhost:3000). | GET | `/api/templates/:id` | Get template | | PUT | `/api/templates/:id` | Update template | | DELETE | `/api/templates/:id` | Delete template | -| GET | `/api/event-groups` | List event groups | -| POST | `/api/event-groups` | Instantiate template → create Calendar events | -| GET | `/api/calendars` | List user's Google Calendars | +| GET | `/api/event-groups` | List task groups | +| POST | `/api/event-groups` | Instantiate template → create Tasks | +| GET | `/api/calendars` | List user's Google Task lists | From 9576b653f522031f1551628256e330fea35a23f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:32:19 +0100 Subject: [PATCH 07/10] Update README with full setup guide including known gotchas --- README.md | 74 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e60b94e..4933070 100644 --- a/README.md +++ b/README.md @@ -37,39 +37,83 @@ git checkout feature/tasks npm install ``` -### 2. Set up Google OAuth +### 2. Set up Google OAuth and enable APIs -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Create a new project -3. Enable the **Google Tasks API** -4. Create **OAuth 2.0 credentials** (Web application) -5. Add `http://localhost:3000/api/auth/callback/google` as an authorized redirect URI +1. Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project +2. Go to **APIs & Services → Library** and enable: + - **Google Tasks API** +3. Go to **APIs & Services → Credentials → Create credentials → OAuth 2.0 Client ID** + - Application type: Web application + - Authorized JavaScript origins: `http://localhost:3000` + - Authorized redirect URIs: `http://localhost:3000/api/auth/callback/google` +4. Go to **APIs & Services → OAuth consent screen** + - If the app is in Testing mode, add your Google account under **Test users** -### 3. Set up Firebase +### 3. Set up Firebase Firestore -1. Create a project in [Firebase Console](https://console.firebase.google.com/) -2. Enable **Firestore** in Native mode -3. Authenticate locally via Google Cloud CLI: +1. Go to [Firebase Console](https://console.firebase.google.com/) and create a new project +2. Go to **Build → Firestore Database → Create database** + - Choose **Native mode** (not Datastore mode — Datastore mode will not work) + - Choose a region, e.g. `europe-west1` +3. Go to **Project Settings** and note your **Project ID** + +### 4. Set up local Google credentials + +The app uses Application Default Credentials to authenticate with Firestore server-side. ```bash +gcloud auth login gcloud auth application-default login -gcloud auth application-default set-quota-project YOUR_PROJECT_ID +gcloud auth application-default set-quota-project YOUR_FIREBASE_PROJECT_ID ``` -### 4. Configure environment +> **Important:** If you use Google Cloud for other projects, you may have a `GOOGLE_APPLICATION_CREDENTIALS` environment variable set in your shell that points to a different service account. This will override Application Default Credentials and cause authentication errors. Unset it before running the app: +> +> ```bash +> unset GOOGLE_APPLICATION_CREDENTIALS +> ``` +> +> You may want to add this to a local `.env` or shell alias for the project. + +### 5. Set up IAM permissions + +Your Google account needs permission to access Firestore. Go to [Google Cloud Console → IAM](https://console.cloud.google.com/iam-admin/iam), select your Firebase project, and ensure your account has the role: + +- **Cloud Datastore User** (`roles/datastore.user`) + +### 6. Configure environment ```bash cp .env.example .env.local -# Fill in GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NEXTAUTH_SECRET, NEXT_PUBLIC_FIREBASE_PROJECT_ID ``` -### 5. Run +Fill in the following values in `.env.local`: + +``` +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +NEXTAUTH_SECRET=... # generate with: openssl rand -base64 32 +NEXTAUTH_URL=http://localhost:3000 +NEXT_PUBLIC_FIREBASE_PROJECT_ID=... # your Firebase project ID +``` + +### 7. Run ```bash npm run dev ``` -Open [http://localhost:3000](http://localhost:3000). +Open [http://localhost:3000](http://localhost:3000) and sign in with Google. + +> **Note:** After signing in for the first time, sign out and sign back in if you encounter Tasks API errors. This ensures the session token includes the correct OAuth scopes. + +## Known setup gotchas + +**Firestore index required** +The first time you load the Templates page, Firestore may return an error saying a composite index is required. The error message contains a direct link to create the index in Firebase Console — click it and wait ~1 minute for the index to build. + +**Wrong Google Cloud project** +If `gcloud config get-value project` shows a different project than your Firebase project, explicitly set the project ID in `.env.local` via `NEXT_PUBLIC_FIREBASE_PROJECT_ID`. The app uses this to connect to the correct Firestore database regardless of your local gcloud config. ## How tasks work From c43a236a5a9ea1e2719341c77f2ab72484fc809f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:35:23 +0100 Subject: [PATCH 08/10] Remove unused FIREBASE_ADMIN_CREDENTIALS, clarify NEXTAUTH_SECRET is optional locally --- .env.example | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index cda91f9..1f9ef0a 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,9 @@ GOOGLE_CLIENT_SECRET=your_google_client_secret # NextAuth NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your_nextauth_secret # generate with: openssl rand -base64 32 +# NEXTAUTH_SECRET is optional locally but required in production. +# Generate with: openssl rand -base64 32 +# NEXTAUTH_SECRET= -# Firebase Admin (service account JSON, base64-encoded) -# How to encode: base64 -i serviceAccount.json -FIREBASE_ADMIN_CREDENTIALS=your_base64_encoded_service_account_json - -# Firebase (client-side) +# Firebase (project ID from Firebase Console → Project Settings) NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id From 82b342f4e6143b7565d793a60f963eb2c96da852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:36:38 +0100 Subject: [PATCH 09/10] Clarify NEXTAUTH_SECRET is optional locally in README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4933070..6f18304 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,11 @@ Fill in the following values in `.env.local`: ``` GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... -NEXTAUTH_SECRET=... # generate with: openssl rand -base64 32 NEXTAUTH_URL=http://localhost:3000 NEXT_PUBLIC_FIREBASE_PROJECT_ID=... # your Firebase project ID + +# Optional locally, required in production: +# NEXTAUTH_SECRET=... # generate with: openssl rand -base64 32 ``` ### 7. Run From c3e52878b089965d5345fa2ba110803751538ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Garb=C3=B8l=20Franck?= Date: Mon, 23 Feb 2026 20:50:14 +0100 Subject: [PATCH 10/10] Mark NEXTAUTH_SECRET as required in README and .env.example --- .env.example | 4 +--- README.md | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 1f9ef0a..eb7b955 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,7 @@ GOOGLE_CLIENT_SECRET=your_google_client_secret # NextAuth NEXTAUTH_URL=http://localhost:3000 -# NEXTAUTH_SECRET is optional locally but required in production. -# Generate with: openssl rand -base64 32 -# NEXTAUTH_SECRET= +NEXTAUTH_SECRET=your_nextauth_secret # generate with: openssl rand -base64 32 # Firebase (project ID from Firebase Console → Project Settings) NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_id diff --git a/README.md b/README.md index 6f18304..3acfdd6 100644 --- a/README.md +++ b/README.md @@ -93,12 +93,12 @@ Fill in the following values in `.env.local`: GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=... # required — generate with: openssl rand -base64 32 NEXT_PUBLIC_FIREBASE_PROJECT_ID=... # your Firebase project ID - -# Optional locally, required in production: -# NEXTAUTH_SECRET=... # generate with: openssl rand -base64 32 ``` +> **Note:** `NEXTAUTH_SECRET` is required even locally. Without it, NextAuth generates a random secret on each server start, which causes session decryption errors every time you restart `npm run dev`. + ### 7. Run ```bash