@drazenbebic/wdid 0.2.0 → 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 +88 -1
  2. package/dist/index.js +844 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,4 +1,17 @@
1
- # wdid
1
+ <div align="center">
2
+ <picture>
3
+ <source
4
+ srcset="./logos/wdid_logo_white_transparent.png"
5
+ media="(prefers-color-scheme: dark)"
6
+ width="820" height="332"
7
+ />
8
+ <img
9
+ src="./logos/wdid_logo_black_transparent.png"
10
+ alt="What did I do Logo"
11
+ width="820" height="332"
12
+ />
13
+ </picture>
14
+ </div>
2
15
 
3
16
  > What did I do? — a small CLI that summarizes your git activity as a tidy table, so you can fill in your timesheet without trying to remember Tuesday.
4
17
 
@@ -113,6 +126,80 @@ CLI flags always win. The first match in this list is used in full (configs do n
113
126
 
114
127
  The column header is picked automatically based on the active format. To override it for a specific preset (e.g. call them "Tasks" instead of "Ticket"), set `ticketColumnLabel` in your config.
115
128
 
129
+ ## Toggl integration
130
+
131
+ `wdid toggl sync [date]` pushes the day's commits to Toggl as time entries. By default, commits with the same ticket are **collapsed into a single entry** (duration scales with commit count) and commits whose subject matches `\bmerge\b` are skipped. Entries stack from a configurable day-start hour — you adjust the exact times in Toggl yourself. The sync is **idempotent**: each entry's description carries one `(wdid <short-sha>)` marker per included commit, and re-running skips commits already pushed.
132
+
133
+ Descriptions are condensed for Toggl: the conventional-commit prefix (`feat:`, `chore(ABC-123):`, `fix!:`, etc.) is stripped, and the ticket — if any — is prepended once. So `chore(EN-4435): remove requestBody` becomes `EN-4435: remove requestBody`. Aggregated entries look like `EN-4435: subject A; subject B; subject C`.
134
+
135
+ ```sh
136
+ wdid toggl sync # push today
137
+ wdid toggl sync 2026-05-27 # push a specific day
138
+ wdid toggl sync today --dry-run # preview without pushing
139
+ wdid toggl sync --workspace 12345 today # override the configured workspace
140
+ wdid toggl sync --from 2026-05-25 --to 2026-05-27 # push a multi-day range (inclusive)
141
+ ```
142
+
143
+ `--from` and `--to` are inclusive and mutually exclusive with the positional `[date]`. Each day is planned independently (its own 09:00 start, its own dedup fetch). On a per-day failure (Toggl 500, missing project, etc.), the sync continues through the remaining days and exits non-zero with a summary so one bad day doesn't strand the rest. The range is capped at 366 days as a guardrail.
144
+
145
+ ### Toggl config
146
+
147
+ Add these alongside the other config fields:
148
+
149
+ ```json
150
+ {
151
+ "togglApiToken": "your-api-token",
152
+ "togglWorkspaceId": 12345,
153
+ "togglProjects": {
154
+ "ABC-": 67890,
155
+ "DEF-": 67891
156
+ },
157
+ "togglDefaultProjectId": 99999,
158
+ "togglDefaultDurationMinutes": 30,
159
+ "togglDayStartHour": 9,
160
+ "togglOneEntryPerTicket": true,
161
+ "togglIgnoreSubjectPattern": "\\bmerge\\b"
162
+ }
163
+ ```
164
+
165
+ | Field | Type | Description |
166
+ | ----------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
167
+ | `togglApiToken` | `string` | Toggl API token (find it in Toggl → Profile → API Token). Prefer the `TOGGL_API_TOKEN` env var. |
168
+ | `togglWorkspaceId` | `number` | Numeric Toggl workspace ID. Required to push. |
169
+ | `togglProjects` | `Record<string, number>` | Map of ticket-prefix → project ID. Longest matching prefix wins. |
170
+ | `togglDefaultProjectId` | `number` | Project ID for commits that don't match any prefix (or have no ticket). |
171
+ | `togglDefaultDurationMinutes` | `number` | Per-commit duration. Default `30`. In per-ticket mode, an entry's total duration is `count × this`. |
172
+ | `togglDayStartHour` | `number` (0–23) | Hour to start stacking entries at. Default `9` (09:00). |
173
+ | `togglOneEntryPerTicket` | `boolean` | When `true` (default), commits sharing a ticket collapse into one entry. Commits without a ticket stay 1:1. |
174
+ | `togglIgnoreSubjectPattern` | `string` (regex) | Subjects matching this pattern (case-insensitive) are skipped. Default `\bmerge\b`. Set to `""` to disable. |
175
+
176
+ ### Auth
177
+
178
+ The API token is resolved in this order: `TOGGL_API_TOKEN` env var > `togglApiToken` in config. The env var path is preferred so you don't have to commit (or remember not to commit) the token.
179
+
180
+ ## Managing config
181
+
182
+ `wdid config` provides four subcommands for the **global** config (`~/.config/wdid/config.json` — honors `XDG_CONFIG_HOME`). Repo-level configs are read but not written by these commands; edit them by hand.
183
+
184
+ ```sh
185
+ wdid config set togglApiToken tok_… # set a scalar field
186
+ wdid config set togglWorkspaceId 12345 # numbers are parsed
187
+ wdid config set togglOneEntryPerTicket false # booleans take "true"/"false"
188
+ wdid config set togglProjects.ABC- 67890 # set a nested record entry
189
+ wdid config get togglApiToken # secrets are masked
190
+ wdid config get togglApiToken --show-secrets # …unless --show-secrets
191
+ wdid config list # all set fields, aligned, secrets masked
192
+ wdid config list --show-secrets # reveal secrets
193
+ wdid config path # absolute path to the config file
194
+ ```
195
+
196
+ Notes:
197
+
198
+ - **Validation runs at `set` time** — `wdid config set togglDayStartHour 99` fails immediately with the schema error, the file is never touched.
199
+ - **Secrets are masked** in `list` / `get` output (`tok_…wa9e0d` style) unless `--show-secrets` is set.
200
+ - **`defaultRepos` is not settable from the CLI** (it's an array). Same for any future array-shaped field. Use `vim $(wdid config path)` to edit those.
201
+ - **To remove a key**, edit the file directly — `unset` isn't included in this slice.
202
+
116
203
  ## Development
117
204
 
118
205
  This project uses [pnpm](https://pnpm.io) (see the `packageManager` field in `package.json`).
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import chalk2 from "chalk";
4
+ import chalk3 from "chalk";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/git.ts
@@ -102,30 +102,31 @@ async function assertGitRepo(cwd) {
102
102
  );
103
103
  }
104
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';
105
106
  async function getGitUserName(cwd) {
107
+ let stdout;
106
108
  try {
107
- const { stdout } = await execFileAsync("git", ["config", "user.name"], {
109
+ const result = await execFileAsync("git", ["config", "user.name"], {
108
110
  cwd
109
111
  });
110
- const name = stdout.trim();
111
- if (!name) {
112
- throw new Error("empty");
113
- }
114
- return name;
112
+ stdout = result.stdout;
115
113
  } catch (err) {
116
114
  if (err.code === "ENOENT") {
117
115
  throw new Error("git is not installed or not on PATH", { cause: err });
118
116
  }
119
- throw new Error(
120
- 'could not read git user.name \u2014 set it with `git config user.name "Your Name"` or pass --author',
121
- { cause: err }
122
- );
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);
123
122
  }
123
+ return name;
124
124
  }
125
125
  async function getCommits(opts) {
126
126
  await assertGitRepo(opts.cwd);
127
127
  const args = [
128
128
  "log",
129
+ "--exclude=refs/stash",
129
130
  "--all",
130
131
  "--author-date-order",
131
132
  "--regexp-ignore-case",
@@ -171,6 +172,7 @@ async function getCommits(opts) {
171
172
  parsed.map((p) => p.sha).filter((s) => s.length > 0)
172
173
  );
173
174
  return parsed.map((p) => ({
175
+ sha: p.sha,
174
176
  date: p.date,
175
177
  time: p.time,
176
178
  ticket: p.ticket,
@@ -251,6 +253,7 @@ function toJsonKey(label) {
251
253
  function renderJson(entries, ticketColumnLabel = "Ticket") {
252
254
  const key = toJsonKey(ticketColumnLabel);
253
255
  const transformed = entries.map((e) => ({
256
+ sha: e.sha,
254
257
  date: e.date,
255
258
  time: e.time,
256
259
  [key]: e.ticket,
@@ -271,6 +274,12 @@ import { homedir } from "os";
271
274
  import { readFile } from "fs/promises";
272
275
  import { join } from "path";
273
276
  import { cosmiconfig } from "cosmiconfig";
277
+ var TOGGL_DEFAULTS = {
278
+ durationMinutes: 30,
279
+ dayStartHour: 9,
280
+ oneEntryPerTicket: true,
281
+ ignoreSubjectPattern: "\\bmerge\\b"
282
+ };
274
283
  var DEFAULT_COLUMN_LABELS = {
275
284
  jira: "Ticket",
276
285
  github: "Issue",
@@ -371,6 +380,71 @@ function validateConfig(raw) {
371
380
  }
372
381
  cfg.ticketColumnLabel = obj.ticketColumnLabel;
373
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
+ }
374
448
  return cfg;
375
449
  }
376
450
  function globalConfigPath() {
@@ -401,6 +475,477 @@ async function loadConfig(cwd) {
401
475
  return {};
402
476
  }
403
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
+
404
949
  // src/index.ts
405
950
  function parseLimit(raw) {
406
951
  if (raw === void 0) {
@@ -490,8 +1035,195 @@ async function run(dateArg, options) {
490
1035
  const rendered = options.groupByDay ? renderTableGroupedByDay(display, ticketColumnLabel) : renderTable(display, ticketColumnLabel);
491
1036
  process.stdout.write(rendered + "\n");
492
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
+ }
1223
+ }
493
1224
  var program = new Command();
494
- program.name("wdid").description("What did I do? \u2014 summarize your git commits as a table").version("0.2.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(
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(
495
1227
  "--author <name>",
496
1228
  "override the git author (defaults to git config user.name, then defaultAuthor in config)"
497
1229
  ).option(
@@ -514,7 +1246,7 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
514
1246
  "group rows under a bold date heading per day (time-only in row)"
515
1247
  ).option("--json", "emit a JSON array of commit entries instead of the table").action(async (dateArg, options) => {
516
1248
  if (shouldDisableColor(options)) {
517
- chalk2.level = 0;
1249
+ chalk3.level = 0;
518
1250
  }
519
1251
  try {
520
1252
  await run(dateArg, options);
@@ -524,4 +1256,103 @@ program.name("wdid").description("What did I do? \u2014 summarize your git commi
524
1256
  process.exitCode = 1;
525
1257
  }
526
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
+ });
527
1358
  program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drazenbebic/wdid",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "What did I do? — summarize your git activity per day as a tidy table, grouped by JIRA ticket.",
5
5
  "keywords": [
6
6
  "git",