logwork-cli 1.1.0 → 1.2.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 +35 -9
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -4
@@ -100,7 +100,7 @@ export async function applyRoLogworkEdit({
100
100
  const reader = fetchLogwork || createF03LogworkReader();
101
101
  const creator = submitCreate || createEntryWriter();
102
102
  const deleter = submitDelete || createDeleteWriter();
103
- const dayReader = fetchDayEntries || createDayEntriesReader();
103
+ const dayReader = fetchDayEntries || (injected ? null : createDayEntriesReader());
104
104
  const ledgerWriter = recordLedger === undefined
105
105
  ? (injected ? null : recordApplyResult)
106
106
  : recordLedger;
@@ -121,15 +121,27 @@ export async function applyRoLogworkEdit({
121
121
  needsProjectId: preview.updated.needsProjectId
122
122
  };
123
123
 
124
+ // Entry ids already on the target day, so a replacement found later by its
125
+ // values is never confused with an older identical entry.
126
+ const existingIds = await readDayEntryIds(dayReader, preview.updated.date);
127
+
124
128
  let createResult;
125
129
  try {
126
130
  createResult = await creator(createRequest);
127
131
  } catch (error) {
128
- error.message = `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`;
129
- error.applyLedger = await recordEditLedger({
130
- writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
131
- });
132
- throw error;
132
+ // Writes are not retried, so a timeout is ambiguous: RO may have saved the
133
+ // entry. Look for it before reporting that nothing changed.
134
+ const savedId = await findCreatedEntryId({ dayReader, preview, existingIds });
135
+ if (savedId === null) {
136
+ error.message = existingIds
137
+ ? `Could not create the replacement RO entry; the original entry ${preview.logworkId} is unchanged. ${error.message}`
138
+ : `Could not create the replacement RO entry (${error.message}). The original entry ${preview.logworkId} is unchanged, but check ${preview.updated.date} (get_logwork or \`logwork query\`) in case RO saved a copy.`;
139
+ error.applyLedger = await recordEditLedger({
140
+ writer: ledgerWriter, preview, status: 'failed', entryStatus: 'failed', error: error.message, cwd
141
+ });
142
+ throw error;
143
+ }
144
+ createResult = { entry: { id: savedId } };
133
145
  }
134
146
 
135
147
  if (createResult?.dryRun) {
@@ -156,7 +168,7 @@ export async function applyRoLogworkEdit({
156
168
 
157
169
  let newLogworkId = createResult?.entry?.id ?? null;
158
170
  if (newLogworkId === null || newLogworkId === undefined) {
159
- newLogworkId = await findCreatedEntryId({ dayReader, preview });
171
+ newLogworkId = await findCreatedEntryId({ dayReader, preview, existingIds });
160
172
  }
161
173
  if (newLogworkId === null || newLogworkId === undefined) {
162
174
  const error = new Error(
@@ -172,6 +184,25 @@ export async function applyRoLogworkEdit({
172
184
  try {
173
185
  await deleter(preview.logworkId);
174
186
  } catch (error) {
187
+ // A failed DELETE may still have deleted the original (timeout). Roll the
188
+ // replacement back only when the original is confirmed to still exist;
189
+ // otherwise rolling back could leave neither entry.
190
+ const originalState = await originalEntryState({ dayReader, preview });
191
+ if (originalState === 'gone') {
192
+ return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
193
+ }
194
+ if (originalState === 'unknown') {
195
+ const message = `Deleting the original RO entry ${preview.logworkId} failed (${error.message}) and its state could not be read. ` +
196
+ `The replacement entry ${newLogworkId} was kept. Check ${preview.original?.date || preview.updated.date} (get_logwork or \`logwork query\`); if both entries exist, delete ${preview.logworkId}.`;
197
+ const wrapped = new Error(message);
198
+ wrapped.cause = error;
199
+ wrapped.newLogworkId = newLogworkId;
200
+ wrapped.rolledBack = false;
201
+ wrapped.applyLedger = await recordEditLedger({
202
+ writer: ledgerWriter, preview, status: 'partial', entryStatus: 'failed', error: message, newLogworkId, cwd
203
+ });
204
+ throw wrapped;
205
+ }
175
206
  const rollback = await rollbackCreatedEntry({ deleter, newLogworkId });
176
207
  const message = rollback.ok
177
208
  ? `Could not delete the original RO entry ${preview.logworkId} (${error.message}). The replacement entry ${newLogworkId} was rolled back; nothing changed.`
@@ -193,6 +224,10 @@ export async function applyRoLogworkEdit({
193
224
  throw wrapped;
194
225
  }
195
226
 
227
+ return finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd });
228
+ }
229
+
230
+ async function finishReplace({ reader, preview, newLogworkId, ledgerWriter, cwd }) {
196
231
  let verified;
197
232
  try {
198
233
  verified = sanitizeLogwork(await reader(newLogworkId));
@@ -483,11 +518,34 @@ function changeFingerprint(change) {
483
518
  };
484
519
  }
485
520
 
486
- async function findCreatedEntryId({ dayReader, preview }) {
521
+ async function readDayEntryIds(dayReader, date) {
522
+ if (!dayReader) {
523
+ return null;
524
+ }
525
+ try {
526
+ return new Set((await dayReader(date) || []).map((entry) => String(entry.id)));
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ async function originalEntryState({ dayReader, preview }) {
533
+ const ids = await readDayEntryIds(dayReader, preview.original?.date || preview.updated.date);
534
+ if (!ids) {
535
+ return 'unknown';
536
+ }
537
+ return ids.has(String(preview.logworkId)) ? 'present' : 'gone';
538
+ }
539
+
540
+ async function findCreatedEntryId({ dayReader, preview, existingIds = null }) {
541
+ if (!dayReader) {
542
+ return null;
543
+ }
487
544
  try {
488
545
  const entries = await dayReader(preview.updated.date);
489
546
  const candidates = (entries || []).filter((entry) => (
490
547
  String(entry.id) !== String(preview.logworkId) &&
548
+ (!existingIds || !existingIds.has(String(entry.id))) &&
491
549
  String(entry.worklogTaskId) === String(preview.updated.worklogTaskId) &&
492
550
  Number(entry.hours) === Number(preview.updated.hours) &&
493
551
  (entry.description || '') === (preview.updated.description || '') &&
@@ -1,24 +1,34 @@
1
1
  import { ApiError, getLogtimeById, getLogworkDay, getProjects } from './api.mjs';
2
2
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
3
 
4
+ const DEFAULT_READS = { getLogtimeById, getLogworkDay, getProjects };
5
+
4
6
  // Reads one existing RO entry with the fields edit, resubmit and delete need. The legacy
5
7
  // GET /member-logtime/:id gives the date, owner and Jira link; /logwork/day
6
8
  // gives the F03 fields (status, worklog task, type of work, description); the
7
- // project-member list maps project_member_id to project_id.
8
- export function createF03LogworkReader() {
9
- const session = createResourceOptimiserSession();
9
+ // project-member list maps project_member_id to project_id. Tests inject the
10
+ // session and the RO reads; callers use the defaults.
11
+ export function createF03LogworkReader({
12
+ session = createResourceOptimiserSession(),
13
+ reads = DEFAULT_READS
14
+ } = {}) {
10
15
  let projectsPromise = null;
11
16
  return async (logworkId) => withRoSessionRetry(session, async ({ token, userId }) => {
12
- const legacy = await getLogtimeById(token, logworkId);
17
+ const legacy = await reads.getLogtimeById(token, logworkId);
13
18
  if (!legacy.date) {
14
19
  throw new Error(`Resource Optimiser did not return a date for logwork ${logworkId}.`);
15
20
  }
16
- const day = await getLogworkDay(token, { date: legacy.date });
21
+ const day = await reads.getLogworkDay(token, { date: legacy.date });
17
22
  const f03 = (day.entries || []).find((entry) => String(entry.id) === String(legacy.id));
18
23
  if (!f03) {
19
24
  throw new Error(`RO logwork ${logworkId} was not found on ${legacy.date}; it may have been deleted.`);
20
25
  }
21
- projectsPromise ||= getProjects(token, userId);
26
+ // Cache only a successful load: a cached rejection (say a 401) would make
27
+ // the retry after a forced login fail the same way.
28
+ projectsPromise ||= reads.getProjects(token, userId).catch((error) => {
29
+ projectsPromise = null;
30
+ throw error;
31
+ });
22
32
  const projects = await projectsPromise;
23
33
  const projectMemberId = f03.projectMemberId ?? legacy.projectMemberId;
24
34
  const project = projects.find((item) => String(item.projectMemberId) === String(projectMemberId));
@@ -54,3 +64,36 @@ function withoutEmpty(record = {}) {
54
64
  value !== undefined && value !== null && value !== ''
55
65
  )));
56
66
  }
67
+
68
+ // Guards shared by delete and resubmit (edit has its own, stricter set): the
69
+ // entry must be the user's own, not synced from Jira, and on an unlocked day.
70
+ export function assertOwnUnlockedRoEntry(logwork, action) {
71
+ if (
72
+ logwork?.currentUserId !== undefined &&
73
+ logwork?.createdBy !== undefined &&
74
+ String(logwork.currentUserId) !== String(logwork.createdBy)
75
+ ) {
76
+ throw new Error(`Cannot ${action} another user's RO logwork.`);
77
+ }
78
+ const jiraId = logwork?.jiraLogworkId;
79
+ if ((jiraId !== null && jiraId !== undefined && String(jiraId).trim() !== '') || String(logwork?.source || '').toLowerCase() === 'jira') {
80
+ throw new Error(`This RO logwork was created by Jira; ${action} it in Jira so both systems stay in step.`);
81
+ }
82
+ if (logwork?.dayLocked) {
83
+ throw new Error(`Logwork for ${logwork.date || 'this day'} is locked on Resource Optimiser; cannot ${action} it.`);
84
+ }
85
+ }
86
+
87
+ // What a preview was approved against; apply refuses if any of it changed.
88
+ export function createRoEntryRevisionFingerprint(logwork) {
89
+ return JSON.stringify({
90
+ id: logwork.id,
91
+ date: logwork.date ?? null,
92
+ hours: logwork.hours,
93
+ status: logwork.status,
94
+ taskName: logwork.taskName,
95
+ worklogTaskId: logwork.worklogTaskId ?? null,
96
+ typeOfWork: logwork.typeOfWork ?? null,
97
+ description: logwork.description ?? null
98
+ });
99
+ }
@@ -1,6 +1,7 @@
1
- import { ApiError, resubmitLogworkEntry } from './api.mjs';
1
+ import { ApiError } from './ro-api-http.mjs';
2
+ import { resubmitLogworkEntry } from './ro-api-entries.mjs';
2
3
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
- import { createF03LogworkReader } from './ro-entry-reader.mjs';
4
+ import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
5
 
5
6
  export async function previewRoLogworkResubmit({
6
7
  logworkId,
@@ -15,31 +16,40 @@ export async function previewRoLogworkResubmit({
15
16
  if (String(current.status || '').toLowerCase() !== 'rejected') {
16
17
  throw new Error('Only rejected RO logwork entries can be resubmitted through this flow.');
17
18
  }
19
+ assertOwnUnlockedRoEntry(current, 'resubmit');
18
20
 
19
21
  const effortHours = hours === undefined ? current.hours : Number(hours);
20
- if (!Number.isFinite(effortHours) || effortHours <= 0) {
21
- throw new Error('hours must be a positive number.');
22
+ if (!Number.isFinite(effortHours) || effortHours <= 0 || effortHours > 24) {
23
+ throw new Error('hours must be a positive number up to 24.');
22
24
  }
23
25
 
24
- const previewId = `resubmit_${current.id}_${effortHours}`;
26
+ const updated = {
27
+ hours: effortHours,
28
+ typeOfWork: typeOfWork || current.typeOfWork || 'other',
29
+ noteToPm: noteToPm ?? current.noteToPm ?? '',
30
+ description: description ?? current.description ?? ''
31
+ };
32
+ const originalFingerprint = createLogworkRevisionFingerprint(current);
33
+ // Every field that will be sent is part of the id, so two different
34
+ // previews of the same entry never share (and overwrite) one cache slot.
35
+ const previewId = `resubmit_${current.id}_${hash(JSON.stringify({ updated, originalFingerprint }))}`;
25
36
  const preview = {
26
37
  previewId,
27
38
  status: 'ready',
28
39
  logworkId: current.id,
29
40
  original: current,
30
- updated: {
31
- hours: effortHours,
32
- typeOfWork: typeOfWork || current.typeOfWork || 'other',
33
- noteToPm: noteToPm ?? current.noteToPm ?? '',
34
- description: description ?? current.description ?? ''
35
- },
36
- originalFingerprint: createLogworkRevisionFingerprint(current)
41
+ updated,
42
+ originalFingerprint
37
43
  };
44
+ const changeLine = (label, from, to) => (String(from ?? '') === String(to ?? '') ? `- ${label}: ${to || '(empty)'}` : `- ${label}: ${from || '(empty)'} -> ${to || '(empty)'}`);
38
45
  preview.summary = [
39
46
  'RO logwork resubmit preview:',
40
- `- Entry: ${current.id}`,
41
- `- Hours: ${current.hours} -> ${effortHours}`,
42
- `- Status: rejected -> approved (after resubmit)`
47
+ `- Entry: ${current.id} (${current.date || 'unknown date'}, ${current.taskName || 'unknown task'})`,
48
+ changeLine('Hours', current.hours, effortHours),
49
+ changeLine('Type of work', current.typeOfWork, updated.typeOfWork),
50
+ changeLine('Description', current.description, updated.description),
51
+ `- Note to PM: ${updated.noteToPm || '(none)'}`,
52
+ '- Status: rejected -> resubmitted'
43
53
  ].join('\n');
44
54
  return preview;
45
55
  }
@@ -125,10 +135,14 @@ function assertIdentifier(value, label) {
125
135
  }
126
136
 
127
137
  function createLogworkRevisionFingerprint(logwork) {
128
- return JSON.stringify({
129
- id: logwork.id,
130
- hours: logwork.hours,
131
- status: logwork.status,
132
- taskName: logwork.taskName
133
- });
138
+ return createRoEntryRevisionFingerprint(logwork);
139
+ }
140
+
141
+ function hash(value) {
142
+ let hashValue = 2166136261;
143
+ for (let index = 0; index < value.length; index += 1) {
144
+ hashValue ^= value.charCodeAt(index);
145
+ hashValue = Math.imul(hashValue, 16777619);
146
+ }
147
+ return (hashValue >>> 0).toString(36);
134
148
  }
@@ -1,12 +1,15 @@
1
- import { isCancel, password, select, text } from '@clack/prompts';
1
+ import * as clackPrompts from '@clack/prompts';
2
2
  import { createAuthRequiredError } from './auth-errors.mjs';
3
3
  import { createCredentialEmailStorage } from './credential-store.mjs';
4
4
 
5
5
  export function createTerminalCredentialProvider({
6
6
  emailStorage = createCredentialEmailStorage(),
7
7
  stdin = process.stdin,
8
- stdout = process.stdout
8
+ stdout = process.stdout,
9
+ prompts = clackPrompts
9
10
  } = {}) {
11
+ const { password, select, text } = prompts;
12
+ const handleCancel = (value) => handleCancelWith(prompts, value);
10
13
  return {
11
14
  async requestCredentials() {
12
15
  ensureInteractive(stdin, stdout);
@@ -111,8 +114,8 @@ function ensureInteractive(stdin, stdout) {
111
114
  }
112
115
  }
113
116
 
114
- function handleCancel(value) {
115
- if (isCancel(value)) {
117
+ function handleCancelWith(prompts, value) {
118
+ if (prompts.isCancel(value)) {
116
119
  throw new Error('User cancelled Resource Optimiser authentication.');
117
120
  }
118
121
 
package/lib/ui.mjs CHANGED
@@ -1,8 +1,22 @@
1
- import { cancel, confirm, isCancel, note, outro, select, text } from '@clack/prompts';
1
+ import * as clackPrompts from '@clack/prompts';
2
2
  import { CONFIG } from './config.mjs';
3
3
 
4
- export async function chooseProject(projects, daySummaries) {
5
- const options = projects.map((project, index) => {
4
+ // Every prompt function takes an optional `{ prompts }` (default: @clack/prompts)
5
+ // so tests can answer prompts without a terminal.
6
+
7
+ export async function chooseProject(projects, daySummaries, { prompts = clackPrompts } = {}) {
8
+ const options = projectSelectOptions(projects, daySummaries);
9
+
10
+ const projectMemberId = handleCancel(await prompts.select({
11
+ message: 'Chọn project cần log work:',
12
+ options
13
+ }), prompts);
14
+
15
+ return projects.find((project) => project.projectMemberId === projectMemberId);
16
+ }
17
+
18
+ export function projectSelectOptions(projects, daySummaries) {
19
+ return projects.map((project, index) => {
6
20
  const summary = daySummaries[index];
7
21
  const logged = summary?.ok ? `${formatHours(summary.totalHours)}h` : `${formatHours(project.loggedHours)}h`;
8
22
  const booked = `${formatHours(project.assignedHours)}h`;
@@ -12,88 +26,87 @@ export async function chooseProject(projects, daySummaries) {
12
26
  label: `${project.projectName} - ${booked}/day (${project.workloadPercent}% assigned) · ${logged} logged today`
13
27
  };
14
28
  });
15
-
16
- const projectMemberId = handleCancel(await select({
17
- message: 'Chọn project cần log work:',
18
- options
19
- }));
20
-
21
- return projects.find((project) => project.projectMemberId === projectMemberId);
22
29
  }
23
30
 
24
- export async function chooseTask(tasks) {
25
- const taskId = handleCancel(await select({
31
+ export async function chooseTask(tasks, { prompts = clackPrompts } = {}) {
32
+ const taskId = handleCancel(await prompts.select({
26
33
  message: 'Chọn loại công việc:',
27
- options: tasks.map((task) => ({
28
- value: task.taskId,
29
- label: `${task.name} (${task.typeOfWork})`,
30
- hint: task.description
31
- }))
32
- }));
34
+ options: taskSelectOptions(tasks)
35
+ }), prompts);
33
36
 
34
37
  return tasks.find((task) => task.taskId === taskId);
35
38
  }
36
39
 
37
- export async function chooseMessageSource(defaultMessage) {
38
- const source = handleCancel(await select({
40
+ export function taskSelectOptions(tasks) {
41
+ return tasks.map((task) => ({
42
+ value: task.taskId,
43
+ label: `${task.name} (${task.typeOfWork})`,
44
+ hint: task.description
45
+ }));
46
+ }
47
+
48
+ export async function chooseMessageSource(defaultMessage, { prompts = clackPrompts } = {}) {
49
+ const source = handleCancel(await prompts.select({
39
50
  message: 'Task message:',
40
51
  options: [
41
52
  { value: 'git', label: 'Use git commit message' },
42
53
  { value: 'custom', label: 'Custom' }
43
54
  ],
44
55
  initialValue: 'git'
45
- }));
56
+ }), prompts);
46
57
 
47
58
  const initialValue = source === 'git' ? defaultMessage : '';
48
- const value = handleCancel(await text({
59
+ const value = handleCancel(await prompts.text({
49
60
  message: 'Nội dung công việc:',
50
61
  placeholder: 'Fix login flow',
51
62
  initialValue,
52
- validate(input) {
53
- if (!input || input.trim() === '') {
54
- return 'Vui lòng nhập nội dung công việc.';
55
- }
56
- return undefined;
57
- }
58
- }));
63
+ validate: validateTaskMessage
64
+ }), prompts);
59
65
 
60
66
  return value.trim();
61
67
  }
62
68
 
63
- export async function askHours(defaultHours = CONFIG.defaultHours) {
64
- const value = handleCancel(await text({
69
+ export function validateTaskMessage(input) {
70
+ if (!input || input.trim() === '') {
71
+ return 'Vui lòng nhập nội dung công việc.';
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ export async function askHours(defaultHours = CONFIG.defaultHours, { prompts = clackPrompts } = {}) {
77
+ const value = handleCancel(await prompts.text({
65
78
  message: 'Số giờ log:',
66
79
  placeholder: String(defaultHours),
67
80
  initialValue: String(defaultHours),
68
81
  validate(input) {
69
- const numeric = Number(input);
70
-
71
- if (!Number.isFinite(numeric)) {
72
- return 'Số giờ phải là số.';
73
- }
74
- if (numeric <= 0) {
75
- return 'Số giờ phải lớn hơn 0.';
76
- }
77
- if (!Number.isInteger(numeric / CONFIG.hourStep)) {
78
- return `Số giờ phải chia hết cho ${CONFIG.hourStep}.`;
79
- }
80
-
81
- return undefined;
82
+ return validateHours(input);
82
83
  }
83
- }));
84
+ }), prompts);
84
85
 
85
86
  return Number(value);
86
87
  }
87
88
 
88
- export async function confirmSubmit(payloadPreview) {
89
- const task = truncate(payloadPreview.taskName, 80);
90
- const description = payloadPreview.description
91
- ? ` | "${truncate(payloadPreview.description, 120)}"`
92
- : '';
93
- const ok = handleCancel(await confirm({
94
- message: `Log ${payloadPreview.hours}h to ${payloadPreview.projectName} with task "${task}"${description}?`,
89
+ export function validateHours(input, hourStep = CONFIG.hourStep) {
90
+ const numeric = Number(input);
91
+
92
+ if (!Number.isFinite(numeric)) {
93
+ return 'Số giờ phải là số.';
94
+ }
95
+ if (numeric <= 0) {
96
+ return 'Số giờ phải lớn hơn 0.';
97
+ }
98
+ if (!Number.isInteger(numeric / hourStep)) {
99
+ return `Số giờ phải chia hết cho ${hourStep}.`;
100
+ }
101
+
102
+ return undefined;
103
+ }
104
+
105
+ export async function confirmSubmit(payloadPreview, { prompts = clackPrompts } = {}) {
106
+ const ok = handleCancel(await prompts.confirm({
107
+ message: formatSubmitConfirmation(payloadPreview),
95
108
  initialValue: true
96
- }));
109
+ }), prompts);
97
110
 
98
111
  if (!ok) {
99
112
  throw new Error('User declined final submit.');
@@ -102,16 +115,24 @@ export async function confirmSubmit(payloadPreview) {
102
115
  return true;
103
116
  }
104
117
 
105
- export function showSuccess(message) {
106
- outro(message);
118
+ export function formatSubmitConfirmation(payloadPreview) {
119
+ const task = truncate(payloadPreview.taskName, 80);
120
+ const description = payloadPreview.description
121
+ ? ` | "${truncate(payloadPreview.description, 120)}"`
122
+ : '';
123
+ return `Log ${payloadPreview.hours}h to ${payloadPreview.projectName} with task "${task}"${description}?`;
124
+ }
125
+
126
+ export function showSuccess(message, { prompts = clackPrompts } = {}) {
127
+ prompts.outro(message);
107
128
  }
108
129
 
109
- export function showError(error) {
110
- cancel(error?.message || String(error));
130
+ export function showError(error, { prompts = clackPrompts } = {}) {
131
+ prompts.cancel(error?.message || String(error));
111
132
  }
112
133
 
113
- export function handleCancel(value) {
114
- if (isCancel(value)) {
134
+ export function handleCancel(value, prompts = clackPrompts) {
135
+ if (prompts.isCancel(value)) {
115
136
  throw new Error('Cancelled.');
116
137
  }
117
138
 
@@ -5,6 +5,7 @@ import { atomicWriteFile } from './atomic-file.mjs';
5
5
  import { redactText } from './auth-redaction.mjs';
6
6
  import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
7
7
  import { readPackageInfo } from './package-info.mjs';
8
+ import { npmInvocation } from './npm-command.mjs';
8
9
  import { helperHome, updateStatePath } from './paths.mjs';
9
10
 
10
11
  export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -187,7 +188,7 @@ export async function installUpdate({
187
188
  };
188
189
  }
189
190
 
190
- const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
191
+ const executable = 'npm';
191
192
  const args = [
192
193
  'exec',
193
194
  '--yes',
@@ -311,7 +312,9 @@ function requireSemver(value, label) {
311
312
 
312
313
  function runUpdateCommand(executable, args, { stdio = 'pipe' } = {}) {
313
314
  return new Promise((resolveValue, reject) => {
314
- const child = spawn(executable, args, {
315
+ const invocation = executable === 'npm' ? npmInvocation(args) : { command: executable, args, shell: false };
316
+ const child = spawn(invocation.command, invocation.args, {
317
+ shell: invocation.shell,
315
318
  cwd: helperHome(),
316
319
  env: process.env,
317
320
  stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe']
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logwork-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Log work to Resource Optimiser and Jira from the terminal, an MCP server for AI IDEs, and a terminal UI.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -58,9 +58,11 @@
58
58
  "audit:prod": "npm audit --omit=dev --audit-level=moderate",
59
59
  "pack:check": "npm pack --dry-run",
60
60
  "release:doctor": "node bin/logwork.mjs release doctor --full",
61
- "release:check": "npm test && npm run audit:prod && npm run pack:check && git diff --check",
62
- "test:windows-credential": "node --test --test-name-pattern=\"Windows Credential Manager stores, reads, and deletes a test credential\" test/credential-store.test.mjs",
63
- "test": "node --test"
61
+ "lint": "eslint .",
62
+ "release:check": "npm run lint && npm test && npm run audit:prod && npm run pack:check && git diff --check",
63
+ "test:windows-credential": "node --import ./test/helpers/setup.mjs --test --test-name-pattern=\"Windows Credential Manager stores, reads, and deletes a test credential\" test/credential-store.test.mjs",
64
+ "test": "node --import ./test/helpers/setup.mjs --test \"test/*.test.mjs\"",
65
+ "test:coverage": "node --import ./test/helpers/setup.mjs --test --experimental-test-coverage --test-coverage-include=\"bin/**\" --test-coverage-include=\"commands/**\" --test-coverage-include=\"lib/**\" --test-coverage-lines=78 --test-coverage-branches=75 --test-coverage-functions=85 \"test/*.test.mjs\""
64
66
  },
65
67
  "dependencies": {
66
68
  "@clack/prompts": "^1.5.0",
@@ -71,5 +73,10 @@
71
73
  "ink-text-input": "^6.0.0",
72
74
  "react": "^19.2.7",
73
75
  "zod": "^4.4.3"
76
+ },
77
+ "devDependencies": {
78
+ "@eslint/js": "^9.39.5",
79
+ "eslint": "^9.39.5",
80
+ "globals": "^17.14.0"
74
81
  }
75
82
  }
@@ -10,18 +10,20 @@ Goal: every logged line is backed by real work (the user's commits or what the u
10
10
  ## Steps
11
11
 
12
12
  1. **Find the gaps.** Call `get_logwork` for the period. A day/project still needs time when booked > logged. Skip days that are already full.
13
- 2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, and splits each day's remaining RO hours across the groups. Treat its hours as a starting point, not a fact.
13
+ 2. **Collect the evidence.** Call `draft_from_commits` with the same period and `repos` set to every repo the user worked in (the open workspace folders; ask the user for others). It groups the user's commits per day by repo, ticket and kind of work, suggests a worklog task per group, picks each group's RO project (a `map_project` mapping whose `repos` holds the repo, else ticket/keyword mappings and that day's bookings), and splits each project's remaining RO hours across its groups. It returns `draftText` and `projectOverrides` (preview entry id → `projectMemberId`). Treat its hours as a starting point, not a fact.
14
14
  3. **Check the worklog tasks.** For each project involved, call `list_tasks(projectId)`. Keep the suggested task when it exists in that catalog; otherwise pick the catalog task whose description matches the work.
15
15
  4. **Write the descriptions.** One line per piece of work: what was done and the outcome, with ticket keys, in English, under about 200 characters, no `|`. Rewrite vague commit subjects ("wip", "fix", "update"); use `get_jira_issue` to read the ticket when the subject alone is unclear.
16
16
  5. **Set the hours.**
17
- - Stay within the remaining booked hours per day and project unless the user says otherwise.
17
+ - Stay within the remaining booked hours per day and project unless the user says otherwise. The draft already gives each project at most its own remaining hours; lines without a project share the room of booked projects no other line claimed. A project with no room left gets 0h, and the summary says so.
18
+ - If `draft_from_commits` returns `authRequired`, call `login` first; do not guess hours without RO.
18
19
  - Use 0.5h steps.
19
20
  - Do not log weekends or days without a booking unless the user asks.
20
21
  - Work without commits (meetings, reviews, support) only comes from the user; ask, then log it as `Other Activities` or `Code Review & Refactoring`.
21
- 6. **Resolve projects.** Ticket prefixes and mappings pick the project. If a line stays unresolved, ask the user and offer `map_project` so it is automatic next time.
22
+ 6. **Resolve projects.** The draft's `projectOverrides` already name the project of every resolved line. For `unmappedRepos` (lines with no project), ask the user which RO project the repo belongs to and offer `map_project` with `repos` (and `tickets` if useful), so later drafts resolve it; then run `draft_from_commits` again. A line `log_work` still cannot resolve: ask the user and add its id to `projectOverrides`.
22
23
  7. **Get approval, then write.**
23
24
  - Show the final block to the user.
24
- - On approval, call `log_work(text)` and show its preview summary.
25
+ - On approval, call `log_work(text, projectOverrides)` and show its preview summary. Pass `projectOverrides` from the draft unchanged when `draftText` is unchanged. Entry ids are `<date>-<NN>` with NN counting lines through the whole text, so if you add, remove or reorder lines, renumber the overrides to match.
26
+ - If the preview flags `possibleDuplicate` entries, show the warnings. They match an existing RO entry (or an earlier line) on date, worklog task, hours and description. Usually drop those lines and preview again. Pass `allowDuplicates: true` on the confirm call only when the user says it is really new work.
25
27
  - Then call `log_work(batchId, confirm: true)`.
26
28
  - If the user also logs Jira, do the same with `log_jira_work`. Jira needs exactly one issue key per line.
27
29