logwork-cli 1.2.0 → 1.3.1
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 +18 -8
- package/README.md +41 -50
- package/RELEASE.md +3 -2
- package/bin/logwork.mjs +2 -2
- package/commands/commits.mjs +78 -14
- package/commands/mcp.mjs +266 -0
- package/commands/setup.mjs +161 -40
- package/docs/advanced.md +7 -6
- package/docs/mcp-setup.md +80 -28
- package/docs/operations-guide.md +18 -3
- package/docs/recipes.md +18 -5
- package/docs/security.md +20 -1
- 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/commit-workflow.mjs +160 -5
- package/lib/diagnostics.mjs +72 -9
- package/lib/launcher.mjs +303 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +34 -1
- package/lib/manual-ink-interaction.mjs +12 -0
- package/lib/manual-ink-ui.mjs +38 -0
- package/lib/manual-mcp.mjs +68 -22
- package/lib/manual-repl.mjs +147 -34
- package/lib/mcp-install.mjs +930 -0
- package/lib/mcp-server.mjs +2 -2
- package/lib/node-runtime.mjs +52 -12
- package/lib/project-mapping-workflow.mjs +2 -20
- package/lib/query-workflow.mjs +19 -0
- package/lib/reminder-scheduler.mjs +34 -18
- package/lib/reminder-service.mjs +18 -4
- package/lib/repo-project-suggestions.mjs +285 -0
- package/lib/ui.mjs +44 -0
- package/package.json +2 -2
- package/skills/logwork/SKILL.md +6 -1
package/docs/recipes.md
CHANGED
|
@@ -41,16 +41,29 @@ The assistant uses `list_projects` and `list_tasks`, writes `+2 <task> | <descri
|
|
|
41
41
|
|
|
42
42
|
## Log From Commits Into The Right Projects
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
Each repo needs its RO project once. Just run the draft:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
logwork commits yesterday
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For a repo with no project yet, it asks in place:
|
|
45
51
|
|
|
46
52
|
```text
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
Which RO project does portal-web belong to?
|
|
54
|
+
› Customer Portal · ticket CP-12 was logged to this project on 2026-11-03
|
|
55
|
+
GKE Fair Chem System (FCS)
|
|
56
|
+
Managed Hosting
|
|
57
|
+
Skip for now
|
|
49
58
|
```
|
|
50
59
|
|
|
51
|
-
|
|
60
|
+
The likely project is preselected. Press Enter to save it as a repo mapping; the draft is then redone with that repo's lines on the project. `Skip for now` asks again next time; `--no-prompt` never asks.
|
|
61
|
+
|
|
62
|
+
Other ways to map a repo, all without ids:
|
|
63
|
+
- Terminal UI: `/map --repo` (this folder) or `/map --repo ~/code/portal` opens the same picker; `/map --repo ~/code/portal Customer Portal` maps by name (a unique part of the name is enough).
|
|
64
|
+
- Assistant: `draft_from_commits` returns `repoSuggestions`, so the assistant asks "Is portal-web part of Customer Portal?" and calls `map_project` with `projectName` and `repos` after you say yes.
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
Mapped repos put their commits on that project. The hours come from what is still free on that project: booked minus logged. If the project is already full, its lines get 0h and the summary says so. Repos without a mapping fall back to ticket/keyword mappings and that day's bookings.
|
|
54
67
|
|
|
55
68
|
The assistant passes the draft's `projectOverrides` to `log_work` with the same text, so each line is logged to the project the draft chose.
|
|
56
69
|
|
package/docs/security.md
CHANGED
|
@@ -113,11 +113,30 @@ The runtime and local state live in `~/.logwork-cli` (`%USERPROFILE%\.logwork-cl
|
|
|
113
113
|
| `update-state.json` | Update check cache: package name, latest SemVer, and check timestamp. Expires after 24 hours and contains no npm credentials. |
|
|
114
114
|
| `reminder.json`, `reminder-state.json` | Reminder config: enabled state, local time, target, weekdays and optional git repo paths for the commit summary. Last-run state: sanitized timestamps and status summaries. |
|
|
115
115
|
| `runtime.json` | The Node path and version `logwork setup` ran with, its install time and the package version. |
|
|
116
|
+
| `bin/logwork-launch` (macOS) | The launcher MCP configs and the reminder run. A `sh` script with the runtime path and the recorded Node path, nothing else. |
|
|
116
117
|
| `diagnostics/` | Sanitized diagnostics reports from `logwork doctor`. |
|
|
117
118
|
|
|
118
119
|
The apply ledger stores the target, flow target, batch ID, timestamp, workspace path, entry metadata, and submitted/failed/blocked status. It does not store credentials, cookies, or raw API responses, and token-like error text is redacted before writing.
|
|
119
120
|
|
|
120
|
-
The macOS launch agent and Windows scheduled task
|
|
121
|
+
The macOS launch agent runs `bin/logwork-launch reminder run`, and the Windows scheduled task runs a fixed `node.exe` with `bin/logwork.mjs reminder run`. They never contain RO tokens, Jira PATs, passwords, or API responses.
|
|
122
|
+
|
|
123
|
+
## MCP Client Configs
|
|
124
|
+
|
|
125
|
+
`logwork setup` and `logwork mcp install` edit the MCP config of the clients they find, after asking (or with `--yes`):
|
|
126
|
+
|
|
127
|
+
| Client | File or command |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| Claude Code | `claude mcp remove` / `claude mcp add --scope user` (or `~/.claude.json` without the CLI) |
|
|
130
|
+
| Cursor | `~/.cursor/mcp.json` |
|
|
131
|
+
| Codex | `~/.codex/config.toml` |
|
|
132
|
+
| VS Code | user `mcp.json` (`~/Library/Application Support/Code/User`, `%APPDATA%\Code\User`) |
|
|
133
|
+
| Google Antigravity | `~/.gemini/antigravity/mcp_config.json` |
|
|
134
|
+
|
|
135
|
+
- Only the `logwork` server entry is written, plus removal of an old `logwork-helper` entry (and, in Claude Code, a stale project-scope `logwork` entry). Other servers and settings are kept.
|
|
136
|
+
- The entry holds only `command` and `args` (the launcher or `node.exe`, the runtime path and `mcp`). No token, PAT, password, cookie or email is ever written to an MCP config.
|
|
137
|
+
- Before changing a file, it is copied to `<file>.logwork-backup-<YYYYMMDDHHmmss>` in the same folder, with the same permissions as the original. The new file is written atomically. Backups are never deleted automatically; remove them when you no longer need them. A backup can contain other servers' settings (for example API keys in their `env`), exactly as the original file does.
|
|
138
|
+
- A file that does not parse cleanly (comments, trailing commas, unusual TOML) is never written; the snippet is printed instead.
|
|
139
|
+
- `logwork mcp install --dry-run` and `logwork mcp status` only read.
|
|
121
140
|
|
|
122
141
|
## Safety Model
|
|
123
142
|
|
package/lib/commit-workflow.mjs
CHANGED
|
@@ -6,7 +6,16 @@ import { CONFIG } from './config.mjs';
|
|
|
6
6
|
import { findRepoMapping, loadLocalConfig } from './logwork-config.mjs';
|
|
7
7
|
import { projectIdentityKey, sameProjectIdentity } from './project-identity.mjs';
|
|
8
8
|
import { resolveEntryProject } from './project-resolver.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
createMembershipProjectsFetcher,
|
|
11
|
+
normalizeQueryRange,
|
|
12
|
+
queryLogwork
|
|
13
|
+
} from './query-workflow.mjs';
|
|
14
|
+
import {
|
|
15
|
+
bookedProjectsByDateFromQuery,
|
|
16
|
+
historyFromQueryResult,
|
|
17
|
+
suggestRepoProjects
|
|
18
|
+
} from './repo-project-suggestions.mjs';
|
|
10
19
|
import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
11
20
|
|
|
12
21
|
// Builds a logwork draft from the user's own git commits:
|
|
@@ -18,7 +27,10 @@ import { addLocalDays, todayLocalDateISO } from './util.mjs';
|
|
|
18
27
|
// -> each project's remaining hours (RO booked - logged) are split across its
|
|
19
28
|
// groups by how much work each group holds; unresolved groups share the
|
|
20
29
|
// room of booked projects no group claimed (without RO: a standard day)
|
|
21
|
-
// -> a "+<hours> <worklog task> | <description>" block for log_work
|
|
30
|
+
// -> a "+<hours> <worklog task> | <description>" block for log_work
|
|
31
|
+
// -> for each repo whose lines got no project, ranked project suggestions
|
|
32
|
+
// (ticket history, repo/remote name, single booked project) so the user
|
|
33
|
+
// can confirm one and map the repo once.
|
|
22
34
|
// Nothing is written anywhere; the agent shows the draft, the user edits and
|
|
23
35
|
// approves it, then log_work previews and applies it as usual.
|
|
24
36
|
|
|
@@ -31,6 +43,7 @@ const CONVENTIONAL_RE = /^(\w+)(?:\([^)]*\))?!?:\s*/;
|
|
|
31
43
|
const FIELD = '\x1f';
|
|
32
44
|
const RECORD = '\x1e';
|
|
33
45
|
const MAX_DESCRIPTION_LENGTH = 240;
|
|
46
|
+
const SUGGESTION_HISTORY_DAYS = 30;
|
|
34
47
|
|
|
35
48
|
// Default RO catalog task per kind of work. These are the shared default task
|
|
36
49
|
// names; a project with its own catalog may need a different name (list_tasks).
|
|
@@ -415,7 +428,11 @@ export async function draftLogworkFromCommits({
|
|
|
415
428
|
runGit = runGitCommand,
|
|
416
429
|
queryRo = queryLogwork,
|
|
417
430
|
loadConfig = loadLocalConfig,
|
|
418
|
-
platform = process.platform
|
|
431
|
+
platform = process.platform,
|
|
432
|
+
suggestProjects = true,
|
|
433
|
+
fetchProjects,
|
|
434
|
+
historyDays = SUGGESTION_HISTORY_DAYS,
|
|
435
|
+
today = todayLocalDateISO()
|
|
419
436
|
} = {}) {
|
|
420
437
|
const range = from || to ? normalizeQueryRange({ from, to }) : normalizeQueryRange({ period });
|
|
421
438
|
const repoPaths = [...new Set((repos?.length ? repos : [cwd]).map((repo) => resolve(cwd, expandHome(String(repo)))))];
|
|
@@ -538,6 +555,25 @@ export async function draftLogworkFromCommits({
|
|
|
538
555
|
.filter((group) => !group.project)
|
|
539
556
|
.map((group) => group.repoPath))];
|
|
540
557
|
|
|
558
|
+
// Suggestions need RO (candidates and history), so they are skipped when the
|
|
559
|
+
// bookings were not read.
|
|
560
|
+
const suggestions = unmappedRepos.length && bookings && suggestProjects
|
|
561
|
+
? await buildRepoSuggestions({
|
|
562
|
+
unmappedRepos,
|
|
563
|
+
days,
|
|
564
|
+
bookings,
|
|
565
|
+
queryRo,
|
|
566
|
+
// Memberships come from the same source as the bookings: a caller that
|
|
567
|
+
// injects queryRo (tests, other workflows) gets none unless it injects
|
|
568
|
+
// fetchProjects too, so it never reaches the real RO.
|
|
569
|
+
fetchProjects: fetchProjects || (queryRo === queryLogwork ? createMembershipProjectsFetcher() : async () => []),
|
|
570
|
+
runGit,
|
|
571
|
+
cwd,
|
|
572
|
+
today,
|
|
573
|
+
historyDays
|
|
574
|
+
})
|
|
575
|
+
: { repoSuggestions: {}, projectCandidates: [] };
|
|
576
|
+
|
|
541
577
|
const result = {
|
|
542
578
|
status: !commits.length ? 'no_commits' : (draftText ? 'draft' : 'no_hours'),
|
|
543
579
|
range,
|
|
@@ -548,7 +584,9 @@ export async function draftLogworkFromCommits({
|
|
|
548
584
|
days,
|
|
549
585
|
draftText,
|
|
550
586
|
projectOverrides,
|
|
551
|
-
unmappedRepos
|
|
587
|
+
unmappedRepos,
|
|
588
|
+
repoSuggestions: suggestions.repoSuggestions,
|
|
589
|
+
projectCandidates: suggestions.projectCandidates
|
|
552
590
|
};
|
|
553
591
|
result.summary = formatCommitDraftSummary(result);
|
|
554
592
|
return result;
|
|
@@ -600,11 +638,128 @@ export function formatCommitDraftSummary(result) {
|
|
|
600
638
|
}
|
|
601
639
|
}
|
|
602
640
|
if (result.unmappedRepos?.length) {
|
|
603
|
-
lines.push('',
|
|
641
|
+
lines.push('', 'No project for the lines of these repos. Confirm each repo\'s RO project once (logwork commits asks in a terminal; /map --repo in the terminal UI; agents call map_project with projectName and repos):');
|
|
642
|
+
for (const repo of result.unmappedRepos) {
|
|
643
|
+
const top = result.repoSuggestions?.[repo]?.[0];
|
|
644
|
+
lines.push(top
|
|
645
|
+
? `- ${repo}: likely ${projectLabel(top)} (${top.reasons.join('; ')})`
|
|
646
|
+
: `- ${repo}: no suggestion; ask which project it belongs to`);
|
|
647
|
+
}
|
|
604
648
|
}
|
|
605
649
|
return lines.join('\n');
|
|
606
650
|
}
|
|
607
651
|
|
|
652
|
+
// Ranked project suggestions per unmapped repo. RO history and memberships
|
|
653
|
+
// are read once per draft and are best effort: an auth error still reaches the
|
|
654
|
+
// caller (authRequired), any other error only drops that evidence.
|
|
655
|
+
async function buildRepoSuggestions({ unmappedRepos, days, bookings, queryRo, fetchProjects, runGit, cwd, today, historyDays }) {
|
|
656
|
+
const historyResult = await bestEffort(() => queryRo({
|
|
657
|
+
from: addLocalDays(today, -historyDays),
|
|
658
|
+
to: addLocalDays(today, 1),
|
|
659
|
+
includeEntries: true,
|
|
660
|
+
cwd
|
|
661
|
+
}));
|
|
662
|
+
const memberships = await bestEffort(fetchProjects);
|
|
663
|
+
const projectCandidates = mergeCandidates([
|
|
664
|
+
...[...bookings.values()].flatMap((day) => day.projects.filter((project) => project.bookedHours > 0)),
|
|
665
|
+
...(Array.isArray(memberships) ? memberships : [])
|
|
666
|
+
]);
|
|
667
|
+
const history = historyFromQueryResult(historyResult);
|
|
668
|
+
const bookedProjectsByDate = {};
|
|
669
|
+
for (const [date, day] of bookings) {
|
|
670
|
+
bookedProjectsByDate[date] = day.projects.filter((project) => project.bookedHours > 0);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const repoSuggestions = {};
|
|
674
|
+
for (const repo of unmappedRepos) {
|
|
675
|
+
const repoGroups = days.flatMap((day) => day.groups
|
|
676
|
+
.filter((group) => group.repoPath === repo && !group.project)
|
|
677
|
+
.map((group) => ({ ...group, date: day.date })));
|
|
678
|
+
repoSuggestions[repo] = suggestRepoProjects({
|
|
679
|
+
repoPath: repo,
|
|
680
|
+
repoGroups,
|
|
681
|
+
candidates: projectCandidates,
|
|
682
|
+
history,
|
|
683
|
+
remoteUrl: await readRemoteUrl(repo, runGit),
|
|
684
|
+
bookedProjectsByDate
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return { repoSuggestions, projectCandidates };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Suggestions for one repo outside a draft (the terminal UI /map picker): the
|
|
691
|
+
// last historyDays of the repo's commits, RO entries and bookings, ranked
|
|
692
|
+
// against the given member projects.
|
|
693
|
+
export async function suggestProjectsForRepo({
|
|
694
|
+
repo,
|
|
695
|
+
projects = [],
|
|
696
|
+
author,
|
|
697
|
+
cwd = process.cwd(),
|
|
698
|
+
runGit = runGitCommand,
|
|
699
|
+
queryRo = queryLogwork,
|
|
700
|
+
historyDays = SUGGESTION_HISTORY_DAYS,
|
|
701
|
+
today = todayLocalDateISO()
|
|
702
|
+
} = {}) {
|
|
703
|
+
const repoPath = resolve(cwd, expandHome(String(repo || '.')));
|
|
704
|
+
const root = await resolveGitRepoRoot(repoPath, runGit);
|
|
705
|
+
const from = addLocalDays(today, -historyDays);
|
|
706
|
+
const to = addLocalDays(today, 1);
|
|
707
|
+
const commits = await readRepoCommits({ repo: root, from, to, author, runGit })
|
|
708
|
+
.then((result) => result.commits)
|
|
709
|
+
.catch(() => []);
|
|
710
|
+
const repoGroups = [...new Set(commits.map((commit) => commit.date))]
|
|
711
|
+
.flatMap((date) => groupCommits(commits.filter((commit) => commit.date === date)).map((group) => ({ ...group, date })));
|
|
712
|
+
const historyResult = await bestEffort(() => queryRo({ from, to, includeEntries: true, cwd }));
|
|
713
|
+
const candidates = mergeCandidates(projects);
|
|
714
|
+
return {
|
|
715
|
+
repoRoot: root,
|
|
716
|
+
candidates,
|
|
717
|
+
suggestions: suggestRepoProjects({
|
|
718
|
+
repoPath: root,
|
|
719
|
+
repoGroups,
|
|
720
|
+
candidates,
|
|
721
|
+
history: historyFromQueryResult(historyResult),
|
|
722
|
+
remoteUrl: await readRemoteUrl(root, runGit),
|
|
723
|
+
bookedProjectsByDate: bookedProjectsByDateFromQuery(historyResult)
|
|
724
|
+
})
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export async function readRemoteUrl(repo, runGit = runGitCommand) {
|
|
729
|
+
try {
|
|
730
|
+
return String(await runGit(repo, ['remote', 'get-url', 'origin'])).trim();
|
|
731
|
+
} catch {
|
|
732
|
+
return '';
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function bestEffort(action) {
|
|
737
|
+
try {
|
|
738
|
+
return await action();
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (isAuthRequiredError(error)) {
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function mergeCandidates(projects) {
|
|
748
|
+
const results = [];
|
|
749
|
+
for (const project of projects) {
|
|
750
|
+
if (!project || !(project.projectName || hasValue(project.projectMemberId))) {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const known = results.findIndex((candidate) => sameProjectIdentity(candidate, project));
|
|
754
|
+
if (known < 0) {
|
|
755
|
+
results.push(projectRef(project));
|
|
756
|
+
} else {
|
|
757
|
+
results[known] = { ...projectRef(project), ...results[known] };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return results;
|
|
761
|
+
}
|
|
762
|
+
|
|
608
763
|
function projectRef(project = {}) {
|
|
609
764
|
return {
|
|
610
765
|
projectName: project.projectName || '',
|
package/lib/diagnostics.mjs
CHANGED
|
@@ -10,7 +10,9 @@ import { getStoredAuthStatus } from './auth.mjs';
|
|
|
10
10
|
import { redactText } from './auth-redaction.mjs';
|
|
11
11
|
import { authDebugLogPath, sanitizeDiagnosticValue } from './auth-diagnostics.mjs';
|
|
12
12
|
import { configPath, loadLocalConfig } from './logwork-config.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { checkLaunchCommand, formatLaunchCheckIssue, resolveLaunchCommand } from './launcher.mjs';
|
|
14
|
+
import { createMcpInstallContext, inspectMcpClients } from './mcp-install.mjs';
|
|
15
|
+
import { formatNodePathIssue, inspectNodePath, inspectScheduledCommand, loadRuntimeInfo } from './node-runtime.mjs';
|
|
14
16
|
import { helperHome, manualDraftsPath, runtimeInfoPath, userConfigPath } from './paths.mjs';
|
|
15
17
|
import { readReminderSchedulerNodePath } from './reminder-scheduler.mjs';
|
|
16
18
|
|
|
@@ -37,6 +39,7 @@ export async function generateDiagnosticsReport({
|
|
|
37
39
|
authStatusFn = getStoredAuthStatus,
|
|
38
40
|
mcpSmokeFn = runMcpSmoke,
|
|
39
41
|
nodeRuntimeFn = collectNodeRuntimeCheck,
|
|
42
|
+
mcpClientsFn = collectMcpClientCheck,
|
|
40
43
|
readFileFn = readFile,
|
|
41
44
|
statFn = stat,
|
|
42
45
|
writeFileFn = writeFile,
|
|
@@ -50,7 +53,12 @@ export async function generateDiagnosticsReport({
|
|
|
50
53
|
const configSnapshot = await safeCollect('config snapshot', () => collectConfigSnapshot({ cwd, includeMappingDetails }));
|
|
51
54
|
const mcpSmoke = await safeCollect('mcp smoke', () => mcpSmokeFn({ cwd }));
|
|
52
55
|
const nodeRuntime = await safeCollect('node runtime', () => nodeRuntimeFn());
|
|
53
|
-
const
|
|
56
|
+
const mcpClients = await safeCollect('mcp clients', () => mcpClientsFn());
|
|
57
|
+
const runtimeIssues = Array.isArray(nodeRuntime?.issues) ? nodeRuntime.issues : [];
|
|
58
|
+
const mcpIssues = (Array.isArray(mcpClients) ? mcpClients : [])
|
|
59
|
+
.filter((client) => client.status === 'stale' || client.legacy || client.localScopes)
|
|
60
|
+
.map((client) => `MCP: ${formatMcpClientCheck(client)}. Fix: run \`logwork mcp install\`.`);
|
|
61
|
+
const nodeIssues = [...runtimeIssues, ...mcpIssues];
|
|
54
62
|
const authDebug = await readSanitizedAuthDebug({ readFileFn });
|
|
55
63
|
|
|
56
64
|
sections.push(formatSection('logwork-cli Diagnostics', [
|
|
@@ -72,13 +80,26 @@ export async function generateDiagnosticsReport({
|
|
|
72
80
|
? formatObjectLines(nodeRuntime)
|
|
73
81
|
: [
|
|
74
82
|
`runtimeFile: ${nodeRuntime.runtimeFile}`,
|
|
83
|
+
...(nodeRuntime.launchCommand ? [
|
|
84
|
+
`launchCommand: ${nodeRuntime.launchCommand}`,
|
|
85
|
+
`launchStatus: ${nodeRuntime.launchStatus}`,
|
|
86
|
+
`launchNode: ${nodeRuntime.launchNode || 'unknown'}`,
|
|
87
|
+
`launchVersion: ${nodeRuntime.launchVersion || 'unknown'}`
|
|
88
|
+
] : []),
|
|
75
89
|
`recordedNode: ${nodeRuntime.recordedNode || 'none (run logwork setup)'}${nodeRuntime.recordedVersion ? ` (${nodeRuntime.recordedVersion})` : ''}`,
|
|
76
|
-
`recordedNodeStatus: ${nodeRuntime.recordedStatus}`,
|
|
90
|
+
`recordedNodeStatus: ${nodeRuntime.recordedStatus}${nodeRuntime.recordedNote ? ` (${nodeRuntime.recordedNote})` : ''}`,
|
|
77
91
|
`reminderNode: ${nodeRuntime.reminderNode || 'none (no reminder schedule)'}`,
|
|
78
92
|
`reminderNodeStatus: ${nodeRuntime.reminderStatus}`,
|
|
79
|
-
...(
|
|
93
|
+
...(runtimeIssues.length ? runtimeIssues.map((issue) => `warning: ${issue}`) : ['warnings: none'])
|
|
80
94
|
]));
|
|
81
95
|
|
|
96
|
+
sections.push(formatSection('MCP Clients', Array.isArray(mcpClients)
|
|
97
|
+
? [
|
|
98
|
+
...(mcpClients.length ? mcpClients.map(formatMcpClientCheck) : ['none detected']),
|
|
99
|
+
...mcpIssues.map((issue) => `warning: ${issue}`)
|
|
100
|
+
]
|
|
101
|
+
: formatObjectLines(mcpClients)));
|
|
102
|
+
|
|
82
103
|
sections.push(formatSection('Commands', commandChecks.map((entry) => `${entry.command}: ${entry.available ? 'found' : 'missing'}${entry.path ? ` (${entry.path})` : ''}`)));
|
|
83
104
|
sections.push(formatSection('Files', fileChecks.map((entry) => formatFileCheck(entry))));
|
|
84
105
|
sections.push(formatSection('Config Snapshot', formatObjectLines(configSnapshot)));
|
|
@@ -101,36 +122,78 @@ export async function generateDiagnosticsReport({
|
|
|
101
122
|
};
|
|
102
123
|
}
|
|
103
124
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
125
|
+
// Checks the launch command IDE configs and the reminder run (it must exist
|
|
126
|
+
// and start a usable Node), the node recorded by `logwork setup`
|
|
127
|
+
// (runtime.json) and the command in the installed reminder schedule.
|
|
128
|
+
// Read-only: it only runs the launch command with --version.
|
|
106
129
|
export async function collectNodeRuntimeCheck({
|
|
130
|
+
launch = resolveLaunchCommand(),
|
|
107
131
|
loadRuntime = loadRuntimeInfo,
|
|
108
132
|
readSchedulerNodePath = readReminderSchedulerNodePath,
|
|
109
|
-
inspect = inspectNodePath
|
|
133
|
+
inspect = inspectNodePath,
|
|
134
|
+
inspectScheduled = (path) => inspectScheduledCommand(path, { launch }),
|
|
135
|
+
checkLaunch = () => checkLaunchCommand(launch)
|
|
110
136
|
} = {}) {
|
|
111
137
|
const runtime = await loadRuntime().catch(() => null);
|
|
112
138
|
const reminderNode = await readSchedulerNodePath().catch(() => null);
|
|
139
|
+
const launchCheck = await checkLaunch().catch((error) => ({ status: 'failed', command: launch.command, kind: launch.kind, detail: error.message }));
|
|
113
140
|
const recorded = runtime?.nodePath
|
|
114
141
|
? await inspect(runtime.nodePath)
|
|
115
142
|
: { status: 'unknown' };
|
|
116
143
|
const reminder = reminderNode
|
|
117
|
-
? await
|
|
144
|
+
? await inspectScheduled(reminderNode)
|
|
118
145
|
: { status: 'unknown' };
|
|
146
|
+
// With the launcher, a gone or switched recorded node is not fatal: the
|
|
147
|
+
// launcher falls back to another Node 22+.
|
|
148
|
+
const launcherCovers = launch.kind === 'launcher' && launchCheck?.status === 'ok';
|
|
149
|
+
const recordedNote = launcherCovers && (recorded.status === 'missing' || recorded.status === 'stale')
|
|
150
|
+
? `the launcher uses ${launchCheck.node || 'another Node'}; run \`logwork setup\` to record it`
|
|
151
|
+
: '';
|
|
119
152
|
const issues = [
|
|
120
|
-
|
|
153
|
+
formatLaunchCheckIssue(launchCheck),
|
|
154
|
+
launcherCovers ? '' : formatNodePathIssue('runtime', recorded),
|
|
121
155
|
formatNodePathIssue('reminder', reminder)
|
|
122
156
|
].filter(Boolean);
|
|
123
157
|
return {
|
|
124
158
|
runtimeFile: runtimeInfoPath(),
|
|
159
|
+
launchCommand: [launch.command, ...launch.argsPrefix].join(' '),
|
|
160
|
+
launchStatus: launchCheck?.status || 'unknown',
|
|
161
|
+
launchNode: launchCheck?.node || '',
|
|
162
|
+
launchVersion: launchCheck?.version || '',
|
|
125
163
|
recordedNode: runtime?.nodePath || '',
|
|
126
164
|
recordedVersion: runtime?.nodeVersion || '',
|
|
127
165
|
recordedStatus: recorded.status,
|
|
166
|
+
recordedNote,
|
|
128
167
|
reminderNode: reminderNode || '',
|
|
129
168
|
reminderStatus: reminder.status,
|
|
130
169
|
issues
|
|
131
170
|
};
|
|
132
171
|
}
|
|
133
172
|
|
|
173
|
+
// The `logwork` entry of each MCP client config (what `logwork mcp status`
|
|
174
|
+
// shows). Read-only.
|
|
175
|
+
export async function collectMcpClientCheck({
|
|
176
|
+
ctx = createMcpInstallContext()
|
|
177
|
+
} = {}) {
|
|
178
|
+
const clients = await inspectMcpClients(ctx);
|
|
179
|
+
return clients.filter((client) => client.detected).map((client) => ({
|
|
180
|
+
label: client.label,
|
|
181
|
+
status: client.status,
|
|
182
|
+
reason: client.reason,
|
|
183
|
+
legacy: client.legacy,
|
|
184
|
+
localScopes: (client.localScopes || []).length
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatMcpClientCheck(client) {
|
|
189
|
+
const extra = [
|
|
190
|
+
client.reason,
|
|
191
|
+
client.legacy ? 'leftover logwork-helper entry' : '',
|
|
192
|
+
client.localScopes ? `${client.localScopes} stale local-scope entries` : ''
|
|
193
|
+
].filter(Boolean).join('; ');
|
|
194
|
+
return `${client.label}: ${client.status}${extra ? ` (${extra})` : ''}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
134
197
|
async function readPackageInfo({ readFileFn }) {
|
|
135
198
|
try {
|
|
136
199
|
const packagePath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|