@usemeno/meno-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/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +626 -0
- package/package.json +34 -0
- package/src/commands/log.ts +112 -0
- package/src/commands/login.ts +68 -0
- package/src/commands/select.ts +86 -0
- package/src/commands/start.ts +77 -0
- package/src/commands/status.ts +62 -0
- package/src/commands/stop.ts +132 -0
- package/src/config.ts +76 -0
- package/src/index.ts +55 -0
- package/src/utils/api.ts +113 -0
- package/src/utils/timer.ts +62 -0
- package/tsconfig.json +20 -0
package/src/utils/api.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fetch from "node-fetch";
|
|
2
|
+
import { getApiKey, getBaseUrl } from "../config.js";
|
|
3
|
+
|
|
4
|
+
export class ApiError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
public statusCode: number
|
|
8
|
+
) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "ApiError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function apiRequest<T = any>(
|
|
15
|
+
endpoint: string,
|
|
16
|
+
options: RequestInit & { body?: string } = {}
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
const apiKey = getApiKey();
|
|
19
|
+
const baseUrl = getBaseUrl();
|
|
20
|
+
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
throw new ApiError(
|
|
23
|
+
"Not logged in. Run 'meno login' to authenticate.",
|
|
24
|
+
401
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const url = `${baseUrl}${endpoint}`;
|
|
29
|
+
|
|
30
|
+
const headers = {
|
|
31
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
...options.headers,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetch(url, {
|
|
38
|
+
...options,
|
|
39
|
+
headers,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = await response.json() as any;
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
if (response.status === 401) {
|
|
46
|
+
throw new ApiError(
|
|
47
|
+
"Invalid API key. Run 'meno login' to re-authenticate.",
|
|
48
|
+
401
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (response.status === 429) {
|
|
53
|
+
throw new ApiError(
|
|
54
|
+
`Rate limit exceeded. ${data.error || "Try again later."}`,
|
|
55
|
+
429
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new ApiError(
|
|
60
|
+
data.error || `Request failed with status ${response.status}`,
|
|
61
|
+
response.status
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return data as T;
|
|
66
|
+
} catch (error: any) {
|
|
67
|
+
if (error instanceof ApiError) {
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") {
|
|
72
|
+
throw new ApiError(
|
|
73
|
+
`Cannot connect to ${baseUrl}. Check your internet connection or base URL.`,
|
|
74
|
+
0
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new ApiError(
|
|
79
|
+
error.message || "An unexpected error occurred",
|
|
80
|
+
500
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface Project {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
clientName: string;
|
|
89
|
+
clientCompany: string;
|
|
90
|
+
hourlyRate: number;
|
|
91
|
+
taxRate: number;
|
|
92
|
+
weeklyHourLimit: number;
|
|
93
|
+
hoursUsed: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TimeEntry {
|
|
97
|
+
id: string;
|
|
98
|
+
projectId: string;
|
|
99
|
+
projectName: string;
|
|
100
|
+
description: string;
|
|
101
|
+
date: string;
|
|
102
|
+
duration: number;
|
|
103
|
+
billable: boolean;
|
|
104
|
+
amount: number;
|
|
105
|
+
createdAt: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Stats {
|
|
109
|
+
totalHours: number;
|
|
110
|
+
totalRevenue: number;
|
|
111
|
+
entriesCount: number;
|
|
112
|
+
currency: string;
|
|
113
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats milliseconds into human-readable duration
|
|
3
|
+
* @param startTime ISO timestamp of when timer started
|
|
4
|
+
* @returns Formatted duration string like "2 hours 30 minutes" or "45 minutes"
|
|
5
|
+
*/
|
|
6
|
+
export function formatDuration(startTime: string): string {
|
|
7
|
+
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
8
|
+
const hours = Math.floor(elapsed / (1000 * 60 * 60));
|
|
9
|
+
const minutes = Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60));
|
|
10
|
+
|
|
11
|
+
if (hours > 0) {
|
|
12
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Calculates elapsed hours from start time
|
|
20
|
+
* @param startTime ISO timestamp
|
|
21
|
+
* @returns Hours as decimal (e.g., 1.5 for 90 minutes)
|
|
22
|
+
*/
|
|
23
|
+
export function calculateElapsedHours(startTime: string): number {
|
|
24
|
+
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
25
|
+
return elapsed / (1000 * 60 * 60);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Calculates current billable value of running timer
|
|
30
|
+
* @param startTime ISO timestamp
|
|
31
|
+
* @param hourlyRate Rate per hour
|
|
32
|
+
* @returns Current billable amount
|
|
33
|
+
*/
|
|
34
|
+
export function calculateTimerValue(startTime: string, hourlyRate: number): number {
|
|
35
|
+
const hours = calculateElapsedHours(startTime);
|
|
36
|
+
return hours * hourlyRate;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parses duration input into decimal hours
|
|
41
|
+
* Supports formats: "45m", "1.5h", "90" (assumes minutes)
|
|
42
|
+
* @param input Duration string
|
|
43
|
+
* @returns Hours as decimal, or null if invalid
|
|
44
|
+
*/
|
|
45
|
+
export function parseDuration(input: string): number | null {
|
|
46
|
+
const trimmed = input.trim().toLowerCase();
|
|
47
|
+
|
|
48
|
+
// Match patterns: "45m", "1.5h", "90"
|
|
49
|
+
const minutesMatch = trimmed.match(/^(\d+(?:\.\d+)?)m?$/);
|
|
50
|
+
const hoursMatch = trimmed.match(/^(\d+(?:\.\d+)?)h$/);
|
|
51
|
+
|
|
52
|
+
if (hoursMatch) {
|
|
53
|
+
return parseFloat(hoursMatch[1]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (minutesMatch) {
|
|
57
|
+
const minutes = parseFloat(minutesMatch[1]);
|
|
58
|
+
return minutes / 60;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"types": ["node"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|