@drazenbebic/wdid 0.1.3 → 0.3.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 (3) hide show
  1. package/README.md +134 -21
  2. package/dist/index.js +1110 -32
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,14 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import chalk3 from "chalk";
4
5
  import { Command } from "commander";
5
6
 
6
7
  // src/git.ts
7
- import { execFile } from "child_process";
8
+ import { execFile, spawn } from "child_process";
8
9
  import { promisify } from "util";
9
10
  var execFileAsync = promisify(execFile);
10
11
  var FIELD_SEP = "";
11
12
  var RECORD_SEP = "";
13
+ var TRUNK_BRANCHES = /* @__PURE__ */ new Set(["main", "master"]);
14
+ var pad2 = (n) => String(n).padStart(2, "0");
15
+ function formatLocalDateTime(iso) {
16
+ const d = new Date(iso);
17
+ if (Number.isNaN(d.getTime())) {
18
+ return { date: "", time: "" };
19
+ }
20
+ const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
21
+ const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
22
+ return { date, time };
23
+ }
12
24
  function extractTicket(message, pattern) {
13
25
  const match = message.match(pattern);
14
26
  if (!match) {
@@ -16,34 +28,110 @@ function extractTicket(message, pattern) {
16
28
  }
17
29
  return match[1] ?? match[0];
18
30
  }
19
- async function getGitUserName(cwd) {
31
+ function normalizeBranchName(rawName) {
32
+ const clean = rawName.replace(/[~^].*$/, "").trim();
33
+ if (!clean || clean === "undefined" || TRUNK_BRANCHES.has(clean)) {
34
+ return null;
35
+ }
36
+ return clean;
37
+ }
38
+ async function runGitWithStdin(cwd, args, input) {
39
+ return new Promise((resolve, reject) => {
40
+ const proc = spawn("git", args, { cwd });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ proc.stdout.on("data", (chunk) => {
44
+ stdout += chunk.toString();
45
+ });
46
+ proc.stderr.on("data", (chunk) => {
47
+ stderr += chunk.toString();
48
+ });
49
+ proc.on("error", reject);
50
+ proc.on("close", (code) => {
51
+ if (code !== 0) {
52
+ reject(new Error(`git ${args.join(" ")} failed: ${stderr.trim()}`));
53
+ return;
54
+ }
55
+ resolve(stdout);
56
+ });
57
+ proc.stdin.write(input);
58
+ proc.stdin.end();
59
+ });
60
+ }
61
+ async function getBranchMap(cwd, shas) {
62
+ const map = /* @__PURE__ */ new Map();
63
+ if (shas.length === 0) {
64
+ return map;
65
+ }
66
+ let stdout;
67
+ try {
68
+ stdout = await runGitWithStdin(
69
+ cwd,
70
+ ["name-rev", "--stdin", "--refs=refs/heads/*"],
71
+ shas.join("\n") + "\n"
72
+ );
73
+ } catch {
74
+ return map;
75
+ }
76
+ for (const line of stdout.split("\n")) {
77
+ const m = line.match(/^([0-9a-fA-F]+)\s+\((.+)\)\s*$/);
78
+ if (!m) {
79
+ continue;
80
+ }
81
+ const sha = m[1];
82
+ const rawName = m[2];
83
+ if (!sha || !rawName) {
84
+ continue;
85
+ }
86
+ map.set(sha, normalizeBranchName(rawName));
87
+ }
88
+ return map;
89
+ }
90
+ async function assertGitRepo(cwd) {
20
91
  try {
21
- const { stdout } = await execFileAsync("git", ["config", "user.name"], {
92
+ await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
22
93
  cwd
23
94
  });
24
- const name = stdout.trim();
25
- if (!name) {
26
- throw new Error("empty");
27
- }
28
- return name;
29
95
  } catch (err) {
30
96
  if (err.code === "ENOENT") {
31
97
  throw new Error("git is not installed or not on PATH", { cause: err });
32
98
  }
33
99
  throw new Error(
34
- 'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author',
100
+ `not inside a git repository: ${cwd} \u2014 cd into a repo or pass --repo <path>`,
35
101
  { cause: err }
36
102
  );
37
103
  }
38
104
  }
105
+ var GIT_USER_NAME_HINT = 'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author';
106
+ async function getGitUserName(cwd) {
107
+ let stdout;
108
+ try {
109
+ const result = await execFileAsync("git", ["config", "user.name"], {
110
+ cwd
111
+ });
112
+ stdout = result.stdout;
113
+ } catch (err) {
114
+ if (err.code === "ENOENT") {
115
+ throw new Error("git is not installed or not on PATH", { cause: err });
116
+ }
117
+ throw new Error(GIT_USER_NAME_HINT, { cause: err });
118
+ }
119
+ const name = stdout.trim();
120
+ if (!name) {
121
+ throw new Error(GIT_USER_NAME_HINT);
122
+ }
123
+ return name;
124
+ }
39
125
  async function getCommits(opts) {
126
+ await assertGitRepo(opts.cwd);
40
127
  const args = [
41
128
  "log",
129
+ "--exclude=refs/stash",
42
130
  "--all",
43
131
  "--author-date-order",
44
132
  "--regexp-ignore-case",
45
133
  `--author=${opts.author}`,
46
- `--pretty=format:%cs${FIELD_SEP}%s${RECORD_SEP}`
134
+ `--pretty=format:%H${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`
47
135
  ];
48
136
  if (opts.from) {
49
137
  args.push(`--after=${opts.from} 00:00`);
@@ -51,43 +139,129 @@ async function getCommits(opts) {
51
139
  if (opts.to) {
52
140
  args.push(`--before=${opts.to} 23:59`);
53
141
  }
54
- const { stdout } = await execFileAsync("git", args, {
55
- cwd: opts.cwd,
56
- maxBuffer: 32 * 1024 * 1024
57
- });
58
- return stdout.split(RECORD_SEP).map((r) => r.trim()).filter((r) => r.length > 0).map((record) => {
59
- const [date = "", subject = ""] = record.split(FIELD_SEP);
142
+ if (opts.limit !== void 0) {
143
+ args.push(`--max-count=${opts.limit}`);
144
+ }
145
+ let stdout;
146
+ try {
147
+ const result = await execFileAsync("git", args, {
148
+ cwd: opts.cwd,
149
+ maxBuffer: 32 * 1024 * 1024
150
+ });
151
+ stdout = result.stdout;
152
+ } catch (err) {
153
+ const stderr = String(err.stderr ?? "");
154
+ if (stderr.includes("does not have any commits") || stderr.includes("bad default revision") || stderr.includes("unknown revision")) {
155
+ return [];
156
+ }
157
+ throw err;
158
+ }
159
+ const parsed = stdout.split(RECORD_SEP).map((r) => r.trim()).filter((r) => r.length > 0).map((record) => {
160
+ const [sha = "", iso = "", subject = ""] = record.split(FIELD_SEP);
161
+ const { date, time } = formatLocalDateTime(iso);
60
162
  return {
163
+ sha,
61
164
  date,
165
+ time,
62
166
  ticket: extractTicket(subject, opts.pattern),
63
167
  description: subject
64
168
  };
65
169
  });
170
+ const branchMap = await getBranchMap(
171
+ opts.cwd,
172
+ parsed.map((p) => p.sha).filter((s) => s.length > 0)
173
+ );
174
+ return parsed.map((p) => ({
175
+ sha: p.sha,
176
+ date: p.date,
177
+ time: p.time,
178
+ ticket: p.ticket,
179
+ description: p.description,
180
+ branch: branchMap.get(p.sha) ?? null
181
+ }));
66
182
  }
67
183
 
68
184
  // src/format.ts
69
185
  import Table from "cli-table3";
70
186
  import chalk from "chalk";
71
- function renderTable(entries) {
187
+ function renderDateCell(entry) {
188
+ if (!entry.time) {
189
+ return entry.date;
190
+ }
191
+ return `${entry.date} ${chalk.dim(entry.time)}`;
192
+ }
193
+ function renderDescriptionCell(entry) {
194
+ if (!entry.branch) {
195
+ return entry.description;
196
+ }
197
+ return `${entry.description} ${chalk.magenta(`[${entry.branch}]`)}`;
198
+ }
199
+ function renderTable(entries, ticketColumnLabel = "Ticket") {
72
200
  const table = new Table({
73
201
  head: [
74
202
  chalk.bold.cyan("Date"),
75
- chalk.bold.cyan("Ticket"),
203
+ chalk.bold.cyan(ticketColumnLabel),
204
+ chalk.bold.cyan("Description")
205
+ ],
206
+ style: { head: [], border: [] },
207
+ wordWrap: true,
208
+ colWidths: [18, 14, 80]
209
+ });
210
+ for (const entry of entries) {
211
+ table.push([
212
+ renderDateCell(entry),
213
+ entry.ticket ? chalk.yellow(entry.ticket) : chalk.gray("\u2014"),
214
+ renderDescriptionCell(entry)
215
+ ]);
216
+ }
217
+ return table.toString();
218
+ }
219
+ function renderTableGroupedByDay(entries, ticketColumnLabel = "Ticket") {
220
+ const table = new Table({
221
+ head: [
222
+ chalk.bold.cyan("Time"),
223
+ chalk.bold.cyan(ticketColumnLabel),
76
224
  chalk.bold.cyan("Description")
77
225
  ],
78
226
  style: { head: [], border: [] },
79
227
  wordWrap: true,
80
- colWidths: [12, 14, 80]
228
+ colWidths: [8, 14, 90]
81
229
  });
230
+ let currentDate = "";
82
231
  for (const entry of entries) {
232
+ if (entry.date && entry.date !== currentDate) {
233
+ table.push([
234
+ {
235
+ content: chalk.bold(entry.date),
236
+ colSpan: 3,
237
+ hAlign: "left"
238
+ }
239
+ ]);
240
+ currentDate = entry.date;
241
+ }
83
242
  table.push([
84
- chalk.dim(entry.date),
243
+ entry.time ? chalk.dim(entry.time) : chalk.gray("\u2014"),
85
244
  entry.ticket ? chalk.yellow(entry.ticket) : chalk.gray("\u2014"),
86
- entry.description
245
+ renderDescriptionCell(entry)
87
246
  ]);
88
247
  }
89
248
  return table.toString();
90
249
  }
250
+ function toJsonKey(label) {
251
+ return label.toLowerCase().replace(/\s+/g, "_");
252
+ }
253
+ function renderJson(entries, ticketColumnLabel = "Ticket") {
254
+ const key = toJsonKey(ticketColumnLabel);
255
+ const transformed = entries.map((e) => ({
256
+ sha: e.sha,
257
+ date: e.date,
258
+ time: e.time,
259
+ [key]: e.ticket,
260
+ description: e.description,
261
+ branch: e.branch
262
+ }));
263
+ return JSON.stringify(transformed, null, 2);
264
+ }
91
265
  function renderEmpty() {
92
266
  return chalk.gray("No commits found for the given filters.");
93
267
  }
@@ -100,6 +274,21 @@ import { homedir } from "os";
100
274
  import { readFile } from "fs/promises";
101
275
  import { join } from "path";
102
276
  import { cosmiconfig } from "cosmiconfig";
277
+ var TOGGL_DEFAULTS = {
278
+ durationMinutes: 30,
279
+ dayStartHour: 9,
280
+ oneEntryPerTicket: true,
281
+ ignoreSubjectPattern: "\\bmerge\\b"
282
+ };
283
+ var DEFAULT_COLUMN_LABELS = {
284
+ jira: "Ticket",
285
+ github: "Issue",
286
+ conventional: "Type",
287
+ custom: "Match"
288
+ };
289
+ function getColumnLabel(format, override) {
290
+ return override ?? DEFAULT_COLUMN_LABELS[format];
291
+ }
103
292
  var PRESET_PATTERNS = {
104
293
  jira: /\b([A-Z][A-Z0-9]+-\d+)\b/,
105
294
  github: /#(\d+)/,
@@ -111,12 +300,28 @@ var VALID_FORMATS = [
111
300
  "conventional",
112
301
  "custom"
113
302
  ];
303
+ var MAX_CUSTOM_PATTERN_LENGTH = 500;
304
+ function compileUserRegex(pattern) {
305
+ if (pattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
306
+ throw new Error(
307
+ `customPattern is ${pattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
308
+ );
309
+ }
310
+ try {
311
+ return new RegExp(pattern);
312
+ } catch (err) {
313
+ throw new Error(
314
+ `customPattern is not a valid regex: ${err.message}`,
315
+ { cause: err }
316
+ );
317
+ }
318
+ }
114
319
  function getTicketPattern(format, customPattern) {
115
320
  if (format === "custom") {
116
321
  if (!customPattern) {
117
322
  throw new Error('format "custom" requires customPattern to be set');
118
323
  }
119
- return new RegExp(customPattern);
324
+ return compileUserRegex(customPattern);
120
325
  }
121
326
  return PRESET_PATTERNS[format];
122
327
  }
@@ -147,6 +352,11 @@ function validateConfig(raw) {
147
352
  if (typeof obj.customPattern !== "string") {
148
353
  throw new Error("customPattern must be a string");
149
354
  }
355
+ if (obj.customPattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
356
+ throw new Error(
357
+ `customPattern is ${obj.customPattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
358
+ );
359
+ }
150
360
  cfg.customPattern = obj.customPattern;
151
361
  }
152
362
  if (cfg.format === "custom" && !cfg.customPattern) {
@@ -164,6 +374,77 @@ function validateConfig(raw) {
164
374
  }
165
375
  cfg.defaultRepos = obj.defaultRepos;
166
376
  }
377
+ if ("ticketColumnLabel" in obj) {
378
+ if (typeof obj.ticketColumnLabel !== "string") {
379
+ throw new Error("ticketColumnLabel must be a string");
380
+ }
381
+ cfg.ticketColumnLabel = obj.ticketColumnLabel;
382
+ }
383
+ if ("togglApiToken" in obj) {
384
+ if (typeof obj.togglApiToken !== "string") {
385
+ throw new Error("togglApiToken must be a string");
386
+ }
387
+ cfg.togglApiToken = obj.togglApiToken;
388
+ }
389
+ if ("togglWorkspaceId" in obj) {
390
+ if (typeof obj.togglWorkspaceId !== "number" || !Number.isInteger(obj.togglWorkspaceId) || obj.togglWorkspaceId < 1) {
391
+ throw new Error("togglWorkspaceId must be a positive integer");
392
+ }
393
+ cfg.togglWorkspaceId = obj.togglWorkspaceId;
394
+ }
395
+ if ("togglProjects" in obj) {
396
+ const raw2 = obj.togglProjects;
397
+ if (typeof raw2 !== "object" || raw2 === null || Array.isArray(raw2)) {
398
+ throw new Error(
399
+ "togglProjects must be an object mapping ticket prefix \u2192 project ID"
400
+ );
401
+ }
402
+ const map = {};
403
+ for (const [prefix, projectId] of Object.entries(raw2)) {
404
+ if (typeof projectId !== "number" || !Number.isInteger(projectId) || projectId < 1) {
405
+ throw new Error(
406
+ `togglProjects["${prefix}"] must be a positive integer`
407
+ );
408
+ }
409
+ map[prefix] = projectId;
410
+ }
411
+ cfg.togglProjects = map;
412
+ }
413
+ if ("togglDefaultProjectId" in obj) {
414
+ if (typeof obj.togglDefaultProjectId !== "number" || !Number.isInteger(obj.togglDefaultProjectId) || obj.togglDefaultProjectId < 1) {
415
+ throw new Error("togglDefaultProjectId must be a positive integer");
416
+ }
417
+ cfg.togglDefaultProjectId = obj.togglDefaultProjectId;
418
+ }
419
+ if ("togglDefaultDurationMinutes" in obj) {
420
+ if (typeof obj.togglDefaultDurationMinutes !== "number" || !Number.isInteger(obj.togglDefaultDurationMinutes) || obj.togglDefaultDurationMinutes < 1) {
421
+ throw new Error("togglDefaultDurationMinutes must be a positive integer");
422
+ }
423
+ cfg.togglDefaultDurationMinutes = obj.togglDefaultDurationMinutes;
424
+ }
425
+ if ("togglDayStartHour" in obj) {
426
+ if (typeof obj.togglDayStartHour !== "number" || !Number.isInteger(obj.togglDayStartHour) || obj.togglDayStartHour < 0 || obj.togglDayStartHour > 23) {
427
+ throw new Error("togglDayStartHour must be an integer between 0 and 23");
428
+ }
429
+ cfg.togglDayStartHour = obj.togglDayStartHour;
430
+ }
431
+ if ("togglOneEntryPerTicket" in obj) {
432
+ if (typeof obj.togglOneEntryPerTicket !== "boolean") {
433
+ throw new Error("togglOneEntryPerTicket must be a boolean");
434
+ }
435
+ cfg.togglOneEntryPerTicket = obj.togglOneEntryPerTicket;
436
+ }
437
+ if ("togglIgnoreSubjectPattern" in obj) {
438
+ if (typeof obj.togglIgnoreSubjectPattern !== "string") {
439
+ throw new Error("togglIgnoreSubjectPattern must be a string");
440
+ }
441
+ if (obj.togglIgnoreSubjectPattern.length > MAX_CUSTOM_PATTERN_LENGTH) {
442
+ throw new Error(
443
+ `togglIgnoreSubjectPattern is ${obj.togglIgnoreSubjectPattern.length} characters; limit is ${MAX_CUSTOM_PATTERN_LENGTH}`
444
+ );
445
+ }
446
+ cfg.togglIgnoreSubjectPattern = obj.togglIgnoreSubjectPattern;
447
+ }
167
448
  return cfg;
168
449
  }
169
450
  function globalConfigPath() {
@@ -194,7 +475,495 @@ async function loadConfig(cwd) {
194
475
  return {};
195
476
  }
196
477
 
478
+ // src/integrations/toggl.ts
479
+ var TOGGL_API_BASE = "https://api.track.toggl.com/api/v9";
480
+ function shortenSha(sha) {
481
+ return sha.slice(0, 7);
482
+ }
483
+ function findProjectId(ticket, projects, defaultProjectId) {
484
+ if (!ticket) {
485
+ return { projectId: defaultProjectId ?? null, matchedPrefix: null };
486
+ }
487
+ const prefixes = Object.keys(projects).sort((a, b) => b.length - a.length);
488
+ for (const prefix of prefixes) {
489
+ if (ticket.startsWith(prefix)) {
490
+ return { projectId: projects[prefix] ?? null, matchedPrefix: prefix };
491
+ }
492
+ }
493
+ return { projectId: defaultProjectId ?? null, matchedPrefix: null };
494
+ }
495
+ var SHA_MARKER_GLOBAL = /\(wdid ([0-9a-f]{7})\)/g;
496
+ function extractSyncedShasFromDescription(description) {
497
+ const shas = [];
498
+ for (const match of description.matchAll(SHA_MARKER_GLOBAL)) {
499
+ if (match[1]) {
500
+ shas.push(match[1]);
501
+ }
502
+ }
503
+ return shas;
504
+ }
505
+ function groupKey(commit, oneEntryPerTicket) {
506
+ if (oneEntryPerTicket && commit.ticket) {
507
+ return `ticket:${commit.ticket}`;
508
+ }
509
+ return `sha:${commit.sha}`;
510
+ }
511
+ function buildMarker(shortShas) {
512
+ return shortShas.map((s) => `(wdid ${s})`).join(" ");
513
+ }
514
+ var CONVENTIONAL_PREFIX = /^\w+(\([^)]+\))?!?:\s*/;
515
+ function escapeRegExp(s) {
516
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
517
+ }
518
+ function cleanSubjectForToggl(rawSubject, ticket) {
519
+ let cleaned = rawSubject.replace(CONVENTIONAL_PREFIX, "");
520
+ if (ticket) {
521
+ const leadingTicket = new RegExp(`^${escapeRegExp(ticket)}[:\\s]+`);
522
+ cleaned = cleaned.replace(leadingTicket, "");
523
+ }
524
+ return cleaned.trim();
525
+ }
526
+ function planEntries(commits, options) {
527
+ const filtered = options.ignoreSubjectPattern ? commits.filter((c) => !options.ignoreSubjectPattern.test(c.description)) : commits;
528
+ const sorted = [...filtered].sort(
529
+ (a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`)
530
+ );
531
+ const groups = /* @__PURE__ */ new Map();
532
+ for (const commit of sorted) {
533
+ const key = groupKey(commit, options.oneEntryPerTicket);
534
+ const existing = groups.get(key);
535
+ if (existing) {
536
+ existing.push(commit);
537
+ } else {
538
+ groups.set(key, [commit]);
539
+ }
540
+ }
541
+ const defaultDurationSeconds = options.defaultDurationMinutes * 60;
542
+ const dayStart = /* @__PURE__ */ new Date(
543
+ `${options.date}T${String(options.dayStartHour).padStart(2, "0")}:00:00`
544
+ );
545
+ let cursor = dayStart.getTime();
546
+ const plans = [];
547
+ for (const groupCommits of groups.values()) {
548
+ const first = groupCommits[0];
549
+ const ticket = first.ticket;
550
+ const shas = groupCommits.map((c) => c.sha);
551
+ const shortShas = shas.map(shortenSha);
552
+ const cleanedSubjects = groupCommits.map((c) => cleanSubjectForToggl(c.description, ticket)).filter((s) => s.length > 0);
553
+ const joined = cleanedSubjects.join("; ");
554
+ const marker = buildMarker(shortShas);
555
+ const body = ticket ? joined.length > 0 ? `${ticket}: ${joined}` : ticket : joined;
556
+ const description = `${body} ${marker}`.trim();
557
+ const durationSeconds = defaultDurationSeconds * groupCommits.length;
558
+ const { projectId, matchedPrefix } = findProjectId(
559
+ ticket,
560
+ options.projects,
561
+ options.defaultProjectId
562
+ );
563
+ const alreadySynced = shortShas.every(
564
+ (s) => options.existingSyncedShas.has(s)
565
+ );
566
+ plans.push({
567
+ shas,
568
+ shortShas,
569
+ description,
570
+ start: new Date(cursor).toISOString(),
571
+ durationSeconds,
572
+ projectId,
573
+ matchedTicketPrefix: matchedPrefix,
574
+ ticket,
575
+ commitCount: groupCommits.length,
576
+ alreadySynced
577
+ });
578
+ cursor += durationSeconds * 1e3;
579
+ }
580
+ return plans;
581
+ }
582
+ function basicAuth(token) {
583
+ return `Basic ${Buffer.from(`${token}:api_token`).toString("base64")}`;
584
+ }
585
+ function nextDay(date) {
586
+ const d = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
587
+ d.setUTCDate(d.getUTCDate() + 1);
588
+ return d.toISOString().slice(0, 10);
589
+ }
590
+ var MAX_SYNC_RANGE_DAYS = 366;
591
+ function enumerateDates(from, to) {
592
+ const start = /* @__PURE__ */ new Date(`${from}T00:00:00Z`);
593
+ const end = /* @__PURE__ */ new Date(`${to}T00:00:00Z`);
594
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
595
+ throw new Error(`invalid date range "${from}" \u2192 "${to}"`);
596
+ }
597
+ if (end < start) {
598
+ throw new Error(`--to (${to}) must be on or after --from (${from})`);
599
+ }
600
+ const days = Math.round((end.getTime() - start.getTime()) / 864e5) + 1;
601
+ if (days > MAX_SYNC_RANGE_DAYS) {
602
+ throw new Error(
603
+ `date range is ${days} days; limit is ${MAX_SYNC_RANGE_DAYS}`
604
+ );
605
+ }
606
+ const result = [];
607
+ for (const d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
608
+ result.push(d.toISOString().slice(0, 10));
609
+ }
610
+ return result;
611
+ }
612
+ async function fetchSyncedShas(auth, date) {
613
+ const url = `${TOGGL_API_BASE}/me/time_entries?start_date=${date}&end_date=${nextDay(date)}`;
614
+ const response = await fetch(url, {
615
+ headers: { Authorization: basicAuth(auth.apiToken) }
616
+ });
617
+ if (!response.ok) {
618
+ throw new Error(
619
+ `Toggl fetch failed: ${response.status} ${response.statusText}`
620
+ );
621
+ }
622
+ const entries = await response.json();
623
+ const shas = /* @__PURE__ */ new Set();
624
+ for (const e of entries) {
625
+ for (const sha of extractSyncedShasFromDescription(e.description ?? "")) {
626
+ shas.add(sha);
627
+ }
628
+ }
629
+ return shas;
630
+ }
631
+ async function pushEntries(auth, workspaceId, plan) {
632
+ const result = { pushed: 0, skipped: 0, failures: [] };
633
+ for (const entry of plan) {
634
+ if (entry.alreadySynced) {
635
+ result.skipped++;
636
+ continue;
637
+ }
638
+ const response = await fetch(
639
+ `${TOGGL_API_BASE}/workspaces/${workspaceId}/time_entries`,
640
+ {
641
+ method: "POST",
642
+ headers: {
643
+ "Content-Type": "application/json",
644
+ Authorization: basicAuth(auth.apiToken)
645
+ },
646
+ body: JSON.stringify({
647
+ description: entry.description,
648
+ start: entry.start,
649
+ duration: entry.durationSeconds,
650
+ workspace_id: workspaceId,
651
+ project_id: entry.projectId,
652
+ created_with: "wdid"
653
+ })
654
+ }
655
+ );
656
+ if (!response.ok) {
657
+ const body = await response.text();
658
+ result.failures.push({
659
+ plan: entry,
660
+ reason: `${response.status} ${response.statusText} \u2014 ${body.slice(0, 200)}`
661
+ });
662
+ continue;
663
+ }
664
+ result.pushed++;
665
+ }
666
+ return result;
667
+ }
668
+
669
+ // src/config-cli.ts
670
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
671
+ import { dirname } from "path";
672
+ import Table2 from "cli-table3";
673
+ import chalk2 from "chalk";
674
+ var FIELDS = {
675
+ format: {
676
+ type: "enum",
677
+ values: ["jira", "github", "conventional", "custom"],
678
+ default: "jira",
679
+ description: "Ticket extraction preset."
680
+ },
681
+ customPattern: {
682
+ type: "string",
683
+ description: 'Regex for ticket extraction when format is "custom".'
684
+ },
685
+ defaultAuthor: {
686
+ type: "string",
687
+ description: "Default author for git log; falls back to git config user.name."
688
+ },
689
+ defaultRepos: {
690
+ type: "string-array",
691
+ settable: false,
692
+ description: "Repo paths to query when --repo is not given. (~) is expanded."
693
+ },
694
+ ticketColumnLabel: {
695
+ type: "string",
696
+ description: "Override the auto-picked Ticket column header."
697
+ },
698
+ togglApiToken: {
699
+ type: "string",
700
+ secret: true,
701
+ description: "Toggl API token. Prefer the TOGGL_API_TOKEN env var."
702
+ },
703
+ togglWorkspaceId: {
704
+ type: "number",
705
+ description: "Numeric Toggl workspace ID. Required to push."
706
+ },
707
+ togglProjects: {
708
+ type: "number-record",
709
+ nested: true,
710
+ description: "Map of ticket-prefix \u2192 Toggl project ID. Longest prefix wins."
711
+ },
712
+ togglDefaultProjectId: {
713
+ type: "number",
714
+ description: "Project ID for commits without a prefix match."
715
+ },
716
+ togglDefaultDurationMinutes: {
717
+ type: "number",
718
+ default: 30,
719
+ description: "Per-commit duration. Total = count \xD7 this in per-ticket mode."
720
+ },
721
+ togglDayStartHour: {
722
+ type: "number",
723
+ default: 9,
724
+ description: "Hour (0\u201323) to start stacking entries at."
725
+ },
726
+ togglOneEntryPerTicket: {
727
+ type: "boolean",
728
+ default: true,
729
+ description: "Collapse same-ticket commits into one entry. No-ticket commits stay 1:1."
730
+ },
731
+ togglIgnoreSubjectPattern: {
732
+ type: "string",
733
+ default: "\\bmerge\\b",
734
+ description: 'Subjects matching this regex (case-insensitive) are skipped. "" to disable.'
735
+ }
736
+ };
737
+ function parseKey(rawKey) {
738
+ const dotIndex = rawKey.indexOf(".");
739
+ const field = dotIndex === -1 ? rawKey : rawKey.slice(0, dotIndex);
740
+ const subKey = dotIndex === -1 ? void 0 : rawKey.slice(dotIndex + 1);
741
+ const spec = FIELDS[field];
742
+ if (!spec) {
743
+ throw new Error(
744
+ `unknown config key "${field}". Known keys: ${Object.keys(FIELDS).join(", ")}`
745
+ );
746
+ }
747
+ if (subKey !== void 0 && !spec.nested) {
748
+ throw new Error(
749
+ `config key "${field}" does not support dotted access (it is a ${spec.type})`
750
+ );
751
+ }
752
+ if (subKey === "") {
753
+ throw new Error(`empty sub-key in "${rawKey}"`);
754
+ }
755
+ return { field, subKey };
756
+ }
757
+ function parseValue(field, rawValue) {
758
+ const spec = FIELDS[field];
759
+ if (!spec) {
760
+ throw new Error(`unknown config key "${field}"`);
761
+ }
762
+ if (spec.settable === false) {
763
+ throw new Error(
764
+ `"${field}" cannot be set from the CLI \u2014 edit the config file directly`
765
+ );
766
+ }
767
+ switch (spec.type) {
768
+ case "string":
769
+ return rawValue;
770
+ case "enum":
771
+ if (!spec.values?.includes(rawValue)) {
772
+ throw new Error(
773
+ `"${rawValue}" is not a valid value for "${field}" \u2014 must be one of ${spec.values?.join(", ")}`
774
+ );
775
+ }
776
+ return rawValue;
777
+ case "number":
778
+ case "number-record": {
779
+ const n = Number(rawValue);
780
+ if (!Number.isFinite(n)) {
781
+ throw new Error(`"${rawValue}" is not a valid number for "${field}"`);
782
+ }
783
+ return n;
784
+ }
785
+ case "boolean":
786
+ if (rawValue === "true") {
787
+ return true;
788
+ }
789
+ if (rawValue === "false") {
790
+ return false;
791
+ }
792
+ throw new Error(
793
+ `"${rawValue}" is not a valid boolean for "${field}" \u2014 use "true" or "false"`
794
+ );
795
+ case "string-array":
796
+ throw new Error(`"${field}" is an array \u2014 edit the config file directly`);
797
+ default:
798
+ throw new Error(`internal: unhandled field type for "${field}"`);
799
+ }
800
+ }
801
+ function setConfigValue(cfg, rawKey, rawValue) {
802
+ const { field, subKey } = parseKey(rawKey);
803
+ const next = { ...cfg };
804
+ if (subKey !== void 0) {
805
+ const spec = FIELDS[field];
806
+ if (spec.type !== "number-record") {
807
+ throw new Error(
808
+ `internal: nested set requested for non-record field "${field}"`
809
+ );
810
+ }
811
+ const parsed = parseValue(field, rawValue);
812
+ const current = cfg[field] ?? {};
813
+ next[field] = {
814
+ ...current,
815
+ [subKey]: parsed
816
+ };
817
+ } else {
818
+ next[field] = parseValue(field, rawValue);
819
+ }
820
+ return validateConfig(next);
821
+ }
822
+ function getConfigValue(cfg, rawKey) {
823
+ const { field, subKey } = parseKey(rawKey);
824
+ const value = cfg[field];
825
+ if (subKey === void 0) {
826
+ return value;
827
+ }
828
+ if (typeof value !== "object" || value === null) {
829
+ return void 0;
830
+ }
831
+ return value[subKey];
832
+ }
833
+ function maskSecret(value) {
834
+ if (value.length <= 10) {
835
+ return "***";
836
+ }
837
+ return `${value.slice(0, 4)}\u2026${value.slice(-6)}`;
838
+ }
839
+ function formatValue(value, secret, reveal) {
840
+ if (value === void 0) {
841
+ return "(not set)";
842
+ }
843
+ if (secret && !reveal && typeof value === "string") {
844
+ return maskSecret(value);
845
+ }
846
+ if (typeof value === "string") {
847
+ return value;
848
+ }
849
+ return JSON.stringify(value);
850
+ }
851
+ function renderConfigList(cfg, options = {}) {
852
+ const reveal = options.showSecrets ?? false;
853
+ const keys = Object.keys(cfg);
854
+ const presentKeys = keys.filter((k) => cfg[k] !== void 0);
855
+ if (presentKeys.length === 0) {
856
+ return chalk2.gray("(no values set)");
857
+ }
858
+ const table = new Table2({
859
+ head: [chalk2.bold.cyan("Key"), chalk2.bold.cyan("Value")],
860
+ style: { head: [], border: [] },
861
+ wordWrap: true,
862
+ colWidths: [32, 60]
863
+ });
864
+ for (const k of presentKeys) {
865
+ const spec = FIELDS[k];
866
+ const secret = spec?.secret ?? false;
867
+ const formatted = formatValue(cfg[k], secret, reveal);
868
+ const valueCell = secret && !reveal ? chalk2.dim(formatted) : formatted;
869
+ table.push([chalk2.cyan(k), valueCell]);
870
+ }
871
+ return table.toString();
872
+ }
873
+ function formatFieldType(spec) {
874
+ switch (spec.type) {
875
+ case "enum":
876
+ return `enum (${(spec.values ?? []).join(" | ")})`;
877
+ case "string-array":
878
+ return "string[]";
879
+ case "number-record":
880
+ return "Record<string, number>";
881
+ default:
882
+ return spec.type;
883
+ }
884
+ }
885
+ function formatFieldNotes(spec) {
886
+ const tags = [];
887
+ if (spec.secret) {
888
+ tags.push("secret");
889
+ }
890
+ if (spec.nested) {
891
+ tags.push("nested");
892
+ }
893
+ if (spec.settable === false) {
894
+ tags.push("edit file directly");
895
+ }
896
+ return tags.length > 0 ? tags.join(", ") : "";
897
+ }
898
+ function formatFieldDefault(spec) {
899
+ if (spec.default === void 0) {
900
+ return "";
901
+ }
902
+ return typeof spec.default === "string" ? spec.default : JSON.stringify(spec.default);
903
+ }
904
+ function renderConfigKeys() {
905
+ const table = new Table2({
906
+ head: [
907
+ chalk2.bold.cyan("Key"),
908
+ chalk2.bold.cyan("Type"),
909
+ chalk2.bold.cyan("Default"),
910
+ chalk2.bold.cyan("Notes"),
911
+ chalk2.bold.cyan("Description")
912
+ ],
913
+ style: { head: [], border: [] },
914
+ wordWrap: true,
915
+ colWidths: [30, 26, 16, 22, 48]
916
+ });
917
+ for (const [key, spec] of Object.entries(FIELDS)) {
918
+ table.push([
919
+ chalk2.cyan(key),
920
+ formatFieldType(spec),
921
+ chalk2.dim(formatFieldDefault(spec)),
922
+ chalk2.yellow(formatFieldNotes(spec)),
923
+ chalk2.dim(spec.description ?? "")
924
+ ]);
925
+ }
926
+ return table.toString();
927
+ }
928
+ function renderSingleValue(value, field, reveal) {
929
+ const spec = FIELDS[field];
930
+ return formatValue(value, spec?.secret ?? false, reveal);
931
+ }
932
+ async function readGlobalConfig() {
933
+ try {
934
+ const content = await readFile2(globalConfigPath(), "utf-8");
935
+ return validateConfig(JSON.parse(content));
936
+ } catch (err) {
937
+ if (err.code === "ENOENT") {
938
+ return {};
939
+ }
940
+ throw err;
941
+ }
942
+ }
943
+ async function writeGlobalConfig(cfg) {
944
+ const path = globalConfigPath();
945
+ await mkdir(dirname(path), { recursive: true });
946
+ await writeFile(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
947
+ }
948
+
197
949
  // src/index.ts
950
+ function parseLimit(raw) {
951
+ if (raw === void 0) {
952
+ return void 0;
953
+ }
954
+ const n = Number.parseInt(raw, 10);
955
+ if (!Number.isInteger(n) || n < 1 || String(n) !== raw.trim()) {
956
+ throw new Error(`invalid --limit "${raw}" \u2014 must be a positive integer`);
957
+ }
958
+ return n;
959
+ }
960
+ function shouldDisableColor(options) {
961
+ if (options.color === false) {
962
+ return true;
963
+ }
964
+ const noColor = process.env.NO_COLOR ?? "";
965
+ return noColor.length > 0;
966
+ }
198
967
  var VALID_PRESETS = [
199
968
  "jira",
200
969
  "github",
@@ -242,21 +1011,219 @@ async function run(dateArg, options) {
242
1011
  from = day;
243
1012
  to = day;
244
1013
  }
245
- const allEntries = [];
246
- for (const cwd of repos) {
247
- const author = options.author ?? config.defaultAuthor ?? await getGitUserName(cwd);
248
- const entries = await getCommits({ author, from, to, cwd, pattern });
249
- allEntries.push(...entries);
1014
+ const limit = parseLimit(options.limit);
1015
+ const perRepoEntries = await Promise.all(
1016
+ repos.map(async (cwd) => {
1017
+ const author = options.author ?? config.defaultAuthor ?? await getGitUserName(cwd);
1018
+ return getCommits({ author, from, to, cwd, pattern, limit });
1019
+ })
1020
+ );
1021
+ const allEntries = perRepoEntries.flat();
1022
+ allEntries.sort(
1023
+ (a, b) => `${b.date} ${b.time}`.localeCompare(`${a.date} ${a.time}`)
1024
+ );
1025
+ const display = limit !== void 0 ? allEntries.slice(0, limit) : allEntries;
1026
+ const ticketColumnLabel = getColumnLabel(format, config.ticketColumnLabel);
1027
+ if (options.json) {
1028
+ process.stdout.write(renderJson(display, ticketColumnLabel) + "\n");
1029
+ return;
250
1030
  }
251
- allEntries.sort((a, b) => b.date.localeCompare(a.date));
252
- if (allEntries.length === 0) {
1031
+ if (display.length === 0) {
253
1032
  process.stdout.write(renderEmpty() + "\n");
254
1033
  return;
255
1034
  }
256
- process.stdout.write(renderTable(allEntries) + "\n");
1035
+ const rendered = options.groupByDay ? renderTableGroupedByDay(display, ticketColumnLabel) : renderTable(display, ticketColumnLabel);
1036
+ process.stdout.write(rendered + "\n");
1037
+ }
1038
+ function resolveTogglSyncDates(dateArg, options) {
1039
+ if (dateArg && (options.from || options.to)) {
1040
+ throw new Error(
1041
+ "cannot combine the positional [date] with --from / --to \u2014 use one or the other"
1042
+ );
1043
+ }
1044
+ if (options.from || options.to) {
1045
+ if (!options.from || !options.to) {
1046
+ throw new Error("--from and --to must both be provided");
1047
+ }
1048
+ return enumerateDates(resolveDate(options.from), resolveDate(options.to));
1049
+ }
1050
+ return [resolveDate(dateArg ?? "today")];
1051
+ }
1052
+ function resolveTogglAuth(config) {
1053
+ const token = process.env.TOGGL_API_TOKEN ?? config.togglApiToken;
1054
+ if (!token) {
1055
+ return null;
1056
+ }
1057
+ return { apiToken: token };
1058
+ }
1059
+ function formatHHMM(iso) {
1060
+ const d = new Date(iso);
1061
+ const pad = (n) => String(n).padStart(2, "0");
1062
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
1063
+ }
1064
+ function renderTogglPlan(plan, date) {
1065
+ const newCount = plan.filter((p) => !p.alreadySynced).length;
1066
+ const skipCount = plan.length - newCount;
1067
+ const lines = [];
1068
+ lines.push(
1069
+ chalk3.bold(`Toggl sync \u2014 ${date}`) + chalk3.dim(
1070
+ ` (${newCount} new, ${skipCount} already synced, ${plan.length} total)`
1071
+ )
1072
+ );
1073
+ lines.push("");
1074
+ for (const entry of plan) {
1075
+ const start = formatHHMM(entry.start);
1076
+ const endIso = new Date(
1077
+ new Date(entry.start).getTime() + entry.durationSeconds * 1e3
1078
+ ).toISOString();
1079
+ const end = formatHHMM(endIso);
1080
+ const status = entry.alreadySynced ? chalk3.gray("skip") : chalk3.green("new ");
1081
+ const project = entry.projectId === null ? chalk3.yellow("(no project)") : chalk3.cyan(
1082
+ `[project ${entry.projectId}${entry.matchedTicketPrefix ? ` \u2190 ${entry.matchedTicketPrefix}` : " (default)"}]`
1083
+ );
1084
+ const commitNote = entry.commitCount > 1 ? chalk3.dim(` (${entry.commitCount} commits)`) : "";
1085
+ lines.push(
1086
+ ` ${status} ${chalk3.dim(`${start}\u2013${end}`)} ${entry.description} ${project}${commitNote}`
1087
+ );
1088
+ }
1089
+ return lines.join("\n");
1090
+ }
1091
+ async function syncOneDay(date, ctx) {
1092
+ const result = {
1093
+ date,
1094
+ planned: 0,
1095
+ pushed: 0,
1096
+ skipped: 0,
1097
+ failures: 0
1098
+ };
1099
+ try {
1100
+ const perRepoCommits = await Promise.all(
1101
+ ctx.repos.map(async (cwd) => {
1102
+ const author = ctx.cliAuthor ?? ctx.configAuthor ?? await getGitUserName(cwd);
1103
+ return getCommits({
1104
+ author,
1105
+ from: date,
1106
+ to: date,
1107
+ cwd,
1108
+ pattern: ctx.pattern
1109
+ });
1110
+ })
1111
+ );
1112
+ const commits = perRepoCommits.flat();
1113
+ const existingSyncedShas = ctx.auth ? await fetchSyncedShas(ctx.auth, date) : /* @__PURE__ */ new Set();
1114
+ const plan = planEntries(commits, {
1115
+ date,
1116
+ defaultDurationMinutes: ctx.defaultDurationMinutes,
1117
+ dayStartHour: ctx.dayStartHour,
1118
+ projects: ctx.projects,
1119
+ defaultProjectId: ctx.defaultProjectId,
1120
+ existingSyncedShas,
1121
+ oneEntryPerTicket: ctx.oneEntryPerTicket,
1122
+ ignoreSubjectPattern: ctx.ignoreSubjectPattern
1123
+ });
1124
+ result.planned = plan.length;
1125
+ if (plan.length === 0) {
1126
+ process.stdout.write(
1127
+ chalk3.gray(`No commits to sync for ${date}.`) + "\n"
1128
+ );
1129
+ return result;
1130
+ }
1131
+ process.stdout.write(renderTogglPlan(plan, date) + "\n");
1132
+ if (ctx.dryRun) {
1133
+ return result;
1134
+ }
1135
+ if (!ctx.auth || ctx.workspaceId === null) {
1136
+ throw new Error("internal: auth/workspaceId resolved to null");
1137
+ }
1138
+ const pushResult = await pushEntries(ctx.auth, ctx.workspaceId, plan);
1139
+ result.pushed = pushResult.pushed;
1140
+ result.skipped = pushResult.skipped;
1141
+ result.failures = pushResult.failures.length;
1142
+ process.stdout.write(
1143
+ "\n" + chalk3.bold(
1144
+ ` ${date}: pushed ${pushResult.pushed}, skipped ${pushResult.skipped}`
1145
+ ) + (pushResult.failures.length > 0 ? chalk3.red(`, ${pushResult.failures.length} failed`) : "") + "\n"
1146
+ );
1147
+ for (const failure of pushResult.failures) {
1148
+ process.stderr.write(
1149
+ chalk3.red(
1150
+ ` ${date} failed: ${failure.plan.shortShas.join(",")} \u2014 ${failure.reason}`
1151
+ ) + "\n"
1152
+ );
1153
+ }
1154
+ } catch (err) {
1155
+ const message = err instanceof Error ? err.message : String(err);
1156
+ result.error = message;
1157
+ process.stderr.write(chalk3.red(` ${date}: ${message}`) + "\n");
1158
+ }
1159
+ return result;
1160
+ }
1161
+ async function runTogglSync(dateArg, options) {
1162
+ const config = await loadConfig(process.cwd());
1163
+ const dates = resolveTogglSyncDates(dateArg, options);
1164
+ const workspaceId = (options.workspace ? Number.parseInt(options.workspace, 10) : config.togglWorkspaceId) ?? null;
1165
+ if (!options.dryRun && workspaceId === null) {
1166
+ throw new Error(
1167
+ "togglWorkspaceId is not set \u2014 add it to your config or pass --workspace <id>"
1168
+ );
1169
+ }
1170
+ const auth = resolveTogglAuth(config);
1171
+ if (!options.dryRun && !auth) {
1172
+ throw new Error(
1173
+ "no Toggl API token \u2014 set TOGGL_API_TOKEN or `togglApiToken` in your config"
1174
+ );
1175
+ }
1176
+ const format = config.format ?? "jira";
1177
+ const pattern = getTicketPattern(format, config.customPattern);
1178
+ const configRepos = config.defaultRepos?.map(expandPath) ?? [];
1179
+ const repos = options.repo && options.repo.length > 0 ? options.repo : configRepos.length > 0 ? configRepos : [process.cwd()];
1180
+ const defaultDurationMinutes = config.togglDefaultDurationMinutes ?? TOGGL_DEFAULTS.durationMinutes;
1181
+ const dayStartHour = config.togglDayStartHour ?? TOGGL_DEFAULTS.dayStartHour;
1182
+ const oneEntryPerTicket = config.togglOneEntryPerTicket ?? TOGGL_DEFAULTS.oneEntryPerTicket;
1183
+ const ignoreSubjectSource = config.togglIgnoreSubjectPattern ?? TOGGL_DEFAULTS.ignoreSubjectPattern;
1184
+ const ignoreSubjectPattern = ignoreSubjectSource ? new RegExp(ignoreSubjectSource, "i") : void 0;
1185
+ const ctx = {
1186
+ auth,
1187
+ workspaceId,
1188
+ dryRun: options.dryRun ?? false,
1189
+ repos,
1190
+ cliAuthor: options.author,
1191
+ configAuthor: config.defaultAuthor,
1192
+ pattern,
1193
+ defaultDurationMinutes,
1194
+ dayStartHour,
1195
+ oneEntryPerTicket,
1196
+ ignoreSubjectPattern,
1197
+ projects: config.togglProjects ?? {},
1198
+ defaultProjectId: config.togglDefaultProjectId
1199
+ };
1200
+ const results = [];
1201
+ for (const date of dates) {
1202
+ results.push(await syncOneDay(date, ctx));
1203
+ }
1204
+ if (options.dryRun) {
1205
+ process.stdout.write("\n" + chalk3.dim("(dry-run \u2014 nothing pushed)") + "\n");
1206
+ return;
1207
+ }
1208
+ if (dates.length > 1) {
1209
+ const totalPushed = results.reduce((n, r) => n + r.pushed, 0);
1210
+ const totalSkipped = results.reduce((n, r) => n + r.skipped, 0);
1211
+ const totalFailures = results.reduce((n, r) => n + r.failures, 0);
1212
+ const erroredDays = results.filter((r) => r.error !== void 0).length;
1213
+ process.stdout.write(
1214
+ "\n" + chalk3.bold(
1215
+ `Total across ${dates.length} days: pushed ${totalPushed}, skipped ${totalSkipped}`
1216
+ ) + (totalFailures > 0 ? chalk3.red(`, ${totalFailures} failed`) : "") + (erroredDays > 0 ? chalk3.red(`, ${erroredDays} day(s) errored`) : "") + "\n"
1217
+ );
1218
+ }
1219
+ const anyFailed = results.some((r) => r.error !== void 0 || r.failures > 0);
1220
+ if (anyFailed) {
1221
+ process.exitCode = 1;
1222
+ }
257
1223
  }
258
1224
  var program = new Command();
259
- program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.1.3", "-V, --version", "output the version number").argument("[date]", 'a YYYY-MM-DD date or "today"; omit to show all history').option("--from <date>", 'start date (YYYY-MM-DD or "today")').option("--to <date>", 'end date (YYYY-MM-DD or "today")').option(
1225
+ program.enablePositionalOptions();
1226
+ program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.3.0", "-V, --version", "output the version number").argument("[date]", 'a YYYY-MM-DD date or "today"; omit to show all history').option("--from <date>", 'start date (YYYY-MM-DD or "today")').option("--to <date>", 'end date (YYYY-MM-DD or "today")').option(
260
1227
  "--author <name>",
261
1228
  "override the git author (defaults to git config user.name, then defaultAuthor in config)"
262
1229
  ).option(
@@ -268,7 +1235,19 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
268
1235
  ).option(
269
1236
  "--ticket-pattern <regex>",
270
1237
  "custom regex for ticket extraction (implies --format custom; overrides --format)"
271
- ).action(async (dateArg, options) => {
1238
+ ).option(
1239
+ "--no-color",
1240
+ "disable colored output (also honored via the NO_COLOR env var)"
1241
+ ).option(
1242
+ "--limit <N>",
1243
+ "cap the table to the most recent N rows (positive integer)"
1244
+ ).option(
1245
+ "--group-by-day",
1246
+ "group rows under a bold date heading per day (time-only in row)"
1247
+ ).option("--json", "emit a JSON array of commit entries instead of the table").action(async (dateArg, options) => {
1248
+ if (shouldDisableColor(options)) {
1249
+ chalk3.level = 0;
1250
+ }
272
1251
  try {
273
1252
  await run(dateArg, options);
274
1253
  } catch (err) {
@@ -277,4 +1256,103 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
277
1256
  process.exitCode = 1;
278
1257
  }
279
1258
  });
1259
+ var togglCmd = program.command("toggl").description("Toggl integration commands");
1260
+ togglCmd.command("sync [date]").description(
1261
+ "push the day's commits as Toggl time entries. Default: today. Pass --from/--to for a range."
1262
+ ).option("--dry-run", "preview the plan without pushing").option(
1263
+ "--workspace <id>",
1264
+ "override the configured togglWorkspaceId for this run"
1265
+ ).option(
1266
+ "--repo <path...>",
1267
+ "one or more repo paths to query (overrides defaultRepos in config)"
1268
+ ).option(
1269
+ "--author <name>",
1270
+ "override the git author (defaults to git config user.name, then defaultAuthor in config)"
1271
+ ).option(
1272
+ "--from <date>",
1273
+ "start of a multi-day range (inclusive). Use with --to. Mutually exclusive with [date]."
1274
+ ).option(
1275
+ "--to <date>",
1276
+ "end of a multi-day range (inclusive). Use with --from. Mutually exclusive with [date]."
1277
+ ).action(async (dateArg, options) => {
1278
+ try {
1279
+ await runTogglSync(dateArg, options);
1280
+ } catch (err) {
1281
+ const message = err instanceof Error ? err.message : String(err);
1282
+ process.stderr.write(renderError(message) + "\n");
1283
+ process.exitCode = 1;
1284
+ }
1285
+ });
1286
+ async function runConfigSet(key, value) {
1287
+ const current = await readGlobalConfig();
1288
+ const next = setConfigValue(current, key, value);
1289
+ await writeGlobalConfig(next);
1290
+ const { field } = parseKey(key);
1291
+ const isSecret = FIELDS[field]?.secret ?? false;
1292
+ process.stdout.write(
1293
+ chalk3.green(`set ${key}`) + (isSecret ? chalk3.dim(" (secret \u2014 value hidden)") : "") + "\n"
1294
+ );
1295
+ }
1296
+ async function runConfigGet(key, options) {
1297
+ const cfg = await readGlobalConfig();
1298
+ const value = getConfigValue(cfg, key);
1299
+ const { field } = parseKey(key);
1300
+ process.stdout.write(
1301
+ renderSingleValue(value, field, options.showSecrets ?? false) + "\n"
1302
+ );
1303
+ if (value === void 0) {
1304
+ process.exitCode = 1;
1305
+ }
1306
+ }
1307
+ async function runConfigList(options) {
1308
+ const cfg = await readGlobalConfig();
1309
+ process.stdout.write(
1310
+ renderConfigList(cfg, { showSecrets: options.showSecrets }) + "\n"
1311
+ );
1312
+ }
1313
+ function runConfigPath() {
1314
+ process.stdout.write(globalConfigPath() + "\n");
1315
+ }
1316
+ var configCmd = program.command("config").description("Read and write the global wdid config file");
1317
+ configCmd.command("set <key> <value>").description(
1318
+ "Set a value in the global config (~/.config/wdid/config.json). Use dotted access for nested fields (togglProjects.ABC-)."
1319
+ ).action(async (key, value) => {
1320
+ try {
1321
+ await runConfigSet(key, value);
1322
+ } catch (err) {
1323
+ const message = err instanceof Error ? err.message : String(err);
1324
+ process.stderr.write(renderError(message) + "\n");
1325
+ process.exitCode = 1;
1326
+ }
1327
+ });
1328
+ configCmd.command("get <key>").description(
1329
+ "Print a single config value. Secrets are masked unless --show-secrets is set."
1330
+ ).option("--show-secrets", "reveal secret values in full").action(async (key, options) => {
1331
+ try {
1332
+ await runConfigGet(key, options);
1333
+ } catch (err) {
1334
+ const message = err instanceof Error ? err.message : String(err);
1335
+ process.stderr.write(renderError(message) + "\n");
1336
+ process.exitCode = 1;
1337
+ }
1338
+ });
1339
+ configCmd.command("list").description(
1340
+ "Print all configured values. Secrets are masked unless --show-secrets is set."
1341
+ ).option("--show-secrets", "reveal secret values in full").action(async (options) => {
1342
+ try {
1343
+ await runConfigList(options);
1344
+ } catch (err) {
1345
+ const message = err instanceof Error ? err.message : String(err);
1346
+ process.stderr.write(renderError(message) + "\n");
1347
+ process.exitCode = 1;
1348
+ }
1349
+ });
1350
+ configCmd.command("path").description(
1351
+ "Print the absolute path to the global config file (honors XDG_CONFIG_HOME)."
1352
+ ).action(() => {
1353
+ runConfigPath();
1354
+ });
1355
+ configCmd.command("keys").description("List every config key with its type, default, and description.").action(() => {
1356
+ process.stdout.write(renderConfigKeys() + "\n");
1357
+ });
280
1358
  program.parseAsync(process.argv);