logwork-cli 1.1.1 → 1.2.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 (64) hide show
  1. package/README.md +23 -7
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +50 -16
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +56 -14
  11. package/docs/operations-guide.md +35 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-workflow.mjs +187 -2
  25. package/lib/commit-workflow.mjs +188 -21
  26. package/lib/config.mjs +7 -0
  27. package/lib/diagnostics.mjs +53 -4
  28. package/lib/jira-api.mjs +25 -3
  29. package/lib/jira-workflow.mjs +36 -6
  30. package/lib/logwork-config.mjs +64 -13
  31. package/lib/manual-apply-state.mjs +15 -0
  32. package/lib/manual-commands.mjs +2 -2
  33. package/lib/manual-ink-app.mjs +108 -1281
  34. package/lib/manual-ink-draft-actions.mjs +227 -0
  35. package/lib/manual-ink-interaction.mjs +306 -0
  36. package/lib/manual-ink-session.mjs +41 -0
  37. package/lib/manual-ink-task-editor.mjs +338 -0
  38. package/lib/manual-ink-wizard.mjs +520 -0
  39. package/lib/manual-mcp.mjs +27 -15
  40. package/lib/manual-repl.mjs +39 -3
  41. package/lib/mcp-server.mjs +51 -20
  42. package/lib/node-runtime.mjs +115 -0
  43. package/lib/paths.mjs +7 -0
  44. package/lib/project-mapping-workflow.mjs +30 -7
  45. package/lib/query-workflow.mjs +0 -1
  46. package/lib/reconciliation-workflow.mjs +1 -1
  47. package/lib/reminder-config.mjs +38 -2
  48. package/lib/reminder-scheduler.mjs +54 -3
  49. package/lib/reminder-service.mjs +28 -3
  50. package/lib/reminder-workflow.mjs +118 -22
  51. package/lib/ro-api-diagnostics.mjs +109 -0
  52. package/lib/ro-api-entries.mjs +359 -0
  53. package/lib/ro-api-fields.mjs +290 -0
  54. package/lib/ro-api-http.mjs +68 -0
  55. package/lib/ro-api-projects.mjs +137 -0
  56. package/lib/ro-api-tasks.mjs +50 -0
  57. package/lib/ro-api-timesheet.mjs +413 -0
  58. package/lib/ro-delete-workflow.mjs +2 -1
  59. package/lib/ro-entry-reader.mjs +11 -6
  60. package/lib/ro-resubmit-workflow.mjs +2 -1
  61. package/lib/terminal-auth-prompts.mjs +7 -4
  62. package/lib/ui.mjs +80 -59
  63. package/package.json +11 -4
  64. package/skills/logwork/SKILL.md +5 -4
@@ -1,4 +1,3 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { constants as fsConstants } from 'node:fs';
3
2
  import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
4
3
  import { EOL, homedir, platform, release, type } from 'node:os';
@@ -11,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
11
10
  import { redactText } from './auth-redaction.mjs';
12
11
  import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
13
12
  import { configPath, loadLocalConfig } from './logwork-config.mjs';
14
- import { helperHome, manualDraftsPath, userConfigPath } from './paths.mjs';
13
+ import { formatNodePathIssue, inspectNodePath, loadRuntimeInfo } from './node-runtime.mjs';
14
+ import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
15
+ import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
15
16
 
16
17
  const DIAGNOSTICS_DIR_NAME = 'diagnostics';
17
18
  const MAX_AUTH_DEBUG_CHARS = 20_000;
@@ -35,6 +36,7 @@ export async function generateDiagnosticsReport({
35
36
  commandFinder = findCommandOnPath,
36
37
  authStatusFn = getStoredAuthStatus,
37
38
  mcpSmokeFn = runMcpSmoke,
39
+ nodeRuntimeFn = collectNodeRuntimeCheck,
38
40
  readFileFn = readFile,
39
41
  statFn = stat,
40
42
  writeFileFn = writeFile,
@@ -47,6 +49,8 @@ export async function generateDiagnosticsReport({
47
49
  const fileChecks = await collectFileChecks({ statFn, cwd });
48
50
  const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
49
51
  const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
52
+ const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
53
+ const nodeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
50
54
  const authDebug = await readSanitizedAuthDebug({ readFileFn });
51
55
 
52
56
  sections.push(formatSection('logwork-cli Diagnostics', [
@@ -64,6 +68,17 @@ export async function generateDiagnosticsReport({
64
68
  `home: ${homedir()}`
65
69
  ]));
66
70
 
71
+ sections.push(formatSection('Node Path', nodeRuntime?.ok === false
72
+ ? formatObjectLines(nodeRuntime)
73
+ : [
74
+ `runtimeFile: ${nodeRuntime.runtimeFile}`,
75
+ `recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
76
+ `recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
77
+ `reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
78
+ `reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
79
+ ...(nodeIssues.length ? nodeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
80
+ ]));
81
+
67
82
  sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
68
83
  sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
69
84
  sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
@@ -78,7 +93,41 @@ export async function generateDiagnosticsReport({
78
93
  return {
79
94
  path: outputPath,
80
95
  report,
81
- summary: `Diagnostics report written to ${outputPath}`
96
+ nodeIssues: nodeIssues.map((issue) => sanitizeReport(issue)),
97
+ summary: [
98
+ ...nodeIssues.map((issue) => sanitizeReport(`Warning: ${issue}`)),
99
+ `Diagnostics report written to ${outputPath}`
100
+ ].join(EOL)
101
+ };
102
+ }
103
+
104
+ // Compares the node recorded by `logwork setup` (runtime.json) and the node in
105
+ // the installed reminder schedule with the node running now. Read-only.
106
+ export async function collectNodeRuntimeCheck({
107
+ loadRuntime = loadRuntimeInfo,
108
+ readSchedulerNodePath = readReminderSchedulerNodePath,
109
+ inspect = inspectNodePath
110
+ } = {}) {
111
+ const runtime = await loadRuntime().catch(() => null);
112
+ const reminderNode = await readSchedulerNodePath().catch(() => null);
113
+ const recorded = runtime?.nodePath
114
+ ? await inspect(runtime.nodePath)
115
+ : { status: 'unknown' };
116
+ const reminder = reminderNode
117
+ ? await inspect(reminderNode)
118
+ : { status: 'unknown' };
119
+ const issues = [
120
+ formatNodePathIssue('runtime', recorded),
121
+ formatNodePathIssue('reminder', reminder)
122
+ ].filter(Boolean);
123
+ return {
124
+ runtimeFile: runtimeInfoPath(),
125
+ recordedNode: runtime?.nodePath || '',
126
+ recordedVersion: runtime?.nodeVersion || '',
127
+ recordedStatus: recorded.status,
128
+ reminderNode: reminderNode || '',
129
+ reminderStatus: reminder.status,
130
+ issues
82
131
  };
83
132
  }
84
133
 
@@ -202,7 +251,7 @@ async function safeCollect(label, fn) {
202
251
  }
203
252
  }
204
253
 
205
- export async function runMcpSmoke({ cwd = process.cwd(), timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
254
+ export async function runMcpSmoke({ timeoutMs = MCP_SMOKE_TIMEOUT_MS } = {}) {
206
255
  const client = new Client({
207
256
  name: 'logwork-diagnostics',
208
257
  version: '0.1.0'
package/lib/jira-api.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { CONFIG } from './config.mjs';
1
+ import { CONFIG, isDryRun } from './config.mjs';
2
2
  import { ApiError } from './api.mjs';
3
3
  import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
4
4
  import { safeJsonParse } from './util.mjs';
@@ -15,6 +15,11 @@ export async function jiraApiFetch(token, path, {
15
15
  } = {}) {
16
16
  assertJiraToken(token);
17
17
  const url = buildJiraUrl(baseUrl, path);
18
+ // Every Jira write goes through here, so this one gate covers worklog create
19
+ // and any write added later. The result never carries the token or headers.
20
+ if (!isIdempotentMethod(method) && isDryRun()) {
21
+ return createJiraDryRunRequest(method, path, body);
22
+ }
18
23
  const response = await fetchWithPolicy(url.toString(), {
19
24
  fetchImpl,
20
25
  timeoutMs,
@@ -123,12 +128,29 @@ export async function addJiraIssueWorklog(token, issueKey, worklog, options = {}
123
128
  assertIssueKey(issueKey);
124
129
  const qs = new URLSearchParams({ adjustEstimate: 'leave' });
125
130
  const path = `/rest/api/2/issue/${encodeURIComponent(issueKey)}/worklog?${qs}`;
126
- return normalizeJiraWorklog(await jiraApiFetch(token, path, {
131
+ const data = await jiraApiFetch(token, path, {
127
132
  ...options,
128
133
  method: 'POST',
129
134
  retries: 0,
130
135
  body: worklog
131
- }));
136
+ });
137
+ return isJiraDryRunResult(data) ? data : normalizeJiraWorklog(data);
138
+ }
139
+
140
+ export function isJiraDryRunResult(value) {
141
+ return value?.dryRun === true;
142
+ }
143
+
144
+ function createJiraDryRunRequest(method, path, body) {
145
+ const request = {
146
+ dryRun: true,
147
+ method: String(method).toUpperCase(),
148
+ path: String(path)
149
+ };
150
+ if (body !== undefined) {
151
+ request.body = structuredClone(body);
152
+ }
153
+ return request;
132
154
  }
133
155
 
134
156
  export function normalizeJiraIssue(issue = {}) {
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
2
2
  import { CONFIG } from './config.mjs';
3
3
  import { recordApplyResult, recordApplyResultSafely } from './apply-ledger.mjs';
4
4
  import { parseWeeklyLogText } from './batch-parser.mjs';
5
- import { ApiError } from './api.mjs';
5
+ import { ApiError } from './ro-api-http.mjs';
6
6
  import {
7
7
  addJiraIssueWorklog,
8
8
  getJiraIssue,
9
- getJiraIssueWorklogs
9
+ getJiraIssueWorklogs,
10
+ isJiraDryRunResult
10
11
  } from './jira-api.mjs';
11
12
  import {
12
13
  createJiraAuthRequiredError,
@@ -148,6 +149,25 @@ export async function applyJiraWorklogBatch({
148
149
  const payload = entry.worklog || buildJiraWorklogPayload(entry);
149
150
  try {
150
151
  const worklog = await submitter(entry.issueKey, payload);
152
+ if (isJiraDryRunResult(worklog)) {
153
+ // LOGWORK_DRY_RUN=1: Jira was not called. Keep only the request so the
154
+ // caller can see what apply would send.
155
+ results.push({
156
+ entryId: entry.id,
157
+ issueKey: entry.issueKey,
158
+ date: entry.date,
159
+ hours: entry.hours,
160
+ taskName: entry.taskName,
161
+ status: 'dry_run',
162
+ dryRun: true,
163
+ request: {
164
+ method: worklog.method,
165
+ path: worklog.path,
166
+ body: worklog.body
167
+ }
168
+ });
169
+ continue;
170
+ }
151
171
  results.push({
152
172
  entryId: entry.id,
153
173
  issueKey: entry.issueKey,
@@ -173,9 +193,13 @@ export async function applyJiraWorklogBatch({
173
193
  }
174
194
 
175
195
  const failed = results.some((result) => result.status === 'failed');
196
+ const dryRun = results.some((result) => result.dryRun);
176
197
  const output = {
177
198
  batchId: batch.batchId,
178
- status: failed ? (results.some((result) => result.status === 'submitted') ? 'partial_failed' : 'failed') : 'submitted',
199
+ status: failed
200
+ ? (results.some((result) => result.status === 'submitted') ? 'partial_failed' : 'failed')
201
+ : (dryRun ? 'dry_run' : 'submitted'),
202
+ dryRun,
179
203
  results,
180
204
  summary: formatJiraApplySummary(results)
181
205
  };
@@ -395,9 +419,15 @@ function formatJiraApplyBlockedSummary(blockedEntries) {
395
419
  function formatJiraApplySummary(results) {
396
420
  const submitted = results.filter((result) => result.status === 'submitted');
397
421
  const failed = results.filter((result) => result.status === 'failed');
398
- const lines = [
399
- `Jira worklog apply: submitted ${submitted.length}, failed ${failed.length}.`
400
- ];
422
+ const dryRun = results.filter((result) => result.dryRun);
423
+ const lines = dryRun.length
424
+ ? [
425
+ 'DRY RUN (LOGWORK_DRY_RUN=1): Jira worklog requests generated; nothing was written to Jira.',
426
+ `Jira worklog dry run: ${dryRun.length} request${dryRun.length === 1 ? '' : 's'}, submitted ${submitted.length}, failed ${failed.length}.`
427
+ ]
428
+ : [
429
+ `Jira worklog apply: submitted ${submitted.length}, failed ${failed.length}.`
430
+ ];
401
431
  for (const result of results) {
402
432
  lines.push(`- ${result.date}: ${result.issueKey} +${formatHours(result.hours)}h -> ${result.status}${result.error ? ` (${result.error})` : ''}`);
403
433
  }
@@ -1,4 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
2
4
  import {
3
5
  legacyUserConfigPath,
4
6
  projectConfigPath,
@@ -29,6 +31,7 @@ export async function upsertProjectMappingConfig({
29
31
  project,
30
32
  tickets = [],
31
33
  keywords = [],
34
+ repos = [],
32
35
  lockOptions = {}
33
36
  }) {
34
37
  const path = scope === 'project' ? projectConfigPath(cwd) : userConfigPath();
@@ -39,25 +42,19 @@ export async function upsertProjectMappingConfig({
39
42
  normalizeConfig(await readRawConfigPath(legacyUserConfigPath())),
40
43
  normalizeConfig(await readRawConfigPath(path))
41
44
  );
42
- const mapping = {
45
+ const mapping = withRepos({
43
46
  projectName: project.projectName,
44
47
  projectMemberId: project.projectMemberId,
45
48
  tickets: normalizeTickets(tickets),
46
49
  keywords: normalizeKeywords(keywords)
47
- };
50
+ }, normalizeRepos(repos));
48
51
  const existingIndex = config.projectMappings.findIndex((candidate) => (
49
52
  sameProjectIdentity(candidate, mapping)
50
53
  ));
51
54
 
52
55
  const projectMappings = [...config.projectMappings];
53
56
  if (existingIndex >= 0) {
54
- const existing = projectMappings[existingIndex];
55
- projectMappings[existingIndex] = {
56
- projectName: mapping.projectName || existing.projectName,
57
- projectMemberId: mapping.projectMemberId ?? existing.projectMemberId,
58
- tickets: uniqueStrings([...existing.tickets, ...mapping.tickets], { uppercase: true }),
59
- keywords: uniqueStrings([...existing.keywords, ...mapping.keywords])
60
- };
57
+ projectMappings[existingIndex] = mergeMapping(projectMappings[existingIndex], mapping);
61
58
  } else {
62
59
  projectMappings.push(mapping);
63
60
  }
@@ -133,21 +130,75 @@ function mappingKey(mapping) {
133
130
  }
134
131
 
135
132
  function normalizeMapping(mapping) {
136
- return {
133
+ return withRepos({
137
134
  projectName: stringOrEmpty(mapping.projectName),
138
135
  projectMemberId: optionalId(mapping.projectMemberId),
139
136
  tickets: normalizeTickets(mapping.tickets),
140
137
  keywords: normalizeKeywords(mapping.keywords)
141
- };
138
+ }, normalizeRepos(mapping.repos));
142
139
  }
143
140
 
144
141
  function mergeMapping(existing, next) {
145
- return {
142
+ return withRepos({
146
143
  projectName: next.projectName || existing.projectName,
147
144
  projectMemberId: next.projectMemberId ?? existing.projectMemberId,
148
145
  tickets: uniqueStrings([...existing.tickets, ...next.tickets], { uppercase: true }),
149
146
  keywords: uniqueStrings([...existing.keywords, ...next.keywords])
150
- };
147
+ }, normalizeRepos([...(existing.repos || []), ...(next.repos || [])]));
148
+ }
149
+
150
+ // repos is optional: a mapping without repos keeps its old shape on disk.
151
+ function withRepos(mapping, repos) {
152
+ return repos.length ? { ...mapping, repos } : mapping;
153
+ }
154
+
155
+ // Git repo roots are stored resolved and without trailing separators. Windows
156
+ // paths compare case-insensitively (see repoPathKey).
157
+ export function normalizeRepoPath(value, platform = process.platform) {
158
+ const text = typeof value === 'string' ? value.trim() : '';
159
+ if (!text) {
160
+ return '';
161
+ }
162
+ const pathApi = platform === 'win32' ? path.win32 : path.posix;
163
+ const expanded = text === '~' || /^~[\\/]/.test(text) ? `${homedir()}${text.slice(1)}` : text;
164
+ const resolved = pathApi.resolve(expanded);
165
+ const root = pathApi.parse(resolved).root;
166
+ let trimmed = resolved;
167
+ while (trimmed.length > root.length && /[\\/]$/.test(trimmed)) {
168
+ trimmed = trimmed.slice(0, -1);
169
+ }
170
+ return trimmed;
171
+ }
172
+
173
+ export function repoPathKey(value, platform = process.platform) {
174
+ const normalized = normalizeRepoPath(value, platform);
175
+ return platform === 'win32' ? normalized.toLowerCase() : normalized;
176
+ }
177
+
178
+ export function normalizeRepos(value, platform = process.platform) {
179
+ const seen = new Set();
180
+ const results = [];
181
+ for (const item of stringArray(value)) {
182
+ const normalized = normalizeRepoPath(item, platform);
183
+ const key = repoPathKey(normalized, platform);
184
+ if (!normalized || seen.has(key)) {
185
+ continue;
186
+ }
187
+ seen.add(key);
188
+ results.push(normalized);
189
+ }
190
+ return results;
191
+ }
192
+
193
+ // The mapping whose repos list contains this git repo root, if any.
194
+ export function findRepoMapping(mappings, repoRoot, platform = process.platform) {
195
+ const key = repoPathKey(repoRoot, platform);
196
+ if (!key) {
197
+ return null;
198
+ }
199
+ return (mappings || []).find((mapping) => (
200
+ (mapping.repos || []).some((repo) => repoPathKey(repo, platform) === key)
201
+ )) || null;
151
202
  }
152
203
 
153
204
  function stringOrEmpty(value) {
@@ -30,6 +30,21 @@ export function manualUnbookedEntryCount(preview) {
30
30
  return preview?.unbookedEntries?.length || 0;
31
31
  }
32
32
 
33
+ export function buildManualDuplicateConfirmation(preview) {
34
+ const duplicates = (preview?.entries || []).filter((entry) => entry.possibleDuplicate);
35
+ const list = duplicates.map((entry) => {
36
+ const duplicate = entry.possibleDuplicate;
37
+ return duplicate.source === 'batch'
38
+ ? `${entry.id} repeats ${duplicate.entryId}`
39
+ : `${entry.id} matches RO logwork ${duplicate.logworkId ?? '(no id)'}`;
40
+ }).join(', ');
41
+ return `This preview contains ${duplicates.length} possible duplicate entries (${list}). Submit them anyway?`;
42
+ }
43
+
44
+ export function manualDuplicateEntryCount(preview) {
45
+ return (preview?.entries || []).filter((entry) => entry.possibleDuplicate).length;
46
+ }
47
+
33
48
  export function totalPreviewHours(preview) {
34
49
  return (preview?.entries || []).reduce((sum, entry) => sum + Number(entry.hours || 0), 0);
35
50
  }
@@ -74,8 +74,8 @@ export const MANUAL_COMMANDS = [
74
74
  },
75
75
  {
76
76
  name: '/map',
77
- description: 'Map ticket prefix to projectMemberId',
78
- usage: '/map SCB 5234',
77
+ description: 'Map ticket prefix or git repo to projectMemberId',
78
+ usage: '/map SCB 5234 | /map --repo [path] 5234',
79
79
  aliases: []
80
80
  }
81
81
  ];