mintree 0.1.5 → 0.1.7
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/dist/commands/dashboard.js +60 -38
- package/dist/commands/doctor.js +33 -6
- package/dist/commands/init.js +1 -1
- package/dist/commands/worktree/clean.js +9 -6
- package/dist/commands/worktree/create.js +53 -8
- package/dist/commands/worktree/list.js +6 -6
- package/dist/commands/worktree/remove.js +1 -1
- package/dist/commands/worktree/work.js +1 -3
- package/dist/lib/dashboard.js +1 -1
- package/dist/lib/git.js +2 -4
- package/dist/lib/githubProject.d.ts +55 -0
- package/dist/lib/githubProject.js +277 -0
- package/dist/lib/metadata.d.ts +7 -0
- package/dist/lib/metadata.js +22 -0
- package/dist/lib/session-signal.js +3 -3
- package/dist/lib/version.d.ts +2 -0
- package/dist/lib/version.js +45 -0
- package/package.json +1 -1
|
@@ -8,11 +8,13 @@ import { createRequire } from "module";
|
|
|
8
8
|
import { findBranchConventionDoc, findMainRepoRoot, getCurrentBranch, getMintreeDir, pathExists, } from "../lib/git.js";
|
|
9
9
|
import { resolveClaudeBinary } from "../lib/claude.js";
|
|
10
10
|
import { tryExec } from "../lib/exec.js";
|
|
11
|
+
import { getLatestVersion, isNewerVersion } from "../lib/version.js";
|
|
11
12
|
import { ALLOWED_TYPES } from "../lib/branch.js";
|
|
12
13
|
import { runCreate, runCreateDetached } from "../lib/worktreeCreate.js";
|
|
13
14
|
import { runRemove, runRemoveByPath } from "../lib/worktreeRemove.js";
|
|
14
15
|
import { buildCreateMarkers, emitMarkers } from "../lib/markers.js";
|
|
15
|
-
import {
|
|
16
|
+
import { transitionIssueToInProgress } from "../lib/githubProject.js";
|
|
17
|
+
import { loadDashboard } from "../lib/dashboard.js";
|
|
16
18
|
const require = createRequire(import.meta.url);
|
|
17
19
|
const { version: mintreeVersion } = require("../../package.json");
|
|
18
20
|
export const description = "Interactive dashboard listing open issues assigned to you with worktree + session state";
|
|
@@ -131,11 +133,7 @@ function sanitizeDesc(value) {
|
|
|
131
133
|
}
|
|
132
134
|
function openInBrowser(url) {
|
|
133
135
|
try {
|
|
134
|
-
const cmd = process.platform === "darwin"
|
|
135
|
-
? "open"
|
|
136
|
-
: process.platform === "win32"
|
|
137
|
-
? "start"
|
|
138
|
-
: "xdg-open";
|
|
136
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
139
137
|
execSync(`${cmd} ${shQuote(url)}`, { stdio: "ignore" });
|
|
140
138
|
return true;
|
|
141
139
|
}
|
|
@@ -163,23 +161,23 @@ function useTerminalSize() {
|
|
|
163
161
|
}, [stdout]);
|
|
164
162
|
return size;
|
|
165
163
|
}
|
|
166
|
-
function HeaderRow({ repoName, claudeVersion, issueCount, }) {
|
|
167
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
|
|
164
|
+
function HeaderRow({ repoName, claudeVersion, issueCount, updateAvailable, }) {
|
|
165
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "mintree" }), _jsx(Text, { dimColor: true, children: ` v${mintreeVersion}` }), updateAvailable && _jsx(Text, { color: "yellow", children: " (*)" }), claudeVersion && _jsx(Text, { dimColor: true, children: ` · claude ${claudeVersion}` }), repoName && _jsx(Text, { dimColor: true, children: ` · ${repoName}` })] }), _jsx(Box, { children: _jsx(Text, { bold: true, backgroundColor: "cyan", color: "black", children: ` Issues (${issueCount}) ` }) })] }));
|
|
168
166
|
}
|
|
169
|
-
function FooterRow({ phase, overlayKind, }) {
|
|
167
|
+
function FooterRow({ phase, overlayKind, latestVersion, }) {
|
|
170
168
|
if (phase === "error") {
|
|
171
169
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "q quit" }) }));
|
|
172
170
|
}
|
|
173
171
|
if (overlayKind === "create") {
|
|
174
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Tab" }), _jsx(Text, { dimColor: true, children: " switch field " }), _jsx(Text, { bold: true, children: "\u2190/\u2192" }), _jsx(Text, { dimColor: true, children: " toggle branch / cycle type " }), _jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { dimColor: true, children: " create + work" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] })] }));
|
|
175
173
|
}
|
|
176
174
|
if (overlayKind === "remove") {
|
|
177
|
-
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm
|
|
175
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "y/Y" }), _jsx(Text, { dimColor: true, children: " confirm " }), _jsx(Text, { bold: true, children: "n/Esc" }), _jsx(Text, { dimColor: true, children: " cancel" })] }));
|
|
178
176
|
}
|
|
179
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav
|
|
177
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { bold: true, children: "j/k" }), _jsx(Text, { dimColor: true, children: " nav " }), _jsx(Text, { bold: true, children: "\u21B5" }), _jsx(Text, { dimColor: true, children: " work (resume / create) " }), _jsx(Text, { bold: true, children: "w" }), _jsx(Text, { dimColor: true, children: " work (always create) " }), _jsx(Text, { bold: true, children: "d" }), _jsx(Text, { dimColor: true, children: " remove" })] }) }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: " refresh " }), _jsx(Text, { bold: true, children: "o" }), _jsx(Text, { dimColor: true, children: " open in browser " }), _jsx(Text, { bold: true, children: "PgUp/PgDn" }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { bold: true, children: "wheel" }), _jsx(Text, { dimColor: true, children: " scroll detail " }), _jsx(Text, { bold: true, children: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }), latestVersion && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "(*)" }), _jsx(Text, { dimColor: true, children: ` new version available — v${latestVersion} · npm i -g mintree` })] }))] }));
|
|
180
178
|
}
|
|
181
179
|
function RemoveOverlayView({ overlay }) {
|
|
182
|
-
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State:
|
|
180
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Remove worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Branch: " }), _jsx(Text, { color: "cyan", children: overlay.branch ?? `(detached) ${overlay.worktreePath}` })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "State: " }), overlay.dirty ? (_jsx(Text, { color: "yellow", children: "dirty (uncommitted changes will be lost)" })) : (_jsx(Text, { color: "green", children: "clean" }))] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Removing the worktree leaves the branch and the issue's session_id in place. You can re-attach later with `mintree worktree create`." }) }), _jsx(Box, { marginTop: 1, children: overlay.dirty ? (_jsxs(Text, { children: ["This worktree is dirty. Press", " ", _jsx(Text, { bold: true, color: "red", children: "Y" }), " ", "to force-remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) : (_jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: "green", children: "y" }), " ", "to remove, ", _jsx(Text, { bold: true, children: "N" }), "/", _jsx(Text, { bold: true, children: "Esc" }), " to cancel."] })) }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
|
|
183
181
|
}
|
|
184
182
|
function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
185
183
|
const labelWidth = 14;
|
|
@@ -191,9 +189,7 @@ function CreateOverlayView({ overlay, onDescChange, onPromptChange, }) {
|
|
|
191
189
|
const dirPreview = isNewBranch
|
|
192
190
|
? `${overlay.issue.issue.number}-${overlay.desc}`
|
|
193
191
|
: `${overlay.issue.issue.number}-${detachedDesc}`;
|
|
194
|
-
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch
|
|
195
|
-
? "new"
|
|
196
|
-
: `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && (_jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" }))] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && (_jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" }))] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) }))] }));
|
|
192
|
+
return (_jsxs(Box, { flexGrow: 1, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Create worktree" }), _jsx(Text, { dimColor: true, children: ` for #${overlay.issue.issue.number}` })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: overlay.issue.issue.title }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "branchMode", children: overlay.field === "branchMode" ? "▸ Branch:" : " Branch:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "branchMode" ? "cyan" : undefined, bold: overlay.field === "branchMode", children: isNewBranch ? "new" : `current (${overlay.currentBranch ?? "?"})` }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "branchMode" && _jsx(Text, { dimColor: true, children: " (use ← / → to toggle)" })] }), isNewBranch && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "type", children: overlay.field === "type" ? "▸ Type:" : " Type:" }) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "< " }), _jsx(Text, { color: overlay.field === "type" ? "cyan" : undefined, bold: overlay.field === "type", children: overlay.type }), _jsx(Text, { dimColor: true, children: " >" })] }), overlay.field === "type" && _jsx(Text, { dimColor: true, children: " (use ← / → to cycle)" })] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "desc", children: overlay.field === "desc" ? "▸ Description:" : " Description:" }) }), _jsx(Box, { children: overlay.field === "desc" ? (_jsx(TextInput, { value: overlay.desc, onChange: onDescChange, placeholder: "kebab-case" })) : (_jsx(Text, { children: overlay.desc || "(empty)" })) })] })] })), _jsxs(Box, { marginTop: 0, children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { bold: overlay.field === "prompt", children: overlay.field === "prompt" ? "▸ Prompt:" : " Prompt:" }) }), _jsx(Box, { children: overlay.field === "prompt" ? (_jsx(TextInput, { value: overlay.prompt, onChange: onPromptChange, placeholder: "(empty = no initial message)" })) : (_jsx(Text, { dimColor: true, children: overlay.prompt || "(empty — Claude starts with no message)" })) })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Checkout:" }) }), _jsx(Text, { color: "green", children: branchPreview })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Worktree:" }) }), _jsxs(Text, { dimColor: true, children: [".mintree/worktrees/", dirPreview] })] }), _jsxs(Box, { children: [_jsx(Box, { width: labelWidth, children: _jsx(Text, { dimColor: true, children: " Mode:" }) }), _jsx(Text, { dimColor: true, children: "--work (Claude launches in the new worktree)" })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [isNewBranch ? (_jsxs(Text, { dimColor: true, children: ["Suggestion is a kebab of the title (capped at ", SUGGESTED_DESC_MAX_WORDS, " words). Edit it to match your repo's branch conventions."] })) : (_jsxs(Text, { dimColor: true, children: ["Detached HEAD at the tip of ", overlay.currentBranch ?? "the current branch", ". No new branch is created \u2014 commit on a new one with `git switch -c` when ready."] })), isNewBranch && overlay.conventionDoc && (_jsx(Text, { dimColor: true, children: `This repo has \`${overlay.conventionDoc}\` — review it before creating.` }))] }), overlay.error && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", overlay.error] }) })), overlay.pending && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" ", overlay.pending] })] }))] }));
|
|
197
193
|
}
|
|
198
194
|
function stateChar(state) {
|
|
199
195
|
if (!state)
|
|
@@ -242,7 +238,10 @@ function wrapLine(s, width) {
|
|
|
242
238
|
// (consecutive empty lines collapse to one) and trimming leading/trailing
|
|
243
239
|
// blank lines. Used to feed the description into the flat-line renderer.
|
|
244
240
|
function wrapBody(body, width) {
|
|
245
|
-
const raw = body
|
|
241
|
+
const raw = body
|
|
242
|
+
.replace(/\r\n/g, "\n")
|
|
243
|
+
.split("\n")
|
|
244
|
+
.map((l) => l.trimEnd());
|
|
246
245
|
while (raw.length > 0 && raw[0] === "")
|
|
247
246
|
raw.shift();
|
|
248
247
|
while (raw.length > 0 && raw[raw.length - 1] === "")
|
|
@@ -286,14 +285,17 @@ function buildDetailLines(d, width) {
|
|
|
286
285
|
const titleWrapped = wrapLine(d.issue.title, Math.max(8, w - titlePrefix.length));
|
|
287
286
|
titleWrapped.forEach((chunk, i) => {
|
|
288
287
|
if (i === 0) {
|
|
289
|
-
lines.push([
|
|
288
|
+
lines.push([
|
|
289
|
+
{ text: titlePrefix, bold: true },
|
|
290
|
+
{ text: chunk, bold: true },
|
|
291
|
+
]);
|
|
290
292
|
}
|
|
291
293
|
else {
|
|
292
294
|
lines.push([{ text: " ".repeat(titlePrefix.length) + chunk, bold: true }]);
|
|
293
295
|
}
|
|
294
296
|
});
|
|
295
|
-
const labels = d.issue.labels.map(l => l.name);
|
|
296
|
-
const labelText = labels.length > 0 ? labels.map(l => `[${l}]`).join(" ") : "(no labels)";
|
|
297
|
+
const labels = d.issue.labels.map((l) => l.name);
|
|
298
|
+
const labelText = labels.length > 0 ? labels.map((l) => `[${l}]`).join(" ") : "(no labels)";
|
|
297
299
|
for (const w2 of wrapLine(labelText, w))
|
|
298
300
|
lines.push([{ text: w2, dim: true }]);
|
|
299
301
|
lines.push([
|
|
@@ -321,9 +323,7 @@ function buildDetailLines(d, width) {
|
|
|
321
323
|
for (const w2 of wrapLine(` path: ${d.worktree.path}`, w))
|
|
322
324
|
lines.push([{ text: w2, dim: true }]);
|
|
323
325
|
const statusLine = [{ text: ` status: `, dim: true }];
|
|
324
|
-
statusLine.push(d.worktree.dirty
|
|
325
|
-
? { text: "dirty", color: "yellow" }
|
|
326
|
-
: { text: "clean", color: "green" });
|
|
326
|
+
statusLine.push(d.worktree.dirty ? { text: "dirty", color: "yellow" } : { text: "clean", color: "green" });
|
|
327
327
|
if (d.worktree.ab) {
|
|
328
328
|
statusLine.push({
|
|
329
329
|
text: ` +${d.worktree.ab.ahead} / -${d.worktree.ab.behind}`,
|
|
@@ -400,6 +400,8 @@ export default function Dashboard() {
|
|
|
400
400
|
const [state, setState] = useState({ phase: "loading" });
|
|
401
401
|
const [repoName, setRepoName] = useState(null);
|
|
402
402
|
const [claudeVersion, setClaudeVersion] = useState(null);
|
|
403
|
+
// Set only when the npm registry reports a strictly newer version.
|
|
404
|
+
const [latestVersion, setLatestVersion] = useState(null);
|
|
403
405
|
const { columns, rows } = useTerminalSize();
|
|
404
406
|
// Switch to the alt-screen buffer once, synchronously, on the first render
|
|
405
407
|
// pass. Doing this here (instead of inside a useEffect) is what makes the
|
|
@@ -446,7 +448,7 @@ export default function Dashboard() {
|
|
|
446
448
|
});
|
|
447
449
|
return;
|
|
448
450
|
}
|
|
449
|
-
setState(prev => {
|
|
451
|
+
setState((prev) => {
|
|
450
452
|
const previousIndex = prev.phase === "ready" ? prev.selectedIndex : 0;
|
|
451
453
|
const previousOverlay = prev.phase === "ready" ? prev.overlay : null;
|
|
452
454
|
const previousToast = prev.phase === "ready" ? prev.toast : null;
|
|
@@ -480,6 +482,10 @@ export default function Dashboard() {
|
|
|
480
482
|
setClaudeVersion(m && m[1] ? m[1] : v);
|
|
481
483
|
}
|
|
482
484
|
}
|
|
485
|
+
const latest = await getLatestVersion("mintree");
|
|
486
|
+
if (latest && isNewerVersion(mintreeVersion, latest)) {
|
|
487
|
+
setLatestVersion(latest);
|
|
488
|
+
}
|
|
483
489
|
})();
|
|
484
490
|
}, []);
|
|
485
491
|
// SGR mouse tracking: enable on mount, disable on unmount, and route
|
|
@@ -505,7 +511,7 @@ export default function Dashboard() {
|
|
|
505
511
|
const inLeftPane = col <= lw;
|
|
506
512
|
// Functional setState so a fast wheel doesn't read stale
|
|
507
513
|
// scroll/selection through the ref between dispatches.
|
|
508
|
-
setState(prev => {
|
|
514
|
+
setState((prev) => {
|
|
509
515
|
if (prev.phase !== "ready")
|
|
510
516
|
return prev;
|
|
511
517
|
if (prev.overlay)
|
|
@@ -638,10 +644,7 @@ export default function Dashboard() {
|
|
|
638
644
|
// as `worktree create --work`, minus the create. The wrapper
|
|
639
645
|
// will cd + run `mintree worktree work`, which itself sees the
|
|
640
646
|
// session_id in metadata and uses --resume.
|
|
641
|
-
emitMarkers([
|
|
642
|
-
`MINTREE_CD:${issue.worktree.path}`,
|
|
643
|
-
"MINTREE_WORK:1",
|
|
644
|
-
]);
|
|
647
|
+
emitMarkers([`MINTREE_CD:${issue.worktree.path}`, "MINTREE_WORK:1"]);
|
|
645
648
|
exit();
|
|
646
649
|
return;
|
|
647
650
|
}
|
|
@@ -684,6 +687,7 @@ export default function Dashboard() {
|
|
|
684
687
|
field: "branchMode",
|
|
685
688
|
error: null,
|
|
686
689
|
conventionDoc: root ? findBranchConventionDoc(root) : null,
|
|
690
|
+
pending: null,
|
|
687
691
|
},
|
|
688
692
|
toast: null,
|
|
689
693
|
});
|
|
@@ -692,6 +696,12 @@ export default function Dashboard() {
|
|
|
692
696
|
if (state.phase !== "ready" || !state.overlay)
|
|
693
697
|
return;
|
|
694
698
|
const overlay = state.overlay;
|
|
699
|
+
// When a create overlay is finishing its post-create transition the
|
|
700
|
+
// worktree is already on disk and we're about to exit() — freeze the
|
|
701
|
+
// overlay so escape / stray keys don't dismiss it mid-flight.
|
|
702
|
+
if (overlay.kind === "create" && overlay.pending) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
695
705
|
if (key.escape || (input === "c" && key.ctrl)) {
|
|
696
706
|
setState({ ...state, overlay: null });
|
|
697
707
|
return;
|
|
@@ -700,7 +710,6 @@ export default function Dashboard() {
|
|
|
700
710
|
handleRemoveOverlayInput(input, key, overlay);
|
|
701
711
|
return;
|
|
702
712
|
}
|
|
703
|
-
// Create overlay from here on.
|
|
704
713
|
// In "current" branch mode we skip type+desc fields entirely — they have
|
|
705
714
|
// no meaning when the worktree is going to be detached. Tab cycles
|
|
706
715
|
// branchMode ⇄ prompt only.
|
|
@@ -804,6 +813,23 @@ export default function Dashboard() {
|
|
|
804
813
|
});
|
|
805
814
|
return;
|
|
806
815
|
}
|
|
816
|
+
// Worktree's on disk — keep the overlay visible while we move the issue
|
|
817
|
+
// to In Progress on its project. Errors from the GraphQL call don't
|
|
818
|
+
// block the worktree hand-off; we swallow them and let `mintree doctor`
|
|
819
|
+
// surface persistent issues (missing `project` scope, etc.).
|
|
820
|
+
setState({
|
|
821
|
+
...state,
|
|
822
|
+
overlay: { ...overlay, error: null, pending: "Updating issue status..." },
|
|
823
|
+
});
|
|
824
|
+
const repoRoot = findMainRepoRoot();
|
|
825
|
+
if (repoRoot) {
|
|
826
|
+
try {
|
|
827
|
+
await transitionIssueToInProgress(repoRoot, issueNumber);
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// best effort — surface via doctor / next dashboard refresh
|
|
831
|
+
}
|
|
832
|
+
}
|
|
807
833
|
emitMarkers(buildCreateMarkers({
|
|
808
834
|
worktreePath: result.worktreePath,
|
|
809
835
|
work: result.work,
|
|
@@ -870,10 +896,10 @@ export default function Dashboard() {
|
|
|
870
896
|
// Left pane is the issue list — it only needs room for "#N ICON title".
|
|
871
897
|
// We give it ~40% of the width so the detail pane (URLs, descriptions,
|
|
872
898
|
// branch paths) has the room it actually needs.
|
|
873
|
-
const listWidthPct = 0.
|
|
899
|
+
const listWidthPct = 0.4;
|
|
874
900
|
const listWidth = Math.max(32, Math.floor(columns * listWidthPct));
|
|
875
901
|
const detailWidth = columns - listWidth - 2; // border slack
|
|
876
|
-
const identifierWidth = Math.max(3, ...issues.map(d => `#${d.issue.number}`.length));
|
|
902
|
+
const identifierWidth = Math.max(3, ...issues.map((d) => `#${d.issue.number}`.length));
|
|
877
903
|
// Lista ocupa todo menos: " #N ICON " (id + 4 cols of pad/icon).
|
|
878
904
|
const maxTitleWidth = Math.max(8, listWidth - identifierWidth - 8);
|
|
879
905
|
// Reserve rows: header (2), top borders (1), footer (3).
|
|
@@ -889,12 +915,8 @@ export default function Dashboard() {
|
|
|
889
915
|
const startIdx = Math.max(0, Math.min(Math.max(0, issues.length - listVisibleRows), selectedIndex - Math.floor(listVisibleRows / 2)));
|
|
890
916
|
const endIdx = Math.min(issues.length, startIdx + listVisibleRows);
|
|
891
917
|
const slice = issues.slice(startIdx, endIdx);
|
|
892
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [slice.map((d, i) => {
|
|
918
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { paddingX: 1, paddingTop: 0, flexDirection: "column", children: _jsx(HeaderRow, { repoName: repoName, claudeVersion: claudeVersion, issueCount: issues.length, updateAvailable: latestVersion !== null }) }), overlay ? (_jsx(Box, { flexGrow: 1, flexDirection: "column", borderStyle: "round", borderColor: overlay.kind === "remove" ? "yellow" : "cyan", children: overlay.kind === "create" ? (_jsx(CreateOverlayView, { overlay: overlay, onDescChange: onOverlayDescChange, onPromptChange: onOverlayPromptChange })) : (_jsx(RemoveOverlayView, { overlay: overlay })) })) : (_jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsx(Box, { width: listWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No open issues assigned to you in this repo." })) : (_jsxs(_Fragment, { children: [slice.map((d, i) => {
|
|
893
919
|
const absoluteIdx = startIdx + i;
|
|
894
920
|
return (_jsx(IssueListRow, { d: d, selected: absoluteIdx === selectedIndex, identifierWidth: identifierWidth, maxTitleWidth: maxTitleWidth }, d.issue.number));
|
|
895
|
-
}), startIdx > 0 &&
|
|
896
|
-
? "green"
|
|
897
|
-
: toast.kind === "error"
|
|
898
|
-
? "red"
|
|
899
|
-
: "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind })] })] }));
|
|
921
|
+
}), startIdx > 0 && _jsxs(Text, { dimColor: true, children: ["\u2191 ", startIdx, " more above"] }), endIdx < issues.length && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", issues.length - endIdx, " more below"] }))] })) }), _jsx(Box, { width: detailWidth, flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(DetailPane, { d: selected, contentWidth: detailWidth - 4, contentHeight: detailContentHeight, scrollOffset: state.detailScrollOffset }) })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [toast && (_jsx(Box, { children: _jsxs(Text, { color: toast.kind === "success" ? "green" : toast.kind === "error" ? "red" : "cyan", children: [toast.kind === "success" ? "✓ " : toast.kind === "error" ? "✗ " : "· ", toast.text] }) })), refreshing && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " refreshing" })] })), _jsx(FooterRow, { phase: "ready", overlayKind: overlay?.kind, latestVersion: latestVersion })] })] }));
|
|
900
922
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,6 +7,7 @@ import * as path from "path";
|
|
|
7
7
|
import { createRequire } from "module";
|
|
8
8
|
import { tryExec, getPath } from "../lib/exec.js";
|
|
9
9
|
import { ghCliAvailable, getGhUserLogin, getRepoFullName } from "../lib/github.js";
|
|
10
|
+
import { getGhTokenScopes, hasProjectScope } from "../lib/githubProject.js";
|
|
10
11
|
import { resolveClaudeBinary } from "../lib/claude.js";
|
|
11
12
|
import { findMainRepoRoot, getMintreeDir, getInitScriptPath, isGitIgnored, isExecutable, pathExists, } from "../lib/git.js";
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
@@ -110,6 +111,19 @@ async function checkGithubIssues() {
|
|
|
110
111
|
: undefined,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
async function checkProjectScope() {
|
|
115
|
+
const scopes = await getGhTokenScopes();
|
|
116
|
+
if (scopes === null) {
|
|
117
|
+
// Auth/install issue — surfaced by the gh row already.
|
|
118
|
+
return { scopes: null, hasScope: false };
|
|
119
|
+
}
|
|
120
|
+
const ok = hasProjectScope(scopes);
|
|
121
|
+
return {
|
|
122
|
+
scopes,
|
|
123
|
+
hasScope: ok,
|
|
124
|
+
hint: ok ? undefined : "Run: gh auth refresh -s project",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
113
127
|
function checkRemoteControl() {
|
|
114
128
|
const home = process.env["HOME"] || "";
|
|
115
129
|
const configPath = path.join(home, ".claude.json");
|
|
@@ -153,7 +167,7 @@ function checkSessionSignalHooks() {
|
|
|
153
167
|
}
|
|
154
168
|
const found = eventHooks.some((entry) => {
|
|
155
169
|
const inner = entry.hooks || [];
|
|
156
|
-
return inner.some(h => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
|
|
170
|
+
return inner.some((h) => typeof h.command === "string" && h.command.includes("mintree helpers session-signal"));
|
|
157
171
|
});
|
|
158
172
|
if (!found)
|
|
159
173
|
missing.push(event);
|
|
@@ -239,13 +253,23 @@ function StatusIcon({ ok, required }) {
|
|
|
239
253
|
function ToolRow({ tool }) {
|
|
240
254
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: tool.installed && !tool.hint, required: tool.required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: tool.name }), _jsxs(Text, { dimColor: true, children: [" - ", tool.description] }), !tool.required && _jsx(Text, { dimColor: true, children: " (optional)" })] }), tool.installed ? (_jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Version: ", tool.version] }), tool.path && _jsxs(Text, { dimColor: true, children: ["Path: ", tool.path] }), tool.authStatus && _jsxs(Text, { dimColor: true, children: ["Auth: ", tool.authStatus] }), tool.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] })] })) : (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", tool.hint] }) }))] }));
|
|
241
255
|
}
|
|
256
|
+
function ProjectScopeRow({ status }) {
|
|
257
|
+
// Optional — auto-discovery still works for the "list issues" path even
|
|
258
|
+
// without the `project` scope; the scope only matters when we need to
|
|
259
|
+
// write status back to a Project v2 board (the `w` flow does this).
|
|
260
|
+
if (status.scopes === null) {
|
|
261
|
+
// gh not installed / not authenticated — handled by the gh row.
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.hasScope, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Project v2 Scope" }), _jsx(Text, { dimColor: true, children: " - lets `w` move the issue to In Progress" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Token scopes: ", status.scopes.join(", ") || "(none)"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
|
|
265
|
+
}
|
|
242
266
|
function GithubIssuesRow({ gh }) {
|
|
243
267
|
// Required only when we're inside a git repo. Outside one, the row is
|
|
244
268
|
// purely informational (auth check) so doctor can stay green when run
|
|
245
269
|
// from $HOME or any non-repo directory.
|
|
246
270
|
const required = gh.inGitRepo;
|
|
247
271
|
const ok = required ? gh.authenticated && !!gh.repoName : gh.authenticated;
|
|
248
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required &&
|
|
272
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: ok, required: required }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "GitHub Issues" }), _jsx(Text, { dimColor: true, children: " - issue listing + PR ops" }), !required && _jsx(Text, { dimColor: true, children: " (no repo here)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [gh.authenticated ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["User: ", gh.accountName] }), required && _jsxs(Text, { dimColor: true, children: ["Repo: ", gh.repoName ?? "(not a GitHub repo)"] })] })) : (_jsx(Text, { dimColor: true, children: "Not authenticated" })), gh.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", gh.hint] })] })] }));
|
|
249
273
|
}
|
|
250
274
|
function ShellRow({ status }) {
|
|
251
275
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: true }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Shell Integration" }), _jsx(Text, { dimColor: true, children: " - enables `cd` into worktrees" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsxs(Text, { dimColor: true, children: ["Shell: ", status.shell ?? "unknown", " (MINTREE_SHELL_INTEGRATION=1)"] })) : status.shell ? (_jsx(Text, { color: "yellow", children: `↳ Add to ~/.${status.shell}rc: eval "$(mintree helpers shell-init ${status.shell})"` })) : (_jsx(Text, { color: "yellow", children: "\u21B3 Unsupported shell. mintree shell integration supports zsh and bash." })) })] }));
|
|
@@ -274,6 +298,7 @@ function MintreeSetupRow({ status }) {
|
|
|
274
298
|
export default function Doctor() {
|
|
275
299
|
const [tools, setTools] = useState(null);
|
|
276
300
|
const [gh, setGh] = useState(null);
|
|
301
|
+
const [projectScope, setProjectScope] = useState(null);
|
|
277
302
|
const [rc, setRc] = useState(null);
|
|
278
303
|
const [hooks, setHooks] = useState(null);
|
|
279
304
|
const [setup, setSetup] = useState(null);
|
|
@@ -303,25 +328,27 @@ export default function Doctor() {
|
|
|
303
328
|
};
|
|
304
329
|
toolResults.unshift(nodeRow);
|
|
305
330
|
const ghRes = await checkGithubIssues();
|
|
331
|
+
const projectScopeRes = await checkProjectScope();
|
|
306
332
|
setTools(toolResults);
|
|
307
333
|
setGh(ghRes);
|
|
334
|
+
setProjectScope(projectScopeRes);
|
|
308
335
|
setRc(checkRemoteControl());
|
|
309
336
|
setHooks(checkSessionSignalHooks());
|
|
310
337
|
setSetup(checkMintreeSetup());
|
|
311
338
|
setShell(checkShellIntegration());
|
|
312
339
|
})();
|
|
313
340
|
}, []);
|
|
314
|
-
const loading = !tools || !gh || !rc || !hooks || !setup || !shell;
|
|
341
|
+
const loading = !tools || !gh || !projectScope || !rc || !hooks || !setup || !shell;
|
|
315
342
|
if (loading) {
|
|
316
343
|
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Checking system requirements..." })] }));
|
|
317
344
|
}
|
|
318
|
-
const requiredMissing = tools.filter(t => t.required && (!t.installed || t.hint));
|
|
319
|
-
const optionalMissing = tools.filter(t => !t.required && !t.installed);
|
|
345
|
+
const requiredMissing = tools.filter((t) => t.required && (!t.installed || t.hint));
|
|
346
|
+
const optionalMissing = tools.filter((t) => !t.required && !t.installed);
|
|
320
347
|
// GitHub Issues only counts toward the required tally when we're inside a
|
|
321
348
|
// git repo; otherwise the auth-only check is purely informational.
|
|
322
349
|
const ghOk = gh.inGitRepo ? gh.authenticated && !!gh.repoName : true;
|
|
323
350
|
const shellOk = shell.configured;
|
|
324
351
|
const allRequired = requiredMissing.length === 0 && ghOk && shellOk;
|
|
325
352
|
const requiredFailing = requiredMissing.length + (ghOk ? 0 : 1) + (shellOk ? 0 : 1);
|
|
326
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map(t => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
|
|
353
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Mintree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((t) => (_jsx(ToolRow, { tool: t }, t.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), _jsx(GithubIssuesRow, { gh: gh }), _jsx(ProjectScopeRow, { status: projectScope }), _jsx(ShellRow, { status: shell }), _jsx(MintreeSetupRow, { status: setup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), _jsx(RemoteControlRow, { status: rc }), _jsx(SessionSignalRow, { status: hooks }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All required checks pass. mintree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredFailing, " required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
|
|
327
354
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -121,7 +121,7 @@ export default function Init() {
|
|
|
121
121
|
if (!result.ok) {
|
|
122
122
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
123
123
|
}
|
|
124
|
-
const anyChange = result.steps.some(s => s.kind === "created" || s.kind === "added");
|
|
124
|
+
const anyChange = result.steps.some((s) => s.kind === "created" || s.kind === "added");
|
|
125
125
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree init" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.repoRoot] })] }), result.steps.map((step, i) => {
|
|
126
126
|
const detail = stepDetail(step.kind);
|
|
127
127
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), detail && _jsxs(Text, { dimColor: true, children: [" (", detail, ")"] })] }), step.hint && (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", step.hint] }) }))] }, i));
|
|
@@ -67,7 +67,7 @@ async function loadCandidates(force) {
|
|
|
67
67
|
if (ours.length === 0) {
|
|
68
68
|
return { phase: "nothing", message: "No mintree worktrees in this repo. Nothing to clean." };
|
|
69
69
|
}
|
|
70
|
-
const prs = await Promise.all(ours.map(w => fetchPr(w.branch)));
|
|
70
|
+
const prs = await Promise.all(ours.map((w) => fetchPr(w.branch)));
|
|
71
71
|
const candidates = [];
|
|
72
72
|
for (let i = 0; i < ours.length; i++) {
|
|
73
73
|
const w = ours[i];
|
|
@@ -96,7 +96,7 @@ async function loadCandidates(force) {
|
|
|
96
96
|
return { phase: "prompt", repoRoot: root, candidates };
|
|
97
97
|
}
|
|
98
98
|
function executeRemovals(repoRoot, candidates) {
|
|
99
|
-
const toRemove = candidates.filter(c => c.willClean);
|
|
99
|
+
const toRemove = candidates.filter((c) => c.willClean);
|
|
100
100
|
const results = [];
|
|
101
101
|
for (const c of toRemove) {
|
|
102
102
|
try {
|
|
@@ -120,7 +120,10 @@ function PrTag({ pr }) {
|
|
|
120
120
|
}
|
|
121
121
|
export default function Clean({ options }) {
|
|
122
122
|
const { exit } = useApp();
|
|
123
|
-
const [state, setState] = useState({
|
|
123
|
+
const [state, setState] = useState({
|
|
124
|
+
phase: "loading",
|
|
125
|
+
message: "Inspecting worktrees...",
|
|
126
|
+
});
|
|
124
127
|
useEffect(() => {
|
|
125
128
|
(async () => {
|
|
126
129
|
try {
|
|
@@ -193,14 +196,14 @@ export default function Clean({ options }) {
|
|
|
193
196
|
return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: state.message }) }));
|
|
194
197
|
}
|
|
195
198
|
if (state.phase === "prompt" || state.phase === "executing") {
|
|
196
|
-
const willCleanCount = state.candidates.filter(c => c.willClean).length;
|
|
197
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)?
|
|
199
|
+
const willCleanCount = state.candidates.filter((c) => c.willClean).length;
|
|
200
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.candidates.length, " candidate(s)"] })] }), state.candidates.map((c, i) => (_jsxs(Box, { children: [_jsx(Text, { color: c.willClean ? "green" : "yellow", children: c.willClean ? "✓" : "○" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: c.branch }), _jsx(Text, { children: " " }), _jsx(PrTag, { pr: c.pr }), c.dirty && _jsx(Text, { color: "yellow", children: " [dirty]" }), c.reasonSkipped && _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.reasonSkipped] })] }, i))), _jsx(Box, { marginTop: 1, children: state.phase === "prompt" ? (_jsxs(Text, { children: ["Remove ", willCleanCount, " worktree(s)? ", _jsx(Text, { bold: true, children: "[y/N]" })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Removing..." })] })) })] }));
|
|
198
201
|
}
|
|
199
202
|
// state.phase === "done"
|
|
200
203
|
if (state.cancelled) {
|
|
201
204
|
return (_jsx(Box, { padding: 1, children: _jsx(Text, { dimColor: true, children: "Cancelled. No worktrees were removed." }) }));
|
|
202
205
|
}
|
|
203
|
-
const okCount = state.results.filter(r => r.ok).length;
|
|
206
|
+
const okCount = state.results.filter((r) => r.ok).length;
|
|
204
207
|
const failCount = state.results.length - okCount;
|
|
205
208
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "mintree worktree clean \u00B7 done" }) }), state.results.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { color: r.ok ? "green" : "red", children: r.ok ? "✓" : "✗" }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: r.branch }), !r.ok && _jsxs(Text, { color: "red", children: [" \u2014 ", r.error] })] }, i))), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Removed ", okCount, failCount > 0 && (_jsxs(_Fragment, { children: [", ", _jsxs(Text, { color: "red", children: [failCount, " failed"] })] })), ". Branches and metadata preserved."] }) })] }));
|
|
206
209
|
}
|
|
@@ -7,11 +7,11 @@ import { z } from "zod";
|
|
|
7
7
|
import { PERMISSION_MODES } from "../../lib/claude.js";
|
|
8
8
|
import { runCreate } from "../../lib/worktreeCreate.js";
|
|
9
9
|
import { buildCreateMarkers, emitMarkers } from "../../lib/markers.js";
|
|
10
|
+
import { findMainRepoRoot } from "../../lib/git.js";
|
|
11
|
+
import { transitionIssueToInProgress, describeTransition, } from "../../lib/githubProject.js";
|
|
10
12
|
export const description = "Create a worktree for an issue branch";
|
|
11
13
|
export const args = z.tuple([
|
|
12
|
-
z
|
|
13
|
-
.string()
|
|
14
|
-
.describe(argument({
|
|
14
|
+
z.string().describe(argument({
|
|
15
15
|
name: "branch",
|
|
16
16
|
description: "Branch in `<type>/<issue>-<kebab-desc>` format (e.g. feat/100-claude-md-inicial)",
|
|
17
17
|
})),
|
|
@@ -53,6 +53,7 @@ function StepIcon({ kind }) {
|
|
|
53
53
|
export default function Create({ args, options }) {
|
|
54
54
|
const [branch] = args;
|
|
55
55
|
const [result, setResult] = useState(null);
|
|
56
|
+
const [transition, setTransition] = useState("idle");
|
|
56
57
|
useEffect(() => {
|
|
57
58
|
setTimeout(() => {
|
|
58
59
|
try {
|
|
@@ -68,26 +69,70 @@ export default function Create({ args, options }) {
|
|
|
68
69
|
}
|
|
69
70
|
}, 0);
|
|
70
71
|
}, [branch, options.base, options.work, options.prompt, options.permissionMode]);
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
72
|
+
// Kick the Project v2 transition once the worktree is in place. Only when
|
|
73
|
+
// --work was on — non-work creates leave status untouched. Errors from the
|
|
74
|
+
// GraphQL call surface as a step but never block the worktree hand-off.
|
|
74
75
|
useEffect(() => {
|
|
75
76
|
if (!result || !result.ok)
|
|
76
77
|
return;
|
|
78
|
+
if (!result.work) {
|
|
79
|
+
setTransition("skipped");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setTransition("running");
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
(async () => {
|
|
85
|
+
const root = findMainRepoRoot();
|
|
86
|
+
if (!root) {
|
|
87
|
+
if (!cancelled)
|
|
88
|
+
setTransition("skipped");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const r = await transitionIssueToInProgress(root, result.issueId);
|
|
93
|
+
if (!cancelled)
|
|
94
|
+
setTransition(r);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (cancelled)
|
|
98
|
+
return;
|
|
99
|
+
setTransition({
|
|
100
|
+
kind: "error",
|
|
101
|
+
message: err instanceof Error ? err.message : String(err),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
};
|
|
108
|
+
}, [result]);
|
|
109
|
+
// Emit shell-wrapper markers when create succeeded AND the transition has
|
|
110
|
+
// settled (run or skipped). Goes through the emitMarkers helper so it
|
|
111
|
+
// lands in MINTREE_MARKER_FILE if set, otherwise stdout. Bypasses Ink so
|
|
112
|
+
// word-wrap can't split a long path mid-marker.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!result || !result.ok)
|
|
115
|
+
return;
|
|
116
|
+
if (transition === "idle" || transition === "running")
|
|
117
|
+
return;
|
|
77
118
|
emitMarkers(buildCreateMarkers({
|
|
78
119
|
worktreePath: result.worktreePath,
|
|
79
120
|
work: result.work,
|
|
80
121
|
promptFile: result.promptFile,
|
|
81
122
|
permissionMode: result.permissionMode,
|
|
82
123
|
}));
|
|
83
|
-
}, [result]);
|
|
124
|
+
}, [result, transition]);
|
|
84
125
|
if (!result) {
|
|
85
126
|
return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsxs(Text, { children: [" Creating worktree for ", branch, "..."] })] }));
|
|
86
127
|
}
|
|
87
128
|
if (!result.ok) {
|
|
88
129
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
89
130
|
}
|
|
90
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), _jsxs(Box, {
|
|
131
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree create" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.steps.map((step, i) => (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }, i))), transition === "running" && (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Updating issue status..." })] })), typeof transition === "object" &&
|
|
132
|
+
(() => {
|
|
133
|
+
const step = describeTransition(transition);
|
|
134
|
+
return (_jsxs(Box, { children: [_jsx(StepIcon, { kind: step.kind }), _jsx(Text, { children: " " }), _jsx(Text, { children: step.label }), step.detail && _jsxs(Text, { dimColor: true, children: [" (", step.detail, ")"] })] }));
|
|
135
|
+
})(), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "green", children: ["Worktree ready at ", _jsx(Text, { bold: true, children: result.worktreePath })] }), _jsx(Text, { dimColor: true, children: result.work
|
|
91
136
|
? "Launching Claude in the new worktree..."
|
|
92
137
|
: "Next: `mt worktree work` to start a Claude session, or `cd` and run `claude` directly." })] })] }));
|
|
93
138
|
}
|
|
@@ -58,7 +58,7 @@ async function load(checkPr) {
|
|
|
58
58
|
}
|
|
59
59
|
const worktreesDir = getWorktreesDir(root);
|
|
60
60
|
const all = listWorktrees(root);
|
|
61
|
-
const ours = all.filter(w => {
|
|
61
|
+
const ours = all.filter((w) => {
|
|
62
62
|
// Filter to worktrees that live under .mintree/worktrees/. macOS reports
|
|
63
63
|
// /private/tmp paths so use a relative-prefix check after resolving both
|
|
64
64
|
// to absolute.
|
|
@@ -70,7 +70,7 @@ async function load(checkPr) {
|
|
|
70
70
|
return { phase: "empty", repoRoot: root };
|
|
71
71
|
}
|
|
72
72
|
const metadata = readMetadata(root);
|
|
73
|
-
const rows = ours.map(w => {
|
|
73
|
+
const rows = ours.map((w) => {
|
|
74
74
|
const issueId = extractIssueId(w.branch);
|
|
75
75
|
const baseFromMeta = issueId ? metadata.issues[issueId]?.base_branch : undefined;
|
|
76
76
|
return {
|
|
@@ -82,7 +82,7 @@ async function load(checkPr) {
|
|
|
82
82
|
};
|
|
83
83
|
});
|
|
84
84
|
if (checkPr) {
|
|
85
|
-
const prResults = await Promise.all(rows.map(r =>
|
|
85
|
+
const prResults = await Promise.all(rows.map((r) => r.branch === "(detached)" ? Promise.resolve(undefined) : fetchPrStatus(r.branch)));
|
|
86
86
|
rows.forEach((r, i) => {
|
|
87
87
|
r.pr = prResults[i];
|
|
88
88
|
});
|
|
@@ -137,7 +137,7 @@ export default function List({ options }) {
|
|
|
137
137
|
if (state.phase === "empty") {
|
|
138
138
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { dimColor: true, children: ["No mintree worktrees in ", state.repoRoot, "."] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Create one with ", _jsx(Text, { bold: true, children: "mintree worktree create <branch>" }), "."] }) })] }));
|
|
139
139
|
}
|
|
140
|
-
const issueWidth = Math.max(5, ...state.rows.map(r => (r.issueId ?? "—").length));
|
|
141
|
-
const branchWidth = Math.max(6, ...state.rows.map(r => r.branch.length));
|
|
142
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: "
|
|
140
|
+
const issueWidth = Math.max(5, ...state.rows.map((r) => (r.issueId ?? "—").length));
|
|
141
|
+
const branchWidth = Math.max(6, ...state.rows.map((r) => r.branch.length));
|
|
142
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, children: pad("ISSUE", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: pad("BRANCH", branchWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "STATUS" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "\u0394" }), _jsx(Text, { children: " " }), state.checkedPr && _jsx(Text, { bold: true, children: "PR" })] }), state.rows.map((r, i) => (_jsxs(Box, { children: [_jsx(Text, { children: pad(r.issueId ?? "—", issueWidth) }), _jsx(Text, { children: " " }), _jsx(Text, { color: "cyan", children: pad(r.branch, branchWidth) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 9, children: _jsx(StatusCell, { dirty: r.dirty }) }), _jsx(Box, { width: 12, children: _jsx(AheadBehindCell, { ab: r.ab }) }), _jsx(PrCell, { pr: r.pr, checked: state.checkedPr })] }, i)))] }));
|
|
143
143
|
}
|
|
@@ -42,5 +42,5 @@ export default function Remove({ args, options }) {
|
|
|
42
42
|
if (!result.ok) {
|
|
43
43
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", result.message] }), result.hint && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u21B3 ", result.hint] }) }))] }));
|
|
44
44
|
}
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed
|
|
45
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "mintree worktree remove" }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", result.branch] })] }), result.variant === "pruned-orphan" ? (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "!" }), " worktree directory was already deleted; pruned the dangling reference"] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", children: "\u2713" }), " removed ", _jsxs(Text, { dimColor: true, children: ["(", result.worktreePath, ")"] })] }), result.wasDirty && _jsx(Text, { color: "yellow", children: "\u21B3 forced past uncommitted changes" })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Branch ", _jsx(Text, { color: "cyan", children: result.branch }), " was preserved (use `git branch -D", " ", result.branch, "` to delete it)."] }), _jsx(Text, { dimColor: true, children: "Issue metadata (incl. session_id) was preserved for re-attach." })] })] }));
|
|
46
46
|
}
|
|
@@ -50,9 +50,7 @@ function resolve(cwd) {
|
|
|
50
50
|
}
|
|
51
51
|
const worktreesDir = path.resolve(getWorktreesDir(repoRoot));
|
|
52
52
|
const cwdAbs = path.resolve(cwd);
|
|
53
|
-
const insideMintreeWorktree = cwdAbs === worktreesDir
|
|
54
|
-
? false
|
|
55
|
-
: cwdAbs.startsWith(worktreesDir + path.sep);
|
|
53
|
+
const insideMintreeWorktree = cwdAbs === worktreesDir ? false : cwdAbs.startsWith(worktreesDir + path.sep);
|
|
56
54
|
if (!insideMintreeWorktree) {
|
|
57
55
|
return {
|
|
58
56
|
ok: false,
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -126,7 +126,7 @@ export async function loadDashboard(repoRoot) {
|
|
|
126
126
|
prByBranch.set(w.branch, pr);
|
|
127
127
|
});
|
|
128
128
|
await Promise.all(prFetches);
|
|
129
|
-
return issues.map(issue => {
|
|
129
|
+
return issues.map((issue) => {
|
|
130
130
|
const issueId = String(issue.number);
|
|
131
131
|
const worktreeRaw = worktreesByIssue.get(issueId) ?? null;
|
|
132
132
|
const sessionId = metadata.issues[issueId]?.session_id;
|
package/dist/lib/git.js
CHANGED
|
@@ -16,9 +16,7 @@ export function findMainRepoRoot(cwd = process.cwd()) {
|
|
|
16
16
|
})
|
|
17
17
|
.toString()
|
|
18
18
|
.trim();
|
|
19
|
-
const absoluteCommonDir = path.isAbsolute(commonDir)
|
|
20
|
-
? commonDir
|
|
21
|
-
: path.resolve(cwd, commonDir);
|
|
19
|
+
const absoluteCommonDir = path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir);
|
|
22
20
|
// `--git-common-dir` points at `<root>/.git` in a normal repo. Its parent
|
|
23
21
|
// is the working tree root we want.
|
|
24
22
|
if (path.basename(absoluteCommonDir) === ".git") {
|
|
@@ -119,7 +117,7 @@ export function isExecutable(p) {
|
|
|
119
117
|
*/
|
|
120
118
|
export function ensureGitignoreEntries(repoRoot, entries) {
|
|
121
119
|
const gitignorePath = path.join(repoRoot, ".gitignore");
|
|
122
|
-
const toAdd = entries.filter(entry => !isGitIgnored(entry, repoRoot));
|
|
120
|
+
const toAdd = entries.filter((entry) => !isGitIgnored(entry, repoRoot));
|
|
123
121
|
if (toAdd.length === 0)
|
|
124
122
|
return [];
|
|
125
123
|
const existing = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf-8") : "";
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type TransitionResult = {
|
|
2
|
+
kind: "transitioned";
|
|
3
|
+
projectTitle: string;
|
|
4
|
+
from: string | null;
|
|
5
|
+
to: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "noop-already";
|
|
8
|
+
projectTitle: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: "noop-protected";
|
|
11
|
+
projectTitle: string;
|
|
12
|
+
current: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "skip-no-repo";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "skip-no-issue";
|
|
17
|
+
} | {
|
|
18
|
+
kind: "skip-no-project";
|
|
19
|
+
} | {
|
|
20
|
+
kind: "skip-ambiguous";
|
|
21
|
+
projects: string[];
|
|
22
|
+
} | {
|
|
23
|
+
kind: "skip-no-status-field";
|
|
24
|
+
projects: string[];
|
|
25
|
+
} | {
|
|
26
|
+
kind: "skip-no-in-progress-option";
|
|
27
|
+
projects: string[];
|
|
28
|
+
} | {
|
|
29
|
+
kind: "error";
|
|
30
|
+
message: string;
|
|
31
|
+
hint?: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
|
|
35
|
+
* project from the issue's projectItems; if there are several candidates and
|
|
36
|
+
* `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
|
|
37
|
+
* rather than guess.
|
|
38
|
+
*
|
|
39
|
+
* Skips silently when the status is already In Progress or one of the
|
|
40
|
+
* protected statuses (In Review, Done by default) — to avoid clobbering
|
|
41
|
+
* a PR-driven transition done by something else.
|
|
42
|
+
*/
|
|
43
|
+
export declare function transitionIssueToInProgress(repoRoot: string, issueNumber: number | string): Promise<TransitionResult>;
|
|
44
|
+
/**
|
|
45
|
+
* Returns the gh CLI token scopes for github.com, or null when `gh` can't be
|
|
46
|
+
* called / the user isn't authenticated. `gh auth status` writes the scopes
|
|
47
|
+
* line to stderr; we capture both streams and grep for it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getGhTokenScopes(): Promise<string[] | null>;
|
|
50
|
+
export declare function hasProjectScope(scopes: string[]): boolean;
|
|
51
|
+
export declare function describeTransition(result: TransitionResult): {
|
|
52
|
+
kind: "ok" | "skip" | "warn";
|
|
53
|
+
label: string;
|
|
54
|
+
detail?: string;
|
|
55
|
+
};
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { getRepoFullName } from "./github.js";
|
|
4
|
+
import { readMetadata } from "./metadata.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const DEFAULT_STATUS_FIELD = "Status";
|
|
7
|
+
const DEFAULT_IN_PROGRESS_OPTION = "In Progress";
|
|
8
|
+
const DEFAULT_PROTECTED_STATUSES = ["In Review", "Done"];
|
|
9
|
+
async function runGhGraphql(query, fields) {
|
|
10
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
11
|
+
for (const [key, value] of fields) {
|
|
12
|
+
if (typeof value === "number") {
|
|
13
|
+
args.push("-F", `${key}=${value}`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
args.push("-f", `${key}=${value}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const { stdout } = await execFileAsync("gh", args);
|
|
20
|
+
return JSON.parse(stdout);
|
|
21
|
+
}
|
|
22
|
+
function readProjectConfig(repoRoot) {
|
|
23
|
+
return readMetadata(repoRoot).project ?? {};
|
|
24
|
+
}
|
|
25
|
+
function parseProjectNumberFromUrl(url) {
|
|
26
|
+
const m = url.match(/\/projects\/(\d+)/);
|
|
27
|
+
return m && m[1] ? Number(m[1]) : null;
|
|
28
|
+
}
|
|
29
|
+
function interpretGhError(err) {
|
|
30
|
+
const stderr = err && typeof err === "object" && "stderr" in err
|
|
31
|
+
? String(err.stderr)
|
|
32
|
+
: err instanceof Error
|
|
33
|
+
? err.message
|
|
34
|
+
: String(err);
|
|
35
|
+
if (/INSUFFICIENT_SCOPES/i.test(stderr) || (/scope/i.test(stderr) && /project/i.test(stderr))) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "error",
|
|
38
|
+
message: "gh token is missing the `project` scope.",
|
|
39
|
+
hint: "Run: gh auth refresh -s project",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (/Could not resolve to a Repository/i.test(stderr)) {
|
|
43
|
+
return { kind: "skip-no-repo" };
|
|
44
|
+
}
|
|
45
|
+
if (/Could not resolve to an Issue/i.test(stderr)) {
|
|
46
|
+
return { kind: "skip-no-issue" };
|
|
47
|
+
}
|
|
48
|
+
const firstLine = stderr.split("\n").find((line) => line.trim().length > 0) ?? "";
|
|
49
|
+
return {
|
|
50
|
+
kind: "error",
|
|
51
|
+
message: firstLine.slice(0, 200) || "gh api graphql failed",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Moves the GitHub issue to "In Progress" on its project. Auto-discovers the
|
|
56
|
+
* project from the issue's projectItems; if there are several candidates and
|
|
57
|
+
* `.mintree/metadata.json` doesn't pin one (via `project.url`), bails out
|
|
58
|
+
* rather than guess.
|
|
59
|
+
*
|
|
60
|
+
* Skips silently when the status is already In Progress or one of the
|
|
61
|
+
* protected statuses (In Review, Done by default) — to avoid clobbering
|
|
62
|
+
* a PR-driven transition done by something else.
|
|
63
|
+
*/
|
|
64
|
+
export async function transitionIssueToInProgress(repoRoot, issueNumber) {
|
|
65
|
+
const repo = await getRepoFullName();
|
|
66
|
+
if (!repo)
|
|
67
|
+
return { kind: "skip-no-repo" };
|
|
68
|
+
const [owner, name] = repo.split("/");
|
|
69
|
+
if (!owner || !name)
|
|
70
|
+
return { kind: "skip-no-repo" };
|
|
71
|
+
const cfg = readProjectConfig(repoRoot);
|
|
72
|
+
const statusFieldName = cfg.statusField ?? DEFAULT_STATUS_FIELD;
|
|
73
|
+
const inProgressOptionName = cfg.inProgressOption ?? DEFAULT_IN_PROGRESS_OPTION;
|
|
74
|
+
const protectedStatuses = cfg.protectedStatuses ?? DEFAULT_PROTECTED_STATUSES;
|
|
75
|
+
// The Status field name is interpolated into the query (not a variable)
|
|
76
|
+
// because GraphQL field-argument names are not parameterizable through
|
|
77
|
+
// the variables object. Escape any embedded quotes to keep the query valid.
|
|
78
|
+
const escapedFieldName = statusFieldName.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
79
|
+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
|
80
|
+
repository(owner: $owner, name: $repo) {
|
|
81
|
+
issue(number: $number) {
|
|
82
|
+
id
|
|
83
|
+
projectItems(first: 20, includeArchived: false) {
|
|
84
|
+
nodes {
|
|
85
|
+
id
|
|
86
|
+
project {
|
|
87
|
+
id
|
|
88
|
+
title
|
|
89
|
+
number
|
|
90
|
+
url
|
|
91
|
+
field(name: "${escapedFieldName}") {
|
|
92
|
+
... on ProjectV2SingleSelectField {
|
|
93
|
+
id
|
|
94
|
+
name
|
|
95
|
+
options { id name }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
fieldValues(first: 30) {
|
|
100
|
+
nodes {
|
|
101
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
102
|
+
name
|
|
103
|
+
field {
|
|
104
|
+
... on ProjectV2SingleSelectField { name }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}`;
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = (await runGhGraphql(query, [
|
|
117
|
+
["owner", owner],
|
|
118
|
+
["repo", name],
|
|
119
|
+
["number", Number(issueNumber)],
|
|
120
|
+
]));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
return interpretGhError(err);
|
|
124
|
+
}
|
|
125
|
+
const issue = raw?.data?.repository?.issue;
|
|
126
|
+
if (!issue)
|
|
127
|
+
return { kind: "skip-no-issue" };
|
|
128
|
+
let nodes = issue.projectItems.nodes;
|
|
129
|
+
if (nodes.length === 0)
|
|
130
|
+
return { kind: "skip-no-project" };
|
|
131
|
+
// Honour an explicit project URL in the config before doing anything else.
|
|
132
|
+
if (cfg.url) {
|
|
133
|
+
const targetNumber = parseProjectNumberFromUrl(cfg.url);
|
|
134
|
+
nodes = nodes.filter((n) => n.project.url === cfg.url || (targetNumber !== null && n.project.number === targetNumber));
|
|
135
|
+
if (nodes.length === 0)
|
|
136
|
+
return { kind: "skip-no-project" };
|
|
137
|
+
}
|
|
138
|
+
const withField = nodes.filter((n) => n.project.field !== null);
|
|
139
|
+
if (withField.length === 0) {
|
|
140
|
+
return { kind: "skip-no-status-field", projects: nodes.map((n) => n.project.title) };
|
|
141
|
+
}
|
|
142
|
+
const withOption = withField.filter((n) => n.project.field.options.some((o) => o.name === inProgressOptionName));
|
|
143
|
+
if (withOption.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "skip-no-in-progress-option",
|
|
146
|
+
projects: withField.map((n) => n.project.title),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (withOption.length > 1) {
|
|
150
|
+
return { kind: "skip-ambiguous", projects: withOption.map((n) => n.project.title) };
|
|
151
|
+
}
|
|
152
|
+
const item = withOption[0];
|
|
153
|
+
const project = item.project;
|
|
154
|
+
const field = project.field;
|
|
155
|
+
const option = field.options.find((o) => o.name === inProgressOptionName);
|
|
156
|
+
if (!option) {
|
|
157
|
+
// Defensive — already filtered above, but TypeScript can't see it.
|
|
158
|
+
return { kind: "skip-no-in-progress-option", projects: [project.title] };
|
|
159
|
+
}
|
|
160
|
+
const currentStatus = item.fieldValues.nodes.find((v) => v.field?.name === statusFieldName)?.name ?? null;
|
|
161
|
+
if (currentStatus === inProgressOptionName) {
|
|
162
|
+
return { kind: "noop-already", projectTitle: project.title };
|
|
163
|
+
}
|
|
164
|
+
if (currentStatus !== null && protectedStatuses.includes(currentStatus)) {
|
|
165
|
+
return { kind: "noop-protected", projectTitle: project.title, current: currentStatus };
|
|
166
|
+
}
|
|
167
|
+
const mutation = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
168
|
+
updateProjectV2ItemFieldValue(input: {
|
|
169
|
+
projectId: $projectId
|
|
170
|
+
itemId: $itemId
|
|
171
|
+
fieldId: $fieldId
|
|
172
|
+
value: { singleSelectOptionId: $optionId }
|
|
173
|
+
}) {
|
|
174
|
+
projectV2Item { id }
|
|
175
|
+
}
|
|
176
|
+
}`;
|
|
177
|
+
try {
|
|
178
|
+
await runGhGraphql(mutation, [
|
|
179
|
+
["projectId", project.id],
|
|
180
|
+
["itemId", item.id],
|
|
181
|
+
["fieldId", field.id],
|
|
182
|
+
["optionId", option.id],
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return interpretGhError(err);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
kind: "transitioned",
|
|
190
|
+
projectTitle: project.title,
|
|
191
|
+
from: currentStatus,
|
|
192
|
+
to: inProgressOptionName,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Returns the gh CLI token scopes for github.com, or null when `gh` can't be
|
|
197
|
+
* called / the user isn't authenticated. `gh auth status` writes the scopes
|
|
198
|
+
* line to stderr; we capture both streams and grep for it.
|
|
199
|
+
*/
|
|
200
|
+
export async function getGhTokenScopes() {
|
|
201
|
+
try {
|
|
202
|
+
const { stdout, stderr } = await execFileAsync("gh", ["auth", "status"]);
|
|
203
|
+
const combined = `${stdout}\n${stderr}`;
|
|
204
|
+
return parseScopesFromAuthStatus(combined);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const out = err && typeof err === "object" && "stdout" in err && "stderr" in err
|
|
208
|
+
? `${String(err.stdout)}\n${String(err.stderr)}`
|
|
209
|
+
: "";
|
|
210
|
+
const parsed = parseScopesFromAuthStatus(out);
|
|
211
|
+
return parsed ?? null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function parseScopesFromAuthStatus(text) {
|
|
215
|
+
const m = text.match(/Token scopes:\s*([^\n]+)/i);
|
|
216
|
+
if (!m || !m[1])
|
|
217
|
+
return null;
|
|
218
|
+
return m[1]
|
|
219
|
+
.split(",")
|
|
220
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
export function hasProjectScope(scopes) {
|
|
224
|
+
return scopes.some((s) => s === "project" || s === "write:project");
|
|
225
|
+
}
|
|
226
|
+
export function describeTransition(result) {
|
|
227
|
+
switch (result.kind) {
|
|
228
|
+
case "transitioned":
|
|
229
|
+
return {
|
|
230
|
+
kind: "ok",
|
|
231
|
+
label: `issue → ${result.to}`,
|
|
232
|
+
detail: result.from ? `${result.projectTitle} (was: ${result.from})` : result.projectTitle,
|
|
233
|
+
};
|
|
234
|
+
case "noop-already":
|
|
235
|
+
return {
|
|
236
|
+
kind: "skip",
|
|
237
|
+
label: "issue already In Progress",
|
|
238
|
+
detail: result.projectTitle,
|
|
239
|
+
};
|
|
240
|
+
case "noop-protected":
|
|
241
|
+
return {
|
|
242
|
+
kind: "skip",
|
|
243
|
+
label: `issue kept at ${result.current}`,
|
|
244
|
+
detail: `${result.projectTitle} (status is protected)`,
|
|
245
|
+
};
|
|
246
|
+
case "skip-no-repo":
|
|
247
|
+
return { kind: "skip", label: "no GitHub repo — skipping project update" };
|
|
248
|
+
case "skip-no-issue":
|
|
249
|
+
return { kind: "skip", label: "issue not found on GitHub — skipping project update" };
|
|
250
|
+
case "skip-no-project":
|
|
251
|
+
return { kind: "skip", label: "issue not on any project — skipping project update" };
|
|
252
|
+
case "skip-ambiguous":
|
|
253
|
+
return {
|
|
254
|
+
kind: "warn",
|
|
255
|
+
label: "multiple matching projects — skipping",
|
|
256
|
+
detail: `set .mintree/metadata.json project.url to one of: ${result.projects.join(", ")}`,
|
|
257
|
+
};
|
|
258
|
+
case "skip-no-status-field":
|
|
259
|
+
return {
|
|
260
|
+
kind: "skip",
|
|
261
|
+
label: "no Status field on project — skipping",
|
|
262
|
+
detail: result.projects.join(", "),
|
|
263
|
+
};
|
|
264
|
+
case "skip-no-in-progress-option":
|
|
265
|
+
return {
|
|
266
|
+
kind: "skip",
|
|
267
|
+
label: "no In Progress option on Status field — skipping",
|
|
268
|
+
detail: result.projects.join(", "),
|
|
269
|
+
};
|
|
270
|
+
case "error":
|
|
271
|
+
return {
|
|
272
|
+
kind: "warn",
|
|
273
|
+
label: "project update failed",
|
|
274
|
+
detail: result.hint ? `${result.message} — ${result.hint}` : result.message,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
package/dist/lib/metadata.d.ts
CHANGED
|
@@ -2,9 +2,16 @@ export type IssueMeta = {
|
|
|
2
2
|
base_branch?: string;
|
|
3
3
|
session_id?: string;
|
|
4
4
|
};
|
|
5
|
+
export type ProjectMeta = {
|
|
6
|
+
url?: string;
|
|
7
|
+
statusField?: string;
|
|
8
|
+
inProgressOption?: string;
|
|
9
|
+
protectedStatuses?: string[];
|
|
10
|
+
};
|
|
5
11
|
export type Metadata = {
|
|
6
12
|
version: 1;
|
|
7
13
|
issues: Record<string, IssueMeta>;
|
|
14
|
+
project?: ProjectMeta;
|
|
8
15
|
};
|
|
9
16
|
export declare function readMetadata(repoRoot: string): Metadata;
|
|
10
17
|
export declare function writeMetadata(repoRoot: string, data: Metadata): void;
|
package/dist/lib/metadata.js
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import { getMetadataPath } from "./git.js";
|
|
3
3
|
const EMPTY = { version: 1, issues: {} };
|
|
4
|
+
function sanitizeProject(raw) {
|
|
5
|
+
if (typeof raw !== "object" || raw === null)
|
|
6
|
+
return undefined;
|
|
7
|
+
const r = raw;
|
|
8
|
+
const out = {};
|
|
9
|
+
if (typeof r["url"] === "string" && r["url"].length > 0)
|
|
10
|
+
out.url = r["url"];
|
|
11
|
+
if (typeof r["statusField"] === "string" && r["statusField"].length > 0) {
|
|
12
|
+
out.statusField = r["statusField"];
|
|
13
|
+
}
|
|
14
|
+
if (typeof r["inProgressOption"] === "string" && r["inProgressOption"].length > 0) {
|
|
15
|
+
out.inProgressOption = r["inProgressOption"];
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(r["protectedStatuses"])) {
|
|
18
|
+
const arr = r["protectedStatuses"].filter((v) => typeof v === "string" && v.length > 0);
|
|
19
|
+
if (arr.length > 0)
|
|
20
|
+
out.protectedStatuses = arr;
|
|
21
|
+
}
|
|
22
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
23
|
+
}
|
|
4
24
|
export function readMetadata(repoRoot) {
|
|
5
25
|
const filePath = getMetadataPath(repoRoot);
|
|
6
26
|
if (!fs.existsSync(filePath))
|
|
@@ -9,11 +29,13 @@ export function readMetadata(repoRoot) {
|
|
|
9
29
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
10
30
|
if (typeof parsed !== "object" || parsed === null)
|
|
11
31
|
return { ...EMPTY, issues: {} };
|
|
32
|
+
const project = sanitizeProject(parsed.project);
|
|
12
33
|
return {
|
|
13
34
|
version: 1,
|
|
14
35
|
issues: typeof parsed.issues === "object" && parsed.issues !== null
|
|
15
36
|
? parsed.issues
|
|
16
37
|
: {},
|
|
38
|
+
...(project ? { project } : {}),
|
|
17
39
|
};
|
|
18
40
|
}
|
|
19
41
|
catch {
|
|
@@ -140,15 +140,15 @@ export function installHooks() {
|
|
|
140
140
|
existingHooks[event] = hookEntries;
|
|
141
141
|
continue;
|
|
142
142
|
}
|
|
143
|
-
const filtered = existing.filter(entry => {
|
|
143
|
+
const filtered = existing.filter((entry) => {
|
|
144
144
|
if (!entry || typeof entry !== "object")
|
|
145
145
|
return true;
|
|
146
146
|
const inner = entry.hooks ?? [];
|
|
147
|
-
return !inner.some(h => {
|
|
147
|
+
return !inner.some((h) => {
|
|
148
148
|
return (h !== null &&
|
|
149
149
|
typeof h === "object" &&
|
|
150
150
|
typeof h.command === "string" &&
|
|
151
|
-
|
|
151
|
+
h.command.includes("mintree helpers session-signal"));
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
existingHooks[event] = [...filtered, ...hookEntries];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Update check: ask the npm registry for the latest published version and
|
|
2
|
+
// compare it against what's running. Best-effort — any failure (offline,
|
|
3
|
+
// timeout, private registry) resolves to null and the dashboard simply
|
|
4
|
+
// doesn't show an update hint.
|
|
5
|
+
const REGISTRY_TIMEOUT_MS = 3000;
|
|
6
|
+
export async function getLatestVersion(pkg) {
|
|
7
|
+
try {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS);
|
|
10
|
+
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, {
|
|
11
|
+
signal: controller.signal,
|
|
12
|
+
headers: { accept: "application/json" },
|
|
13
|
+
});
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
if (!res.ok)
|
|
16
|
+
return null;
|
|
17
|
+
const data = (await res.json());
|
|
18
|
+
return typeof data.version === "string" ? data.version : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Returns true when `latest` is strictly newer than `current`. Both are
|
|
25
|
+
// expected as plain `major.minor.patch` strings; anything unparseable is
|
|
26
|
+
// treated as "not newer" so we never nag on a bad comparison.
|
|
27
|
+
export function isNewerVersion(current, latest) {
|
|
28
|
+
const parse = (v) => v
|
|
29
|
+
.trim()
|
|
30
|
+
.split(".")
|
|
31
|
+
.map((n) => parseInt(n, 10));
|
|
32
|
+
const a = parse(current);
|
|
33
|
+
const b = parse(latest);
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
const ca = a[i] ?? 0;
|
|
36
|
+
const cb = b[i] ?? 0;
|
|
37
|
+
if (Number.isNaN(ca) || Number.isNaN(cb))
|
|
38
|
+
return false;
|
|
39
|
+
if (cb > ca)
|
|
40
|
+
return true;
|
|
41
|
+
if (cb < ca)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
package/package.json
CHANGED