@korso/shepherd-ui 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,6 @@ export function ShepherdRoot({ hubUrl }) {
48
48
  // The Config tab's content. With a workspace selected it's the sidebar
49
49
  // <ConfigPanel>; with none it's a prompt pointing at the switcher, since
50
50
  // create/join now live there (the sidebar has nothing to configure).
51
- const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), selected ? (_jsx(ConfigPanel, { workspace: selected, hubUrl: hubUrl, membersRefreshKey: membersRefreshKey, onMembersChanged: () => setMembersRefreshKey((k) => k + 1), onLeft: () => void fetchWorkspaces() })) : (_jsxs("p", { className: "config-none", children: ["You're not in a workspace yet. Use the ", _jsx("b", { children: "Get started \u25BE" }), " menu at the top to create one or join with an invite code."] }))] }));
51
+ const configSection = (_jsxs("section", { className: "shepherd-config", "aria-labelledby": "config-heading", children: [_jsx("h2", { id: "config-heading", children: "Configuration" }), selected ? (_jsx(ConfigPanel, { workspace: selected, hubUrl: hubUrl, membersRefreshKey: membersRefreshKey, onMembersChanged: () => setMembersRefreshKey((k) => k + 1), onLeft: () => void fetchWorkspaces(), onDeleted: () => void fetchWorkspaces() })) : (_jsxs("p", { className: "config-none", children: ["You're not in a workspace yet. Use the ", _jsx("b", { children: "Get started \u25BE" }), " menu at the top to create one or join with an invite code."] }))] }));
52
52
  return (_jsx("div", { className: "shepherd-root", children: _jsx(Dashboard, { workspaceId: selected?.id, config: configSection, switcher: switcher, hasWorkspace: hasWorkspace }) }));
53
53
  }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, InviteByEmailResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT, FeedbackRequestT, FeedbackResponseT } from "@shepherd/shared";
1
+ import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, DeleteWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, InviteByEmailResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT, FeedbackRequestT, FeedbackResponseT } from "@shepherd/shared";
2
2
  /**
3
3
  * The single error type surfaced by the client. A missing `status` means a
4
4
  * transport failure (network error or request timeout); a present `status` is
@@ -43,6 +43,8 @@ export interface ShepherdClient {
43
43
  listWorkspaces(): Promise<ListWorkspacesResponseT>;
44
44
  /** POST a new workspace; the caller becomes its admin. */
45
45
  createWorkspace(body: CreateWorkspaceRequestT): Promise<CreateWorkspaceResponseT>;
46
+ /** DELETE a workspace and ALL its data (admin only). Permanent, irreversible. */
47
+ deleteWorkspace(workspaceId: string): Promise<DeleteWorkspaceResponseT>;
46
48
  /** POST a new agent token; the raw `shp_` value is returned exactly once. */
47
49
  mintToken(workspaceId: string, body: MintTokenRequestT): Promise<MintTokenResponseT>;
48
50
  /** GET the workspace's token metadata (never the raw token). */
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, InviteByEmailResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, FeedbackResponse, } from "@shepherd/shared";
1
+ import { ListWorkspacesResponse, CreateWorkspaceResponse, DeleteWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, InviteByEmailResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, FeedbackResponse, } from "@shepherd/shared";
2
2
  /** Per-request abort fires after this many ms when no timeoutMs is configured. */
3
3
  const DEFAULT_TIMEOUT_MS = 5000;
4
4
  /**
@@ -88,9 +88,13 @@ export function createShepherdClient(config) {
88
88
  const timer = setTimeout(() => controller.abort(), timeoutMs);
89
89
  let res;
90
90
  try {
91
- const baseHeaders = {
92
- "Content-Type": "application/json",
93
- };
91
+ // Only advertise a JSON body when there actually IS one. A bodyless
92
+ // POST/DELETE (leave, revoke, deleteWorkspace) that still sent
93
+ // `Content-Type: application/json` made the hub's Fastify parser reject the
94
+ // empty body (FST_ERR_CTP_EMPTY_JSON_BODY), which surfaced to the user as an
95
+ // opaque HTTP 500. Omitting the header on bodyless calls lets the request
96
+ // reach the route handler instead of dying in body parsing.
97
+ const baseHeaders = opts.body !== undefined ? { "Content-Type": "application/json" } : {};
94
98
  // Resolve getAuthHeader (sync or async) into a header record: a string is
95
99
  // the Authorization value, a map merges verbatim, undefined adds nothing.
96
100
  // Injected auth wins over the base, so a host may even override the
@@ -163,6 +167,11 @@ export function createShepherdClient(config) {
163
167
  schema: CreateWorkspaceResponse,
164
168
  });
165
169
  },
170
+ deleteWorkspace(workspaceId) {
171
+ return request("DELETE", `/workspaces/${enc(workspaceId)}`, {
172
+ schema: DeleteWorkspaceResponse,
173
+ });
174
+ },
166
175
  mintToken(workspaceId, body) {
167
176
  return request("POST", `/workspaces/${enc(workspaceId)}/tokens`, {
168
177
  body,
@@ -10,5 +10,7 @@ export interface ConfigPanelProps {
10
10
  onMembersChanged?: () => void;
11
11
  /** Called after a successful leave, so the shell re-lists its workspaces. */
12
12
  onLeft?: () => void;
13
+ /** Called after a successful delete, so the shell re-lists its workspaces. */
14
+ onDeleted?: () => void;
13
15
  }
14
- export declare function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, }: ConfigPanelProps): import("react").JSX.Element;
16
+ export declare function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, }: ConfigPanelProps): import("react").JSX.Element;
@@ -9,8 +9,8 @@ const SECTIONS = [
9
9
  { id: "members", label: "Members" },
10
10
  { id: "agent", label: "Agent" },
11
11
  ];
12
- export function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, }) {
12
+ export function ConfigPanel({ workspace, hubUrl, membersRefreshKey, onMembersChanged, onLeft, onDeleted, }) {
13
13
  const [section, setSection] = useState("general");
14
14
  const isAdmin = workspace.role === "admin";
15
- return (_jsxs("div", { className: "config-layout", children: [_jsx("nav", { className: "config-nav", "aria-label": "Configuration sections", children: SECTIONS.map(({ id, label }) => (_jsx("button", { type: "button", className: "config-nav__item" + (section === id ? " config-nav__item--on" : ""), "aria-current": section === id ? "page" : undefined, onClick: () => setSection(id), children: label }, id))) }), _jsxs("div", { className: "config-panel", children: [section === "general" && (_jsx(GeneralSettings, { workspace: workspace, onLeft: onLeft })), section === "members" && (_jsxs(_Fragment, { children: [_jsx(Members, { workspaceId: workspace.id, refreshKey: membersRefreshKey, canRemove: isAdmin }), isAdmin && (_jsx(Invites, { workspaceId: workspace.id, onMembersChanged: onMembersChanged }))] })), section === "agent" && _jsx(ConnectAgent, { hubUrl: hubUrl })] })] }));
15
+ return (_jsxs("div", { className: "config-layout", children: [_jsx("nav", { className: "config-nav", "aria-label": "Configuration sections", children: SECTIONS.map(({ id, label }) => (_jsx("button", { type: "button", className: "config-nav__item" + (section === id ? " config-nav__item--on" : ""), "aria-current": section === id ? "page" : undefined, onClick: () => setSection(id), children: label }, id))) }), _jsxs("div", { className: "config-panel", children: [section === "general" && (_jsx(GeneralSettings, { workspace: workspace, onLeft: onLeft, onDeleted: onDeleted })), section === "members" && (_jsxs(_Fragment, { children: [_jsx(Members, { workspaceId: workspace.id, refreshKey: membersRefreshKey, canRemove: isAdmin }), isAdmin && (_jsx(Invites, { workspaceId: workspace.id, onMembersChanged: onMembersChanged }))] })), section === "agent" && _jsx(ConnectAgent, { hubUrl: hubUrl })] })] }));
16
16
  }
@@ -0,0 +1,13 @@
1
+ export interface ConfirmDeleteWorkspaceProps {
2
+ /** The workspace name the operator must type verbatim to enable Delete. */
3
+ workspaceName: string;
4
+ /** True while the delete request is in flight — disables the controls. */
5
+ busy: boolean;
6
+ /** A failed-delete message to show inline, or null. */
7
+ error: string | null;
8
+ /** Fired when the confirmed Delete button is pressed. */
9
+ onConfirm: () => void;
10
+ /** Fired on Cancel / Escape / backdrop click (ignored while busy). */
11
+ onCancel: () => void;
12
+ }
13
+ export declare function ConfirmDeleteWorkspace({ workspaceName, busy, error, onConfirm, onCancel, }: ConfirmDeleteWorkspaceProps): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
+ export function ConfirmDeleteWorkspace({ workspaceName, busy, error, onConfirm, onCancel, }) {
4
+ const headingId = useId();
5
+ const descId = useId();
6
+ const [typed, setTyped] = useState("");
7
+ const inputRef = useRef(null);
8
+ // Focus the confirmation input when the modal opens.
9
+ useEffect(() => {
10
+ inputRef.current?.focus();
11
+ }, []);
12
+ // Escape cancels (unless a delete is in flight).
13
+ useEffect(() => {
14
+ function onKey(e) {
15
+ if (e.key === "Escape" && !busy)
16
+ onCancel();
17
+ }
18
+ document.addEventListener("keydown", onKey);
19
+ return () => document.removeEventListener("keydown", onKey);
20
+ }, [busy, onCancel]);
21
+ const confirmed = typed === workspaceName;
22
+ return (_jsx("div", { className: "shepherd-modal__backdrop", onClick: () => {
23
+ if (!busy)
24
+ onCancel();
25
+ }, children: _jsxs("div", { className: "shepherd-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": headingId, "aria-describedby": descId,
26
+ // Clicks inside the dialog must not bubble to the backdrop's cancel.
27
+ onClick: (e) => e.stopPropagation(), children: [_jsx("h3", { id: headingId, children: "Delete workspace" }), _jsxs("p", { id: descId, className: "shepherd-modal__body", children: ["This permanently deletes ", _jsx("b", { children: workspaceName }), " and all of its data \u2014 agents, sessions, tasks, announcements, change history, tokens, invites, and members. This cannot be undone."] }), _jsxs("label", { htmlFor: `${headingId}-input`, className: "shepherd-modal__label", children: ["Type ", _jsx("b", { children: workspaceName }), " to confirm"] }), _jsx("input", { id: `${headingId}-input`, ref: inputRef, type: "text", value: typed, autoComplete: "off", disabled: busy, onChange: (e) => setTyped(e.target.value) }), error && _jsx("p", { role: "alert", children: error }), _jsxs("div", { className: "shepherd-modal__actions", children: [_jsx("button", { type: "button", onClick: onCancel, disabled: busy, children: "Cancel" }), _jsx("button", { type: "button", className: "danger", onClick: onConfirm, disabled: !confirmed || busy, children: busy ? "Deleting…" : "Delete workspace" })] })] }) }));
28
+ }
@@ -32,7 +32,7 @@ function installCommand(tool, hubUrl, token) {
32
32
  mcpServers: {
33
33
  shepherd: {
34
34
  command: "npx",
35
- args: ["-y", "@korso/shepherd"],
35
+ args: ["-y", "-p", "@korso/shepherd", "shepherd-mcp"],
36
36
  env: { HUB_URL: hubUrl, SHEPHERD_TOKEN: token },
37
37
  },
38
38
  },
@@ -45,7 +45,7 @@ function installCommand(tool, hubUrl, token) {
45
45
  CLI_PREFIX[tool],
46
46
  `-e HUB_URL=${hubUrl}`,
47
47
  `-e SHEPHERD_TOKEN=${token}`,
48
- "-- npx -y @korso/shepherd",
48
+ "-- npx -y -p @korso/shepherd shepherd-mcp",
49
49
  ].join(" ");
50
50
  }
51
51
  export function ConnectAgent({ hubUrl }) {
@@ -4,5 +4,7 @@ export interface GeneralSettingsProps {
4
4
  workspace: WorkspaceSummaryT;
5
5
  /** Called after a successful leave, so the shell refreshes its workspace list. */
6
6
  onLeft?: () => void;
7
+ /** Called after a successful delete, so the shell refreshes its workspace list. */
8
+ onDeleted?: () => void;
7
9
  }
8
- export declare function GeneralSettings({ workspace, onLeft }: GeneralSettingsProps): import("react").JSX.Element;
10
+ export declare function GeneralSettings({ workspace, onLeft, onDeleted }: GeneralSettingsProps): import("react").JSX.Element;
@@ -1,27 +1,65 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId, useState } from "react";
3
3
  import { useShepherdClient } from "../context.js";
4
- import { describeError } from "../client.js";
5
- export function GeneralSettings({ workspace, onLeft }) {
4
+ import { describeError, ShepherdClientError } from "../client.js";
5
+ import { ConfirmDeleteWorkspace } from "./ConfirmDeleteWorkspace.js";
6
+ export function GeneralSettings({ workspace, onLeft, onDeleted }) {
6
7
  const client = useShepherdClient();
7
8
  const headingId = useId();
8
9
  const [error, setError] = useState(null);
9
10
  const [leaving, setLeaving] = useState(false);
11
+ // Set when a leave is rejected because the caller is the last admin (409): they
12
+ // cannot leave by design, so we surface the "delete instead" hint pointing at
13
+ // the Delete section below (which is visible to them, since they are an admin).
14
+ const [lastAdminBlocked, setLastAdminBlocked] = useState(false);
15
+ const [confirmOpen, setConfirmOpen] = useState(false);
16
+ const [deleting, setDeleting] = useState(false);
17
+ const [deleteError, setDeleteError] = useState(null);
18
+ const isAdmin = workspace.role === "admin";
10
19
  async function leave() {
11
20
  if (leaving)
12
21
  return;
13
22
  setLeaving(true);
14
23
  setError(null);
24
+ setLastAdminBlocked(false);
15
25
  try {
16
26
  await client.leave(workspace.id);
17
27
  onLeft?.();
18
28
  }
19
29
  catch (err) {
20
30
  setError(describeError(err));
31
+ // A 409 here is the last-admin guard: the workspace must always keep an
32
+ // admin, so this member cannot leave. Point them at Delete instead.
33
+ if (err instanceof ShepherdClientError && err.status === 409) {
34
+ setLastAdminBlocked(true);
35
+ }
21
36
  }
22
37
  finally {
23
38
  setLeaving(false);
24
39
  }
25
40
  }
26
- return (_jsxs("section", { className: "shepherd-general", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "General" }), _jsx("p", { className: "card-sub", children: "Your active workspace and its settings." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Workspace name" }), _jsx("p", { className: "readonly-value", children: workspace.name })] }), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Your role" }), _jsx("p", { className: "readonly-value", children: workspace.role })] }), _jsxs("div", { className: "field leave", children: [_jsx("label", { children: "Leave workspace" }), _jsx("p", { className: "helper", children: "Remove yourself from this workspace. You'll need a new invite to rejoin." }), _jsx("button", { type: "button", className: "danger", onClick: () => void leave(), disabled: leaving, children: "Leave workspace" })] })] })] }));
41
+ async function confirmDelete() {
42
+ if (deleting)
43
+ return;
44
+ setDeleting(true);
45
+ setDeleteError(null);
46
+ try {
47
+ await client.deleteWorkspace(workspace.id);
48
+ setConfirmOpen(false);
49
+ onDeleted?.();
50
+ }
51
+ catch (err) {
52
+ setDeleteError(describeError(err));
53
+ }
54
+ finally {
55
+ setDeleting(false);
56
+ }
57
+ }
58
+ return (_jsxs("section", { className: "shepherd-general", "aria-labelledby": headingId, children: [_jsxs("div", { className: "card-head", children: [_jsx("h3", { id: headingId, children: "General" }), _jsx("p", { className: "card-sub", children: "Your active workspace and its settings." })] }), _jsxs("div", { className: "card-body", children: [error && _jsx("p", { role: "alert", children: error }), lastAdminBlocked && isAdmin && (_jsx("p", { className: "helper", children: "You're the last admin, so you can't leave. To remove this workspace entirely, delete it below." })), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Workspace name" }), _jsx("p", { className: "readonly-value", children: workspace.name })] }), _jsxs("div", { className: "field", children: [_jsx("label", { children: "Your role" }), _jsx("p", { className: "readonly-value", children: workspace.role })] }), _jsxs("div", { className: "field leave", children: [_jsx("label", { children: "Leave workspace" }), _jsx("p", { className: "helper", children: "Remove yourself from this workspace. You'll need a new invite to rejoin." }), _jsx("button", { type: "button", className: "danger", onClick: () => void leave(), disabled: leaving, children: "Leave workspace" })] }), isAdmin && (_jsxs("div", { className: "field delete", children: [_jsx("label", { children: "Delete workspace" }), _jsx("p", { className: "helper", children: "Permanently delete this workspace and all of its data. This cannot be undone." }), _jsx("button", { type: "button", className: "danger", onClick: () => {
59
+ setDeleteError(null);
60
+ setConfirmOpen(true);
61
+ }, disabled: deleting, children: "Delete workspace" })] }))] }), confirmOpen && (_jsx(ConfirmDeleteWorkspace, { workspaceName: workspace.name, busy: deleting, error: deleteError, onConfirm: () => void confirmDelete(), onCancel: () => {
62
+ if (!deleting)
63
+ setConfirmOpen(false);
64
+ } }))] }));
27
65
  }