@blastin-dev/clocktopus-cli 0.1.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,61 @@
1
+ # Clocktopus CLI
2
+
3
+ Command-line interface for [Clocktopus](https://clocktopus.app) time tracking.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @blastin-dev/clocktopus-cli
9
+ ```
10
+
11
+ Or run directly with npx:
12
+
13
+ ```bash
14
+ npx @blastin-dev/clocktopus-cli <command>
15
+ ```
16
+
17
+ ## Getting Started
18
+
19
+ Authenticate with your Clocktopus account:
20
+
21
+ ```bash
22
+ clocktopus login
23
+ ```
24
+
25
+ This will open a browser window where you authorize the CLI. Once complete, your session is stored locally.
26
+
27
+ ## Commands
28
+
29
+ ### `clocktopus login`
30
+
31
+ Authenticate using the device authorization flow. Opens a URL in your browser to approve access.
32
+
33
+ ### `clocktopus logout`
34
+
35
+ Clear stored credentials.
36
+
37
+ ### `clocktopus whoami`
38
+
39
+ Display the current authenticated user.
40
+
41
+ ### `clocktopus clock in`
42
+
43
+ Record a clock-in signal.
44
+
45
+ ### `clocktopus clock out`
46
+
47
+ Record a clock-out signal.
48
+
49
+ ### `clocktopus clock status`
50
+
51
+ Show signals for a given date.
52
+
53
+ ## Token Storage
54
+
55
+ Credentials are stored in a platform-specific config directory:
56
+
57
+ - **macOS**: `~/Library/Preferences/clocktopus-cli-nodejs/config.json`
58
+ - **Linux**: `~/.config/clocktopus-cli-nodejs/config.json`
59
+ - **Windows**: `%APPDATA%/clocktopus-cli-nodejs/config.json`
60
+
61
+ Run `clocktopus logout` to clear stored credentials.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=clocktopus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clocktopus.d.ts","sourceRoot":"","sources":["../../bin/clocktopus.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/index.js";
3
+ run();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Record a clock-in signal for today
3
+ */
4
+ export declare function clockInCommand(): Promise<void>;
5
+ /**
6
+ * Record a clock-out signal for today
7
+ */
8
+ export declare function clockOutCommand(): Promise<void>;
9
+ /**
10
+ * Show clock signals status for a given date (default: today)
11
+ */
12
+ export declare function clockStatusCommand(options: {
13
+ date?: string;
14
+ }): Promise<void>;
15
+ //# sourceMappingURL=clock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../../../src/commands/clock.ts"],"names":[],"mappings":"AAuDA;;GAEG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAqCpD;AAED;;GAEG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAuCrD;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDhB"}
@@ -0,0 +1,172 @@
1
+ import { format } from "date-fns";
2
+ import { z } from "zod";
3
+ import { ApiError, get, post } from "../lib/api.js";
4
+ import { isLoggedIn } from "../lib/config.js";
5
+ /**
6
+ * Schema for clock signal response
7
+ */
8
+ const ClockSignalResponseSchema = z.object({
9
+ success: z.boolean(),
10
+ signal: z.object({
11
+ id: z.string(),
12
+ signalType: z.enum(["clock_in", "clock_out"]),
13
+ workDate: z.string(),
14
+ effectiveTime: z.string(),
15
+ timezone: z.string(),
16
+ signalTimestamp: z.string(),
17
+ alreadyExists: z.boolean(),
18
+ blockedReason: z.enum(["already_clocked_in", "not_clocked_in"]).nullable(),
19
+ }),
20
+ });
21
+ /**
22
+ * Schema for clock signals list response
23
+ */
24
+ const ClockSignalsListSchema = z.object({
25
+ signals: z.array(z.object({
26
+ id: z.string(),
27
+ userId: z.string(),
28
+ signalType: z.enum(["clock_in", "clock_out"]),
29
+ signalTimestamp: z.string(),
30
+ workDate: z.string(),
31
+ timezone: z.string(),
32
+ effectiveTime: z.string(),
33
+ createdAt: z.string(),
34
+ })),
35
+ });
36
+ function checkAuth() {
37
+ if (!isLoggedIn()) {
38
+ console.error("You are not logged in. Use 'clocktopus login' to authenticate first.");
39
+ return false;
40
+ }
41
+ return true;
42
+ }
43
+ function formatSignalType(type) {
44
+ return type === "clock_in" ? "Clock In" : "Clock Out";
45
+ }
46
+ /**
47
+ * Record a clock-in signal for today
48
+ */
49
+ export async function clockInCommand() {
50
+ if (!checkAuth()) {
51
+ process.exit(1);
52
+ }
53
+ try {
54
+ const data = await post("/api/clock-signal", { signalType: "clock_in" });
55
+ const response = ClockSignalResponseSchema.parse(data);
56
+ if (response.signal.alreadyExists) {
57
+ console.log(`\nAlready clocked in - must clock out first.`);
58
+ console.log(` Current clock-in:`);
59
+ console.log(` Date: ${response.signal.workDate}`);
60
+ console.log(` Time: ${response.signal.effectiveTime}`);
61
+ console.log(` Timezone: ${response.signal.timezone}`);
62
+ }
63
+ else {
64
+ console.log(`\nClock In recorded!`);
65
+ console.log(` Date: ${response.signal.workDate}`);
66
+ console.log(` Time: ${response.signal.effectiveTime}`);
67
+ console.log(` Timezone: ${response.signal.timezone}`);
68
+ }
69
+ }
70
+ catch (error) {
71
+ if (error instanceof ApiError) {
72
+ if (error.status === 401) {
73
+ console.error("Session expired. Use 'clocktopus login' to authenticate again.");
74
+ }
75
+ else {
76
+ console.error(`Failed to record clock-in: ${error.message}`);
77
+ }
78
+ }
79
+ else {
80
+ console.error(`Failed to record clock-in: ${error instanceof Error ? error.message : "Unknown error"}`);
81
+ }
82
+ process.exit(1);
83
+ }
84
+ }
85
+ /**
86
+ * Record a clock-out signal for today
87
+ */
88
+ export async function clockOutCommand() {
89
+ if (!checkAuth()) {
90
+ process.exit(1);
91
+ }
92
+ try {
93
+ const data = await post("/api/clock-signal", { signalType: "clock_out" });
94
+ const response = ClockSignalResponseSchema.parse(data);
95
+ if (response.signal.alreadyExists) {
96
+ console.log(`\nNot clocked in - must clock in first.`);
97
+ if (response.signal.id) {
98
+ console.log(` Last signal:`);
99
+ console.log(` Date: ${response.signal.workDate}`);
100
+ console.log(` Time: ${response.signal.effectiveTime}`);
101
+ console.log(` Timezone: ${response.signal.timezone}`);
102
+ }
103
+ }
104
+ else {
105
+ console.log(`\nClock Out recorded!`);
106
+ console.log(` Date: ${response.signal.workDate}`);
107
+ console.log(` Time: ${response.signal.effectiveTime}`);
108
+ console.log(` Timezone: ${response.signal.timezone}`);
109
+ }
110
+ }
111
+ catch (error) {
112
+ if (error instanceof ApiError) {
113
+ if (error.status === 401) {
114
+ console.error("Session expired. Use 'clocktopus login' to authenticate again.");
115
+ }
116
+ else {
117
+ console.error(`Failed to record clock-out: ${error.message}`);
118
+ }
119
+ }
120
+ else {
121
+ console.error(`Failed to record clock-out: ${error instanceof Error ? error.message : "Unknown error"}`);
122
+ }
123
+ process.exit(1);
124
+ }
125
+ }
126
+ /**
127
+ * Show clock signals status for a given date (default: today)
128
+ */
129
+ export async function clockStatusCommand(options) {
130
+ if (!checkAuth()) {
131
+ process.exit(1);
132
+ }
133
+ // Use provided date or default to today (local time)
134
+ const date = options.date ?? format(new Date(), "yyyy-MM-dd");
135
+ // Validate date format
136
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
137
+ console.error("Invalid date format. Use YYYY-MM-DD.");
138
+ process.exit(1);
139
+ }
140
+ try {
141
+ const data = await get(`/api/clock-signal?date=${date}`);
142
+ const response = ClockSignalsListSchema.parse(data);
143
+ console.log(`\nClock signals for ${date}:`);
144
+ if (response.signals.length === 0) {
145
+ console.log(" No clock signals recorded for this date.");
146
+ console.log(" (Default work hours from your preferences will be used)");
147
+ }
148
+ else {
149
+ // Show all signals in chronological order
150
+ for (let i = 0; i < response.signals.length; i++) {
151
+ const signal = response.signals[i];
152
+ if (!signal)
153
+ continue;
154
+ console.log(` ${i + 1}. ${formatSignalType(signal.signalType)} at ${signal.effectiveTime} (${signal.timezone})`);
155
+ }
156
+ }
157
+ }
158
+ catch (error) {
159
+ if (error instanceof ApiError) {
160
+ if (error.status === 401) {
161
+ console.error("Session expired. Use 'clocktopus login' to authenticate again.");
162
+ }
163
+ else {
164
+ console.error(`Failed to fetch clock status: ${error.message}`);
165
+ }
166
+ }
167
+ else {
168
+ console.error(`Failed to fetch clock status: ${error instanceof Error ? error.message : "Unknown error"}`);
169
+ }
170
+ process.exit(1);
171
+ }
172
+ }
@@ -0,0 +1,2 @@
1
+ export declare function loginCommand(): Promise<void>;
2
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../../src/commands/login.ts"],"names":[],"mappings":"AAKA,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAsFlD"}
@@ -0,0 +1,72 @@
1
+ import { get } from "../lib/api.js";
2
+ import { pollForToken, requestDeviceCode, sleep } from "../lib/auth.js";
3
+ import { isLoggedIn, setToken } from "../lib/config.js";
4
+ import { UserSchema } from "../lib/validators.js";
5
+ export async function loginCommand() {
6
+ if (isLoggedIn()) {
7
+ console.log("You are already logged in. Use 'clocktopus logout' to log out first.");
8
+ return;
9
+ }
10
+ console.log("Starting device authorization...\n");
11
+ try {
12
+ const deviceCode = await requestDeviceCode();
13
+ const formattedCode = `${deviceCode.userCode.slice(0, 4)}-${deviceCode.userCode.slice(4)}`;
14
+ // Build URL with code pre-filled
15
+ const fullUrl = `${deviceCode.verificationUri}?user_code=${deviceCode.userCode}`;
16
+ console.log(`Open this link to authorize:\n`);
17
+ console.log(` ${fullUrl}\n`);
18
+ console.log(`Or visit ${deviceCode.verificationUri} and enter code: ${formattedCode}\n`);
19
+ console.log("Waiting for authorization...");
20
+ let interval = deviceCode.interval;
21
+ const startTime = Date.now();
22
+ const timeoutMs = deviceCode.expiresIn * 1000;
23
+ while (Date.now() - startTime < timeoutMs) {
24
+ await sleep(interval * 1000);
25
+ const result = await pollForToken(deviceCode.deviceCode, interval);
26
+ switch (result.status) {
27
+ case "success":
28
+ setToken(result.accessToken);
29
+ // Fetch user info to display
30
+ try {
31
+ const data = await get("/api/auth/me");
32
+ const response = UserSchema.parse(data);
33
+ console.log(`\nLogged in as ${response.user.name} (${response.user.email})`);
34
+ }
35
+ catch {
36
+ console.log("\nLogin successful!");
37
+ }
38
+ return;
39
+ case "pending":
40
+ // Continue polling
41
+ process.stdout.write(".");
42
+ break;
43
+ case "slow_down":
44
+ interval = result.newInterval;
45
+ break;
46
+ case "denied":
47
+ console.log(`\nAuthorization denied: ${result.message}`);
48
+ process.exit(1);
49
+ break;
50
+ case "expired":
51
+ console.log(`\nAuthorization expired: ${result.message}`);
52
+ process.exit(1);
53
+ break;
54
+ case "error":
55
+ console.log(`\nError: ${result.message}`);
56
+ process.exit(1);
57
+ break;
58
+ }
59
+ }
60
+ console.log("\nAuthorization timed out. Please try again.");
61
+ process.exit(1);
62
+ }
63
+ catch (error) {
64
+ if (error instanceof Error) {
65
+ console.error(`\nLogin failed: ${error.message}`);
66
+ }
67
+ else {
68
+ console.error("\nLogin failed: Unknown error");
69
+ }
70
+ process.exit(1);
71
+ }
72
+ }
@@ -0,0 +1,2 @@
1
+ export declare function logoutCommand(): void;
2
+ //# sourceMappingURL=logout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../../src/commands/logout.ts"],"names":[],"mappings":"AAEA,wBAAgB,aAAa,IAAI,IAAI,CAQpC"}
@@ -0,0 +1,9 @@
1
+ import { clearToken, isLoggedIn } from "../lib/config.js";
2
+ export function logoutCommand() {
3
+ if (!isLoggedIn()) {
4
+ console.log("You are not logged in.");
5
+ return;
6
+ }
7
+ clearToken();
8
+ console.log("Logged out successfully.");
9
+ }
@@ -0,0 +1,2 @@
1
+ export declare function whoamiCommand(): Promise<void>;
2
+ //# sourceMappingURL=whoami.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"whoami.d.ts","sourceRoot":"","sources":["../../../src/commands/whoami.ts"],"names":[],"mappings":"AAIA,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAoBnD"}
@@ -0,0 +1,26 @@
1
+ import { ApiError, get } from "../lib/api.js";
2
+ import { isLoggedIn } from "../lib/config.js";
3
+ import { UserSchema } from "../lib/validators.js";
4
+ export async function whoamiCommand() {
5
+ if (!isLoggedIn()) {
6
+ console.log("Not logged in. Use 'clocktopus login' to authenticate.");
7
+ return;
8
+ }
9
+ try {
10
+ const data = await get("/api/auth/me");
11
+ const response = UserSchema.parse(data);
12
+ console.log(`Logged in as ${response.user.name} (${response.user.email})`);
13
+ }
14
+ catch (error) {
15
+ if (error instanceof ApiError && error.status === 401) {
16
+ console.log("Session expired. Please run 'clocktopus login' again.");
17
+ }
18
+ else if (error instanceof Error) {
19
+ console.error(`Failed to fetch user info: ${error.message}`);
20
+ }
21
+ else {
22
+ console.error("Failed to fetch user info: Unknown error");
23
+ }
24
+ process.exit(1);
25
+ }
26
+ }
@@ -0,0 +1,2 @@
1
+ export declare function run(): void;
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAqEA,wBAAgB,GAAG,IAAI,IAAI,CAE1B"}
@@ -0,0 +1,54 @@
1
+ import { Command } from "commander";
2
+ import { clockInCommand, clockOutCommand, clockStatusCommand, } from "./commands/clock.js";
3
+ import { loginCommand } from "./commands/login.js";
4
+ import { logoutCommand } from "./commands/logout.js";
5
+ import { whoamiCommand } from "./commands/whoami.js";
6
+ import { ENVIRONMENTS, setRuntimeEnvironment, } from "./lib/config.js";
7
+ const program = new Command();
8
+ program
9
+ .name("clocktopus")
10
+ .description("CLI for Clocktopus time tracking")
11
+ .version("0.1.0")
12
+ .option("-e, --env <environment>", "Use environment (dev or prod)")
13
+ .hook("preAction", (thisCommand) => {
14
+ const opts = thisCommand.opts();
15
+ if (opts.env) {
16
+ if (!(opts.env in ENVIRONMENTS)) {
17
+ console.error(`Invalid environment: ${opts.env}. Use 'dev' or 'prod'.`);
18
+ process.exit(1);
19
+ }
20
+ setRuntimeEnvironment(opts.env);
21
+ }
22
+ });
23
+ program
24
+ .command("login")
25
+ .description("Authenticate with Clocktopus using device authorization")
26
+ .action(loginCommand);
27
+ program
28
+ .command("logout")
29
+ .description("Log out and clear stored credentials")
30
+ .action(logoutCommand);
31
+ program
32
+ .command("whoami")
33
+ .description("Display the currently logged in user")
34
+ .action(whoamiCommand);
35
+ // Clock commands (subcommands)
36
+ const clock = program
37
+ .command("clock")
38
+ .description("Record clock-in and clock-out signals");
39
+ clock
40
+ .command("in")
41
+ .description("Record clock-in for today")
42
+ .action(clockInCommand);
43
+ clock
44
+ .command("out")
45
+ .description("Record clock-out for today")
46
+ .action(clockOutCommand);
47
+ clock
48
+ .command("status")
49
+ .description("Show clock signals for a specific date")
50
+ .option("-d, --date <date>", "Date in YYYY-MM-DD format (default: today)")
51
+ .action(clockStatusCommand);
52
+ export function run() {
53
+ program.parse();
54
+ }
@@ -0,0 +1,16 @@
1
+ type RequestOptions = {
2
+ method?: "GET" | "POST" | "PUT" | "DELETE";
3
+ body?: Record<string, unknown>;
4
+ headers?: Record<string, string>;
5
+ authenticated?: boolean;
6
+ };
7
+ export declare class ApiError extends Error {
8
+ status: number;
9
+ statusText: string;
10
+ constructor(status: number, statusText: string, message?: string);
11
+ }
12
+ export declare function request<T>(path: string, options?: RequestOptions): Promise<T>;
13
+ export declare function get<T>(path: string, authenticated?: boolean): Promise<T>;
14
+ export declare function post<T>(path: string, body: Record<string, unknown>, authenticated?: boolean): Promise<T>;
15
+ export {};
16
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/lib/api.ts"],"names":[],"mappings":"AAEA,KAAK,cAAc,GAAG;IACpB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,QAAS,SAAQ,KAAK;IAExB,MAAM,EAAE,MAAM;IACd,UAAU,EAAE,MAAM;gBADlB,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,MAAM,EACzB,OAAO,CAAC,EAAE,MAAM;CAKnB;AAED,wBAAsB,OAAO,CAAC,CAAC,EAC7B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,CAAC,CAAC,CA6BZ;AAED,wBAAsB,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,UAAO,GAAG,OAAO,CAAC,CAAC,CAAC,CAE3E;AAED,wBAAsB,IAAI,CAAC,CAAC,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,aAAa,UAAO,GACnB,OAAO,CAAC,CAAC,CAAC,CAEZ"}
@@ -0,0 +1,41 @@
1
+ import { getApiUrl, getToken } from "./config.js";
2
+ export class ApiError extends Error {
3
+ status;
4
+ statusText;
5
+ constructor(status, statusText, message) {
6
+ super(message || `API Error: ${status} ${statusText}`);
7
+ this.status = status;
8
+ this.statusText = statusText;
9
+ this.name = "ApiError";
10
+ }
11
+ }
12
+ export async function request(path, options = {}) {
13
+ const { method = "GET", body, headers = {}, authenticated = true } = options;
14
+ const url = `${getApiUrl()}${path}`;
15
+ const requestHeaders = {
16
+ "Content-Type": "application/json",
17
+ ...headers,
18
+ };
19
+ if (authenticated) {
20
+ const token = getToken();
21
+ if (token) {
22
+ requestHeaders["Authorization"] = `Bearer ${token}`;
23
+ }
24
+ }
25
+ const response = await fetch(url, {
26
+ method,
27
+ headers: requestHeaders,
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+ if (!response.ok) {
31
+ const errorText = await response.text();
32
+ throw new ApiError(response.status, response.statusText, errorText);
33
+ }
34
+ return response.json();
35
+ }
36
+ export async function get(path, authenticated = true) {
37
+ return request(path, { method: "GET", authenticated });
38
+ }
39
+ export async function post(path, body, authenticated = true) {
40
+ return request(path, { method: "POST", body, authenticated });
41
+ }
@@ -0,0 +1,30 @@
1
+ export type DeviceCodeResult = {
2
+ deviceCode: string;
3
+ userCode: string;
4
+ verificationUri: string;
5
+ verificationUriComplete?: string;
6
+ expiresIn: number;
7
+ interval: number;
8
+ };
9
+ export declare function requestDeviceCode(): Promise<DeviceCodeResult>;
10
+ export type PollResult = {
11
+ status: "success";
12
+ accessToken: string;
13
+ } | {
14
+ status: "pending";
15
+ } | {
16
+ status: "slow_down";
17
+ newInterval: number;
18
+ } | {
19
+ status: "denied";
20
+ message: string;
21
+ } | {
22
+ status: "expired";
23
+ message: string;
24
+ } | {
25
+ status: "error";
26
+ message: string;
27
+ };
28
+ export declare function pollForToken(deviceCode: string, interval: number): Promise<PollResult>;
29
+ export declare function sleep(ms: number): Promise<void>;
30
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../src/lib/auth.ts"],"names":[],"mappings":"AA6BA,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,gBAAgB,CAAC,CA4BnE;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GACrB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,MAAM,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACtC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,UAAU,CAAC,CA8CrB;AAED,wBAAgB,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/C"}
@@ -0,0 +1,72 @@
1
+ import { getApiUrl } from "./config.js";
2
+ const CLIENT_ID = "clocktopus-cli";
3
+ export async function requestDeviceCode() {
4
+ const url = `${getApiUrl()}/api/auth/device/code`;
5
+ const response = await fetch(url, {
6
+ method: "POST",
7
+ headers: {
8
+ "Content-Type": "application/json",
9
+ },
10
+ body: JSON.stringify({
11
+ client_id: CLIENT_ID,
12
+ }),
13
+ });
14
+ if (!response.ok) {
15
+ const errorText = await response.text();
16
+ throw new Error(`Failed to request device code: ${errorText}`);
17
+ }
18
+ const data = (await response.json());
19
+ return {
20
+ deviceCode: data.device_code,
21
+ userCode: data.user_code,
22
+ verificationUri: data.verification_uri,
23
+ verificationUriComplete: data.verification_uri_complete,
24
+ expiresIn: data.expires_in,
25
+ interval: data.interval,
26
+ };
27
+ }
28
+ export async function pollForToken(deviceCode, interval) {
29
+ const url = `${getApiUrl()}/api/auth/device/token`;
30
+ const response = await fetch(url, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ },
35
+ body: JSON.stringify({
36
+ client_id: CLIENT_ID,
37
+ device_code: deviceCode,
38
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
39
+ }),
40
+ });
41
+ const data = (await response.json());
42
+ if ("access_token" in data) {
43
+ return { status: "success", accessToken: data.access_token };
44
+ }
45
+ if ("error" in data) {
46
+ switch (data.error) {
47
+ case "authorization_pending":
48
+ return { status: "pending" };
49
+ case "slow_down":
50
+ return { status: "slow_down", newInterval: interval + 5 };
51
+ case "access_denied":
52
+ return {
53
+ status: "denied",
54
+ message: data.error_description || "Access denied by user",
55
+ };
56
+ case "expired_token":
57
+ return {
58
+ status: "expired",
59
+ message: data.error_description || "Device code expired",
60
+ };
61
+ default:
62
+ return {
63
+ status: "error",
64
+ message: data.error_description || "Unknown error",
65
+ };
66
+ }
67
+ }
68
+ return { status: "error", message: "Unexpected response" };
69
+ }
70
+ export function sleep(ms) {
71
+ return new Promise((resolve) => setTimeout(resolve, ms));
72
+ }
@@ -0,0 +1,13 @@
1
+ export declare const ENVIRONMENTS: {
2
+ readonly prod: "https://clocktopus.app";
3
+ readonly dev: "http://localhost:3000";
4
+ };
5
+ export type Environment = keyof typeof ENVIRONMENTS;
6
+ export declare function setRuntimeEnvironment(env: Environment): void;
7
+ export declare function getEnvironment(): Environment;
8
+ export declare function getApiUrl(): string;
9
+ export declare function getToken(): string | undefined;
10
+ export declare function setToken(token: string): void;
11
+ export declare function clearToken(): void;
12
+ export declare function isLoggedIn(): boolean;
13
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/lib/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY;;;CAGf,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,YAAY,CAAC;AAapD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,WAAW,GAAG,IAAI,CAE5D;AAED,wBAAgB,cAAc,IAAI,WAAW,CAO5C;AAED,wBAAgB,SAAS,IAAI,MAAM,CASlC;AAED,wBAAgB,QAAQ,IAAI,MAAM,GAAG,SAAS,CAE7C;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAE5C;AAED,wBAAgB,UAAU,IAAI,IAAI,CAEjC;AAED,wBAAgB,UAAU,IAAI,OAAO,CAEpC"}
@@ -0,0 +1,42 @@
1
+ import Conf from "conf";
2
+ export const ENVIRONMENTS = {
3
+ prod: "https://clocktopus.app",
4
+ dev: "http://localhost:3000",
5
+ };
6
+ const config = new Conf({
7
+ projectName: "clocktopus-cli",
8
+ });
9
+ // Runtime override from --env flag
10
+ let runtimeEnv;
11
+ export function setRuntimeEnvironment(env) {
12
+ runtimeEnv = env;
13
+ }
14
+ export function getEnvironment() {
15
+ // Check runtime flag
16
+ if (runtimeEnv) {
17
+ return runtimeEnv;
18
+ }
19
+ // Default to prod
20
+ return "prod";
21
+ }
22
+ export function getApiUrl() {
23
+ // 1. Check environment variable (highest priority)
24
+ if (process.env.CLOCKTOPUS_API_URL) {
25
+ return process.env.CLOCKTOPUS_API_URL;
26
+ }
27
+ // 2. Check runtime flag, then default to prod
28
+ const env = getEnvironment();
29
+ return ENVIRONMENTS[env];
30
+ }
31
+ export function getToken() {
32
+ return config.get("accessToken");
33
+ }
34
+ export function setToken(token) {
35
+ config.set("accessToken", token);
36
+ }
37
+ export function clearToken() {
38
+ config.delete("accessToken");
39
+ }
40
+ export function isLoggedIn() {
41
+ return !!getToken();
42
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ export declare const UserSchema: z.ZodObject<{
3
+ user: z.ZodObject<{
4
+ id: z.ZodString;
5
+ name: z.ZodString;
6
+ email: z.ZodString;
7
+ avatarUrl: z.ZodOptional<z.ZodNullable<z.ZodString>>;
8
+ createdAt: z.ZodOptional<z.ZodString>;
9
+ updatedAt: z.ZodOptional<z.ZodString>;
10
+ }, z.core.$strip>;
11
+ }, z.core.$strip>;
12
+ export type UserResponse = z.infer<typeof UserSchema>;
13
+ export declare const DeviceCodeResponseSchema: z.ZodObject<{
14
+ device_code: z.ZodString;
15
+ user_code: z.ZodString;
16
+ verification_uri: z.ZodString;
17
+ verification_uri_complete: z.ZodOptional<z.ZodString>;
18
+ expires_in: z.ZodNumber;
19
+ interval: z.ZodNumber;
20
+ }, z.core.$strip>;
21
+ export type DeviceCodeResponse = z.infer<typeof DeviceCodeResponseSchema>;
22
+ export declare const TokenResponseSchema: z.ZodObject<{
23
+ access_token: z.ZodString;
24
+ token_type: z.ZodString;
25
+ expires_in: z.ZodOptional<z.ZodNumber>;
26
+ }, z.core.$strip>;
27
+ export declare const TokenErrorResponseSchema: z.ZodObject<{
28
+ error: z.ZodEnum<{
29
+ authorization_pending: "authorization_pending";
30
+ slow_down: "slow_down";
31
+ access_denied: "access_denied";
32
+ expired_token: "expired_token";
33
+ invalid_request: "invalid_request";
34
+ }>;
35
+ error_description: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$strip>;
37
+ export type TokenResponse = z.infer<typeof TokenResponseSchema>;
38
+ export type TokenErrorResponse = z.infer<typeof TokenErrorResponseSchema>;
39
+ //# sourceMappingURL=validators.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validators.d.ts","sourceRoot":"","sources":["../../../src/lib/validators.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,eAAO,MAAM,UAAU;;;;;;;;;iBASrB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,eAAO,MAAM,wBAAwB;;;;;;;iBAOnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,mBAAmB;;;;iBAI9B,CAAC;AAEH,eAAO,MAAM,wBAAwB;;;;;;;;;iBASnC,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAChE,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ export const UserSchema = z.object({
3
+ user: z.object({
4
+ id: z.string(),
5
+ name: z.string(),
6
+ email: z.string().email(),
7
+ avatarUrl: z.string().nullable().optional(),
8
+ createdAt: z.string().optional(),
9
+ updatedAt: z.string().optional(),
10
+ }),
11
+ });
12
+ export const DeviceCodeResponseSchema = z.object({
13
+ device_code: z.string(),
14
+ user_code: z.string(),
15
+ verification_uri: z.string(),
16
+ verification_uri_complete: z.string().optional(),
17
+ expires_in: z.number(),
18
+ interval: z.number(),
19
+ });
20
+ export const TokenResponseSchema = z.object({
21
+ access_token: z.string(),
22
+ token_type: z.string(),
23
+ expires_in: z.number().optional(),
24
+ });
25
+ export const TokenErrorResponseSchema = z.object({
26
+ error: z.enum([
27
+ "authorization_pending",
28
+ "slow_down",
29
+ "access_denied",
30
+ "expired_token",
31
+ "invalid_request",
32
+ ]),
33
+ error_description: z.string().optional(),
34
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@blastin-dev/clocktopus-cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "clocktopus": "./dist/bin/clocktopus.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "dependencies": {
12
+ "commander": "^13.0.0",
13
+ "conf": "^13.0.0",
14
+ "zod": "4.1.12"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "22.15.3",
18
+ "eslint": "^9.28.0",
19
+ "typescript": "5.9.2",
20
+ "@repo/eslint-config": "0.0.0",
21
+ "@repo/typescript-config": "0.0.0",
22
+ "@repo/prettier-config": "0.1.0"
23
+ },
24
+ "prettier": "@repo/prettier-config",
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "dev": "tsc --watch",
28
+ "check-types": "tsc --noEmit",
29
+ "lint": "eslint",
30
+ "format": "prettier --check . --ignore-path ../../.gitignore",
31
+ "format:fix": "prettier --write . --ignore-path ../../.gitignore"
32
+ }
33
+ }