logwork-cli 0.0.0-stage → 1.1.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/LICENSE +11 -0
- package/README.md +330 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +104 -0
- package/commands/auth.mjs +227 -0
- package/commands/commits.mjs +82 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +508 -0
- package/commands/skill.mjs +91 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +368 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/agent-skill.mjs +16 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/commit-workflow.mjs +402 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +833 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +73 -4
- package/setup.sh +22 -0
- package/skills/logwork/SKILL.md +43 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { atomicWriteFile } from './atomic-file.mjs';
|
|
4
|
+
import { withFileLock } from './file-lock.mjs';
|
|
5
|
+
import { applyLedgerPath } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_RECORDS = 500;
|
|
8
|
+
const VALID_TARGETS = new Set(['ro', 'jira']);
|
|
9
|
+
const VALID_FLOW_TARGETS = new Set(['ro', 'jira', 'both']);
|
|
10
|
+
|
|
11
|
+
export async function recordApplyResult(input, {
|
|
12
|
+
path = applyLedgerPath(),
|
|
13
|
+
maxRecords = DEFAULT_MAX_RECORDS,
|
|
14
|
+
lockOptions = {},
|
|
15
|
+
now = () => new Date(),
|
|
16
|
+
random = Math.random
|
|
17
|
+
} = {}) {
|
|
18
|
+
const record = sanitizeApplyRecord({
|
|
19
|
+
...input,
|
|
20
|
+
id: input?.id || createRecordId(now(), random),
|
|
21
|
+
timestamp: input?.timestamp || now().toISOString()
|
|
22
|
+
});
|
|
23
|
+
if (!record) {
|
|
24
|
+
throw new Error('Cannot record apply result without a valid target and batchId.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return withFileLock(path, async () => {
|
|
28
|
+
const current = await loadApplyLedger({ path });
|
|
29
|
+
const records = [record, ...current]
|
|
30
|
+
.filter((candidate, index, all) => all.findIndex((other) => other.id === candidate.id) === index)
|
|
31
|
+
.sort(sortNewestFirst)
|
|
32
|
+
.slice(0, normalizeMaxRecords(maxRecords));
|
|
33
|
+
await atomicWriteFile(path, `${JSON.stringify({ records }, null, 2)}\n`);
|
|
34
|
+
return record;
|
|
35
|
+
}, lockOptions);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadApplyLedger({ path = applyLedgerPath() } = {}) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const records = Array.isArray(parsed) ? parsed : parsed?.records;
|
|
50
|
+
return (Array.isArray(records) ? records : [])
|
|
51
|
+
.map(sanitizeApplyRecord)
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.sort(sortNewestFirst);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function queryApplyLedger({
|
|
57
|
+
target,
|
|
58
|
+
limit = 20,
|
|
59
|
+
cwd,
|
|
60
|
+
path = applyLedgerPath()
|
|
61
|
+
} = {}) {
|
|
62
|
+
const normalizedTarget = normalizeQueryTarget(target);
|
|
63
|
+
const normalizedCwd = normalizeCwd(cwd);
|
|
64
|
+
const records = (await loadApplyLedger({ path }))
|
|
65
|
+
.filter((record) => !normalizedTarget || (
|
|
66
|
+
normalizedTarget === 'both'
|
|
67
|
+
? record.flowTarget === 'both'
|
|
68
|
+
: record.target === normalizedTarget
|
|
69
|
+
))
|
|
70
|
+
.filter((record) => !normalizedCwd || normalizeCwd(record.cwd) === normalizedCwd)
|
|
71
|
+
.slice(0, normalizeLimit(limit));
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
status: 'ok',
|
|
75
|
+
filters: {
|
|
76
|
+
target: normalizedTarget || null,
|
|
77
|
+
cwd: normalizedCwd || null,
|
|
78
|
+
limit: normalizeLimit(limit)
|
|
79
|
+
},
|
|
80
|
+
records,
|
|
81
|
+
summary: formatApplyLedgerSummary(records)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sanitizeApplyRecord(record = {}) {
|
|
86
|
+
const target = String(record.target || '').toLowerCase();
|
|
87
|
+
const flowTarget = String(record.flowTarget || target).toLowerCase();
|
|
88
|
+
const batchId = cleanText(record.batchId, 160);
|
|
89
|
+
if (!VALID_TARGETS.has(target) || !VALID_FLOW_TARGETS.has(flowTarget) || !batchId) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entries = (Array.isArray(record.entries) ? record.entries : [])
|
|
94
|
+
.map(sanitizeEntry)
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
id: cleanText(record.id, 160) || createRecordId(new Date(), Math.random),
|
|
99
|
+
timestamp: normalizeTimestamp(record.timestamp),
|
|
100
|
+
target,
|
|
101
|
+
flowTarget,
|
|
102
|
+
batchId,
|
|
103
|
+
status: cleanText(record.status, 80) || 'unknown',
|
|
104
|
+
cwd: cleanText(record.cwd, 1_000) || '',
|
|
105
|
+
summary: buildRecordSummary(entries, record.summary),
|
|
106
|
+
entries
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatApplyLedgerSummary(records = []) {
|
|
111
|
+
const lines = [`Apply history: ${records.length} record${records.length === 1 ? '' : 's'}.`];
|
|
112
|
+
for (const record of records) {
|
|
113
|
+
const flow = record.flowTarget === 'both' ? `${record.target} via both` : record.target;
|
|
114
|
+
lines.push(`- ${record.timestamp}: ${flow} ${record.status}, batch ${record.batchId}, submitted ${record.summary.submittedCount}, failed ${record.summary.failedCount}, blocked ${record.summary.blockedCount}, ${formatHours(record.summary.totalHours)}h.`);
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function recordApplyResultSafely(writer, input) {
|
|
120
|
+
if (typeof writer !== 'function') {
|
|
121
|
+
return { status: 'disabled', recorded: false };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const record = await writer(input);
|
|
125
|
+
return {
|
|
126
|
+
status: 'recorded',
|
|
127
|
+
recorded: true,
|
|
128
|
+
recordId: record?.id || null
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
status: 'failed',
|
|
133
|
+
recorded: false,
|
|
134
|
+
error: cleanText(error.message, 500) || 'Unknown ledger error'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sanitizeEntry(entry = {}) {
|
|
140
|
+
const hours = Number(entry.hours);
|
|
141
|
+
const entryId = cleanText(entry.entryId || entry.id, 160);
|
|
142
|
+
if (!entryId && !entry.date && !entry.taskName) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
entryId: entryId || '',
|
|
147
|
+
date: cleanText(entry.date, 20) || '',
|
|
148
|
+
hours: Number.isFinite(hours) ? Number(hours.toFixed(2)) : 0,
|
|
149
|
+
taskName: cleanText(entry.taskName, 1_000) || '',
|
|
150
|
+
description: cleanText(entry.description, 1_000) || '',
|
|
151
|
+
projectName: cleanText(entry.projectName || entry.matchedProject?.projectName, 500) || '',
|
|
152
|
+
issueKey: cleanText(entry.issueKey, 80) || '',
|
|
153
|
+
status: cleanText(entry.status, 80) || (entry.ok === true ? 'submitted' : 'unknown'),
|
|
154
|
+
error: cleanText(entry.error, 500) || '',
|
|
155
|
+
dryRun: Boolean(entry.dryRun)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildRecordSummary(entries, summary = {}) {
|
|
160
|
+
return {
|
|
161
|
+
entryCount: entries.length,
|
|
162
|
+
submittedCount: countStatus(entries, ['submitted']),
|
|
163
|
+
failedCount: countStatus(entries, ['failed']),
|
|
164
|
+
blockedCount: countStatus(entries, ['blocked', 'duplicate', 'unresolved']),
|
|
165
|
+
totalHours: Number(entries.reduce((sum, entry) => sum + Number(entry.hours || 0), 0).toFixed(2)),
|
|
166
|
+
message: cleanText(typeof summary === 'string' ? summary : summary?.message, 1_000) || ''
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function countStatus(entries, statuses) {
|
|
171
|
+
return entries.filter((entry) => statuses.includes(entry.status)).length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeQueryTarget(value) {
|
|
175
|
+
const target = String(value || '').trim().toLowerCase();
|
|
176
|
+
if (!target) {
|
|
177
|
+
return '';
|
|
178
|
+
}
|
|
179
|
+
if (!VALID_FLOW_TARGETS.has(target)) {
|
|
180
|
+
throw new Error('History target must be ro, jira, or both.');
|
|
181
|
+
}
|
|
182
|
+
return target;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeLimit(value) {
|
|
186
|
+
const limit = Number(value);
|
|
187
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
188
|
+
throw new Error('History limit must be an integer from 1 to 500.');
|
|
189
|
+
}
|
|
190
|
+
return limit;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeMaxRecords(value) {
|
|
194
|
+
const max = Number(value);
|
|
195
|
+
return Number.isInteger(max) && max > 0 ? max : DEFAULT_MAX_RECORDS;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeTimestamp(value) {
|
|
199
|
+
const date = new Date(value || 0);
|
|
200
|
+
return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeCwd(value) {
|
|
204
|
+
const text = String(value || '').trim();
|
|
205
|
+
return text ? resolve(text) : '';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function createRecordId(date, random) {
|
|
209
|
+
return `apply_${date.getTime()}_${random().toString(36).slice(2, 10)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function cleanText(value, maxLength) {
|
|
213
|
+
const text = String(value || '')
|
|
214
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer <redacted>')
|
|
215
|
+
.replace(/(token|password|secret|otp)\s*[=:]\s*[^\s,;]+/gi, '$1=<redacted>')
|
|
216
|
+
.trim();
|
|
217
|
+
return text ? text.slice(0, maxLength) : '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sortNewestFirst(left, right) {
|
|
221
|
+
return String(right.timestamp || '').localeCompare(String(left.timestamp || ''));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatHours(value) {
|
|
225
|
+
const number = Number(value || 0);
|
|
226
|
+
return Number.isInteger(number) ? String(number) : String(Number(number.toFixed(2)));
|
|
227
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function atomicWriteFile(filePath, content, {
|
|
5
|
+
encoding = 'utf8',
|
|
6
|
+
mode = 0o600,
|
|
7
|
+
mkdirFn = fs.mkdir,
|
|
8
|
+
writeFileFn = fs.writeFile,
|
|
9
|
+
renameFn = fs.rename,
|
|
10
|
+
rmFn = fs.rm,
|
|
11
|
+
now = Date.now,
|
|
12
|
+
random = Math.random
|
|
13
|
+
} = {}) {
|
|
14
|
+
await mkdirFn(dirname(filePath), { recursive: true });
|
|
15
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${now()}.${random().toString(36).slice(2, 10)}`;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await writeFileFn(tmpPath, content, { encoding, mode });
|
|
19
|
+
await renameFn(tmpPath, filePath);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
await rmFn(tmpPath, { force: true }).catch(() => {});
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { isSensitiveName, redactText, sanitizeUrl } from './auth-redaction.mjs';
|
|
5
|
+
import { helperHome } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
export const AUTH_DEBUG_LOG_FILE_NAME = 'auth-debug.log';
|
|
8
|
+
|
|
9
|
+
export function authDebugLogPath() {
|
|
10
|
+
return resolve(helperHome(), AUTH_DEBUG_LOG_FILE_NAME);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createAuthDiagnosticsRecorder({
|
|
14
|
+
attemptId = randomUUID(),
|
|
15
|
+
logPath = authDebugLogPath(),
|
|
16
|
+
now = () => new Date().toISOString()
|
|
17
|
+
} = {}) {
|
|
18
|
+
const createdAt = now();
|
|
19
|
+
const events = [];
|
|
20
|
+
|
|
21
|
+
function event(name, details = {}) {
|
|
22
|
+
events.push({
|
|
23
|
+
at: now(),
|
|
24
|
+
name: String(name || 'unknown'),
|
|
25
|
+
details: sanitizeDiagnosticValue(details)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function snapshot(error) {
|
|
30
|
+
return {
|
|
31
|
+
attemptId,
|
|
32
|
+
createdAt,
|
|
33
|
+
failedAt: now(),
|
|
34
|
+
error: sanitizeDiagnosticValue({
|
|
35
|
+
name: error?.name || 'Error',
|
|
36
|
+
code: error?.code || null,
|
|
37
|
+
message: error?.message || String(error || 'Unknown auth error')
|
|
38
|
+
}),
|
|
39
|
+
events: [...events]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function writeFailure(error) {
|
|
44
|
+
const payload = snapshot(error);
|
|
45
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
46
|
+
await appendFile(logPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
47
|
+
return {
|
|
48
|
+
attemptId,
|
|
49
|
+
path: logPath,
|
|
50
|
+
payload
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
attemptId,
|
|
56
|
+
logPath,
|
|
57
|
+
events,
|
|
58
|
+
event,
|
|
59
|
+
snapshot,
|
|
60
|
+
writeFailure
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function sanitizeDiagnosticValue(value, key = '') {
|
|
65
|
+
if (isSensitiveName(key)) {
|
|
66
|
+
return '<redacted>';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (value === null || value === undefined) {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof value === 'string') {
|
|
74
|
+
return sanitizeDiagnosticString(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
return value.slice(0, 40).map((item) => sanitizeDiagnosticValue(item));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof value === 'object') {
|
|
86
|
+
const output = {};
|
|
87
|
+
for (const [entryKey, entryValue] of Object.entries(value).slice(0, 80)) {
|
|
88
|
+
output[entryKey] = sanitizeDiagnosticValue(entryValue, entryKey);
|
|
89
|
+
}
|
|
90
|
+
return output;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return sanitizeDiagnosticString(String(value));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeDiagnosticString(value) {
|
|
97
|
+
const text = String(value || '');
|
|
98
|
+
if (/<\/?(html|body|form|input|script|style)\b/i.test(text)) {
|
|
99
|
+
return '<html-redacted>';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let sanitized = text;
|
|
103
|
+
if (/^https?:\/\//i.test(text)) {
|
|
104
|
+
try {
|
|
105
|
+
sanitized = sanitizeUrl(text);
|
|
106
|
+
} catch {
|
|
107
|
+
sanitized = text;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
sanitized = redactText(sanitized);
|
|
112
|
+
return sanitized.length > 500 ? `${sanitized.slice(0, 500)}…` : sanitized;
|
|
113
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createAuthRequiredError(message = authRequiredSummary()) {
|
|
2
|
+
const error = new Error(message);
|
|
3
|
+
error.code = 'AUTH_REQUIRED';
|
|
4
|
+
return error;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isAuthRequiredError(error) {
|
|
8
|
+
return error?.code === 'AUTH_REQUIRED';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function authRequiredPayload(error) {
|
|
12
|
+
return {
|
|
13
|
+
status: 'auth_required',
|
|
14
|
+
authRequired: true,
|
|
15
|
+
command: 'logwork login',
|
|
16
|
+
summary: error?.message || authRequiredSummary()
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function authRequiredSummary() {
|
|
21
|
+
return 'Resource Optimiser authentication required. Run `logwork login` in a terminal, complete SSO/2FA there, then retry this MCP tool.';
|
|
22
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const SENSITIVE_FIELD_NAMES = new Set([
|
|
2
|
+
'access_token',
|
|
3
|
+
'accesstoken',
|
|
4
|
+
'authorization',
|
|
5
|
+
'auth_session_id',
|
|
6
|
+
'auth_session_id_legacy',
|
|
7
|
+
'code',
|
|
8
|
+
'cookie',
|
|
9
|
+
'credential',
|
|
10
|
+
'credentialid',
|
|
11
|
+
'execution',
|
|
12
|
+
'kc_restart',
|
|
13
|
+
'nonce',
|
|
14
|
+
'otp',
|
|
15
|
+
'password',
|
|
16
|
+
'refresh_token',
|
|
17
|
+
'session_code',
|
|
18
|
+
'state',
|
|
19
|
+
'tab_id',
|
|
20
|
+
'token'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function sanitizeUrl(rawUrl) {
|
|
24
|
+
const url = new URL(rawUrl);
|
|
25
|
+
for (const key of [...url.searchParams.keys()]) {
|
|
26
|
+
if (isSensitiveName(key)) {
|
|
27
|
+
url.searchParams.set(key, '<redacted>');
|
|
28
|
+
} else {
|
|
29
|
+
url.searchParams.set(key, '<value>');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
url.hash = url.hash ? '#<redacted>' : '';
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sanitizeHeaders(headers = {}) {
|
|
37
|
+
const sanitized = {};
|
|
38
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
39
|
+
sanitized[key] = isSensitiveName(key) ? '<redacted>' : String(value);
|
|
40
|
+
}
|
|
41
|
+
return sanitized;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sanitizeFormBody(body = '') {
|
|
45
|
+
const params = new URLSearchParams(String(body || ''));
|
|
46
|
+
return [...params.keys()].map((name) => ({
|
|
47
|
+
name,
|
|
48
|
+
redacted: isSensitiveName(name)
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sanitizeJsonKeys(value) {
|
|
53
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return Object.keys(value).map((name) => ({
|
|
57
|
+
name,
|
|
58
|
+
redacted: isSensitiveName(name)
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isSensitiveName(name) {
|
|
63
|
+
const normalized = String(name || '')
|
|
64
|
+
.replace(/[^a-z0-9]/gi, '')
|
|
65
|
+
.toLowerCase();
|
|
66
|
+
if (SENSITIVE_FIELD_NAMES.has(normalized)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
return /token|password|cookie|authorization|session|nonce|state|code|otp|credential/i.test(String(name || ''));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function redactText(value) {
|
|
73
|
+
let text = String(value || '');
|
|
74
|
+
text = text.replace(/(password|otp|code|accessToken|access_token|token|session_code|state|nonce|tab_id)=([^&\s]+)/gi, '$1=<redacted>');
|
|
75
|
+
text = text.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer <redacted>');
|
|
76
|
+
text = text.replace(/eyJ[A-Za-z0-9._-]+/g, '<jwt-redacted>');
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { dirname, resolve, win32 } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { helperHome } from './paths.mjs';
|
|
5
|
+
|
|
6
|
+
export function buildAuthLoginArgs(target = 'ro') {
|
|
7
|
+
return target === 'jira' ? ['login', 'jira'] : ['login'];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildAuthLoginShellCommand({
|
|
11
|
+
nodePath = process.execPath,
|
|
12
|
+
authCliPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'logwork.mjs'),
|
|
13
|
+
home = helperHome(),
|
|
14
|
+
platform = process.platform
|
|
15
|
+
} = {}) {
|
|
16
|
+
const args = buildAuthLoginArgs();
|
|
17
|
+
const authCliDir = dirnameForPlatform(dirnameForPlatform(authCliPath, platform), platform);
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
return [
|
|
20
|
+
`Set-Location -LiteralPath ${quotePowerShellArg(authCliDir)}`,
|
|
21
|
+
`$env:LOGWORK_HOME = ${quotePowerShellArg(home)}`,
|
|
22
|
+
`& ${quotePowerShellArg(nodePath)} ${quotePowerShellArg(authCliPath)} ${args.map(quotePowerShellArg).join(' ')}`
|
|
23
|
+
].join('; ');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return [
|
|
27
|
+
`cd ${quotePosixArg(authCliDir)}`,
|
|
28
|
+
`LOGWORK_HOME=${quotePosixArg(home)} ${quotePosixArg(nodePath)} ${quotePosixArg(authCliPath)} ${args.map(quotePosixArg).join(' ')}`
|
|
29
|
+
].join(' && ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function startAuthLoginTerminal({
|
|
33
|
+
platform = process.platform,
|
|
34
|
+
run = runCommand
|
|
35
|
+
} = {}) {
|
|
36
|
+
const command = buildAuthLoginShellCommand({ platform });
|
|
37
|
+
if (platform !== 'darwin') {
|
|
38
|
+
const shellName = platform === 'win32' ? 'PowerShell' : 'terminal';
|
|
39
|
+
return {
|
|
40
|
+
status: 'auth_required',
|
|
41
|
+
command: 'logwork login',
|
|
42
|
+
terminalCommand: command,
|
|
43
|
+
summary: `Run this command in ${shellName} to authenticate Resource Optimiser:\n${command}`
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await run('/usr/bin/osascript', [
|
|
48
|
+
'-e',
|
|
49
|
+
'tell application "Terminal"',
|
|
50
|
+
'-e',
|
|
51
|
+
`do script ${JSON.stringify(command)}`,
|
|
52
|
+
'-e',
|
|
53
|
+
'activate',
|
|
54
|
+
'-e',
|
|
55
|
+
'end tell'
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
status: 'auth_login_started',
|
|
60
|
+
command: 'logwork login',
|
|
61
|
+
terminalCommand: command,
|
|
62
|
+
summary: 'Opened a macOS Terminal auth session. Enter Resource Optimiser email/password and 2FA there, then retry the MCP tool.'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runCommand(command, args) {
|
|
67
|
+
return new Promise((resolveValue, reject) => {
|
|
68
|
+
const child = spawn(command, args, {
|
|
69
|
+
stdio: ['ignore', 'ignore', 'pipe']
|
|
70
|
+
});
|
|
71
|
+
let stderr = '';
|
|
72
|
+
child.stderr.setEncoding('utf8');
|
|
73
|
+
child.stderr.on('data', (chunk) => {
|
|
74
|
+
stderr += chunk;
|
|
75
|
+
});
|
|
76
|
+
child.on('error', reject);
|
|
77
|
+
child.on('close', (code) => {
|
|
78
|
+
if (code === 0) {
|
|
79
|
+
resolveValue();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
reject(new Error(`${command} failed with exit code ${code}: ${stderr.trim() || 'no stderr'}`));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function quotePosixArg(value) {
|
|
88
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function quotePowerShellArg(value) {
|
|
92
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function dirnameForPlatform(value, platform) {
|
|
96
|
+
return platform === 'win32' ? win32.dirname(value) : dirname(value);
|
|
97
|
+
}
|