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.
Files changed (91) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +287 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +101 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/delete.mjs +69 -0
  7. package/commands/doctor.mjs +24 -0
  8. package/commands/edit.mjs +88 -0
  9. package/commands/history.mjs +62 -0
  10. package/commands/query.mjs +52 -0
  11. package/commands/quick.mjs +163 -0
  12. package/commands/reconcile.mjs +55 -0
  13. package/commands/release.mjs +77 -0
  14. package/commands/reminder.mjs +172 -0
  15. package/commands/setup.mjs +504 -0
  16. package/commands/tui.mjs +50 -0
  17. package/commands/update.mjs +161 -0
  18. package/docs/advanced.md +334 -0
  19. package/docs/mcp-setup.md +347 -0
  20. package/docs/operations-guide.md +587 -0
  21. package/docs/recipes.md +259 -0
  22. package/docs/security.md +151 -0
  23. package/examples/mcp/antigravity.mcp_config.json +8 -0
  24. package/examples/mcp/codex.config.toml +5 -0
  25. package/examples/mcp/copilot.mcp.json +9 -0
  26. package/lib/api-auth.mjs +1259 -0
  27. package/lib/api.mjs +1422 -0
  28. package/lib/apply-ledger.mjs +227 -0
  29. package/lib/atomic-file.mjs +24 -0
  30. package/lib/auth-diagnostics.mjs +113 -0
  31. package/lib/auth-errors.mjs +22 -0
  32. package/lib/auth-redaction.mjs +78 -0
  33. package/lib/auth-terminal.mjs +97 -0
  34. package/lib/auth.mjs +323 -0
  35. package/lib/batch-parser.mjs +145 -0
  36. package/lib/batch-workflow.mjs +842 -0
  37. package/lib/config.mjs +191 -0
  38. package/lib/credential-store.mjs +130 -0
  39. package/lib/diagnostics.mjs +307 -0
  40. package/lib/entrypoint.mjs +14 -0
  41. package/lib/file-lock.mjs +44 -0
  42. package/lib/http.mjs +125 -0
  43. package/lib/jira-api.mjs +258 -0
  44. package/lib/jira-auth.mjs +203 -0
  45. package/lib/jira-workflow.mjs +523 -0
  46. package/lib/keychain.mjs +105 -0
  47. package/lib/legacy-migration.mjs +66 -0
  48. package/lib/list-navigation.mjs +19 -0
  49. package/lib/logwork-config.mjs +199 -0
  50. package/lib/logwork-f03-enrich.mjs +41 -0
  51. package/lib/macos.mjs +33 -0
  52. package/lib/manual-apply-state.mjs +35 -0
  53. package/lib/manual-commands.mjs +294 -0
  54. package/lib/manual-drafts.mjs +154 -0
  55. package/lib/manual-ink-app.mjs +1654 -0
  56. package/lib/manual-ink-auth.mjs +349 -0
  57. package/lib/manual-ink-ui.mjs +675 -0
  58. package/lib/manual-input.mjs +21 -0
  59. package/lib/manual-logwork-wizard.mjs +310 -0
  60. package/lib/manual-mcp.mjs +187 -0
  61. package/lib/manual-repl.mjs +1037 -0
  62. package/lib/manual-targets.mjs +78 -0
  63. package/lib/mcp-server.mjs +793 -0
  64. package/lib/os-notification.mjs +105 -0
  65. package/lib/package-info.mjs +17 -0
  66. package/lib/paths.mjs +68 -0
  67. package/lib/project-identity.mjs +66 -0
  68. package/lib/project-mapping-workflow.mjs +309 -0
  69. package/lib/project-resolver.mjs +373 -0
  70. package/lib/query-workflow.mjs +553 -0
  71. package/lib/reconciliation-workflow.mjs +446 -0
  72. package/lib/release-doctor.mjs +448 -0
  73. package/lib/reminder-config.mjs +71 -0
  74. package/lib/reminder-scheduler.mjs +301 -0
  75. package/lib/reminder-service.mjs +118 -0
  76. package/lib/reminder-state.mjs +38 -0
  77. package/lib/reminder-workflow.mjs +299 -0
  78. package/lib/ro-delete-args.mjs +38 -0
  79. package/lib/ro-delete-workflow.mjs +115 -0
  80. package/lib/ro-edit-args.mjs +142 -0
  81. package/lib/ro-edit-workflow.mjs +632 -0
  82. package/lib/ro-entry-reader.mjs +56 -0
  83. package/lib/ro-resubmit-workflow.mjs +134 -0
  84. package/lib/terminal-auth-prompts.mjs +120 -0
  85. package/lib/ui.mjs +133 -0
  86. package/lib/update-service.mjs +350 -0
  87. package/lib/util.mjs +66 -0
  88. package/lib/windows-credential-manager.mjs +260 -0
  89. package/lib/worklog-task-resolver.mjs +113 -0
  90. package/package.json +72 -4
  91. 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
+ }