Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6e903c22 | |||
| 1da46f10f4 | |||
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 |
+3
-2
File diff suppressed because one or more lines are too long
+48
@@ -86,6 +86,54 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **No systemd, so no auto-start on boot.** The server is a detached process
|
||||
that runs until logout or reboot. It comes back at the next Claude Code
|
||||
session start (within about 10 s), or by hand with `agenthub start`. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot, open `claude`, and the dashboard comes back within about 10 s.
|
||||
9. `/agenthub-update` completes and the server restarts on the new version.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
|
||||
@@ -104,6 +104,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -92,6 +92,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -379,7 +379,7 @@ export function AddLaneModal({
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
@@ -553,7 +553,18 @@ export function AddLaneModal({
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @file LaneConflictView.tsx
|
||||
* @description The Changes panel's view of ONE conflicted file during a merge
|
||||
* (issue #45): the file with each conflict block drawn as its two sides —
|
||||
* "mine" and "theirs", labelled from git's own markers and tinted apart — and
|
||||
* a Keep mine / Take theirs / Keep both choice per block, undoable. Save and
|
||||
* mark resolved writes the chosen text over exactly the version it opened
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
type ConflictChoice,
|
||||
type ConflictPart,
|
||||
} from "../../lib/conflicts";
|
||||
|
||||
const BASE =
|
||||
"rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
const BTN = `${BASE} border-border-light text-fg-secondary hover:text-fg-primary`;
|
||||
|
||||
function Lines({ lines, className = "" }: { lines: string[]; className?: string }) {
|
||||
return (
|
||||
<pre className={`whitespace-pre px-2 font-mono text-[11px] leading-5 ${className}`}>
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
}: {
|
||||
laneId: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
/** Open the file in the panel's plain editor instead. */
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [file, setFile] = useState<{ lines: string[]; sha?: string; usable: boolean } | null>(null);
|
||||
const [choices, setChoices] = useState<(ConflictChoice | undefined)[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [at, setAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setFile(null);
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(laneId, path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
r.available
|
||||
? { lines: r.lines, sha: r.sha, usable: !r.binary && !r.truncated && !!r.sha }
|
||||
: { lines: [], usable: false }
|
||||
);
|
||||
})
|
||||
.catch((e) => live && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
const chosen = choices.filter(Boolean).length;
|
||||
const done = total > 0 && chosen === total;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
onResolved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
|
||||
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
|
||||
});
|
||||
const takeAll = (side: "ours" | "theirs") =>
|
||||
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
|
||||
|
||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const i = (at + step + total) % total;
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView({ block: "center" });
|
||||
};
|
||||
|
||||
let blockIndex = -1;
|
||||
return (
|
||||
<div data-testid="lane-conflict-view" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<span className="text-[11px] text-status-warning">
|
||||
{t("conflict.left", { left: total - chosen, total })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.prev")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.next")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void takeAll("ours")}>
|
||||
{t("conflict.allOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void takeAll("theirs")}
|
||||
>
|
||||
{t("conflict.allTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-wrap text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!file ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !parts ? (
|
||||
<p className="text-[11px] text-status-warning">
|
||||
{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return <Lines key={n} lines={p.lines} className="text-fg-primary" />;
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="conflict-block"
|
||||
className="my-1 border-y-2 border-status-warning/70"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-2 py-0.5 text-[11px]">
|
||||
{c ? (
|
||||
<>
|
||||
<span className="text-fg-muted">{t(`conflict.chose.${c}`)}</span>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("conflict.undo")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-status-success/70 font-medium text-status-success`}
|
||||
onClick={() => choose(i, "ours")}
|
||||
>
|
||||
{t("conflict.keepOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-accent/70 font-medium text-accent`}
|
||||
onClick={() => choose(i, "theirs")}
|
||||
>
|
||||
{t("conflict.takeTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, "both")}>
|
||||
{t("conflict.keepBoth")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{i + 1}/{total}
|
||||
</span>
|
||||
</div>
|
||||
{c ? (
|
||||
<Lines
|
||||
lines={
|
||||
c === "ours" ? p.ours : c === "theirs" ? p.theirs : [...p.ours, ...p.theirs]
|
||||
}
|
||||
className="bg-surface-2 text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-l-4 border-status-success bg-status-success/20">
|
||||
<div className="bg-status-success/30 px-2 py-0.5 text-[11px] font-semibold text-status-success">
|
||||
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
|
||||
</div>
|
||||
<Lines lines={p.ours} className="text-fg-primary" />
|
||||
</div>
|
||||
<div className="border-l-4 border-accent bg-accent/20">
|
||||
<div className="bg-accent/30 px-2 py-0.5 text-[11px] font-semibold text-accent">
|
||||
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
|
||||
</div>
|
||||
<Lines lines={p.theirs} className="text-fg-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts && total > 0 && (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy || !chosen}
|
||||
onClick={() => setChoices([])}
|
||||
>
|
||||
{t("conflict.reset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !done}
|
||||
onClick={() => void save()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("conflict.save")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, switch branch, and create/delete a branch. Committing is
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
@@ -12,17 +15,41 @@
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CloudUpload,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete";
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
@@ -85,6 +112,13 @@ export function LaneGitBar({
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
@@ -102,6 +136,10 @@ export function LaneGitBar({
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
@@ -109,21 +147,31 @@ export function LaneGitBar({
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (action: Action, body?: { branch?: string }) => {
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, body || {});
|
||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// A merge that stopped on conflicts still succeeded in starting; the
|
||||
// Changes panel below lists the files to resolve.
|
||||
if (res?.conflicts?.length)
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "publish") {
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
} else if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
@@ -136,7 +184,12 @@ export function LaneGitBar({
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
if (laneNow.current === lane)
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
} else if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
@@ -170,6 +223,11 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
@@ -183,7 +241,11 @@ export function LaneGitBar({
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.upstream && git.ahead != null && git.behind != null ? (
|
||||
{git.unborn ? (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noCommits")}
|
||||
</span>
|
||||
) : git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
@@ -211,45 +273,88 @@ export function LaneGitBar({
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
||||
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A repo with no commit has nothing to push, fetch into or branch
|
||||
from — the one next step is the commit box below. */}
|
||||
{git.unborn ? (
|
||||
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
||||
) : (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
// Same `push -u <remote> HEAD` either way; a branch with no
|
||||
// upstream is being published, not updated, so it says that.
|
||||
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={
|
||||
git.hasRemote && !git.upstream
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.publish")}
|
||||
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => {
|
||||
setPublishing((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
@@ -281,6 +386,14 @@ export function LaneGitBar({
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
@@ -339,6 +452,102 @@ export function LaneGitBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publishing && !git.hasRemote && !git.unborn && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
placeholder={t("gitBar.publishUrl")}
|
||||
aria-label={t("gitBar.publishUrl")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.publishApply")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<form
|
||||
data-testid="lane-git-login"
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { protocol, host } = login.auth;
|
||||
void run(login.action, {
|
||||
credentials: { protocol, host, username: username.trim(), password, remember },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-fg-primary">
|
||||
{t("gitBar.loginTitle", { host: login.auth.host })}
|
||||
</p>
|
||||
{login.auth.rejected && (
|
||||
<p role="alert" className="text-status-danger">
|
||||
{t("gitBar.loginRejected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
aria-label={t("gitBar.loginUser")}
|
||||
placeholder={t("gitBar.loginUser")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitBar.loginPassword")}
|
||||
placeholder={t("gitBar.loginPassword")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || !password || !!busy}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitBar.loginSubmit")}
|
||||
</button>
|
||||
<Btn
|
||||
label={t("gitBar.loginCancel")}
|
||||
onClick={() => {
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
|
||||
@@ -305,6 +305,28 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: [],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/empty");
|
||||
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
// There is nothing to branch from, so the worktree cannot be created.
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -80,6 +80,13 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
@@ -301,13 +308,82 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
|
||||
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel reverts to the saved text and stays in the editor.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
@@ -341,8 +417,7 @@ describe("LaneChangesPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
|
||||
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
|
||||
const base = changes();
|
||||
@@ -1170,3 +1245,103 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a merge in progress (issue #45)", () => {
|
||||
const merging = (conflicts: number) => ({
|
||||
available: true as const,
|
||||
branch: "main",
|
||||
head: "abc1234",
|
||||
subject: "main",
|
||||
dirty: 1,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
merging: true,
|
||||
conflicts,
|
||||
});
|
||||
const conflicted = changes({
|
||||
files: [
|
||||
{
|
||||
path: "a.txt",
|
||||
status: "UU",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
unstaged: true,
|
||||
conflicted: true,
|
||||
added: 3,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
{
|
||||
path: "b.txt",
|
||||
status: "A ",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
const group = await screen.findByTestId("lane-changes-conflicts");
|
||||
expect(within(group).getByText("a.txt")).toBeInTheDocument();
|
||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||
|
||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a conflicted file in the conflict view, not the diff", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"],
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
await user.click(
|
||||
within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt")
|
||||
);
|
||||
expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work");
|
||||
});
|
||||
|
||||
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abort merge" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
|
||||
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict view: both sides of each block shown
|
||||
* under their own labels, one choice per block with undo, Save only once
|
||||
* every block is chosen — and Save writes the chosen text over the version
|
||||
* opened, then marks the file resolved. Whole-file ours/theirs reuse
|
||||
* `resolve`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneConflictView } from "../LaneConflictView";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const LINES = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> origin/main",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: LINES,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
vi.mocked(api.lanes.gitSaveFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true } as never);
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "resolve", ok: true, output: "" });
|
||||
});
|
||||
|
||||
const blocks = () => screen.getAllByTestId("conflict-block");
|
||||
|
||||
describe("LaneConflictView", () => {
|
||||
it("shows both sides under their labels and saves once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
|
||||
const [first, second] = (await screen.findAllByTestId("conflict-block")) as [
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
];
|
||||
expect(first).toHaveTextContent("Mine (HEAD)");
|
||||
expect(first).toHaveTextContent("Theirs (origin/main)");
|
||||
const save = screen.getByRole("button", { name: "Save and mark resolved" });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.click(within(first).getByRole("button", { name: "Keep mine" }));
|
||||
// Chosen: the block is plain text now, with an undo.
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Undo" }));
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Take theirs" }));
|
||||
await user.click(within(second).getByRole("button", { name: "Keep both" }));
|
||||
expect(save).toBeEnabled();
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
|
||||
3,
|
||||
"a.txt",
|
||||
"top\ntheirs\nmiddle\na\nb",
|
||||
"s1"
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "done",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes a whole side for the file through resolve", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
await screen.findAllByTestId("conflict-block");
|
||||
await user.click(screen.getByRole("button", { name: "Take all theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(api.lanes.gitSaveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the plain editor when the markers do not balance", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: ["<<<<<<< HEAD", "x"],
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={vi.fn()} onEdit={onEdit} />);
|
||||
await screen.findByText(/markers do not line up/);
|
||||
await user.click(screen.getByRole("button", { name: "Edit by hand" }));
|
||||
expect(onEdit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that it offers no commit (the
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
@@ -24,6 +25,7 @@ function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}):
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
unborn: false,
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
@@ -79,6 +81,65 @@ describe("LaneGitBar", () => {
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({
|
||||
branch: "main",
|
||||
head: null,
|
||||
unborn: true,
|
||||
subject: "",
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
})}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
||||
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a repo with no remote to a typed URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
||||
url: "git@host:owner/repo.git",
|
||||
})
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
@@ -255,3 +316,91 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git login (issue #45)", () => {
|
||||
const authErr = (rejected = false) =>
|
||||
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
||||
code: "EGITAUTH",
|
||||
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
||||
});
|
||||
|
||||
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
||||
credentials: {
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "s3cret",
|
||||
remember: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockRejectedValueOnce(authErr())
|
||||
.mockRejectedValueOnce(authErr(true));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
||||
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
||||
);
|
||||
});
|
||||
|
||||
it("disables pull and switch while a merge is in progress", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -249,10 +293,44 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"addLaneBranchModeExisting": "Existing branch",
|
||||
"addLaneBranchModeNew": "New branch",
|
||||
"addLaneBranchPlaceholder": "Automatic — named from the title",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
|
||||
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
|
||||
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
|
||||
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
@@ -112,6 +113,9 @@
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
@@ -178,7 +182,14 @@
|
||||
"gitBar.newLane": "New lane here",
|
||||
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
|
||||
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
|
||||
"gitBar.noCommits": "no commits yet",
|
||||
"gitBar.noUpstream": "not pushed",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish branch",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — this branch has no upstream yet",
|
||||
"gitBar.publishHint": "Add a remote named origin and push this branch to it (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "Remote URL: https://host/owner/repo.git or git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
@@ -189,6 +200,7 @@
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
@@ -250,5 +262,51 @@
|
||||
"changes.hunkOf": "{{n}} of {{total}}",
|
||||
"changes.noEditBinary": "Binary file — not editable here",
|
||||
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
|
||||
"changes.noEdit": "Not editable here"
|
||||
"changes.noEdit": "Not editable here",
|
||||
"gitBar.loginTitle": "Git needs a login for {{host}}",
|
||||
"gitBar.loginRejected": "The server rejected that username or token.",
|
||||
"gitBar.loginUser": "Username",
|
||||
"gitBar.loginPassword": "Password or access token",
|
||||
"gitBar.loginRemember": "Remember for everyone using this AgentHub",
|
||||
"gitBar.loginTokenHint": "On Gitea: Settings → Applications → Generate token, with repository read/write.",
|
||||
"gitBar.loginInsecure": "This dashboard is on plain http — the token crosses the network unencrypted.",
|
||||
"gitBar.loginSubmit": "Log in and retry",
|
||||
"gitBar.loginCancel": "Cancel",
|
||||
"gitBar.merge": "Merge into current",
|
||||
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
"conflict.left": "{{left}} of {{total}} conflicts left",
|
||||
"conflict.prev": "Previous conflict",
|
||||
"conflict.next": "Next conflict",
|
||||
"conflict.allOurs": "Keep all mine",
|
||||
"conflict.allTheirs": "Take all theirs",
|
||||
"conflict.editByHand": "Edit by hand",
|
||||
"conflict.unbalanced": "The conflict markers do not line up (edited by hand?) — use Edit by hand.",
|
||||
"conflict.unusable": "This file cannot be shown here (binary or too large) — use Keep all mine / Take all theirs, or a terminal.",
|
||||
"conflict.keepOurs": "Keep mine",
|
||||
"conflict.takeTheirs": "Take theirs",
|
||||
"conflict.keepBoth": "Keep both",
|
||||
"conflict.chose.ours": "Kept mine",
|
||||
"conflict.chose.theirs": "Took theirs",
|
||||
"conflict.chose.both": "Kept both",
|
||||
"conflict.undo": "Undo",
|
||||
"conflict.ours": "Mine ({{label}})",
|
||||
"conflict.theirs": "Theirs ({{label}})",
|
||||
"conflict.reset": "Cancel",
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +510,23 @@
|
||||
"lightThemes": "Light themes",
|
||||
"active": "· in use now",
|
||||
"default": "Default"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
"add": "Add login",
|
||||
"replace": "Replace token",
|
||||
"remove": "Delete",
|
||||
"removeConfirm": "Delete this git login? Dashboard fetch/pull/push to that host will ask for a login again.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fieldHost": "Server URL",
|
||||
"fieldUser": "Username",
|
||||
"fieldToken": "Password or access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Username",
|
||||
"colSaved": "Saved",
|
||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"addLaneBranchModeExisting": "Nhánh có sẵn",
|
||||
"addLaneBranchModeNew": "Nhánh mới",
|
||||
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
|
||||
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
|
||||
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
|
||||
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
@@ -112,6 +113,9 @@
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
@@ -178,7 +182,14 @@
|
||||
"gitBar.newLane": "Tạo lane mới",
|
||||
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
|
||||
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
|
||||
"gitBar.noCommits": "chưa có commit",
|
||||
"gitBar.noUpstream": "chưa push",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish nhánh",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — nhánh này chưa có upstream",
|
||||
"gitBar.publishHint": "Thêm remote tên origin rồi push nhánh này lên đó (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "URL remote: https://host/owner/repo.git hoặc git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
@@ -189,6 +200,7 @@
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
@@ -250,5 +262,51 @@
|
||||
"changes.hunkOf": "{{n}} / {{total}}",
|
||||
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
|
||||
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
|
||||
"changes.noEdit": "Không sửa được ở đây"
|
||||
"changes.noEdit": "Không sửa được ở đây",
|
||||
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
|
||||
"gitBar.loginRejected": "Máy chủ từ chối (rejected) tài khoản hoặc token này.",
|
||||
"gitBar.loginUser": "Tên đăng nhập",
|
||||
"gitBar.loginPassword": "Mật khẩu hoặc access token",
|
||||
"gitBar.loginRemember": "Ghi nhớ cho mọi người dùng AgentHub",
|
||||
"gitBar.loginTokenHint": "Trên Gitea: Settings → Applications → Generate token, cấp quyền đọc/ghi repository.",
|
||||
"gitBar.loginInsecure": "Dashboard đang chạy http thường - token sẽ đi qua mạng không mã hoá.",
|
||||
"gitBar.loginSubmit": "Đăng nhập và thử lại",
|
||||
"gitBar.loginCancel": "Huỷ",
|
||||
"gitBar.merge": "Merge vào nhánh hiện tại",
|
||||
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
"conflict.left": "Còn {{left}}/{{total}} khối conflict",
|
||||
"conflict.prev": "Khối trước",
|
||||
"conflict.next": "Khối sau",
|
||||
"conflict.allOurs": "Giữ hết của mình",
|
||||
"conflict.allTheirs": "Lấy hết bản kia",
|
||||
"conflict.editByHand": "Sửa tay",
|
||||
"conflict.unbalanced": "Dấu conflict không khớp nhau (đã sửa tay?) - hãy dùng Sửa tay.",
|
||||
"conflict.unusable": "Không hiển thị được file này ở đây (nhị phân hoặc quá lớn) - hãy dùng Giữ hết của mình / Lấy hết bản kia, hoặc terminal.",
|
||||
"conflict.keepOurs": "Giữ của mình",
|
||||
"conflict.takeTheirs": "Lấy bản kia",
|
||||
"conflict.keepBoth": "Giữ cả hai",
|
||||
"conflict.chose.ours": "Đã giữ của mình",
|
||||
"conflict.chose.theirs": "Đã lấy bản kia",
|
||||
"conflict.chose.both": "Đã giữ cả hai",
|
||||
"conflict.undo": "Hoàn tác",
|
||||
"conflict.ours": "Của mình ({{label}})",
|
||||
"conflict.theirs": "Bản kia ({{label}})",
|
||||
"conflict.reset": "Huỷ",
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file"
|
||||
}
|
||||
|
||||
@@ -163,5 +163,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +510,23 @@
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
"add": "Thêm tài khoản",
|
||||
"replace": "Thay token",
|
||||
"remove": "Xoá",
|
||||
"removeConfirm": "Xoá tài khoản git này? Fetch/pull/push tới host đó từ dashboard sẽ hỏi đăng nhập lại.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"fieldHost": "URL máy chủ",
|
||||
"fieldUser": "Tên đăng nhập",
|
||||
"fieldToken": "Mật khẩu hoặc access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Tên đăng nhập",
|
||||
"colSaved": "Đã lưu",
|
||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict parser behind the Changes panel's
|
||||
* conflict view: plain and diff3 blocks, the two side labels, applying a
|
||||
* choice per block, and refusing a file whose markers do not balance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConflicts, applyChoices } from "../conflicts";
|
||||
|
||||
const FILE = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"=======",
|
||||
"theirs 1",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"||||||| base",
|
||||
"old",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> feat",
|
||||
"end",
|
||||
];
|
||||
|
||||
describe("parseConflicts", () => {
|
||||
it("splits text from conflict blocks and reads both labels", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(parts.map((p) => p.kind)).toEqual(["text", "conflict", "text", "conflict", "text"]);
|
||||
expect(parts[1]).toMatchObject({
|
||||
ours: ["mine 1", "mine 2"],
|
||||
theirs: ["theirs 1"],
|
||||
oursLabel: "HEAD",
|
||||
theirsLabel: "origin/main",
|
||||
});
|
||||
// diff3: the base section is not either side.
|
||||
expect(parts[3]).toMatchObject({ ours: ["a"], theirs: ["b"], theirsLabel: "feat" });
|
||||
});
|
||||
|
||||
it("returns an empty list of blocks for a file without markers", () => {
|
||||
expect(parseConflicts(["a", "b"])).toEqual([{ kind: "text", lines: ["a", "b"] }]);
|
||||
});
|
||||
|
||||
it("refuses markers that do not balance", () => {
|
||||
expect(parseConflicts(["<<<<<<< HEAD", "x", "======="])).toBeNull();
|
||||
expect(parseConflicts(["x", ">>>>>>> feat"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyChoices", () => {
|
||||
it("writes each block as the chosen side, both sides in order for 'both'", () => {
|
||||
const parts = parseConflicts(FILE)!;
|
||||
expect(applyChoices(parts, ["ours", "both"])).toEqual([
|
||||
"top",
|
||||
"mine 1",
|
||||
"mine 2",
|
||||
"middle",
|
||||
"a",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
expect(applyChoices(parts, ["theirs", "theirs"])).toEqual([
|
||||
"top",
|
||||
"theirs 1",
|
||||
"middle",
|
||||
"b",
|
||||
"end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+70
-5
@@ -539,6 +539,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
// EGITAUTH from a lane git action: which host wants a login.
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
@@ -1921,6 +1923,24 @@ export const api = {
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
/** GET /api/git-credentials */
|
||||
list: () => request<{ items: GitCredential[] }>("/git-credentials"),
|
||||
/** POST /api/git-credentials - save, or replace the one for that host. */
|
||||
save: (body: GitCredentialInput) =>
|
||||
request<{ items: GitCredential[] }>("/git-credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** DELETE /api/git-credentials/:protocol/:host */
|
||||
remove: (protocol: string, host: string) =>
|
||||
request<{ items: GitCredential[] }>(
|
||||
`/git-credentials/${encodeURIComponent(protocol)}/${encodeURIComponent(host)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
@@ -1982,6 +2002,14 @@ export const api = {
|
||||
if (q) qs.set("q", q);
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
* server's machine; the returned path is what gets pasted into the pane. */
|
||||
dropImage: (file: Blob) =>
|
||||
request<{ path: string }>("/run/drop-image", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -2505,12 +2533,13 @@ export const api = {
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
|
||||
* `branch-delete`.
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
|
||||
* `branch-delete` or `publish`.
|
||||
* @param body `{message, only}` for commit; `{branch, create}` for switch;
|
||||
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
|
||||
* `{paths, confirm: true}` for discard — which is irreversible, so the
|
||||
* server refuses it without that flag.
|
||||
* server refuses it without that flag; `{url}` for publish (a repo with
|
||||
* no remote yet: adds `origin` and pushes HEAD with an upstream).
|
||||
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
||||
*/
|
||||
gitAction: (
|
||||
@@ -2527,8 +2556,14 @@ export const api = {
|
||||
| "stage-hunk"
|
||||
| "discard-hunk"
|
||||
| "branch-create"
|
||||
| "branch-delete",
|
||||
| "branch-delete"
|
||||
| "publish"
|
||||
| "merge"
|
||||
| "resolve"
|
||||
| "merge-commit"
|
||||
| "merge-abort",
|
||||
body: {
|
||||
url?: string;
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
branch?: string;
|
||||
@@ -2539,9 +2574,14 @@ export const api = {
|
||||
hunk?: string;
|
||||
context?: number;
|
||||
confirm?: boolean;
|
||||
/** resolve: which side wins, or `done` for a hand-edited file. */
|
||||
side?: "ours" | "theirs" | "done";
|
||||
/** fetch/pull/push after `EGITAUTH`: used for this one command, saved
|
||||
* only when git accepted it and `remember` is set. */
|
||||
credentials?: GitCredentialInput & { remember: boolean };
|
||||
} = {}
|
||||
) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string }>(
|
||||
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
||||
`/lanes/${id}/git/${action}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
@@ -2903,6 +2943,31 @@ export interface AiRouterModels {
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt: string;
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The `auth` block of a 409 `EGITAUTH`: the host git needed a login for. */
|
||||
export interface GitAuthNeeded {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string | null;
|
||||
/** true when a login WAS sent and the host refused it. */
|
||||
rejected: boolean;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Parses git's merge-conflict markers out of a file's lines, and writes
|
||||
* the file back with one side (or both) chosen per block — the model behind
|
||||
* the Changes panel's conflict view. Handles diff3 style (`|||||||` base
|
||||
* section, dropped: it is neither side). Pure, no React.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type ConflictChoice = "ours" | "theirs" | "both";
|
||||
|
||||
export type ConflictPart =
|
||||
| { kind: "text"; lines: string[] }
|
||||
| { kind: "conflict"; ours: string[]; theirs: string[]; oursLabel: string; theirsLabel: string };
|
||||
|
||||
const OPEN = /^<{7}(?: (.*))?$/;
|
||||
const BASE = /^\|{7}(?: .*)?$/;
|
||||
const MID = /^={7}$/;
|
||||
const CLOSE = /^>{7}(?: (.*))?$/;
|
||||
|
||||
/**
|
||||
* The file as text runs and conflict blocks, or null when its markers do not
|
||||
* balance (a hand edit left half a block) — the caller falls back to the
|
||||
* plain editor then rather than guess.
|
||||
*/
|
||||
export function parseConflicts(lines: string[]): ConflictPart[] | null {
|
||||
const parts: ConflictPart[] = [];
|
||||
let text: string[] = [];
|
||||
let block: { ours: string[]; theirs: string[]; oursLabel: string } | null = null;
|
||||
let section: "ours" | "base" | "theirs" = "ours";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!block) {
|
||||
const open = line.match(OPEN);
|
||||
if (open) {
|
||||
if (text.length) parts.push({ kind: "text", lines: text });
|
||||
text = [];
|
||||
block = { ours: [], theirs: [], oursLabel: open[1] ?? "" };
|
||||
section = "ours";
|
||||
} else if (CLOSE.test(line) || MID.test(line)) {
|
||||
return null;
|
||||
} else {
|
||||
text.push(line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section === "ours" && BASE.test(line)) section = "base";
|
||||
else if (section !== "theirs" && MID.test(line)) section = "theirs";
|
||||
else if (section === "theirs" && CLOSE.test(line)) {
|
||||
const close = line.match(CLOSE)!;
|
||||
parts.push({ kind: "conflict", ...block, theirsLabel: close[1] ?? "" });
|
||||
block = null;
|
||||
} else if (OPEN.test(line)) return null;
|
||||
else if (section === "ours") block.ours.push(line);
|
||||
else if (section === "theirs") block.theirs.push(line);
|
||||
}
|
||||
if (block) return null;
|
||||
if (text.length || !parts.length) parts.push({ kind: "text", lines: text });
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The file's lines with block `i` replaced by `choices[i]`; an unchosen block keeps its markers. */
|
||||
export function applyChoices(
|
||||
parts: ConflictPart[],
|
||||
choices: (ConflictChoice | undefined)[]
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for (const p of parts) {
|
||||
if (p.kind === "text") {
|
||||
out.push(...p.lines);
|
||||
continue;
|
||||
}
|
||||
const c = choices[i++];
|
||||
if (c === "ours") out.push(...p.ours);
|
||||
else if (c === "theirs") out.push(...p.theirs);
|
||||
else if (c === "both") out.push(...p.ours, ...p.theirs);
|
||||
else
|
||||
out.push(
|
||||
`<<<<<<< ${p.oursLabel}`.trimEnd(),
|
||||
...p.ours,
|
||||
"=======",
|
||||
...p.theirs,
|
||||
`>>>>>>> ${p.theirsLabel}`.trimEnd()
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+10
-3
@@ -2437,6 +2437,7 @@ export interface Lane {
|
||||
gate_decision: string | null;
|
||||
ci_status: string | null;
|
||||
needs_action: string | null;
|
||||
needs_action_session?: string | null;
|
||||
links: Record<string, string>;
|
||||
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
||||
notes: string | null;
|
||||
@@ -2584,9 +2585,11 @@ export type LaneGitFacts =
|
||||
available: true;
|
||||
/** Branch name, or the literal `HEAD` for a detached checkout. */
|
||||
branch: string;
|
||||
/** Short commit hash of HEAD. */
|
||||
head: string;
|
||||
/** Subject line of the HEAD commit. */
|
||||
/** Short commit hash of HEAD; null in a repo with no commit yet. */
|
||||
head: string | null;
|
||||
/** True when the repo has no commit yet (a fresh `git init`). */
|
||||
unborn?: boolean;
|
||||
/** Subject line of the HEAD commit; "" when unborn. */
|
||||
subject: string;
|
||||
/** Tracked files with uncommitted changes. */
|
||||
dirty: number;
|
||||
@@ -2603,6 +2606,10 @@ export type LaneGitFacts =
|
||||
ahead: number | null;
|
||||
/** Commits the upstream has that HEAD does not; null without an upstream. */
|
||||
behind: number | null;
|
||||
/** A merge is in progress (issue #45). Absent on an older server. */
|
||||
merging?: boolean;
|
||||
/** Files the merge still holds unmerged, repo-wide. */
|
||||
conflicts?: number;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file GitCredentialsConfig.tsx
|
||||
* @description The **Git credentials** tab of Settings (issue #45) — the
|
||||
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
|
||||
* host, shared by everyone using this install. Lists them, replaces a token,
|
||||
* adds one, deletes one. A token is write-only here: the server never returns
|
||||
* one, so the form starts blank and the list shows only that one is saved.
|
||||
* The git bar's login form saves into the same store.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { api, type GitCredential } from "../lib/api";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
|
||||
export function GitCredentialsConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<GitCredential[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.gitCredentials.list()).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setError(null);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(form.url.trim());
|
||||
} catch {
|
||||
setError(t("gitCreds.badUrl"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.gitCredentials.save({
|
||||
protocol: url.protocol.replace(/:$/, ""),
|
||||
host: url.host,
|
||||
username: form.username.trim(),
|
||||
password: form.token,
|
||||
});
|
||||
setItems(r.items);
|
||||
setForm(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (c: GitCredential) => {
|
||||
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
||||
try {
|
||||
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" />
|
||||
{t("gitCreds.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ url: "", username: "", token: "" })}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="https://git.example.com"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{new Date(c.savedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
||||
title={t("gitCreds.replace")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
url: `${c.protocol}://${c.host}`,
|
||||
username: c.username,
|
||||
token: "",
|
||||
})
|
||||
}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.remove")} ${c.host}`}
|
||||
title={t("gitCreds.remove")}
|
||||
onClick={() => void remove(c)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
|
||||
{t("gitCreds.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -33,6 +34,12 @@ const TOOL_TABS: ToolTab[] = [
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
labelKey: "tools.gitCredentials",
|
||||
Icon: KeyRound,
|
||||
Panel: GitCredentialsConfig,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -583,8 +583,11 @@ export function Workspace() {
|
||||
|
||||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||||
// so the form is fully reachable on short windows.
|
||||
const viewportLocked = paneHasActiveRun;
|
||||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||||
// hides that form anyway, and without a locked height it shrinks to its
|
||||
// content — the editor inside it collapsed to a few lines (no live run).
|
||||
const viewportLocked =
|
||||
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -1032,6 +1035,7 @@ export function Workspace() {
|
||||
</div>
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||
* logins without ever rendering a token, replaces the token for a host, adds
|
||||
* one from a URL, and deletes only after a confirm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitCredentialsConfig } from "../GitCredentialsConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
protocol: "https" as const,
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
savedAt: "2026-09-25T01:00:00.000Z",
|
||||
hasToken: true as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.gitCredentials.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.save)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.remove).mockReset().mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
describe("GitCredentialsConfig", () => {
|
||||
it("lists a saved login and replaces its token", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
expect(await screen.findByText("git.smartgift.io.vn")).toBeInTheDocument();
|
||||
expect(screen.getByText("vi")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" }));
|
||||
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("gitCreds.fieldToken")).toHaveValue("");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "newtok");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "newtok",
|
||||
})
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("newtok");
|
||||
});
|
||||
|
||||
it("adds a login from a URL with a port", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("gitCreds.fieldHost"),
|
||||
"http://gitea.lan:3000/org/repo.git"
|
||||
);
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "http",
|
||||
host: "gitea.lan:3000",
|
||||
username: "bot",
|
||||
password: "t",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes only after a confirm", async () => {
|
||||
const confirm = vi
|
||||
.spyOn(window, "confirm")
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
|
||||
await user.click(del);
|
||||
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
|
||||
await user.click(del);
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
gitTree: r({ entries: [] }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
|
||||
@@ -10364,7 +10364,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.8.7 (rebuild client to match)
|
||||
UI build v1.8.10 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+79
-5
@@ -224,7 +224,10 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
|
||||
runs will spawn. Immutable once the lane is created; an attempt to update it on
|
||||
an existing lane returns `400 EBADTOOL`.
|
||||
|
||||
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
|
||||
`sourceRepo` must exist, be absolute, and be a git repository with at least
|
||||
one commit — a repo with no commits has nothing to branch from and is refused
|
||||
with `400 EEMPTYREPO` before any lane row is created (adopt it as a Repo lane
|
||||
with `POST /api/lanes/ensure` instead, commit, then `publish`). When `base` is
|
||||
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
|
||||
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
|
||||
defaults to `feat/`). `title` (or an optional `slug`) determines the
|
||||
@@ -380,6 +383,7 @@ What the lane's directory looks like to git right now:
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"unborn": false,
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
@@ -399,6 +403,9 @@ What the lane's directory looks like to git right now:
|
||||
not count its siblings' files (the same scoping as `/git/changes`).
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- A repo with **no commit yet** (a fresh `git init`) is available too: it
|
||||
reports `unborn: true`, `head: null`, `subject: ""` and the branch the first
|
||||
commit will land on.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
@@ -585,6 +592,11 @@ cannot collide on `index.lock`.
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
|
||||
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
@@ -592,8 +604,22 @@ cannot collide on `index.lock`.
|
||||
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no merge, rebase, reset or stash here —
|
||||
running agent. There is deliberately no rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
|
||||
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
|
||||
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
|
||||
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
|
||||
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
|
||||
is in progress `pull`, `switch`, `merge`, `stage-hunk` and `discard-hunk` are
|
||||
`409 EMERGING`, and `GET /api/lanes/:id/git` reports `merging: true` plus
|
||||
`conflicts` (unmerged files, repo-wide). `resolve` accepts only a path git
|
||||
reports as conflicted (`400 EBADPATH`) and a `side` of `ours`/`theirs`/`done`
|
||||
(`400 EBADSIDE`); `done` and a plain `stage` of a conflicted file both refuse
|
||||
while a conflict marker is left in it (`400 EMARKERS`). A side that deleted
|
||||
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
|
||||
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
|
||||
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
@@ -617,10 +643,21 @@ cannot collide on `index.lock`.
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- `publish` is the one write whose **destination comes from the request**, so
|
||||
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
|
||||
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
|
||||
re-points one), and the name is always `origin`. The URL is allowlisted —
|
||||
`https://` with no userinfo (a token in the URL would land in `.git/config`),
|
||||
`ssh://` and scp-like `user@host:path`; a local path, `file://`, `ext::`,
|
||||
`fd::`, `git://`, `http://` and anything starting with `-` are
|
||||
`400 EBADURL`. If the push fails, `origin` is removed again, so a typo or a
|
||||
missing credential can simply be retried. The response adds
|
||||
`"remote": "origin"`.
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
|
||||
non-zero (a rejected push, a credential failure, a diverged pull) is
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
|
||||
`ENOCOMMITS`); git itself exiting
|
||||
non-zero (a rejected push, a diverged pull) is
|
||||
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
|
||||
rejection reads better in git's words. Anything else (git not installed, a
|
||||
timeout kill) is **500**, still under a string code (`EGIT` when there is
|
||||
@@ -629,8 +666,42 @@ cannot collide on `index.lock`.
|
||||
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
|
||||
`git commit` takes the whole index whatever the cwd, and scoping it with a
|
||||
pathspec would commit the working-tree copy (unstaged edits included).
|
||||
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
|
||||
- Network actions (`fetch`/`pull`/`push`/`publish`) are killed after 60s so an unreachable
|
||||
remote fails instead of holding the request open.
|
||||
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
|
||||
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries
|
||||
`auth: { protocol, host, username, rejected }` — `rejected: true` when a
|
||||
login WAS sent and refused. Resend the same action with
|
||||
`credentials: { protocol, host, username, password, remember }`: accepted
|
||||
only for a host that is one of the repo's own remotes (`400 EBADHOST`
|
||||
otherwise), used for that one command, and saved to AgentHub's store (below)
|
||||
only after git succeeded AND `remember` is true. Empty or multi-line values
|
||||
are `400 EBADCRED`.
|
||||
|
||||
#### Git credentials
|
||||
|
||||
```
|
||||
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
|
||||
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
|
||||
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
|
||||
```
|
||||
|
||||
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
|
||||
this install; the **Git credentials** tab of Settings
|
||||
(`/settings?tool=git-credentials`) and the git bar's login form both write
|
||||
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
|
||||
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
|
||||
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
|
||||
`400 EBADCRED`.
|
||||
|
||||
A network action reaches git with `-c credential.helper= -c
|
||||
credential.helper=<AgentHub's helper>` whenever AgentHub holds a login for one
|
||||
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
|
||||
helper, so a stale token in the machine's helper cannot win over the one just
|
||||
typed, and git's post-success `store` cannot copy a use-once token into the
|
||||
OS user's helper. Otherwise the command is unchanged and the machine's own
|
||||
helpers answer as before. The secret never reaches argv: the helper reads the
|
||||
store file, or a use-once login from its own process env.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
@@ -1994,12 +2065,15 @@ GET /api/run/binary?tool= { found, path } for the `claude` or `codex
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
```
|
||||
|
||||
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
|
||||
|
||||
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
|
||||
|
||||
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
||||
|
||||
+3
-1
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
|
||||
|
||||
### model_pricing
|
||||
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_pricing (
|
||||
|
||||
+75
-10
@@ -22,7 +22,7 @@ agenthub lanes add --cwd /path/to/repo --title "My Feature"
|
||||
|
||||
The lane is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the lane automatically by matching the cwd.
|
||||
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists).
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists). A `cwd` that exists is stored by its real path (`fs.realpathSync.native`), so `/tmp/wt` on macOS is stored as `/private/tmp/wt` and a differently-cased path on a case-insensitive volume as the spelling on disk — the path a session reports. A path that does not exist yet is resolved through its deepest existing ancestor (`/tmp/new` → `/private/tmp/new`); rows created before this rule keep theirs.
|
||||
|
||||
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
|
||||
|
||||
@@ -438,7 +438,7 @@ The **Active runs** button in the console header opens the merged run list. It s
|
||||
|
||||
1. live in-memory tmux runs from `GET /api/run`,
|
||||
2. persisted dashboard runs from `GET /api/run/history`,
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes the ones its lane owns (next paragraph), so two hand-started agents in the lane's folder read as "2 active runs".
|
||||
|
||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||
|
||||
@@ -524,6 +524,8 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
top of the Changes panel opens this list as a panel: one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
|
||||
`head: null`, an empty `subject` and the branch its first commit will land on.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
@@ -547,17 +549,37 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| --- | --- | --- |
|
||||
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||||
| **Merge into current** | `git merge --no-edit <branch>` (after a confirm) | the tree is dirty, the lane is a subdirectory of its repo, or a merge is already in progress |
|
||||
| **Merge `<upstream>`** | the same, offered when the branch has diverged (ahead AND behind) | as above |
|
||||
|
||||
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
|
||||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||||
lane needs several times an hour. The boring half of git is the whole list on
|
||||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||||
lane needs several times an hour. There is no rebase, reset or stash button,
|
||||
because a dashboard click is the wrong place to start an operation whose
|
||||
recovery needs a terminal. **Merge** is the one exception (issue #45), because
|
||||
its recovery is a button too: a merge that stops on conflicts puts a banner at
|
||||
the top of the Changes panel — **Complete merge** (enabled once nothing is
|
||||
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
|
||||
and lists the conflicted files in their own **Merge conflicts** group with
|
||||
**Keep mine** / **Take theirs** for the whole file and **Mark resolved**.
|
||||
Opening a conflicted file shows the **conflict view** instead of its diff:
|
||||
each conflict block drawn as its two sides — *Mine (HEAD)* tinted green,
|
||||
*Theirs (<branch>)* tinted blue, labelled from git's own markers — with
|
||||
**Keep mine**, **Take theirs** or **Keep both** per block (undoable, ^/v to
|
||||
jump between blocks). **Save and mark resolved** is enabled once every block
|
||||
is chosen; it saves over exactly the version opened (a file changed meanwhile
|
||||
is refused, `409 ESTALE`) and then marks the file resolved. **Edit by hand**
|
||||
opens the plain editor, for a block neither side gets right; marking resolved
|
||||
is refused while any `<<<<<<<`/`>>>>>>>` marker is left. In the Files tree a
|
||||
conflicted file is listed once (git indexes it once per side) and marked with
|
||||
a red **!**. Pull and branch switch are disabled until the merge is finished or
|
||||
aborted.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
deliberately does not switch: the usual reason to make a branch from a lane is
|
||||
@@ -568,14 +590,48 @@ one hidden in the git bar. There is no **Commit** here: committing is the
|
||||
overview's staged-only box below, the one commit path, and it only ever takes
|
||||
what was staged.
|
||||
|
||||
**Publish…** is the one button whose destination the user types, so the server
|
||||
keeps it narrow: it only gives a remote to a repo that has none (it never
|
||||
re-points one), always names it `origin`, and accepts only `https://` without
|
||||
credentials in the URL, `ssh://` and scp-like `git@host:owner/repo.git` — never
|
||||
a local path, `file://`, `ext::` or `http://`. Credentials come from the
|
||||
machine's own credential helper, ssh agent or AgentHub's own git login store
|
||||
(the login form below), exactly as for **Push**.
|
||||
|
||||
A repo with **no commits yet** (a fresh `git init`) still gets the header: its
|
||||
branch, "no commits yet", and a hint to commit first — the network and branch
|
||||
buttons are hidden because there is nothing to push, fetch into or branch from.
|
||||
The commit box below makes the first commit, and **Publish…** appears once it
|
||||
exists. **Add lane** in Worktree mode refuses such a repo (`400 EEMPTYREPO`,
|
||||
nothing to fork a worktree from) and offers **Use as a Repo lane** instead.
|
||||
|
||||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||||
says more than any sentence the dashboard could write.
|
||||
|
||||
The exception is a remote that wants a **login** (issue #45). The dashboard
|
||||
runs git without a terminal, so where a shell would prompt, the bar opens a
|
||||
form instead — "Git needs a login for `<host>`": username, password or access
|
||||
token (on Gitea: Settings → Applications → Generate token), and **Remember for
|
||||
everyone using this AgentHub** (on by default) — and resends the same action
|
||||
with it. A saved login lives in AgentHub's own store, one per host, shared by
|
||||
every user of the install, and is managed from Settings → **Git credentials**;
|
||||
it is saved only after git accepted it. While AgentHub holds a login for a
|
||||
repo's host, that login is the only one git is given for the command — the
|
||||
machine's own credential helpers are left out, so a stale token there cannot
|
||||
win. Over plain `http://` on a LAN address the form warns that the token
|
||||
crosses the network unencrypted. See `docs/API.md` § Git credentials.
|
||||
|
||||
### The Changes panel
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted) → closed (`agenthub.workspace.changesSize`
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
|
||||
height-locked so the panel fills the viewport even with no live run; the whole
|
||||
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
|
||||
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
|
||||
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
|
||||
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
|
||||
restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
@@ -667,7 +723,11 @@ there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
|
||||
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
already reporting as changed.
|
||||
already reporting as changed. In the `work` range the overview splits like VS
|
||||
Code's Source Control: **Staged Changes** (what the next commit takes, each row
|
||||
offering Unstage) above **Changes** (unstaged and untracked, each offering
|
||||
Stage); a partly staged file appears in both. In the tree, a changed file's
|
||||
whole row takes its status tint, not only its letter.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
@@ -687,7 +747,7 @@ This shows each lane's:
|
||||
- **status**: `running` (a session is active), `idle` (no active session), or `provisioning`
|
||||
- **liveness**: `active` (recently updated), `dead` (silent for too long), or `idle` (not expected to be running)
|
||||
- **progress**: percentage of the pipeline completed (0–100%)
|
||||
- **needs_action**: a notification if the session is blocked waiting for input
|
||||
- **needs_action**: a notification if the session is blocked waiting for input. `needs_action_session` records which session raised it, and only that session's next hook clears it — a second session in the same cwd (a hand-started terminal beside the dashboard pane) rebinds `session_id` but cannot clear a prompt it is not the one waiting on
|
||||
|
||||
A lane row also gets a `⇢ detected:<stage>` suffix when the server's inferred
|
||||
stage (see "Stage detection" below) leads the declared `stage` shown in the
|
||||
@@ -1505,7 +1565,7 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
|
||||
curl -X POST http://localhost:4820/api/lanes/5/stop
|
||||
```
|
||||
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish
|
||||
|
||||
The git writes the Changes panel and its action row offer. See
|
||||
"Acting on a lane's working copy" above for what each one refuses and why.
|
||||
@@ -1525,6 +1585,11 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
|
||||
|
||||
# A repo with commits but no remote: add `origin` and push HEAD to it
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/publish \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "git@github.com:owner/repo.git"}'
|
||||
```
|
||||
|
||||
### clear
|
||||
|
||||
+9
-3
@@ -128,13 +128,13 @@ check.
|
||||
milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
|
||||
@@ -235,7 +235,13 @@ server falls back to the old per-session detached spawn instead.
|
||||
|
||||
On macOS, Windows, or a Linux box without systemd, nothing changes: the
|
||||
server is a detached spawn started fresh on the next `SessionStart` after a
|
||||
reboot, same as always.
|
||||
reboot, same as always. That spawn inherits the Claude Code session's env, so
|
||||
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
|
||||
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
|
||||
before anything else runs — otherwise tmux hands them to every lane pane. It is
|
||||
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no
|
||||
launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos).
|
||||
|
||||
### Who may stop the server
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Git credentials for dashboard fetch/pull/push (issue #45, part A)
|
||||
|
||||
## Problem
|
||||
|
||||
The dashboard runs git with no terminal and `GIT_TERMINAL_PROMPT=0`. When no
|
||||
credential helper on the machine holds a credential for the remote's host, git
|
||||
wants to prompt, cannot, and fails:
|
||||
`fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled`.
|
||||
Reproduced with `git -c credential.helper= push --dry-run`.
|
||||
|
||||
## Decisions (approved 2026-09-25)
|
||||
|
||||
- AgentHub keeps its OWN credentials, shared by everyone using the AgentHub
|
||||
install (multi-user login is planned; accounts and settings are shared).
|
||||
- The dashboard asks for a credential when git needs one, then retries.
|
||||
- Merge and conflict resolution is part B and gets its own design.
|
||||
|
||||
## Design
|
||||
|
||||
**Store** — `server/lib/git-credentials.js`, one file
|
||||
`<CLAUDE_HOME>/agenthub-git-credentials/credentials.json` (dir 0700, file 0600,
|
||||
written through a temp file), one record per `protocol + host`:
|
||||
`{protocol, host, username, password, savedAt}`. No route returns `password`;
|
||||
the list reports `hasToken`. Routes: `GET /api/git-credentials`,
|
||||
`DELETE /api/git-credentials/:protocol/:host`.
|
||||
|
||||
**Delivery** — `server/lib/git-credential-helper.js`, a git credential helper
|
||||
speaking git's own protocol. For a network action, when AgentHub holds a
|
||||
credential for one of the repo's remote hosts, or the request carries one, git
|
||||
runs with `-c credential.helper= -c credential.helper=<ours>`: ONLY our helper.
|
||||
The machine's helpers are left out on purpose — a stale token there would win
|
||||
over the one just typed, and git's post-success `store` would copy a
|
||||
"use once" token into the OS user's own helper. With nothing from AgentHub for
|
||||
the host, the command is unchanged. The secret never reaches argv: the helper
|
||||
reads the store file, or `AGENTHUB_GIT_ONCE` in that one git process's env.
|
||||
|
||||
**Flow** — an auth failure (`could not read Username|Password`,
|
||||
`Authentication failed`, `terminal prompts disabled`, `Invalid username or
|
||||
password`) answers `409 EGITAUTH` with `{protocol, host, username?}`. The git
|
||||
bar shows a form (username, password/token, "remember for everyone on this
|
||||
AgentHub", on by default) and resends the action with
|
||||
`credentials: {protocol, host, username, password, remember}`. The server
|
||||
accepts it only for a host that is one of the repo's remotes, runs the action,
|
||||
and saves the credential only after git succeeded and `remember` is set.
|
||||
|
||||
**Limits** — one credential per host, so everyone on one AgentHub pushes as
|
||||
the same account. `fetch --all` over remotes on several hosts: a host with no
|
||||
AgentHub credential gets no machine helper either while one does. Over plain
|
||||
HTTP on a LAN the token crosses the network unencrypted; the form says so.
|
||||
|
||||
## Testing
|
||||
|
||||
Store: save/list/remove, 0600, list never carries the password, bad host
|
||||
refused. Helper: real `git credential fill` through our helper, stored and
|
||||
once. Route: a local HTTP remote answering 401 → `EGITAUTH` with the host; a
|
||||
resend with credentials reaches that server as Basic auth; nothing saved on
|
||||
failure. Client: the form appears on `EGITAUTH`, resends, never renders a token.
|
||||
+12
-8
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.8.7
|
||||
version: 1.8.10
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -10636,7 +10636,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Find or adopt the lane owning a working directory
|
||||
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
description: 'Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
operationId: ensureLane
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -10718,7 +10718,7 @@ paths:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'400':
|
||||
description: sourceRepo is relative, missing, or not a git repository.
|
||||
description: 'EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -10807,7 +10807,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
description: available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
@@ -11188,7 +11188,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: One git write on the lane's working copy
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote''s host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
operationId: runLaneGitAction
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -11214,6 +11214,7 @@ paths:
|
||||
- switch
|
||||
- branch-create
|
||||
- branch-delete
|
||||
- publish
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -11221,6 +11222,9 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.
|
||||
message:
|
||||
type: string
|
||||
description: commit only; required, max 4000 chars.
|
||||
@@ -11257,13 +11261,13 @@ paths:
|
||||
description: discard and discard-hunk; the call is refused without it.
|
||||
responses:
|
||||
'200':
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr.'
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr; publish adds remote:"origin".'
|
||||
'400':
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
'409':
|
||||
description: EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.
|
||||
description: EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.
|
||||
'500':
|
||||
description: Unexpected server-side failure.
|
||||
/api/lanes/{id}/{action}:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.10",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.10",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -165,7 +165,7 @@ function reloadPanes() {
|
||||
try {
|
||||
paneCmd = execFileSync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", name, "#{pane_current_command}"],
|
||||
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
|
||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
@@ -184,8 +184,8 @@ function reloadPanes() {
|
||||
// detached and serves no requests.
|
||||
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
||||
first = false;
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
reloaded.push(name);
|
||||
} catch {
|
||||
|
||||
@@ -359,13 +359,15 @@ function cliDir() {
|
||||
*
|
||||
* @returns {"written"|"exists"|"foreign"|"failed"}
|
||||
*/
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath) {
|
||||
const marker = "# agenthub-plugin-launcher";
|
||||
const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
||||
const body =
|
||||
process.platform === "win32"
|
||||
? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n`
|
||||
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
: // The pinned node is a versioned path (Homebrew Cellar, nvm) that an
|
||||
// upgrade deletes; fall back to whatever `node` PATH has then.
|
||||
`#!/bin/sh\n${marker}\nN="${nodePath}"; [ -x "$N" ] || N=node\nexec "$N" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
@@ -419,7 +421,13 @@ function liveServers() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
||||
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
||||
return servers.filter((s) => s && isPidAlive(s.pid));
|
||||
// An entry older than this boot is a leftover whose pid may now belong to
|
||||
// an unrelated process (macOS has no systemd to clean up after a reboot).
|
||||
// 60 s margin for clock skew; an unparseable startedAt is kept.
|
||||
// ponytail: an NTP step larger than 60 s after boot misreads a live entry
|
||||
// as stale; compare against a monotonic boot id if that ever bites.
|
||||
const bootedAt = Date.now() - os.uptime() * 1000 - 60_000;
|
||||
return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -316,7 +316,9 @@ function cliStatus() {
|
||||
: [
|
||||
"warn",
|
||||
"agenthub CLI",
|
||||
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
|
||||
`${file} exists but ${dir} is not on PATH — add${
|
||||
process.platform === "darwin" ? " to ~/.zshrc" : ""
|
||||
}: export PATH="${dir}:$PATH"`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -524,7 +524,7 @@ Request body shape:
|
||||
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
|
||||
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
@@ -532,7 +532,10 @@ Request body shape:
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
|
||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response lane starts as
|
||||
@@ -703,6 +706,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
|
||||
@@ -43,13 +43,13 @@ function stubTmux(sessions, panes = {}) {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
if (sessions.includes(args[2].slice(1))) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const name = args[3].slice(1, -1);
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
@@ -59,7 +59,7 @@ function stubTmux(sessions, panes = {}) {
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
if (panes[args[2].slice(1, -1)] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests that the server drops the variables describing the Claude Code
|
||||
* session that started it (always the case on macOS, where there is no
|
||||
* systemd unit to give it a clean env) — tmux and every lane pane inherit the
|
||||
* server's env, so a leaked CLAUDECODE made each pane look like a nested
|
||||
* session. User configuration (CLAUDE_CONFIG_DIR, ANTHROPIC_*, …) must survive.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-envscrub-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
process.env.CLAUDECODE = "1";
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
process.env.CLAUDE_CODE_SESSION_ID = "abc";
|
||||
process.env.CLAUDE_CODE_MESSAGING_SOCKET = "/tmp/x.sock";
|
||||
process.env.CLAUDE_PLUGIN_ROOT = "/some/cache/dir";
|
||||
process.env.CLAUDE_CONFIG_DIR = "/Users/x/.claude-work";
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://example.invalid";
|
||||
|
||||
require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
db.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("strips the parent session's variables and keeps user configuration", () => {
|
||||
for (const k of [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION_ID",
|
||||
"CLAUDE_CODE_MESSAGING_SOCKET",
|
||||
"CLAUDE_PLUGIN_ROOT",
|
||||
]) {
|
||||
assert.equal(process.env[k], undefined, k);
|
||||
}
|
||||
assert.equal(process.env.CLAUDE_CONFIG_DIR, "/Users/x/.claude-work");
|
||||
assert.equal(process.env.CLAUDE_CODE_USE_BEDROCK, "1");
|
||||
assert.equal(process.env.ANTHROPIC_BASE_URL, "https://example.invalid");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Route tests for git credentials (issue #45): /api/git-credentials
|
||||
* never returns a password and refuses a cross-origin write, and a lane's
|
||||
* fetch against a remote that wants a login answers 409 EGITAUTH with the
|
||||
* host the git bar's login form needs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
let host;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${remote.address().port}`;
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
await new Promise((r) => remote.close(r));
|
||||
});
|
||||
|
||||
describe("/api/git-credentials", () => {
|
||||
it("saves, lists without the secret, and deletes", async () => {
|
||||
const saved = await request("POST", "/api/git-credentials", {
|
||||
protocol: "https",
|
||||
host: "git.example.test",
|
||||
username: "vi",
|
||||
password: "super-secret-token",
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
const list = await request("GET", "/api/git-credentials");
|
||||
assert.equal(list.body.items[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
|
||||
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
|
||||
assert.deepEqual(del.body.items, []);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/git-credentials",
|
||||
{ protocol: "https", host: "h.test", username: "u", password: "p" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/git/fetch", () => {
|
||||
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
|
||||
const repo = path.join(ROOT, "repo");
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
|
||||
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "EGITAUTH");
|
||||
assert.equal(r.body.error.auth.host, host);
|
||||
assert.equal(r.body.error.auth.rejected, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file Tests for AgentHub's own git credential store and the credential
|
||||
* helper that hands it to git: save/list/remove at 0600, a list that never
|
||||
* carries the secret, auth-failure detection, and a REAL `git credential fill`
|
||||
* run through the helper — stored and use-once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
function fill(dir, cfg, env = {}) {
|
||||
// Exactly what git-ops passes, then git's own credential lookup.
|
||||
const out = execFileSync("git", [...cfg.args, "credential", "fill"], {
|
||||
cwd: dir,
|
||||
input: "protocol=https\nhost=git.example.test\n\n",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
return Object.fromEntries(
|
||||
out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true }));
|
||||
|
||||
describe("git credential store", () => {
|
||||
it("saves one record per protocol+host at 0600 and never lists the secret", () => {
|
||||
creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" });
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" });
|
||||
const list = creds.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].host, "git.example.test");
|
||||
assert.equal(list[0].username, "b");
|
||||
assert.equal(list[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list).includes("t2"));
|
||||
assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600);
|
||||
assert.equal(creds.remove("https", "git.example.test"), true);
|
||||
assert.deepEqual(creds.list(), []);
|
||||
});
|
||||
|
||||
it("refuses a host that is not a host", () => {
|
||||
for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) {
|
||||
assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
}
|
||||
assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises git's auth failures and names the host", () => {
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled"
|
||||
),
|
||||
{ protocol: "https", host: "git.smartgift.io.vn" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'"
|
||||
),
|
||||
{ protocol: "http", host: "h.local:3000" }
|
||||
);
|
||||
assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git credential helper, through real git", () => {
|
||||
const repo = fs.mkdtempSync(path.join(ROOT, "repo-"));
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]);
|
||||
|
||||
it("answers from the store, and only our helper is asked", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.equal(cfg.args[1], "credential.helper=");
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.username, "u1");
|
||||
assert.equal(got.password, "stored");
|
||||
assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv");
|
||||
});
|
||||
|
||||
it("prefers a use-once credential, which never touches the store", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" };
|
||||
const cfg = await creds.gitConfigFor(repo, once);
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.password, "once");
|
||||
assert.equal(creds.list()[0].username, "u1");
|
||||
assert.ok(!cfg.args.join(" ").includes("once"));
|
||||
});
|
||||
|
||||
it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => {
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.deepEqual(cfg, { args: [], env: {} });
|
||||
});
|
||||
|
||||
it("refuses a use-once credential for a host the repo does not use", async () => {
|
||||
await assert.rejects(
|
||||
creds.gitConfigFor(repo, {
|
||||
protocol: "https",
|
||||
host: "evil.test",
|
||||
username: "u",
|
||||
password: "p",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { runGitAction, validateRemoteUrl } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
|
||||
@@ -660,3 +660,140 @@ describe("runGitAction — one hunk at a time", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unborn repo and `publish`", () => {
|
||||
const EMPTY = path.join(ROOT, "empty");
|
||||
const URL_OK = "https://example.test/owner/r.git";
|
||||
|
||||
/** A `git init`ed repo with no commit and no remote. */
|
||||
function unborn() {
|
||||
fs.rmSync(EMPTY, { recursive: true, force: true });
|
||||
g(ROOT, "init", "-q", "-b", "main", EMPTY);
|
||||
g(EMPTY, "config", "user.email", "t@h");
|
||||
g(EMPTY, "config", "user.name", "t");
|
||||
}
|
||||
|
||||
function firstCommit() {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
gc(EMPTY, "add", "-A");
|
||||
gc(EMPTY, "commit", "-qm", "first");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
freshFixture();
|
||||
unborn();
|
||||
});
|
||||
|
||||
it("reports an unborn repo instead of failing to read it", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.unborn, true);
|
||||
assert.equal(facts.head, null);
|
||||
assert.equal(facts.branch, "main");
|
||||
assert.equal(facts.subject, "");
|
||||
assert.equal(facts.untracked, 1);
|
||||
assert.equal(facts.hasRemote, false);
|
||||
assert.equal((await gitFacts(LANE)).unborn, false);
|
||||
});
|
||||
|
||||
it("makes the first commit of an unborn repo", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const out = await runGitAction(EMPTY, "commit", { message: "first" });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal((await gitFacts(EMPTY)).unborn, false);
|
||||
});
|
||||
|
||||
it("refuses to publish before the first commit", async () => {
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), {
|
||||
code: "ENOCOMMITS",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to publish a repo that already has a remote", async () => {
|
||||
await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), {
|
||||
code: "EREMOTEEXISTS",
|
||||
});
|
||||
assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN);
|
||||
});
|
||||
|
||||
it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => {
|
||||
firstCommit();
|
||||
for (const url of [
|
||||
ORIGIN, // a local path
|
||||
"/tmp/x",
|
||||
"../sibling.git",
|
||||
"file:///tmp/x.git",
|
||||
"ext::sh -c id",
|
||||
"ext::sh",
|
||||
"fd::3",
|
||||
"git://host/r.git",
|
||||
"http://host/r.git",
|
||||
"-oProxyCommand=x",
|
||||
"--upload-pack=x",
|
||||
"https://u:p@host/r.git",
|
||||
"https://token@host/r.git",
|
||||
"https://host/r .git",
|
||||
"https://host/r.git\n",
|
||||
"ssh://-oProxyCommand=x/r.git",
|
||||
// WHATWG parses these as https://host/x, but git sees no "://" and reads
|
||||
// them as scp-like ssh to a host named "https" — the checked string and
|
||||
// the used string must be the same string.
|
||||
"https:/host/r.git",
|
||||
"https:host/r.git",
|
||||
"https:\\\\host\\r.git",
|
||||
// Percent-encoded userinfo/host that git decodes into a leading "-".
|
||||
"ssh://%2DoProxyCommand=x@host/r.git",
|
||||
"ssh://git@%2Dhost/r.git",
|
||||
"-git@host:r.git",
|
||||
"",
|
||||
42,
|
||||
undefined,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
() => runGitAction(EMPTY, "publish", { url }),
|
||||
{ code: "EBADURL" },
|
||||
`accepted ${JSON.stringify(url)}`
|
||||
);
|
||||
}
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
|
||||
it("accepts https, ssh:// and scp-like URLs", () => {
|
||||
for (const url of [
|
||||
URL_OK,
|
||||
"https://host:8443/owner/r",
|
||||
"ssh://git@host:2222/owner/r.git",
|
||||
"ssh://host/r.git",
|
||||
"git@github.com:owner/r.git",
|
||||
]) {
|
||||
assert.equal(validateRemoteUrl(url), url);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds origin and pushes HEAD with an upstream", async () => {
|
||||
firstCommit();
|
||||
const bare = path.join(ROOT, "published.git");
|
||||
g(ROOT, "init", "-q", "--bare", bare);
|
||||
// Repo-local insteadOf maps the https URL onto the bare fixture, so the
|
||||
// real https branch of the allowlist is exercised without a network.
|
||||
g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK);
|
||||
|
||||
const out = await runGitAction(EMPTY, "publish", { url: URL_OK });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(out.remote, "origin");
|
||||
// `remote add origin -- <url>` stores the URL itself, not "--".
|
||||
assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK);
|
||||
assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim());
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.upstream, "origin/main");
|
||||
assert.equal(facts.ahead, 0);
|
||||
});
|
||||
|
||||
it("removes the remote again when the push fails", async () => {
|
||||
firstCommit();
|
||||
g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK);
|
||||
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }));
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Issue #45: a dashboard fetch/pull/push against a remote that needs a
|
||||
* login. A local HTTP "remote" answers 401 to every request and records the
|
||||
* Authorization header it was sent, so the tests see exactly what git sent:
|
||||
* nothing → EGITAUTH naming the host; a typed credential → that credential,
|
||||
* as Basic auth, to that host; saved only when git succeeded AND asked to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-ops-auth-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
// The machine's own helpers must not answer for the fake host.
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
let server;
|
||||
let host;
|
||||
const seen = [];
|
||||
const LANE = path.join(ROOT, "lane");
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
seen.push(req.headers.authorization || null);
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${server.address().port}`;
|
||||
execFileSync("git", ["init", "-q", LANE]);
|
||||
execFileSync("git", ["-C", LANE, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
});
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
beforeEach(() => {
|
||||
seen.length = 0;
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("git auth on fetch", () => {
|
||||
it("answers EGITAUTH naming the host when git has no credential", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), (e) => {
|
||||
assert.equal(e.code, "EGITAUTH");
|
||||
assert.deepEqual(e.auth, { protocol: "http", host, username: null, rejected: false });
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a typed credential as Basic auth, and does not save a failed one", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "http", host, username: "vi", password: "tok", remember: true },
|
||||
}),
|
||||
(e) => e.code === "EGITAUTH" && e.auth.rejected === true
|
||||
);
|
||||
const basic = `Basic ${Buffer.from("vi:tok").toString("base64")}`;
|
||||
assert.ok(seen.includes(basic), `expected ${basic} in ${JSON.stringify(seen)}`);
|
||||
assert.deepEqual(creds.list(), [], "a credential git rejected is never saved");
|
||||
});
|
||||
|
||||
it("uses a stored credential without being asked", async () => {
|
||||
creds.save({ protocol: "http", host, username: "saved", password: "s3" });
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), { code: "EGITAUTH" });
|
||||
assert.ok(seen.includes(`Basic ${Buffer.from("saved:s3").toString("base64")}`));
|
||||
});
|
||||
|
||||
it("refuses a credential for a host that is not this repo's remote", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "https", host: "evil.test", username: "u", password: "p" },
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file Issue #45 part B: merge and conflict resolution from the dashboard,
|
||||
* against a REAL repo whose `main` and `feat` edit the same line. Pins the
|
||||
* guards (clean tree, repo-root lane, a ref that exists, one merge at a time,
|
||||
* a path that IS conflicted, markers gone before "resolved", confirm on
|
||||
* abort) and the happy paths (ours, theirs, hand-edited, commit, abort).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
const { listTree, listPaths } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
fs.mkdirSync(path.join(REPO, "sub"));
|
||||
write("a.txt", "base\n");
|
||||
write("sub/s.txt", "s\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("switch", "-qc", "feat");
|
||||
write("a.txt", "theirs\n");
|
||||
write("b.txt", "only on feat\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "feat");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main");
|
||||
});
|
||||
|
||||
const merge = () => runGitAction(REPO, "merge", { branch: "feat" });
|
||||
|
||||
describe("merge", () => {
|
||||
it("reports conflicts instead of failing, and gitFacts shows the merge", async () => {
|
||||
const r = await merge();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const facts = await gitFacts(REPO);
|
||||
assert.equal(facts.merging, true);
|
||||
assert.equal(facts.conflicts, 1);
|
||||
});
|
||||
|
||||
it("merges cleanly when there is nothing to resolve", async () => {
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "agree");
|
||||
const r = await merge();
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("b.txt"), "only on feat\n");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
});
|
||||
|
||||
it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => {
|
||||
write("a.txt", "wip\n");
|
||||
await assert.rejects(merge(), { code: "EDIRTY" });
|
||||
g("checkout", "--", "a.txt");
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" });
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" });
|
||||
await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), {
|
||||
code: "ESUBDIR",
|
||||
});
|
||||
await merge();
|
||||
await assert.rejects(merge(), { code: "EMERGING" });
|
||||
await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listing files mid-merge", () => {
|
||||
it("lists a conflicted file once, not once per merge stage", async () => {
|
||||
await merge();
|
||||
const root = await listTree(REPO, {});
|
||||
assert.equal(root.entries.filter((e) => e.name === "a.txt").length, 1);
|
||||
assert.deepEqual((await listPaths(REPO, { q: "a.txt" })).paths, ["a.txt"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("takes our side or theirs, and only for a conflicted path", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), {
|
||||
code: "EBADSIDE",
|
||||
});
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
assert.equal(read("a.txt"), "theirs\n");
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
|
||||
it("marks a hand-edited file resolved only once its markers are gone", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), {
|
||||
code: "EMARKERS",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" });
|
||||
write("a.txt", "both\n");
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" });
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("finish or abort", () => {
|
||||
it("commits the merge only when nothing is left unresolved", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("aborts only with confirm, back to where it started", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false);
|
||||
});
|
||||
});
|
||||
@@ -486,14 +486,20 @@ describe("managed worktree provisioning", () => {
|
||||
});
|
||||
|
||||
it("marks the lane failed with git's message when provisioning fails", async () => {
|
||||
const emptyRepo = path.join(ROOT, "empty-repo");
|
||||
fs.mkdirSync(emptyRepo, { recursive: true });
|
||||
g(emptyRepo, "init", "-b", "main");
|
||||
// A branch named `feat` makes `feat/<slug>` impossible to create — a git
|
||||
// failure the route cannot see coming. (An unborn repo used to serve here;
|
||||
// the route now refuses that one up front with EEMPTYREPO.)
|
||||
const clashRepo = path.join(ROOT, "clash-repo");
|
||||
fs.mkdirSync(clashRepo, { recursive: true });
|
||||
g(clashRepo, "init", "-b", "main");
|
||||
g(clashRepo, "-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i");
|
||||
g(clashRepo, "branch", "feat");
|
||||
|
||||
const created = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo: emptyRepo,
|
||||
title: "Unborn Repo",
|
||||
sourceRepo: clashRepo,
|
||||
title: "Ref Clash",
|
||||
base: "main",
|
||||
branch: "feat/clash",
|
||||
});
|
||||
|
||||
assert.equal(created.status, 202);
|
||||
@@ -850,9 +856,9 @@ describe("destructive lane lifecycle actions", () => {
|
||||
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
|
||||
// Same shape as tmux.js's own exec: sync, argv only, throws on failure.
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && args[2] === runId) return "";
|
||||
if (args[0] === "has-session" && args[2] === `=${runId}`) return "";
|
||||
if (args[0] === "list-sessions") return `${runId}\n`;
|
||||
if (args[0] === "kill-session" && args[2] === runId) return "";
|
||||
if (args[0] === "kill-session" && args[2] === `=${runId}`) return "";
|
||||
throw Object.assign(new Error("mocked tmux failure"), { status: 1 });
|
||||
});
|
||||
|
||||
@@ -943,7 +949,12 @@ describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
// Mock tmux so the session appears live
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (
|
||||
cmd === "tmux" &&
|
||||
args[0] === "has-session" &&
|
||||
args[1] === "-t" &&
|
||||
args[2] === `=${runId}`
|
||||
) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
@@ -1212,7 +1223,12 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
|
||||
// Mock tmux so the run appears to be live.
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (
|
||||
cmd === "tmux" &&
|
||||
args[0] === "has-session" &&
|
||||
args[1] === "-t" &&
|
||||
args[2] === `=${runId}`
|
||||
) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
@@ -1258,7 +1274,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
|
||||
// Mock tmux so the session appears to be gone (has-session fails with status 1).
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (args[0] === "has-session" && args[1] === "-t" && args[2] === `=${runId}`) {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
|
||||
@@ -321,6 +321,27 @@ describe("/api/lanes", () => {
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("router apply asks for a restart only while the lane's pane is live", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !live) throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.restart_required, live);
|
||||
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null);
|
||||
}
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
@@ -396,7 +417,7 @@ describe("hook → lane binding", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
|
||||
|
||||
// Session B's second PostToolUse clears the flag (B is now bound)
|
||||
// B is bound now, but it still did not raise the flag: A is still waiting.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
@@ -406,6 +427,18 @@ describe("hook → lane binding", () => {
|
||||
tool_use_id: "t2",
|
||||
},
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
|
||||
|
||||
// A answering its own prompt is what clears it.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
session_id: "sess-a",
|
||||
cwd: "/tmp/lane-cross-session",
|
||||
tool_name: "Read",
|
||||
tool_use_id: "t3",
|
||||
},
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
@@ -921,6 +954,13 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||
});
|
||||
|
||||
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/git/publish`, { url: "file:///tmp/x" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADURL");
|
||||
assert.equal(g(dir, "remote").trim(), "");
|
||||
});
|
||||
|
||||
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
|
||||
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
|
||||
assert.equal(log.status, 200);
|
||||
@@ -1442,6 +1482,23 @@ describe("/api/lanes tool tests", () => {
|
||||
assert.equal(second.body.lane.id, first.body.lane.id);
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure resolves a symlinked path, existing or not, to one lane", async () => {
|
||||
const real = fs.mkdtempSync(path.join(os.tmpdir(), "lane-real-"));
|
||||
const link = `${real}-link`;
|
||||
fs.symlinkSync(real, link);
|
||||
try {
|
||||
for (const sub of ["", "/not-yet"]) {
|
||||
const first = await request("POST", "/api/lanes/ensure", { cwd: real + sub });
|
||||
const second = await request("POST", "/api/lanes/ensure", { cwd: link + sub });
|
||||
assert.equal(second.body.created, false, sub);
|
||||
assert.equal(second.body.lane.id, first.body.lane.id, sub);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(link);
|
||||
fs.rmSync(real, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/same-tool-lane",
|
||||
@@ -1554,6 +1611,11 @@ describe("/api/lanes tool tests", () => {
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
|
||||
require("child_process").execSync(
|
||||
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
|
||||
{ cwd: sourceRepo }
|
||||
);
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
@@ -1574,6 +1636,24 @@ describe("/api/lanes tool tests", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree refuses a repo with no commits, creating no row", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
`agenthub-worktree-empty-${Date.now()}`
|
||||
);
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init -q", { cwd: sourceRepo });
|
||||
const before = (await request("GET", "/api/lanes")).body.lanes.length;
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", { sourceRepo, slug: "empty" });
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EEMPTYREPO");
|
||||
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
|
||||
fs.rmSync(sourceRepo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
@@ -1582,6 +1662,11 @@ describe("/api/lanes tool tests", () => {
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
|
||||
require("child_process").execSync(
|
||||
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
|
||||
{ cwd: sourceRepo }
|
||||
);
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
|
||||
@@ -458,9 +458,17 @@ describe("agenthub lanes add", () => {
|
||||
});
|
||||
|
||||
it("reports the failure notes, not success, when provisioning fails", async () => {
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-unborn-repo-"));
|
||||
// A branch named `feat` makes the default `feat/<slug>` impossible to
|
||||
// create, so provisioning fails inside git (an unborn repo is now refused
|
||||
// up front with EEMPTYREPO and never reaches provisioning).
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-clash-repo-"));
|
||||
try {
|
||||
await git(["init", "-b", "main"], emptyRepo);
|
||||
await git(
|
||||
["-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i"],
|
||||
emptyRepo
|
||||
);
|
||||
await git(["branch", "feat"], emptyRepo);
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
||||
emptyRepo
|
||||
|
||||
@@ -85,6 +85,17 @@ describe("legacy lanes_new rebuild (pre-tool, single-column cwd UNIQUE DB)", ()
|
||||
assert.equal(row.tool, "claude");
|
||||
});
|
||||
|
||||
it("keeps every column added before the rebuild, router_profile included", () => {
|
||||
const { db } = require("../db");
|
||||
const cols = db
|
||||
.prepare("PRAGMA table_info(lanes)")
|
||||
.all()
|
||||
.map((c) => c.name);
|
||||
for (const name of ["router_profile", "needs_action_session"]) {
|
||||
assert.ok(cols.includes(name), `expected ${name} to survive the rebuild`);
|
||||
}
|
||||
});
|
||||
|
||||
it("recreates idx_lanes_session and idx_lanes_slot", () => {
|
||||
const { db } = require("../db");
|
||||
for (const name of ["idx_lanes_session", "idx_lanes_slot"]) {
|
||||
|
||||
@@ -1269,3 +1269,21 @@ describe("the forward-only hold is short enough for one work session", () => {
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLane — canonical cwd (macOS /tmp -> /private/tmp class)", () => {
|
||||
it("stores the realpath of a symlinked dir, and the given string when it does not exist yet", () => {
|
||||
const fs = require("node:fs");
|
||||
const base = fs.realpathSync(fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-realpath-")));
|
||||
const target = pathMod.join(base, "real");
|
||||
const link = pathMod.join(base, "link");
|
||||
fs.mkdirSync(target);
|
||||
fs.symlinkSync(target, link);
|
||||
try {
|
||||
assert.equal(lanes.createLane({ cwd: link + "/" }).cwd, target);
|
||||
const missing = pathMod.join(base, "not-yet");
|
||||
assert.equal(lanes.createLane({ cwd: missing }).cwd, missing);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,16 @@ describe("serverIsLive", () => {
|
||||
assert.equal(boot.serverIsLive(), true);
|
||||
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]);
|
||||
});
|
||||
|
||||
it("ignores an entry started before this boot, even when its pid is alive (pid reuse)", () => {
|
||||
const beforeBoot = new Date(Date.now() - os.uptime() * 1000 - 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
infoPath,
|
||||
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: beforeBoot }] })
|
||||
);
|
||||
assert.equal(boot.serverIsLive(), false);
|
||||
assert.deepEqual(boot.liveServers(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime location", () => {
|
||||
@@ -419,6 +429,26 @@ describe("agenthub CLI launcher", () => {
|
||||
assert.equal(boot.linkCli(bin), "foreign");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
|
||||
});
|
||||
|
||||
it(
|
||||
"falls back to `node` on PATH when the pinned node binary is gone (brew upgrade)",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const root = path.join(TMP_HOME, "fake-root");
|
||||
fs.mkdirSync(path.join(root, "bin"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "bin", "agenthub.js"),
|
||||
"console.log('ran:' + process.argv.slice(2).join(','));\n"
|
||||
);
|
||||
assert.equal(boot.linkCli(bin, root, "/nonexistent/Cellar/node/0.0.0/bin/node"), "written");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /\/nonexistent\/Cellar/);
|
||||
const out = require("child_process").execFileSync("sh", [target, "a", "b c"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` },
|
||||
});
|
||||
assert.equal(out.trim(), "ran:a,b c");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("plugin-open / plugin-bootstrap require cycle", () => {
|
||||
|
||||
@@ -324,3 +324,42 @@ describe("calculateCost — date-effective (intro) pricing", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
it("prices claude-opus-5-5 at its own rates, not Opus 5's", () => {
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-opus55-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { DEFAULT_PRICING } = require("../db");
|
||||
const rules = DEFAULT_PRICING.map(([p, n, i, o, cr, cw, cw1h, fi, fo]) => ({
|
||||
model_pattern: p,
|
||||
display_name: n,
|
||||
input_per_mtok: i,
|
||||
output_per_mtok: o,
|
||||
cache_read_per_mtok: cr,
|
||||
cache_write_per_mtok: cw,
|
||||
cache_write_1h_per_mtok: cw1h,
|
||||
fast_input_per_mtok: fi,
|
||||
fast_output_per_mtok: fo,
|
||||
}));
|
||||
// A real session: Claude Code's /cost reported $2.72 for exactly these
|
||||
// counts (all cache writes at the 1h tier); Opus 5's rates gave $4.86.
|
||||
const r = calculateCost(
|
||||
[
|
||||
bucket({
|
||||
model: "claude-opus-5-5",
|
||||
input_tokens: 102,
|
||||
output_tokens: 25_408,
|
||||
cache_read_tokens: 5_866_977,
|
||||
cache_write_tokens: 129_432,
|
||||
cache_write_1h_tokens: 129_432,
|
||||
}),
|
||||
],
|
||||
rules
|
||||
);
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||
* no row of its own gets one from the price list, an edited row is never
|
||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { db } = require("../db");
|
||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
||||
|
||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||
const LIST = {
|
||||
"claude-opus-9-9": {
|
||||
litellm_provider: "anthropic",
|
||||
input_cost_per_token: 0.000004,
|
||||
output_cost_per_token: 0.00002,
|
||||
cache_read_input_token_cost: 2e-7,
|
||||
cache_creation_input_token_cost: 0.000005,
|
||||
cache_creation_input_token_cost_above_1hr: 0.000008,
|
||||
supports_fast_mode: true,
|
||||
provider_specific_entry: { fast: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
function useModel(model) {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, status, started_at) VALUES (?, 'active', ?)").run(
|
||||
`s-${model}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.prepare("INSERT INTO token_usage (session_id, model) VALUES (?, ?)").run(`s-${model}`, model);
|
||||
}
|
||||
const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?").get(p);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetCache();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
||||
});
|
||||
|
||||
describe("pricing sync", () => {
|
||||
it("reduces an id to its family and names it", () => {
|
||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||
assert.equal(familyOf("gpt-4"), null);
|
||||
assert.equal(familyOf("claude-x%"), null);
|
||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||
});
|
||||
|
||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9%', 'Broad', 5)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9-20260101");
|
||||
const added = await syncPricing(db, { fetchList: async () => LIST });
|
||||
assert.deepEqual(added, ["claude-opus-9-9%"]);
|
||||
const r = row("claude-opus-9-9%");
|
||||
assert.equal(r.display_name, "Claude Opus 9.9");
|
||||
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.2]);
|
||||
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [5, 8]);
|
||||
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
|
||||
});
|
||||
|
||||
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9");
|
||||
let fetched = false;
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
fetched = true;
|
||||
return LIST;
|
||||
},
|
||||
});
|
||||
assert.equal(fetched, false);
|
||||
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
|
||||
});
|
||||
|
||||
it("skips absurd rates and survives a failed fetch", async () => {
|
||||
useModel("claude-opus-9-9");
|
||||
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => bad }), []);
|
||||
_resetCache();
|
||||
const failed = await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,7 @@ describe("pty-attach", () => {
|
||||
return "";
|
||||
});
|
||||
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
assert.deepEqual(calls[0], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
|
||||
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
|
||||
});
|
||||
|
||||
it("still attaches even if the mouse-mode set-option call fails", () => {
|
||||
|
||||
@@ -241,7 +241,18 @@ describe("pty-run", () => {
|
||||
return "";
|
||||
});
|
||||
assert.equal(pty.killRun("agenthub-lane-5"), true);
|
||||
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "agenthub-lane-5"));
|
||||
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "=agenthub-lane-5"));
|
||||
});
|
||||
|
||||
it("never touches a tmux session that is not a lane pane", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
assert.equal(pty.killRun("main"), false);
|
||||
assert.equal(pty.getRun("main"), null);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("listRuns reflects live tmux-session state, not cached memory", () => {
|
||||
|
||||
@@ -79,7 +79,9 @@ const RULES = [
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
skip: [],
|
||||
// A third-party project that really is on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads.
|
||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
|
||||
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.raw) {
|
||||
body = opts.raw.buf;
|
||||
headers["Content-Type"] = opts.raw.type;
|
||||
headers["Content-Length"] = body.length;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = body.length;
|
||||
@@ -203,3 +207,45 @@ describe("/api/run", () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/drop-image", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||
|
||||
it("saves the image under a random name, owner-only, and returns its path", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
});
|
||||
|
||||
it("refuses anything that is not one of the image types", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin browser request", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,3 +393,55 @@ describe("watchdog liveness reap", () => {
|
||||
assert.equal(stmts.getSession.get(sid).status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLiveCwds — darwin branch (ps + lsof, stubbed)", () => {
|
||||
const realPlatform = process.platform;
|
||||
const PS =
|
||||
" 101 /opt/homebrew/bin/claude --resume x\n 102 node /Users/x/claude-dashboard/index.js\n 103 claude\n";
|
||||
const LSOF = "p101\nfcwd\nn/Users/x/proj\np103\nfcwd\nn/private/tmp/other\n";
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.DASHBOARD_LIVENESS_PROBE;
|
||||
process.env.AGENTHUB_FORCE_HOST = "1";
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
calls = [];
|
||||
});
|
||||
after(() => {
|
||||
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
|
||||
liveness.__setExecImpl(null);
|
||||
delete process.env.AGENTHUB_FORCE_HOST;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
});
|
||||
|
||||
it("reads each claude pid's cwd from lsof -Fn records", () => {
|
||||
liveness.__setExecImpl((bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
return bin === "ps" ? PS : LSOF;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds].sort(), ["/Users/x/proj", "/private/tmp/other"]);
|
||||
assert.deepEqual(calls[1], ["lsof", ["-a", "-p", "101,103", "-d", "cwd", "-Fn"]]);
|
||||
});
|
||||
|
||||
it("keeps lsof's partial output when it exits 1 because a pid vanished", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
const err = new Error("exit 1");
|
||||
err.stdout = "p101\nfcwd\nn/Users/x/proj\n";
|
||||
throw err;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds], ["/Users/x/proj"]);
|
||||
});
|
||||
|
||||
it("reports unavailable when lsof fails with no output", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
throw Object.assign(new Error("ENOENT"), { stdout: "" });
|
||||
});
|
||||
assert.equal(realProbe().available, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("tmux wrapper", () => {
|
||||
"--model",
|
||||
"opus",
|
||||
]);
|
||||
assert.deepEqual(calls[1], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
|
||||
assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
|
||||
});
|
||||
|
||||
it("sendText types the literal string and presses Enter, with no quoting", () => {
|
||||
@@ -64,11 +64,25 @@ describe("tmux wrapper", () => {
|
||||
assert.deepEqual(calls[0], [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"agenthub-lane-1",
|
||||
"=agenthub-lane-1:",
|
||||
"-l",
|
||||
'Reminder: run "agenthub stage implement"',
|
||||
]);
|
||||
assert.deepEqual(calls[1], ["send-keys", "-t", "agenthub-lane-1", "Enter"]);
|
||||
assert.deepEqual(calls[1], ["send-keys", "-t", "=agenthub-lane-1:", "Enter"]);
|
||||
});
|
||||
|
||||
it("targets a session exactly, so lane 1 never resolves to lane 12 by prefix", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
tmux.hasSession("agenthub-lane-1");
|
||||
tmux.killSession("agenthub-lane-1");
|
||||
assert.deepEqual(calls, [
|
||||
["has-session", "-t", "=agenthub-lane-1"],
|
||||
["kill-session", "-t", "=agenthub-lane-1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("killSession never throws when the session is already gone", () => {
|
||||
|
||||
+18
-1
@@ -645,6 +645,8 @@ try {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
-- Every column the probes ABOVE this block add must be listed here too,
|
||||
-- or the rebuild silently drops it (router_profile once was).
|
||||
CREATE TABLE lanes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
@@ -669,6 +671,7 @@ try {
|
||||
base_branch TEXT,
|
||||
slug TEXT,
|
||||
tool TEXT NOT NULL DEFAULT 'claude',
|
||||
router_profile TEXT,
|
||||
detected_stage TEXT,
|
||||
detected_signal TEXT,
|
||||
detected_at TEXT,
|
||||
@@ -679,7 +682,7 @@ try {
|
||||
INSERT INTO lanes_new SELECT
|
||||
id, title, cwd, branch, pipeline, session_id, run_id, stage, stage_since,
|
||||
status, gate_decision, ci_status, needs_action, links, stages, notes,
|
||||
created_at, updated_at, kind, source_repo, base_branch, slug, tool,
|
||||
created_at, updated_at, kind, source_repo, base_branch, slug, tool, router_profile,
|
||||
detected_stage, detected_signal, detected_at, slot, ports, active_feature_id
|
||||
FROM lanes;
|
||||
DROP TABLE lanes;
|
||||
@@ -695,6 +698,17 @@ try {
|
||||
}
|
||||
db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run();
|
||||
|
||||
// Migrate: which session raised `needs_action`. Two sessions in one lane's cwd
|
||||
// (a hand-started terminal beside the dashboard pane) rebind `session_id` on
|
||||
// every hook, so "the bound session" is not "the session that is waiting".
|
||||
// Additive and nullable; null (a flag raised before this column) falls back to
|
||||
// the bound session. Placed after the rebuild above, which lists its columns.
|
||||
try {
|
||||
db.prepare("SELECT needs_action_session FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN needs_action_session TEXT").run();
|
||||
}
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
// becomes one run; each archived feature becomes one more. That is all the
|
||||
@@ -834,6 +848,9 @@ const DEFAULT_PRICING = [
|
||||
["claude-fable-5%", "Claude Fable 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
["claude-mythos-5%", "Claude Mythos 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
// Opus family (fast mode available on 5 / 4.6 / 4.7 / 4.8)
|
||||
// 5.5 is cheaper than 5 and "claude-opus-5%" would swallow it — own row
|
||||
// (longest pattern wins in calculateCost).
|
||||
["claude-opus-5-5%", "Claude Opus 5.5", 4, 20, 0.2, 5, 8, 8, 40],
|
||||
["claude-opus-5%", "Claude Opus 5", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-8%", "Claude Opus 4.8", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25, 10, 30, 150],
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||
|
||||
// A server started from inside a Claude Code session (the bootstrap fallback,
|
||||
// always the case on macOS) inherits that session's own variables, and tmux
|
||||
// hands them to every lane pane — whose `claude` then believes it is nested.
|
||||
// ponytail: a DENYLIST of parent-session variables only, so user config
|
||||
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
|
||||
// it must grow when Claude Code adds a new per-session variable.
|
||||
const PARENT_SESSION_VARS =
|
||||
/^(CLAUDECODE|AI_AGENT|CLAUDE_PID|CLAUDE_PROJECT_DIR|CLAUDE_ENV_FILE|CLAUDE_PLUGIN_(ROOT|DATA)|CLAUDE_CODE_(ENTRYPOINT|SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|EXECPATH|MESSAGING_.*))$/;
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
|
||||
}
|
||||
// Claude Code sets GIT_EDITOR=true so its own commits never block; a user's
|
||||
// real editor setting is anything else and stays.
|
||||
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
||||
|
||||
// Load .env file (simple key=value, no external dependency needed)
|
||||
(function loadDotEnv() {
|
||||
const fs = require("fs");
|
||||
@@ -74,6 +89,7 @@ const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const aiRoutersRouter = require("./routes/ai-routers");
|
||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -117,6 +133,7 @@ function createApp() {
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/ai-routers", aiRoutersRouter);
|
||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
@@ -325,6 +342,9 @@ function startBackgroundServices() {
|
||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||
autoImportLegacySessions();
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
|
||||
// A provisioning job belongs to the previous Node process and cannot survive
|
||||
// a restart. Recover those rows now so the dashboard never shows a permanent
|
||||
// provisioning spinner for work that was interrupted by the restart.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file The git credential helper AgentHub installs on its own network git
|
||||
* commands (`-c credential.helper=!node <this>`, see git-credentials.js).
|
||||
* Speaks git's credential protocol: `get` reads `key=value` lines on stdin
|
||||
* and answers `username=` / `password=` for a matching protocol + host —
|
||||
* first from `AGENTHUB_GIT_ONCE` (a credential typed for this one command),
|
||||
* then from the store file named by `AGENTHUB_GIT_CRED_FILE`. `store` and
|
||||
* `erase` do nothing: AgentHub saves only what the user asked it to keep, and
|
||||
* only after git has proved it works. Prints nothing else, ever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
if (process.argv[2] !== "get") process.exit(0);
|
||||
|
||||
let input = "";
|
||||
process.stdin.on("data", (c) => (input += c));
|
||||
process.stdin.on("end", () => {
|
||||
const want = {};
|
||||
for (const line of input.split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) want[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
const protocol = String(want.protocol || "").toLowerCase();
|
||||
const host = String(want.host || "").toLowerCase();
|
||||
const matches = (r) => r && r.protocol === protocol && r.host === host && r.password;
|
||||
|
||||
let hit = null;
|
||||
try {
|
||||
const once = JSON.parse(process.env.AGENTHUB_GIT_ONCE || "null");
|
||||
if (matches(once)) hit = once;
|
||||
} catch {
|
||||
/* malformed: fall through to the store */
|
||||
}
|
||||
if (!hit && process.env.AGENTHUB_GIT_CRED_FILE) {
|
||||
try {
|
||||
const rows = JSON.parse(fs.readFileSync(process.env.AGENTHUB_GIT_CRED_FILE, "utf8"));
|
||||
hit = (Array.isArray(rows) ? rows : []).find(matches) || null;
|
||||
} catch {
|
||||
/* no store yet */
|
||||
}
|
||||
}
|
||||
// A helper that answers nothing lets git fail with its own auth error,
|
||||
// which the dashboard turns into the login form.
|
||||
if (hit) process.stdout.write(`username=${hit.username}\npassword=${hit.password}\n`);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file AgentHub's own git credentials — what a dashboard fetch/pull/push
|
||||
* authenticates with when the machine's git has nothing (issue #45). The
|
||||
* dashboard runs git with no terminal, so without this a remote needing a
|
||||
* login fails with "could not read Username … terminal prompts disabled".
|
||||
*
|
||||
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
||||
* file 0600, written through a temp file), one record per protocol + host,
|
||||
* shared by everyone using this AgentHub. Like the AI Router store, **no route
|
||||
* ever returns a password** — `list` reports `hasToken`.
|
||||
*
|
||||
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
||||
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
|
||||
* ours, because a stale token in the machine's helper would win over the one
|
||||
* just typed, and git's post-success `store` would copy a use-once token into
|
||||
* the OS user's own helper. The secret itself never reaches argv.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
|
||||
const PROTOCOLS = new Set(["https", "http"]);
|
||||
const HELPER = path.join(__dirname, "git-credential-helper.js");
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function credentialsFile() {
|
||||
return path.join(getClaudeHome(), "agenthub-git-credentials", "credentials.json");
|
||||
}
|
||||
|
||||
/** `{protocol, host}` normalised, or EBADHOST — a host is also a lookup key. */
|
||||
function key(protocol, host) {
|
||||
const p = String(protocol || "").toLowerCase();
|
||||
const h = String(host || "").toLowerCase();
|
||||
if (!PROTOCOLS.has(p) || !HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "protocol must be http(s) and host a plain host[:port]");
|
||||
}
|
||||
return { protocol: p, host: h };
|
||||
}
|
||||
|
||||
function readAll() {
|
||||
try {
|
||||
const rows = JSON.parse(fs.readFileSync(credentialsFile(), "utf8"));
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(rows) {
|
||||
const file = credentialsFile();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function list() {
|
||||
return readAll().map(({ protocol, host, username, savedAt }) => ({
|
||||
protocol,
|
||||
host,
|
||||
username,
|
||||
savedAt,
|
||||
hasToken: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function find(protocol, host) {
|
||||
const k = key(protocol, host);
|
||||
return readAll().find((r) => r.protocol === k.protocol && r.host === k.host) || null;
|
||||
}
|
||||
|
||||
function save({ protocol, host, username, password }) {
|
||||
const k = key(protocol, host);
|
||||
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
|
||||
throw err("EBADCRED", "username is required");
|
||||
}
|
||||
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
|
||||
throw err("EBADCRED", "password or access token is required");
|
||||
}
|
||||
const rows = readAll().filter((r) => !(r.protocol === k.protocol && r.host === k.host));
|
||||
rows.push({ ...k, username: username.trim(), password, savedAt: new Date().toISOString() });
|
||||
writeAll(rows);
|
||||
}
|
||||
|
||||
function remove(protocol, host) {
|
||||
const k = key(protocol, host);
|
||||
const rows = readAll();
|
||||
const kept = rows.filter((r) => !(r.protocol === k.protocol && r.host === k.host));
|
||||
if (kept.length === rows.length) return false;
|
||||
writeAll(kept);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* git's wording for "I needed a login and did not get one (or it was wrong)",
|
||||
* and the URL it names. null for every other failure.
|
||||
*/
|
||||
function authFailure(stderr) {
|
||||
const text = String(stderr || "");
|
||||
if (
|
||||
!/could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const m = text.match(/'(https?):\/\/(?:[^@'/]*@)?([^/'\s]+)/i);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return key(m[1], m[2]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The http(s) hosts a repo's remotes point at. */
|
||||
async function remoteHosts(dir) {
|
||||
// Required lazily: worktree.js is heavy and only this path needs it.
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(dir, ["remote", "-v"]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const hosts = new Map();
|
||||
// A username in the URL (https://vi@host/…) pre-fills the login form; a
|
||||
// password in it (user:pass@) is never read back out.
|
||||
for (const m of out.matchAll(/\t(https?):\/\/(?:([^@\s/:]*)(?::[^@\s/]*)?@)?([^/\s]+)/gi)) {
|
||||
try {
|
||||
const k = key(m[1], m[3]);
|
||||
hosts.set(`${k.protocol}://${k.host}`, {
|
||||
...k,
|
||||
username: m[2] ? decodeURIComponent(m[2]) : null,
|
||||
});
|
||||
} catch {
|
||||
/* not a host we can hold a credential for */
|
||||
}
|
||||
}
|
||||
return [...hosts.values()];
|
||||
}
|
||||
|
||||
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
* The `-c` args and env a network git command runs with. `once` is a
|
||||
* credential from the request, accepted only for one of the repo's own
|
||||
* remote hosts — a key typed for one server is never sent to another.
|
||||
*/
|
||||
async function gitConfigFor(dir, once) {
|
||||
const hosts = await remoteHosts(dir);
|
||||
const env = {};
|
||||
if (once) {
|
||||
const k = key(once.protocol, once.host);
|
||||
if (!hosts.some((h) => h.protocol === k.protocol && h.host === k.host)) {
|
||||
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
|
||||
}
|
||||
env.AGENTHUB_GIT_ONCE = JSON.stringify({
|
||||
...k,
|
||||
username: String(once.username || ""),
|
||||
password: String(once.password || ""),
|
||||
});
|
||||
} else if (!hosts.some((h) => find(h.protocol, h.host))) {
|
||||
return { args: [], env: {} };
|
||||
}
|
||||
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host
|
||||
// gets no machine helper either (see the file header); chain the machine's
|
||||
// helpers for unknown hosts if mixed-host `fetch --all` ever matters.
|
||||
env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
|
||||
return {
|
||||
args: [
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`,
|
||||
],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
credentialsFile,
|
||||
list,
|
||||
find,
|
||||
save,
|
||||
remove,
|
||||
authFailure,
|
||||
remoteHosts,
|
||||
gitConfigFor,
|
||||
};
|
||||
@@ -760,7 +760,9 @@ async function lsFiles(dir, args) {
|
||||
const all = out.split("\0");
|
||||
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
|
||||
const cut = all.pop() !== "";
|
||||
return { all: all.filter(Boolean), cut };
|
||||
// Mid-merge, `ls-files --cached` prints a conflicted path once per stage
|
||||
// (base, ours, theirs): one path is one file.
|
||||
return { all: [...new Set(all.filter(Boolean))], cut };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
@@ -907,6 +909,7 @@ async function listTreeAll(dir) {
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
|
||||
+253
-20
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, and create or
|
||||
* delete a branch. Everything here is deliberately the boring half of git —
|
||||
* there is no merge, no rebase, no reset, no stash and no conflict
|
||||
* resolution, because a dashboard button is the wrong place to start an
|
||||
* stage/unstage a file, commit, discard a file, switch branch, create or
|
||||
* delete a branch, publish a repo that has no remote yet, and merge a branch
|
||||
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no reset and
|
||||
* no stash, because a dashboard button is the wrong place to start an
|
||||
* operation whose recovery needs a terminal (`reset` stays where it was:
|
||||
* behind the three-check destroy guard in worktree.js).
|
||||
* behind the three-check destroy guard in worktree.js). A merge is the
|
||||
* exception because its recovery is ONE button here too — `merge-abort` puts
|
||||
* the branch back where it was.
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
@@ -18,11 +21,18 @@
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
|
||||
* invent a merge commit or a conflict underneath a running agent.
|
||||
* invent a merge commit or a conflict underneath a running agent. A merge
|
||||
* happens only when asked for by name (`merge`), on a clean tree, from a
|
||||
* lane that IS the repo root (a merge is repo-wide), one at a time.
|
||||
*
|
||||
* `discard` is the ONE irreversible action here — it throws away work git has
|
||||
* no copy of — so it additionally demands an explicit `confirm: true` in the
|
||||
* body. Do not add a second destructive action without the same gate.
|
||||
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
|
||||
* of, so each demands an explicit `confirm: true` in the body. Do not add
|
||||
* another destructive action without the same gate.
|
||||
*
|
||||
* `publish` is the ONE action whose destination comes from the request (a
|
||||
* remote URL), so it is gated differently: only when `git remote` prints
|
||||
* nothing, always named `origin`, the URL allowlisted by `validateRemoteUrl`,
|
||||
* and the remote removed again if the push fails.
|
||||
*
|
||||
* Network actions carry a timeout: a lane pointed at an unreachable remote
|
||||
* should fail in half a minute, not hold an HTTP request open.
|
||||
@@ -39,7 +49,8 @@ const {
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus } = require("./git-diff");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
@@ -61,8 +72,25 @@ const ACTIONS = new Set([
|
||||
"discard-hunk",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
"publish",
|
||||
"merge",
|
||||
"resolve",
|
||||
"merge-commit",
|
||||
"merge-abort",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge in progress. */
|
||||
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
|
||||
|
||||
/** A line git writes to open or close a conflict block. */
|
||||
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
|
||||
|
||||
/** A remote URL is at most this long — a forge URL, not a payload. */
|
||||
const MAX_URL_LEN = 1024;
|
||||
/** `user@host:path`, git's scp-like ssh form. Nothing may start with `-`. */
|
||||
const SCP_LIKE =
|
||||
/^[A-Za-z0-9._][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
|
||||
|
||||
/** Branches this module refuses to delete, whatever the request says. */
|
||||
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
||||
|
||||
@@ -72,6 +100,43 @@ function bad(code, message) {
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination `publish` may push to, or an EBADURL throw.
|
||||
*
|
||||
* An allowlist, because this is the one write whose destination comes from
|
||||
* the request: `https://` with NO userinfo (a token typed into the URL would
|
||||
* land in .git/config in plain text), `ssh://`, and scp-like `user@host:path`.
|
||||
* Everything else is refused — a local path or `file://` (a push into some
|
||||
* other directory on this machine), `ext::` (git runs it as a command),
|
||||
* `fd::`, `git://` and `http://` (unauthenticated / cleartext), and anything
|
||||
* starting with `-` that could be read as an option.
|
||||
*/
|
||||
function validateRemoteUrl(url) {
|
||||
const refuse = () => bad("EBADURL", "url must be https://, ssh:// or user@host:path");
|
||||
if (typeof url !== "string" || !url || url.length > MAX_URL_LEN) throw refuse();
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
|
||||
if (SCP_LIKE.test(url)) return url;
|
||||
// WHATWG is more forgiving than git: it reads `https:host/x` or `https:\\host`
|
||||
// as https://host/x, where git sees no "://" and dials an ssh host named
|
||||
// "https". So the scheme must be spelled out literally, and the authority
|
||||
// may carry no `%` (git decodes `%2D` into the leading "-" refused below)
|
||||
// and no backslash — the string checked is the string git gets.
|
||||
const scheme = ["https://", "ssh://"].find((p) => url.startsWith(p));
|
||||
if (!scheme || /[%\\]/.test(url.slice(scheme.length).split("/")[0])) throw refuse();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw refuse();
|
||||
}
|
||||
const { protocol, username, password, hostname } = parsed;
|
||||
if (!hostname || hostname.startsWith("-") || password) throw refuse();
|
||||
if (protocol === "https:" && !username) return url;
|
||||
if (protocol === "ssh:" && !username.startsWith("-")) return url;
|
||||
throw refuse();
|
||||
}
|
||||
|
||||
/** Whether the working tree has anything uncommitted (tracked or not). */
|
||||
async function isDirty(dir) {
|
||||
const { dirty, untracked } = await statusCounts(dir);
|
||||
@@ -159,6 +224,49 @@ async function resolvePaths(dir, body) {
|
||||
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch/pull/push, authenticated with AgentHub's own git credentials
|
||||
* (git-credentials.js): a stored one for the remote's host, or
|
||||
* `body.credentials` typed for this one call. A login failure becomes
|
||||
* EGITAUTH carrying the host, which the git bar turns into a login form; a
|
||||
* typed credential is saved only after git accepted it and only if asked.
|
||||
*/
|
||||
async function networkGit(dir, args, body = {}) {
|
||||
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
|
||||
const cfg = await gitCredentials.gitConfigFor(dir, typed);
|
||||
try {
|
||||
const out = await git(dir, [...cfg.args, ...args], {
|
||||
timeout: NETWORK_TIMEOUT_MS,
|
||||
env: cfg.env,
|
||||
});
|
||||
if (typed?.remember) gitCredentials.save(typed);
|
||||
return out;
|
||||
} catch (e) {
|
||||
const auth = gitCredentials.authFailure(e.git?.stderr);
|
||||
if (!auth) throw e;
|
||||
const remote = (await gitCredentials.remoteHosts(dir)).find(
|
||||
(h) => h.protocol === auth.protocol && h.host === auth.host
|
||||
);
|
||||
const stored = gitCredentials.find(auth.protocol, auth.host);
|
||||
const wrong = Boolean(typed) || Boolean(stored);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
wrong
|
||||
? `${auth.host} rejected the username or token`
|
||||
: `${auth.host} needs a username and password or access token`
|
||||
),
|
||||
{
|
||||
code: "EGITAUTH",
|
||||
auth: {
|
||||
...auth,
|
||||
username: typed?.username || stored?.username || remote?.username || null,
|
||||
rejected: wrong,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one action against a lane's working directory.
|
||||
*
|
||||
@@ -172,18 +280,19 @@ async function resolvePaths(dir, body) {
|
||||
*/
|
||||
async function runGitAction(dir, action, body = {}) {
|
||||
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
|
||||
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
|
||||
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
|
||||
}
|
||||
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
|
||||
|
||||
if (action === "fetch") {
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
|
||||
}
|
||||
|
||||
if (action === "pull") {
|
||||
// --ff-only on purpose: a divergent branch is reported, never merged. The
|
||||
// user (or the agent in the pane) decides how to reconcile it.
|
||||
return done(action, await git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
|
||||
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
|
||||
}
|
||||
|
||||
if (action === "push") {
|
||||
@@ -191,14 +300,41 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// is actually on, and -u makes the first push set the upstream the
|
||||
// ahead/behind counts then read.
|
||||
const remote = await pushRemote(dir);
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
|
||||
}
|
||||
|
||||
if (action === "publish") {
|
||||
// Gives a remote to a repo that has NONE, then does what `push` does. It
|
||||
// never re-points an existing remote, so it cannot redirect a repo that
|
||||
// already publishes somewhere, and the remote name is fixed like `push`'s.
|
||||
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "make the first commit before publishing");
|
||||
if ((await git(dir, ["remote"])).stdout.trim()) {
|
||||
throw bad("EREMOTEEXISTS", "this repo already has a remote");
|
||||
}
|
||||
const url = validateRemoteUrl(body.url);
|
||||
await git(dir, ["remote", "add", "origin", "--", url]);
|
||||
let result;
|
||||
try {
|
||||
result = await networkGit(dir, ["push", "-u", "origin", "HEAD"], body);
|
||||
} catch (err) {
|
||||
// A remote left behind by a failed push (a typo, a missing credential)
|
||||
// would trip EREMOTEEXISTS forever and need a terminal to undo.
|
||||
await git(dir, ["remote", "remove", "origin"]).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return { ...done(action, result), remote: "origin" };
|
||||
}
|
||||
|
||||
if (action === "stage") {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
// Staging a conflicted file marks it resolved: not while git's markers
|
||||
// are still in it (the same check `resolve` makes).
|
||||
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
|
||||
const file = path.join(dir, p.path);
|
||||
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
|
||||
throw bad("EMARKERS", `${p.path} still has conflict markers`);
|
||||
}
|
||||
}
|
||||
// `add` covers every case the panel can show: a modification, a new file
|
||||
// and a deletion all stage with the same verb.
|
||||
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
|
||||
@@ -336,6 +472,103 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
}
|
||||
|
||||
/** Whether a merge is in progress (MERGE_HEAD exists). */
|
||||
async function isMerging(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paths git still holds as unmerged, repo-wide. */
|
||||
async function unmergedPaths(dir) {
|
||||
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** merge / resolve / merge-commit / merge-abort. */
|
||||
async function mergeAction(dir, action, body) {
|
||||
if (action === "merge") {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
// A merge moves the whole repo, so only a lane that IS the repo may start
|
||||
// one — a subdirectory lane could not see, let alone resolve, a conflict
|
||||
// outside its own folder.
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
"a merge changes the whole repository; run it from a lane at the repo root"
|
||||
);
|
||||
}
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
// The ref is looked up, never passed through: git sees the full refname
|
||||
// of a local or remote-tracking branch that exists, nothing else.
|
||||
const refs = (
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)%00%(refname)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
const ref = refs.find(([short]) => short === branch)?.[1];
|
||||
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
|
||||
try {
|
||||
const out = await git(dir, ["merge", "--no-edit", ref]);
|
||||
return { ...done(action, out, branch), conflicts: [] };
|
||||
} catch (e) {
|
||||
// A conflict is a merge that STARTED: report the files, not a failure.
|
||||
if (!(await isMerging(dir))) throw e;
|
||||
return {
|
||||
...done(action, e.git || { stdout: "", stderr: "" }, branch),
|
||||
conflicts: await unmergedPaths(dir),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "resolve") {
|
||||
const [target] = await resolvePaths(dir, { path: body.path });
|
||||
if (!CONFLICT_CODES.has(target.code))
|
||||
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
|
||||
const side = body.side;
|
||||
if (!["ours", "theirs", "done"].includes(side))
|
||||
throw bad("EBADSIDE", "side must be ours, theirs or done");
|
||||
const p = target.path;
|
||||
const gone =
|
||||
(side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D");
|
||||
if (side !== "done" && !gone) {
|
||||
await git(dir, ["checkout", `--${side}`, "--", p]);
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
if (side === "done" && fs.existsSync(path.join(dir, p))) {
|
||||
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
|
||||
throw bad("EMARKERS", `${p} still has conflict markers`);
|
||||
}
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
// The chosen side deleted it (or the hand edit did): resolved as gone.
|
||||
return done(action, await git(dir, ["rm", "-q", "--", p]));
|
||||
}
|
||||
|
||||
if (action === "merge-abort" && body.confirm !== true) {
|
||||
throw bad("ECONFIRM", "merge-abort requires confirm: true");
|
||||
}
|
||||
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
|
||||
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
|
||||
// merge-commit
|
||||
const left = await unmergedPaths(dir);
|
||||
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
|
||||
return done(action, await git(dir, ["commit", "--no-edit"]));
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
async function branchAction(dir, action, body) {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
@@ -390,4 +623,4 @@ function done(action, result, branch) {
|
||||
return { action, ok: true, output, ...(branch ? { branch } : {}) };
|
||||
}
|
||||
|
||||
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
|
||||
+28
-1
@@ -69,6 +69,7 @@ const PATCHABLE = new Set([
|
||||
"gate_decision",
|
||||
"ci_status",
|
||||
"needs_action",
|
||||
"needs_action_session",
|
||||
"links",
|
||||
"notes",
|
||||
"session_id",
|
||||
@@ -160,6 +161,31 @@ function normalizeTitle(value) {
|
||||
return String(value).trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the OS calls this directory: macOS hands a session `/private/tmp/x`
|
||||
* for a typed `/tmp/x`, and a case-insensitive volume accepts `~/Code` for
|
||||
* `~/code`, so a lane stored under the typed string would never bind a
|
||||
* session. A path that does not exist yet (a worktree lane is created before
|
||||
* its directory) keeps the string it was given.
|
||||
*/
|
||||
function canonicalCwd(cwd) {
|
||||
// A relative path must stay relative, so createLane still refuses it.
|
||||
if (!cwd.startsWith("/")) return cwd;
|
||||
const trimmed = cwd.replace(/\/+$/, "");
|
||||
// A path that does not exist yet resolves through its deepest existing
|
||||
// ancestor, so /link/new and /real/new still name the same lane.
|
||||
const path = require("node:path");
|
||||
const tail = [];
|
||||
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
||||
try {
|
||||
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
|
||||
} catch {
|
||||
if (dir === "/" || !dir) return trimmed;
|
||||
tail.unshift(path.basename(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLane({
|
||||
title = "",
|
||||
cwd,
|
||||
@@ -184,7 +210,7 @@ function createLane({
|
||||
)
|
||||
.run(
|
||||
normalizeTitle(title),
|
||||
cwd.replace(/\/+$/, ""),
|
||||
canonicalCwd(cwd),
|
||||
branch,
|
||||
pipeline,
|
||||
kind,
|
||||
@@ -842,6 +868,7 @@ module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
detectionIsStale,
|
||||
canonicalCwd,
|
||||
createLane,
|
||||
listLanes,
|
||||
getLane,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file Automatic pricing for Claude models the dashboard has seen but has no
|
||||
* rate row of its own for. Every model id in `token_usage` is reduced to its
|
||||
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
|
||||
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
||||
* never updated, so a rate the user edited in Settings always wins. "Its own
|
||||
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
||||
* Opus 5 by the broader `claude-opus-5%` pattern.
|
||||
*
|
||||
* The list is fetched only when something is missing, at most once per
|
||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const PRICE_LIST_URL =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
const CHECK_MS = 15 * 60_000;
|
||||
const CACHE_MS = 24 * 60 * 60_000;
|
||||
/** A per-MTok rate above this is a parse error, not a price. */
|
||||
const MAX_PER_MTOK = 1000;
|
||||
|
||||
let cached = null; // { at, list }
|
||||
|
||||
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
||||
function familyOf(model) {
|
||||
if (typeof model !== "string") return null;
|
||||
const fam = model.replace(/-\d{8}$/, "");
|
||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||
}
|
||||
|
||||
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
||||
function displayName(fam) {
|
||||
const [, ...parts] = fam.split("-");
|
||||
const words = [];
|
||||
const nums = [];
|
||||
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
||||
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
||||
}
|
||||
|
||||
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
||||
function toRow(fam, e) {
|
||||
if (!e || e.litellm_provider !== "anthropic") return null;
|
||||
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
||||
const input = per(e.input_cost_per_token);
|
||||
const output = per(e.output_cost_per_token);
|
||||
// Anthropic's published multipliers stand in for a field the list omits.
|
||||
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
||||
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
||||
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
||||
const rates = [input, output, read, w5m, w1h];
|
||||
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
||||
if (input === 0 && output === 0) return null;
|
||||
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||
return {
|
||||
pattern: `${fam}%`,
|
||||
name: displayName(fam),
|
||||
input: r(input),
|
||||
output: r(output),
|
||||
read: r(read),
|
||||
w5m: r(w5m),
|
||||
w1h: r(w1h),
|
||||
fastIn: fastOk ? r(input * fast) : 0,
|
||||
fastOut: fastOk ? r(output * fast) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Families used in token_usage that have no `<family>%` row of their own. */
|
||||
function missingFamilies(db) {
|
||||
const have = new Set(
|
||||
db
|
||||
.prepare("SELECT model_pattern FROM model_pricing")
|
||||
.all()
|
||||
.map((r) => r.model_pattern)
|
||||
);
|
||||
const out = new Set();
|
||||
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
|
||||
const fam = familyOf(model);
|
||||
if (fam && !have.has(`${fam}%`)) out.add(fam);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async function defaultFetch() {
|
||||
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass. Returns the patterns inserted. Never throws: pricing is a display
|
||||
* concern and a failed fetch must not reach the server's startup path.
|
||||
*/
|
||||
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
|
||||
try {
|
||||
const missing = missingFamilies(db);
|
||||
if (missing.length === 0) return [];
|
||||
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const added = [];
|
||||
for (const fam of missing) {
|
||||
const row = toRow(fam, cached.list?.[fam]);
|
||||
if (!row) continue;
|
||||
const { changes } = insert.run(
|
||||
row.pattern,
|
||||
row.name,
|
||||
row.input,
|
||||
row.output,
|
||||
row.read,
|
||||
row.w5m,
|
||||
row.w1h,
|
||||
row.fastIn,
|
||||
row.fastOut
|
||||
);
|
||||
if (changes) added.push(row.pattern);
|
||||
}
|
||||
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
|
||||
return added;
|
||||
} catch (err) {
|
||||
console.warn("[pricing] sync failed:", err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startPricingSync(db) {
|
||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
||||
void syncPricing(db);
|
||||
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
||||
}
|
||||
|
||||
function _resetCache() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|
||||
@@ -48,7 +48,7 @@ function attach(ws, runId, { cols, rows }) {
|
||||
} catch {
|
||||
/* session gone or tmux unavailable — attach below will surface that */
|
||||
}
|
||||
const pty = spawnImpl("tmux", ["attach-session", "-t", runId], {
|
||||
const pty = spawnImpl("tmux", ["attach-session", "-t", `=${runId}`], {
|
||||
name: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
@@ -322,8 +322,10 @@ function spawnRun(args) {
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
// Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main`
|
||||
// would kill the user's own tmux session named `main`.
|
||||
function killRun(id) {
|
||||
if (!id || !tmux.hasSession(id)) return false;
|
||||
if (laneIdFromRunId(id) === null || !tmux.hasSession(id)) return false;
|
||||
tmux.killSession(id);
|
||||
if (dashboardRuns) {
|
||||
dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() });
|
||||
@@ -353,7 +355,7 @@ function publicRun(id) {
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
if (!id) return null;
|
||||
if (laneIdFromRunId(id) === null) return null;
|
||||
return publicRun(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { isInsideContainer } = require("../../scripts/install-hooks");
|
||||
|
||||
let execImpl = execFileSync;
|
||||
|
||||
/** Test seam — same pattern as tmux.js / desktop-env.js. */
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn || execFileSync;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
|
||||
|
||||
/**
|
||||
@@ -62,7 +69,7 @@ function probeLiveCwds() {
|
||||
|
||||
let psOut;
|
||||
try {
|
||||
psOut = execFileSync("ps", ["-Ao", "pid=,args="], {
|
||||
psOut = execImpl("ps", ["-Ao", "pid=,args="], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
@@ -95,7 +102,7 @@ function probeLiveCwds() {
|
||||
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
|
||||
let lsofOut;
|
||||
try {
|
||||
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
|
||||
lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
@@ -113,4 +120,4 @@ function probeLiveCwds() {
|
||||
return { available: true, cwds };
|
||||
}
|
||||
|
||||
module.exports = { probeLiveCwds, isClaudeCommand };
|
||||
module.exports = { probeLiveCwds, isClaudeCommand, __setExecImpl };
|
||||
|
||||
+15
-7
@@ -33,9 +33,16 @@ function __reset() {
|
||||
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||
}
|
||||
|
||||
// tmux resolves a bare `-t agenthub-lane-1` by PREFIX when no exact match
|
||||
// exists, so it would hit `agenthub-lane-12` once lane 1's session is gone —
|
||||
// and killSession would kill the wrong lane. `=` forces an exact match; pane
|
||||
// and option targets need the trailing `:` (tmux 3.4 rejects `=name` there).
|
||||
const session = (name) => `=${name}`;
|
||||
const pane = (name) => `=${name}:`;
|
||||
|
||||
function hasSession(name) {
|
||||
try {
|
||||
execImpl(["has-session", "-t", name]);
|
||||
execImpl(["has-session", "-t", session(name)]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -71,7 +78,7 @@ function newSession({ name, cwd, argv, env }) {
|
||||
// this option existed — or before a server restart picked up the change —
|
||||
// still gets it; `set-option` is idempotent, so re-running it is harmless.
|
||||
function enableMouse(name) {
|
||||
execImpl(["set-option", "-t", name, "mouse", "on"]);
|
||||
execImpl(["set-option", "-t", pane(name), "mouse", "on"]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +89,8 @@ function enableMouse(name) {
|
||||
function paneCommand(name) {
|
||||
try {
|
||||
return (
|
||||
execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null
|
||||
execImpl(["display-message", "-p", "-t", pane(name), "#{pane_current_command}"]).trim() ||
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -107,7 +115,7 @@ function sendCommand(name, argv, env, cwd) {
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
name,
|
||||
pane(name),
|
||||
...(cwd ? ["-c", cwd] : []),
|
||||
...envFlags(env),
|
||||
"--",
|
||||
@@ -125,14 +133,14 @@ function sendCommand(name, argv, env, cwd) {
|
||||
* only the first line and leave the rest sitting unsent in the input box.
|
||||
*/
|
||||
function sendText(name, text) {
|
||||
execImpl(["send-keys", "-t", name, "-l", text]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
execImpl(["send-keys", "-t", pane(name), "-l", text]);
|
||||
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||
}
|
||||
|
||||
/** Idempotent — a session that's already gone is not an error. */
|
||||
function killSession(name) {
|
||||
try {
|
||||
execImpl(["kill-session", "-t", name]);
|
||||
execImpl(["kill-session", "-t", session(name)]);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
|
||||
+47
-8
@@ -30,7 +30,11 @@ const STATUS_FILE_CAP = 20;
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
|
||||
) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -60,6 +64,8 @@ async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, litera
|
||||
delete env.GIT_ICASE_PATHSPECS;
|
||||
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
|
||||
else delete env.GIT_LITERAL_PATHSPECS;
|
||||
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
|
||||
Object.assign(env, extraEnv);
|
||||
|
||||
try {
|
||||
const result = await execFileAsync("git", args, {
|
||||
@@ -635,9 +641,14 @@ async function statusCounts(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get short commit hash
|
||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||
const head = headResult.stdout.trim();
|
||||
// Short commit hash — null in a repo with no commit yet (an unborn HEAD),
|
||||
// which is a normal state for a fresh `git init`, not an unreadable repo.
|
||||
let head = null;
|
||||
try {
|
||||
head = (await git(dir, ["rev-parse", "--short", "HEAD"])).stdout.trim() || null;
|
||||
} catch {
|
||||
/* unborn */
|
||||
}
|
||||
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
@@ -684,7 +695,8 @@ async function laneFiles(dir) {
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
* short HEAD, that commit's subject, and how much is uncommitted. A repo with
|
||||
* no commit yet reports `unborn: true`, `head: null` and an empty subject.
|
||||
*
|
||||
* Read-only and cheap, but it is five subprocesses, which is why it lives
|
||||
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||
@@ -694,9 +706,36 @@ async function laneFiles(dir) {
|
||||
async function gitFacts(dir) {
|
||||
const { head } = await statusCounts(dir);
|
||||
const { dirty, untracked, files } = await laneFiles(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
|
||||
// An unborn HEAD has no commit to abbreviate or log, but it does name the
|
||||
// branch the first commit will land on — symbolic-ref reads that.
|
||||
const branch = head
|
||||
? (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim()
|
||||
: (await git(dir, ["symbolic-ref", "--short", "HEAD"])).stdout.trim();
|
||||
const subject = head ? (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim() : "";
|
||||
return {
|
||||
branch,
|
||||
head,
|
||||
unborn: !head,
|
||||
subject,
|
||||
dirty,
|
||||
untracked,
|
||||
files,
|
||||
...(await trackingFacts(dir)),
|
||||
...(await mergeFacts(dir)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
|
||||
async function mergeFacts(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
} catch {
|
||||
return { merging: false, conflicts: 0 };
|
||||
}
|
||||
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -116,7 +116,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Find or adopt the lane owning a working directory",
|
||||
description:
|
||||
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
"Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
operationId: "ensureLane",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -199,7 +199,8 @@ const paths = {
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "sourceRepo is relative, missing, or not a git repository.",
|
||||
description:
|
||||
"EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
@@ -279,7 +280,7 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
|
||||
'available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).',
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
@@ -618,7 +619,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One git write on the lane's working copy",
|
||||
description:
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
operationId: "runLaneGitAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -641,6 +642,7 @@ const paths = {
|
||||
"switch",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
"publish",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -652,6 +654,11 @@ const paths = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
|
||||
},
|
||||
message: { type: "string", description: "commit only; required, max 4000 chars." },
|
||||
only: {
|
||||
type: "string",
|
||||
@@ -693,15 +700,18 @@ const paths = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
|
||||
200: {
|
||||
description:
|
||||
'{action, ok:true, output} — git\'s own stdout+stderr; publish adds remote:"origin".',
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
409: {
|
||||
description:
|
||||
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
|
||||
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.",
|
||||
},
|
||||
500: { description: "Unexpected server-side failure." },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
||||
* list, save/replace, delete. Like the AI Router routes, nothing here ever
|
||||
* returns a password — `GET` reports `hasToken` — and every write is behind
|
||||
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
||||
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function fail(res, e) {
|
||||
const status = e.code === "EBADHOST" || e.code === "EBADCRED" ? 400 : 500;
|
||||
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ items: creds.list() });
|
||||
});
|
||||
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const { protocol, host, username, password } = req.body || {};
|
||||
creds.save({ protocol, host, username, password });
|
||||
res.json({ items: creds.list() });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:protocol/:host", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
if (!creds.remove(req.params.protocol, req.params.host)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such credential" } });
|
||||
}
|
||||
res.json({ items: creds.list() });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+18
-3
@@ -210,10 +210,18 @@ function touchLaneFromHook(hookType, data) {
|
||||
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
||||
if (hookType === "Notification" && !idleNudge) {
|
||||
patch.needs_action = data.message || "needs you";
|
||||
} else if (lane.needs_action && data.session_id === lane.session_id) {
|
||||
// Clear only when the hook comes from the session currently bound to the lane
|
||||
// (evaluated before any rebinding, so a rebinding hook never clears in the same pass)
|
||||
patch.needs_action_session = data.session_id || null;
|
||||
} else if (
|
||||
lane.needs_action &&
|
||||
data.session_id &&
|
||||
data.session_id === (lane.needs_action_session || lane.session_id)
|
||||
) {
|
||||
// Clear only from the session that raised it: another session sharing the
|
||||
// cwd rebinds session_id on every hook but is not the one waiting. A flag
|
||||
// with no recorded raiser falls back to the bound session (evaluated
|
||||
// before any rebinding, so a rebinding hook never clears in the same pass).
|
||||
patch.needs_action = null;
|
||||
patch.needs_action_session = null;
|
||||
}
|
||||
|
||||
// Only idle↔running is mirrored: provisioning and failed are lifecycle
|
||||
@@ -1592,6 +1600,13 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
|
||||
continue;
|
||||
}
|
||||
if (probe.cwds.has(resolvedCwd)) continue;
|
||||
// lsof reports the OS's own path (/private/tmp on macOS); a session row may
|
||||
// hold the symlinked or differently-cased spelling of the same directory.
|
||||
try {
|
||||
if (probe.cwds.has(require("fs").realpathSync.native(resolvedCwd))) continue;
|
||||
} catch {
|
||||
/* directory gone — the process cannot be sitting in it either */
|
||||
}
|
||||
|
||||
// Idle gate (watchdog ticks only — boot passes skip it, see above): the
|
||||
// transcript mtime is the ground truth for "when did this session last
|
||||
|
||||
+35
-3
@@ -319,7 +319,10 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
||||
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
||||
// through unchanged.
|
||||
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir();
|
||||
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
||||
const cwd = lanesLib.canonicalCwd(
|
||||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
||||
);
|
||||
|
||||
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
@@ -687,7 +690,7 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
// outside; the pane has to be restarted. Report it instead of letting the
|
||||
// switch look clean and then fail on the user's next prompt.
|
||||
const runId = runs.runIdForLane(lane.id);
|
||||
const live = !!runs.getRun(runId);
|
||||
const live = runs.getRun(runId)?.status === "running";
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
@@ -973,6 +976,14 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"ESTAGEDOUTSIDE",
|
||||
"ENOREMOTE",
|
||||
"EUNTRACKED",
|
||||
"EBADHOST",
|
||||
"EBADCRED",
|
||||
"ESUBDIR",
|
||||
"EBADSIDE",
|
||||
"EMARKERS",
|
||||
"EBADURL",
|
||||
"EREMOTEEXISTS",
|
||||
"ENOCOMMITS",
|
||||
];
|
||||
// A numeric code is git's own exit status (execFile reports one that way):
|
||||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||||
@@ -982,7 +993,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
// EHUNKGONE is the same kind of answer: the tree moved under the click.
|
||||
const status = badRequest.includes(err.code)
|
||||
? 400
|
||||
: exited || err.code === "EHUNKGONE"
|
||||
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
@@ -994,6 +1005,9 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
error: {
|
||||
code: gitErrorCode(err),
|
||||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||||
// EGITAUTH: which host wants a login, for the git bar's form. Never
|
||||
// a secret — just protocol, host, a username hint and `rejected`.
|
||||
...(err.auth ? { auth: err.auth } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1046,6 +1060,24 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// A repo with no commit has nothing a worktree could branch from: git would
|
||||
// fail later, in the background job, and leave a `failed` row behind. Refuse
|
||||
// it here instead — that repo is a Repo lane until its first commit.
|
||||
if (!(await listBranches(sourceRepo)).branches.length) {
|
||||
const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
if (unborn) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EEMPTYREPO",
|
||||
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let slug;
|
||||
try {
|
||||
slug = slugify(body.slug || body.title);
|
||||
|
||||
+55
-1
@@ -19,8 +19,11 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
@@ -81,6 +84,57 @@ router.get("/", (_req, res) => {
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, which is what the Workspace page's per-lane history shows.
|
||||
*/
|
||||
// ── Drag & drop into the terminal ─────────────────────────────────────────
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
// `claude` attaches it. A browser has no path to give, so the image is saved
|
||||
// here — on the machine the pane runs on — and the client pastes this path.
|
||||
// Only the bytes come from the request: the name is random and the type picks
|
||||
// the extension from a fixed map, so a request never names a file.
|
||||
const DROP_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
DROP_TYPES[
|
||||
String(req.headers["content-type"] || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
];
|
||||
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
|
||||
}
|
||||
try {
|
||||
const dir = dropDir();
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
|
||||
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
let dr = null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user