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.
- package/README.md +35 -9
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +157 -19
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +57 -15
- package/docs/operations-guide.md +37 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- 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-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +201 -2
- package/lib/commit-workflow.mjs +278 -30
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/keychain.mjs +19 -12
- 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 +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +63 -21
- package/lib/node-runtime.mjs +115 -0
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +11 -1
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- 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 +5 -8
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +49 -6
- package/lib/ro-resubmit-workflow.mjs +35 -21
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/lib/update-service.mjs +5 -2
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +6 -4
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/keychain.mjs
CHANGED
|
@@ -41,16 +41,9 @@ export function createKeychainValueStorage({
|
|
|
41
41
|
},
|
|
42
42
|
|
|
43
43
|
async set(token) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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 = '';
|
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 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
|
];
|