logwork-cli 0.0.0-stage → 1.1.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 (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
package/lib/auth.mjs ADDED
@@ -0,0 +1,323 @@
1
+ import { CONFIG } from './config.mjs';
2
+ import { authenticateWithApiSession, extractRoSession } from './api-auth.mjs';
3
+ import { createAuthRequiredError } from './auth-errors.mjs';
4
+ import { redactText } from './auth-redaction.mjs';
5
+ import { createCredentialEmailStorage, createCredentialTokenStorage } from './credential-store.mjs';
6
+ import { fetchWithPolicy } from './http.mjs';
7
+ import { safeJsonParse } from './util.mjs';
8
+
9
+ export function decodeJwt(token) {
10
+ if (typeof token !== 'string') {
11
+ throw new Error('JWT token is not a string.');
12
+ }
13
+
14
+ const parts = token.split('.');
15
+ if (parts.length < 2) {
16
+ throw new Error('Malformed JWT token.');
17
+ }
18
+
19
+ try {
20
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
21
+ return JSON.parse(payload);
22
+ } catch (error) {
23
+ throw new Error(`Unable to decode JWT payload: ${error.message}`);
24
+ }
25
+ }
26
+
27
+ export function isJwtExpired(token, skewSeconds = 60) {
28
+ try {
29
+ const payload = decodeJwt(token);
30
+ if (typeof payload.exp !== 'number') {
31
+ return true;
32
+ }
33
+
34
+ const expiresAtMs = payload.exp * 1000;
35
+ return expiresAtMs <= Date.now() + skewSeconds * 1000;
36
+ } catch {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ export function getUserIdFromJwt(token) {
42
+ const payload = decodeJwt(token);
43
+ const userId = payload.id ?? payload.user_id ?? payload.sub;
44
+
45
+ if (userId === undefined || userId === null || String(userId).trim() === '') {
46
+ throw new Error('Unable to find user id in JWT payload.');
47
+ }
48
+
49
+ return userId;
50
+ }
51
+
52
+ export function summarizeToken(token) {
53
+ const payload = decodeJwt(token);
54
+ return {
55
+ userId: getUserIdFromJwt(token),
56
+ email: payload.email || null,
57
+ expiresAt: typeof payload.exp === 'number' ? new Date(payload.exp * 1000).toISOString() : null
58
+ };
59
+ }
60
+
61
+ export async function getStoredAuthStatus({ storage = createCredentialTokenStorage() } = {}) {
62
+ const session = normalizeStoredAuthSession(await storage.get());
63
+ if (!session?.accessToken) {
64
+ return {
65
+ authenticated: false,
66
+ expired: false,
67
+ summary: 'Not authenticated. Run `logwork login`.'
68
+ };
69
+ }
70
+
71
+ if (isJwtExpired(session.accessToken)) {
72
+ if (session.refreshToken) {
73
+ return {
74
+ authenticated: true,
75
+ expired: true,
76
+ refreshAvailable: true,
77
+ summary: 'Stored Resource Optimiser access token is expired, but a refresh token is available. It will refresh automatically on the next request.'
78
+ };
79
+ }
80
+ return {
81
+ authenticated: false,
82
+ expired: true,
83
+ summary: 'Stored Resource Optimiser token is expired. Run `logwork login`.'
84
+ };
85
+ }
86
+
87
+ const details = summarizeToken(session.accessToken);
88
+ return {
89
+ authenticated: true,
90
+ expired: false,
91
+ refreshAvailable: Boolean(session.refreshToken),
92
+ ...details,
93
+ summary: `Authenticated as user ${details.userId}${details.email ? ` (${details.email})` : ''}. Token expires at ${details.expiresAt}.`
94
+ };
95
+ }
96
+
97
+ export async function loginResourceOptimiser({
98
+ credentialProvider,
99
+ storage = createCredentialTokenStorage(),
100
+ authenticator = authenticateWithApiSession
101
+ } = {}) {
102
+ const session = normalizeAuthSession(await authenticator({ credentialProvider }));
103
+ await validateAndStoreSession(session, storage);
104
+ const details = summarizeToken(session.accessToken);
105
+ return {
106
+ ...details,
107
+ summary: `Authenticated as user ${details.userId}${details.email ? ` (${details.email})` : ''}. Token expires at ${details.expiresAt}.`
108
+ };
109
+ }
110
+
111
+ export async function logoutResourceOptimiser({
112
+ storage = createCredentialTokenStorage(),
113
+ emailStorage = createCredentialEmailStorage()
114
+ } = {}) {
115
+ const deleted = await storage.delete();
116
+ await emailStorage.delete().catch(() => {});
117
+ return {
118
+ deleted,
119
+ summary: deleted ? 'Deleted stored Resource Optimiser token.' : 'No stored Resource Optimiser token found.'
120
+ };
121
+ }
122
+
123
+ export async function getFreshToken({
124
+ credentialProvider,
125
+ forceLogin = false,
126
+ interactive = true,
127
+ storage = createCredentialTokenStorage(),
128
+ authenticator = authenticateWithApiSession,
129
+ refreshSession = refreshResourceOptimiserSession
130
+ } = {}) {
131
+ if (!forceLogin) {
132
+ const storedSession = normalizeStoredAuthSession(await storage.get());
133
+ if (storedSession?.accessToken && !isJwtExpired(storedSession.accessToken)) {
134
+ return storedSession.accessToken;
135
+ }
136
+
137
+ if (storedSession?.accessToken && storedSession.refreshToken) {
138
+ try {
139
+ const refreshed = normalizeAuthSession(await refreshSession({
140
+ session: storedSession
141
+ }));
142
+ await validateAndStoreSession(refreshed, storage);
143
+ return refreshed.accessToken;
144
+ } catch {
145
+ if (!interactive) {
146
+ throw createAuthRequiredError();
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ if (!interactive) {
153
+ throw createAuthRequiredError();
154
+ }
155
+
156
+ const session = normalizeAuthSession(await authenticator({ credentialProvider }));
157
+ await validateAndStoreSession(session, storage);
158
+ return session.accessToken;
159
+ }
160
+
161
+ export async function refreshResourceOptimiserSession({
162
+ session,
163
+ apiBase = CONFIG.apiBase,
164
+ refreshTokenPath = CONFIG.refreshTokenPath,
165
+ fetchImpl = fetch,
166
+ timeoutMs = CONFIG.httpTimeoutMs
167
+ } = {}) {
168
+ if (!session?.accessToken || !session.refreshToken) {
169
+ throw new Error('No refresh token is available.');
170
+ }
171
+
172
+ assertSecureUrl(apiBase, 'Resource Optimiser API endpoint');
173
+ const browserOrigin = originFromUrl(CONFIG.loginUrl);
174
+ const response = await fetchWithPolicy(`${apiBase}${refreshTokenPath}`, {
175
+ fetchImpl,
176
+ timeoutMs,
177
+ retries: 0,
178
+ idempotent: false,
179
+ method: 'POST',
180
+ headers: {
181
+ Authorization: `Bearer ${session.accessToken}`,
182
+ Accept: 'application/json, text/plain, */*',
183
+ 'Content-Type': 'application/json',
184
+ Origin: browserOrigin,
185
+ Referer: `${browserOrigin}/`
186
+ },
187
+ body: JSON.stringify({
188
+ refreshToken: session.refreshToken
189
+ })
190
+ });
191
+ const text = await response.text();
192
+ const data = text ? safeJsonParse(text, text) : null;
193
+
194
+ if (!response.ok) {
195
+ throw new Error(redactText(`Resource Optimiser refresh-token failed with HTTP ${response.status}.`));
196
+ }
197
+
198
+ const refreshed = extractRoSession(data, response.headers);
199
+ if (!refreshed?.accessToken) {
200
+ throw new Error(redactText(`Resource Optimiser refresh-token did not return a usable API token. Status: ${response.status}. JSON keys: ${collectJsonKeyPaths(data).join(', ') || '(none)'}`));
201
+ }
202
+
203
+ return {
204
+ accessToken: refreshed.accessToken,
205
+ refreshToken: refreshed.refreshToken || session.refreshToken
206
+ };
207
+ }
208
+
209
+ function assertSecureUrl(rawUrl, label) {
210
+ let url;
211
+ try {
212
+ url = new URL(rawUrl);
213
+ } catch {
214
+ throw new Error(`${label} must be a valid URL.`);
215
+ }
216
+
217
+ if (!['http:', 'https:'].includes(url.protocol)) {
218
+ throw new Error(`${label} must use HTTP or HTTPS.`);
219
+ }
220
+
221
+ if (url.protocol === 'http:' && !isLocalHttpHost(url.hostname)) {
222
+ throw new Error(`${label} must use HTTPS unless it points to localhost or loopback.`);
223
+ }
224
+
225
+ return url;
226
+ }
227
+
228
+ function originFromUrl(rawUrl) {
229
+ return assertSecureUrl(rawUrl, 'Origin URL').origin;
230
+ }
231
+
232
+ function isLocalHttpHost(hostname) {
233
+ const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
234
+ return host === 'localhost' ||
235
+ host === '127.0.0.1' ||
236
+ host === '::1' ||
237
+ host.startsWith('127.');
238
+ }
239
+
240
+ export function normalizeStoredAuthSession(rawValue) {
241
+ if (!rawValue) {
242
+ return null;
243
+ }
244
+
245
+ const value = String(rawValue).trim();
246
+ if (!value) {
247
+ return null;
248
+ }
249
+
250
+ try {
251
+ return normalizeAuthSession(JSON.parse(value));
252
+ } catch {
253
+ if (value.split('.').length >= 3) {
254
+ return {
255
+ accessToken: value,
256
+ refreshToken: null,
257
+ legacy: true
258
+ };
259
+ }
260
+ return null;
261
+ }
262
+ }
263
+
264
+ export function normalizeAuthSession(value) {
265
+ if (typeof value === 'string') {
266
+ return {
267
+ accessToken: value,
268
+ refreshToken: null
269
+ };
270
+ }
271
+
272
+ if (!value || typeof value !== 'object') {
273
+ return {
274
+ accessToken: null,
275
+ refreshToken: null
276
+ };
277
+ }
278
+
279
+ return {
280
+ accessToken: value.accessToken || value.token || null,
281
+ refreshToken: value.refreshToken || value.refresh_token || null
282
+ };
283
+ }
284
+
285
+ export function serializeAuthSession(session) {
286
+ return JSON.stringify({
287
+ accessToken: session.accessToken,
288
+ refreshToken: session.refreshToken || null
289
+ });
290
+ }
291
+
292
+ async function validateAndStoreSession(session, storage) {
293
+ if (!session?.accessToken || isJwtExpired(session.accessToken)) {
294
+ throw new Error('Resource Optimiser auth did not return a valid API token.');
295
+ }
296
+
297
+ getUserIdFromJwt(session.accessToken);
298
+ await storage.set(serializeAuthSession(session));
299
+ }
300
+
301
+ function collectJsonKeyPaths(value, path = '', seen = new Set(), output = []) {
302
+ if (!value || typeof value !== 'object' || seen.has(value)) {
303
+ return output;
304
+ }
305
+ seen.add(value);
306
+
307
+ if (Array.isArray(value)) {
308
+ value.slice(0, 5).forEach((item, index) => {
309
+ collectJsonKeyPaths(item, `${path}[${index}]`, seen, output);
310
+ });
311
+ return output;
312
+ }
313
+
314
+ for (const key of Object.keys(value)) {
315
+ const nextPath = path ? `${path}.${key}` : key;
316
+ output.push(nextPath);
317
+ if (output.length >= 40) {
318
+ return output;
319
+ }
320
+ collectJsonKeyPaths(value[key], nextPath, seen, output);
321
+ }
322
+ return output;
323
+ }
@@ -0,0 +1,145 @@
1
+ const DAY_HEADING_RE = /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+(\d{2})\s+([A-Za-z]{3})\s+(\d{4})$/;
2
+ const ENTRY_RE = /^\+(\d+(?:\.\d+)?)\s+(.+)$/;
3
+ const TICKET_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
4
+
5
+ const MONTHS = new Map([
6
+ ['jan', 1],
7
+ ['feb', 2],
8
+ ['mar', 3],
9
+ ['apr', 4],
10
+ ['may', 5],
11
+ ['jun', 6],
12
+ ['jul', 7],
13
+ ['aug', 8],
14
+ ['sep', 9],
15
+ ['oct', 10],
16
+ ['nov', 11],
17
+ ['dec', 12]
18
+ ]);
19
+
20
+ export function parseWeeklyLogText(text) {
21
+ if (typeof text !== 'string' || text.trim() === '') {
22
+ throw new Error('text must be a non-empty weekly log block.');
23
+ }
24
+
25
+ const entries = [];
26
+ const errors = [];
27
+ let currentDate = null;
28
+ let entryIndex = 0;
29
+ const lines = text.split(/\r?\n/);
30
+
31
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
32
+ const lineNumber = lineIndex + 1;
33
+ const line = lines[lineIndex].trim();
34
+
35
+ if (!line) {
36
+ continue;
37
+ }
38
+
39
+ const heading = DAY_HEADING_RE.exec(line);
40
+ if (heading) {
41
+ currentDate = parseHeadingDate(heading, lineNumber, errors);
42
+ continue;
43
+ }
44
+
45
+ const entry = ENTRY_RE.exec(line);
46
+ if (!entry) {
47
+ errors.push({
48
+ line: lineNumber,
49
+ message: `Invalid line format: ${line}`
50
+ });
51
+ continue;
52
+ }
53
+
54
+ if (!currentDate) {
55
+ errors.push({
56
+ line: lineNumber,
57
+ message: 'Log entry appears before a valid date heading.'
58
+ });
59
+ continue;
60
+ }
61
+
62
+ const hours = Number(entry[1]);
63
+ if (!Number.isFinite(hours) || hours <= 0) {
64
+ errors.push({
65
+ line: lineNumber,
66
+ message: `Invalid hours value: ${entry[1]}`
67
+ });
68
+ continue;
69
+ }
70
+
71
+ const text = entry[2].trim();
72
+ const { taskName, description, taskExplicit } = splitTaskAndDescription(text);
73
+ if (!taskName) {
74
+ errors.push({
75
+ line: lineNumber,
76
+ message: `Worklog task name is empty before "|": ${line}`
77
+ });
78
+ continue;
79
+ }
80
+ entryIndex += 1;
81
+ entries.push({
82
+ id: `${currentDate}-${String(entryIndex).padStart(2, '0')}`,
83
+ date: currentDate,
84
+ hours,
85
+ taskName,
86
+ description,
87
+ taskExplicit,
88
+ text,
89
+ tickets: [...new Set(text.match(TICKET_RE) || [])],
90
+ line: lineNumber
91
+ });
92
+ }
93
+
94
+ return { entries, errors };
95
+ }
96
+
97
+ // Entry text format: "<worklog task> | <description>".
98
+ // Without "|", the whole text is kept as taskName and description stays empty;
99
+ // batch-workflow later decides whether that text is a catalog task name or a
100
+ // description that needs a default worklog task.
101
+ export function splitTaskAndDescription(text) {
102
+ const value = String(text || '').trim();
103
+ const separatorIndex = value.indexOf('|');
104
+ if (separatorIndex === -1) {
105
+ return { taskName: value, description: '', taskExplicit: false };
106
+ }
107
+ return {
108
+ taskName: value.slice(0, separatorIndex).trim(),
109
+ description: value.slice(separatorIndex + 1).trim(),
110
+ taskExplicit: true
111
+ };
112
+ }
113
+
114
+ function parseHeadingDate(match, lineNumber, errors) {
115
+ const day = Number(match[2]);
116
+ const month = MONTHS.get(match[3].toLowerCase());
117
+ const year = Number(match[4]);
118
+
119
+ if (!month) {
120
+ errors.push({
121
+ line: lineNumber,
122
+ message: `Unsupported month: ${match[3]}`
123
+ });
124
+ return null;
125
+ }
126
+
127
+ const date = new Date(Date.UTC(year, month - 1, day));
128
+ if (
129
+ date.getUTCFullYear() !== year ||
130
+ date.getUTCMonth() !== month - 1 ||
131
+ date.getUTCDate() !== day
132
+ ) {
133
+ errors.push({
134
+ line: lineNumber,
135
+ message: `Invalid date heading: ${match[0]}`
136
+ });
137
+ return null;
138
+ }
139
+
140
+ return [
141
+ String(year).padStart(4, '0'),
142
+ String(month).padStart(2, '0'),
143
+ String(day).padStart(2, '0')
144
+ ].join('-');
145
+ }