clocktopus 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,297 @@
1
+ # Clocktopus
2
+
3
+ <p align="center">
4
+ <img src="assets/logo.png" alt="Clocktopus Logo" width="300px" />
5
+ </p>
6
+
7
+ ## About
8
+
9
+ Clocktopus is a powerful command-line interface tool designed to streamline your time tracking with platforms like Clockify and Jira. It offers a suite of features to automate and simplify the process of logging your work, ensuring accuracy and efficiency.
10
+
11
+ ### Key Features
12
+
13
+ - **Automated Idle Monitoring:** Automatically stops and restarts timers based on your system's idle activity.
14
+ - **Jira Integration:** Seamlessly link your time entries to Jira tickets, fetching and prepending ticket titles to descriptions.
15
+ - **Google Calendar Integration:** Log your Google Calendar events directly as Clockify time entries, with intelligent project caching for recurring events.
16
+ - **Local Project Filtering:** Curate a personalized list of projects for quick selection, reducing clutter.
17
+ - **Session Management:** Start, stop, and check the status of your time entries directly from the terminal.
18
+ - **Database Cleanup:** Easily manage and clean up old session logs from the local SQLite database.
19
+
20
+ ## Installation
21
+
22
+ To get started with the Clocktopus CLI, follow these steps:
23
+
24
+ 1. **Clone the repository:**
25
+
26
+ ```bash
27
+ git clone <repository_url>
28
+ cd clocktopus
29
+ ```
30
+
31
+ 2. **Install dependencies:**
32
+ ```bash
33
+ bun install
34
+ ```
35
+
36
+ ## Configuration
37
+
38
+ ### Environment Variables
39
+
40
+ Create a `.env` file in the `data/` directory with the following variables:
41
+
42
+ ```
43
+ CLOCKIFY_API_KEY="your_clockify_api_key_here"
44
+ ATLASSIAN_CLIENT_ID="your_atlassian_oauth_client_id"
45
+ ATLASSIAN_CLIENT_SECRET="your_atlassian_oauth_client_secret"
46
+ GOOGLE_CLIENT_ID="google_client_id"
47
+ GOOGLE_CLIENT_SECRET="google_client_secret"
48
+ ```
49
+
50
+ You can get your Clockify API key from [Manage API Keys](https://app.clockify.me/manage-api-keys).
51
+
52
+ ### Jira Integration (Atlassian OAuth)
53
+
54
+ Clocktopus uses Atlassian OAuth 2.0 so users can connect their Jira account with a single click from the dashboard instead of manually copying API tokens.
55
+
56
+ #### Setting up the Atlassian OAuth App
57
+
58
+ 1. Go to [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and create a new **OAuth 2.0 (3LO)** app
59
+ 2. Under **Authorization**, set the callback URL to:
60
+ ```
61
+ http://localhost:4001/api/jira/callback
62
+ ```
63
+ 3. Under **Permissions**, add the following scopes:
64
+ - **Jira API**: `read:jira-work`, `write:jira-work`
65
+ - **User identity API**: `read:me`
66
+ 4. Copy the **Client ID** and **Client Secret** from the app's **Settings** page
67
+ 5. Add them to your `.env` file as `ATLASSIAN_CLIENT_ID` and `ATLASSIAN_CLIENT_SECRET`
68
+ 6. Start the dashboard (`bun run dashboard`) and click **Connect Atlassian**
69
+
70
+ > **Fallback:** If you prefer using an API token instead of OAuth, you can expand the "or use API token" section on the dashboard and enter your credentials manually. For this, set the following in `.env`:
71
+ >
72
+ > ```
73
+ > ATLASSIAN_URL="https://your_org.atlassian.net/rest/api/3"
74
+ > ATLASSIAN_API_TOKEN="your_atlassian_api_token_here"
75
+ > ATLASSIAN_EMAIL="username@example.com"
76
+ > ```
77
+
78
+ ### Local Projects Filtering
79
+
80
+ The CLI allows you to filter the projects displayed when starting a new time entry. On the first run of the `clock start` command, the application will fetch all your Clockify projects and populate `data/local-projects.json` with their IDs and names. You can then edit this file to keep only the projects you frequently work on.
81
+
82
+ Example `data/local-projects.json`:
83
+
84
+ ```json
85
+ [
86
+ {
87
+ "id": "671b783fbd91bc5e5ddcb944",
88
+ "name": "2024 Project Management Traineeship"
89
+ },
90
+ {
91
+ "id": "another_project_id",
92
+ "name": "Another Project Name"
93
+ }
94
+ ]
95
+ ```
96
+
97
+ Remove any project objects (both `id` and `name`) that you don't want to appear in the project selection list.
98
+
99
+ ## Usage
100
+
101
+ ### Build the application
102
+
103
+ Before running, you need to compile the TypeScript code:
104
+
105
+ ```bash
106
+ bun run build
107
+ ```
108
+
109
+ ### Run the application
110
+
111
+ Once built, you can run the CLI commands using the following commands:
112
+
113
+ - **Monitor idle state and auto-manage timers:**
114
+
115
+ ```bash
116
+ bun run monitor
117
+ ```
118
+
119
+ This command will monitor your system's idle time and automatically manage your Clockify timer:
120
+ - If you are idle for more than 5 minutes, the currently running timer will be stopped.
121
+ - When you become active again (move the mouse, press a key, etc.), if your last session was auto-completed due to idleness, a new timer will automatically be created for the last used project.
122
+ - All session events (start, stop, auto-complete, resume) are logged locally in the SQLite database, including project and description.
123
+
124
+ This ensures your time tracking is accurate even if you step away from your computer or forget to manually stop and restart your timer.
125
+
126
+ > Note: This ensures your time tracking is accurate even if you step away from your computer or forget to manually stop and restart your timer.
127
+ > If you do not want the background process to check your idle state, you can skip this.
128
+
129
+ - **Start a new time entry:**
130
+
131
+ ```bash
132
+ bun run clock start "Task description"
133
+ ```
134
+
135
+ This will prompt you to select a project from your curated list.
136
+
137
+ - **Start a new time entry with a Jira ticket:**
138
+
139
+ ```bash
140
+ bun run clock start -j TICKET-123
141
+ ```
142
+
143
+ When you provide a Jira ticket number with the `-j` flag, the tool will automatically fetch the ticket's title from Jira and prepend it to your time entry description. For example, if the title of `TICKET-123` is "Fix the login button", the description will be saved as `TICKET-123 Fix the login button`. If you also provide a description, it will be appended after the Jira title.
144
+
145
+ - **Stop the currently running time entry:**
146
+
147
+ ```bash
148
+ bun run clock stop
149
+ ```
150
+
151
+ - **Check the status of the current timer:**
152
+ ```bash
153
+ bun run clock status
154
+ ```
155
+
156
+ ### Manage Monitor
157
+
158
+ - **Restart the monitor process (after code changes):**
159
+
160
+ ```bash
161
+ bun run monitor:restart
162
+ ```
163
+
164
+ Use this command to restart the monitor process, for example after making code changes or updating dependencies. This ensures the monitor is running the latest version of your code.
165
+
166
+ - **Stop the monitor process:**
167
+
168
+ ```bash
169
+ bun run monitor:stop
170
+ ```
171
+
172
+ This command will stop the monitor process if it is running in the background. Use this when you want to fully halt all automatic idle monitoring and timer management.
173
+
174
+ - **Show monitor logs:**
175
+
176
+ ```bash
177
+ bun run monitor:logs
178
+ ```
179
+
180
+ This command will display logs related to the monitor process. Use it to review idle/active transitions, timer events, and session details that have been recorded while the monitor was running. This is useful for troubleshooting, auditing, or reviewing your time tracking history.
181
+
182
+ ### Database Cleanup
183
+
184
+ To delete old session logs from the local SQLite database, use the `db:cleanup` command:
185
+
186
+ ```bash
187
+ bun run db:cleanup <older-than-number-in-days>
188
+ ```
189
+
190
+ - `<older-than-number-in-days>` (Optional): Specifies the number of days. Session logs older than this number of days will be deleted. If not provided, logs older than 5 days will be deleted by default.
191
+
192
+ Examples:
193
+
194
+ - Delete logs older than 5 days (default):
195
+
196
+ ```bash
197
+ bun run db:cleanup
198
+ ```
199
+
200
+ - Delete logs older than 5 days:
201
+ ```bash
202
+ bun run db:cleanup 5
203
+ ```
204
+
205
+ ### Google Calendar Integration
206
+
207
+ This tool can log your Google Calendar events as time entries in Clockify. This is particularly useful for automatically tracking time spent in meetings or other scheduled events.
208
+
209
+ #### 1. Google Authentication
210
+
211
+ Before you can log calendar events, you need to authenticate with your Google account. This will grant the tool read-only access to your Google Calendar.
212
+
213
+ ```bash
214
+ bun run google-auth
215
+ ```
216
+
217
+ Follow the prompts in your browser to complete the authentication process. A token will be stored locally to maintain your session.
218
+
219
+ #### 2. Log Calendar Events
220
+
221
+ Once authenticated, you can log events for a specific date range:
222
+
223
+ ```bash
224
+ bun run log-calendar -s <start-date> -e <end-date>
225
+ ```
226
+
227
+ - `<start-date>`: The start date for fetching calendar events (e.g., `2025-07-21`).
228
+ - `<end-date>`: The end date for fetching calendar events (e.g., `2025-07-22`).
229
+
230
+ You can also log events for today using the `-t` or `--today` flag:
231
+
232
+ ```bash
233
+ bun run log-calendar -t
234
+ ```
235
+
236
+ For each calendar event, the tool will prompt you to select a Clockify project. Your selection will be cached based on the event's summary (name), so if you have recurring events with the same name, you will only be asked once for the project. If you provide a `project-id` using the `-p` flag, all events will be logged to that project without prompting.
237
+
238
+ Example:
239
+
240
+ ```bash
241
+ bun run log-calendar -s 2025-07-21 -e 2025-07-22
242
+ ```
243
+
244
+ Or, to log all events to a specific project:
245
+
246
+ ```bash
247
+ bun run log-calendar -s 2025-07-21 -e 2025-07-22 -p your_clockify_project_id
248
+ ```
249
+
250
+ ## Troubleshooting
251
+
252
+ ### No notifications on macOS
253
+
254
+ If you are not receiving notifications on macOS, you may need to adjust your system settings.
255
+
256
+ - **Check System Settings for Notifications:**
257
+ - Go to **System Settings > Notifications**.
258
+ - Look for **Terminal** (or your specific terminal application if you use another one like iTerm).
259
+ - Make sure that **Allow Notifications** is turned on for it.
260
+ - If you see an entry for **Node**, ensure it also has permissions.
261
+
262
+ Often, the first time a script tries to send a notification, macOS will ask for permission. If this was accidentally denied, you won't see any notifications.
263
+
264
+ ## Linux Requirements
265
+
266
+ X server development package and pkg-config are required to run `desktop-idle` package:
267
+
268
+ ```
269
+ apt install libxss-dev pkg-config build-essential
270
+ ```
271
+
272
+ ## Zsh Alias
273
+
274
+ If you super lazy just like me, then you can add aliases for different actions. Here is what I use:
275
+
276
+ ```bash
277
+ # Clocktopus
278
+ CLOCKTOPUS_PATH="$HOME/Projects/Personal/clocktopus"
279
+
280
+ clocktopus() {
281
+ cd "$CLOCKTOPUS_PATH" || return
282
+ bun run "$@"
283
+ }
284
+
285
+ alias cbuild="clocktopus build"
286
+ alias cstart="clocktopus clock start"
287
+ alias cstop="clocktopus clock stop"
288
+ alias mstart="clocktopus monitor"
289
+ alias mstop="clocktopus monitor:stop"
290
+ alias mrestart="clocktopus monitor:restart"
291
+ alias mstatus="clocktopus monitor:status"
292
+ alias mlogs="clocktopus monitor:logs"
293
+ alias cgcalauth="clocktopus google-auth"
294
+ alias cgcal="clocktopus log-calendar"
295
+ ```
296
+
297
+ Copy the above code in `.zshrc` file, change `CLOCKTOPUS_PATH` based on your path and save it. Then source the file using `source ~/.zshrc`.
@@ -0,0 +1,188 @@
1
+ import { HttpClient } from './lib/http-client.js';
2
+ import { logSessionStart } from './lib/db.js';
3
+ import { v4 as uuidv4 } from 'uuid';
4
+ import { NotificationCenter } from 'node-notifier';
5
+ import { getJiraTicket } from './lib/jira.js';
6
+ export class Clockify {
7
+ constructor() {
8
+ this.httpClient = new HttpClient().getClient();
9
+ this.notifier = new NotificationCenter();
10
+ }
11
+ sendNotification(title, message, actions, callback) {
12
+ this.notifier.notify({
13
+ title,
14
+ message,
15
+ sound: true,
16
+ wait: true,
17
+ actions,
18
+ }, callback ??
19
+ ((err) => {
20
+ if (err)
21
+ console.error('Notification error:', err);
22
+ }));
23
+ }
24
+ async getUser() {
25
+ try {
26
+ const response = await this.httpClient.get('/user');
27
+ return response.data;
28
+ }
29
+ catch (error) {
30
+ if (error instanceof Error) {
31
+ console.error('[clockify] Could not connect to Clockify. Please check your API key.', error.message);
32
+ }
33
+ else {
34
+ console.error('[clockify] An unknown error occurred.');
35
+ }
36
+ return null;
37
+ }
38
+ }
39
+ async getProjects(workspaceId) {
40
+ try {
41
+ let allProjects = [];
42
+ let page = 1;
43
+ const pageSize = 50;
44
+ let hasMore = true;
45
+ while (hasMore) {
46
+ const response = await this.httpClient.get(`/workspaces/${workspaceId}/projects`, {
47
+ params: {
48
+ page: page,
49
+ 'page-size': pageSize,
50
+ archived: false,
51
+ },
52
+ });
53
+ if (response.data.length > 0) {
54
+ allProjects = allProjects.concat(response.data);
55
+ page++;
56
+ }
57
+ else {
58
+ hasMore = false;
59
+ }
60
+ }
61
+ return allProjects;
62
+ }
63
+ catch (error) {
64
+ if (error instanceof Error) {
65
+ console.error('Error fetching projects:', error.message);
66
+ }
67
+ else {
68
+ console.error('Error fetching projects: An unknown error occurred.');
69
+ }
70
+ return [];
71
+ }
72
+ }
73
+ async getProjectById(workspaceId, projectId) {
74
+ try {
75
+ const response = await this.httpClient.get(`/workspaces/${workspaceId}/projects/${projectId}`);
76
+ return response.data;
77
+ }
78
+ catch (error) {
79
+ if (error instanceof Error) {
80
+ console.error('Error fetching project:', error.message);
81
+ }
82
+ else {
83
+ console.error('Error fetching project: An unknown error occurred.');
84
+ }
85
+ return null;
86
+ }
87
+ }
88
+ async startTimer(workspaceId, projectId, description = 'Working on a task...', jiraTicket) {
89
+ try {
90
+ const user = await this.getUser();
91
+ if (!user) {
92
+ return null;
93
+ }
94
+ let finalDescription = description;
95
+ if (jiraTicket) {
96
+ const ticket = await getJiraTicket(jiraTicket);
97
+ if (ticket) {
98
+ finalDescription = `${jiraTicket} ${ticket.fields.summary}`;
99
+ }
100
+ }
101
+ const startedAt = new Date().toISOString();
102
+ const sessionId = uuidv4();
103
+ const response = await this.httpClient.post(`/workspaces/${workspaceId}/time-entries`, {
104
+ projectId: projectId,
105
+ description: finalDescription,
106
+ start: startedAt,
107
+ });
108
+ // Log session to SQLite
109
+ logSessionStart(sessionId, projectId, finalDescription, startedAt, jiraTicket);
110
+ const project = await this.getProjectById(workspaceId, projectId);
111
+ this.sendNotification(`Timer started for ${project ? project.name : 'a project'}`, finalDescription, ['Stop'], (err, response, metadata) => {
112
+ if (err) {
113
+ console.error(err);
114
+ return;
115
+ }
116
+ if (metadata.activationValue === 'Stop') {
117
+ this.stopTimer(workspaceId, user.id);
118
+ }
119
+ });
120
+ return response.data;
121
+ }
122
+ catch (error) {
123
+ if (error instanceof Error) {
124
+ console.error('Error starting timer:', error.message);
125
+ }
126
+ else {
127
+ console.error('Error starting timer: An unknown error occurred.');
128
+ }
129
+ return null;
130
+ }
131
+ }
132
+ async stopTimer(workspaceId, userId) {
133
+ try {
134
+ const response = await this.httpClient.patch(`/workspaces/${workspaceId}/user/${userId}/time-entries`, {
135
+ end: new Date().toISOString(),
136
+ });
137
+ this.sendNotification('Timer stopped', 'Your timer has been stopped.');
138
+ return response.data;
139
+ }
140
+ catch (error) {
141
+ if (error instanceof Error) {
142
+ console.error('Error stopping timer:', error.message);
143
+ }
144
+ else {
145
+ console.error('Error stopping timer: An unknown error occurred.');
146
+ }
147
+ return null;
148
+ }
149
+ }
150
+ async getActiveTimer(workspaceId, userId) {
151
+ try {
152
+ const response = await this.httpClient.get(`/workspaces/${workspaceId}/user/${userId}/time-entries?in-progress=true`);
153
+ return response.data[0];
154
+ }
155
+ catch (error) {
156
+ if (error instanceof Error) {
157
+ console.error('Error fetching active timer:', error.message);
158
+ }
159
+ else {
160
+ console.error('Error fetching active timer: An unknown error occurred.');
161
+ }
162
+ return null;
163
+ }
164
+ }
165
+ async logTime(workspaceId, projectId, start, end, description) {
166
+ if (!projectId) {
167
+ return null;
168
+ }
169
+ try {
170
+ const response = await this.httpClient.post(`/workspaces/${workspaceId}/time-entries`, {
171
+ projectId: projectId,
172
+ start: start,
173
+ end: end,
174
+ description: description,
175
+ });
176
+ return response.data;
177
+ }
178
+ catch (error) {
179
+ if (error instanceof Error) {
180
+ console.error('Error logging time:', error.message);
181
+ }
182
+ else {
183
+ console.error('Error logging time: An unknown error occurred.');
184
+ }
185
+ return null;
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,7 @@
1
+ import { resolveCredential } from '../lib/credentials.js';
2
+ export default {
3
+ baseUrl: 'https://api.clockify.me/api/v1',
4
+ get apiKey() {
5
+ return resolveCredential('CLOCKIFY_API_KEY');
6
+ },
7
+ };
@@ -0,0 +1,25 @@
1
+ import { Hono } from 'hono';
2
+ import axios from 'axios';
3
+ import { saveCredential } from '../../lib/credentials.js';
4
+ const clockifyRoutes = new Hono();
5
+ clockifyRoutes.post('/clockify', async (c) => {
6
+ const { apiKey } = await c.req.json();
7
+ if (!apiKey) {
8
+ return c.json({ ok: false, error: 'API key is required.' }, 400);
9
+ }
10
+ try {
11
+ const res = await axios.get('https://api.clockify.me/api/v1/user', {
12
+ headers: { 'X-Api-Key': apiKey },
13
+ timeout: 5000,
14
+ });
15
+ if (res.status === 200) {
16
+ saveCredential('CLOCKIFY_API_KEY', apiKey);
17
+ return c.json({ ok: true, user: res.data.name });
18
+ }
19
+ return c.json({ ok: false, error: 'Invalid API key.' });
20
+ }
21
+ catch {
22
+ return c.json({ ok: false, error: 'Could not validate API key with Clockify.' });
23
+ }
24
+ });
25
+ export default clockifyRoutes;
@@ -0,0 +1,61 @@
1
+ import { Hono } from 'hono';
2
+ import { getRecentSessions, getSessionCount, getActiveProjects, getAllProjects, upsertProjects, toggleProjectActive, } from '../../lib/db.js';
3
+ import { Clockify } from '../../clockify.js';
4
+ const dataRoutes = new Hono();
5
+ // Active projects for timer dropdown
6
+ dataRoutes.get('/projects', (c) => {
7
+ const projects = getActiveProjects();
8
+ return c.json(projects);
9
+ });
10
+ // All projects for settings management
11
+ dataRoutes.get('/projects/all', (c) => {
12
+ const projects = getAllProjects();
13
+ return c.json(projects);
14
+ });
15
+ // Fetch projects from Clockify and save to DB
16
+ dataRoutes.post('/projects/fetch', async (c) => {
17
+ try {
18
+ const clockify = new Clockify();
19
+ const user = await clockify.getUser();
20
+ if (!user)
21
+ return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
22
+ const projects = await clockify.getProjects(user.defaultWorkspace);
23
+ if (projects.length === 0)
24
+ return c.json({ ok: false, error: 'No projects found.' }, 404);
25
+ upsertProjects(projects);
26
+ return c.json({ ok: true, count: projects.length });
27
+ }
28
+ catch {
29
+ return c.json({ ok: false, error: 'Failed to fetch projects.' }, 500);
30
+ }
31
+ });
32
+ // Toggle project active status
33
+ dataRoutes.post('/projects/toggle', async (c) => {
34
+ const { id, active } = await c.req.json();
35
+ if (!id)
36
+ return c.json({ ok: false, error: 'Project ID required.' }, 400);
37
+ toggleProjectActive(id, active);
38
+ return c.json({ ok: true });
39
+ });
40
+ // Sessions with pagination
41
+ dataRoutes.get('/sessions', (c) => {
42
+ const page = Math.max(1, parseInt(c.req.query('page') || '1', 10));
43
+ const limit = Math.min(50, Math.max(1, parseInt(c.req.query('limit') || '10', 10)));
44
+ const offset = (page - 1) * limit;
45
+ const sessions = getRecentSessions(limit, offset);
46
+ const total = getSessionCount();
47
+ const allProjects = getAllProjects();
48
+ const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
49
+ const enriched = sessions.map((s) => ({
50
+ ...s,
51
+ projectName: projectMap.get(s.projectId) ?? 'Unknown',
52
+ }));
53
+ return c.json({
54
+ data: enriched,
55
+ page,
56
+ limit,
57
+ total,
58
+ totalPages: Math.ceil(total / limit),
59
+ });
60
+ });
61
+ export default dataRoutes;
@@ -0,0 +1,49 @@
1
+ import { Hono } from 'hono';
2
+ import { google } from 'googleapis';
3
+ import { getAuthenticatedClient, getAuthUrl, exchangeGoogleCode } from '../../lib/google.js';
4
+ import { storeToken } from '../../lib/db.js';
5
+ import { saveCredential } from '../../lib/credentials.js';
6
+ const DASHBOARD_REDIRECT_URI = 'http://localhost:4001/api/google/callback';
7
+ const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.email'];
8
+ const googleRoutes = new Hono();
9
+ googleRoutes.get('/google/connect', async (c) => {
10
+ try {
11
+ const url = await getAuthUrl(DASHBOARD_REDIRECT_URI, SCOPES);
12
+ return c.redirect(url);
13
+ }
14
+ catch {
15
+ return c.json({ ok: false, error: 'Failed to generate Google auth URL.' }, 500);
16
+ }
17
+ });
18
+ googleRoutes.get('/google/auth-url', async (c) => {
19
+ try {
20
+ const url = await getAuthUrl(DASHBOARD_REDIRECT_URI, SCOPES);
21
+ return c.json({ url });
22
+ }
23
+ catch {
24
+ return c.json({ ok: false, error: 'Failed to generate Google auth URL.' }, 500);
25
+ }
26
+ });
27
+ googleRoutes.get('/google/callback', async (c) => {
28
+ const code = c.req.query('code');
29
+ if (!code) {
30
+ return c.text('Missing authorization code.', 400);
31
+ }
32
+ try {
33
+ const tokens = await exchangeGoogleCode(code, DASHBOARD_REDIRECT_URI);
34
+ storeToken(tokens);
35
+ // Fetch and store the user's email
36
+ const oAuth2Client = getAuthenticatedClient(DASHBOARD_REDIRECT_URI);
37
+ oAuth2Client.setCredentials(tokens);
38
+ const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client });
39
+ const { data } = await oauth2.userinfo.get();
40
+ if (data.email) {
41
+ saveCredential('GOOGLE_ACCOUNT_EMAIL', data.email);
42
+ }
43
+ return c.redirect('/?google=connected');
44
+ }
45
+ catch {
46
+ return c.text('Failed to exchange authorization code for tokens.', 500);
47
+ }
48
+ });
49
+ export default googleRoutes;