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.
- package/README.md +53 -25
- package/RELEASE.md +4 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +128 -13
- package/commands/history.mjs +0 -4
- package/commands/mcp.mjs +266 -0
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +197 -42
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +18 -7
- package/docs/mcp-setup.md +120 -26
- package/docs/operations-guide.md +50 -2
- package/docs/recipes.md +40 -0
- package/docs/security.md +23 -2
- package/examples/mcp/antigravity.mcp_config.json +2 -2
- package/examples/mcp/codex.config.toml +2 -2
- package/examples/mcp/copilot.mcp.json +2 -2
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +345 -23
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +116 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/launcher.mjs +303 -0
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +142 -1282
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +318 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +78 -20
- package/lib/manual-repl.mjs +171 -22
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +155 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +32 -27
- package/lib/query-workflow.mjs +19 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +85 -18
- package/lib/reminder-service.mjs +42 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +121 -56
- package/package.json +11 -4
- 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
|
-
|
|
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 = {}) {
|
package/lib/jira-workflow.mjs
CHANGED
|
@@ -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
|
|
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
|
|
399
|
-
|
|
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/launcher.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/logwork-config.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/manual-commands.mjs
CHANGED
|
@@ -74,8 +74,8 @@ export const MANUAL_COMMANDS = [
|
|
|
74
74
|
},
|
|
75
75
|
{
|
|
76
76
|
name: '/map',
|
|
77
|
-
description: 'Map ticket prefix to
|
|
78
|
-
usage: '/map SCB
|
|
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
|
];
|