Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dd97a369c | |||
| 62b4520507 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` via `-e`) and pool groups (a team runs only on its group's accounts); the pool card has a tab per tool._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.21 (2026-10-23) — Claude Docs docs and session documents (.pptx/.docx/.xlsx/.pdf/.html) land in Artifacts, unowned rows are claimed; a plugin update reports its running step (`plugin_update_progress`); a hidden update or clone leaves a progress chip._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
@@ -9,7 +9,7 @@ _Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` v
|
|||||||
## Repo map
|
## Repo map
|
||||||
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
|
||||||
- `client/`: React + Vite UI.
|
- `client/`: React + Vite UI.
|
||||||
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
|
||||||
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
|
||||||
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
|
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
|
||||||
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
* background process can do for it. Escape, the backdrop and × close it for
|
* background process can do for it. Escape, the backdrop and × close it for
|
||||||
* this page load only — also while an update applies: it keeps running in the
|
* this page load only — also while an update applies: it keeps running in the
|
||||||
* background (the page still reloads itself once the new server is up), and a
|
* background (the page still reloads itself once the new server is up), and a
|
||||||
* failure re-opens the modal with its error; "Skip this version" is the one
|
* failure re-opens the modal with its error. While hidden, a small chip in the
|
||||||
|
* corner keeps showing the update — with the plugin chain's current step from
|
||||||
|
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
|
||||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||||
* and starts a Claude Code session there with the error as its prompt — the
|
* and starts a Claude Code session there with the error as its prompt — the
|
||||||
@@ -184,6 +186,8 @@ export function UpdateNotifier() {
|
|||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [fixing, setFixing] = useState(false);
|
const [fixing, setFixing] = useState(false);
|
||||||
|
/** The running plugin chain's step, from the polls while it runs. */
|
||||||
|
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
@@ -312,6 +316,7 @@ export function UpdateNotifier() {
|
|||||||
api.updates
|
api.updates
|
||||||
.status()
|
.status()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
|
setProgress(s.plugin_update_progress ?? null);
|
||||||
const v = s.last_plugin_update;
|
const v = s.last_plugin_update;
|
||||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||||
@@ -342,6 +347,7 @@ export function UpdateNotifier() {
|
|||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgress(null);
|
||||||
setApplying(true);
|
setApplying(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.updates.apply();
|
const result = await api.updates.apply();
|
||||||
@@ -392,6 +398,53 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stepText = progress?.step
|
||||||
|
? t(`pluginStep.${progress.step}`, {
|
||||||
|
index: progress.index,
|
||||||
|
total: progress.total,
|
||||||
|
defaultValue: progress.step,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
|
||||||
|
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="update-chip"
|
||||||
|
onClick={() => setClosedKey(null)}
|
||||||
|
title={t("chipHint")}
|
||||||
|
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||||
|
<span className="truncate">
|
||||||
|
{status?.latest_release
|
||||||
|
? t("chipTitle", { version: status.latest_release })
|
||||||
|
: t("updating")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||||
|
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||||
|
</span>
|
||||||
|
{progress && progress.total > 0 ? (
|
||||||
|
<span
|
||||||
|
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progress.index}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={progress.total}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-accent-solid transition-all"
|
||||||
|
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -520,7 +573,14 @@ export function UpdateNotifier() {
|
|||||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||||
dependencies before it restarts, so it takes minutes, not
|
dependencies before it restarts, so it takes minutes, not
|
||||||
seconds — saying "restarting" there would look hung. */}
|
seconds — saying "restarting" there would look hung. */}
|
||||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
<span>
|
||||||
|
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||||
|
{stepText ? (
|
||||||
|
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||||
|
{stepText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,58 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — hidden while it runs", () => {
|
||||||
|
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||||
|
const PLUGIN: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(PLUGIN);
|
||||||
|
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
|
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...PLUGIN,
|
||||||
|
plugin_update_progress: {
|
||||||
|
step: "plugin_update",
|
||||||
|
index: 2,
|
||||||
|
total: 3,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||||
|
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
const chip = await screen.findByTestId("update-chip");
|
||||||
|
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||||
|
expect(chip).toHaveTextContent(step);
|
||||||
|
await userEvent.click(chip);
|
||||||
|
expect(
|
||||||
|
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no chip when nothing is running", async () => {
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await screen.findByText(i18n.t("updates:title"));
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("UpdateNotifier — Auto fix", () => {
|
describe("UpdateNotifier — Auto fix", () => {
|
||||||
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||||
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||||
|
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||||
|
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||||
|
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||||
|
* for the next open so a login row is still there to fill in.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { cloneDirName } from "./AddLaneModal";
|
import { cloneDirName } from "./AddLaneModal";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
|||||||
parent,
|
parent,
|
||||||
onClose,
|
onClose,
|
||||||
onCloned,
|
onCloned,
|
||||||
|
onReopen,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** The absolute folder the clone goes into. */
|
/** The absolute folder the clone goes into. */
|
||||||
parent: string;
|
parent: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCloned: (lane: Lane, path: string) => void;
|
onCloned: (lane: Lane, path: string) => void;
|
||||||
|
/** Show the dialog again from the background chip. */
|
||||||
|
onReopen: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
|||||||
const [sshRefused, setSshRefused] = useState(false);
|
const [sshRefused, setSshRefused] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||||
|
const [target, setTarget] = useState("");
|
||||||
|
// Read by the request's own continuation and by the reset below.
|
||||||
|
const openRef = useRef(open);
|
||||||
|
openRef.current = open;
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||||
|
const keepRef = useRef(false);
|
||||||
|
useDialog(open, panelRef, onClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open || busyRef.current) return;
|
||||||
|
if (keepRef.current) {
|
||||||
|
keepRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setUrl("");
|
setUrl("");
|
||||||
setName("");
|
setName("");
|
||||||
setLogin(null);
|
setLogin(null);
|
||||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, [open, parent]);
|
}, [open, parent]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
const folder = name.trim() || cloneDirName(url);
|
const folder = name.trim() || cloneDirName(url);
|
||||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
if (!busy || !token) return null;
|
||||||
|
return createPortal(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="fx-clone-chip"
|
||||||
|
onClick={onReopen}
|
||||||
|
title={into}
|
||||||
|
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||||
|
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{t("clone.cloning")} {into.split("/").pop()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<CloneProgress token={token} />
|
||||||
|
</button>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!u || busy) return;
|
if (!u || busy) return;
|
||||||
|
const progressToken = newCloneToken();
|
||||||
|
busyRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setToken(progressToken);
|
||||||
|
setTarget(into);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
try {
|
try {
|
||||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
|||||||
url: u,
|
url: u,
|
||||||
parent,
|
parent,
|
||||||
name: name.trim() || undefined,
|
name: name.trim() || undefined,
|
||||||
|
progressToken,
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
busyRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
onCloned(result.lane, result.path);
|
onCloned(result.lane, result.path);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
const host = e.auth?.host || u;
|
const host = e.auth?.host || u;
|
||||||
|
let message: string;
|
||||||
if (e.code === "ECREDENTIALS") {
|
if (e.code === "ECREDENTIALS") {
|
||||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||||
setLogin((l) => ({
|
setLogin((l) => ({
|
||||||
host,
|
host,
|
||||||
username: l?.username ?? "",
|
username: l?.username ?? "",
|
||||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
|||||||
}));
|
}));
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
setSshRefused(true);
|
setSshRefused(true);
|
||||||
setError(t("clone.sshRefused", { host }));
|
message = t("clone.sshRefused", { host });
|
||||||
} else {
|
} else {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
message = err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
setError(message);
|
||||||
|
busyRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setToken(null);
|
||||||
|
if (!openRef.current) {
|
||||||
|
keepRef.current = true;
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
onClick={busy ? undefined : onClose}
|
onClick={onClose}
|
||||||
// A portal still bubbles through React: keep keys away from the File
|
// A portal still bubbles through React: keep keys away from the File
|
||||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
|||||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||||
|
>
|
||||||
<FolderGit2 className="h-4 w-4" />
|
<FolderGit2 className="h-4 w-4" />
|
||||||
{t("fx.cloneRepo")}
|
{t("fx.cloneRepo")}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{login && (
|
{login && (
|
||||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
<div
|
||||||
|
data-testid="fx-clone-login"
|
||||||
|
className="space-y-2 rounded-md border border-border p-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
aria-label={t("clone.username")}
|
aria-label={t("clone.username")}
|
||||||
placeholder={t("clone.username")}
|
placeholder={t("clone.username")}
|
||||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{busy && token && <CloneProgress token={token} />}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-xs text-status-danger">
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
{error}{" "}
|
{error}{" "}
|
||||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||||
{t("fx.cancel")}
|
{busy ? t("clone.background") : t("fx.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -974,6 +974,7 @@ export function FileBrowser({
|
|||||||
open={cloning}
|
open={cloning}
|
||||||
parent={cloneParent}
|
parent={cloneParent}
|
||||||
onClose={() => setCloning(false)}
|
onClose={() => setCloning(false)}
|
||||||
|
onReopen={() => setCloning(true)}
|
||||||
onCloned={(lane, at) => {
|
onCloned={(lane, at) => {
|
||||||
setCloning(false);
|
setCloning(false);
|
||||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
clone: vi.fn(),
|
clone: vi.fn(),
|
||||||
|
cloneProgress: vi.fn(),
|
||||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
|||||||
url: "git@host:o/repo.git",
|
url: "git@host:o/repo.git",
|
||||||
parent: "/w/one/src",
|
parent: "/w/one/src",
|
||||||
name: undefined,
|
name: undefined,
|
||||||
|
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(toast.success).toHaveBeenCalled();
|
expect(toast.success).toHaveBeenCalled();
|
||||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||||
|
let finish: (v: unknown) => void = () => {};
|
||||||
|
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||||
|
state: "running",
|
||||||
|
position: null,
|
||||||
|
running: 1,
|
||||||
|
waiting: 0,
|
||||||
|
limit: 2,
|
||||||
|
phase: "receiving",
|
||||||
|
label: null,
|
||||||
|
percent: 45,
|
||||||
|
detail: "450/1000",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||||
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
|
const chip = await screen.findByTestId("fx-clone-chip");
|
||||||
|
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||||
|
await user.click(chip);
|
||||||
|
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
finish({
|
||||||
|
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/repo",
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||||
|
let fail: (e: unknown) => void = () => {};
|
||||||
|
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
fail(
|
||||||
|
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
|
const again = await openClone(user);
|
||||||
|
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||||
|
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||||
|
"https://git.example/o/r.git"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||||
vi.mocked(api.lanes.clone)
|
vi.mocked(api.lanes.clone)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||||
* kind of host, the extra keys, the other hosts) — and, for
|
* kind of host, then the other hosts; a named key is a card of its own under
|
||||||
|
* its type's default one, with the same steps, and "Add another key" makes
|
||||||
|
* the next one below) — and, for
|
||||||
* a user with their own Linux account (S3), their own HTTPS
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. The private key and
|
||||||
@@ -24,17 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||||
Cloud,
|
|
||||||
Copy,
|
|
||||||
GitBranch,
|
|
||||||
KeyRound,
|
|
||||||
KeySquare,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
|
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One kind of git host and the key it takes: no key → a Generate button;
|
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||||
|
* steps. The default key of a type and every named one share it.
|
||||||
*/
|
*/
|
||||||
function KeyCard({
|
function KeyCard({
|
||||||
title,
|
title,
|
||||||
sub,
|
sub,
|
||||||
type,
|
label,
|
||||||
k,
|
k,
|
||||||
id,
|
writable,
|
||||||
busy,
|
busy,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -114,12 +108,13 @@ function KeyCard({
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
type: "ed25519" | "rsa";
|
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||||
|
label: string;
|
||||||
k: Key | undefined;
|
k: Key | undefined;
|
||||||
id: GitIdentity;
|
writable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
onGenerate: () => Promise<void>;
|
||||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
onRemove: () => Promise<void>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -130,23 +125,19 @@ function KeyCard({
|
|||||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||||
</div>
|
</div>
|
||||||
{id.writable && (
|
{writable && (
|
||||||
<button
|
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void onGenerate(type)}
|
|
||||||
>
|
|
||||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{k && id.writable && (
|
{k && writable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${BTN} hover:text-status-danger`}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void onRemove(type)}
|
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||||
|
onClick={() => void onRemove()}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.remove")}
|
{t("gitIdentity.remove")}
|
||||||
@@ -165,9 +156,9 @@ function KeyCard({
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
readOnly
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||||
value={k.publicKey}
|
value={k.publicKey}
|
||||||
rows={type === "rsa" ? 3 : 2}
|
rows={k.type === "rsa" ? 3 : 2}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||||
/>
|
/>
|
||||||
@@ -182,6 +173,104 @@ function KeyCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||||
|
* default key's login is the region's plain host; a named key's is saved
|
||||||
|
* under an account name (its own name unless another is typed), tied to it.
|
||||||
|
*/
|
||||||
|
function AwsLoginForm({
|
||||||
|
busy,
|
||||||
|
named,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
/** The named key's name; undefined for the default key. */
|
||||||
|
named?: string;
|
||||||
|
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.awsKeyId")}
|
||||||
|
placeholder="APKA…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={v.keyId}
|
||||||
|
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.awsRegion")}
|
||||||
|
value={v.region}
|
||||||
|
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
{AWS_REGIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.alias")}
|
||||||
|
title={t("gitIdentity.aliasHint")}
|
||||||
|
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={v.alias}
|
||||||
|
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||||
|
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||||
|
* offers only this key — the default key would log in as the other account.
|
||||||
|
*/
|
||||||
|
function GitHostForm({
|
||||||
|
busy,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
onSave: (host: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [host, setHost] = useState("github.com");
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-1 flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void onSave(host.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherHost")}
|
||||||
|
placeholder="github.com"
|
||||||
|
autoComplete="off"
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
|
|||||||
const keyOf = (type: "ed25519" | "rsa") =>
|
const keyOf = (type: "ed25519" | "rsa") =>
|
||||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||||
const named = keys.filter((k) => k.name);
|
const named = keys.filter((k) => k.name);
|
||||||
|
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||||
const keyLabel = (keyId: string | null | undefined) => {
|
const keyLabel = (keyId: string | null | undefined) => {
|
||||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||||
if (!k) return t("gitIdentity.anyKey");
|
if (!k) return t("gitIdentity.anyKey");
|
||||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||||
};
|
};
|
||||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||||
const makeNamed = async () => {
|
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||||
if (!newKey) return;
|
// account name of the user's own goes in its step 3.
|
||||||
const ok = await run(
|
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
const prefix = type === "rsa" ? "aws" : "git";
|
||||||
|
let n = 2;
|
||||||
|
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||||
|
t("gitIdentity.keyMade")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const addKey = (type: "ed25519" | "rsa") => (
|
||||||
|
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.addKey")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
const replaceNamed = async (k: Key) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||||
t("gitIdentity.keyMade")
|
t("gitIdentity.keyMade")
|
||||||
);
|
);
|
||||||
if (ok) setNewKey(null);
|
|
||||||
};
|
};
|
||||||
const removeNamed = async (keyId: string) => {
|
const removeNamed = async (keyId: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
|
|||||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||||
});
|
});
|
||||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
// A login is shown under the key it names; one naming no key (or one gone)
|
||||||
const saveAws = async () => {
|
// under the default key.
|
||||||
let user = aws.keyId.trim();
|
const namedIds = new Set(named.map((k) => k.id));
|
||||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||||
|
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||||
|
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||||
|
let user = v.keyId.trim();
|
||||||
|
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||||
if (m?.[1] && m[2]) {
|
if (m?.[1] && m[2]) {
|
||||||
user = m[1];
|
user = m[1];
|
||||||
host = m[2].toLowerCase();
|
host = m[2].toLowerCase();
|
||||||
}
|
}
|
||||||
const alias = aws.alias.trim().toLowerCase();
|
// A named key's account goes by the key's name unless another is typed.
|
||||||
const ok = await run(
|
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||||
|
return run(
|
||||||
() =>
|
() =>
|
||||||
api.gitIdentity.setSshUser({
|
api.gitIdentity.setSshUser({
|
||||||
host,
|
host,
|
||||||
user,
|
user,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(aws.key ? { key: aws.key } : {}),
|
...(k?.id ? { key: k.id } : {}),
|
||||||
}),
|
}),
|
||||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||||
);
|
);
|
||||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
|
||||||
};
|
};
|
||||||
|
const saveGitHost = (host: string, k: Key) => {
|
||||||
|
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||||
|
return run(
|
||||||
|
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||||
|
t("gitIdentity.otherSaved", { host: k.name })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** The logins of one key, each with its delete button. */
|
||||||
|
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||||
|
rows.map((r) => (
|
||||||
|
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{text(r)}
|
||||||
|
</span>
|
||||||
|
{id?.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||||
|
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||||
const saveOther = async () => {
|
const saveOther = async () => {
|
||||||
let host = other.host.trim();
|
let host = other.host.trim();
|
||||||
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
|
|||||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||||
};
|
};
|
||||||
/** The key picker of a login form: every key, or one. */
|
/** The key picker of a login form: every key, or one. */
|
||||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||||
<select
|
<select
|
||||||
aria-label={t("gitIdentity.loginKey")}
|
aria-label={t("gitIdentity.loginKey")}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
|
|||||||
>
|
>
|
||||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||||
{keys
|
{keys
|
||||||
.filter((k) => k.id && (!only || k.type === only))
|
.filter((k) => k.id)
|
||||||
.map((k) => (
|
.map((k) => (
|
||||||
<option key={k.id} value={k.id}>
|
<option key={k.id} value={k.id}>
|
||||||
{keyLabel(k.id)}
|
{keyLabel(k.id)}
|
||||||
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
|
|||||||
const sshTabs = [
|
const sshTabs = [
|
||||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||||
...(named.length > 0 || id.writable
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "more",
|
|
||||||
label: named.length
|
|
||||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
|
||||||
: t("gitIdentity.tabMore"),
|
|
||||||
Icon: KeySquare,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||||
: []),
|
: []),
|
||||||
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
|
|||||||
onSelect={setSshTab}
|
onSelect={setSshTab}
|
||||||
>
|
>
|
||||||
{activeSshTab === "git" && (
|
{activeSshTab === "git" && (
|
||||||
<KeyCard
|
<div className="space-y-2">
|
||||||
title={t("gitIdentity.gitHubTitle")}
|
<KeyCard
|
||||||
sub={t("gitIdentity.gitHubSub")}
|
title={t("gitIdentity.gitHubTitle")}
|
||||||
type="ed25519"
|
sub={t("gitIdentity.gitHubSub")}
|
||||||
k={keyOf("ed25519")}
|
label="ED25519"
|
||||||
id={id}
|
k={keyOf("ed25519")}
|
||||||
busy={busy}
|
writable={id.writable}
|
||||||
onGenerate={generate}
|
busy={busy}
|
||||||
onRemove={removeKey}
|
onGenerate={() => generate("ed25519")}
|
||||||
>
|
onRemove={() => removeKey("ed25519")}
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
>
|
||||||
<Step n={3}>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
{t("gitIdentity.gitHubStepClone")}
|
<Step n={3}>
|
||||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
{t("gitIdentity.gitHubStepClone")}
|
||||||
</Step>
|
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||||
</KeyCard>
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
|
{namedOf("ed25519").map((k) => {
|
||||||
|
const rows = rowsOf(otherRows, k);
|
||||||
|
return (
|
||||||
|
<KeyCard
|
||||||
|
key={k.id}
|
||||||
|
title={k.name!}
|
||||||
|
sub={t("gitIdentity.namedGitSub")}
|
||||||
|
label={k.name!}
|
||||||
|
k={k}
|
||||||
|
writable={id.writable}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={() => replaceNamed(k)}
|
||||||
|
onRemove={() => removeNamed(k.id!)}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||||
|
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||||
|
{id.writable && rows.length === 0 && (
|
||||||
|
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||||
|
)}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Code key={r.alias ?? r.host}>
|
||||||
|
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||||
|
</Code>
|
||||||
|
))}
|
||||||
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{id.writable &&
|
||||||
|
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||||
|
addKey("ed25519")}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "aws" && (
|
{activeSshTab === "aws" && (
|
||||||
<KeyCard
|
<div className="space-y-2">
|
||||||
title={t("gitIdentity.awsTitle")}
|
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||||
sub={t("gitIdentity.awsSub")}
|
const isNamed = i > 0 && !!k;
|
||||||
type="rsa"
|
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||||
k={keyOf("rsa")}
|
return (
|
||||||
id={id}
|
<KeyCard
|
||||||
busy={busy}
|
key={k?.id ?? "id_rsa"}
|
||||||
onGenerate={generate}
|
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||||
onRemove={removeKey}
|
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||||
>
|
label={isNamed ? k!.name! : "RSA"}
|
||||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
k={k}
|
||||||
<Step n={3}>
|
writable={id.writable}
|
||||||
{t("gitIdentity.awsStepKeyId")}
|
busy={busy}
|
||||||
{id.sshUsers === undefined ? null : (
|
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||||
<>
|
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||||
{awsRows.map((r) => (
|
>
|
||||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
<Step n={3}>
|
||||||
{r.alias ? `${r.alias} · ` : ""}
|
{t("gitIdentity.awsStepKeyId")}
|
||||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
{id.sshUsers === undefined ? null : (
|
||||||
</span>
|
<>
|
||||||
{id.writable && (
|
{loginRows(
|
||||||
<button
|
rows,
|
||||||
type="button"
|
(r) =>
|
||||||
className={`${BTN} hover:text-status-danger`}
|
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||||
disabled={busy}
|
)}
|
||||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
{id.writable && (
|
||||||
onClick={() =>
|
<AwsLoginForm
|
||||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
busy={busy}
|
||||||
}
|
named={isNamed ? k!.name! : undefined}
|
||||||
>
|
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
/>
|
||||||
</button>
|
)}
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.awsReady")}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<Code key={r.alias ?? r.host}>
|
||||||
|
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||||
|
</Code>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{id.writable && (
|
|
||||||
<form
|
|
||||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void saveAws();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.awsKeyId")}
|
|
||||||
placeholder="APKA…"
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.keyId}
|
|
||||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.awsRegion")}
|
|
||||||
value={aws.region}
|
|
||||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
{AWS_REGIONS.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.alias")}
|
|
||||||
title={t("gitIdentity.aliasHint")}
|
|
||||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
|
||||||
autoComplete="off"
|
|
||||||
value={aws.alias}
|
|
||||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
|
||||||
{t("gitIdentity.awsSave")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
</KeyCard>
|
||||||
</>
|
);
|
||||||
)}
|
})}
|
||||||
</Step>
|
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||||
{awsRows.length > 0 && (
|
<>
|
||||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
{addKey("rsa")}
|
||||||
{t("gitIdentity.awsReady")}
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||||
{awsRows.map((r) => (
|
</>
|
||||||
<Code key={r.alias ?? r.host}>
|
|
||||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
|
||||||
</Code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</KeyCard>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeSshTab === "more" && (
|
|
||||||
<section
|
|
||||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
|
||||||
className="space-y-2 rounded border border-border p-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-xs font-medium text-fg-primary">
|
|
||||||
{t("gitIdentity.moreKeysTitle")}
|
|
||||||
</div>
|
|
||||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
|
||||||
</div>
|
|
||||||
{id.writable && !newKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.newKey")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{newKey && (
|
|
||||||
<form
|
|
||||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void makeNamed();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
aria-label={t("gitIdentity.keyName")}
|
|
||||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
value={newKey.name}
|
|
||||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
|
||||||
className={`${INPUT} font-mono`}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
aria-label={t("gitIdentity.keyType")}
|
|
||||||
value={newKey.type}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
|
||||||
}
|
|
||||||
className="select !py-1 !text-xs"
|
|
||||||
>
|
|
||||||
<option value="rsa">RSA 4096 (AWS)</option>
|
|
||||||
<option value="ed25519">ED25519</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
|
||||||
{t("gitIdentity.generate")}
|
|
||||||
</button>
|
|
||||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
|
||||||
{t("gitCreds.cancel")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
{named.map((k) => (
|
|
||||||
<div
|
|
||||||
key={k.id}
|
|
||||||
className="space-y-1 rounded border border-border-light p-2"
|
|
||||||
data-testid="git-named-key"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
|
||||||
{k.name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
|
||||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
|
||||||
{k.fingerprint}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={BTN}
|
|
||||||
onClick={() => void copyText(k.publicKey)}
|
|
||||||
>
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
{t("gitIdentity.copy")}
|
|
||||||
</button>
|
|
||||||
{id.writable && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${BTN} hover:text-status-danger`}
|
|
||||||
disabled={busy}
|
|
||||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
|
||||||
onClick={() => void removeNamed(k.id!)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
readOnly
|
|
||||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
|
||||||
value={k.publicKey}
|
|
||||||
rows={2}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeSshTab === "other" && (
|
{activeSshTab === "other" && (
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||||
* the sections never mix; a named key
|
* the sections never mix; "Add another key" makes a named key as one more
|
||||||
* is made beside the default ones, and a second AWS account is saved under
|
* card in its own tab, a second AWS account is saved from that card under
|
||||||
* its own name with the one key it uses.
|
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
|
|||||||
fingerprint: "SHA256:r",
|
fingerprint: "SHA256:r",
|
||||||
};
|
};
|
||||||
const PROD = {
|
const PROD = {
|
||||||
id: "id_rsa_aws-prod",
|
id: "id_rsa_aws-2",
|
||||||
type: "rsa" as const,
|
type: "rsa" as const,
|
||||||
name: "aws-prod",
|
name: "aws-2",
|
||||||
publicKey: "ssh-rsa B prod",
|
publicKey: "ssh-rsa B prod",
|
||||||
fingerprint: "SHA256:p",
|
fingerprint: "SHA256:p",
|
||||||
};
|
};
|
||||||
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
|
|||||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
view({
|
view({
|
||||||
sshKeys: [RSA, PROD],
|
sshKeys: [RSA, PROD],
|
||||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
view({ sshKeys: [RSA], sshUsers: [] })
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
// Only an RSA key: the AWS tab opens first.
|
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||||
await tab("More keys");
|
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
// One click makes the key — nothing to type before it.
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
// Another key can be added below it.
|
||||||
|
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||||
|
|
||||||
await tab("AWS CodeCommit");
|
// Its own step 3: the account name defaults to the key's name.
|
||||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
|
||||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
|
||||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
|
||||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
host: H,
|
host: H,
|
||||||
user: "APKA2",
|
user: "APKA2",
|
||||||
alias: "aws-prod",
|
alias: "aws-2",
|
||||||
key: "id_rsa_aws-prod",
|
key: "id_rsa_aws-2",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||||
|
// The default key's card does not list the named key's login.
|
||||||
|
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||||
|
|
||||||
await tab("More keys (1)");
|
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
});
|
||||||
await waitFor(() =>
|
|
||||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||||
|
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||||
|
const WORK = {
|
||||||
|
id: "id_ed25519_git-2",
|
||||||
|
type: "ed25519" as const,
|
||||||
|
name: "git-2",
|
||||||
|
publicKey: "ssh-ed25519 W work",
|
||||||
|
fingerprint: "SHA256:w",
|
||||||
|
};
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
|
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||||
);
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [ED, WORK],
|
||||||
|
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||||
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||||
|
const work = await screen.findByRole("region", { name: "git-2" });
|
||||||
|
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||||
|
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: "github.com",
|
||||||
|
user: "git",
|
||||||
|
alias: "git-2",
|
||||||
|
key: "id_ed25519_git-2",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,5 +183,19 @@
|
|||||||
"stat.updated": "Updated",
|
"stat.updated": "Updated",
|
||||||
"detail.runAs": "Run it as",
|
"detail.runAs": "Run it as",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Added {{date}}"
|
"detail.added": "Added {{date}}",
|
||||||
|
"tab.unowned": "Unowned",
|
||||||
|
"tab.suggested": "Is this yours?",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||||
|
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||||
|
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||||
|
"unowned.mine": "It's mine",
|
||||||
|
"unowned.takeIt": "Take it",
|
||||||
|
"unowned.notMine": "Not mine",
|
||||||
|
"unowned.assign": "Assign to…",
|
||||||
|
"unowned.claimed": "Added to your library",
|
||||||
|
"unowned.declined": "Removed from your list",
|
||||||
|
"unowned.assigned": "Owner assigned",
|
||||||
|
"unowned.empty": "Nothing without an owner."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||||
"clone.nameLabel": "Folder name",
|
"clone.nameLabel": "Folder name",
|
||||||
"clone.cloning": "Cloning…",
|
"clone.cloning": "Cloning…",
|
||||||
|
"clone.background": "Run in background",
|
||||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
|
|||||||
@@ -908,16 +908,14 @@
|
|||||||
"alias": "Account name",
|
"alias": "Account name",
|
||||||
"aliasPlaceholder": "name (optional)",
|
"aliasPlaceholder": "name (optional)",
|
||||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||||
"moreKeysTitle": "More SSH keys",
|
|
||||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
|
||||||
"newKey": "New key",
|
|
||||||
"keyName": "Key name",
|
|
||||||
"keyNamePlaceholder": "e.g. aws-prod",
|
|
||||||
"keyType": "Key type",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "More keys",
|
"tabOther": "Other hosts",
|
||||||
"tabOther": "Other hosts"
|
"addKey": "Add another key",
|
||||||
|
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||||
|
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||||
|
"namedAwsSub": "RSA · for one more AWS account.",
|
||||||
|
"removeKeyNamed": "Delete key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -36,5 +36,10 @@
|
|||||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
"autoFixWorkspace": "AgentHub update",
|
"autoFixWorkspace": "AgentHub update",
|
||||||
"autoFixError": "Could not start the Auto fix session",
|
"autoFixError": "Could not start the Auto fix session",
|
||||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
|
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
||||||
|
"chipTitle": "Updating to {{version}}…",
|
||||||
|
"chipHint": "The update runs in the background — click to show it",
|
||||||
|
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||||
|
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||||
|
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"role": "Role",
|
"role": "Role",
|
||||||
"workspaces": "Workspaces",
|
"workspaces": "Workspaces",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
|
"today": "Today",
|
||||||
"last7d": "7 days",
|
"last7d": "7 days",
|
||||||
"last30d": "30 days",
|
"last30d": "30 days",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
|
|||||||
@@ -183,5 +183,19 @@
|
|||||||
"stat.updated": "Cập nhật",
|
"stat.updated": "Cập nhật",
|
||||||
"detail.runAs": "Gọi bằng",
|
"detail.runAs": "Gọi bằng",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Thêm lúc {{date}}"
|
"detail.added": "Thêm lúc {{date}}",
|
||||||
|
"tab.unowned": "Vô chủ",
|
||||||
|
"tab.suggested": "Có phải của bạn?",
|
||||||
|
"auto": "Tự động",
|
||||||
|
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||||
|
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||||
|
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||||
|
"unowned.mine": "Của tôi",
|
||||||
|
"unowned.takeIt": "Nhận về tôi",
|
||||||
|
"unowned.notMine": "Không phải của tôi",
|
||||||
|
"unowned.assign": "Gán cho…",
|
||||||
|
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||||
|
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||||
|
"unowned.assigned": "Đã gán chủ",
|
||||||
|
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,7 @@
|
|||||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||||
"clone.nameLabel": "Tên thư mục",
|
"clone.nameLabel": "Tên thư mục",
|
||||||
"clone.cloning": "Đang clone…",
|
"clone.cloning": "Đang clone…",
|
||||||
|
"clone.background": "Chạy nền",
|
||||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||||
"clone.username": "Username",
|
"clone.username": "Username",
|
||||||
|
|||||||
@@ -896,16 +896,14 @@
|
|||||||
"alias": "Tên tài khoản",
|
"alias": "Tên tài khoản",
|
||||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||||
"moreKeysTitle": "Thêm SSH key",
|
|
||||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
|
||||||
"newKey": "Key mới",
|
|
||||||
"keyName": "Tên key",
|
|
||||||
"keyNamePlaceholder": "vd aws-prod",
|
|
||||||
"keyType": "Loại key",
|
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "GitHub · GitLab",
|
||||||
"tabMore": "Key khác",
|
"tabOther": "Host khác",
|
||||||
"tabOther": "Host khác"
|
"addKey": "Thêm key khác",
|
||||||
|
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||||
|
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||||
|
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||||
|
"removeKeyNamed": "Xoá key {{name}}"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
|
|||||||
@@ -36,5 +36,10 @@
|
|||||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
"autoFixError": "Không mở được phiên Tự sửa",
|
"autoFixError": "Không mở được phiên Tự sửa",
|
||||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
|
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
||||||
|
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||||
|
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||||
|
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||||
|
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||||
|
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"role": "Vai trò",
|
"role": "Vai trò",
|
||||||
"workspaces": "Workspace",
|
"workspaces": "Workspace",
|
||||||
"sessions": "Phiên",
|
"sessions": "Phiên",
|
||||||
|
"today": "Hôm nay",
|
||||||
"last7d": "7 ngày",
|
"last7d": "7 ngày",
|
||||||
"last30d": "30 ngày",
|
"last30d": "30 ngày",
|
||||||
"storage": "Dung lượng",
|
"storage": "Dung lượng",
|
||||||
|
|||||||
+26
-3
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
workspaces: number;
|
workspaces: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
|
/** Since the viewer's local midnight (absent from an older server). */
|
||||||
|
today?: { cost: number; tokens: number };
|
||||||
last7d: { cost: number; tokens: number };
|
last7d: { cost: number; tokens: number };
|
||||||
last30d: { cost: number; tokens: number };
|
last30d: { cost: number; tokens: number };
|
||||||
/** Active login sessions (cookies not expired). */
|
/** Active login sessions (cookies not expired). */
|
||||||
@@ -1083,7 +1085,8 @@ export const api = {
|
|||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
list: () =>
|
||||||
|
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||||
* account changes, newest first. */
|
* account changes, newest first. */
|
||||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||||
@@ -3016,7 +3019,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||||
artifacts: {
|
artifacts: {
|
||||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
list: (
|
||||||
|
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||||
|
) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.scope) qs.set("scope", params.scope);
|
if (params.scope) qs.set("scope", params.scope);
|
||||||
if (params.kind) qs.set("kind", params.kind);
|
if (params.kind) qs.set("kind", params.kind);
|
||||||
@@ -3092,6 +3097,18 @@ export const api = {
|
|||||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||||
},
|
},
|
||||||
|
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||||
|
claim: (id: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||||
|
/** An unowned row offered to the caller is not theirs. */
|
||||||
|
decline: (id: number) =>
|
||||||
|
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||||
|
/** Admin: give an unowned row to a user. */
|
||||||
|
assign: (id: number, userId: number) =>
|
||||||
|
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
}),
|
||||||
update: (
|
update: (
|
||||||
id: number,
|
id: number,
|
||||||
body: {
|
body: {
|
||||||
@@ -4382,7 +4399,7 @@ export interface Artifact {
|
|||||||
market: string | null;
|
market: string | null;
|
||||||
/** page: the claude.ai link. */
|
/** page: the claude.ai link. */
|
||||||
url: string | null;
|
url: string | null;
|
||||||
files: { path: string; size: number }[];
|
files: { path: string; size: number; mtime?: number }[];
|
||||||
size: number | null;
|
size: number | null;
|
||||||
shared: boolean;
|
shared: boolean;
|
||||||
sharedAt: string | null;
|
sharedAt: string | null;
|
||||||
@@ -4391,6 +4408,12 @@ export interface Artifact {
|
|||||||
inLibrary: boolean;
|
inLibrary: boolean;
|
||||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||||
installed?: boolean;
|
installed?: boolean;
|
||||||
|
/** file: captured when its session stopped, not pinned by hand. */
|
||||||
|
auto?: boolean;
|
||||||
|
/** Server mode: nobody owns it yet. */
|
||||||
|
unowned?: boolean;
|
||||||
|
/** Unowned and offered to the caller to claim or decline. */
|
||||||
|
suggestedForMe?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
|
|||||||
plugin_root?: string;
|
plugin_root?: string;
|
||||||
finished_at?: string;
|
finished_at?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** The step a running plugin-update chain is on (null between runs or on
|
||||||
|
* a server that predates it). Polled while an update runs. */
|
||||||
|
plugin_update_progress?: {
|
||||||
|
step: string;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
started_at: string;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── Terminal run status ─────
|
// ───── Terminal run status ─────
|
||||||
|
|||||||
+120
-13
@@ -49,7 +49,9 @@ import {
|
|||||||
type ArtifactInstalls,
|
type ArtifactInstalls,
|
||||||
type ArtifactKind,
|
type ArtifactKind,
|
||||||
type ArtifactSettings,
|
type ArtifactSettings,
|
||||||
|
type ServerUserUsage,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
@@ -114,7 +116,11 @@ const footAction =
|
|||||||
export function Artifacts() {
|
export function Artifacts() {
|
||||||
const { t } = useTranslation("artifacts");
|
const { t } = useTranslation("artifacts");
|
||||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||||
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
|
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||||
|
const [unownedCount, setUnownedCount] = useState(0);
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||||
@@ -147,14 +153,45 @@ export function Artifacts() {
|
|||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, [load, q]);
|
}, [load, q]);
|
||||||
|
|
||||||
|
const serverMode = !!settings?.serverMode;
|
||||||
|
const countUnowned = useCallback(() => {
|
||||||
|
if (!serverMode) return;
|
||||||
|
api.artifacts
|
||||||
|
.list({ scope: "unowned" })
|
||||||
|
.then((r) => setUnownedCount(r.items.length))
|
||||||
|
.catch(() => setUnownedCount(0));
|
||||||
|
}, [serverMode]);
|
||||||
|
useEffect(countUnowned, [countUnowned]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "unowned" || !isAdmin || users.length) return;
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, [tab, isAdmin, users.length]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
eventBus.subscribe((msg) => {
|
eventBus.subscribe((msg) => {
|
||||||
if (msg.type === "artifact_changed") load();
|
if (msg.type === "artifact_changed") {
|
||||||
|
load();
|
||||||
|
countUnowned();
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[load]
|
[load, countUnowned]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const settle = async (run: () => Promise<unknown>, done: string) => {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
toast.success(t(done));
|
||||||
|
load();
|
||||||
|
countUnowned();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// The Add menu closes on Escape or a click anywhere outside it.
|
// The Add menu closes on Escape or a click anywhere outside it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!addMenu) return;
|
if (!addMenu) return;
|
||||||
@@ -297,15 +334,63 @@ export function Artifacts() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const card = (a: Artifact) => (
|
const card = (a: Artifact) =>
|
||||||
<ArtifactCard
|
tab === "unowned" ? (
|
||||||
key={a.id}
|
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
|
||||||
item={a}
|
<ArtifactCard
|
||||||
server={server}
|
item={a}
|
||||||
canInstall={!!settings?.installsEnabled}
|
server={server}
|
||||||
{...actionsFor(a)}
|
canInstall={!!settings?.installsEnabled}
|
||||||
/>
|
{...actionsFor(a)}
|
||||||
);
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{(a.suggestedForMe || isAdmin) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary px-2 py-1 text-xs"
|
||||||
|
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
|
||||||
|
>
|
||||||
|
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.suggestedForMe && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={footAction}
|
||||||
|
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
|
||||||
|
>
|
||||||
|
{t("unowned.notMine")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && users.length > 0 && (
|
||||||
|
<select
|
||||||
|
className="input h-7 w-auto py-0 text-xs"
|
||||||
|
aria-label={t("unowned.assign")}
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const userId = Number(e.target.value);
|
||||||
|
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("unowned.assign")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.displayName || u.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ArtifactCard
|
||||||
|
key={a.id}
|
||||||
|
item={a}
|
||||||
|
server={server}
|
||||||
|
canInstall={!!settings?.installsEnabled}
|
||||||
|
{...actionsFor(a)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Local mode (everything is yours): the unfiltered page is shelved by
|
// Local mode (everything is yours): the unfiltered page is shelved by
|
||||||
// kind; a category or a search is one flat grid of results.
|
// kind; a category or a search is one flat grid of results.
|
||||||
@@ -320,7 +405,7 @@ export function Artifacts() {
|
|||||||
// what you shared, My library into what you installed from others (the
|
// what you shared, My library into what you installed from others (the
|
||||||
// server adds those to `mine`) and your own.
|
// server adds those to `mine`) and your own.
|
||||||
const halves =
|
const halves =
|
||||||
server && items
|
server && items && tab !== "unowned"
|
||||||
? (["others", "own"] as const).map((who) => ({
|
? (["others", "own"] as const).map((who) => ({
|
||||||
key: `${tab}.${who}`,
|
key: `${tab}.${who}`,
|
||||||
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
||||||
@@ -424,12 +509,29 @@ export function Artifacts() {
|
|||||||
{t(`tab.${v}`)}
|
{t(`tab.${v}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={tab === "unowned"}
|
||||||
|
onClick={() => setTab("unowned")}
|
||||||
|
className={segment(tab === "unowned")}
|
||||||
|
data-testid="artifacts-tab-unowned"
|
||||||
|
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
|
||||||
|
>
|
||||||
|
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
|
||||||
|
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items === null ? (
|
{items === null ? (
|
||||||
<SkeletonGrid label={t("loading")} />
|
<SkeletonGrid label={t("loading")} />
|
||||||
|
) : items.length === 0 && tab === "unowned" ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t("unowned.empty")}
|
||||||
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
hub={tab === "hub"}
|
hub={tab === "hub"}
|
||||||
@@ -755,6 +857,11 @@ function ArtifactCard({
|
|||||||
{t(hubOnly ? "hubOnly" : "shared")}
|
{t(hubOnly ? "hubOnly" : "shared")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{a.auto && (
|
||||||
|
<span className="badge self-start" title={t("autoHint")}>
|
||||||
|
{t("auto")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @description The Users tab of the Administration page (`/admin`, which
|
* @description The Users tab of the Administration page (`/admin`, which
|
||||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||||
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card overflow-x-auto">
|
<div className="card overflow-x-auto">
|
||||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left">
|
<tr className="border-b border-border text-left">
|
||||||
<th className={th}>{t("name")}</th>
|
<th className={th}>{t("name")}</th>
|
||||||
<th className={th}>{t("role")}</th>
|
<th className={th}>{t("role")}</th>
|
||||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("today")}</th>
|
||||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||||
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
|||||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
{u.sessions}
|
{u.sessions}
|
||||||
</td>
|
</td>
|
||||||
|
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||||
<UsageCell usage={u.last7d} />
|
<UsageCell usage={u.last7d} />
|
||||||
<UsageCell usage={u.last30d} />
|
<UsageCell usage={u.last30d} />
|
||||||
<StorageCell
|
<StorageCell
|
||||||
|
|||||||
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.20 (rebuild client to match)
|
UI build v2.8.21 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-6
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
@@ -395,7 +395,10 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
|||||||
into the folder it has open — of the user's own home or of a workspace's own
|
into the folder it has open — of the user's own home or of a workspace's own
|
||||||
folder, never a session worktree, an extra folder or another user's home —
|
folder, never a session worktree, an extra folder or another user's home —
|
||||||
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||||
check of that route applies, and opens a workspace on the clone.
|
check of that route applies, and opens a workspace on the clone. It sends a
|
||||||
|
`progressToken` and shows the queue place / git progress while the clone runs;
|
||||||
|
closing the dialog then only hides it behind a progress chip (click to reopen)
|
||||||
|
— the request keeps going and its outcome arrives as a toast.
|
||||||
|
|
||||||
#### Clone a repository into a new workspace
|
#### Clone a repository into a new workspace
|
||||||
|
|
||||||
@@ -3087,7 +3090,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
|||||||
### Artifacts hub
|
### Artifacts hub
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
@@ -3107,17 +3110,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
|||||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||||
|
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||||
|
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||||
|
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||||
```
|
```
|
||||||
|
|
||||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||||
|
|
||||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
|
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||||
|
- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions.
|
||||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||||
@@ -3218,7 +3225,7 @@ POST /api/updates/check
|
|||||||
POST /api/updates/apply
|
POST /api/updates/apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, repo_root, … }` (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
||||||
|
|
||||||
### Import history
|
### Import history
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -160,7 +160,7 @@ Workspaces made before server mode, or by the host account, have none.
|
|||||||
An account's last login is kept in `users.last_login_at`, so it still shows
|
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||||
after the login itself has ended.
|
after the login itself has ended.
|
||||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days — the
|
||||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||||
@@ -738,7 +738,7 @@ What runs as the member, and how:
|
|||||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||||
- the workspace prompt and its context files;
|
- the workspace prompt and its context files;
|
||||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
|||||||
|
|
||||||
| Kind | What it is | Where it comes from |
|
| Kind | What it is | Where it comes from |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
|
||||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
|
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
|
||||||
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
||||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.20",
|
"version": "2.8.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.20",
|
"version": "2.8.21",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.20",
|
"version": "2.8.21",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
||||||
* only "Command failed: <argv>".
|
* only "Command failed: <argv>".
|
||||||
*
|
*
|
||||||
|
* While a step runs, `plugin-update-progress.json` beside it names that step
|
||||||
|
* (`progress`), so the dashboard can say how far the chain got rather than
|
||||||
|
* only that it is busy; a final verdict removes it.
|
||||||
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
|||||||
// main() — which a require never runs — writes these.
|
// main() — which a require never runs — writes these.
|
||||||
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
||||||
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
||||||
|
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
|
||||||
|
|
||||||
|
/** The chain's steps, in order, as `progress` reports them. */
|
||||||
|
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
|
||||||
|
|
||||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||||
// reach an argv — so they are re-validated here rather than trusted across the
|
// reach an argv — so they are re-validated here rather than trusted across the
|
||||||
@@ -59,13 +67,34 @@ function log(line) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function record(result) {
|
/** Every verdict but the pre-written handover one (`final: false`) ends the
|
||||||
|
* chain, so its progress record goes with it. */
|
||||||
|
function record(result, { final = true } = {}) {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
RESULT_FILE,
|
RESULT_FILE,
|
||||||
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
||||||
);
|
);
|
||||||
|
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Say which step is running now — a courtesy for the UI, never a verdict. */
|
||||||
|
function progress(step) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
PROGRESS_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
step,
|
||||||
|
index: STEPS.indexOf(step) + 1,
|
||||||
|
total: STEPS.length,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
}
|
}
|
||||||
@@ -182,6 +211,7 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress("marketplace_update");
|
||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -189,6 +219,7 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress("plugin_update");
|
||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -219,12 +250,16 @@ function main() {
|
|||||||
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
||||||
// pre-written record is overwritten by the real outcome when this survives,
|
// pre-written record is overwritten by the real outcome when this survives,
|
||||||
// and is the outcome when it does not.
|
// and is the outcome when it does not.
|
||||||
record({
|
record(
|
||||||
ok: true,
|
{
|
||||||
step: "bootstrap",
|
ok: true,
|
||||||
plugin_root: newRoot,
|
step: "bootstrap",
|
||||||
note: "the new plugin version is installed; the server is being handed over",
|
plugin_root: newRoot,
|
||||||
});
|
note: "the new plugin version is installed; the server is being handed over",
|
||||||
|
},
|
||||||
|
{ final: false }
|
||||||
|
);
|
||||||
|
progress("bootstrap");
|
||||||
try {
|
try {
|
||||||
// Foreground and blocking: this stops the old server (us), waits for the
|
// Foreground and blocking: this stops the old server (us), waits for the
|
||||||
// ports, then starts the new one. It must finish before we record success.
|
// ports, then starts the new one. It must finish before we record success.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||||
* developer, the admin's installs switch, and same-origin writes.
|
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||||
|
* docs captured at their birth, documents captured at a session's stop, and
|
||||||
|
* unowned rows claimed, declined and assigned.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -120,6 +122,42 @@ describe("pages", () => {
|
|||||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records a Claude Docs doc at its birth, never on a later batch", () => {
|
||||||
|
const tool = "mcp__claude_ai_Claude_Docs__batch";
|
||||||
|
assert.equal(artifacts.isPageTool(tool), true);
|
||||||
|
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
|
||||||
|
assert.equal(artifacts.isPageTool("Artifact"), true);
|
||||||
|
const row = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
|
||||||
|
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
|
||||||
|
});
|
||||||
|
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
|
||||||
|
assert.equal(row.title, "Q3 plan");
|
||||||
|
const edit = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
|
||||||
|
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
|
||||||
|
});
|
||||||
|
assert.equal(edit, null);
|
||||||
|
// The doc's own link wins over one its content cites earlier in the ack.
|
||||||
|
const cited = artifacts.captureFromHook("s1", {
|
||||||
|
tool_name: tool,
|
||||||
|
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
|
||||||
|
tool_response: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
JSON.stringify({
|
||||||
|
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
|
||||||
|
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
|
||||||
|
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||||
const uid = db
|
const uid = db
|
||||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||||
@@ -711,3 +749,101 @@ describe("search, frontmatter and marketplace limits", () => {
|
|||||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("outputs captured at a session's stop", () => {
|
||||||
|
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
|
||||||
|
const past = new Date(Date.now() - 3600 * 1000);
|
||||||
|
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
|
||||||
|
const deck = path.join(ws, "out", "deck.pptx");
|
||||||
|
fs.writeFileSync(deck, "PK-deck-v1");
|
||||||
|
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
|
||||||
|
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
|
||||||
|
fs.utimesSync(deck, soon, soon);
|
||||||
|
|
||||||
|
const first = await artifacts.captureOutputs("s1");
|
||||||
|
assert.deepEqual(
|
||||||
|
first.map((r) => r.source),
|
||||||
|
["out/deck.pptx"]
|
||||||
|
);
|
||||||
|
assert.equal(first[0].auto, 1);
|
||||||
|
assert.equal(first[0].lane_id, lane.id);
|
||||||
|
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
|
||||||
|
|
||||||
|
fs.writeFileSync(deck, "PK-deck-v2");
|
||||||
|
const later = new Date(Date.now() + 60 * 1000);
|
||||||
|
fs.utimesSync(deck, later, later);
|
||||||
|
const again = await artifacts.captureOutputs("s1");
|
||||||
|
assert.equal(again.length, 1);
|
||||||
|
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
|
||||||
|
assert.notEqual(again[0].snapshot, first[0].snapshot);
|
||||||
|
assert.equal(
|
||||||
|
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unowned rows", () => {
|
||||||
|
it("are suggested, claimed, declined and assigned only by the right people", () => {
|
||||||
|
const addUser = (name, role) =>
|
||||||
|
Number(
|
||||||
|
db
|
||||||
|
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
|
||||||
|
.run(name, role).lastInsertRowid
|
||||||
|
);
|
||||||
|
const bobId = addUser("bob-u", "developer");
|
||||||
|
const amyId = addUser("amy-u", "developer");
|
||||||
|
const adminId = addUser("root-u", "admin");
|
||||||
|
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
|
||||||
|
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
|
||||||
|
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
|
||||||
|
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
try {
|
||||||
|
// A host session with no workspace, run inside bob's workspace folder.
|
||||||
|
const row = artifacts.captureFromHook(
|
||||||
|
"loose-host",
|
||||||
|
{
|
||||||
|
tool_input: { action: "publish" },
|
||||||
|
tool_response: "https://claude.ai/artifact/un1",
|
||||||
|
cwd: ws,
|
||||||
|
},
|
||||||
|
{ kind: "host" }
|
||||||
|
);
|
||||||
|
assert.equal(row.owner_id, null);
|
||||||
|
assert.equal(row.suggested_owner_id, bobId);
|
||||||
|
assert.ok(
|
||||||
|
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
|
||||||
|
);
|
||||||
|
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
|
||||||
|
assert.equal(artifacts.canSeeId(amy, row.id), false);
|
||||||
|
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
|
||||||
|
|
||||||
|
const claimed = artifacts.claim(bob, row.id);
|
||||||
|
assert.equal(claimed.ownerId, bobId);
|
||||||
|
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
|
||||||
|
|
||||||
|
const other = artifacts.captureFromHook(
|
||||||
|
"loose-host-2",
|
||||||
|
{
|
||||||
|
tool_input: { action: "publish" },
|
||||||
|
tool_response: "https://claude.ai/artifact/un2",
|
||||||
|
cwd: ws,
|
||||||
|
},
|
||||||
|
{ kind: "host" }
|
||||||
|
);
|
||||||
|
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
|
||||||
|
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
|
||||||
|
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
|
||||||
|
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
|
||||||
|
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
}
|
||||||
|
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ const os = require("os");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync } = require("child_process");
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
|
const {
|
||||||
|
readLastPluginUpdate,
|
||||||
|
readPluginUpdateProgress,
|
||||||
|
schedulePluginUpdate,
|
||||||
|
runtimeDir,
|
||||||
|
} = require("../lib/plugin-update");
|
||||||
|
|
||||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
||||||
|
|
||||||
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readPluginUpdateProgress", () => {
|
||||||
|
const write = (name, body) => {
|
||||||
|
const rt = path.join(tmpDir, name);
|
||||||
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
|
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
|
||||||
|
return rt;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("is null with no file and for malformed JSON", () => {
|
||||||
|
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
|
||||||
|
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the running step", () => {
|
||||||
|
const started_at = new Date().toISOString();
|
||||||
|
const rt = write(
|
||||||
|
"prog-ok",
|
||||||
|
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
|
||||||
|
);
|
||||||
|
assert.deepEqual(readPluginUpdateProgress(rt), {
|
||||||
|
step: "plugin_update",
|
||||||
|
index: 2,
|
||||||
|
total: 3,
|
||||||
|
started_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a file left by a helper that died long ago", () => {
|
||||||
|
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const rt = write(
|
||||||
|
"prog-stale",
|
||||||
|
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
|
||||||
|
);
|
||||||
|
assert.equal(readPluginUpdateProgress(rt), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("schedulePluginUpdate", () => {
|
describe("schedulePluginUpdate", () => {
|
||||||
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
||||||
// Redirect the runtime dir into the temp tree — this must not touch the
|
// Redirect the runtime dir into the temp tree — this must not touch the
|
||||||
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
|
|||||||
assert.equal(v.step, "validate");
|
assert.equal(v.step, "validate");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes a progress record with its final verdict", () => {
|
||||||
|
const rt = path.join(tmpDir, "guard-progress");
|
||||||
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(rt, "plugin-update-progress.json"),
|
||||||
|
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
|
||||||
|
);
|
||||||
|
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
|
||||||
|
stdio: "ignore",
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
|
||||||
|
});
|
||||||
|
|
||||||
it("stamps every verdict with a finish time", () => {
|
it("stamps every verdict with a finish time", () => {
|
||||||
// The failure mode this file exists to prevent is a helper that dies
|
// The failure mode this file exists to prevent is a helper that dies
|
||||||
// silently: the tab would poll a server that never returns and say nothing.
|
// silently: the tab would poll a server that never returns and say nothing.
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ describe("staff views", () => {
|
|||||||
assert.equal(mia.workspaces, 1);
|
assert.equal(mia.workspaces, 1);
|
||||||
assert.equal(mia.sessions, 1);
|
assert.equal(mia.sessions, 1);
|
||||||
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
||||||
|
// This hour's spend is today's in every timezone.
|
||||||
|
assert.equal(mia.today.cost, mia.last7d.cost);
|
||||||
|
const east = await call("/api/users?tz_offset=-840", admin);
|
||||||
|
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
|
||||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||||
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
||||||
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
||||||
|
|||||||
@@ -960,6 +960,20 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
||||||
}
|
}
|
||||||
|
// `auto = 1`: a document file a session wrote, captured when it stopped
|
||||||
|
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||||
|
// no owner, the user whose folder the session ran in — they may claim it or
|
||||||
|
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||||
|
for (const [col, ddl] of [
|
||||||
|
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||||
|
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||||
|
} catch {
|
||||||
|
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||||
|
|||||||
+281
-18
@@ -6,8 +6,12 @@
|
|||||||
* Four kinds:
|
* Four kinds:
|
||||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||||
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
||||||
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
|
* tool's PostToolUse hook (`captureFromHook`; a Claude Docs doc's
|
||||||
* - `file` a file an agent wrote in a workspace, pinned by the user.
|
* birth too) or pasted by hand. In server mode a row nobody owns
|
||||||
|
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
|
||||||
|
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
|
||||||
|
* document (pptx/docx/xlsx/pdf/html) a session created, captured
|
||||||
|
* when it stops (`captureOutputs`, `auto`).
|
||||||
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
||||||
* `.claude/skills/<name>`).
|
* `.claude/skills/<name>`).
|
||||||
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
||||||
@@ -85,14 +89,21 @@ const installsOffFlag = () => path.join(root(), "installs-off");
|
|||||||
function visibleWhere(req) {
|
function visibleWhere(req) {
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||||
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
|
return {
|
||||||
|
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
|
||||||
|
params: [u.id, u.id],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSee = (req, row) => {
|
const canSee = (req, row) => {
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
if (!row) return false;
|
if (!row) return false;
|
||||||
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
||||||
return row.owner_id === u.id || row.shared === 1;
|
return (
|
||||||
|
row.owner_id === u.id ||
|
||||||
|
row.shared === 1 ||
|
||||||
|
(row.owner_id == null && row.suggested_owner_id === u.id)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||||
@@ -101,7 +112,10 @@ const canChange = (req, row) =>
|
|||||||
|
|
||||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||||
function canSeeId(req, id) {
|
function canSeeId(req, id) {
|
||||||
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
|
return canSee(
|
||||||
|
req,
|
||||||
|
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(req, id) {
|
function load(req, id) {
|
||||||
@@ -144,6 +158,10 @@ function view(row, req, owners = null) {
|
|||||||
sharedAt: row.shared_at,
|
sharedAt: row.shared_at,
|
||||||
inLibrary: row.in_library !== 0,
|
inLibrary: row.in_library !== 0,
|
||||||
installs: row.installs,
|
installs: row.installs,
|
||||||
|
auto: row.auto === 1,
|
||||||
|
// Server mode only: a row nobody owns waits for an admin or its suggested user.
|
||||||
|
unowned: isServer() && row.owner_id == null,
|
||||||
|
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -152,7 +170,9 @@ function view(row, req, owners = null) {
|
|||||||
/**
|
/**
|
||||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||||
* shared skills/plugins of others the caller has installed; every row in
|
* shared skills/plugins of others the caller has installed; every row in
|
||||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
|
||||||
|
* nobody owns — every one for an admin, those suggested to the caller for
|
||||||
|
* anyone else), anything else = all visible.
|
||||||
* Each row also carries `installed` (`installedCheck`).
|
* Each row also carries `installed` (`installedCheck`).
|
||||||
*/
|
*/
|
||||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||||
@@ -168,6 +188,13 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
|||||||
params.push(req.user.id, req.user.id);
|
params.push(req.user.id, req.user.id);
|
||||||
} else if (scope === "hub") {
|
} else if (scope === "hub") {
|
||||||
where.push("a.shared = 1");
|
where.push("a.shared = 1");
|
||||||
|
} else if (scope === "unowned") {
|
||||||
|
if (!isServer() || !req?.user) return [];
|
||||||
|
where.push("a.owner_id IS NULL");
|
||||||
|
if (req.user.role !== "admin") {
|
||||||
|
where.push("a.suggested_owner_id = ?");
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (kind) {
|
if (kind) {
|
||||||
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
||||||
@@ -297,23 +324,90 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
|||||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||||
* The host's account is shared by every admin, so it names nobody.
|
* The host's account is shared by every admin, so it names nobody.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Whose a row with no owner probably is, from the folder its session ran in:
|
||||||
|
* the owner of the workspace holding that folder, else (S3) the member whose
|
||||||
|
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
|
||||||
|
*/
|
||||||
|
function suggestOwner(cwd) {
|
||||||
|
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
|
||||||
|
const lane = require("./lanes").resolveLaneByCwd(cwd);
|
||||||
|
if (lane?.owner_id != null) return lane.owner_id;
|
||||||
|
try {
|
||||||
|
const acct = require("./run-as").ownerForRead(cwd);
|
||||||
|
if (!acct?.name) return null;
|
||||||
|
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
|
||||||
|
return row ? row.id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function peerOwner(peer) {
|
function peerOwner(peer) {
|
||||||
if (peer?.kind !== "member" || !peer.name) return null;
|
if (peer?.kind !== "member" || !peer.name) return null;
|
||||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||||
return row ? row.id : null;
|
return row ? row.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
|
||||||
|
* the one that births a doc. Slides, dashboards and designs are made through the
|
||||||
|
* `Artifact` tool (`type_url`) and are captured as its publish.
|
||||||
|
*/
|
||||||
|
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||||
|
|
||||||
|
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
|
||||||
|
function isPageTool(toolName) {
|
||||||
|
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
|
||||||
|
function docsBirth(input) {
|
||||||
|
let container = input.container;
|
||||||
|
if (typeof container === "string") {
|
||||||
|
try {
|
||||||
|
container = JSON.parse(container);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const create = container?.create;
|
||||||
|
return create && typeof create === "object" ? create : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
|
||||||
|
* trailer appended in the same text block, so it is matched, not parsed.
|
||||||
|
*/
|
||||||
|
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
|
||||||
|
function docsFrameUrl(response) {
|
||||||
|
const blocks = Array.isArray(response) ? response : [response];
|
||||||
|
for (const b of blocks) {
|
||||||
|
const raw = typeof b === "string" ? b : b?.text;
|
||||||
|
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
|
||||||
|
if (m && PAGE_URL_RE.test(m[1])) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
|
||||||
* owned by the session's workspace owner, else by the member who sent the
|
* doc answers with the doc's link the same way (later batches edit it, and are
|
||||||
* hook (`peerOwner`). Returns the row or null.
|
* skipped). Recorded once per (owner, url), owned by the session's workspace
|
||||||
|
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
|
||||||
|
* or null.
|
||||||
*/
|
*/
|
||||||
function captureFromHook(sessionId, data, peer = null) {
|
function captureFromHook(sessionId, data, peer = null) {
|
||||||
const input = data?.tool_input || {};
|
const input = data?.tool_input || {};
|
||||||
|
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
|
||||||
|
const birth = docs ? docsBirth(input) : null;
|
||||||
|
if (docs && !birth) return null;
|
||||||
if (input.action && input.action !== "publish") return null;
|
if (input.action && input.action !== "publish") return null;
|
||||||
|
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
|
||||||
|
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
|
||||||
const text = JSON.stringify(data?.tool_response ?? "");
|
const text = JSON.stringify(data?.tool_response ?? "");
|
||||||
const m = PAGE_IN_TEXT_RE.exec(text);
|
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const url = m[0].replace(/[\\"]+$/, "");
|
const url = m[0].replace(/[\\"]+$/, "");
|
||||||
if (!PAGE_URL_RE.test(url)) return null;
|
if (!PAGE_URL_RE.test(url)) return null;
|
||||||
@@ -327,13 +421,14 @@ function captureFromHook(sessionId, data, peer = null) {
|
|||||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||||
return insert({
|
return insert({
|
||||||
kind: "page",
|
kind: "page",
|
||||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
|
||||||
description: cleanDescription(input.description),
|
description: cleanDescription(input.description),
|
||||||
owner_id: ownerId,
|
owner_id: ownerId,
|
||||||
lane_id: laneId,
|
lane_id: laneId,
|
||||||
session_id: String(sessionId),
|
session_id: String(sessionId),
|
||||||
source: file ? file.slice(0, 1000) : null,
|
source: file ? file.slice(0, 1000) : null,
|
||||||
url,
|
url,
|
||||||
|
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,9 +484,8 @@ function fileCandidates(laneId) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
/** A new snapshot of one workspace file, read under the read path's guards. */
|
||||||
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
async function snapshotLaneFile(lane, rel) {
|
||||||
const lane = laneOf(laneId);
|
|
||||||
const { openDownload } = require("./lane-file-transfer");
|
const { openDownload } = require("./lane-file-transfer");
|
||||||
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
||||||
if (file.size > FILE_MAX_BYTES) {
|
if (file.size > FILE_MAX_BYTES) {
|
||||||
@@ -399,10 +493,9 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||||
}
|
}
|
||||||
const snap = newSnapshotDir();
|
const snap = newSnapshotDir();
|
||||||
const name = file.name;
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
|
const out = fs.createWriteStream(path.join(snap.dir, file.name), { mode: 0o600 });
|
||||||
file.stream.on("error", reject);
|
file.stream.on("error", reject);
|
||||||
out.on("error", reject);
|
out.on("error", reject);
|
||||||
out.on("finish", resolve);
|
out.on("finish", resolve);
|
||||||
@@ -412,6 +505,13 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
dropSnapshot(snap.id);
|
dropSnapshot(snap.id);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
return { snap, name: file.name, size: file.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||||
|
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||||
|
const lane = laneOf(laneId);
|
||||||
|
const { snap, name, size } = await snapshotLaneFile(lane, rel);
|
||||||
const relPosix = require("./git-diff").lanePath(rel);
|
const relPosix = require("./git-diff").lanePath(rel);
|
||||||
return view(
|
return view(
|
||||||
insert({
|
insert({
|
||||||
@@ -423,13 +523,121 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
session_id: sessionId ? String(sessionId) : null,
|
session_id: sessionId ? String(sessionId) : null,
|
||||||
source: relPosix,
|
source: relPosix,
|
||||||
snapshot: snap.id,
|
snapshot: snap.id,
|
||||||
files: JSON.stringify([{ path: name, size: file.size }]),
|
files: JSON.stringify([{ path: name, size }]),
|
||||||
size: file.size,
|
size,
|
||||||
}),
|
}),
|
||||||
req
|
req
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Outputs captured when a session stops ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
|
||||||
|
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||||
|
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||||
|
const OUTPUT_MAX = 20;
|
||||||
|
const capturing = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace's documents a session created or changed since it started:
|
||||||
|
* untracked or modified files in a repo (an HTML file only when untracked, so a
|
||||||
|
* site's own pages are not taken), every file of a plain folder. Paths are
|
||||||
|
* workspace-relative.
|
||||||
|
*/
|
||||||
|
async function outputCandidates(lane, since) {
|
||||||
|
const gd = require("./git-diff");
|
||||||
|
let paths;
|
||||||
|
let tracked = new Set();
|
||||||
|
if (await gd.inRepo(lane.cwd)) {
|
||||||
|
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
|
||||||
|
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
|
||||||
|
tracked = new Set(modified);
|
||||||
|
paths = [...untracked, ...modified];
|
||||||
|
} else {
|
||||||
|
paths = (await gd.walkFolder(lane.cwd)).files;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const rel of new Set(paths)) {
|
||||||
|
if (!OUTPUT_EXT.test(rel)) continue;
|
||||||
|
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
|
||||||
|
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At a session's Stop: snapshot each document it created or changed into its
|
||||||
|
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
|
||||||
|
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
|
||||||
|
* pinned by hand is left to them. Returns the rows added or refreshed.
|
||||||
|
*/
|
||||||
|
function captureOutputs(sessionId) {
|
||||||
|
const laneId = sessionLane(sessionId);
|
||||||
|
if (laneId == null) return Promise.resolve([]);
|
||||||
|
// One capture per workspace at a time: two Stops in a row must not both insert.
|
||||||
|
const prev = capturing.get(laneId) || Promise.resolve();
|
||||||
|
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
|
||||||
|
capturing.set(laneId, run);
|
||||||
|
run.finally(() => {
|
||||||
|
if (capturing.get(laneId) === run) capturing.delete(laneId);
|
||||||
|
});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureOutputsNow(laneId, sessionId) {
|
||||||
|
const lane = require("./lanes").getLane(Number(laneId));
|
||||||
|
if (!lane) return [];
|
||||||
|
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
|
||||||
|
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
|
||||||
|
const done = [];
|
||||||
|
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
|
||||||
|
const rows = sql(
|
||||||
|
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
|
||||||
|
).all(lane.id, rel);
|
||||||
|
if (rows.some((r) => r.auto !== 1)) continue;
|
||||||
|
const row = rows[0];
|
||||||
|
const kept = row?.files ? JSON.parse(row.files)[0] : null;
|
||||||
|
if (kept && kept.mtime === mtimeMs) continue;
|
||||||
|
let shot;
|
||||||
|
try {
|
||||||
|
shot = await snapshotLaneFile(lane, rel);
|
||||||
|
} catch {
|
||||||
|
continue; // a secret name, a vanished file, one grown past the cap
|
||||||
|
}
|
||||||
|
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
||||||
|
if (row) {
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
||||||
|
dropSnapshot(row.snapshot);
|
||||||
|
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||||
|
} else {
|
||||||
|
done.push(
|
||||||
|
insert({
|
||||||
|
kind: "file",
|
||||||
|
title: cleanTitle(null, shot.name),
|
||||||
|
owner_id: lane.owner_id ?? null,
|
||||||
|
lane_id: lane.id,
|
||||||
|
session_id: String(sessionId),
|
||||||
|
source: rel,
|
||||||
|
snapshot: shot.snap.id,
|
||||||
|
files,
|
||||||
|
size: shot.size,
|
||||||
|
auto: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
||||||
* one, under a name made from the upload's own (no folder, no path).
|
* one, under a name made from the upload's own (no folder, no path).
|
||||||
@@ -1171,6 +1379,56 @@ function addPlugin(req, { key, market, title, description }) {
|
|||||||
|
|
||||||
// ── change / delete ───────────────────────────────────────────────────────
|
// ── change / delete ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ── Unowned rows ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function unownedRow(req, id) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
|
||||||
|
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
|
||||||
|
function claim(req, id) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
|
||||||
|
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
|
||||||
|
}
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(req.user.id, row.id);
|
||||||
|
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
audit(req, next, "claim");
|
||||||
|
return view(next, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
|
||||||
|
function decline(req, id) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (row.suggested_owner_id !== req.user.id) {
|
||||||
|
throw fail("EFORBIDDEN", "only the suggested user can decline it");
|
||||||
|
}
|
||||||
|
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
|
||||||
|
audit(req, row, "decline");
|
||||||
|
return { id: row.id, declined: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An admin gives an unowned row to an enabled user. */
|
||||||
|
function assign(req, id, userId) {
|
||||||
|
const row = unownedRow(req, id);
|
||||||
|
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
|
||||||
|
const user = /^\d+$/.test(String(userId))
|
||||||
|
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
|
||||||
|
: null;
|
||||||
|
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
|
||||||
|
sql(
|
||||||
|
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
|
).run(user.id, row.id);
|
||||||
|
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||||
|
audit(req, next, "assign");
|
||||||
|
return view(next, req);
|
||||||
|
}
|
||||||
|
|
||||||
function audit(req, row, action) {
|
function audit(req, row, action) {
|
||||||
if (!req?.user) return;
|
if (!req?.user) return;
|
||||||
try {
|
try {
|
||||||
@@ -1669,6 +1927,11 @@ module.exports = {
|
|||||||
canSeeId,
|
canSeeId,
|
||||||
addPage,
|
addPage,
|
||||||
captureFromHook,
|
captureFromHook,
|
||||||
|
captureOutputs,
|
||||||
|
isPageTool,
|
||||||
|
claim,
|
||||||
|
decline,
|
||||||
|
assign,
|
||||||
fileCandidates,
|
fileCandidates,
|
||||||
pinFile,
|
pinFile,
|
||||||
availableSkills,
|
availableSkills,
|
||||||
|
|||||||
@@ -1350,6 +1350,7 @@ module.exports = {
|
|||||||
laneStatus,
|
laneStatus,
|
||||||
listCommits,
|
listCommits,
|
||||||
listPaths,
|
listPaths,
|
||||||
|
lsFiles,
|
||||||
listTree,
|
listTree,
|
||||||
listTreeAll,
|
listTreeAll,
|
||||||
parseNumstat,
|
parseNumstat,
|
||||||
@@ -1358,6 +1359,7 @@ module.exports = {
|
|||||||
readFileAt,
|
readFileAt,
|
||||||
readImageAt,
|
readImageAt,
|
||||||
resolveBaseRef,
|
resolveBaseRef,
|
||||||
|
walkFolder,
|
||||||
defaultBranch,
|
defaultBranch,
|
||||||
DEFAULT_CONTEXT,
|
DEFAULT_CONTEXT,
|
||||||
MAX_CONTEXT,
|
MAX_CONTEXT,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const path = require("path");
|
|||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
const { getDataDir } = require("./claude-home");
|
const { getDataDir } = require("./claude-home");
|
||||||
|
|
||||||
|
/** Longer than any one step's timeout in the helper (bootstrap: 15 min). */
|
||||||
|
const PROGRESS_STALE_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
||||||
* cache on purpose — that directory is replaced by the update itself. */
|
* cache on purpose — that directory is replaced by the update itself. */
|
||||||
function runtimeDir() {
|
function runtimeDir() {
|
||||||
@@ -53,6 +56,34 @@ function readLastPluginUpdate(rt = runtimeDir()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The step a running chain is on (`plugin-update-progress.json`, written by the
|
||||||
|
* helper as each step starts and removed with its final verdict), or null.
|
||||||
|
* Surfaced as `plugin_update_progress` so the dashboard can show how far an
|
||||||
|
* update got. A file older than the chain's longest run is left over from a
|
||||||
|
* helper that died, and reads as nothing.
|
||||||
|
*
|
||||||
|
* @returns {{step: string, index: number, total: number, started_at: string} | null}
|
||||||
|
*/
|
||||||
|
function readPluginUpdateProgress(rt = runtimeDir()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8")
|
||||||
|
);
|
||||||
|
if (!parsed || typeof parsed.step !== "string") return null;
|
||||||
|
const started = Date.parse(parsed.started_at);
|
||||||
|
if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null;
|
||||||
|
return {
|
||||||
|
step: parsed.step,
|
||||||
|
index: Number(parsed.index) || 0,
|
||||||
|
total: Number(parsed.total) || 0,
|
||||||
|
started_at: parsed.started_at,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn the detached update chain. Returns immediately; the caller must have
|
* Spawn the detached update chain. Returns immediately; the caller must have
|
||||||
* already written its HTTP response, because this process is about to be
|
* already written its HTTP response, because this process is about to be
|
||||||
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(rt, { recursive: true });
|
fs.mkdirSync(rt, { recursive: true });
|
||||||
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
||||||
|
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort — a stale verdict is better than failing the update */
|
/* best effort — a stale verdict is better than failing the update */
|
||||||
}
|
}
|
||||||
@@ -86,4 +118,9 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
|||||||
return { spawned: true, runtime_dir: rt };
|
return { spawned: true, runtime_dir: rt };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
|
module.exports = {
|
||||||
|
schedulePluginUpdate,
|
||||||
|
readLastPluginUpdate,
|
||||||
|
readPluginUpdateProgress,
|
||||||
|
runtimeDir,
|
||||||
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFile } = require("child_process");
|
const { execFile } = require("child_process");
|
||||||
const { readLastPluginUpdate } = require("./plugin-update");
|
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
|
||||||
const { getClaudeHome } = require("./claude-home");
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||||
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
|
|||||||
if (!err) return resolve(String(stdout).trim());
|
if (!err) return resolve(String(stdout).trim());
|
||||||
if (err.killed) {
|
if (err.killed) {
|
||||||
return reject(
|
return reject(
|
||||||
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
|
new Error(
|
||||||
|
`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
|
const tail = String(stderr || err.message || err)
|
||||||
|
.trim()
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.slice(-15)
|
||||||
|
.join("\n");
|
||||||
reject(new Error(`${step} failed:\n${tail}`));
|
reject(new Error(`${step} failed:\n${tail}`));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
|||||||
plugin_marketplace: pluginInstall.marketplace,
|
plugin_marketplace: pluginInstall.marketplace,
|
||||||
plugin_name: pluginInstall.plugin,
|
plugin_name: pluginInstall.plugin,
|
||||||
last_plugin_update: readLastPluginUpdate(),
|
last_plugin_update: readLastPluginUpdate(),
|
||||||
|
plugin_update_progress: readPluginUpdateProgress(),
|
||||||
|
|
||||||
local_version: localVersion,
|
local_version: localVersion,
|
||||||
// Not a clone, so there is nothing to be shallow about and no distances to
|
// Not a clone, so there is nothing to be shallow about and no distances to
|
||||||
@@ -785,6 +792,8 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
|||||||
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
||||||
// a chain that died is visible instead of looking like a slow restart.
|
// a chain that died is visible instead of looking like a slow restart.
|
||||||
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
||||||
|
// The step a running chain is on, while it runs.
|
||||||
|
plugin_update_progress: pluginInstall ? readPluginUpdateProgress() : null,
|
||||||
local_version: localVersion,
|
local_version: localVersion,
|
||||||
// True when commit distances are unusable and version strings are the only
|
// True when commit distances are unusable and version strings are the only
|
||||||
// signal — the UI must not render an "N commits" badge in that case.
|
// signal — the UI must not render an "N commits" badge in that case.
|
||||||
|
|||||||
@@ -36,9 +36,17 @@ const schemas = {
|
|||||||
laneId: { type: "integer", nullable: true },
|
laneId: { type: "integer", nullable: true },
|
||||||
sessionId: { type: "string", nullable: true },
|
sessionId: { type: "string", nullable: true },
|
||||||
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
|
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
|
||||||
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
|
market: {
|
||||||
|
type: "string",
|
||||||
|
nullable: true,
|
||||||
|
description: "Plugin: owner/repo, git URL or marketplace.json URL.",
|
||||||
|
},
|
||||||
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
|
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
|
||||||
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
|
files: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "object" },
|
||||||
|
description: "The files the snapshot recorded.",
|
||||||
|
},
|
||||||
size: { type: "integer", nullable: true },
|
size: { type: "integer", nullable: true },
|
||||||
shared: { type: "boolean" },
|
shared: { type: "boolean" },
|
||||||
sharedAt: { type: "string", nullable: true },
|
sharedAt: { type: "string", nullable: true },
|
||||||
@@ -82,13 +90,23 @@ const paths = {
|
|||||||
description:
|
description:
|
||||||
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
||||||
parameters: [
|
parameters: [
|
||||||
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
|
{
|
||||||
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
|
name: "scope",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kind",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
|
||||||
|
},
|
||||||
{ name: "q", in: "query", schema: { type: "string" } },
|
{ name: "q", in: "query", schema: { type: "string" } },
|
||||||
laneQuery,
|
laneQuery,
|
||||||
{ name: "sessionId", in: "query", schema: { type: "string" } },
|
{ name: "sessionId", in: "query", schema: { type: "string" } },
|
||||||
],
|
],
|
||||||
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
|
responses: {
|
||||||
|
200: { description: "`{items}`.", content: { "application/json": { schema: items } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/artifacts/settings": {
|
"/api/artifacts/settings": {
|
||||||
@@ -186,11 +204,14 @@ const paths = {
|
|||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
summary: "Keep one uploaded file (≤ 25 MB)",
|
summary: "Keep one uploaded file (≤ 25 MB)",
|
||||||
operationId: "uploadArtifactFile",
|
operationId: "uploadArtifactFile",
|
||||||
requestBody: multipart({
|
requestBody: multipart(
|
||||||
files: { type: "string", format: "binary" },
|
{
|
||||||
title: { type: "string" },
|
files: { type: "string", format: "binary" },
|
||||||
description: { type: "string" },
|
title: { type: "string" },
|
||||||
}, ["files"]),
|
description: { type: "string" },
|
||||||
|
},
|
||||||
|
["files"]
|
||||||
|
),
|
||||||
responses: created(item),
|
responses: created(item),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -218,13 +239,16 @@ const paths = {
|
|||||||
operationId: "uploadArtifactSkills",
|
operationId: "uploadArtifactSkills",
|
||||||
description:
|
description:
|
||||||
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
|
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
|
||||||
requestBody: multipart({
|
requestBody: multipart(
|
||||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
{
|
||||||
paths: { type: "string" },
|
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||||
name: { type: "string" },
|
paths: { type: "string" },
|
||||||
title: { type: "string" },
|
name: { type: "string" },
|
||||||
description: { type: "string" },
|
title: { type: "string" },
|
||||||
}, ["files"]),
|
description: { type: "string" },
|
||||||
|
},
|
||||||
|
["files"]
|
||||||
|
),
|
||||||
responses: created(items),
|
responses: created(items),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -242,7 +266,10 @@ const paths = {
|
|||||||
},
|
},
|
||||||
["name", "description", "content"]
|
["name", "description", "content"]
|
||||||
),
|
),
|
||||||
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
|
responses: created({
|
||||||
|
type: "object",
|
||||||
|
properties: { item: item.properties.item, items: items.properties.items },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/artifacts/plugin/inspect": {
|
"/api/artifacts/plugin/inspect": {
|
||||||
@@ -250,10 +277,14 @@ const paths = {
|
|||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
summary: "Read a marketplace's plugin list",
|
summary: "Read a marketplace's plugin list",
|
||||||
operationId: "inspectArtifactMarket",
|
operationId: "inspectArtifactMarket",
|
||||||
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
description:
|
||||||
|
"Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
||||||
requestBody: json({ market: { type: "string" } }, ["market"]),
|
requestBody: json({ market: { type: "string" } }, ["market"]),
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
|
200: {
|
||||||
|
description:
|
||||||
|
"`{market, name, description, plugins: [{name, key, description, version, category}]}`.",
|
||||||
|
},
|
||||||
502: errs,
|
502: errs,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -281,7 +312,10 @@ const paths = {
|
|||||||
summary: "One artifact",
|
summary: "One artifact",
|
||||||
operationId: "getArtifact",
|
operationId: "getArtifact",
|
||||||
parameters: [idParam],
|
parameters: [idParam],
|
||||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
|
responses: {
|
||||||
|
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||||
|
404: errs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
patch: {
|
patch: {
|
||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
@@ -295,7 +329,11 @@ const paths = {
|
|||||||
source: { type: "string", description: "Plugin only." },
|
source: { type: "string", description: "Plugin only." },
|
||||||
market: { type: "string", description: "Plugin only." },
|
market: { type: "string", description: "Plugin only." },
|
||||||
}),
|
}),
|
||||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
|
responses: {
|
||||||
|
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||||
|
403: errs,
|
||||||
|
404: errs,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
|
|||||||
@@ -393,6 +393,11 @@ const paths = {
|
|||||||
properties: {
|
properties: {
|
||||||
workspaces: { type: "integer" },
|
workspaces: { type: "integer" },
|
||||||
sessions: { type: "integer" },
|
sessions: { type: "integer" },
|
||||||
|
today: {
|
||||||
|
type: "object",
|
||||||
|
description: "Spent since the viewer's local midnight (`tz_offset`).",
|
||||||
|
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||||
|
},
|
||||||
last7d: {
|
last7d: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||||
|
|||||||
@@ -1048,6 +1048,69 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/artifacts/{id}/claim": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Take an unowned artifact",
|
||||||
|
operationId: "claimArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
responses: {
|
||||||
|
200: ok("Claimed.", { item: { type: "object" } }),
|
||||||
|
400: errs("ELOCALMODE."),
|
||||||
|
403: errs("EFORBIDDEN: not the suggested user."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED: it already has an owner."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/artifacts/{id}/decline": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Say an unowned artifact offered to you is not yours",
|
||||||
|
operationId: "declineArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
responses: {
|
||||||
|
200: ok("Declined.", { id: { type: "integer" }, declined: { type: "boolean" } }),
|
||||||
|
400: errs("ELOCALMODE."),
|
||||||
|
403: errs("EFORBIDDEN: not the suggested user."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/artifacts/{id}/assign": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Give an unowned artifact to a user (admin)",
|
||||||
|
operationId: "assignArtifact",
|
||||||
|
description:
|
||||||
|
"Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["userId"],
|
||||||
|
properties: { userId: { type: "integer" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: ok("Assigned.", { item: { type: "object" } }),
|
||||||
|
400: errs("ELOCALMODE / EBADFIELD."),
|
||||||
|
403: errs("EFORBIDDEN: not an admin."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("EOWNED."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { tags, schemas, paths };
|
module.exports = { tags, schemas, paths };
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const STATUS = {
|
|||||||
EMARKETFETCH: 502,
|
EMARKETFETCH: 502,
|
||||||
ENEEDLOGIN: 502,
|
ENEEDLOGIN: 502,
|
||||||
ETOOBIG: 413,
|
ETOOBIG: 413,
|
||||||
|
EOWNED: 409,
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendError(res, err) {
|
function sendError(res, err) {
|
||||||
@@ -362,6 +363,34 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A row nobody owns: its suggested user says "mine" or "not mine"; an admin
|
||||||
|
// takes it or gives it to someone (artifacts.js `claim`/`decline`/`assign`).
|
||||||
|
router.post(
|
||||||
|
"/:id/claim",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.claim(req, req.params.id);
|
||||||
|
changed(item);
|
||||||
|
res.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/decline",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => res.json(artifacts.decline(req, req.params.id)))
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/:id/assign",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const item = artifacts.assign(req, req.params.id, req.body?.userId);
|
||||||
|
changed(item);
|
||||||
|
res.json({ item });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/:id/uninstall",
|
"/:id/uninstall",
|
||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
|
|||||||
+23
-2
@@ -1295,9 +1295,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
|
|||||||
};
|
};
|
||||||
broadcast("new_event", event);
|
broadcast("new_event", event);
|
||||||
// A claude.ai page the session published goes into its owner's Artifacts.
|
// A claude.ai page the session published goes into its owner's Artifacts.
|
||||||
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
|
// A Claude Docs doc counts too (`isPageTool`): it is a claude.ai page the session made.
|
||||||
|
if (hookType === "PostToolUse" && tool !== "codex") {
|
||||||
try {
|
try {
|
||||||
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
|
const artifacts = require("../lib/artifacts");
|
||||||
|
const row = artifacts.isPageTool(toolName)
|
||||||
|
? artifacts.captureFromHook(sessionId, data, peer)
|
||||||
|
: null;
|
||||||
if (row) {
|
if (row) {
|
||||||
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
|
||||||
}
|
}
|
||||||
@@ -1736,6 +1740,23 @@ router.post("/event", (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Documents the session made (slides, Word, Excel, PDF, HTML) go into its
|
||||||
|
// workspace owner's Artifacts when it stops. Off the response path, fail-safe.
|
||||||
|
if (["Stop", "SessionEnd"].includes(hook_type) && data.session_id) {
|
||||||
|
require("../lib/artifacts")
|
||||||
|
.captureOutputs(data.session_id)
|
||||||
|
.then((rows) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
broadcast("artifact_changed", {
|
||||||
|
id: row.id,
|
||||||
|
ownerId: row.owner_id,
|
||||||
|
shared: row.shared === 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.warn("[artifacts] output capture failed:", err.message));
|
||||||
|
}
|
||||||
|
|
||||||
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
|
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
|
||||||
// hooks, so the journal (written at workflow completion) is the only source.
|
// hooks, so the journal (written at workflow completion) is the only source.
|
||||||
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
// Run on the lifecycle hooks that bracket a workflow finishing, off the
|
||||||
|
|||||||
+19
-4
@@ -2,9 +2,10 @@
|
|||||||
* @file users.js
|
* @file users.js
|
||||||
* @description `/api/users` — server mode's per-user view for an admin or
|
* @description `/api/users` — server mode's per-user view for an admin or
|
||||||
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
|
||||||
* `GET /` lists every account with its workspaces, sessions and $ spent in the
|
* `GET /` lists every account with its workspaces, sessions and $ spent today
|
||||||
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
|
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
|
||||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
* and in the last 7 and 30 days (from `token_usage_hourly`, sessions owned
|
||||||
|
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||||
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
|
||||||
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
|
||||||
*
|
*
|
||||||
@@ -99,10 +100,22 @@ function usageSince(sinceHour, rules) {
|
|||||||
|
|
||||||
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UTC hour the viewer's local day began in — `offset` is the browser's
|
||||||
|
* getTimezoneOffset() (minutes local time is BEHIND UTC; UTC when missing).
|
||||||
|
* Buckets are whole UTC hours, so a half-hour zone's day starts up to 30 min early.
|
||||||
|
*/
|
||||||
|
function todayHour(offset) {
|
||||||
|
const off = Number.isFinite(offset) && Math.abs(offset) <= 840 ? offset : 0;
|
||||||
|
const local = new Date(Date.now() - off * 60e3);
|
||||||
|
local.setUTCHours(0, 0, 0, 0);
|
||||||
|
return new Date(local.getTime() + off * 60e3).toISOString().slice(0, 13);
|
||||||
|
}
|
||||||
|
|
||||||
/** Map(key → n) from rows of `{k, n}`. */
|
/** Map(key → n) from rows of `{k, n}`. */
|
||||||
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
|
||||||
|
|
||||||
router.get("/", (_req, res) => {
|
router.get("/", (req, res) => {
|
||||||
const rules = stmts.listPricing.all();
|
const rules = stmts.listPricing.all();
|
||||||
// A fixed number of grouped queries, however many accounts there are.
|
// A fixed number of grouped queries, however many accounts there are.
|
||||||
const logins = auth.allLoginStats();
|
const logins = auth.allLoginStats();
|
||||||
@@ -112,6 +125,7 @@ router.get("/", (_req, res) => {
|
|||||||
const sessions = counts(
|
const sessions = counts(
|
||||||
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
|
||||||
);
|
);
|
||||||
|
const today = usageSince(todayHour(parseInt(req.query.tz_offset, 10)), rules);
|
||||||
const last7d = usageSince(hourAgo(7), rules);
|
const last7d = usageSince(hourAgo(7), rules);
|
||||||
const last30d = usageSince(hourAgo(30), rules);
|
const last30d = usageSince(hourAgo(30), rules);
|
||||||
const none = { cost: 0, tokens: 0 };
|
const none = { cost: 0, tokens: 0 };
|
||||||
@@ -120,6 +134,7 @@ router.get("/", (_req, res) => {
|
|||||||
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
|
||||||
workspaces: workspaces.get(u.id) || 0,
|
workspaces: workspaces.get(u.id) || 0,
|
||||||
sessions: sessions.get(u.id) || 0,
|
sessions: sessions.get(u.id) || 0,
|
||||||
|
today: today.get(u.id) || none,
|
||||||
last7d: last7d.get(u.id) || none,
|
last7d: last7d.get(u.id) || none,
|
||||||
last30d: last30d.get(u.id) || none,
|
last30d: last30d.get(u.id) || none,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user