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,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
|
+
}
|