@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { getSelectedProject } from "../config.js";
|
|
5
|
+
import { apiRequest, ApiError, Project } from "../utils/api.js";
|
|
6
|
+
import { parseDuration } from "../utils/timer.js";
|
|
7
|
+
|
|
8
|
+
interface LogOptions {
|
|
9
|
+
duration?: string;
|
|
10
|
+
project?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function logTime(description: string, options: LogOptions) {
|
|
14
|
+
console.log(chalk.bold("\nš Log Time Entry\n"));
|
|
15
|
+
|
|
16
|
+
let projectId = options.project;
|
|
17
|
+
let duration = options.duration ? parseDuration(options.duration) : null;
|
|
18
|
+
let projectName = "";
|
|
19
|
+
let hourlyRate = 0;
|
|
20
|
+
|
|
21
|
+
// Determine project
|
|
22
|
+
if (!projectId) {
|
|
23
|
+
const selected = getSelectedProject();
|
|
24
|
+
if (selected) {
|
|
25
|
+
projectId = selected.id;
|
|
26
|
+
projectName = selected.name;
|
|
27
|
+
hourlyRate = selected.hourlyRate;
|
|
28
|
+
} else {
|
|
29
|
+
// Need to prompt for project
|
|
30
|
+
const spinner = ora("Loading projects...").start();
|
|
31
|
+
try {
|
|
32
|
+
const { projects } = await apiRequest<{ projects: Project[] }>("/api/external/projects");
|
|
33
|
+
spinner.stop();
|
|
34
|
+
|
|
35
|
+
if (projects.length === 0) {
|
|
36
|
+
console.log(chalk.yellow("No active projects found.\n"));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = await enquirer.prompt({
|
|
41
|
+
type: "select",
|
|
42
|
+
name: "project",
|
|
43
|
+
message: "Select a project:",
|
|
44
|
+
choices: projects.map((p) => ({
|
|
45
|
+
name: p.id,
|
|
46
|
+
message: `${p.name} (${p.clientName}) ⢠$${p.hourlyRate}/hr`,
|
|
47
|
+
value: p,
|
|
48
|
+
})),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const answer = (result as any).project;
|
|
52
|
+
|
|
53
|
+
// enquirer returns the choice "name" (ID) not "value" (object)
|
|
54
|
+
const project = projects.find((p) => p.id === answer);
|
|
55
|
+
if (!project) {
|
|
56
|
+
console.log(chalk.red("Error: Selected project not found\n"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
projectId = project.id;
|
|
61
|
+
projectName = project.name;
|
|
62
|
+
hourlyRate = project.hourlyRate;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
spinner.fail(chalk.red("Failed to load projects"));
|
|
65
|
+
if (error instanceof ApiError) {
|
|
66
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Parse duration if not provided
|
|
74
|
+
if (!duration) {
|
|
75
|
+
console.log(chalk.yellow("\nā Duration is required. Use --duration flag (e.g., 45m, 1.5h)\n"));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (duration <= 0) {
|
|
80
|
+
console.log(chalk.red("\nā Invalid duration\n"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Log the entry
|
|
85
|
+
const spinner = ora("Logging entry...").start();
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const response = await apiRequest("/api/external/time-entries", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
projectId,
|
|
92
|
+
description,
|
|
93
|
+
duration,
|
|
94
|
+
billable: true,
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const amount = duration * (hourlyRate || response.entry.amount / duration);
|
|
99
|
+
spinner.succeed(
|
|
100
|
+
chalk.green(`ā Logged ${duration.toFixed(2)} hours to ${projectName || response.entry.projectName} ($${amount.toFixed(2)})`)
|
|
101
|
+
);
|
|
102
|
+
console.log();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
spinner.fail(chalk.red("Failed to log entry"));
|
|
105
|
+
|
|
106
|
+
if (error instanceof ApiError) {
|
|
107
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
108
|
+
} else {
|
|
109
|
+
console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { setApiKey, setBaseUrl } from "../config.js";
|
|
5
|
+
import { apiRequest, ApiError} from "../utils/api.js";
|
|
6
|
+
|
|
7
|
+
export async function login() {
|
|
8
|
+
console.log(chalk.bold("\nš Meno CLI Login\n"));
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// Prompt for API key
|
|
12
|
+
const apiKeyPrompt = await enquirer.prompt({
|
|
13
|
+
type: "password",
|
|
14
|
+
name: "apiKey",
|
|
15
|
+
message: "Enter your API key:",
|
|
16
|
+
validate: (value: string) => {
|
|
17
|
+
if (!value || !value.startsWith("meno_sk_")) {
|
|
18
|
+
return "Invalid API key format. Expected: meno_sk_...";
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const apiKey = (apiKeyPrompt as any).apiKey;
|
|
25
|
+
|
|
26
|
+
// Optional: Custom base URL
|
|
27
|
+
const baseUrlPrompt = await enquirer.prompt({
|
|
28
|
+
type: "input",
|
|
29
|
+
name: "baseUrl",
|
|
30
|
+
message: "Base URL (press Enter for default):",
|
|
31
|
+
initial: "http://localhost:3000",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const baseUrl = (baseUrlPrompt as any).baseUrl;
|
|
35
|
+
|
|
36
|
+
// Validate API key by making a test request
|
|
37
|
+
const spinner = ora("Validating API key...").start();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Temporarily set the key to test it
|
|
41
|
+
setApiKey(apiKey as string);
|
|
42
|
+
if (baseUrl) {
|
|
43
|
+
setBaseUrl(baseUrl as string);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Test the key
|
|
47
|
+
await apiRequest("/api/external/projects");
|
|
48
|
+
|
|
49
|
+
spinner.succeed(chalk.green("ā Logged in successfully!"));
|
|
50
|
+
console.log(chalk.dim(`\nAPI key stored securely.`));
|
|
51
|
+
console.log(chalk.dim(`Run ${chalk.bold("meno select")} to choose a project.\n`));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
spinner.fail(chalk.red("ā Authentication failed"));
|
|
54
|
+
|
|
55
|
+
if (error instanceof ApiError) {
|
|
56
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
57
|
+
} else {
|
|
58
|
+
console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// User cancelled
|
|
65
|
+
console.log(chalk.yellow("\nā Login cancelled\n"));
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { setSelectedProject, clearSelectedProject, getSelectedProject } from "../config.js";
|
|
5
|
+
import { apiRequest, ApiError, Project as ApiProject } from "../utils/api.js";
|
|
6
|
+
|
|
7
|
+
export async function selectProject() {
|
|
8
|
+
console.log(chalk.bold("\nš Select Project\n"));
|
|
9
|
+
|
|
10
|
+
const spinner = ora("Loading projects...").start();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const { projects } = await apiRequest<{ projects: ApiProject[] }>("/api/external/projects");
|
|
14
|
+
|
|
15
|
+
spinner.stop();
|
|
16
|
+
|
|
17
|
+
if (projects.length === 0) {
|
|
18
|
+
console.log(chalk.yellow("No active projects found."));
|
|
19
|
+
console.log(chalk.dim("Create a project in the web app first.\n"));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const currentProject = getSelectedProject();
|
|
24
|
+
|
|
25
|
+
// Build choices
|
|
26
|
+
const choices = [
|
|
27
|
+
{
|
|
28
|
+
name: "clear",
|
|
29
|
+
message: chalk.dim("[Clear Selection]"),
|
|
30
|
+
},
|
|
31
|
+
...projects.map((project) => {
|
|
32
|
+
const isSelected = currentProject?.id === project.id;
|
|
33
|
+
const prefix = isSelected ? "ā " : " ";
|
|
34
|
+
const displayName = `${project.name} (${project.clientName}) ⢠$${project.hourlyRate}/hr`;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
name: project.id,
|
|
38
|
+
message: prefix + displayName,
|
|
39
|
+
value: project,
|
|
40
|
+
};
|
|
41
|
+
}),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const result = await enquirer.prompt({
|
|
45
|
+
type: "select",
|
|
46
|
+
name: "project",
|
|
47
|
+
message: "Select a project:",
|
|
48
|
+
choices,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const answer = (result as any).project;
|
|
52
|
+
|
|
53
|
+
if (answer === "clear") {
|
|
54
|
+
clearSelectedProject();
|
|
55
|
+
console.log(chalk.green("\nā Selection cleared\n"));
|
|
56
|
+
} else {
|
|
57
|
+
// Find the full project object by ID
|
|
58
|
+
const project = projects.find((p) => p.id === answer);
|
|
59
|
+
|
|
60
|
+
if (!project) {
|
|
61
|
+
console.log(chalk.red("\nā Project not found\n"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setSelectedProject({
|
|
66
|
+
id: project.id,
|
|
67
|
+
name: project.name,
|
|
68
|
+
clientName: project.clientName,
|
|
69
|
+
hourlyRate: project.hourlyRate,
|
|
70
|
+
});
|
|
71
|
+
console.log(
|
|
72
|
+
chalk.green(`\nā Selected: ${chalk.bold(project.name)} (${project.clientName}) ⢠$${project.hourlyRate}/hr\n`)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
spinner.fail(chalk.red("Failed to load projects"));
|
|
77
|
+
|
|
78
|
+
if (error instanceof ApiError) {
|
|
79
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { getSelectedProject, getActiveTimer, setActiveTimer } from "../config.js";
|
|
4
|
+
import { apiRequest, ApiError, Project } from "../utils/api.js";
|
|
5
|
+
import { formatDuration } from "../utils/timer.js";
|
|
6
|
+
|
|
7
|
+
export async function startTimer(projectId?: string) {
|
|
8
|
+
// Check if timer is already running
|
|
9
|
+
const existingTimer = getActiveTimer();
|
|
10
|
+
if (existingTimer) {
|
|
11
|
+
const elapsed = formatDuration(existingTimer.startTime);
|
|
12
|
+
console.log(
|
|
13
|
+
chalk.yellow(
|
|
14
|
+
`\nā Timer already running for ${chalk.bold(existingTimer.projectName)} (${elapsed})`
|
|
15
|
+
)
|
|
16
|
+
);
|
|
17
|
+
console.log(chalk.dim(`Stop it first with ${chalk.bold("meno stop")} or discard with ${chalk.bold("meno stop --discard")}\n`));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let project: Project | undefined;
|
|
22
|
+
|
|
23
|
+
if (projectId) {
|
|
24
|
+
// Start with explicit project ID
|
|
25
|
+
const spinner = ora("Loading project...").start();
|
|
26
|
+
try {
|
|
27
|
+
const { projects } = await apiRequest<{ projects: Project[] }>("/api/external/projects");
|
|
28
|
+
project = projects.find((p) => p.id === projectId);
|
|
29
|
+
spinner.stop();
|
|
30
|
+
|
|
31
|
+
if (!project) {
|
|
32
|
+
console.log(chalk.red(`\nā Project not found: ${projectId}\n`));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
spinner.fail(chalk.red("Failed to load project"));
|
|
37
|
+
if (error instanceof ApiError) {
|
|
38
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
// Use selected project
|
|
44
|
+
const selected = getSelectedProject();
|
|
45
|
+
if (!selected) {
|
|
46
|
+
console.log(chalk.yellow(`\nā No project selected.`));
|
|
47
|
+
console.log(chalk.dim(`Run ${chalk.bold("meno select")} first or provide a project ID: ${chalk.bold("meno start <project-id>")}\n`));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
project = {
|
|
52
|
+
id: selected.id,
|
|
53
|
+
name: selected.name,
|
|
54
|
+
clientName: selected.clientName,
|
|
55
|
+
clientCompany: "",
|
|
56
|
+
hourlyRate: selected.hourlyRate,
|
|
57
|
+
taxRate: 0,
|
|
58
|
+
weeklyHourLimit: 0,
|
|
59
|
+
hoursUsed: 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Start the timer
|
|
64
|
+
const startTime = new Date().toISOString();
|
|
65
|
+
setActiveTimer({
|
|
66
|
+
projectId: project.id,
|
|
67
|
+
projectName: project.name,
|
|
68
|
+
startTime,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
72
|
+
console.log(
|
|
73
|
+
chalk.green(
|
|
74
|
+
`\nā±ļø Started: ${chalk.bold(project.name)} ⢠$${project.hourlyRate}/hr ⢠[${timestamp}]\n`
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { getSelectedProject, getActiveTimer } from "../config.js";
|
|
4
|
+
import { apiRequest, ApiError, Stats } from "../utils/api.js";
|
|
5
|
+
import { formatDuration, calculateTimerValue } from "../utils/timer.js";
|
|
6
|
+
|
|
7
|
+
export async function showStatus() {
|
|
8
|
+
console.log(chalk.bold("\nš Meno Status\n"));
|
|
9
|
+
|
|
10
|
+
const selected = getSelectedProject();
|
|
11
|
+
const timer = getActiveTimer();
|
|
12
|
+
|
|
13
|
+
// Show selected project
|
|
14
|
+
if (selected) {
|
|
15
|
+
console.log(
|
|
16
|
+
chalk.cyan(
|
|
17
|
+
`š Selected: ${chalk.bold(selected.name)} (${selected.clientName}) ⢠$${selected.hourlyRate}/hr`
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Show active timer
|
|
23
|
+
if (timer) {
|
|
24
|
+
const elapsed = formatDuration(timer.startTime);
|
|
25
|
+
const value = calculateTimerValue(timer.startTime, selected?.hourlyRate || 0);
|
|
26
|
+
console.log(
|
|
27
|
+
chalk.green(
|
|
28
|
+
`ā±ļø Running: ${chalk.bold(timer.projectName)} ⢠${elapsed} ⢠$${value.toFixed(2)}`
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (selected || timer) {
|
|
34
|
+
console.log(); // Add spacing
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Fetch unbilled stats
|
|
38
|
+
const spinner = ora("Loading stats...").start();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const stats = await apiRequest<Stats>("/api/external/stats");
|
|
42
|
+
spinner.stop();
|
|
43
|
+
|
|
44
|
+
console.log(
|
|
45
|
+
chalk.green.bold(`š° Unbilled Revenue: $${stats.totalRevenue.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`)
|
|
46
|
+
);
|
|
47
|
+
console.log(
|
|
48
|
+
chalk.dim(
|
|
49
|
+
`ā±ļø Total Hours: ${stats.totalHours.toFixed(1)} hours across ${stats.entriesCount} ${stats.entriesCount === 1 ? "entry" : "entries"}`
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
console.log();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
spinner.fail(chalk.red("Failed to load stats"));
|
|
55
|
+
|
|
56
|
+
if (error instanceof ApiError) {
|
|
57
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import enquirer from "enquirer";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { getActiveTimer, clearActiveTimer, getSelectedProject } from "../config.js";
|
|
5
|
+
import { apiRequest, ApiError } from "../utils/api.js";
|
|
6
|
+
import { formatDuration, calculateElapsedHours, parseDuration } from "../utils/timer.js";
|
|
7
|
+
|
|
8
|
+
interface StopOptions {
|
|
9
|
+
discard?: boolean;
|
|
10
|
+
description?: string;
|
|
11
|
+
noConfirm?: boolean;
|
|
12
|
+
yes?: boolean;
|
|
13
|
+
adjust?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function stopTimer(options: StopOptions) {
|
|
17
|
+
const timer = getActiveTimer();
|
|
18
|
+
|
|
19
|
+
if (!timer) {
|
|
20
|
+
console.log(chalk.yellow(`\nā No active timer.`));
|
|
21
|
+
console.log(chalk.dim(`Start one with ${chalk.bold("meno start")}\n`));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// If discard flag, just clear and exit
|
|
26
|
+
if (options.discard) {
|
|
27
|
+
clearActiveTimer();
|
|
28
|
+
console.log(chalk.yellow(`\nā Timer discarded\n`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Calculate duration (or use adjusted duration)
|
|
33
|
+
let duration: number;
|
|
34
|
+
let formatted: string;
|
|
35
|
+
|
|
36
|
+
if (options.adjust) {
|
|
37
|
+
// Parse adjusted duration
|
|
38
|
+
duration = parseDuration(options.adjust);
|
|
39
|
+
if (duration === 0) {
|
|
40
|
+
console.log(chalk.red(`\nā Invalid duration format: ${options.adjust}\n`));
|
|
41
|
+
console.log(chalk.dim(`Use format like: 1.5h, 90m, 45\n`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const hours = Math.floor(duration);
|
|
45
|
+
const minutes = Math.round((duration - hours) * 60);
|
|
46
|
+
formatted = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
47
|
+
console.log(chalk.yellow(`\nā Adjusted duration from timer\n`));
|
|
48
|
+
} else {
|
|
49
|
+
duration = calculateElapsedHours(timer.startTime);
|
|
50
|
+
formatted = formatDuration(timer.startTime);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Get project details for rate calculation
|
|
54
|
+
const project = getSelectedProject();
|
|
55
|
+
const hourlyRate = project?.hourlyRate || 0;
|
|
56
|
+
const amount = duration * hourlyRate;
|
|
57
|
+
|
|
58
|
+
console.log(chalk.bold(`\nā±ļø Duration: ${formatted} ⢠$${amount.toFixed(2)}\n`));
|
|
59
|
+
|
|
60
|
+
let description: string;
|
|
61
|
+
let shouldLog = true;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Get description (from flag or prompt)
|
|
65
|
+
if (options.description) {
|
|
66
|
+
description = options.description;
|
|
67
|
+
console.log(chalk.dim(`Description: ${description}\n`));
|
|
68
|
+
} else {
|
|
69
|
+
const descResult = await enquirer.prompt({
|
|
70
|
+
type: "input",
|
|
71
|
+
name: "description",
|
|
72
|
+
message: "What were you working on?",
|
|
73
|
+
initial: timer.description || "",
|
|
74
|
+
validate: (value: string) => {
|
|
75
|
+
if (!value || value.trim().length === 0) {
|
|
76
|
+
return "Description is required";
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
description = (descResult as any).description;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Confirm logging (unless --no-confirm or -y flag)
|
|
85
|
+
const skipConfirm = options.noConfirm || options.yes;
|
|
86
|
+
if (!skipConfirm) {
|
|
87
|
+
const confirmResult = await enquirer.prompt({
|
|
88
|
+
type: "confirm",
|
|
89
|
+
name: "shouldLog",
|
|
90
|
+
message: "Log this entry?",
|
|
91
|
+
initial: true,
|
|
92
|
+
});
|
|
93
|
+
shouldLog = (confirmResult as any).shouldLog;
|
|
94
|
+
|
|
95
|
+
if (!shouldLog) {
|
|
96
|
+
console.log(chalk.yellow("\nā Entry not logged\n"));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Log the entry
|
|
102
|
+
const spinner = ora("Logging entry...").start();
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await apiRequest("/api/external/time-entries", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
projectId: timer.projectId,
|
|
109
|
+
description: description,
|
|
110
|
+
duration: Math.round(duration * 100) / 100, // Round to 2 decimals
|
|
111
|
+
date: new Date().toISOString().split("T")[0], // Today
|
|
112
|
+
billable: true,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
clearActiveTimer();
|
|
117
|
+
spinner.succeed(chalk.green(`ā Logged ${duration.toFixed(2)} hours to ${timer.projectName}`));
|
|
118
|
+
console.log(chalk.dim(`\nEntry created successfully\n`));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
spinner.fail(chalk.red("Failed to log entry"));
|
|
121
|
+
|
|
122
|
+
if (error instanceof ApiError) {
|
|
123
|
+
console.log(chalk.red(`\nError: ${error.message}\n`));
|
|
124
|
+
} else {
|
|
125
|
+
console.log(chalk.red(`\nUnexpected error: ${(error as Error).message}\n`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// User cancelled
|
|
130
|
+
console.log(chalk.yellow("\nā Cancelled\n"));
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import Conf from "conf";
|
|
2
|
+
|
|
3
|
+
interface Config {
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
selectedProject?: {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
clientName: string;
|
|
10
|
+
hourlyRate: number;
|
|
11
|
+
};
|
|
12
|
+
activeTimer?: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
projectName: string;
|
|
15
|
+
startTime: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = new Conf<Config>({
|
|
21
|
+
projectName: "meno-cli",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function getApiKey(): string | undefined {
|
|
25
|
+
return config.get("apiKey");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setApiKey(key: string): void {
|
|
29
|
+
config.set("apiKey", key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getBaseUrl(): string {
|
|
33
|
+
return config.get("baseUrl") || process.env.MENO_BASE_URL || "http://localhost:3000";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function setBaseUrl(url: string): void {
|
|
37
|
+
config.set("baseUrl", url);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getSelectedProject() {
|
|
41
|
+
return config.get("selectedProject");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setSelectedProject(project: {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
clientName: string;
|
|
48
|
+
hourlyRate: number;
|
|
49
|
+
}): void {
|
|
50
|
+
config.set("selectedProject", project);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearSelectedProject(): void {
|
|
54
|
+
config.delete("selectedProject");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getActiveTimer() {
|
|
58
|
+
return config.get("activeTimer");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function setActiveTimer(timer: {
|
|
62
|
+
projectId: string;
|
|
63
|
+
projectName: string;
|
|
64
|
+
startTime: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
}): void {
|
|
67
|
+
config.set("activeTimer", timer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearActiveTimer(): void {
|
|
71
|
+
config.delete("activeTimer");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearConfig(): void {
|
|
75
|
+
config.clear();
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { login } from "./commands/login.js";
|
|
5
|
+
import { selectProject } from "./commands/select.js";
|
|
6
|
+
import { startTimer } from "./commands/start.js";
|
|
7
|
+
import { stopTimer } from "./commands/stop.js";
|
|
8
|
+
import { logTime } from "./commands/log.js";
|
|
9
|
+
import { showStatus } from "./commands/status.js";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("meno")
|
|
15
|
+
.description("CLI for Meno time tracking")
|
|
16
|
+
.version("0.1.0");
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command("login")
|
|
20
|
+
.description("Authenticate with your Meno API key")
|
|
21
|
+
.action(login);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("select")
|
|
25
|
+
.description("Select a project to work on")
|
|
26
|
+
.action(selectProject);
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command("start [project-id]")
|
|
30
|
+
.description("Start a timer on selected project or specific project ID")
|
|
31
|
+
.action(startTimer);
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command("stop")
|
|
35
|
+
.description("Stop the running timer and log the entry")
|
|
36
|
+
.option("--discard", "Discard the timer without logging")
|
|
37
|
+
.option("-d, --description <text>", "Entry description (skip prompt)")
|
|
38
|
+
.option("-y, --yes", "Auto-confirm (skip confirmation prompt)")
|
|
39
|
+
.option("--no-confirm", "Skip confirmation prompt")
|
|
40
|
+
.option("--adjust <duration>", "Override calculated duration (e.g., 1.5h, 45m)")
|
|
41
|
+
.action(stopTimer);
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command("log <description>")
|
|
45
|
+
.description("Log time manually")
|
|
46
|
+
.option("-d, --duration <duration>", "Duration (e.g., 45m, 1.5h, 90)")
|
|
47
|
+
.option("-p, --project <id>", "Project ID")
|
|
48
|
+
.action(logTime);
|
|
49
|
+
|
|
50
|
+
program
|
|
51
|
+
.command("status")
|
|
52
|
+
.description("Show selected project, timer status, and unbilled stats")
|
|
53
|
+
.action(showStatus);
|
|
54
|
+
|
|
55
|
+
program.parse();
|