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/manual-mcp.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { mcpArgs, resolveLaunchCommand } from './launcher.mjs';
|
|
2
3
|
import { helperHome } from './paths.mjs';
|
|
3
4
|
|
|
4
5
|
export const MCP_CLIENTS = [
|
|
@@ -39,15 +40,48 @@ export const MCP_CLIENTS = [
|
|
|
39
40
|
}
|
|
40
41
|
];
|
|
41
42
|
|
|
42
|
-
// IDE configs run
|
|
43
|
-
//
|
|
44
|
-
//
|
|
43
|
+
// IDE configs run the launch command from lib/launcher.mjs: on macOS/Linux
|
|
44
|
+
// the generated <home>/bin/logwork-launch script (it finds a usable Node 22+
|
|
45
|
+
// at start time), on Windows a stable node.exe plus <home>\bin\logwork.mjs.
|
|
46
|
+
// Both are absolute because GUI IDEs (Cursor, VS Code started from the Dock or
|
|
47
|
+
// Finder) often start without the npm global bin directory or nvm's node on
|
|
48
|
+
// PATH, so neither `logwork` nor a bare `node` would be found.
|
|
45
49
|
export const MCP_SERVER_NAME = 'logwork';
|
|
46
50
|
|
|
51
|
+
// Only printed when the command is a raw Node path that an upgrade can remove.
|
|
52
|
+
export const MCP_NODE_PATH_NOTE = 'The config uses the absolute path of the Node that ran this command. If Node is upgraded or moved (nvm, Homebrew, a new installer), re-run `logwork setup` (it updates the config for you).';
|
|
53
|
+
|
|
54
|
+
export const MCP_LAUNCHER_NOTE = 'The command is the logwork launcher; it finds Node 22+ itself, so Node upgrades need no config change.';
|
|
55
|
+
|
|
56
|
+
const INSTALL_CLIENT_KEYS = {
|
|
57
|
+
cursor: 'cursor',
|
|
58
|
+
antigravity: 'antigravity',
|
|
59
|
+
copilot: 'vscode',
|
|
60
|
+
'claude-code': 'claude',
|
|
61
|
+
codex: 'codex'
|
|
62
|
+
};
|
|
63
|
+
|
|
47
64
|
export function defaultMcpServerPath() {
|
|
48
65
|
return join(helperHome(), 'bin', 'logwork.mjs');
|
|
49
66
|
}
|
|
50
67
|
|
|
68
|
+
// Accepts a launch command ({ command, argsPrefix }) or, for older callers, an
|
|
69
|
+
// entry path plus { nodePath }: a raw `<node> <entry>` command.
|
|
70
|
+
export function toLaunchCommand(launchOrServerPath, { nodePath } = {}) {
|
|
71
|
+
if (launchOrServerPath && typeof launchOrServerPath === 'object') {
|
|
72
|
+
return launchOrServerPath;
|
|
73
|
+
}
|
|
74
|
+
if (launchOrServerPath === undefined && nodePath === undefined) {
|
|
75
|
+
return resolveLaunchCommand();
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
command: nodePath || process.execPath,
|
|
79
|
+
argsPrefix: [launchOrServerPath || defaultMcpServerPath()],
|
|
80
|
+
kind: 'node',
|
|
81
|
+
stable: false
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
export function resolveMcpClient(value) {
|
|
52
86
|
const normalized = normalizeMcpClientValue(value);
|
|
53
87
|
if (!normalized) {
|
|
@@ -82,17 +116,22 @@ export function formatMcpClientOptions(clients = MCP_CLIENTS) {
|
|
|
82
116
|
|
|
83
117
|
export function formatMcpSetup({
|
|
84
118
|
client,
|
|
85
|
-
|
|
119
|
+
launch,
|
|
120
|
+
serverPath,
|
|
121
|
+
nodePath,
|
|
86
122
|
platform = process.platform
|
|
87
123
|
} = {}) {
|
|
88
124
|
const resolvedClient = typeof client === 'object' ? client : resolveMcpClient(client);
|
|
89
125
|
if (!resolvedClient) {
|
|
90
126
|
throw new Error('Unknown MCP client. Use: cursor, antigravity, copilot, claude-code, or codex.');
|
|
91
127
|
}
|
|
128
|
+
const command = launch || (serverPath === undefined && nodePath === undefined
|
|
129
|
+
? resolveLaunchCommand({ platform })
|
|
130
|
+
: toLaunchCommand(serverPath, { nodePath }));
|
|
92
131
|
|
|
93
132
|
const lines = [
|
|
94
133
|
`MCP setup: ${resolvedClient.label}`,
|
|
95
|
-
`
|
|
134
|
+
`Command: ${[command.command, ...mcpArgs(command)].join(' ')}`,
|
|
96
135
|
''
|
|
97
136
|
];
|
|
98
137
|
|
|
@@ -100,34 +139,38 @@ export function formatMcpSetup({
|
|
|
100
139
|
lines.push(
|
|
101
140
|
`Copy this TOML into ${resolvedClient.target}:`,
|
|
102
141
|
'',
|
|
103
|
-
formatCodexToml(
|
|
142
|
+
formatCodexToml(command)
|
|
104
143
|
);
|
|
105
144
|
} else if (resolvedClient.key === 'copilot') {
|
|
106
145
|
lines.push(
|
|
107
146
|
`Copy this JSON into ${resolvedClient.target}:`,
|
|
108
147
|
'',
|
|
109
|
-
JSON.stringify(vscodeMcpConfig(
|
|
148
|
+
JSON.stringify(vscodeMcpConfig(command), null, 2)
|
|
110
149
|
);
|
|
111
150
|
} else if (resolvedClient.key === 'claude-code') {
|
|
112
151
|
lines.push(
|
|
113
152
|
'Recommended command:',
|
|
114
153
|
'',
|
|
115
|
-
|
|
154
|
+
formatClaudeMcpAddCommand(command, { platform }),
|
|
116
155
|
'',
|
|
117
156
|
'Or copy this JSON into project .mcp.json:',
|
|
118
157
|
'',
|
|
119
|
-
JSON.stringify(stdioMcpServersConfig(
|
|
158
|
+
JSON.stringify(stdioMcpServersConfig(command), null, 2)
|
|
120
159
|
);
|
|
121
160
|
} else {
|
|
122
161
|
lines.push(
|
|
123
162
|
`Copy this JSON into ${resolvedClient.target}:`,
|
|
124
163
|
'',
|
|
125
|
-
JSON.stringify(stdioMcpServersConfig(
|
|
164
|
+
JSON.stringify(stdioMcpServersConfig(command), null, 2)
|
|
126
165
|
);
|
|
127
166
|
}
|
|
128
167
|
|
|
168
|
+
const installKey = INSTALL_CLIENT_KEYS[resolvedClient.key];
|
|
169
|
+
const note = command.stable ? (command.kind === 'launcher' ? MCP_LAUNCHER_NOTE : '') : MCP_NODE_PATH_NOTE;
|
|
129
170
|
lines.push(
|
|
130
171
|
'',
|
|
172
|
+
...(note ? [note] : []),
|
|
173
|
+
...(installKey ? [`To let logwork write it for you instead, run: logwork mcp install --client ${installKey}`] : []),
|
|
131
174
|
'After saving, restart or reload the MCP client.',
|
|
132
175
|
'Do not paste passwords, 2FA codes, Bearer tokens, cookies, or raw auth logs into AI chat.'
|
|
133
176
|
);
|
|
@@ -142,41 +185,56 @@ function normalizeMcpClientValue(value) {
|
|
|
142
185
|
.replace(/[\s_]+/g, '-');
|
|
143
186
|
}
|
|
144
187
|
|
|
145
|
-
export function stdioMcpServersConfig(
|
|
188
|
+
export function stdioMcpServersConfig(launchOrServerPath, options = {}) {
|
|
189
|
+
const launch = toLaunchCommand(launchOrServerPath, options);
|
|
146
190
|
return {
|
|
147
191
|
mcpServers: {
|
|
148
192
|
[MCP_SERVER_NAME]: {
|
|
149
|
-
command:
|
|
150
|
-
args:
|
|
193
|
+
command: launch.command,
|
|
194
|
+
args: mcpArgs(launch)
|
|
151
195
|
}
|
|
152
196
|
}
|
|
153
197
|
};
|
|
154
198
|
}
|
|
155
199
|
|
|
156
|
-
export function vscodeMcpConfig(
|
|
200
|
+
export function vscodeMcpConfig(launchOrServerPath, options = {}) {
|
|
201
|
+
const launch = toLaunchCommand(launchOrServerPath, options);
|
|
157
202
|
return {
|
|
158
203
|
servers: {
|
|
159
204
|
[MCP_SERVER_NAME]: {
|
|
160
205
|
type: 'stdio',
|
|
161
|
-
command:
|
|
162
|
-
args:
|
|
206
|
+
command: launch.command,
|
|
207
|
+
args: mcpArgs(launch)
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
210
|
};
|
|
166
211
|
}
|
|
167
212
|
|
|
168
|
-
export function formatCodexToml(
|
|
213
|
+
export function formatCodexToml(launchOrServerPath, options = {}) {
|
|
214
|
+
const launch = toLaunchCommand(launchOrServerPath, options);
|
|
169
215
|
return [
|
|
170
216
|
`[mcp_servers.${MCP_SERVER_NAME}]`,
|
|
171
|
-
|
|
172
|
-
`args = ["${escapeTomlString(
|
|
217
|
+
`command = "${escapeTomlString(launch.command)}"`,
|
|
218
|
+
`args = [${mcpArgs(launch).map((arg) => `"${escapeTomlString(arg)}"`).join(', ')}]`,
|
|
173
219
|
'startup_timeout_sec = 20',
|
|
174
220
|
'tool_timeout_sec = 120'
|
|
175
221
|
].join('\n');
|
|
176
222
|
}
|
|
177
223
|
|
|
224
|
+
// User scope, so the server is available in every project.
|
|
225
|
+
export function formatClaudeMcpAddCommand(launchOrServerPath, { platform = process.platform, ...options } = {}) {
|
|
226
|
+
const launch = toLaunchCommand(launchOrServerPath, options);
|
|
227
|
+
return `claude mcp add --transport stdio --scope user ${MCP_SERVER_NAME} -- ${[launch.command, ...mcpArgs(launch)].map((arg) => (arg === 'mcp' ? arg : quoteShellArg(arg, { platform }))).join(' ')}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// TOML basic string: escape backslashes (Windows paths), quotes and control
|
|
231
|
+
// characters.
|
|
178
232
|
function escapeTomlString(value) {
|
|
179
|
-
return String(value)
|
|
233
|
+
return String(value)
|
|
234
|
+
.replace(/\\/g, '\\\\')
|
|
235
|
+
.replace(/"/g, '\\"')
|
|
236
|
+
// eslint-disable-next-line no-control-regex -- TOML needs control characters escaped.
|
|
237
|
+
.replace(/[\u0000-\u001f\u007f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
180
238
|
}
|
|
181
239
|
|
|
182
240
|
function quoteShellArg(value, { platform = process.platform } = {}) {
|
package/lib/manual-repl.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { confirm, isCancel, select } from '@clack/prompts';
|
|
3
4
|
import { getStoredAuthStatus, loginResourceOptimiser } from './auth.mjs';
|
|
4
5
|
import { queryApplyLedger } from './apply-ledger.mjs';
|
|
5
6
|
import { isAuthRequiredError } from './auth-errors.mjs';
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
import { findManualCommand, formatManualHelp } from './manual-commands.mjs';
|
|
31
32
|
import {
|
|
32
33
|
buildManualApplyConfirmation,
|
|
34
|
+
buildManualDuplicateConfirmation,
|
|
33
35
|
buildManualUnbookedConfirmation,
|
|
34
36
|
getManualApplyBlocker,
|
|
37
|
+
manualDuplicateEntryCount,
|
|
35
38
|
manualUnbookedEntryCount
|
|
36
39
|
} from './manual-apply-state.mjs';
|
|
37
40
|
import { runManualInkApp } from './manual-ink-app.mjs';
|
|
@@ -46,8 +49,11 @@ import {
|
|
|
46
49
|
import {
|
|
47
50
|
enrichRoPreviewWithJiraProjects,
|
|
48
51
|
listLogworkProjects,
|
|
52
|
+
resolveProjectSelection,
|
|
49
53
|
upsertProjectMapping
|
|
50
54
|
} from './project-mapping-workflow.mjs';
|
|
55
|
+
import { suggestProjectsForRepo } from './commit-workflow.mjs';
|
|
56
|
+
import { repoProjectFromOption, repoProjectSelectOptions } from './ui.mjs';
|
|
51
57
|
import { queryLogwork } from './query-workflow.mjs';
|
|
52
58
|
import { parseRoEditArgs } from './ro-edit-args.mjs';
|
|
53
59
|
import {
|
|
@@ -363,21 +369,7 @@ export async function executeManualCommand(command, session, context) {
|
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
if (command.type === 'map') {
|
|
366
|
-
|
|
367
|
-
if (!ok) {
|
|
368
|
-
print('Mapping cancelled.');
|
|
369
|
-
return { exit: false };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const result = await runWithInlineAuth(() => workflows.upsertProjectMapping({
|
|
373
|
-
projectMemberId: command.projectMemberId,
|
|
374
|
-
tickets: command.tickets,
|
|
375
|
-
keywords: [],
|
|
376
|
-
scope: 'user',
|
|
377
|
-
confirm: true,
|
|
378
|
-
cwd
|
|
379
|
-
}), { workflows, print });
|
|
380
|
-
print(result.summary);
|
|
372
|
+
await executeMapCommand(command, { cwd, workflows, prompts, print });
|
|
381
373
|
return { exit: false };
|
|
382
374
|
}
|
|
383
375
|
|
|
@@ -498,10 +490,115 @@ export function createManualPrompts() {
|
|
|
498
490
|
throw new Error('Cancelled.');
|
|
499
491
|
}
|
|
500
492
|
return Boolean(answer);
|
|
493
|
+
},
|
|
494
|
+
// { message, options: [{ value, label, hint }], initialValue } -> value,
|
|
495
|
+
// or null when cancelled.
|
|
496
|
+
async select({ message, options, initialValue }) {
|
|
497
|
+
const answer = await select({ message, options, initialValue });
|
|
498
|
+
return isCancel(answer) ? null : answer;
|
|
501
499
|
}
|
|
502
500
|
};
|
|
503
501
|
}
|
|
504
502
|
|
|
503
|
+
// /map with a project member id saves it as before. Otherwise the project is
|
|
504
|
+
// chosen by name (case-insensitive, then a unique substring) or, with no name
|
|
505
|
+
// or an ambiguous one, from a picker of member projects; for a repo the
|
|
506
|
+
// picker lists the suggested projects first.
|
|
507
|
+
async function executeMapCommand(command, { cwd, workflows, prompts, print }) {
|
|
508
|
+
const subject = [
|
|
509
|
+
...command.tickets,
|
|
510
|
+
...(command.repos || []).map((repo) => `repo ${resolvePath(cwd, repo)}`)
|
|
511
|
+
].join(', ');
|
|
512
|
+
const save = (project) => runWithInlineAuth(() => workflows.upsertProjectMapping({
|
|
513
|
+
projectMemberId: project.projectMemberId,
|
|
514
|
+
...(project.projectMemberId === undefined ? { projectName: project.projectName } : {}),
|
|
515
|
+
tickets: command.tickets,
|
|
516
|
+
keywords: [],
|
|
517
|
+
...(command.repos?.length ? { repos: command.repos } : {}),
|
|
518
|
+
scope: 'user',
|
|
519
|
+
confirm: true,
|
|
520
|
+
cwd
|
|
521
|
+
}), { workflows, print });
|
|
522
|
+
|
|
523
|
+
if (command.projectMemberId !== undefined) {
|
|
524
|
+
const ok = await prompts.confirm(`Map ${subject} to projectMemberId ${command.projectMemberId}?`);
|
|
525
|
+
if (!ok) {
|
|
526
|
+
print('Mapping cancelled.');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
print((await save({ projectMemberId: command.projectMemberId })).summary);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const { projects = [] } = await runWithInlineAuth(() => workflows.listLogworkProjects({ cwd }), { workflows, print });
|
|
534
|
+
if (!projects.length) {
|
|
535
|
+
throw new Error('No Resource Optimiser project memberships found to map to.');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let project = null;
|
|
539
|
+
let pickerProjects = projects;
|
|
540
|
+
let pickerNote = '';
|
|
541
|
+
if (command.projectQuery) {
|
|
542
|
+
const resolution = resolveProjectSelection(projects, { projectName: command.projectQuery });
|
|
543
|
+
project = resolution.project || null;
|
|
544
|
+
if (!project) {
|
|
545
|
+
pickerNote = `${resolution.reason}.`;
|
|
546
|
+
pickerProjects = resolution.candidates?.length ? resolution.candidates : projects;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (project) {
|
|
551
|
+
const ok = await prompts.confirm(`Map ${subject} to ${project.projectName}?`);
|
|
552
|
+
if (!ok) {
|
|
553
|
+
print('Mapping cancelled.');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
let suggestions = [];
|
|
558
|
+
if (command.repos?.length && !command.projectQuery) {
|
|
559
|
+
const suggested = await runWithInlineAuth(() => workflows.suggestProjectsForRepo({
|
|
560
|
+
repo: command.repos[0],
|
|
561
|
+
projects,
|
|
562
|
+
cwd
|
|
563
|
+
}), { workflows, print }).catch((error) => {
|
|
564
|
+
if (isAuthRequiredError(error)) {
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
pickerNote = `No suggestions: ${error.message}`;
|
|
568
|
+
return null;
|
|
569
|
+
});
|
|
570
|
+
suggestions = suggested?.suggestions || [];
|
|
571
|
+
}
|
|
572
|
+
if (pickerNote) {
|
|
573
|
+
print(pickerNote);
|
|
574
|
+
}
|
|
575
|
+
project = await pickMapProject({ subject, suggestions, projects: pickerProjects, prompts, print });
|
|
576
|
+
if (!project) {
|
|
577
|
+
print('Mapping cancelled.');
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
print((await save(project)).summary);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function pickMapProject({ subject, suggestions, projects, prompts, print }) {
|
|
586
|
+
const { items, options, initialValue } = repoProjectSelectOptions(suggestions, projects, { skipLabel: 'Cancel' });
|
|
587
|
+
if (typeof prompts.select === 'function') {
|
|
588
|
+
return repoProjectFromOption(items, await prompts.select({
|
|
589
|
+
message: `Which RO project should ${subject} map to?`,
|
|
590
|
+
options,
|
|
591
|
+
initialValue
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
// Without a picker, list the projects; the user re-runs /map with a name.
|
|
595
|
+
print([
|
|
596
|
+
`Which RO project should ${subject} map to? Re-run /map with the project name:`,
|
|
597
|
+
...items.map((item, index) => ` ${index + 1}. ${item.project.projectName}${item.suggestion ? ` (${item.suggestion.reasons.join('; ')})` : ''}`)
|
|
598
|
+
].join('\n'));
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
505
602
|
async function readPasteBlock({ readLine, print, target = RO_TARGET }) {
|
|
506
603
|
if (typeof readLine !== 'function') {
|
|
507
604
|
throw new Error('/logwork requires an interactive terminal.');
|
|
@@ -666,10 +763,20 @@ async function applyManualRoPreview({
|
|
|
666
763
|
}
|
|
667
764
|
}
|
|
668
765
|
|
|
766
|
+
let allowDuplicates = false;
|
|
767
|
+
if (manualDuplicateEntryCount(preview)) {
|
|
768
|
+
allowDuplicates = await prompts.confirm(buildManualDuplicateConfirmation(preview), false);
|
|
769
|
+
if (!allowDuplicates) {
|
|
770
|
+
print('Resource Optimiser apply cancelled because possible duplicates were not approved. Nothing was written.');
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
669
775
|
const result = await runWithInlineAuth(() => workflows.applyLogworkBatch({
|
|
670
776
|
batch: preview,
|
|
671
777
|
confirm: true,
|
|
672
778
|
allowUnbooked,
|
|
779
|
+
allowDuplicates,
|
|
673
780
|
cwd,
|
|
674
781
|
ledgerContext: { flowTarget }
|
|
675
782
|
}), { workflows, print });
|
|
@@ -871,28 +978,69 @@ function parseTargetCommand(type, parts, {
|
|
|
871
978
|
};
|
|
872
979
|
}
|
|
873
980
|
|
|
981
|
+
const MAP_USAGE = 'Usage: /map <TICKET_PREFIX[,PREFIX...]> [project name or id] | /map --repo [path] [project name or id]';
|
|
982
|
+
const PATH_LIKE = /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|~(?:[\\/]|$)|[A-Za-z]:[\\/])/;
|
|
983
|
+
|
|
874
984
|
function parseMapCommand(parts) {
|
|
875
|
-
if (parts
|
|
876
|
-
|
|
985
|
+
if (parts[0] === '--repo') {
|
|
986
|
+
const { repo, project } = splitRepoAndProject(parts.slice(1));
|
|
987
|
+
return {
|
|
988
|
+
type: 'map',
|
|
989
|
+
tickets: [],
|
|
990
|
+
repos: [repo],
|
|
991
|
+
...mapProjectFields(project)
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (!parts.length || parts[0].startsWith('-')) {
|
|
996
|
+
throw new Error(MAP_USAGE);
|
|
877
997
|
}
|
|
878
998
|
|
|
879
999
|
const tickets = parts[0]
|
|
880
1000
|
.split(',')
|
|
881
1001
|
.map((ticket) => ticket.trim().toUpperCase())
|
|
882
1002
|
.filter(Boolean);
|
|
883
|
-
const projectMemberId = parts[1];
|
|
884
1003
|
|
|
885
|
-
if (!tickets.length
|
|
886
|
-
throw new Error(
|
|
1004
|
+
if (!tickets.length) {
|
|
1005
|
+
throw new Error(MAP_USAGE);
|
|
887
1006
|
}
|
|
888
1007
|
|
|
889
1008
|
return {
|
|
890
1009
|
type: 'map',
|
|
891
1010
|
tickets,
|
|
892
|
-
|
|
1011
|
+
...mapProjectFields(parts.slice(1).join(' ').trim())
|
|
893
1012
|
};
|
|
894
1013
|
}
|
|
895
1014
|
|
|
1015
|
+
// "/map --repo [path] [project]": the path defaults to the current folder. A
|
|
1016
|
+
// path starts with ".", "/", "~" or a drive letter, or is quoted (for spaces);
|
|
1017
|
+
// anything else is the project. The old form "<path> <projectMemberId>" still
|
|
1018
|
+
// reads any path.
|
|
1019
|
+
function splitRepoAndProject(tokens) {
|
|
1020
|
+
const rest = tokens.join(' ').trim();
|
|
1021
|
+
const quoted = /^(["'])(.*?)\1(?:\s+(.*))?$/.exec(rest);
|
|
1022
|
+
if (quoted) {
|
|
1023
|
+
return { repo: quoted[2].trim() || '.', project: (quoted[3] || '').trim() };
|
|
1024
|
+
}
|
|
1025
|
+
if (tokens.length >= 2 && /^\d+$/.test(tokens.at(-1))) {
|
|
1026
|
+
return { repo: tokens.slice(0, -1).join(' '), project: tokens.at(-1) };
|
|
1027
|
+
}
|
|
1028
|
+
if (tokens.length && PATH_LIKE.test(tokens[0])) {
|
|
1029
|
+
return { repo: tokens[0], project: tokens.slice(1).join(' ').trim() };
|
|
1030
|
+
}
|
|
1031
|
+
return { repo: '.', project: rest };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function mapProjectFields(project) {
|
|
1035
|
+
if (!project) {
|
|
1036
|
+
return {};
|
|
1037
|
+
}
|
|
1038
|
+
if (project.startsWith('-')) {
|
|
1039
|
+
throw new Error(MAP_USAGE);
|
|
1040
|
+
}
|
|
1041
|
+
return /^\d+$/.test(project) ? { projectMemberId: project } : { projectQuery: project };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
896
1044
|
export function formatProjects(result, weekly = null, projectFilter = undefined) {
|
|
897
1045
|
const lines = [result.summary];
|
|
898
1046
|
const projects = filterProjects(result.projects || [], projectFilter);
|
|
@@ -1028,6 +1176,7 @@ function defaultWorkflows() {
|
|
|
1028
1176
|
applyJiraWorklogBatch,
|
|
1029
1177
|
listLogworkProjects,
|
|
1030
1178
|
upsertProjectMapping,
|
|
1179
|
+
suggestProjectsForRepo,
|
|
1031
1180
|
loginResourceOptimiser,
|
|
1032
1181
|
getStoredAuthStatus,
|
|
1033
1182
|
getStoredJiraStatus,
|