@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/dist/index.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/login.ts
|
|
7
|
+
import enquirer from "enquirer";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import Conf from "conf";
|
|
13
|
+
var config = new Conf({
|
|
14
|
+
projectName: "meno-cli"
|
|
15
|
+
});
|
|
16
|
+
function getApiKey() {
|
|
17
|
+
return config.get("apiKey");
|
|
18
|
+
}
|
|
19
|
+
function setApiKey(key) {
|
|
20
|
+
config.set("apiKey", key);
|
|
21
|
+
}
|
|
22
|
+
function getBaseUrl() {
|
|
23
|
+
return config.get("baseUrl") || process.env.MENO_BASE_URL || "http://localhost:3000";
|
|
24
|
+
}
|
|
25
|
+
function setBaseUrl(url) {
|
|
26
|
+
config.set("baseUrl", url);
|
|
27
|
+
}
|
|
28
|
+
function getSelectedProject() {
|
|
29
|
+
return config.get("selectedProject");
|
|
30
|
+
}
|
|
31
|
+
function setSelectedProject(project) {
|
|
32
|
+
config.set("selectedProject", project);
|
|
33
|
+
}
|
|
34
|
+
function clearSelectedProject() {
|
|
35
|
+
config.delete("selectedProject");
|
|
36
|
+
}
|
|
37
|
+
function getActiveTimer() {
|
|
38
|
+
return config.get("activeTimer");
|
|
39
|
+
}
|
|
40
|
+
function setActiveTimer(timer) {
|
|
41
|
+
config.set("activeTimer", timer);
|
|
42
|
+
}
|
|
43
|
+
function clearActiveTimer() {
|
|
44
|
+
config.delete("activeTimer");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/utils/api.ts
|
|
48
|
+
import fetch from "node-fetch";
|
|
49
|
+
var ApiError = class extends Error {
|
|
50
|
+
constructor(message, statusCode) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.statusCode = statusCode;
|
|
53
|
+
this.name = "ApiError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
async function apiRequest(endpoint, options = {}) {
|
|
57
|
+
const apiKey = getApiKey();
|
|
58
|
+
const baseUrl = getBaseUrl();
|
|
59
|
+
if (!apiKey) {
|
|
60
|
+
throw new ApiError(
|
|
61
|
+
"Not logged in. Run 'meno login' to authenticate.",
|
|
62
|
+
401
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const url = `${baseUrl}${endpoint}`;
|
|
66
|
+
const headers = {
|
|
67
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
...options.headers
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(url, {
|
|
73
|
+
...options,
|
|
74
|
+
headers
|
|
75
|
+
});
|
|
76
|
+
const data = await response.json();
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
if (response.status === 401) {
|
|
79
|
+
throw new ApiError(
|
|
80
|
+
"Invalid API key. Run 'meno login' to re-authenticate.",
|
|
81
|
+
401
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (response.status === 429) {
|
|
85
|
+
throw new ApiError(
|
|
86
|
+
`Rate limit exceeded. ${data.error || "Try again later."}`,
|
|
87
|
+
429
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
throw new ApiError(
|
|
91
|
+
data.error || `Request failed with status ${response.status}`,
|
|
92
|
+
response.status
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return data;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof ApiError) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") {
|
|
101
|
+
throw new ApiError(
|
|
102
|
+
`Cannot connect to ${baseUrl}. Check your internet connection or base URL.`,
|
|
103
|
+
0
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
throw new ApiError(
|
|
107
|
+
error.message || "An unexpected error occurred",
|
|
108
|
+
500
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/commands/login.ts
|
|
114
|
+
async function login() {
|
|
115
|
+
console.log(chalk.bold("\n\u{1F511} Meno CLI Login\n"));
|
|
116
|
+
try {
|
|
117
|
+
const apiKeyPrompt = await enquirer.prompt({
|
|
118
|
+
type: "password",
|
|
119
|
+
name: "apiKey",
|
|
120
|
+
message: "Enter your API key:",
|
|
121
|
+
validate: (value) => {
|
|
122
|
+
if (!value || !value.startsWith("meno_sk_")) {
|
|
123
|
+
return "Invalid API key format. Expected: meno_sk_...";
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const apiKey = apiKeyPrompt.apiKey;
|
|
129
|
+
const baseUrlPrompt = await enquirer.prompt({
|
|
130
|
+
type: "input",
|
|
131
|
+
name: "baseUrl",
|
|
132
|
+
message: "Base URL (press Enter for default):",
|
|
133
|
+
initial: "http://localhost:3000"
|
|
134
|
+
});
|
|
135
|
+
const baseUrl = baseUrlPrompt.baseUrl;
|
|
136
|
+
const spinner = ora("Validating API key...").start();
|
|
137
|
+
try {
|
|
138
|
+
setApiKey(apiKey);
|
|
139
|
+
if (baseUrl) {
|
|
140
|
+
setBaseUrl(baseUrl);
|
|
141
|
+
}
|
|
142
|
+
await apiRequest("/api/external/projects");
|
|
143
|
+
spinner.succeed(chalk.green("\u2713 Logged in successfully!"));
|
|
144
|
+
console.log(chalk.dim(`
|
|
145
|
+
API key stored securely.`));
|
|
146
|
+
console.log(chalk.dim(`Run ${chalk.bold("meno select")} to choose a project.
|
|
147
|
+
`));
|
|
148
|
+
} catch (error) {
|
|
149
|
+
spinner.fail(chalk.red("\u2717 Authentication failed"));
|
|
150
|
+
if (error instanceof ApiError) {
|
|
151
|
+
console.log(chalk.red(`
|
|
152
|
+
Error: ${error.message}
|
|
153
|
+
`));
|
|
154
|
+
} else {
|
|
155
|
+
console.log(chalk.red(`
|
|
156
|
+
Unexpected error: ${error.message}
|
|
157
|
+
`));
|
|
158
|
+
}
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.log(chalk.yellow("\n\u2717 Login cancelled\n"));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/commands/select.ts
|
|
168
|
+
import enquirer2 from "enquirer";
|
|
169
|
+
import ora2 from "ora";
|
|
170
|
+
import chalk2 from "chalk";
|
|
171
|
+
async function selectProject() {
|
|
172
|
+
console.log(chalk2.bold("\n\u{1F4C2} Select Project\n"));
|
|
173
|
+
const spinner = ora2("Loading projects...").start();
|
|
174
|
+
try {
|
|
175
|
+
const { projects } = await apiRequest("/api/external/projects");
|
|
176
|
+
spinner.stop();
|
|
177
|
+
if (projects.length === 0) {
|
|
178
|
+
console.log(chalk2.yellow("No active projects found."));
|
|
179
|
+
console.log(chalk2.dim("Create a project in the web app first.\n"));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const currentProject = getSelectedProject();
|
|
183
|
+
const choices = [
|
|
184
|
+
{
|
|
185
|
+
name: "clear",
|
|
186
|
+
message: chalk2.dim("[Clear Selection]")
|
|
187
|
+
},
|
|
188
|
+
...projects.map((project) => {
|
|
189
|
+
const isSelected = currentProject?.id === project.id;
|
|
190
|
+
const prefix = isSelected ? "\u25CF " : " ";
|
|
191
|
+
const displayName = `${project.name} (${project.clientName}) \u2022 $${project.hourlyRate}/hr`;
|
|
192
|
+
return {
|
|
193
|
+
name: project.id,
|
|
194
|
+
message: prefix + displayName,
|
|
195
|
+
value: project
|
|
196
|
+
};
|
|
197
|
+
})
|
|
198
|
+
];
|
|
199
|
+
const result = await enquirer2.prompt({
|
|
200
|
+
type: "select",
|
|
201
|
+
name: "project",
|
|
202
|
+
message: "Select a project:",
|
|
203
|
+
choices
|
|
204
|
+
});
|
|
205
|
+
const answer = result.project;
|
|
206
|
+
if (answer === "clear") {
|
|
207
|
+
clearSelectedProject();
|
|
208
|
+
console.log(chalk2.green("\n\u2713 Selection cleared\n"));
|
|
209
|
+
} else {
|
|
210
|
+
const project = projects.find((p) => p.id === answer);
|
|
211
|
+
if (!project) {
|
|
212
|
+
console.log(chalk2.red("\n\u2717 Project not found\n"));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
setSelectedProject({
|
|
216
|
+
id: project.id,
|
|
217
|
+
name: project.name,
|
|
218
|
+
clientName: project.clientName,
|
|
219
|
+
hourlyRate: project.hourlyRate
|
|
220
|
+
});
|
|
221
|
+
console.log(
|
|
222
|
+
chalk2.green(`
|
|
223
|
+
\u2713 Selected: ${chalk2.bold(project.name)} (${project.clientName}) \u2022 $${project.hourlyRate}/hr
|
|
224
|
+
`)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
spinner.fail(chalk2.red("Failed to load projects"));
|
|
229
|
+
if (error instanceof ApiError) {
|
|
230
|
+
console.log(chalk2.red(`
|
|
231
|
+
Error: ${error.message}
|
|
232
|
+
`));
|
|
233
|
+
} else {
|
|
234
|
+
console.log(chalk2.red(`
|
|
235
|
+
Unexpected error: ${error.message}
|
|
236
|
+
`));
|
|
237
|
+
}
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/commands/start.ts
|
|
243
|
+
import chalk3 from "chalk";
|
|
244
|
+
import ora3 from "ora";
|
|
245
|
+
|
|
246
|
+
// src/utils/timer.ts
|
|
247
|
+
function formatDuration(startTime) {
|
|
248
|
+
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
249
|
+
const hours = Math.floor(elapsed / (1e3 * 60 * 60));
|
|
250
|
+
const minutes = Math.floor(elapsed % (1e3 * 60 * 60) / (1e3 * 60));
|
|
251
|
+
if (hours > 0) {
|
|
252
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
253
|
+
}
|
|
254
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
255
|
+
}
|
|
256
|
+
function calculateElapsedHours(startTime) {
|
|
257
|
+
const elapsed = Date.now() - new Date(startTime).getTime();
|
|
258
|
+
return elapsed / (1e3 * 60 * 60);
|
|
259
|
+
}
|
|
260
|
+
function calculateTimerValue(startTime, hourlyRate) {
|
|
261
|
+
const hours = calculateElapsedHours(startTime);
|
|
262
|
+
return hours * hourlyRate;
|
|
263
|
+
}
|
|
264
|
+
function parseDuration(input) {
|
|
265
|
+
const trimmed = input.trim().toLowerCase();
|
|
266
|
+
const minutesMatch = trimmed.match(/^(\d+(?:\.\d+)?)m?$/);
|
|
267
|
+
const hoursMatch = trimmed.match(/^(\d+(?:\.\d+)?)h$/);
|
|
268
|
+
if (hoursMatch) {
|
|
269
|
+
return parseFloat(hoursMatch[1]);
|
|
270
|
+
}
|
|
271
|
+
if (minutesMatch) {
|
|
272
|
+
const minutes = parseFloat(minutesMatch[1]);
|
|
273
|
+
return minutes / 60;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/commands/start.ts
|
|
279
|
+
async function startTimer(projectId) {
|
|
280
|
+
const existingTimer = getActiveTimer();
|
|
281
|
+
if (existingTimer) {
|
|
282
|
+
const elapsed = formatDuration(existingTimer.startTime);
|
|
283
|
+
console.log(
|
|
284
|
+
chalk3.yellow(
|
|
285
|
+
`
|
|
286
|
+
\u26A0 Timer already running for ${chalk3.bold(existingTimer.projectName)} (${elapsed})`
|
|
287
|
+
)
|
|
288
|
+
);
|
|
289
|
+
console.log(chalk3.dim(`Stop it first with ${chalk3.bold("meno stop")} or discard with ${chalk3.bold("meno stop --discard")}
|
|
290
|
+
`));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
let project;
|
|
294
|
+
if (projectId) {
|
|
295
|
+
const spinner = ora3("Loading project...").start();
|
|
296
|
+
try {
|
|
297
|
+
const { projects } = await apiRequest("/api/external/projects");
|
|
298
|
+
project = projects.find((p) => p.id === projectId);
|
|
299
|
+
spinner.stop();
|
|
300
|
+
if (!project) {
|
|
301
|
+
console.log(chalk3.red(`
|
|
302
|
+
\u2717 Project not found: ${projectId}
|
|
303
|
+
`));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
spinner.fail(chalk3.red("Failed to load project"));
|
|
308
|
+
if (error instanceof ApiError) {
|
|
309
|
+
console.log(chalk3.red(`
|
|
310
|
+
Error: ${error.message}
|
|
311
|
+
`));
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
const selected = getSelectedProject();
|
|
317
|
+
if (!selected) {
|
|
318
|
+
console.log(chalk3.yellow(`
|
|
319
|
+
\u26A0 No project selected.`));
|
|
320
|
+
console.log(chalk3.dim(`Run ${chalk3.bold("meno select")} first or provide a project ID: ${chalk3.bold("meno start <project-id>")}
|
|
321
|
+
`));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
project = {
|
|
325
|
+
id: selected.id,
|
|
326
|
+
name: selected.name,
|
|
327
|
+
clientName: selected.clientName,
|
|
328
|
+
clientCompany: "",
|
|
329
|
+
hourlyRate: selected.hourlyRate,
|
|
330
|
+
taxRate: 0,
|
|
331
|
+
weeklyHourLimit: 0,
|
|
332
|
+
hoursUsed: 0
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const startTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
336
|
+
setActiveTimer({
|
|
337
|
+
projectId: project.id,
|
|
338
|
+
projectName: project.name,
|
|
339
|
+
startTime
|
|
340
|
+
});
|
|
341
|
+
const timestamp = (/* @__PURE__ */ new Date()).toLocaleTimeString();
|
|
342
|
+
console.log(
|
|
343
|
+
chalk3.green(
|
|
344
|
+
`
|
|
345
|
+
\u23F1\uFE0F Started: ${chalk3.bold(project.name)} \u2022 $${project.hourlyRate}/hr \u2022 [${timestamp}]
|
|
346
|
+
`
|
|
347
|
+
)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/commands/stop.ts
|
|
352
|
+
import enquirer3 from "enquirer";
|
|
353
|
+
import chalk4 from "chalk";
|
|
354
|
+
import ora4 from "ora";
|
|
355
|
+
async function stopTimer(options) {
|
|
356
|
+
const timer = getActiveTimer();
|
|
357
|
+
if (!timer) {
|
|
358
|
+
console.log(chalk4.yellow(`
|
|
359
|
+
\u26A0 No active timer.`));
|
|
360
|
+
console.log(chalk4.dim(`Start one with ${chalk4.bold("meno start")}
|
|
361
|
+
`));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (options.discard) {
|
|
365
|
+
clearActiveTimer();
|
|
366
|
+
console.log(chalk4.yellow(`
|
|
367
|
+
\u2717 Timer discarded
|
|
368
|
+
`));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
let duration;
|
|
372
|
+
let formatted;
|
|
373
|
+
if (options.adjust) {
|
|
374
|
+
duration = parseDuration(options.adjust);
|
|
375
|
+
if (duration === 0) {
|
|
376
|
+
console.log(chalk4.red(`
|
|
377
|
+
\u2717 Invalid duration format: ${options.adjust}
|
|
378
|
+
`));
|
|
379
|
+
console.log(chalk4.dim(`Use format like: 1.5h, 90m, 45
|
|
380
|
+
`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const hours = Math.floor(duration);
|
|
384
|
+
const minutes = Math.round((duration - hours) * 60);
|
|
385
|
+
formatted = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
386
|
+
console.log(chalk4.yellow(`
|
|
387
|
+
\u26A0 Adjusted duration from timer
|
|
388
|
+
`));
|
|
389
|
+
} else {
|
|
390
|
+
duration = calculateElapsedHours(timer.startTime);
|
|
391
|
+
formatted = formatDuration(timer.startTime);
|
|
392
|
+
}
|
|
393
|
+
const project = getSelectedProject();
|
|
394
|
+
const hourlyRate = project?.hourlyRate || 0;
|
|
395
|
+
const amount = duration * hourlyRate;
|
|
396
|
+
console.log(chalk4.bold(`
|
|
397
|
+
\u23F1\uFE0F Duration: ${formatted} \u2022 $${amount.toFixed(2)}
|
|
398
|
+
`));
|
|
399
|
+
let description;
|
|
400
|
+
let shouldLog = true;
|
|
401
|
+
try {
|
|
402
|
+
if (options.description) {
|
|
403
|
+
description = options.description;
|
|
404
|
+
console.log(chalk4.dim(`Description: ${description}
|
|
405
|
+
`));
|
|
406
|
+
} else {
|
|
407
|
+
const descResult = await enquirer3.prompt({
|
|
408
|
+
type: "input",
|
|
409
|
+
name: "description",
|
|
410
|
+
message: "What were you working on?",
|
|
411
|
+
initial: timer.description || "",
|
|
412
|
+
validate: (value) => {
|
|
413
|
+
if (!value || value.trim().length === 0) {
|
|
414
|
+
return "Description is required";
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
description = descResult.description;
|
|
420
|
+
}
|
|
421
|
+
const skipConfirm = options.noConfirm || options.yes;
|
|
422
|
+
if (!skipConfirm) {
|
|
423
|
+
const confirmResult = await enquirer3.prompt({
|
|
424
|
+
type: "confirm",
|
|
425
|
+
name: "shouldLog",
|
|
426
|
+
message: "Log this entry?",
|
|
427
|
+
initial: true
|
|
428
|
+
});
|
|
429
|
+
shouldLog = confirmResult.shouldLog;
|
|
430
|
+
if (!shouldLog) {
|
|
431
|
+
console.log(chalk4.yellow("\n\u2717 Entry not logged\n"));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const spinner = ora4("Logging entry...").start();
|
|
436
|
+
try {
|
|
437
|
+
const response = await apiRequest("/api/external/time-entries", {
|
|
438
|
+
method: "POST",
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
projectId: timer.projectId,
|
|
441
|
+
description,
|
|
442
|
+
duration: Math.round(duration * 100) / 100,
|
|
443
|
+
// Round to 2 decimals
|
|
444
|
+
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
445
|
+
// Today
|
|
446
|
+
billable: true
|
|
447
|
+
})
|
|
448
|
+
});
|
|
449
|
+
clearActiveTimer();
|
|
450
|
+
spinner.succeed(chalk4.green(`\u2713 Logged ${duration.toFixed(2)} hours to ${timer.projectName}`));
|
|
451
|
+
console.log(chalk4.dim(`
|
|
452
|
+
Entry created successfully
|
|
453
|
+
`));
|
|
454
|
+
} catch (error) {
|
|
455
|
+
spinner.fail(chalk4.red("Failed to log entry"));
|
|
456
|
+
if (error instanceof ApiError) {
|
|
457
|
+
console.log(chalk4.red(`
|
|
458
|
+
Error: ${error.message}
|
|
459
|
+
`));
|
|
460
|
+
} else {
|
|
461
|
+
console.log(chalk4.red(`
|
|
462
|
+
Unexpected error: ${error.message}
|
|
463
|
+
`));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.log(chalk4.yellow("\n\u2717 Cancelled\n"));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/commands/log.ts
|
|
472
|
+
import enquirer4 from "enquirer";
|
|
473
|
+
import chalk5 from "chalk";
|
|
474
|
+
import ora5 from "ora";
|
|
475
|
+
async function logTime(description, options) {
|
|
476
|
+
console.log(chalk5.bold("\n\u{1F4DD} Log Time Entry\n"));
|
|
477
|
+
let projectId = options.project;
|
|
478
|
+
let duration = options.duration ? parseDuration(options.duration) : null;
|
|
479
|
+
let projectName = "";
|
|
480
|
+
let hourlyRate = 0;
|
|
481
|
+
if (!projectId) {
|
|
482
|
+
const selected = getSelectedProject();
|
|
483
|
+
if (selected) {
|
|
484
|
+
projectId = selected.id;
|
|
485
|
+
projectName = selected.name;
|
|
486
|
+
hourlyRate = selected.hourlyRate;
|
|
487
|
+
} else {
|
|
488
|
+
const spinner2 = ora5("Loading projects...").start();
|
|
489
|
+
try {
|
|
490
|
+
const { projects } = await apiRequest("/api/external/projects");
|
|
491
|
+
spinner2.stop();
|
|
492
|
+
if (projects.length === 0) {
|
|
493
|
+
console.log(chalk5.yellow("No active projects found.\n"));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const result = await enquirer4.prompt({
|
|
497
|
+
type: "select",
|
|
498
|
+
name: "project",
|
|
499
|
+
message: "Select a project:",
|
|
500
|
+
choices: projects.map((p) => ({
|
|
501
|
+
name: p.id,
|
|
502
|
+
message: `${p.name} (${p.clientName}) \u2022 $${p.hourlyRate}/hr`,
|
|
503
|
+
value: p
|
|
504
|
+
}))
|
|
505
|
+
});
|
|
506
|
+
const answer = result.project;
|
|
507
|
+
const project = projects.find((p) => p.id === answer);
|
|
508
|
+
if (!project) {
|
|
509
|
+
console.log(chalk5.red("Error: Selected project not found\n"));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
projectId = project.id;
|
|
513
|
+
projectName = project.name;
|
|
514
|
+
hourlyRate = project.hourlyRate;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
spinner2.fail(chalk5.red("Failed to load projects"));
|
|
517
|
+
if (error instanceof ApiError) {
|
|
518
|
+
console.log(chalk5.red(`
|
|
519
|
+
Error: ${error.message}
|
|
520
|
+
`));
|
|
521
|
+
}
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!duration) {
|
|
527
|
+
console.log(chalk5.yellow("\n\u26A0 Duration is required. Use --duration flag (e.g., 45m, 1.5h)\n"));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (duration <= 0) {
|
|
531
|
+
console.log(chalk5.red("\n\u2717 Invalid duration\n"));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const spinner = ora5("Logging entry...").start();
|
|
535
|
+
try {
|
|
536
|
+
const response = await apiRequest("/api/external/time-entries", {
|
|
537
|
+
method: "POST",
|
|
538
|
+
body: JSON.stringify({
|
|
539
|
+
projectId,
|
|
540
|
+
description,
|
|
541
|
+
duration,
|
|
542
|
+
billable: true
|
|
543
|
+
})
|
|
544
|
+
});
|
|
545
|
+
const amount = duration * (hourlyRate || response.entry.amount / duration);
|
|
546
|
+
spinner.succeed(
|
|
547
|
+
chalk5.green(`\u2713 Logged ${duration.toFixed(2)} hours to ${projectName || response.entry.projectName} ($${amount.toFixed(2)})`)
|
|
548
|
+
);
|
|
549
|
+
console.log();
|
|
550
|
+
} catch (error) {
|
|
551
|
+
spinner.fail(chalk5.red("Failed to log entry"));
|
|
552
|
+
if (error instanceof ApiError) {
|
|
553
|
+
console.log(chalk5.red(`
|
|
554
|
+
Error: ${error.message}
|
|
555
|
+
`));
|
|
556
|
+
} else {
|
|
557
|
+
console.log(chalk5.red(`
|
|
558
|
+
Unexpected error: ${error.message}
|
|
559
|
+
`));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/commands/status.ts
|
|
565
|
+
import chalk6 from "chalk";
|
|
566
|
+
import ora6 from "ora";
|
|
567
|
+
async function showStatus() {
|
|
568
|
+
console.log(chalk6.bold("\n\u{1F4CA} Meno Status\n"));
|
|
569
|
+
const selected = getSelectedProject();
|
|
570
|
+
const timer = getActiveTimer();
|
|
571
|
+
if (selected) {
|
|
572
|
+
console.log(
|
|
573
|
+
chalk6.cyan(
|
|
574
|
+
`\u{1F4CC} Selected: ${chalk6.bold(selected.name)} (${selected.clientName}) \u2022 $${selected.hourlyRate}/hr`
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
if (timer) {
|
|
579
|
+
const elapsed = formatDuration(timer.startTime);
|
|
580
|
+
const value = calculateTimerValue(timer.startTime, selected?.hourlyRate || 0);
|
|
581
|
+
console.log(
|
|
582
|
+
chalk6.green(
|
|
583
|
+
`\u23F1\uFE0F Running: ${chalk6.bold(timer.projectName)} \u2022 ${elapsed} \u2022 $${value.toFixed(2)}`
|
|
584
|
+
)
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
if (selected || timer) {
|
|
588
|
+
console.log();
|
|
589
|
+
}
|
|
590
|
+
const spinner = ora6("Loading stats...").start();
|
|
591
|
+
try {
|
|
592
|
+
const stats = await apiRequest("/api/external/stats");
|
|
593
|
+
spinner.stop();
|
|
594
|
+
console.log(
|
|
595
|
+
chalk6.green.bold(`\u{1F4B0} Unbilled Revenue: $${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)
|
|
596
|
+
);
|
|
597
|
+
console.log(
|
|
598
|
+
chalk6.dim(
|
|
599
|
+
`\u23F1\uFE0F Total Hours: ${stats.totalHours.toFixed(1)} hours across ${stats.entriesCount} ${stats.entriesCount === 1 ? "entry" : "entries"}`
|
|
600
|
+
)
|
|
601
|
+
);
|
|
602
|
+
console.log();
|
|
603
|
+
} catch (error) {
|
|
604
|
+
spinner.fail(chalk6.red("Failed to load stats"));
|
|
605
|
+
if (error instanceof ApiError) {
|
|
606
|
+
console.log(chalk6.red(`
|
|
607
|
+
Error: ${error.message}
|
|
608
|
+
`));
|
|
609
|
+
} else {
|
|
610
|
+
console.log(chalk6.red(`
|
|
611
|
+
Unexpected error: ${error.message}
|
|
612
|
+
`));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// src/index.ts
|
|
618
|
+
var program = new Command();
|
|
619
|
+
program.name("meno").description("CLI for Meno time tracking").version("0.1.0");
|
|
620
|
+
program.command("login").description("Authenticate with your Meno API key").action(login);
|
|
621
|
+
program.command("select").description("Select a project to work on").action(selectProject);
|
|
622
|
+
program.command("start [project-id]").description("Start a timer on selected project or specific project ID").action(startTimer);
|
|
623
|
+
program.command("stop").description("Stop the running timer and log the entry").option("--discard", "Discard the timer without logging").option("-d, --description <text>", "Entry description (skip prompt)").option("-y, --yes", "Auto-confirm (skip confirmation prompt)").option("--no-confirm", "Skip confirmation prompt").option("--adjust <duration>", "Override calculated duration (e.g., 1.5h, 45m)").action(stopTimer);
|
|
624
|
+
program.command("log <description>").description("Log time manually").option("-d, --duration <duration>", "Duration (e.g., 45m, 1.5h, 90)").option("-p, --project <id>", "Project ID").action(logTime);
|
|
625
|
+
program.command("status").description("Show selected project, timer status, and unbilled stats").action(showStatus);
|
|
626
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usemeno/meno-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line interface for Meno time tracking",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"meno": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts --format esm --dts --shims",
|
|
12
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["meno", "time-tracking", "cli", "productivity"],
|
|
16
|
+
"author": "",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^12.0.0",
|
|
20
|
+
"conf": "^13.0.1",
|
|
21
|
+
"ora": "^8.0.1",
|
|
22
|
+
"enquirer": "^2.4.1",
|
|
23
|
+
"chalk": "^5.3.0",
|
|
24
|
+
"node-fetch": "^3.3.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20.11.0",
|
|
28
|
+
"tsup": "^8.0.1",
|
|
29
|
+
"typescript": "^5.3.3"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|