logwork-cli 0.0.0-stage → 1.0.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/LICENSE +11 -0
- package/README.md +287 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +101 -0
- package/commands/auth.mjs +227 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +504 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +347 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +793 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +72 -4
- package/setup.sh +22 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ApiError, resubmitLogworkEntry } from './api.mjs';
|
|
2
|
+
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
|
+
import { createF03LogworkReader } from './ro-entry-reader.mjs';
|
|
4
|
+
|
|
5
|
+
export async function previewRoLogworkResubmit({
|
|
6
|
+
logworkId,
|
|
7
|
+
hours,
|
|
8
|
+
typeOfWork,
|
|
9
|
+
noteToPm,
|
|
10
|
+
description,
|
|
11
|
+
fetchLogwork
|
|
12
|
+
}) {
|
|
13
|
+
const reader = fetchLogwork || createF03LogworkReader();
|
|
14
|
+
const current = await reader(assertIdentifier(logworkId, 'logworkId'));
|
|
15
|
+
if (String(current.status || '').toLowerCase() !== 'rejected') {
|
|
16
|
+
throw new Error('Only rejected RO logwork entries can be resubmitted through this flow.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
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
|
+
}
|
|
23
|
+
|
|
24
|
+
const previewId = `resubmit_${current.id}_${effortHours}`;
|
|
25
|
+
const preview = {
|
|
26
|
+
previewId,
|
|
27
|
+
status: 'ready',
|
|
28
|
+
logworkId: current.id,
|
|
29
|
+
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)
|
|
37
|
+
};
|
|
38
|
+
preview.summary = [
|
|
39
|
+
'RO logwork resubmit preview:',
|
|
40
|
+
`- Entry: ${current.id}`,
|
|
41
|
+
`- Hours: ${current.hours} -> ${effortHours}`,
|
|
42
|
+
`- Status: rejected -> approved (after resubmit)`
|
|
43
|
+
].join('\n');
|
|
44
|
+
return preview;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function applyRoLogworkResubmit({
|
|
48
|
+
preview,
|
|
49
|
+
confirm,
|
|
50
|
+
fetchLogwork,
|
|
51
|
+
submitResubmit
|
|
52
|
+
}) {
|
|
53
|
+
if (confirm !== true) {
|
|
54
|
+
throw new Error('Resubmit requires confirm: true.');
|
|
55
|
+
}
|
|
56
|
+
if (!preview || preview.status !== 'ready') {
|
|
57
|
+
throw new Error('A valid resubmit preview is required (resubmit_entry without confirm).');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const reader = fetchLogwork || createF03LogworkReader();
|
|
61
|
+
const writer = submitResubmit || createResubmitWriter();
|
|
62
|
+
const current = await reader(preview.logworkId);
|
|
63
|
+
if (createLogworkRevisionFingerprint(current) !== preview.originalFingerprint) {
|
|
64
|
+
throw new Error('RO logwork changed after preview. Preview the resubmit again before applying.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = await writer({
|
|
68
|
+
logworkId: preview.logworkId,
|
|
69
|
+
effortHours: preview.updated.hours,
|
|
70
|
+
typeOfWork: preview.updated.typeOfWork,
|
|
71
|
+
noteToPm: preview.updated.noteToPm,
|
|
72
|
+
description: preview.updated.description
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (result?.dryRun) {
|
|
76
|
+
return {
|
|
77
|
+
status: 'dry_run',
|
|
78
|
+
dryRun: true,
|
|
79
|
+
logworkId: preview.logworkId,
|
|
80
|
+
result,
|
|
81
|
+
summary: `Dry run: RO logwork ${preview.logworkId} was not resubmitted.`
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
status: 'resubmitted',
|
|
87
|
+
logworkId: preview.logworkId,
|
|
88
|
+
result,
|
|
89
|
+
summary: `Resubmitted RO logwork ${preview.logworkId} with ${preview.updated.hours}h.`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createResubmitWriter() {
|
|
94
|
+
const session = createResourceOptimiserSession();
|
|
95
|
+
return async (payload) => {
|
|
96
|
+
const { token } = await session.get();
|
|
97
|
+
try {
|
|
98
|
+
return await resubmitLogworkEntry(token, payload.logworkId, {
|
|
99
|
+
effortHours: payload.effortHours,
|
|
100
|
+
typeOfWork: payload.typeOfWork,
|
|
101
|
+
noteToPm: payload.noteToPm,
|
|
102
|
+
description: payload.description
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!(error instanceof ApiError) || error.status !== 401) {
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
const refreshed = await session.get({ forceLogin: true });
|
|
109
|
+
return resubmitLogworkEntry(refreshed.token, payload.logworkId, {
|
|
110
|
+
effortHours: payload.effortHours,
|
|
111
|
+
typeOfWork: payload.typeOfWork,
|
|
112
|
+
noteToPm: payload.noteToPm,
|
|
113
|
+
description: payload.description
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertIdentifier(value, label) {
|
|
120
|
+
const text = String(value ?? '').trim();
|
|
121
|
+
if (!text) {
|
|
122
|
+
throw new Error(`${label} is required.`);
|
|
123
|
+
}
|
|
124
|
+
return text;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createLogworkRevisionFingerprint(logwork) {
|
|
128
|
+
return JSON.stringify({
|
|
129
|
+
id: logwork.id,
|
|
130
|
+
hours: logwork.hours,
|
|
131
|
+
status: logwork.status,
|
|
132
|
+
taskName: logwork.taskName
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isCancel, password, select, text } from '@clack/prompts';
|
|
2
|
+
import { createAuthRequiredError } from './auth-errors.mjs';
|
|
3
|
+
import { createCredentialEmailStorage } from './credential-store.mjs';
|
|
4
|
+
|
|
5
|
+
export function createTerminalCredentialProvider({
|
|
6
|
+
emailStorage = createCredentialEmailStorage(),
|
|
7
|
+
stdin = process.stdin,
|
|
8
|
+
stdout = process.stdout
|
|
9
|
+
} = {}) {
|
|
10
|
+
return {
|
|
11
|
+
async requestCredentials() {
|
|
12
|
+
ensureInteractive(stdin, stdout);
|
|
13
|
+
const rememberedEmail = await emailStorage.get().catch(() => null);
|
|
14
|
+
const email = handleCancel(await text({
|
|
15
|
+
message: 'Resource Optimiser email:',
|
|
16
|
+
placeholder: 'name@example.com',
|
|
17
|
+
initialValue: rememberedEmail || '',
|
|
18
|
+
validate(value) {
|
|
19
|
+
if (!String(value || '').trim()) {
|
|
20
|
+
return 'Email is required.';
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
})).trim();
|
|
25
|
+
|
|
26
|
+
const secret = handleCancel(await password({
|
|
27
|
+
message: 'Resource Optimiser password:',
|
|
28
|
+
validate(value) {
|
|
29
|
+
if (!String(value || '')) {
|
|
30
|
+
return 'Password is required.';
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
await emailStorage.set(email).catch(() => {});
|
|
37
|
+
return {
|
|
38
|
+
email,
|
|
39
|
+
password: secret
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async requestPasswordUpdate() {
|
|
44
|
+
ensureInteractive(stdin, stdout);
|
|
45
|
+
const newPassword = handleCancel(await password({
|
|
46
|
+
message: 'New Resource Optimiser password:',
|
|
47
|
+
validate(value) {
|
|
48
|
+
if (!String(value || '')) {
|
|
49
|
+
return 'New password is required.';
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
const confirmPassword = handleCancel(await password({
|
|
55
|
+
message: 'Confirm new Resource Optimiser password:',
|
|
56
|
+
validate(value) {
|
|
57
|
+
if (!String(value || '')) {
|
|
58
|
+
return 'Password confirmation is required.';
|
|
59
|
+
}
|
|
60
|
+
if (value !== newPassword) {
|
|
61
|
+
return 'Passwords do not match.';
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
newPassword,
|
|
69
|
+
confirmPassword
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async requestDeviceSelection(devices) {
|
|
74
|
+
ensureInteractive(stdin, stdout);
|
|
75
|
+
if (!Array.isArray(devices) || devices.length === 0) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (devices.length === 1) {
|
|
79
|
+
return devices[0];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const selected = handleCancel(await select({
|
|
83
|
+
message: 'Choose 2FA device:',
|
|
84
|
+
options: devices.map((device, index) => ({
|
|
85
|
+
value: String(index),
|
|
86
|
+
label: device.label || `Device ${index + 1}`
|
|
87
|
+
}))
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
return devices[Number(selected)];
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async requestOtp() {
|
|
94
|
+
ensureInteractive(stdin, stdout);
|
|
95
|
+
return handleCancel(await password({
|
|
96
|
+
message: '2FA code:',
|
|
97
|
+
validate(value) {
|
|
98
|
+
if (!String(value || '').trim()) {
|
|
99
|
+
return '2FA code is required.';
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
})).trim();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ensureInteractive(stdin, stdout) {
|
|
109
|
+
if (!stdin?.isTTY || !stdout?.isTTY) {
|
|
110
|
+
throw createAuthRequiredError();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleCancel(value) {
|
|
115
|
+
if (isCancel(value)) {
|
|
116
|
+
throw new Error('User cancelled Resource Optimiser authentication.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return value;
|
|
120
|
+
}
|
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { cancel, confirm, isCancel, note, outro, select, text } from '@clack/prompts';
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
|
|
4
|
+
export async function chooseProject(projects, daySummaries) {
|
|
5
|
+
const options = projects.map((project, index) => {
|
|
6
|
+
const summary = daySummaries[index];
|
|
7
|
+
const logged = summary?.ok ? `${formatHours(summary.totalHours)}h` : `${formatHours(project.loggedHours)}h`;
|
|
8
|
+
const booked = `${formatHours(project.assignedHours)}h`;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
value: project.projectMemberId,
|
|
12
|
+
label: `${project.projectName} - ${booked}/day (${project.workloadPercent}% assigned) · ${logged} logged today`
|
|
13
|
+
};
|
|
14
|
+
});
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function chooseTask(tasks) {
|
|
25
|
+
const taskId = handleCancel(await select({
|
|
26
|
+
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
|
+
}));
|
|
33
|
+
|
|
34
|
+
return tasks.find((task) => task.taskId === taskId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function chooseMessageSource(defaultMessage) {
|
|
38
|
+
const source = handleCancel(await select({
|
|
39
|
+
message: 'Task message:',
|
|
40
|
+
options: [
|
|
41
|
+
{ value: 'git', label: 'Use git commit message' },
|
|
42
|
+
{ value: 'custom', label: 'Custom' }
|
|
43
|
+
],
|
|
44
|
+
initialValue: 'git'
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const initialValue = source === 'git' ? defaultMessage : '';
|
|
48
|
+
const value = handleCancel(await text({
|
|
49
|
+
message: 'Nội dung công việc:',
|
|
50
|
+
placeholder: 'Fix login flow',
|
|
51
|
+
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
|
+
}));
|
|
59
|
+
|
|
60
|
+
return value.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function askHours(defaultHours = CONFIG.defaultHours) {
|
|
64
|
+
const value = handleCancel(await text({
|
|
65
|
+
message: 'Số giờ log:',
|
|
66
|
+
placeholder: String(defaultHours),
|
|
67
|
+
initialValue: String(defaultHours),
|
|
68
|
+
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
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
return Number(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
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}?`,
|
|
95
|
+
initialValue: true
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
if (!ok) {
|
|
99
|
+
throw new Error('User declined final submit.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function showSuccess(message) {
|
|
106
|
+
outro(message);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function showError(error) {
|
|
110
|
+
cancel(error?.message || String(error));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function handleCancel(value) {
|
|
114
|
+
if (isCancel(value)) {
|
|
115
|
+
throw new Error('Cancelled.');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatHours(value) {
|
|
122
|
+
const number = Number(value || 0);
|
|
123
|
+
return Number.isInteger(number) ? String(number) : String(number.toFixed(1)).replace(/\.0$/, '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function truncate(value, maxLength) {
|
|
127
|
+
const textValue = String(value);
|
|
128
|
+
if (textValue.length <= maxLength) {
|
|
129
|
+
return textValue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return `${textValue.slice(0, maxLength - 1)}…`;
|
|
133
|
+
}
|