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.
- package/README.md +53 -25
- package/RELEASE.md +4 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +128 -13
- package/commands/history.mjs +0 -4
- package/commands/mcp.mjs +266 -0
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +197 -42
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +18 -7
- package/docs/mcp-setup.md +120 -26
- package/docs/operations-guide.md +50 -2
- package/docs/recipes.md +40 -0
- package/docs/security.md +23 -2
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +345 -23
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +116 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/launcher.mjs +303 -0
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +142 -1282
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +318 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +78 -20
- package/lib/manual-repl.mjs +171 -22
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +155 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +32 -27
- package/lib/query-workflow.mjs +19 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +85 -18
- package/lib/reminder-service.mjs +42 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +121 -56
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +10 -4
package/lib/apply-ledger.mjs
CHANGED
|
@@ -211,7 +211,7 @@ function createRecordId(date, random) {
|
|
|
211
211
|
|
|
212
212
|
function cleanText(value, maxLength) {
|
|
213
213
|
const text = String(value || '')
|
|
214
|
-
.replace(/Bearer\s+[A-Za-z0-9._
|
|
214
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/gi, 'Bearer <redacted>')
|
|
215
215
|
.replace(/(token|password|secret|otp)\s*[=:]\s*[^\s,;]+/gi, '$1=<redacted>')
|
|
216
216
|
.trim();
|
|
217
217
|
return text ? text.slice(0, maxLength) : '';
|
package/lib/batch-workflow.mjs
CHANGED
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
ApiError,
|
|
4
4
|
fetchWorklogTasksForProject,
|
|
5
5
|
getBookedProjectsForDate,
|
|
6
|
+
getLogworkDay,
|
|
6
7
|
getProjects
|
|
7
8
|
} from './api.mjs';
|
|
9
|
+
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
8
10
|
import {
|
|
9
11
|
listAvailableTaskNames,
|
|
10
12
|
listSuggestedTaskNames,
|
|
@@ -24,6 +26,7 @@ export async function previewLogworkBatch({
|
|
|
24
26
|
fetchProjects,
|
|
25
27
|
fetchMembershipProjects,
|
|
26
28
|
fetchWorklogTasks,
|
|
29
|
+
fetchDayEntries,
|
|
27
30
|
typeOfWork,
|
|
28
31
|
defaultWorklogTask
|
|
29
32
|
}) {
|
|
@@ -72,9 +75,153 @@ export async function previewLogworkBatch({
|
|
|
72
75
|
: enriched;
|
|
73
76
|
// typeOfWork also changes what gets written, so it is folded into the
|
|
74
77
|
// batchId the same way.
|
|
75
|
-
|
|
78
|
+
const typed = typeOfWork
|
|
76
79
|
? { ...output, typeOfWork, batchId: `${output.batchId}_${hash(`typeOfWork:${typeOfWork}`)}`, summary: `${output.summary}\n\nType of work for every entry: ${typeOfWork}` }
|
|
77
80
|
: output;
|
|
81
|
+
return attachDuplicateWarnings(typed, {
|
|
82
|
+
fetchDayEntries: fetchDayEntries || createDayEntriesFetcher()
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Duplicate guard: an entry is a possible duplicate when RO already has an
|
|
87
|
+
// entry on the same date with the same worklog task (id, else name), the same
|
|
88
|
+
// hours and the same description (trimmed, whitespace collapsed, any case), or
|
|
89
|
+
// when an earlier line of this batch is identical. Apply refuses those entries
|
|
90
|
+
// unless allowDuplicates: true. A failed day read (other than auth) skips the
|
|
91
|
+
// check for that date and says so; it never blocks the preview.
|
|
92
|
+
export async function attachDuplicateWarnings(preview, { fetchDayEntries } = {}) {
|
|
93
|
+
const entries = preview.entries || [];
|
|
94
|
+
if (!entries.length || preview.errors?.length) {
|
|
95
|
+
return preview;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const dates = [...new Set(entries.map((entry) => entry.date).filter(Boolean))].sort();
|
|
99
|
+
const existingByDate = new Map();
|
|
100
|
+
const skipped = [];
|
|
101
|
+
for (const date of dates) {
|
|
102
|
+
try {
|
|
103
|
+
existingByDate.set(date, await fetchDayEntries(date));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (isAuthRequiredError(error) || (error instanceof ApiError && error.status === 401)) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
skipped.push({ date, error: String(error?.message || error) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const seen = [];
|
|
113
|
+
const flagged = entries.map((entry) => {
|
|
114
|
+
const { possibleDuplicate: _previous, ...base } = entry;
|
|
115
|
+
const existing = (existingByDate.get(entry.date) || [])
|
|
116
|
+
.filter((candidate) => !candidate?.raw?.deleted_date && !candidate?.deletedDate)
|
|
117
|
+
.find((candidate) => isSameLogwork(entry, candidate));
|
|
118
|
+
const earlier = seen.find((candidate) => isSameLogwork(entry, candidate));
|
|
119
|
+
seen.push(entry);
|
|
120
|
+
if (existing) {
|
|
121
|
+
return {
|
|
122
|
+
...base,
|
|
123
|
+
possibleDuplicate: {
|
|
124
|
+
source: 'ro',
|
|
125
|
+
logworkId: existing.id ?? null,
|
|
126
|
+
date: existing.date || entry.date,
|
|
127
|
+
hours: Number(existing.hours),
|
|
128
|
+
taskName: existingTaskName(existing),
|
|
129
|
+
worklogTaskId: existing.worklogTaskId ?? null,
|
|
130
|
+
description: existing.description || '',
|
|
131
|
+
status: existing.status || '',
|
|
132
|
+
projectName: existing.projectName || ''
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (earlier) {
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
possibleDuplicate: {
|
|
140
|
+
source: 'batch',
|
|
141
|
+
logworkId: null,
|
|
142
|
+
entryId: earlier.id,
|
|
143
|
+
date: earlier.date,
|
|
144
|
+
hours: earlier.hours,
|
|
145
|
+
taskName: earlier.taskName,
|
|
146
|
+
description: earlier.description || ''
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return base;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const duplicateEntries = flagged.filter((entry) => entry.possibleDuplicate);
|
|
154
|
+
const duplicateCheck = {
|
|
155
|
+
status: skipped.length ? (skipped.length === dates.length ? 'skipped' : 'partial') : 'checked',
|
|
156
|
+
checkedDates: dates.filter((date) => existingByDate.has(date)),
|
|
157
|
+
...(skipped.length ? { skippedDates: skipped } : {})
|
|
158
|
+
};
|
|
159
|
+
const summaryLines = [];
|
|
160
|
+
if (duplicateEntries.length) {
|
|
161
|
+
summaryLines.push(`Possible duplicates: ${duplicateEntries.length}. Apply requires allowDuplicates: true.`);
|
|
162
|
+
summaryLines.push(...duplicateEntries.map(formatDuplicateWarning));
|
|
163
|
+
}
|
|
164
|
+
for (const item of skipped) {
|
|
165
|
+
summaryLines.push(`Note: duplicate check skipped for ${item.date} (could not read RO entries: ${item.error}).`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...preview,
|
|
170
|
+
entries: flagged,
|
|
171
|
+
unresolvedEntries: flagged.filter((entry) => entry.status === 'unresolved'),
|
|
172
|
+
unbookedEntries: flagged.filter((entry) => entry.status === 'resolved_unbooked'),
|
|
173
|
+
duplicateEntries,
|
|
174
|
+
duplicateCheck,
|
|
175
|
+
approvalSummary: {
|
|
176
|
+
...preview.approvalSummary,
|
|
177
|
+
possibleDuplicates: duplicateEntries.length,
|
|
178
|
+
requiresAllowDuplicates: duplicateEntries.length > 0
|
|
179
|
+
},
|
|
180
|
+
summary: summaryLines.length ? `${preview.summary}\n${summaryLines.join('\n')}` : preview.summary
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatDuplicateWarning(entry) {
|
|
185
|
+
const duplicate = entry.possibleDuplicate;
|
|
186
|
+
const line = `${entry.id}: +${entry.hours}h ${formatTaskAndDescription(entry)}`;
|
|
187
|
+
if (duplicate.source === 'batch') {
|
|
188
|
+
return `- WARNING possible duplicate ${line} repeats line ${duplicate.entryId} of this batch.`;
|
|
189
|
+
}
|
|
190
|
+
const status = duplicate.status ? `, ${duplicate.status}` : '';
|
|
191
|
+
const project = duplicate.projectName ? ` on ${duplicate.projectName}` : '';
|
|
192
|
+
return `- WARNING possible duplicate ${line} matches existing RO logwork ${duplicate.logworkId ?? '(no id)'}${project}${status}.`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isSameLogwork(entry, other) {
|
|
196
|
+
if (!other || (other.date && entry.date && other.date !== entry.date)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (Math.abs(Number(entry.hours) - Number(other.hours)) > 1e-9) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (normalizeDuplicateText(entry.description) !== normalizeDuplicateText(other.description)) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const entryTaskId = entry.worklogTaskId;
|
|
206
|
+
const otherTaskId = other.worklogTaskId;
|
|
207
|
+
if (hasId(entryTaskId) && hasId(otherTaskId)) {
|
|
208
|
+
return String(entryTaskId) === String(otherTaskId);
|
|
209
|
+
}
|
|
210
|
+
const entryTask = normalizeDuplicateText(entry.worklogTaskName || entry.taskName);
|
|
211
|
+
return Boolean(entryTask) && entryTask === normalizeDuplicateText(existingTaskName(other));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function existingTaskName(entry) {
|
|
215
|
+
const raw = entry?.raw || {};
|
|
216
|
+
return entry?.worklogTaskName || raw.worklog_task_name || raw.worklog_task?.name || raw.worklogTask?.name || entry?.taskName || '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeDuplicateText(value) {
|
|
220
|
+
return String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasId(value) {
|
|
224
|
+
return value !== undefined && value !== null && String(value).trim() !== '';
|
|
78
225
|
}
|
|
79
226
|
|
|
80
227
|
export const LOGWORK_LINE_FORMAT = '+<hours> <worklog task> | <description>';
|
|
@@ -313,9 +460,11 @@ export async function applyLogworkBatch({
|
|
|
313
460
|
confirm,
|
|
314
461
|
projectOverrides = {},
|
|
315
462
|
allowUnbooked = false,
|
|
463
|
+
allowDuplicates = false,
|
|
316
464
|
cwd = process.cwd(),
|
|
317
465
|
fetchProjects,
|
|
318
466
|
fetchMembershipProjects,
|
|
467
|
+
fetchDayEntries,
|
|
319
468
|
submitEntry,
|
|
320
469
|
verifyLogwork,
|
|
321
470
|
recordLedger,
|
|
@@ -332,7 +481,8 @@ export async function applyLogworkBatch({
|
|
|
332
481
|
projectOverrides,
|
|
333
482
|
cwd,
|
|
334
483
|
fetchProjects,
|
|
335
|
-
fetchMembershipProjects
|
|
484
|
+
fetchMembershipProjects,
|
|
485
|
+
fetchDayEntries
|
|
336
486
|
});
|
|
337
487
|
|
|
338
488
|
if (preview.errors?.length) {
|
|
@@ -351,6 +501,11 @@ export async function applyLogworkBatch({
|
|
|
351
501
|
throw new Error(`Cannot apply ${unbookedEntries.length} unbooked entries without allowUnbooked: true. ${blocked}`);
|
|
352
502
|
}
|
|
353
503
|
|
|
504
|
+
const duplicateEntries = preview.entries.filter((entry) => entry.possibleDuplicate);
|
|
505
|
+
if (duplicateEntries.length && allowDuplicates !== true) {
|
|
506
|
+
throw new Error(`Nothing was written. ${formatDuplicateBlock(duplicateEntries)} Apply requires allowDuplicates: true.`);
|
|
507
|
+
}
|
|
508
|
+
|
|
354
509
|
const results = [];
|
|
355
510
|
const taskCache = new Map();
|
|
356
511
|
const entrySubmitter = submitEntry || createLogtimeSubmitter(taskCache);
|
|
@@ -474,6 +629,36 @@ function createBookedProjectsFetcher() {
|
|
|
474
629
|
};
|
|
475
630
|
}
|
|
476
631
|
|
|
632
|
+
export function formatDuplicateBlock(entries) {
|
|
633
|
+
const list = entries.map((entry) => {
|
|
634
|
+
const duplicate = entry.possibleDuplicate || {};
|
|
635
|
+
const target = duplicate.source === 'batch'
|
|
636
|
+
? `line ${duplicate.entryId}`
|
|
637
|
+
: `RO logwork ${duplicate.logworkId ?? '(no id)'}`;
|
|
638
|
+
return `${entry.id} (+${entry.hours}h ${entry.taskName}) matches ${target}`;
|
|
639
|
+
}).join('; ');
|
|
640
|
+
return `${entries.length} entr${entries.length === 1 ? 'y looks' : 'ies look'} already logged: ${list}.`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Existing RO entries for one date, from GET /logwork/day, with one forced
|
|
644
|
+
// re-login on a 401.
|
|
645
|
+
function createDayEntriesFetcher() {
|
|
646
|
+
const session = createResourceOptimiserSession();
|
|
647
|
+
return async (localDateISO) => {
|
|
648
|
+
const { token } = await session.get();
|
|
649
|
+
try {
|
|
650
|
+
return (await getLogworkDay(token, { date: localDateISO })).entries || [];
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
653
|
+
throw error;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
657
|
+
return (await getLogworkDay(refreshed.token, { date: localDateISO })).entries || [];
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
477
662
|
function createMembershipProjectsFetcher() {
|
|
478
663
|
const session = createResourceOptimiserSession();
|
|
479
664
|
return async () => {
|