logwork-cli 1.1.0 → 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 (70) hide show
  1. package/README.md +35 -9
  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 +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -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-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -4
@@ -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
  }
package/lib/keychain.mjs CHANGED
@@ -41,16 +41,9 @@ export function createKeychainValueStorage({
41
41
  },
42
42
 
43
43
  async set(token) {
44
- await run([
45
- 'add-generic-password',
46
- '-U',
47
- '-s',
48
- service,
49
- '-a',
50
- account,
51
- '-w',
52
- token
53
- ]);
44
+ // The value goes in on stdin (security's interactive mode, as hex), never
45
+ // in argv where `ps` could show it.
46
+ await run(['-i'], { input: formatAddPasswordCommand({ service, account, value: token }) });
54
47
  },
55
48
 
56
49
  async delete() {
@@ -67,11 +60,25 @@ export function createKeychainValueStorage({
67
60
  };
68
61
  }
69
62
 
70
- function runSecurity(args) {
63
+ export function formatAddPasswordCommand({ service, account, value }) {
64
+ for (const [label, text] of [['service', service], ['account', account]]) {
65
+ if (!/^[A-Za-z0-9._@+-]+$/.test(String(text))) {
66
+ throw new Error(`Unsupported Keychain ${label} name: ${text}`);
67
+ }
68
+ }
69
+ const hex = Buffer.from(String(value), 'utf8').toString('hex');
70
+ return `add-generic-password -U -s "${service}" -a "${account}" -X ${hex}\n`;
71
+ }
72
+
73
+ function runSecurity(args, { input } = {}) {
71
74
  return new Promise((resolve, reject) => {
72
75
  const child = spawn('/usr/bin/security', args, {
73
- stdio: ['ignore', 'pipe', 'pipe']
76
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe']
74
77
  });
78
+ if (input !== undefined) {
79
+ child.stdin.on('error', () => {});
80
+ child.stdin.end(input);
81
+ }
75
82
 
76
83
  let stdout = '';
77
84
  let stderr = '';
@@ -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
  ];