@invoicer/cli 1.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.
Files changed (50) hide show
  1. package/.env.example +15 -0
  2. package/config.json +19 -0
  3. package/data/last.json +11 -0
  4. package/dist/cli.js +38 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/clients.js +184 -0
  7. package/dist/commands/clients.js.map +1 -0
  8. package/dist/commands/generate.js +294 -0
  9. package/dist/commands/generate.js.map +1 -0
  10. package/dist/commands/timesheet.js +34 -0
  11. package/dist/commands/timesheet.js.map +1 -0
  12. package/dist/core/json-store.js +27 -0
  13. package/dist/core/json-store.js.map +1 -0
  14. package/dist/core/paths.js +29 -0
  15. package/dist/core/paths.js.map +1 -0
  16. package/dist/core/validators.js +131 -0
  17. package/dist/core/validators.js.map +1 -0
  18. package/dist/domain/types.js +2 -0
  19. package/dist/domain/types.js.map +1 -0
  20. package/dist/services/email.js +32 -0
  21. package/dist/services/email.js.map +1 -0
  22. package/dist/services/invoice-number.js +33 -0
  23. package/dist/services/invoice-number.js.map +1 -0
  24. package/dist/services/pdf-render.js +69 -0
  25. package/dist/services/pdf-render.js.map +1 -0
  26. package/dist/services/timesheet.js +99 -0
  27. package/dist/services/timesheet.js.map +1 -0
  28. package/dist/utils/sanitize.js +8 -0
  29. package/dist/utils/sanitize.js.map +1 -0
  30. package/index.html +2584 -0
  31. package/logo.svg +17 -0
  32. package/package.json +39 -0
  33. package/scripts/invoice.mjs +23 -0
  34. package/src/cli.ts +44 -0
  35. package/src/commands/clients.ts +221 -0
  36. package/src/commands/generate.ts +379 -0
  37. package/src/commands/timesheet.ts +47 -0
  38. package/src/core/json-store.ts +33 -0
  39. package/src/core/paths.ts +42 -0
  40. package/src/core/validators.ts +168 -0
  41. package/src/domain/types.ts +129 -0
  42. package/src/services/email.ts +40 -0
  43. package/src/services/invoice-number.ts +46 -0
  44. package/src/services/pdf-render.ts +95 -0
  45. package/src/services/timesheet.ts +173 -0
  46. package/src/utils/sanitize.ts +8 -0
  47. package/test/cli-wiring.test.ts +25 -0
  48. package/test/invoice-number.test.ts +45 -0
  49. package/test/timesheet.test.ts +104 -0
  50. package/tsconfig.json +17 -0
package/logo.svg ADDED
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
3
+ <!-- Background -->
4
+ <rect width="200" height="200" rx="40" fill="#1e293b"/>
5
+
6
+ <!-- "IN" Text -->
7
+ <text
8
+ x="100"
9
+ y="140"
10
+ font-family="Arial, Helvetica, sans-serif"
11
+ font-size="96"
12
+ font-weight="900"
13
+ fill="#ffffff"
14
+ text-anchor="middle"
15
+ letter-spacing="-8">IN</text>
16
+ </svg>
17
+
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@invoicer/cli",
3
+ "version": "1.1.0",
4
+ "description": "Professional invoice generator with timesheet integration and CLI support",
5
+ "type": "module",
6
+ "preferGlobal": true,
7
+ "bin": {
8
+ "invoicer": "./scripts/invoice.mjs"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.json",
12
+ "invoice": "npm run build && node scripts/invoice.mjs",
13
+ "invoice:month": "npm run build && node scripts/invoice.mjs generate --month=$(date +%Y-%m)",
14
+ "test": "npm run build && node --test --experimental-strip-types test/**/*.test.ts"
15
+ },
16
+ "keywords": [
17
+ "invoice",
18
+ "billing",
19
+ "timesheet",
20
+ "cli",
21
+ "automation",
22
+ "pdf"
23
+ ],
24
+ "author": "Milad Fahmy <milad@inbrief.sh>",
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "commander": "^14.0.3",
28
+ "dotenv": "^16.4.5",
29
+ "inquirer": "^9.2.20",
30
+ "nodemailer": "^8.0.1",
31
+ "puppeteer": "^22.15.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/inquirer": "^9.0.9",
35
+ "@types/node": "^22.10.2",
36
+ "@types/nodemailer": "^7.0.9",
37
+ "typescript": "^5.7.2"
38
+ }
39
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const currentFilePath = fileURLToPath(import.meta.url);
7
+ const projectRoot = path.resolve(path.dirname(currentFilePath), "..");
8
+ const distCliPath = path.join(projectRoot, "dist", "cli.js");
9
+
10
+ if (!fs.existsSync(distCliPath)) {
11
+ console.error("❌ Missing compiled CLI: dist/cli.js");
12
+ console.error(" Run: npm run build");
13
+ process.exit(1);
14
+ }
15
+
16
+ const cliModule = await import(pathToFileURL(distCliPath).href);
17
+
18
+ if (typeof cliModule.runCli !== "function") {
19
+ console.error("❌ Failed to load CLI runtime from dist/cli.js");
20
+ process.exit(1);
21
+ }
22
+
23
+ await cliModule.runCli(process.argv);
package/src/cli.ts ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { Command } from "commander";
4
+ import { pathToFileURL } from "node:url";
5
+ import { createProjectPaths, type ProjectPaths } from "./core/paths.js";
6
+ import { registerGenerateCommand } from "./commands/generate.js";
7
+ import { registerClientsCommand } from "./commands/clients.js";
8
+ import { registerTimesheetCommand } from "./commands/timesheet.js";
9
+
10
+ export function createProgram(paths: ProjectPaths = createProjectPaths()): Command {
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("invoicer")
15
+ .description("Professional invoice generator with timesheet integration")
16
+ .version("1.0.0");
17
+
18
+ registerGenerateCommand(program, paths);
19
+ registerClientsCommand(program, paths);
20
+ registerTimesheetCommand(program, paths);
21
+
22
+ return program;
23
+ }
24
+
25
+ export async function runCli(argv = process.argv, paths: ProjectPaths = createProjectPaths()): Promise<void> {
26
+ const program = createProgram(paths);
27
+ await program.parseAsync(argv);
28
+ }
29
+
30
+ function isMainModule(): boolean {
31
+ const entry = process.argv[1];
32
+ if (!entry) {
33
+ return false;
34
+ }
35
+ return import.meta.url === pathToFileURL(entry).href;
36
+ }
37
+
38
+ if (isMainModule()) {
39
+ runCli().catch((error: unknown) => {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ console.error(`❌ ${message}`);
42
+ process.exit(1);
43
+ });
44
+ }
@@ -0,0 +1,221 @@
1
+ import inquirer from "inquirer";
2
+ import type { Command } from "commander";
3
+ import { loadJsonFile, writeJsonFile } from "../core/json-store.js";
4
+ import { type ProjectPaths } from "../core/paths.js";
5
+ import { validateConfig } from "../core/validators.js";
6
+ import { type ClientsOptions, type Config } from "../domain/types.js";
7
+
8
+ function loadConfig(paths: ProjectPaths): Config {
9
+ return loadJsonFile(paths.configPath, { client: [], from: {} }, validateConfig);
10
+ }
11
+
12
+ export function registerClientsCommand(program: Command, paths: ProjectPaths): void {
13
+ program
14
+ .command("clients")
15
+ .description("Manage clients")
16
+ .option("--list", "List all configured clients", false)
17
+ .option("--add", "Add a new client interactively", false)
18
+ .option("--edit <name>", "Edit a client by name")
19
+ .option("--delete <name>", "Delete a client by name")
20
+ .action(async (options: ClientsOptions) => {
21
+ const cfg = loadConfig(paths);
22
+
23
+ if (options.list) {
24
+ if (cfg.client.length === 0) {
25
+ console.log("No clients configured.");
26
+ process.exit(0);
27
+ }
28
+
29
+ console.log("\n📋 Configured Clients:\n");
30
+ cfg.client.forEach((client, index) => {
31
+ console.log(`${index + 1}. ${client.name}`);
32
+ console.log(` Currency: ${client.currency || "USD"}`);
33
+ console.log(` Rate: ${client.defaultRate}/hr`);
34
+ console.log(` Email: ${client.email || "—"}`);
35
+ if (client.address) console.log(` Address: ${client.address}`);
36
+ if (client.payPeriodType) console.log(` Terms: ${client.payPeriodType}`);
37
+ console.log();
38
+ });
39
+
40
+ process.exit(0);
41
+ }
42
+
43
+ if (options.add) {
44
+ const answers = await inquirer.prompt<{
45
+ name: string;
46
+ email: string;
47
+ address: string;
48
+ currency: string;
49
+ defaultRate: number;
50
+ payPeriodType: string;
51
+ }>([
52
+ {
53
+ type: "input",
54
+ name: "name",
55
+ message: "Client name:",
56
+ validate: (value: string) => value.trim().length > 0 || "Name is required",
57
+ },
58
+ {
59
+ type: "input",
60
+ name: "email",
61
+ message: "Email address:",
62
+ default: "",
63
+ validate: (value: string) =>
64
+ !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
65
+ },
66
+ {
67
+ type: "input",
68
+ name: "address",
69
+ message: "Address (optional):",
70
+ default: "",
71
+ },
72
+ {
73
+ type: "list",
74
+ name: "currency",
75
+ message: "Currency:",
76
+ choices: ["USD", "EUR", "GBP", "JPY"],
77
+ default: "USD",
78
+ },
79
+ {
80
+ type: "number",
81
+ name: "defaultRate",
82
+ message: "Default rate per hour:",
83
+ default: 50,
84
+ validate: (value: number) => value > 0 || "Rate must be positive",
85
+ },
86
+ {
87
+ type: "input",
88
+ name: "payPeriodType",
89
+ message: "Payment terms (e.g., Net 30, Due on Receipt):",
90
+ default: "Due on Receipt",
91
+ },
92
+ ]);
93
+
94
+ const newClient = {
95
+ name: answers.name.trim(),
96
+ email: answers.email.trim(),
97
+ address: answers.address.trim(),
98
+ currency: answers.currency,
99
+ defaultRate: Number(answers.defaultRate),
100
+ payPeriodType: answers.payPeriodType.trim(),
101
+ };
102
+
103
+ if (cfg.client.some((client) => client.name === newClient.name)) {
104
+ console.error(`❌ Client "${newClient.name}" already exists`);
105
+ process.exit(1);
106
+ }
107
+
108
+ cfg.client.push(newClient);
109
+ writeJsonFile(paths.configPath, cfg);
110
+ console.log(`✅ Client "${newClient.name}" added successfully`);
111
+ process.exit(0);
112
+ }
113
+
114
+ if (options.edit) {
115
+ const clientName = options.edit.trim();
116
+ const clientIndex = cfg.client.findIndex((client) => client.name === clientName);
117
+
118
+ if (clientIndex === -1) {
119
+ console.error(`❌ Client "${clientName}" not found`);
120
+ process.exit(1);
121
+ }
122
+
123
+ const current = cfg.client[clientIndex];
124
+
125
+ const answers = await inquirer.prompt<{
126
+ name: string;
127
+ email: string;
128
+ address: string;
129
+ currency: string;
130
+ defaultRate: number;
131
+ payPeriodType: string;
132
+ }>([
133
+ {
134
+ type: "input",
135
+ name: "name",
136
+ message: "Client name:",
137
+ default: current.name,
138
+ validate: (value: string) => value.trim().length > 0 || "Name is required",
139
+ },
140
+ {
141
+ type: "input",
142
+ name: "email",
143
+ message: "Email:",
144
+ default: current.email || "",
145
+ validate: (value: string) =>
146
+ !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || "Enter valid email",
147
+ },
148
+ {
149
+ type: "input",
150
+ name: "address",
151
+ message: "Address:",
152
+ default: current.address || "",
153
+ },
154
+ {
155
+ type: "list",
156
+ name: "currency",
157
+ message: "Currency:",
158
+ choices: ["USD", "EUR", "GBP", "JPY"],
159
+ default: current.currency || "USD",
160
+ },
161
+ {
162
+ type: "number",
163
+ name: "defaultRate",
164
+ message: "Default rate per hour:",
165
+ default: current.defaultRate || 50,
166
+ validate: (value: number) => value > 0 || "Rate must be positive",
167
+ },
168
+ {
169
+ type: "input",
170
+ name: "payPeriodType",
171
+ message: "Payment terms:",
172
+ default: current.payPeriodType || "Due on Receipt",
173
+ },
174
+ ]);
175
+
176
+ cfg.client[clientIndex] = {
177
+ name: answers.name.trim(),
178
+ email: answers.email.trim(),
179
+ address: answers.address.trim(),
180
+ currency: answers.currency,
181
+ defaultRate: Number(answers.defaultRate),
182
+ payPeriodType: answers.payPeriodType.trim(),
183
+ };
184
+
185
+ writeJsonFile(paths.configPath, cfg);
186
+ console.log(`✅ Client "${answers.name}" updated successfully`);
187
+ process.exit(0);
188
+ }
189
+
190
+ if (options.delete) {
191
+ const clientName = options.delete.trim();
192
+ const clientIndex = cfg.client.findIndex((client) => client.name === clientName);
193
+
194
+ if (clientIndex === -1) {
195
+ console.error(`❌ Client "${clientName}" not found`);
196
+ process.exit(1);
197
+ }
198
+
199
+ const answer = await inquirer.prompt<{ confirm: boolean }>([
200
+ {
201
+ type: "confirm",
202
+ name: "confirm",
203
+ message: `Delete client "${clientName}"? This cannot be undone.`,
204
+ default: false,
205
+ },
206
+ ]);
207
+
208
+ if (!answer.confirm) {
209
+ console.log("Cancelled.");
210
+ process.exit(0);
211
+ }
212
+
213
+ cfg.client.splice(clientIndex, 1);
214
+ writeJsonFile(paths.configPath, cfg);
215
+ console.log(`✅ Client "${clientName}" deleted`);
216
+ process.exit(0);
217
+ }
218
+
219
+ console.log("Usage: invoicer clients [--list | --add | --edit <name> | --delete <name>]");
220
+ });
221
+ }