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.
- package/README.md +35 -9
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +157 -19
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +57 -15
- package/docs/operations-guide.md +37 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- 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-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +201 -2
- package/lib/commit-workflow.mjs +278 -30
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/keychain.mjs +19 -12
- 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 +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +63 -21
- package/lib/node-runtime.mjs +115 -0
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +11 -1
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- 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 +5 -8
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +49 -6
- package/lib/ro-resubmit-workflow.mjs +35 -21
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/lib/update-service.mjs +5 -2
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +6 -4
package/lib/ro-edit-workflow.mjs
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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 || '') &&
|
package/lib/ro-entry-reader.mjs
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
1
|
+
import * as clackPrompts from '@clack/prompts';
|
|
2
2
|
import { CONFIG } from './config.mjs';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
28
|
-
|
|
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
|
|
38
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
106
|
-
|
|
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
|
|
package/lib/update-service.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
62
|
-
"
|
|
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
|
}
|
package/skills/logwork/SKILL.md
CHANGED
|
@@ -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
|
|
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.**
|
|
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
|
|