logwork-cli 1.1.1 → 1.3.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 +53 -25
  2. package/RELEASE.md +4 -2
  3. package/bin/logwork.mjs +2 -2
  4. package/commands/commits.mjs +128 -13
  5. package/commands/history.mjs +0 -4
  6. package/commands/mcp.mjs +266 -0
  7. package/commands/quick.mjs +38 -23
  8. package/commands/reminder.mjs +41 -2
  9. package/commands/setup.mjs +197 -42
  10. package/commands/tui.mjs +1 -0
  11. package/docs/advanced.md +18 -7
  12. package/docs/mcp-setup.md +120 -26
  13. package/docs/operations-guide.md +50 -2
  14. package/docs/recipes.md +40 -0
  15. package/docs/security.md +23 -2
  16. package/examples/mcp/antigravity.mcp_config.json +2 -2
  17. package/examples/mcp/codex.config.toml +2 -2
  18. package/examples/mcp/copilot.mcp.json +2 -2
  19. package/lib/api-auth-forms.mjs +393 -0
  20. package/lib/api-auth-http.mjs +184 -0
  21. package/lib/api-auth-support.mjs +101 -0
  22. package/lib/api-auth-tokens.mjs +129 -0
  23. package/lib/api-auth.mjs +39 -813
  24. package/lib/api.mjs +38 -1422
  25. package/lib/apply-ledger.mjs +1 -1
  26. package/lib/batch-workflow.mjs +187 -2
  27. package/lib/commit-workflow.mjs +345 -23
  28. package/lib/config.mjs +7 -0
  29. package/lib/diagnostics.mjs +116 -4
  30. package/lib/jira-api.mjs +25 -3
  31. package/lib/jira-workflow.mjs +36 -6
  32. package/lib/launcher.mjs +303 -0
  33. package/lib/logwork-config.mjs +64 -13
  34. package/lib/manual-apply-state.mjs +15 -0
  35. package/lib/manual-commands.mjs +2 -2
  36. package/lib/manual-ink-app.mjs +142 -1282
  37. package/lib/manual-ink-draft-actions.mjs +227 -0
  38. package/lib/manual-ink-interaction.mjs +318 -0
  39. package/lib/manual-ink-session.mjs +41 -0
  40. package/lib/manual-ink-task-editor.mjs +338 -0
  41. package/lib/manual-ink-ui.mjs +38 -0
  42. package/lib/manual-ink-wizard.mjs +520 -0
  43. package/lib/manual-mcp.mjs +78 -20
  44. package/lib/manual-repl.mjs +171 -22
  45. package/lib/mcp-install.mjs +930 -0
  46. package/lib/mcp-server.mjs +51 -20
  47. package/lib/node-runtime.mjs +155 -0
  48. package/lib/paths.mjs +7 -0
  49. package/lib/project-mapping-workflow.mjs +32 -27
  50. package/lib/query-workflow.mjs +19 -1
  51. package/lib/reconciliation-workflow.mjs +1 -1
  52. package/lib/reminder-config.mjs +38 -2
  53. package/lib/reminder-scheduler.mjs +85 -18
  54. package/lib/reminder-service.mjs +42 -3
  55. package/lib/reminder-workflow.mjs +118 -22
  56. package/lib/repo-project-suggestions.mjs +285 -0
  57. package/lib/ro-api-diagnostics.mjs +109 -0
  58. package/lib/ro-api-entries.mjs +359 -0
  59. package/lib/ro-api-fields.mjs +290 -0
  60. package/lib/ro-api-http.mjs +68 -0
  61. package/lib/ro-api-projects.mjs +137 -0
  62. package/lib/ro-api-tasks.mjs +50 -0
  63. package/lib/ro-api-timesheet.mjs +413 -0
  64. package/lib/ro-delete-workflow.mjs +2 -1
  65. package/lib/ro-entry-reader.mjs +11 -6
  66. package/lib/ro-resubmit-workflow.mjs +2 -1
  67. package/lib/terminal-auth-prompts.mjs +7 -4
  68. package/lib/ui.mjs +121 -56
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +10 -4
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
  }
@@ -0,0 +1,303 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants, promises as fs } from 'node:fs';
3
+ import { posix, win32 } from 'node:path';
4
+ import { atomicWriteFile } from './atomic-file.mjs';
5
+ import { helperHome } from './paths.mjs';
6
+
7
+ // IDE MCP configs and the reminder schedule must keep working after nvm,
8
+ // Homebrew or a Node installer upgrade, without the user editing anything.
9
+ //
10
+ // - macOS/Linux: `logwork setup` writes <home>/bin/logwork-launch, a POSIX sh
11
+ // script that finds a usable Node 22+ at start time and runs
12
+ // <home>/bin/logwork.mjs with it. Configs point at the script, which never
13
+ // moves.
14
+ // - Windows: GUI clients would need `cmd /c` for a .cmd launcher, so configs
15
+ // use a stable node.exe instead: the nvm-windows symlink when Node runs from
16
+ // an nvm-windows version folder, otherwise the running node.exe (usually
17
+ // C:\Program Files\nodejs\node.exe, which an installer upgrade keeps).
18
+
19
+ export const LAUNCHER_FILE_NAME = 'logwork-launch';
20
+ export const MIN_NODE_MAJOR = 22;
21
+ // Makes the launcher print the Node it would use and exit, without starting
22
+ // logwork. Used by `logwork doctor`.
23
+ export const LAUNCHER_PRINT_NODE_ENV = 'LOGWORK_LAUNCHER_PRINT_NODE';
24
+ const ENTRY_RELATIVE = ['bin', 'logwork.mjs'];
25
+
26
+ export function launcherPath(home = helperHome()) {
27
+ return posix.join(home, 'bin', LAUNCHER_FILE_NAME);
28
+ }
29
+
30
+ export function entryPath(home = helperHome(), platform = process.platform) {
31
+ return pathApi(platform).join(home, ...ENTRY_RELATIVE);
32
+ }
33
+
34
+ // The command every MCP config and the reminder schedule run. MCP args are
35
+ // [...argsPrefix, 'mcp'].
36
+ // stable: true when the command survives a Node upgrade without re-running
37
+ // `logwork setup`.
38
+ export function resolveLaunchCommand({
39
+ platform = process.platform,
40
+ home = helperHome(),
41
+ execPath = process.execPath,
42
+ env = process.env
43
+ } = {}) {
44
+ if (platform !== 'win32') {
45
+ return {
46
+ command: launcherPath(home),
47
+ argsPrefix: [],
48
+ kind: 'launcher',
49
+ stable: true
50
+ };
51
+ }
52
+ const entry = win32.join(home, ...ENTRY_RELATIVE);
53
+ const symlink = String(readEnv(env, 'NVM_SYMLINK') || '').trim();
54
+ if (symlink && isNvmWindowsNode(execPath, env)) {
55
+ return {
56
+ command: win32.join(symlink, 'node.exe'),
57
+ argsPrefix: [entry],
58
+ kind: 'nvm-symlink',
59
+ stable: true
60
+ };
61
+ }
62
+ return {
63
+ command: String(execPath),
64
+ argsPrefix: [entry],
65
+ kind: 'node',
66
+ stable: isStandardWindowsInstall(execPath)
67
+ };
68
+ }
69
+
70
+ export function mcpArgs(launch) {
71
+ return [...(launch?.argsPrefix || []), 'mcp'];
72
+ }
73
+
74
+ // Node inside an nvm-windows version folder, e.g. C:\Users\me\AppData\Roaming\nvm\v22.11.0\node.exe.
75
+ export function isNvmWindowsNode(execPath, env = process.env) {
76
+ const path = String(execPath || '').toLowerCase().replaceAll('/', '\\');
77
+ const nvmHome = String(readEnv(env, 'NVM_HOME') || '').toLowerCase().replaceAll('/', '\\').replace(/\\+$/, '');
78
+ if (nvmHome && path.startsWith(`${nvmHome}\\`)) {
79
+ return true;
80
+ }
81
+ return /\\nvm\\v\d+\.\d+\.\d+[^\\]*\\node\.exe$/.test(path);
82
+ }
83
+
84
+ // The official installer keeps node.exe at <Program Files>\nodejs\node.exe
85
+ // across upgrades.
86
+ export function isStandardWindowsInstall(execPath) {
87
+ return /\\nodejs\\node\.exe$/i.test(String(execPath || '').replaceAll('/', '\\'));
88
+ }
89
+
90
+ export function buildPosixLauncherScript({ home, nodePath }) {
91
+ const entry = posix.join(home, ...ENTRY_RELATIVE);
92
+ return `#!/bin/sh
93
+ # Generated by \`logwork setup\`; it is rewritten on every setup, so do not edit.
94
+ # Starts logwork with the first usable Node.js ${MIN_NODE_MAJOR}+, so IDE MCP configs and the
95
+ # reminder keep working after nvm, Homebrew or installer upgrades.
96
+ LOGWORK_ENTRY=${quoteSh(entry)}
97
+ LOGWORK_RECORDED_NODE=${quoteSh(nodePath || '')}
98
+
99
+ logwork_node_ok() {
100
+ [ -n "$1" ] && [ -f "$1" ] && [ -x "$1" ] || return 1
101
+ "$1" -e 'process.exit(+process.versions.node.split(".")[0]>=${MIN_NODE_MAJOR}?0:1)' >/dev/null 2>&1
102
+ }
103
+
104
+ logwork_run() {
105
+ logwork_node=$1
106
+ shift
107
+ if [ "\${${LAUNCHER_PRINT_NODE_ENV}:-}" = "1" ]; then
108
+ printf '%s\\n' "$logwork_node"
109
+ exit 0
110
+ fi
111
+ exec "$logwork_node" "$LOGWORK_ENTRY" "$@"
112
+ }
113
+
114
+ logwork_newest_nvm_node() {
115
+ logwork_best=''
116
+ logwork_best_key=0
117
+ for logwork_dir in "$@"; do
118
+ [ -n "$logwork_dir" ] && [ -d "$logwork_dir/versions/node" ] || continue
119
+ for logwork_candidate in "$logwork_dir"/versions/node/v*/bin/node; do
120
+ [ -x "$logwork_candidate" ] || continue
121
+ logwork_version=\${logwork_candidate%/bin/node}
122
+ logwork_version=\${logwork_version##*/v}
123
+ logwork_major=\${logwork_version%%.*}
124
+ logwork_rest=\${logwork_version#*.}
125
+ logwork_minor=\${logwork_rest%%.*}
126
+ logwork_patch=\${logwork_rest#*.}
127
+ logwork_patch=\${logwork_patch%%[!0-9]*}
128
+ case "$logwork_major.$logwork_minor.$logwork_patch" in
129
+ *[!0-9.]*|.*|*..*|*.) continue ;;
130
+ esac
131
+ [ "$logwork_major" -ge ${MIN_NODE_MAJOR} ] || continue
132
+ logwork_key=$((logwork_major * 1000000 + logwork_minor * 1000 + logwork_patch))
133
+ if [ "$logwork_key" -gt "$logwork_best_key" ]; then
134
+ logwork_best=$logwork_candidate
135
+ logwork_best_key=$logwork_key
136
+ fi
137
+ done
138
+ done
139
+ printf '%s' "$logwork_best"
140
+ }
141
+
142
+ # 1. The Node recorded by setup. Its path names its version, so an existing
143
+ # file is still that Node: skip the version check for a fast start.
144
+ if [ -n "$LOGWORK_RECORDED_NODE" ] && [ -f "$LOGWORK_RECORDED_NODE" ] && [ -x "$LOGWORK_RECORDED_NODE" ]; then
145
+ logwork_run "$LOGWORK_RECORDED_NODE" "$@"
146
+ fi
147
+
148
+ # 2. node on PATH.
149
+ logwork_candidate=$(command -v node 2>/dev/null || true)
150
+ if logwork_node_ok "$logwork_candidate"; then
151
+ logwork_run "$logwork_candidate" "$@"
152
+ fi
153
+
154
+ # 3. The newest nvm Node ${MIN_NODE_MAJOR}+.
155
+ logwork_candidate=$(logwork_newest_nvm_node "\${NVM_DIR:-}" "\${HOME:-}/.nvm")
156
+ if logwork_node_ok "$logwork_candidate"; then
157
+ logwork_run "$logwork_candidate" "$@"
158
+ fi
159
+
160
+ # 4. Volta and fnm defaults, then 5. Homebrew and /usr/local.
161
+ for logwork_candidate in \\
162
+ "\${HOME:-}/.volta/bin/node" \\
163
+ "\${HOME:-}/.local/share/fnm/aliases/default/bin/node" \\
164
+ "\${HOME:-}/Library/Application Support/fnm/aliases/default/bin/node" \\
165
+ /opt/homebrew/bin/node \\
166
+ /usr/local/bin/node
167
+ do
168
+ if logwork_node_ok "$logwork_candidate"; then
169
+ logwork_run "$logwork_candidate" "$@"
170
+ fi
171
+ done
172
+
173
+ echo "logwork: no usable Node.js ${MIN_NODE_MAJOR}+ found. Install Node.js ${MIN_NODE_MAJOR} or newer, or run \\\`logwork setup\\\` again." >&2
174
+ exit 127
175
+ `;
176
+ }
177
+
178
+ // Writes the launcher on macOS/Linux. A no-op on Windows (returns null).
179
+ export async function writeLauncher({
180
+ home = helperHome(),
181
+ nodePath = process.execPath,
182
+ platform = process.platform,
183
+ writeFile = atomicWriteFile,
184
+ chmod = fs.chmod
185
+ } = {}) {
186
+ if (platform === 'win32') {
187
+ return null;
188
+ }
189
+ const path = launcherPath(home);
190
+ await writeFile(path, buildPosixLauncherScript({ home, nodePath }), { mode: 0o755 });
191
+ // atomicWriteFile applies the mode through the umask; make it explicit.
192
+ await chmod(path, 0o755);
193
+ return { path, nodePath };
194
+ }
195
+
196
+ // Checks the command MCP configs and the reminder run. Read-only: it runs the
197
+ // command with `--version` (which prints the logwork version through
198
+ // bin/logwork.mjs) and, for the launcher, asks which Node it would pick.
199
+ export async function checkLaunchCommand(launch = resolveLaunchCommand(), {
200
+ platform = process.platform,
201
+ access = fs.access,
202
+ run = runProcess,
203
+ env = process.env
204
+ } = {}) {
205
+ const result = {
206
+ command: launch.command,
207
+ kind: launch.kind,
208
+ status: 'ok',
209
+ node: launch.kind === 'launcher' ? '' : launch.command,
210
+ version: ''
211
+ };
212
+ try {
213
+ await access(launch.command, fsConstants.F_OK);
214
+ } catch {
215
+ return { ...result, status: 'missing' };
216
+ }
217
+ if (platform !== 'win32') {
218
+ try {
219
+ await access(launch.command, fsConstants.X_OK);
220
+ } catch {
221
+ return { ...result, status: 'not_executable' };
222
+ }
223
+ }
224
+ const childEnv = withoutLogworkEnv(env);
225
+ if (launch.kind === 'launcher') {
226
+ const picked = await run(launch.command, [], { env: { ...childEnv, [LAUNCHER_PRINT_NODE_ENV]: '1' } }).catch((error) => ({ code: 1, stdout: '', stderr: error.message }));
227
+ if (picked.code !== 0) {
228
+ return { ...result, status: 'no_node', detail: firstLine(picked.stderr || picked.stdout) };
229
+ }
230
+ result.node = firstLine(picked.stdout);
231
+ }
232
+ const version = await run(launch.command, [...launch.argsPrefix, '--version'], { env: childEnv }).catch((error) => ({ code: 1, stdout: '', stderr: error.message }));
233
+ if (version.code !== 0) {
234
+ return { ...result, status: 'failed', detail: firstLine(version.stderr || version.stdout) };
235
+ }
236
+ result.version = firstLine(version.stdout);
237
+ return result;
238
+ }
239
+
240
+ export function formatLaunchCheckIssue(check) {
241
+ switch (check?.status) {
242
+ case 'missing':
243
+ return check.kind === 'launcher'
244
+ ? `Launcher: ${check.command} is missing, so IDE MCP configs and the reminder cannot start. Fix: run \`logwork setup\`.`
245
+ : `Node: ${check.command} no longer exists, so IDE MCP configs and the reminder cannot start. Fix: run \`logwork setup\`.`;
246
+ case 'not_executable':
247
+ return `Launcher: ${check.command} is not executable. Fix: run \`logwork setup\`.`;
248
+ case 'no_node':
249
+ return `Launcher: found no usable Node.js ${MIN_NODE_MAJOR}+. Fix: install Node.js ${MIN_NODE_MAJOR} or newer, then run \`logwork setup\`.`;
250
+ case 'failed':
251
+ return `Launcher: ${check.command} --version failed${check.detail ? ` (${check.detail})` : ''}. Fix: run \`logwork setup\`.`;
252
+ default:
253
+ return '';
254
+ }
255
+ }
256
+
257
+ export function runProcess(command, args, { env = process.env, cwd } = {}) {
258
+ return new Promise((resolveValue, reject) => {
259
+ const child = spawn(command, args, {
260
+ cwd,
261
+ env,
262
+ stdio: ['ignore', 'pipe', 'pipe'],
263
+ windowsHide: true
264
+ });
265
+ let stdout = '';
266
+ let stderr = '';
267
+ child.stdout.setEncoding('utf8');
268
+ child.stderr.setEncoding('utf8');
269
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
270
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
271
+ child.on('error', reject);
272
+ child.on('close', (code, signal) => resolveValue({ code: signal ? 1 : (code ?? 1), stdout, stderr }));
273
+ });
274
+ }
275
+
276
+ export function pathApi(platform = process.platform) {
277
+ return platform === 'win32' ? win32 : posix;
278
+ }
279
+
280
+ // The launcher must not inherit a test or dev LOGWORK_HOME that points at a
281
+ // different runtime than the one it launches.
282
+ function withoutLogworkEnv(env) {
283
+ return Object.fromEntries(Object.entries(env || {}).filter(([key]) => !key.startsWith('LOGWORK_')));
284
+ }
285
+
286
+ function firstLine(value) {
287
+ return String(value || '').trim().split(/\r?\n/)[0] || '';
288
+ }
289
+
290
+ function quoteSh(value) {
291
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
292
+ }
293
+
294
+ function readEnv(env, name) {
295
+ if (!env) {
296
+ return undefined;
297
+ }
298
+ if (env[name] !== undefined) {
299
+ return env[name];
300
+ }
301
+ const key = Object.keys(env).find((item) => item.toUpperCase() === name);
302
+ return key ? env[key] : undefined;
303
+ }
@@ -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 an RO project (picker)',
78
+ usage: '/map SCB [project] | /map --repo [path] [project]',
79
79
  aliases: []
80
80
  }
81
81
  ];