logwork-cli 1.1.1 → 1.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 (70) hide show
  1. package/README.md +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs';
3
3
  import { homedir, userInfo } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { atomicWriteFile } from './atomic-file.mjs';
6
+ import { resolveLaunchCommand, writeLauncher } from './launcher.mjs';
6
7
  import { helperHome } from './paths.mjs';
7
8
  import { normalizeReminderConfig } from './reminder-config.mjs';
8
9
 
@@ -12,18 +13,24 @@ export const WINDOWS_REMINDER_TASK_NAME = 'Logwork Reminder';
12
13
  // point at the old runtime, so installing or removing the reminder clears them.
13
14
  export const LEGACY_MACOS_REMINDER_LABEL = 'sg.vinova.logwork-helper.reminder';
14
15
  export const LEGACY_WINDOWS_REMINDER_TASK_NAME = 'Logwork Helper Reminder';
15
- // The scheduler runs `node <home>/bin/logwork.mjs reminder run --home <home>`.
16
+ // The scheduler runs the launch command from lib/launcher.mjs with
17
+ // `reminder run --home <home>`: on macOS the <home>/bin/logwork-launch script
18
+ // (it finds Node itself), on Windows a stable node.exe plus
19
+ // <home>\bin\logwork.mjs.
16
20
  export const REMINDER_RUNNER_RELATIVE_PATH = join('bin', 'logwork.mjs');
17
21
 
18
22
  export async function installReminderScheduler(config, {
19
23
  platform = process.platform,
20
24
  nodePath = process.execPath,
21
25
  home = helperHome(),
26
+ env = process.env,
27
+ launch = resolveLaunchCommand({ platform, home, execPath: nodePath, env }),
22
28
  userHome = homedir(),
23
29
  uid = userInfo().uid,
24
30
  run = runCommand,
25
31
  writeFile = atomicWriteFile,
26
- accessRunner = fs.access
32
+ accessRunner = fs.access,
33
+ writeLauncherFn = writeLauncher
27
34
  } = {}) {
28
35
  const normalized = normalizeReminderConfig({ ...config, enabled: true });
29
36
  const runnerPath = join(home, REMINDER_RUNNER_RELATIVE_PATH);
@@ -32,14 +39,17 @@ export async function installReminderScheduler(config, {
32
39
  } catch {
33
40
  throw new Error(`Reminder runtime is missing at ${runnerPath}. Run \`logwork setup --no-login\` and retry.`);
34
41
  }
42
+ if (launch.kind === 'launcher') {
43
+ // `reminder enable` can run before `logwork setup` wrote the launcher.
44
+ await writeLauncherFn({ home, nodePath, platform });
45
+ }
35
46
  await removeLegacyReminderScheduler({ platform, userHome, uid, run });
36
47
 
37
48
  if (platform === 'darwin') {
38
49
  const plistPath = macosReminderPlistPath(userHome);
39
50
  await writeFile(plistPath, buildMacosReminderPlist({
40
51
  config: normalized,
41
- nodePath,
42
- runnerPath,
52
+ launch,
43
53
  home
44
54
  }));
45
55
  const domain = `gui/${uid}`;
@@ -58,8 +68,7 @@ export async function installReminderScheduler(config, {
58
68
  if (platform === 'win32') {
59
69
  const result = await run('schtasks.exe', buildWindowsReminderCreateArgs({
60
70
  config: normalized,
61
- nodePath,
62
- runnerPath,
71
+ launch,
63
72
  home
64
73
  }));
65
74
  assertCommandSuccess(result, 'schtasks /Create');
@@ -119,7 +128,8 @@ export async function getReminderSchedulerStatus({
119
128
  userHome = homedir(),
120
129
  uid = userInfo().uid,
121
130
  run = runCommand,
122
- accessFile = fs.access
131
+ accessFile = fs.access,
132
+ readFile = fs.readFile
123
133
  } = {}) {
124
134
  if (platform === 'darwin') {
125
135
  const plistPath = macosReminderPlistPath(userHome);
@@ -130,7 +140,8 @@ export async function getReminderSchedulerStatus({
130
140
  installed: fileExists && result.code === 0,
131
141
  scheduler: 'launchd',
132
142
  identifier: MACOS_REMINDER_LABEL,
133
- path: plistPath
143
+ path: plistPath,
144
+ nodePath: fileExists ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
134
145
  };
135
146
  }
136
147
 
@@ -141,14 +152,63 @@ export async function getReminderSchedulerStatus({
141
152
  installed: result.code === 0,
142
153
  scheduler: 'Task Scheduler',
143
154
  identifier: WINDOWS_REMINDER_TASK_NAME,
144
- path: null
155
+ path: null,
156
+ nodePath: result.code === 0 ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
145
157
  };
146
158
  }
147
159
 
148
160
  throw unsupportedReminderPlatform(platform);
149
161
  }
150
162
 
151
- export function buildMacosReminderPlist({ config, nodePath, runnerPath, home }) {
163
+ // The command the installed schedule runs: the first ProgramArguments entry of
164
+ // the launchd plist (the launcher, or a raw node path from an older install),
165
+ // or the <Command> of the Windows task (node.exe). Returns null when no
166
+ // schedule is installed or it cannot be read. Read-only.
167
+ export async function readReminderSchedulerNodePath({
168
+ platform = process.platform,
169
+ userHome = homedir(),
170
+ run = runCommand,
171
+ readFile = fs.readFile
172
+ } = {}) {
173
+ try {
174
+ if (platform === 'darwin') {
175
+ const plist = await readFile(macosReminderPlistPath(userHome), 'utf8');
176
+ return parseMacosPlistNodePath(plist);
177
+ }
178
+ if (platform === 'win32') {
179
+ const result = await run('schtasks.exe', ['/Query', '/TN', WINDOWS_REMINDER_TASK_NAME, '/XML']);
180
+ return result.code === 0 ? parseWindowsTaskNodePath(result.stdout) : null;
181
+ }
182
+ } catch {
183
+ // Missing or unreadable schedule: nothing to compare.
184
+ }
185
+ return null;
186
+ }
187
+
188
+ export function parseMacosPlistNodePath(plist) {
189
+ const match = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(String(plist || ''));
190
+ return match ? unescapeXml(match[1]).trim() || null : null;
191
+ }
192
+
193
+ export function parseWindowsTaskNodePath(xml) {
194
+ // schtasks may write the XML as UTF-16; decoded as UTF-8 that leaves NULs.
195
+ const match = /<Command>([^<]*)<\/Command>/i.exec(String(xml || '').replaceAll('\0', ''));
196
+ if (!match) {
197
+ return null;
198
+ }
199
+ // schtasks keeps the quotes /TR put around a path with spaces.
200
+ return unescapeXml(match[1]).trim().replace(/^"(.*)"$/, '$1') || null;
201
+ }
202
+
203
+ // `launch` is { command, argsPrefix }. Older callers pass nodePath + runnerPath.
204
+ function scheduleProgram({ launch, nodePath, runnerPath, home }) {
205
+ const program = launch
206
+ ? [launch.command, ...(launch.argsPrefix || [])]
207
+ : [nodePath, runnerPath];
208
+ return [...program, 'reminder', 'run', '--home', home];
209
+ }
210
+
211
+ export function buildMacosReminderPlist({ config, launch, nodePath, runnerPath, home }) {
152
212
  const normalized = normalizeReminderConfig({ ...config, enabled: true });
153
213
  const [hour, minute] = normalized.time.split(':').map(Number);
154
214
  const calendarEntries = normalized.days.map((weekday) => ` <dict>
@@ -156,6 +216,9 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
156
216
  <key>Hour</key><integer>${hour}</integer>
157
217
  <key>Minute</key><integer>${minute}</integer>
158
218
  </dict>`).join('\n');
219
+ const programArguments = scheduleProgram({ launch, nodePath, runnerPath, home })
220
+ .map((part) => ` <string>${escapeXml(assertSafePath(part))}</string>`)
221
+ .join('\n');
159
222
 
160
223
  return `<?xml version="1.0" encoding="UTF-8"?>
161
224
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -164,12 +227,7 @@ export function buildMacosReminderPlist({ config, nodePath, runnerPath, home })
164
227
  <key>Label</key><string>${escapeXml(MACOS_REMINDER_LABEL)}</string>
165
228
  <key>ProgramArguments</key>
166
229
  <array>
167
- <string>${escapeXml(assertSafePath(nodePath))}</string>
168
- <string>${escapeXml(assertSafePath(runnerPath))}</string>
169
- <string>reminder</string>
170
- <string>run</string>
171
- <string>--home</string>
172
- <string>${escapeXml(assertSafePath(home))}</string>
230
+ ${programArguments}
173
231
  </array>
174
232
  <key>WorkingDirectory</key><string>${escapeXml(assertSafePath(home))}</string>
175
233
  <key>StartCalendarInterval</key>
@@ -183,9 +241,9 @@ ${calendarEntries}
183
241
  `;
184
242
  }
185
243
 
186
- export function buildWindowsReminderCreateArgs({ config, nodePath, runnerPath, home }) {
244
+ export function buildWindowsReminderCreateArgs({ config, launch, nodePath, runnerPath, home }) {
187
245
  const normalized = normalizeReminderConfig({ ...config, enabled: true });
188
- const action = [nodePath, runnerPath, 'reminder', 'run', '--home', home]
246
+ const action = scheduleProgram({ launch, nodePath, runnerPath, home })
189
247
  .map(quoteWindowsActionPart)
190
248
  .join(' ');
191
249
  const dayNames = normalized.days.map((day) => ({
@@ -296,6 +354,15 @@ function escapeXml(value) {
296
354
  .replaceAll("'", '&apos;');
297
355
  }
298
356
 
357
+ function unescapeXml(value) {
358
+ return String(value)
359
+ .replaceAll('&lt;', '<')
360
+ .replaceAll('&gt;', '>')
361
+ .replaceAll('&quot;', '"')
362
+ .replaceAll('&apos;', "'")
363
+ .replaceAll('&amp;', '&');
364
+ }
365
+
299
366
  function unsupportedReminderPlatform(platform) {
300
367
  return new Error(`Reminder scheduling is unsupported on ${platform}. Supported platforms are macOS and Windows.`);
301
368
  }
@@ -1,6 +1,9 @@
1
+ import { checkLaunchCommand, formatLaunchCheckIssue } from './launcher.mjs';
2
+ import { formatNodePathIssue, inspectScheduledCommand } from './node-runtime.mjs';
1
3
  import {
2
4
  loadReminderConfig,
3
5
  normalizeReminderConfig,
6
+ normalizeReminderRepos,
4
7
  saveReminderConfig
5
8
  } from './reminder-config.mjs';
6
9
  import {
@@ -11,9 +14,12 @@ import {
11
14
  import { loadReminderState } from './reminder-state.mjs';
12
15
  import { sendTestReminderNotification } from './reminder-workflow.mjs';
13
16
 
17
+ // repos: undefined keeps the configured commit repos; an array (even empty)
18
+ // replaces them.
14
19
  export async function enableLogworkReminder({
15
20
  time,
16
21
  target,
22
+ repos,
17
23
  confirm,
18
24
  loadConfig = loadReminderConfig,
19
25
  saveConfig = saveReminderConfig,
@@ -26,7 +32,8 @@ export async function enableLogworkReminder({
26
32
  ...current,
27
33
  enabled: true,
28
34
  ...(time ? { time } : {}),
29
- ...(target ? { target } : {})
35
+ ...(target ? { target } : {}),
36
+ ...(repos !== undefined ? { repos: normalizeReminderRepos(repos) } : {})
30
37
  });
31
38
 
32
39
  const scheduler = await installScheduler(config);
@@ -41,7 +48,12 @@ export async function enableLogworkReminder({
41
48
  status: 'enabled',
42
49
  config,
43
50
  scheduler,
44
- summary: `Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`
51
+ summary: [
52
+ `Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`,
53
+ config.repos.length
54
+ ? `Commit summary repos: ${config.repos.join(', ')}.`
55
+ : 'No reminder repos set; add --repo <path> to include today\'s commits (repos on project mappings are also used).'
56
+ ].join('\n')
45
57
  };
46
58
  }
47
59
 
@@ -69,7 +81,9 @@ export async function disableLogworkReminder({
69
81
  export async function getLogworkReminderStatus({
70
82
  loadConfig = loadReminderConfig,
71
83
  loadState = loadReminderState,
72
- schedulerStatus = getReminderSchedulerStatus
84
+ schedulerStatus = getReminderSchedulerStatus,
85
+ inspectNode = inspectScheduledCommand,
86
+ checkLaunch = checkLaunchCommand
73
87
  } = {}) {
74
88
  const [config, state, scheduler] = await Promise.all([
75
89
  loadConfig(),
@@ -82,9 +96,32 @@ export async function getLogworkReminderStatus({
82
96
  `Schedule: Monday-Friday at ${config.time}; target ${config.target}.`,
83
97
  `OS scheduler: ${scheduler.scheduler} ${scheduler.installed ? 'installed' : 'not installed'}.`
84
98
  ];
99
+ if (config.repos?.length) {
100
+ lines.push(`Commit summary repos: ${config.repos.join(', ')}.`);
101
+ }
85
102
  if (!consistent) {
86
103
  lines.push('Warning: reminder config and OS scheduler are out of sync; run reminder enable or disable again.');
87
104
  }
105
+ // The schedule stores an absolute command: the launcher on macOS (an older
106
+ // install: a raw node path), node.exe on Windows. nvm or Homebrew can remove
107
+ // a node while the schedule still looks installed.
108
+ const nodeCheck = scheduler.nodePath
109
+ ? await inspectNode(scheduler.nodePath).catch(() => ({ status: 'unknown', recordedPath: scheduler.nodePath }))
110
+ : null;
111
+ const nodeIssue = nodeCheck ? formatNodePathIssue('reminder', nodeCheck) : '';
112
+ if (nodeIssue) {
113
+ lines.push(`Warning: ${nodeIssue}`);
114
+ }
115
+ let launchCheck = null;
116
+ if (nodeCheck?.status === 'ok' && nodeCheck.kind === 'launcher') {
117
+ launchCheck = await checkLaunch().catch(() => null);
118
+ const launchIssue = formatLaunchCheckIssue(launchCheck);
119
+ if (launchIssue) {
120
+ lines.push(`Warning: ${launchIssue}`);
121
+ } else if (launchCheck?.node) {
122
+ lines.push(`Launcher: runs Node ${launchCheck.node}.`);
123
+ }
124
+ }
88
125
  if (state?.lastRunAt) {
89
126
  lines.push(`Last run: ${state.lastRunAt}, status ${state.lastStatus || 'unknown'}.`);
90
127
  }
@@ -94,6 +131,8 @@ export async function getLogworkReminderStatus({
94
131
  scheduler,
95
132
  state,
96
133
  consistent,
134
+ ...(nodeCheck ? { nodeCheck } : {}),
135
+ ...(launchCheck ? { launchCheck } : {}),
97
136
  summary: lines.join('\n')
98
137
  };
99
138
  }
@@ -1,26 +1,37 @@
1
1
  import { isAuthRequiredError } from './auth-errors.mjs';
2
2
  import { redactText } from './auth-redaction.mjs';
3
+ import { draftLogworkFromCommits } from './commit-workflow.mjs';
3
4
  import { withFileLock } from './file-lock.mjs';
4
5
  import { searchJiraIssues } from './jira-api.mjs';
5
6
  import {
6
7
  getStoredJiraSession,
7
8
  isJiraAuthRequiredError
8
9
  } from './jira-auth.mjs';
10
+ import { loadLocalConfig } from './logwork-config.mjs';
9
11
  import { sendOsNotification } from './os-notification.mjs';
10
- import { reminderStatePath } from './paths.mjs';
12
+ import { helperHome, reminderStatePath } from './paths.mjs';
11
13
  import { queryLogwork } from './query-workflow.mjs';
12
- import { loadReminderConfig } from './reminder-config.mjs';
14
+ import { loadReminderConfig, normalizeReminderRepos } from './reminder-config.mjs';
13
15
  import {
14
16
  loadReminderState,
15
17
  saveReminderState
16
18
  } from './reminder-state.mjs';
17
19
  import { todayLocalDateISO } from './util.mjs';
18
20
 
21
+ const REMINDER_HEADER = 'Logwork hôm nay chưa hoàn tất';
22
+ const DEFAULT_ACTION_LINE = 'Mở Terminal và chạy: logwork';
23
+ const COMMITS_ACTION_LINE = 'Mở Terminal và chạy: logwork commits today (bản nháp từ commit)';
24
+ const COMMIT_SUMMARY_TIMEOUT_MS = 20_000;
25
+
26
+ // repos: git repos whose commits back up a missing-RO reminder. Without repos,
27
+ // or when reading them fails, the message is the plain RO/Jira one.
19
28
  export async function checkLogworkReminder({
20
29
  target = 'both',
21
30
  date = todayLocalDateISO(),
31
+ repos = [],
22
32
  queryRo = queryResourceOptimiserDay,
23
- queryJira = queryJiraWorklogDay
33
+ queryJira = queryJiraWorklogDay,
34
+ summarizeCommits = summarizeTodayCommits
24
35
  } = {}) {
25
36
  const checks = {};
26
37
  const jobs = [];
@@ -42,15 +53,16 @@ export async function checkLogworkReminder({
42
53
 
43
54
  const problemLines = buildProblemLines(checks);
44
55
  const notify = problemLines.length > 0;
45
- const notification = notify ? {
46
- title: 'Logwork',
47
- message: [
48
- 'Logwork hôm nay chưa hoàn tất',
49
- ...problemLines,
50
- '',
51
- 'Mở Terminal và chạy: logwork'
52
- ].join('\n')
53
- } : null;
56
+ let commits = null;
57
+ if (notify && checks.ro?.status === 'incomplete' && repos?.length) {
58
+ // Commit evidence is a bonus: any failure keeps the plain message.
59
+ try {
60
+ commits = await summarizeCommits({ date, repos });
61
+ } catch (error) {
62
+ commits = { status: 'unavailable', error: redactText(error.message || String(error)).slice(0, 300) };
63
+ }
64
+ }
65
+ const notification = notify ? buildReminderNotification({ problemLines, commits }) : null;
54
66
 
55
67
  return {
56
68
  date,
@@ -58,6 +70,7 @@ export async function checkLogworkReminder({
58
70
  status: notify ? 'notification_required' : 'complete',
59
71
  notify,
60
72
  checks,
73
+ ...(commits ? { commits } : {}),
61
74
  notification,
62
75
  summary: notify
63
76
  ? `Logwork reminder requires attention: ${problemLines.join(' | ')}`
@@ -117,8 +130,81 @@ export async function queryJiraWorklogDay({
117
130
  };
118
131
  }
119
132
 
133
+ // Counts today's commits in the given repos with the read-only commit draft
134
+ // (no RO query: the reminder already has the RO totals). Throws when no repo
135
+ // could be read, so the caller falls back to the plain message.
136
+ export async function summarizeTodayCommits({
137
+ date,
138
+ repos,
139
+ draft = draftLogworkFromCommits,
140
+ cwd = helperHome(),
141
+ timeoutMs = COMMIT_SUMMARY_TIMEOUT_MS
142
+ } = {}) {
143
+ const result = await withTimeout(
144
+ draft({ from: date, repos, useBookings: false, cwd }),
145
+ timeoutMs,
146
+ 'Reading commits timed out.'
147
+ );
148
+ const repoResults = Array.isArray(result?.repos) ? result.repos : [];
149
+ const readable = repoResults.filter((repo) => !repo.error);
150
+ if (!readable.length) {
151
+ throw new Error('None of the reminder repos could be read.');
152
+ }
153
+ return {
154
+ status: 'ok',
155
+ commitCount: Number(result.commitCount || 0),
156
+ repoCount: readable.filter((repo) => Number(repo.commitCount || 0) > 0).length,
157
+ readRepoCount: readable.length,
158
+ failedRepoCount: repoResults.length - readable.length
159
+ };
160
+ }
161
+
162
+ // Reminder repos plus any `repos` listed on project mappings in the local
163
+ // config. Mapping repos are optional and read defensively.
164
+ export async function collectReminderRepos({
165
+ config,
166
+ loadMappings = loadLocalConfig,
167
+ cwd = helperHome()
168
+ } = {}) {
169
+ const repos = [...(Array.isArray(config?.repos) ? config.repos : [])];
170
+ let mappings = [];
171
+ try {
172
+ mappings = (await loadMappings(cwd))?.projectMappings || [];
173
+ } catch {
174
+ mappings = [];
175
+ }
176
+ for (const mapping of Array.isArray(mappings) ? mappings : []) {
177
+ if (!Array.isArray(mapping?.repos)) {
178
+ continue;
179
+ }
180
+ for (const repo of mapping.repos) {
181
+ try {
182
+ repos.push(...normalizeReminderRepos([repo], { cwd }));
183
+ } catch {
184
+ // Skip a malformed mapping path.
185
+ }
186
+ }
187
+ }
188
+ return [...new Set(repos)];
189
+ }
190
+
191
+ export function buildReminderNotification({ header = REMINDER_HEADER, problemLines = [], commits = null } = {}) {
192
+ const hasCommits = commits?.status === 'ok' && commits.commitCount > 0;
193
+ return {
194
+ title: 'Logwork',
195
+ message: [
196
+ header,
197
+ ...problemLines,
198
+ ...(hasCommits ? [`Hôm nay: ${commits.commitCount} commit trong ${commits.repoCount} repo`] : []),
199
+ '',
200
+ hasCommits ? COMMITS_ACTION_LINE : DEFAULT_ACTION_LINE
201
+ ].join('\n')
202
+ };
203
+ }
204
+
120
205
  export async function runScheduledReminder({
121
206
  loadConfig = loadReminderConfig,
207
+ collectRepos = collectReminderRepos,
122
208
  loadState = loadReminderState,
123
209
  saveState = saveReminderState,
124
210
  check = checkLogworkReminder,
@@ -165,9 +251,11 @@ export async function runScheduledReminder({
165
251
  return result;
166
252
  }
167
253
 
254
+ const repos = await collectRepos({ config }).catch(() => []);
168
255
  const checkResult = await check({
169
256
  target: config.target,
170
- date
257
+ date,
258
+ repos
171
259
  });
172
260
  if (!checkResult.notify) {
173
261
  const result = {
@@ -219,15 +307,13 @@ export async function sendTestReminderNotification({
219
307
  if (!lines.length) {
220
308
  throw new Error('Reminder target must be ro, jira, or both.');
221
309
  }
222
- const notification = {
223
- title: 'Logwork',
224
- message: [
225
- 'Đây là notification thử nghiệm',
226
- ...lines,
227
- '',
228
- 'Mở Terminal và chạy: logwork'
229
- ].join('\n')
230
- };
310
+ // Sample data in the real message format, including the commit line a
311
+ // reminder with repos shows when RO hours are missing.
312
+ const notification = buildReminderNotification({
313
+ header: 'Đây là notification thử nghiệm',
314
+ problemLines: lines,
315
+ commits: target === 'jira' ? null : { status: 'ok', commitCount: 5, repoCount: 2 }
316
+ });
231
317
  const result = await notify(notification);
232
318
  return {
233
319
  status: 'sent',
@@ -292,6 +378,16 @@ function formatHours(value) {
292
378
  return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
293
379
  }
294
380
 
381
+ function withTimeout(promise, timeoutMs, message) {
382
+ let timer;
383
+ return Promise.race([
384
+ promise,
385
+ new Promise((_, reject) => {
386
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
387
+ })
388
+ ]).finally(() => clearTimeout(timer));
389
+ }
390
+
295
391
  function assertDateOnly(value) {
296
392
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) {
297
393
  throw new Error('Reminder date must use YYYY-MM-DD format.');