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,227 @@
1
+ import {
2
+ getStoredAuthStatus,
3
+ loginResourceOptimiser,
4
+ logoutResourceOptimiser
5
+ } from '../lib/auth.mjs';
6
+ import { authenticateWithApiSession } from '../lib/api-auth.mjs';
7
+ import { createAuthDiagnosticsRecorder } from '../lib/auth-diagnostics.mjs';
8
+ import { credentialStoreLabel } from '../lib/credential-store.mjs';
9
+ import {
10
+ getStoredJiraStatus,
11
+ loginJira,
12
+ logoutJira,
13
+ safeJiraAuthError
14
+ } from '../lib/jira-auth.mjs';
15
+
16
+ // `logwork login|logout|status [ro|jira]`. Resource Optimiser is the default
17
+ // target. Secrets are only ever typed in this terminal and stored in the OS
18
+ // credential store; they are never accepted as arguments.
19
+
20
+ export async function runLogin(argv = []) {
21
+ const { target, args } = splitTarget(argv);
22
+ if (wantsHelp(args)) {
23
+ console.log(formatAuthHelp());
24
+ return 0;
25
+ }
26
+ if (target === 'jira') {
27
+ const result = await runJiraLoginCommand(parseJiraLoginArgs(args));
28
+ return result.ok ? 0 : 1;
29
+ }
30
+ parseLoginArgs(args);
31
+ const result = await runLoginCommand();
32
+ return result.ok ? 0 : 1;
33
+ }
34
+
35
+ export async function runLogout(argv = []) {
36
+ const { target, args } = splitTarget(argv);
37
+ if (wantsHelp(args)) {
38
+ console.log(formatAuthHelp());
39
+ return 0;
40
+ }
41
+ assertNoArgs(args, 'logout');
42
+ const result = target === 'jira' ? await logoutJira() : await logoutResourceOptimiser();
43
+ console.log(result.summary);
44
+ return 0;
45
+ }
46
+
47
+ export async function runStatus(argv = []) {
48
+ if (wantsHelp(argv)) {
49
+ console.log(formatAuthHelp());
50
+ return 0;
51
+ }
52
+ const { target, args } = splitTarget(argv);
53
+ assertNoArgs(args, 'status');
54
+ const targets = argv.length ? [target] : ['ro', 'jira'];
55
+ let authenticated = true;
56
+ for (const item of targets) {
57
+ const result = item === 'jira' ? await getStoredJiraStatus() : await getStoredAuthStatus();
58
+ console.log(`${item === 'jira' ? 'Jira' : 'Resource Optimiser'}: ${result.summary}`);
59
+ if (item === 'ro' && !result.authenticated) {
60
+ authenticated = false;
61
+ }
62
+ }
63
+ return authenticated ? 0 : 1;
64
+ }
65
+
66
+ export function formatAuthHelp() {
67
+ const storeLabel = credentialStoreLabel();
68
+ return `Usage:
69
+ logwork login [ro|jira] [--base-url <url>]
70
+ logwork logout [ro|jira]
71
+ logwork status [ro|jira]
72
+
73
+ Targets:
74
+ ro Resource Optimiser (default). Email, password and 2FA are asked in this terminal.
75
+ jira Jira Personal Access Token, pasted in this terminal.
76
+
77
+ Sessions are stored in ${storeLabel}, never in files or command arguments.
78
+
79
+ Options:
80
+ --base-url <url> Jira base URL for \`login jira\`. Defaults to https://jira-vnv.vinova.sg`;
81
+ }
82
+
83
+ function splitTarget(argv = []) {
84
+ const [first, ...rest] = argv;
85
+ if (first === 'ro' || first === 'jira') {
86
+ return { target: first, args: rest };
87
+ }
88
+ return { target: 'ro', args: argv };
89
+ }
90
+
91
+ function wantsHelp(args) {
92
+ return args.includes('--help') || args.includes('-h');
93
+ }
94
+
95
+ function assertNoArgs(args, command) {
96
+ if (args.length) {
97
+ throw new Error(`Unknown ${command} option: ${args[0]}`);
98
+ }
99
+ }
100
+
101
+ export async function runLoginCommand({
102
+ login = loginResourceOptimiser,
103
+ createRecorder = createAuthDiagnosticsRecorder,
104
+ stdout = console.log,
105
+ stderr = console.error
106
+ } = {}) {
107
+ stdout('Starting Resource Optimiser API auth. Secrets are requested in this terminal and are not stored.');
108
+ const diagnosticsRecorder = createRecorder();
109
+
110
+ try {
111
+ const result = await login({
112
+ authenticator: (options) => authenticateWithApiSession({
113
+ ...options,
114
+ diagnosticsRecorder
115
+ })
116
+ });
117
+ stdout(result.summary);
118
+ return {
119
+ ok: true,
120
+ result
121
+ };
122
+ } catch (error) {
123
+ stderr(error.message);
124
+ try {
125
+ const written = await diagnosticsRecorder.writeFailure(error);
126
+ stderr(`Auth failed. Sanitized diagnostics saved to ${written.path}. Attempt: ${written.attemptId}`);
127
+ } catch (writeError) {
128
+ stderr(`Auth failed. Unable to write sanitized diagnostics: ${writeError.message}`);
129
+ }
130
+ return {
131
+ ok: false,
132
+ error
133
+ };
134
+ }
135
+ }
136
+
137
+ export function parseLoginArgs(args = []) {
138
+ for (let index = 0; index < args.length; index += 1) {
139
+ const arg = args[index];
140
+
141
+ if (arg === '--api-mode') {
142
+ continue;
143
+ }
144
+
145
+ if (arg === '--browser' || arg.startsWith('--browser=')) {
146
+ throw new Error('Browser auth has been removed. Run `logwork login` for API-only auth.');
147
+ }
148
+
149
+ if (arg === '--browser-mode') {
150
+ throw new Error('Browser auth has been removed. Run `logwork login` for API-only auth.');
151
+ }
152
+
153
+ if (arg === 'record') {
154
+ throw new Error('Auth recording has been removed because auth is API-only.');
155
+ }
156
+
157
+ if (arg === '--help' || arg === '-h') {
158
+ continue;
159
+ }
160
+
161
+ throw new Error(`Unknown auth login option: ${arg}`);
162
+ }
163
+
164
+ return { mode: 'api' };
165
+ }
166
+
167
+ export async function runJiraLoginCommand({
168
+ baseUrl,
169
+ login = loginJira,
170
+ stdout = console.log,
171
+ stderr = console.error
172
+ } = {}) {
173
+ stdout('Starting Jira PAT auth. Paste the token in this terminal; it is stored only in the OS credential store.');
174
+
175
+ try {
176
+ const result = await login({
177
+ ...(baseUrl ? { baseUrl } : {})
178
+ });
179
+ stdout(result.summary);
180
+ return {
181
+ ok: true,
182
+ result
183
+ };
184
+ } catch (error) {
185
+ const safeError = safeJiraAuthError(error);
186
+ stderr(safeError.message);
187
+ return {
188
+ ok: false,
189
+ error: safeError
190
+ };
191
+ }
192
+ }
193
+
194
+ export function parseJiraLoginArgs(args = []) {
195
+ const options = {};
196
+
197
+ for (let index = 0; index < args.length; index += 1) {
198
+ const arg = args[index];
199
+
200
+ if (arg === '--help' || arg === '-h') {
201
+ continue;
202
+ }
203
+
204
+ if (arg === '--base-url') {
205
+ const value = args[index + 1];
206
+ if (!value || value.startsWith('--')) {
207
+ throw new Error('--base-url requires a value.');
208
+ }
209
+ options.baseUrl = value;
210
+ index += 1;
211
+ continue;
212
+ }
213
+
214
+ if (arg.startsWith('--base-url=')) {
215
+ const value = arg.slice('--base-url='.length);
216
+ if (!value) {
217
+ throw new Error('--base-url requires a value.');
218
+ }
219
+ options.baseUrl = value;
220
+ continue;
221
+ }
222
+
223
+ throw new Error(`Unknown jira login option: ${arg}`);
224
+ }
225
+
226
+ return options;
227
+ }
@@ -0,0 +1,69 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import { parseRoDeleteArgs } from '../lib/ro-delete-args.mjs';
3
+ import {
4
+ applyRoLogworkDelete,
5
+ previewRoLogworkDelete
6
+ } from '../lib/ro-delete-workflow.mjs';
7
+
8
+ export async function runRoDeleteCommand(args = [], {
9
+ preview = previewRoLogworkDelete,
10
+ apply = applyRoLogworkDelete,
11
+ promptConfirm = defaultConfirm,
12
+ isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
13
+ print = console.log
14
+ } = {}) {
15
+ const options = parseRoDeleteArgs(args);
16
+ if (options.help) {
17
+ print(formatRoDeleteHelp());
18
+ return null;
19
+ }
20
+
21
+ if (options.json && !options.yes) {
22
+ throw new Error('--json requires --yes so stdout remains valid JSON.');
23
+ }
24
+
25
+ const deletePreview = await preview({ logworkId: options.logworkId });
26
+ if (!options.json) {
27
+ print(deletePreview.summary);
28
+ }
29
+
30
+ let approved = options.yes;
31
+ if (!approved) {
32
+ if (!isTTY) {
33
+ throw new Error('Interactive confirmation is unavailable. Re-run with --yes.');
34
+ }
35
+ approved = await promptConfirm(`Delete RO logwork entry ${deletePreview.logworkId}?`);
36
+ }
37
+ if (!approved) {
38
+ print('RO logwork delete cancelled.');
39
+ return null;
40
+ }
41
+
42
+ const result = await apply({
43
+ preview: deletePreview,
44
+ confirm: true
45
+ });
46
+ print(options.json ? JSON.stringify(result, null, 2) : result.summary);
47
+ return result;
48
+ }
49
+
50
+ export function formatRoDeleteHelp() {
51
+ return `Usage:
52
+ logwork delete <logwork-id> [--yes] [--json]
53
+
54
+ Soft-deletes one Resource Optimiser logwork entry (submitted or approved only).
55
+ The command previews the entry, checks for concurrent changes, then calls DELETE /logwork/entries/:id.
56
+
57
+ Options:
58
+ -y, --yes Delete without interactive confirmation
59
+ --json Print structured JSON; requires --yes
60
+ -h, --help Show this help`;
61
+ }
62
+
63
+ async function defaultConfirm(message) {
64
+ const result = await confirm({
65
+ message,
66
+ initialValue: false
67
+ });
68
+ return !isCancel(result) && result === true;
69
+ }
@@ -0,0 +1,24 @@
1
+ import { generateDiagnosticsReport } from '../lib/diagnostics.mjs';
2
+
3
+ export async function run(args = []) {
4
+ if (args.includes('--help') || args.includes('-h')) {
5
+ printHelp();
6
+ return;
7
+ }
8
+ if (args.length) {
9
+ throw new Error(`Unknown doctor option: ${args[0]}`);
10
+ }
11
+
12
+ const result = await generateDiagnosticsReport();
13
+ console.log(result.summary);
14
+ console.log('Send this sanitized file to the developer. Do not send raw curl logs, cookies, passwords, OTPs, or tokens.');
15
+ }
16
+
17
+ function printHelp() {
18
+ console.log(`Usage:
19
+ logwork doctor
20
+
21
+ Checks your setup and writes a sanitized support report under ~/.logwork-cli/diagnostics.
22
+ The report redacts tokens, cookies, passwords, OTPs, auth codes, and raw HTML.
23
+ `);
24
+ }
@@ -0,0 +1,88 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import { parseRoEditArgs } from '../lib/ro-edit-args.mjs';
3
+ import {
4
+ applyRoLogworkEdit,
5
+ previewRoLogworkEdit
6
+ } from '../lib/ro-edit-workflow.mjs';
7
+
8
+ export async function runRoEditCommand(args = [], {
9
+ preview = previewRoLogworkEdit,
10
+ apply = applyRoLogworkEdit,
11
+ promptConfirm = defaultConfirm,
12
+ isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY),
13
+ print = console.log
14
+ } = {}) {
15
+ const options = parseRoEditArgs(args);
16
+ if (options.help) {
17
+ print(formatRoEditHelp());
18
+ return null;
19
+ }
20
+
21
+ if (options.json && !options.yes) {
22
+ throw new Error('--json requires --yes so stdout remains valid JSON.');
23
+ }
24
+
25
+ const editPreview = await preview({
26
+ logworkId: options.logworkId,
27
+ hours: options.hours,
28
+ taskName: options.taskName,
29
+ description: options.description,
30
+ typeOfWork: options.typeOfWork
31
+ });
32
+ if (!options.json) {
33
+ print(editPreview.summary);
34
+ }
35
+
36
+ let approved = options.yes;
37
+ if (!approved) {
38
+ if (!isTTY) {
39
+ throw new Error('Interactive confirmation is unavailable. Re-run with --yes.');
40
+ }
41
+ approved = await promptConfirm(`Apply RO logwork edit ${editPreview.previewId}?`);
42
+ }
43
+ if (!approved) {
44
+ print('RO logwork edit cancelled.');
45
+ return null;
46
+ }
47
+
48
+ const result = await apply({
49
+ preview: editPreview,
50
+ confirm: true
51
+ });
52
+ print(options.json ? JSON.stringify(result, null, 2) : result.summary);
53
+ return result;
54
+ }
55
+
56
+ export function formatRoEditHelp() {
57
+ return `Usage:
58
+ logwork edit <id> [--hours <h>] [--task <task>] [--desc <text>] [--type <type>] [--yes] [--json]
59
+
60
+ Changes one Resource Optimiser entry. Project and date are preserved. Get the id
61
+ from \`logwork query\`.
62
+
63
+ RO has no staff edit API, so apply replaces the entry: it creates the corrected
64
+ entry, then deletes the original (the new entry gets a new id and is approved
65
+ immediately). If the delete fails, the new entry is rolled back. Only submitted
66
+ or approved entries can be edited; rejected entries are fixed with resubmit.
67
+
68
+ Options:
69
+ --hours <h> New hours (alias: --logtimes)
70
+ --task <task> New worklog task from the project catalog, or #<id> (alias: --task-name)
71
+ --desc <text> New description: what you did (alias: --description)
72
+ --type <type> New type of work: create, correct, improve, other
73
+ (follows the new task's catalog type when the task changes)
74
+ -y, --yes Apply without the confirmation prompt
75
+ --json Print the result as JSON (requires --yes)
76
+ -h, --help Show this help
77
+
78
+ Example:
79
+ logwork edit 304032 --task "Bug Fix" --desc "Fix date picker timezone (SAP-42)"`;
80
+ }
81
+
82
+ async function defaultConfirm(message) {
83
+ const result = await confirm({
84
+ message,
85
+ initialValue: false
86
+ });
87
+ return !isCancel(result) && result === true;
88
+ }
@@ -0,0 +1,62 @@
1
+ import { queryApplyLedger } from '../lib/apply-ledger.mjs';
2
+
3
+ export function parseHistoryArgs(args = []) {
4
+ const options = {
5
+ target: undefined,
6
+ limit: 20,
7
+ json: false
8
+ };
9
+
10
+ for (let index = 0; index < args.length; index += 1) {
11
+ const arg = args[index];
12
+ if (arg === '--target') {
13
+ options.target = args[index + 1];
14
+ index += 1;
15
+ continue;
16
+ }
17
+ if (arg === '--limit') {
18
+ options.limit = Number(args[index + 1]);
19
+ index += 1;
20
+ continue;
21
+ }
22
+ if (arg === '--json') {
23
+ options.json = true;
24
+ continue;
25
+ }
26
+ if (arg === '--help' || arg === '-h') {
27
+ return { help: true };
28
+ }
29
+ throw new Error(`Unknown history option: ${arg}`);
30
+ }
31
+
32
+ return options;
33
+ }
34
+
35
+ export async function runHistoryCommand(args = [], {
36
+ query = queryApplyLedger,
37
+ print = console.log
38
+ } = {}) {
39
+ const options = parseHistoryArgs(args);
40
+ if (options.help) {
41
+ printHistoryHelp(print);
42
+ return null;
43
+ }
44
+
45
+ const result = await query({
46
+ target: options.target,
47
+ limit: options.limit
48
+ });
49
+ print(options.json ? JSON.stringify(result, null, 2) : result.summary);
50
+ return result;
51
+ }
52
+
53
+ function printHistoryHelp(print = console.log) {
54
+ print(`Usage:
55
+ logwork history [--target ro|jira|both] [--limit <count>] [--json]
56
+
57
+ Shows the local apply result ledger. Target both means entries applied from a combined Both flow.`);
58
+ }
59
+
60
+ async function main() {
61
+ await runHistoryCommand(process.argv.slice(2));
62
+ }
@@ -0,0 +1,52 @@
1
+ import { queryLogwork } from '../lib/query-workflow.mjs';
2
+ import { normalizeReconciliationPeriod } from '../lib/reconciliation-workflow.mjs';
3
+
4
+ // `logwork query [period] [--json]`: booked vs logged Resource Optimiser hours,
5
+ // with each entry's id, worklog task, description and status.
6
+ export function parseQueryArgs(args = []) {
7
+ const options = { period: 'today', json: false, help: false };
8
+ const positional = [];
9
+ for (const arg of args) {
10
+ if (arg === '--json') {
11
+ options.json = true;
12
+ continue;
13
+ }
14
+ if (arg === '--help' || arg === '-h') {
15
+ options.help = true;
16
+ continue;
17
+ }
18
+ if (arg.startsWith('-')) {
19
+ throw new Error(`Unknown query option: ${arg}`);
20
+ }
21
+ positional.push(arg);
22
+ }
23
+ if (positional.length > 1) {
24
+ throw new Error('Query accepts exactly one preset period.');
25
+ }
26
+ if (positional[0]) {
27
+ options.period = normalizeReconciliationPeriod(positional[0]);
28
+ }
29
+ return options;
30
+ }
31
+
32
+ export async function run(args = [], {
33
+ query = queryLogwork,
34
+ print = console.log
35
+ } = {}) {
36
+ const options = parseQueryArgs(args);
37
+ if (options.help) {
38
+ print(formatQueryHelp());
39
+ return 0;
40
+ }
41
+ const result = await query({ period: options.period, includeEntries: true });
42
+ print(options.json ? JSON.stringify(result, null, 2) : result.summary);
43
+ return 0;
44
+ }
45
+
46
+ export function formatQueryHelp() {
47
+ return `Usage:
48
+ logwork query [today|yesterday|this-week|last-week|this-month|last-month] [--json]
49
+
50
+ Shows booked vs logged Resource Optimiser hours per day and project, with each
51
+ entry's id (use it with \`logwork edit\` / \`logwork delete\`). Defaults to today.`;
52
+ }
@@ -0,0 +1,163 @@
1
+ import { intro } from '@clack/prompts';
2
+ import { CONFIG } from '../lib/config.mjs';
3
+ import { getFreshToken, getUserIdFromJwt } from '../lib/auth.mjs';
4
+ import {
5
+ addLogtime,
6
+ ApiError,
7
+ fetchWorklogTasksForProject,
8
+ getTodayTimesheetProjects
9
+ } from '../lib/api.mjs';
10
+ import * as ui from '../lib/ui.mjs';
11
+ import { todayLocalDateISO } from '../lib/util.mjs';
12
+
13
+ // `logwork quick [-m "what you did"]`: log one Resource Optimiser entry for
14
+ // today with guided prompts (project -> worklog task -> description -> hours).
15
+ export async function run(argv = []) {
16
+ const { message, help } = parseQuickArgs(argv);
17
+ if (help) {
18
+ console.log(formatQuickHelp());
19
+ return 0;
20
+ }
21
+
22
+ try {
23
+ intro('Logwork quick');
24
+ const result = await runQuickLog({ message });
25
+ ui.showSuccess(result.dryRun
26
+ ? 'DRY RUN: payload generated; no API write performed.'
27
+ : 'Logwork submitted.');
28
+ return 0;
29
+ } catch (error) {
30
+ ui.showError(error);
31
+ return 1;
32
+ }
33
+ }
34
+
35
+ export async function runQuickLog({ message = '' } = {}) {
36
+ const localDateISO = todayLocalDateISO();
37
+ let session = await openSession();
38
+
39
+ let projects;
40
+ try {
41
+ projects = await loadBookedProjects(session, localDateISO);
42
+ } catch (error) {
43
+ if (!isUnauthorized(error)) {
44
+ throw error;
45
+ }
46
+ session = await openSession({ forceLogin: true });
47
+ projects = await loadBookedProjects(session, localDateISO);
48
+ }
49
+
50
+ const daySummaries = projects.map((project) => ({
51
+ projectId: project.projectId,
52
+ totalHours: project.loggedHours,
53
+ ok: true
54
+ }));
55
+ const project = await ui.chooseProject(projects, daySummaries);
56
+ const tasks = (await fetchWorklogTasksForProject(session.token, project.projectId)).map((task) => ({
57
+ taskId: task.id,
58
+ name: task.name,
59
+ typeOfWork: task.typeOfWork || 'other',
60
+ description: task.description,
61
+ needsProjectId: task.projectId === null || task.projectId === undefined
62
+ }));
63
+ if (!tasks.length) {
64
+ throw new Error(`No worklog tasks are available for ${project.projectName}.`);
65
+ }
66
+ const task = await ui.chooseTask(tasks);
67
+ const description = await ui.chooseMessageSource(message);
68
+ const hours = await ui.askHours(CONFIG.defaultHours);
69
+
70
+ await ui.confirmSubmit({
71
+ projectName: project.projectName,
72
+ hours,
73
+ taskName: task.name,
74
+ description,
75
+ localDateISO
76
+ });
77
+
78
+ const entry = {
79
+ projectId: project.projectId,
80
+ projectMemberId: project.projectMemberId,
81
+ logtimes: hours,
82
+ taskName: task.name,
83
+ localDateISO,
84
+ worklogTaskId: task.taskId,
85
+ typeOfWork: task.typeOfWork,
86
+ description,
87
+ worklogNeedsProjectId: task.needsProjectId
88
+ };
89
+
90
+ try {
91
+ return await addLogtime(session.token, entry);
92
+ } catch (error) {
93
+ if (!isUnauthorized(error)) {
94
+ throw error;
95
+ }
96
+ session = await openSession({ forceLogin: true });
97
+ return addLogtime(session.token, entry);
98
+ }
99
+ }
100
+
101
+ export function parseQuickArgs(argv = []) {
102
+ let message = '';
103
+ let help = false;
104
+ const positional = [];
105
+
106
+ for (let index = 0; index < argv.length; index += 1) {
107
+ const arg = argv[index];
108
+ if (arg === '--help' || arg === '-h') {
109
+ help = true;
110
+ continue;
111
+ }
112
+ if (arg === '--message' || arg === '-m') {
113
+ const value = argv[index + 1];
114
+ if (!value) {
115
+ throw new Error(`${arg} requires a value.`);
116
+ }
117
+ message = value;
118
+ index += 1;
119
+ continue;
120
+ }
121
+ if (arg.startsWith('--message=')) {
122
+ message = arg.slice('--message='.length);
123
+ continue;
124
+ }
125
+ positional.push(arg);
126
+ }
127
+
128
+ if (!message && positional.length) {
129
+ message = positional.join(' ');
130
+ }
131
+ return { message, help };
132
+ }
133
+
134
+ export function formatQuickHelp() {
135
+ return `Usage:
136
+ logwork quick
137
+ logwork quick -m "Fix login bug (SCB-12)"
138
+ logwork quick "Fix login bug (SCB-12)"
139
+
140
+ Logs one Resource Optimiser entry for today. You pick a booked project, a worklog
141
+ task and the hours; the message becomes the entry description.
142
+
143
+ Options:
144
+ -m, --message <text> Description to prefill
145
+ -h, --help Show this help`;
146
+ }
147
+
148
+ async function openSession({ forceLogin = false } = {}) {
149
+ const token = await getFreshToken({ forceLogin });
150
+ return { token, userId: getUserIdFromJwt(token) };
151
+ }
152
+
153
+ async function loadBookedProjects(session, localDateISO) {
154
+ const projects = await getTodayTimesheetProjects(session.token, session.userId, localDateISO);
155
+ if (!projects.length) {
156
+ throw new Error(`You have no booked project on ${localDateISO}.`);
157
+ }
158
+ return projects;
159
+ }
160
+
161
+ function isUnauthorized(error) {
162
+ return error instanceof ApiError && error.status === 401;
163
+ }