@korso/shepherd-ui 0.1.1 → 0.2.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.
- package/README.md +47 -47
- package/dist/ShepherdProvider.d.ts +12 -0
- package/dist/ShepherdProvider.js +33 -0
- package/dist/ShepherdProvider.test.d.ts +1 -0
- package/dist/ShepherdProvider.test.js +47 -0
- package/dist/ShepherdRoot.d.ts +9 -0
- package/dist/ShepherdRoot.js +88 -0
- package/dist/ShepherdRoot.routing.test.d.ts +1 -0
- package/dist/ShepherdRoot.routing.test.js +61 -0
- package/dist/ShepherdRoot.test.d.ts +1 -0
- package/dist/ShepherdRoot.test.js +37 -0
- package/dist/app/assets/index-CDOCIg6s.js +11 -0
- package/dist/app/index.html +12 -0
- package/dist/client.d.ts +107 -0
- package/dist/client.js +238 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +298 -0
- package/dist/components/ActiveList.d.ts +22 -0
- package/dist/components/ActiveList.js +59 -0
- package/dist/components/Chat.d.ts +30 -0
- package/dist/components/Chat.js +61 -0
- package/dist/components/Composer.d.ts +37 -0
- package/dist/components/Composer.js +146 -0
- package/dist/components/Crew.d.ts +23 -0
- package/dist/components/Crew.js +31 -0
- package/dist/components/Dashboard.d.ts +34 -0
- package/dist/components/Dashboard.js +209 -0
- package/dist/components/DoneList.d.ts +29 -0
- package/dist/components/DoneList.js +50 -0
- package/dist/components/RepoSelect.d.ts +38 -0
- package/dist/components/RepoSelect.js +56 -0
- package/dist/components/Territory.d.ts +21 -0
- package/dist/components/Territory.js +21 -0
- package/dist/config/ConnectAgent.d.ts +10 -0
- package/dist/config/ConnectAgent.js +119 -0
- package/dist/config/ConnectAgent.test.d.ts +1 -0
- package/dist/config/ConnectAgent.test.js +92 -0
- package/dist/config/EmptyState.d.ts +12 -0
- package/dist/config/EmptyState.js +4 -0
- package/dist/config/EmptyState.test.d.ts +1 -0
- package/dist/config/EmptyState.test.js +32 -0
- package/dist/config/Members.d.ts +10 -0
- package/dist/config/Members.js +83 -0
- package/dist/config/Members.test.d.ts +1 -0
- package/dist/config/Members.test.js +66 -0
- package/dist/config/Workspaces.d.ts +12 -0
- package/dist/config/Workspaces.js +96 -0
- package/dist/config/Workspaces.test.d.ts +1 -0
- package/dist/config/Workspaces.test.js +90 -0
- package/dist/config/index.d.ts +8 -0
- package/dist/config/index.js +6 -0
- package/dist/context.d.ts +28 -0
- package/dist/context.js +36 -0
- package/dist/index.cjs +5289 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +149 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
- package/dist/lib/index.d.ts +438 -16
- package/dist/lib/index.js +455 -6
- package/dist/lib/selfhost.js +9 -9
- package/dist/lib/styles.css +209 -182
- package/dist/logic.d.ts +208 -0
- package/dist/logic.js +372 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +11 -0
- package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
- package/dist/selfhost/assets/index-CS268KSw.js +40 -0
- package/dist/selfhost/index.html +13 -13
- package/dist/selfhost.d.ts +22 -0
- package/dist/selfhost.js +97 -0
- package/dist/test/mockClient.d.ts +6 -0
- package/dist/test/mockClient.js +45 -0
- package/dist/test/setup.d.ts +1 -0
- package/dist/test/setup.js +2 -0
- package/dist/useLandscapePolling.d.ts +63 -0
- package/dist/useLandscapePolling.js +129 -0
- package/dist/views/Chat.d.ts +5 -0
- package/dist/views/Chat.js +60 -0
- package/dist/views/Chat.test.d.ts +1 -0
- package/dist/views/Chat.test.js +92 -0
- package/dist/views/Config.d.ts +12 -0
- package/dist/views/Config.js +6 -0
- package/dist/views/Tasks.d.ts +5 -0
- package/dist/views/Tasks.js +75 -0
- package/dist/views/Tasks.test.d.ts +1 -0
- package/dist/views/Tasks.test.js +115 -0
- package/dist/views/useLandscape.d.ts +14 -0
- package/dist/views/useLandscape.js +74 -0
- package/dist/views/useLandscape.test.d.ts +1 -0
- package/dist/views/useLandscape.test.js +101 -0
- package/dist/views/wallboard.d.ts +46 -0
- package/dist/views/wallboard.js +209 -0
- package/dist/views/wallboard.test.d.ts +1 -0
- package/dist/views/wallboard.test.js +143 -0
- package/package.json +67 -67
- package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
- package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Shepherd</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CDOCIg6s.js"></script>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ListWorkspacesResponseT, CreateWorkspaceRequestT, CreateWorkspaceResponseT, MintTokenRequestT, MintTokenResponseT, ListTokensResponseT, CreateInviteRequestT, InviteResponseT, RedeemInviteResponseT, ListMembersResponseT, WorkspaceLandscapeResponseT, WorkspaceAnnounceRequestT, WorkspaceAnnounceResponseT } from "@shepherd/shared";
|
|
2
|
+
/**
|
|
3
|
+
* The single error type surfaced by the client. A missing `status` means a
|
|
4
|
+
* transport failure (network error or request timeout); a present `status` is
|
|
5
|
+
* the upstream HTTP status of a non-2xx response. One class — distinguished by
|
|
6
|
+
* the optional `status` — keeps callers from having to branch on two error
|
|
7
|
+
* types just to tell "couldn't reach the hub" from "the hub said no".
|
|
8
|
+
*/
|
|
9
|
+
export declare class ShepherdClientError extends Error {
|
|
10
|
+
/** Upstream HTTP status for a non-2xx response; absent for transport errors. */
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
/**
|
|
13
|
+
* @param message - Human-readable failure description.
|
|
14
|
+
* @param status - Upstream HTTP status, omitted for network/abort failures.
|
|
15
|
+
*/
|
|
16
|
+
constructor(message: string, status?: number);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Maps any thrown value into a short, human-friendly message for the view layer.
|
|
20
|
+
* A {@link ShepherdClientError} (and any other `Error`) surfaces its own
|
|
21
|
+
* `message` — which already carries the hub's detail for non-2xx responses and a
|
|
22
|
+
* transport description otherwise — while a non-Error value degrades to a
|
|
23
|
+
* generic line rather than stringifying something unprintable.
|
|
24
|
+
*
|
|
25
|
+
* @param err - The caught value (typically from a client method rejection).
|
|
26
|
+
* @returns A display-ready message string.
|
|
27
|
+
*/
|
|
28
|
+
export declare function describeError(err: unknown): string;
|
|
29
|
+
/**
|
|
30
|
+
* The typed Shepherd hub surface. The plural, workspace-scoped methods use the
|
|
31
|
+
* `/workspaces/:id/...` form (which works in both deployments: a bearer token
|
|
32
|
+
* resolves its own workspace and ignores `:id`, while a browser session
|
|
33
|
+
* validates membership of `:id`). The singular `getLandscape()`/`announce()`
|
|
34
|
+
* pair are the self-host aliases against the implicit single-workspace routes.
|
|
35
|
+
* Every response with a dedicated schema is Zod-parsed at the boundary so a
|
|
36
|
+
* contract drift throws `Invalid response schema` rather than flowing a
|
|
37
|
+
* malformed object into the view layer.
|
|
38
|
+
*/
|
|
39
|
+
export interface ShepherdClient {
|
|
40
|
+
/** The normalised (trailing-slash-stripped) hub base URL the client targets. */
|
|
41
|
+
readonly baseUrl: string;
|
|
42
|
+
/** GET the caller's workspaces, each tagged with the caller's role. */
|
|
43
|
+
listWorkspaces(): Promise<ListWorkspacesResponseT>;
|
|
44
|
+
/** POST a new workspace; the caller becomes its admin. */
|
|
45
|
+
createWorkspace(body: CreateWorkspaceRequestT): Promise<CreateWorkspaceResponseT>;
|
|
46
|
+
/** POST a new agent token; the raw `shp_` value is returned exactly once. */
|
|
47
|
+
mintToken(workspaceId: string, body: MintTokenRequestT): Promise<MintTokenResponseT>;
|
|
48
|
+
/** GET the workspace's token metadata (never the raw token). */
|
|
49
|
+
listTokens(workspaceId: string): Promise<ListTokensResponseT>;
|
|
50
|
+
/** DELETE (revoke) a token by id. */
|
|
51
|
+
revokeToken(workspaceId: string, tokenId: string): Promise<void>;
|
|
52
|
+
/** POST a new invite code for the workspace (admin only). */
|
|
53
|
+
createInvite(workspaceId: string, body: CreateInviteRequestT): Promise<InviteResponseT>;
|
|
54
|
+
/** POST to revoke an invite code (admin only). */
|
|
55
|
+
revokeInvite(workspaceId: string, code: string): Promise<void>;
|
|
56
|
+
/** POST to redeem an invite code, joining its workspace as a member. */
|
|
57
|
+
redeemInvite(code: string): Promise<RedeemInviteResponseT>;
|
|
58
|
+
/** GET the workspace's member roster. */
|
|
59
|
+
listMembers(workspaceId: string): Promise<ListMembersResponseT>;
|
|
60
|
+
/** DELETE (remove) a member by account id (admin only). */
|
|
61
|
+
removeMember(workspaceId: string, accountId: string): Promise<void>;
|
|
62
|
+
/** POST to leave the workspace. */
|
|
63
|
+
leave(workspaceId: string): Promise<void>;
|
|
64
|
+
/** GET a specific workspace's landscape (agents, tasks, announcements). */
|
|
65
|
+
landscape(workspaceId: string): Promise<WorkspaceLandscapeResponseT>;
|
|
66
|
+
/** POST an operator announcement to a specific workspace. */
|
|
67
|
+
announceTo(workspaceId: string, body: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
|
|
68
|
+
/** GET the unfiltered whole-workspace view (agents, tasks, announcements). */
|
|
69
|
+
getLandscape(): Promise<WorkspaceLandscapeResponseT>;
|
|
70
|
+
/** POST an operator announcement (broadcast or @targeted) to the workspace. */
|
|
71
|
+
announce(req: WorkspaceAnnounceRequestT): Promise<WorkspaceAnnounceResponseT>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Construction options for {@link createShepherdClient}. Auth is supplied
|
|
75
|
+
* externally via `getAuthHeader` so the core client stays auth-agnostic — it
|
|
76
|
+
* never reads tokens, localStorage, or a BFF; it only merges whatever the host
|
|
77
|
+
* injects and notifies the host on a 401 via `onUnauthorized`.
|
|
78
|
+
*/
|
|
79
|
+
export interface ShepherdClientConfig {
|
|
80
|
+
/** Hub origin; a trailing slash is tolerated and normalised away. "" = same-origin. */
|
|
81
|
+
baseUrl: string;
|
|
82
|
+
/**
|
|
83
|
+
* Returns the credential for the next request, as either:
|
|
84
|
+
* - a string → sent as the `Authorization` header value (e.g. "Bearer …"),
|
|
85
|
+
* - a header map → merged verbatim over the JSON content-type base, or
|
|
86
|
+
* - undefined → no auth header added (same-origin BFF supplies the cookie).
|
|
87
|
+
* May be sync or async (e.g. refreshing a short-lived token). Omit entirely
|
|
88
|
+
* for an unauthenticated/same-origin deployment.
|
|
89
|
+
*/
|
|
90
|
+
getAuthHeader?: () => string | Record<string, string> | undefined | Promise<string | Record<string, string> | undefined>;
|
|
91
|
+
/** Invoked once when the hub responds 401, before the error is thrown. */
|
|
92
|
+
onUnauthorized?: () => void;
|
|
93
|
+
/** Per-request abort timeout in ms; defaults to {@link DEFAULT_TIMEOUT_MS}. */
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Builds a browser fetch client for the Shepherd hub. Each call wires up an
|
|
98
|
+
* AbortController timeout (cleared in `finally`), merges the injected auth
|
|
99
|
+
* headers over a JSON content-type base, and Zod-parses ONLY the response at
|
|
100
|
+
* the boundary — the request body is forwarded verbatim because the caller owns
|
|
101
|
+
* its input shape. void-returning methods (revoke/remove/leave) await the
|
|
102
|
+
* request and parse nothing.
|
|
103
|
+
*
|
|
104
|
+
* @param config - Base URL, optional injected auth, 401 hook, and timeout.
|
|
105
|
+
* @returns A {@link ShepherdClient}.
|
|
106
|
+
*/
|
|
107
|
+
export declare function createShepherdClient(config: ShepherdClientConfig): ShepherdClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { ListWorkspacesResponse, CreateWorkspaceResponse, MintTokenResponse, ListTokensResponse, InviteResponse, RedeemInviteResponse, ListMembersResponse, WorkspaceLandscapeResponse, WorkspaceAnnounceResponse, } from "@shepherd/shared";
|
|
2
|
+
/** Per-request abort fires after this many ms when no timeoutMs is configured. */
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
4
|
+
/**
|
|
5
|
+
* The single error type surfaced by the client. A missing `status` means a
|
|
6
|
+
* transport failure (network error or request timeout); a present `status` is
|
|
7
|
+
* the upstream HTTP status of a non-2xx response. One class — distinguished by
|
|
8
|
+
* the optional `status` — keeps callers from having to branch on two error
|
|
9
|
+
* types just to tell "couldn't reach the hub" from "the hub said no".
|
|
10
|
+
*/
|
|
11
|
+
export class ShepherdClientError extends Error {
|
|
12
|
+
/** Upstream HTTP status for a non-2xx response; absent for transport errors. */
|
|
13
|
+
status;
|
|
14
|
+
/**
|
|
15
|
+
* @param message - Human-readable failure description.
|
|
16
|
+
* @param status - Upstream HTTP status, omitted for network/abort failures.
|
|
17
|
+
*/
|
|
18
|
+
constructor(message, status) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ShepherdClientError";
|
|
21
|
+
if (status !== undefined) {
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Maps any thrown value into a short, human-friendly message for the view layer.
|
|
28
|
+
* A {@link ShepherdClientError} (and any other `Error`) surfaces its own
|
|
29
|
+
* `message` — which already carries the hub's detail for non-2xx responses and a
|
|
30
|
+
* transport description otherwise — while a non-Error value degrades to a
|
|
31
|
+
* generic line rather than stringifying something unprintable.
|
|
32
|
+
*
|
|
33
|
+
* @param err - The caught value (typically from a client method rejection).
|
|
34
|
+
* @returns A display-ready message string.
|
|
35
|
+
*/
|
|
36
|
+
export function describeError(err) {
|
|
37
|
+
return err instanceof Error ? err.message : "Something went wrong.";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Reads a best-effort `{ error: string }` message from a non-2xx response body
|
|
41
|
+
* so the thrown error carries the hub's own explanation. A non-JSON or bodyless
|
|
42
|
+
* response degrades to an empty string — the status code is still informative.
|
|
43
|
+
*
|
|
44
|
+
* @param res - The non-2xx response to inspect.
|
|
45
|
+
* @returns The upstream error string, or "" when none could be read.
|
|
46
|
+
*/
|
|
47
|
+
async function readErrorDetail(res) {
|
|
48
|
+
try {
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
if (data !== null &&
|
|
51
|
+
typeof data === "object" &&
|
|
52
|
+
"error" in data &&
|
|
53
|
+
typeof data.error === "string") {
|
|
54
|
+
return data.error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Body wasn't JSON; the status code alone remains informative.
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Builds a browser fetch client for the Shepherd hub. Each call wires up an
|
|
64
|
+
* AbortController timeout (cleared in `finally`), merges the injected auth
|
|
65
|
+
* headers over a JSON content-type base, and Zod-parses ONLY the response at
|
|
66
|
+
* the boundary — the request body is forwarded verbatim because the caller owns
|
|
67
|
+
* its input shape. void-returning methods (revoke/remove/leave) await the
|
|
68
|
+
* request and parse nothing.
|
|
69
|
+
*
|
|
70
|
+
* @param config - Base URL, optional injected auth, 401 hook, and timeout.
|
|
71
|
+
* @returns A {@link ShepherdClient}.
|
|
72
|
+
*/
|
|
73
|
+
export function createShepherdClient(config) {
|
|
74
|
+
// Strip a trailing slash so `${baseUrl}/workspace/...` never yields a double
|
|
75
|
+
// slash regardless of how the host configured the origin. An empty baseUrl
|
|
76
|
+
// stays "" so paths resolve root-relative (same-origin).
|
|
77
|
+
const baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
78
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
79
|
+
const enc = encodeURIComponent;
|
|
80
|
+
/**
|
|
81
|
+
* Performs one request, mapping transport failures to a status-less error,
|
|
82
|
+
* 401s to `onUnauthorized` + a 401 error, other non-2xx to a status error,
|
|
83
|
+
* and a 2xx body either through the supplied Zod schema or — when no schema
|
|
84
|
+
* is given (void endpoints) — to `undefined`.
|
|
85
|
+
*/
|
|
86
|
+
async function request(method, path, opts = {}) {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
89
|
+
let res;
|
|
90
|
+
try {
|
|
91
|
+
const baseHeaders = {
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
};
|
|
94
|
+
// Resolve getAuthHeader (sync or async) into a header record: a string is
|
|
95
|
+
// the Authorization value, a map merges verbatim, undefined adds nothing.
|
|
96
|
+
// Injected auth wins over the base, so a host may even override the
|
|
97
|
+
// content-type if it ever needs to. Built INSIDE the try so a rejecting
|
|
98
|
+
// (async) getAuthHeader is funneled through the catch and the finally
|
|
99
|
+
// still clears the abort timer — no leaked timer on an auth failure.
|
|
100
|
+
const resolved = await config.getAuthHeader?.();
|
|
101
|
+
const authHeaders = typeof resolved === "string"
|
|
102
|
+
? { Authorization: resolved }
|
|
103
|
+
: resolved ?? {};
|
|
104
|
+
const init = {
|
|
105
|
+
method,
|
|
106
|
+
headers: { ...baseHeaders, ...authHeaders },
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
};
|
|
109
|
+
if (opts.body !== undefined) {
|
|
110
|
+
init.body = JSON.stringify(opts.body);
|
|
111
|
+
}
|
|
112
|
+
res = await fetch(`${baseUrl}${path}`, init);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
// Network error or abort/timeout: no HTTP status exists, so the error is
|
|
116
|
+
// thrown WITHOUT one to mark it a transport failure.
|
|
117
|
+
throw new ShepherdClientError(err instanceof Error ? err.message : String(err));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
if (res.status === 401) {
|
|
123
|
+
config.onUnauthorized?.();
|
|
124
|
+
throw new ShepherdClientError("Unauthorized", 401);
|
|
125
|
+
}
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const detail = await readErrorDetail(res);
|
|
128
|
+
throw new ShepherdClientError(detail !== ""
|
|
129
|
+
? `HTTP ${res.status}: ${detail}`
|
|
130
|
+
: `HTTP ${res.status}`, res.status);
|
|
131
|
+
}
|
|
132
|
+
if (!opts.schema) {
|
|
133
|
+
// void endpoint: no dedicated response schema. Drain any body and resolve
|
|
134
|
+
// undefined — callers of these methods don't depend on the payload.
|
|
135
|
+
try {
|
|
136
|
+
await res.json();
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// empty / non-JSON body is fine for a void endpoint.
|
|
140
|
+
}
|
|
141
|
+
// void endpoints are called as request<void>(...); the undefined resolve
|
|
142
|
+
// is the intended value, asserted to T since the no-schema branch is only
|
|
143
|
+
// reached for the void-typed callers.
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const parsed = opts.schema.safeParse(await res.json());
|
|
147
|
+
if (!parsed.success) {
|
|
148
|
+
throw new ShepherdClientError("Invalid response schema");
|
|
149
|
+
}
|
|
150
|
+
return parsed.data;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
baseUrl,
|
|
154
|
+
// --- multi-workspace management surface ---------------------------------
|
|
155
|
+
listWorkspaces() {
|
|
156
|
+
return request("GET", "/workspaces", {
|
|
157
|
+
schema: ListWorkspacesResponse,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
createWorkspace(body) {
|
|
161
|
+
return request("POST", "/workspaces", {
|
|
162
|
+
body,
|
|
163
|
+
schema: CreateWorkspaceResponse,
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
mintToken(workspaceId, body) {
|
|
167
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/tokens`, {
|
|
168
|
+
body,
|
|
169
|
+
schema: MintTokenResponse,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
listTokens(workspaceId) {
|
|
173
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/tokens`, {
|
|
174
|
+
schema: ListTokensResponse,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
revokeToken(workspaceId, tokenId) {
|
|
178
|
+
return request("DELETE", `/workspaces/${enc(workspaceId)}/tokens/${enc(tokenId)}`);
|
|
179
|
+
},
|
|
180
|
+
createInvite(workspaceId, body) {
|
|
181
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/invites`, {
|
|
182
|
+
body,
|
|
183
|
+
schema: InviteResponse,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
revokeInvite(workspaceId, code) {
|
|
187
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/invites/${enc(code)}/revoke`);
|
|
188
|
+
},
|
|
189
|
+
redeemInvite(code) {
|
|
190
|
+
return request("POST", `/invites/${enc(code)}/redeem`, {
|
|
191
|
+
schema: RedeemInviteResponse,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
listMembers(workspaceId) {
|
|
195
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/members`, {
|
|
196
|
+
schema: ListMembersResponse,
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
removeMember(workspaceId, accountId) {
|
|
200
|
+
return request("DELETE", `/workspaces/${enc(workspaceId)}/members/${enc(accountId)}`);
|
|
201
|
+
},
|
|
202
|
+
leave(workspaceId) {
|
|
203
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/leave`);
|
|
204
|
+
},
|
|
205
|
+
landscape(workspaceId) {
|
|
206
|
+
// WorkspaceLandscapeResponse's inferred type widens the announcement
|
|
207
|
+
// admin flags to optional, so it doesn't structurally match the declared
|
|
208
|
+
// ...ResponseT alias; an explicit cast is kept for just this schema.
|
|
209
|
+
return request("GET", `/workspaces/${enc(workspaceId)}/landscape`, {
|
|
210
|
+
schema: WorkspaceLandscapeResponse,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
announceTo(workspaceId, body) {
|
|
214
|
+
// The request body is NOT validated here — the caller owns input shape;
|
|
215
|
+
// only the RESPONSE is parsed at the boundary.
|
|
216
|
+
return request("POST", `/workspaces/${enc(workspaceId)}/announce`, {
|
|
217
|
+
body,
|
|
218
|
+
schema: WorkspaceAnnounceResponse,
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
// --- self-host singular aliases (implicit single workspace) -------------
|
|
222
|
+
getLandscape() {
|
|
223
|
+
// See landscape() above: this schema's inferred type doesn't structurally
|
|
224
|
+
// match the ...ResponseT alias, so its explicit cast is kept.
|
|
225
|
+
return request("GET", "/workspace/landscape", {
|
|
226
|
+
schema: WorkspaceLandscapeResponse,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
announce(req) {
|
|
230
|
+
// The request body is NOT validated here — the caller owns input shape;
|
|
231
|
+
// only the RESPONSE is parsed at the boundary.
|
|
232
|
+
return request("POST", "/workspace/announce", {
|
|
233
|
+
body: req,
|
|
234
|
+
schema: WorkspaceAnnounceResponse,
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createShepherdClient, ShepherdHttpError } from "./client.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// ShepherdClient — fetch-mock unit tests (DB-free; mocks global `fetch`).
|
|
5
|
+
//
|
|
6
|
+
// Each method asserts: the request URL (baseUrl + exact encoded path), the HTTP
|
|
7
|
+
// method, that auth headers from getAuthHeader are merged (Bearer-string, header-
|
|
8
|
+
// map, and undefined/no-op cases), Content-Type + body JSON on bodied requests,
|
|
9
|
+
// and that the response is validated by the @shepherd/shared zod schema. A
|
|
10
|
+
// malformed 2xx body throws; a non-2xx rejects with a typed ShepherdHttpError.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const BASE = "https://hub.example.com";
|
|
13
|
+
/** Build a Response-like object for a mocked fetch resolution. */
|
|
14
|
+
function jsonResponse(body, init) {
|
|
15
|
+
const status = init?.status ?? 200;
|
|
16
|
+
return {
|
|
17
|
+
ok: status >= 200 && status < 300,
|
|
18
|
+
status,
|
|
19
|
+
statusText: init?.statusText ?? "OK",
|
|
20
|
+
json: async () => body,
|
|
21
|
+
text: async () => JSON.stringify(body),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
let fetchMock;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
fetchMock = vi.fn();
|
|
27
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals();
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
/** The single call recorded on the fetch mock: [url, init]. */
|
|
34
|
+
function lastCall() {
|
|
35
|
+
const call = fetchMock.mock.calls[0];
|
|
36
|
+
return [call[0], call[1]];
|
|
37
|
+
}
|
|
38
|
+
/** Read a header from a recorded RequestInit (headers stored as a plain record). */
|
|
39
|
+
function header(init, name) {
|
|
40
|
+
const h = init.headers;
|
|
41
|
+
if (!h)
|
|
42
|
+
return undefined;
|
|
43
|
+
const key = Object.keys(h).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
44
|
+
return key ? h[key] : undefined;
|
|
45
|
+
}
|
|
46
|
+
// --- fixtures matching the @shepherd/shared schemas ------------------------
|
|
47
|
+
const WORKSPACE_SUMMARY = { id: "ws_1", slug: "acme", name: "Acme", role: "admin" };
|
|
48
|
+
const LANDSCAPE = {
|
|
49
|
+
agents: [],
|
|
50
|
+
tasks: [],
|
|
51
|
+
announcements: [],
|
|
52
|
+
serverTime: "2026-06-29T00:00:00.000Z",
|
|
53
|
+
};
|
|
54
|
+
describe("createShepherdClient", () => {
|
|
55
|
+
let client;
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
client = createShepherdClient({ baseUrl: BASE });
|
|
58
|
+
});
|
|
59
|
+
describe("listWorkspaces", () => {
|
|
60
|
+
it("GETs /workspaces and returns the validated body", async () => {
|
|
61
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ workspaces: [WORKSPACE_SUMMARY] }));
|
|
62
|
+
const out = await client.listWorkspaces();
|
|
63
|
+
const [url, init] = lastCall();
|
|
64
|
+
expect(url).toBe(`${BASE}/workspaces`);
|
|
65
|
+
expect(init.method).toBe("GET");
|
|
66
|
+
expect(out).toEqual({ workspaces: [WORKSPACE_SUMMARY] });
|
|
67
|
+
});
|
|
68
|
+
it("throws when the response body fails schema validation", async () => {
|
|
69
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ workspaces: [{ id: "x" }] }));
|
|
70
|
+
await expect(client.listWorkspaces()).rejects.toThrow();
|
|
71
|
+
});
|
|
72
|
+
it("rejects with a typed ShepherdHttpError carrying the status on non-2xx", async () => {
|
|
73
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "nope" }, { status: 401, statusText: "Unauthorized" }));
|
|
74
|
+
await expect(client.listWorkspaces()).rejects.toBeInstanceOf(ShepherdHttpError);
|
|
75
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "nope" }, { status: 500, statusText: "Server Error" }));
|
|
76
|
+
await expect(client.listWorkspaces()).rejects.toMatchObject({ status: 500 });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("createWorkspace", () => {
|
|
80
|
+
it("POSTs /workspaces with a JSON body and returns the WorkspaceSummary", async () => {
|
|
81
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(WORKSPACE_SUMMARY));
|
|
82
|
+
const out = await client.createWorkspace({ name: "Acme" });
|
|
83
|
+
const [url, init] = lastCall();
|
|
84
|
+
expect(url).toBe(`${BASE}/workspaces`);
|
|
85
|
+
expect(init.method).toBe("POST");
|
|
86
|
+
expect(header(init, "Content-Type")).toBe("application/json");
|
|
87
|
+
expect(JSON.parse(init.body)).toEqual({ name: "Acme" });
|
|
88
|
+
expect(out).toEqual(WORKSPACE_SUMMARY);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("mintToken", () => {
|
|
92
|
+
it("POSTs /workspaces/:id/tokens and returns the MintTokenResponse", async () => {
|
|
93
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ token: "shp_abc", id: "tok_1" }));
|
|
94
|
+
const out = await client.mintToken("ws_1", { name: "ci" });
|
|
95
|
+
const [url, init] = lastCall();
|
|
96
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/tokens`);
|
|
97
|
+
expect(init.method).toBe("POST");
|
|
98
|
+
expect(JSON.parse(init.body)).toEqual({ name: "ci" });
|
|
99
|
+
expect(out).toEqual({ token: "shp_abc", id: "tok_1" });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe("listTokens", () => {
|
|
103
|
+
it("GETs /workspaces/:id/tokens and returns the ListTokensResponse", async () => {
|
|
104
|
+
const token = {
|
|
105
|
+
id: "tok_1",
|
|
106
|
+
name: "ci",
|
|
107
|
+
lastUsedAt: null,
|
|
108
|
+
createdAt: "2026-06-29T00:00:00.000Z",
|
|
109
|
+
revokedAt: null,
|
|
110
|
+
};
|
|
111
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ tokens: [token] }));
|
|
112
|
+
const out = await client.listTokens("ws_1");
|
|
113
|
+
const [url, init] = lastCall();
|
|
114
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/tokens`);
|
|
115
|
+
expect(init.method).toBe("GET");
|
|
116
|
+
expect(out).toEqual({ tokens: [token] });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe("revokeToken", () => {
|
|
120
|
+
it("DELETEs /workspaces/:id/tokens/:tokenId (no schema validation)", async () => {
|
|
121
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
122
|
+
await client.revokeToken("ws_1", "tok_1");
|
|
123
|
+
const [url, init] = lastCall();
|
|
124
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/tokens/tok_1`);
|
|
125
|
+
expect(init.method).toBe("DELETE");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("createInvite", () => {
|
|
129
|
+
it("POSTs /workspaces/:id/invites and returns the InviteResponse", async () => {
|
|
130
|
+
const invite = { code: "inv_abc", expiresAt: null, maxUses: 5, useCount: 0 };
|
|
131
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(invite));
|
|
132
|
+
const out = await client.createInvite("ws_1", { maxUses: 5 });
|
|
133
|
+
const [url, init] = lastCall();
|
|
134
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/invites`);
|
|
135
|
+
expect(init.method).toBe("POST");
|
|
136
|
+
expect(JSON.parse(init.body)).toEqual({ maxUses: 5 });
|
|
137
|
+
expect(out).toEqual(invite);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe("revokeInvite", () => {
|
|
141
|
+
it("POSTs /workspaces/:id/invites/:code/revoke (encodes the code)", async () => {
|
|
142
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
143
|
+
await client.revokeInvite("ws_1", "a/b code");
|
|
144
|
+
const [url, init] = lastCall();
|
|
145
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/invites/a%2Fb%20code/revoke`);
|
|
146
|
+
expect(init.method).toBe("POST");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe("redeemInvite", () => {
|
|
150
|
+
it("POSTs /invites/:code/redeem and returns the RedeemInviteResponse", async () => {
|
|
151
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ workspace: WORKSPACE_SUMMARY }));
|
|
152
|
+
const out = await client.redeemInvite("inv_abc");
|
|
153
|
+
const [url, init] = lastCall();
|
|
154
|
+
expect(url).toBe(`${BASE}/invites/inv_abc/redeem`);
|
|
155
|
+
expect(init.method).toBe("POST");
|
|
156
|
+
expect(out).toEqual({ workspace: WORKSPACE_SUMMARY });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe("listMembers", () => {
|
|
160
|
+
it("GETs /workspaces/:id/members and returns the ListMembersResponse", async () => {
|
|
161
|
+
const member = {
|
|
162
|
+
accountId: "acc_1",
|
|
163
|
+
displayName: "Dana",
|
|
164
|
+
githubLogin: "dana",
|
|
165
|
+
avatarUrl: null,
|
|
166
|
+
role: "member",
|
|
167
|
+
};
|
|
168
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ members: [member] }));
|
|
169
|
+
const out = await client.listMembers("ws_1");
|
|
170
|
+
const [url, init] = lastCall();
|
|
171
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/members`);
|
|
172
|
+
expect(init.method).toBe("GET");
|
|
173
|
+
expect(out).toEqual({ members: [member] });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
describe("removeMember", () => {
|
|
177
|
+
it("DELETEs /workspaces/:id/members/:accountId (encodes the accountId)", async () => {
|
|
178
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
179
|
+
await client.removeMember("ws_1", "acc/1");
|
|
180
|
+
const [url, init] = lastCall();
|
|
181
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/members/acc%2F1`);
|
|
182
|
+
expect(init.method).toBe("DELETE");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("leave", () => {
|
|
186
|
+
it("POSTs /workspaces/:id/leave", async () => {
|
|
187
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
188
|
+
await client.leave("ws_1");
|
|
189
|
+
const [url, init] = lastCall();
|
|
190
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/leave`);
|
|
191
|
+
expect(init.method).toBe("POST");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("landscape", () => {
|
|
195
|
+
it("GETs /workspaces/:id/landscape and returns the WorkspaceLandscapeResponse", async () => {
|
|
196
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(LANDSCAPE));
|
|
197
|
+
const out = await client.landscape("ws_1");
|
|
198
|
+
const [url, init] = lastCall();
|
|
199
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/landscape`);
|
|
200
|
+
expect(init.method).toBe("GET");
|
|
201
|
+
expect(out).toEqual(LANDSCAPE);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe("announce", () => {
|
|
205
|
+
it("POSTs /workspaces/:id/announce and returns the WorkspaceAnnounceResponse", async () => {
|
|
206
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, announcementIds: [1, 2] }));
|
|
207
|
+
const out = await client.announce("ws_1", { body: "hello", repo: "r" });
|
|
208
|
+
const [url, init] = lastCall();
|
|
209
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/announce`);
|
|
210
|
+
expect(init.method).toBe("POST");
|
|
211
|
+
expect(header(init, "Content-Type")).toBe("application/json");
|
|
212
|
+
expect(JSON.parse(init.body)).toEqual({ body: "hello", repo: "r" });
|
|
213
|
+
expect(out).toEqual({ ok: true, announcementIds: [1, 2] });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
// --- encodeURIComponent on path params ----------------------------------
|
|
217
|
+
describe("path-param encoding", () => {
|
|
218
|
+
it("encodes the workspaceId segment", async () => {
|
|
219
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(LANDSCAPE));
|
|
220
|
+
await client.landscape("ws/1 special");
|
|
221
|
+
const [url] = lastCall();
|
|
222
|
+
expect(url).toBe(`${BASE}/workspaces/ws%2F1%20special/landscape`);
|
|
223
|
+
});
|
|
224
|
+
it("encodes a tokenId with a special char", async () => {
|
|
225
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
|
226
|
+
await client.revokeToken("ws_1", "tok/1");
|
|
227
|
+
const [url] = lastCall();
|
|
228
|
+
expect(url).toBe(`${BASE}/workspaces/ws_1/tokens/tok%2F1`);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// --- baseUrl joining --------------------------------------------------------
|
|
233
|
+
describe("baseUrl joining", () => {
|
|
234
|
+
it("does not double-slash when baseUrl has a trailing slash", async () => {
|
|
235
|
+
const fm = vi.fn().mockResolvedValueOnce(jsonResponse({ workspaces: [] }));
|
|
236
|
+
vi.stubGlobal("fetch", fm);
|
|
237
|
+
const client = createShepherdClient({ baseUrl: `${BASE}/` });
|
|
238
|
+
await client.listWorkspaces();
|
|
239
|
+
expect(fm.mock.calls[0][0]).toBe(`${BASE}/workspaces`);
|
|
240
|
+
vi.unstubAllGlobals();
|
|
241
|
+
});
|
|
242
|
+
it("supports same-origin ('') baseUrl producing a root-relative path", async () => {
|
|
243
|
+
const fm = vi.fn().mockResolvedValueOnce(jsonResponse({ workspaces: [] }));
|
|
244
|
+
vi.stubGlobal("fetch", fm);
|
|
245
|
+
const client = createShepherdClient({ baseUrl: "" });
|
|
246
|
+
await client.listWorkspaces();
|
|
247
|
+
expect(fm.mock.calls[0][0]).toBe("/workspaces");
|
|
248
|
+
vi.unstubAllGlobals();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
// --- getAuthHeader resolution: all three return shapes, sync + async --------
|
|
252
|
+
describe("getAuthHeader injection", () => {
|
|
253
|
+
beforeEach(() => {
|
|
254
|
+
fetchMock = vi.fn().mockResolvedValue(jsonResponse({ workspaces: [] }));
|
|
255
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
256
|
+
});
|
|
257
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
258
|
+
it("merges a Bearer string into the Authorization header (sync)", async () => {
|
|
259
|
+
const client = createShepherdClient({
|
|
260
|
+
baseUrl: BASE,
|
|
261
|
+
getAuthHeader: () => "Bearer team_tok",
|
|
262
|
+
});
|
|
263
|
+
await client.listWorkspaces();
|
|
264
|
+
const [, init] = lastCall();
|
|
265
|
+
expect(header(init, "Authorization")).toBe("Bearer team_tok");
|
|
266
|
+
});
|
|
267
|
+
it("merges a Bearer string returned asynchronously", async () => {
|
|
268
|
+
const client = createShepherdClient({
|
|
269
|
+
baseUrl: BASE,
|
|
270
|
+
getAuthHeader: async () => "Bearer async_tok",
|
|
271
|
+
});
|
|
272
|
+
await client.listWorkspaces();
|
|
273
|
+
const [, init] = lastCall();
|
|
274
|
+
expect(header(init, "Authorization")).toBe("Bearer async_tok");
|
|
275
|
+
});
|
|
276
|
+
it("merges a header-map return", async () => {
|
|
277
|
+
const client = createShepherdClient({
|
|
278
|
+
baseUrl: BASE,
|
|
279
|
+
getAuthHeader: () => ({ Authorization: "Bearer map_tok", "X-Extra": "1" }),
|
|
280
|
+
});
|
|
281
|
+
await client.listWorkspaces();
|
|
282
|
+
const [, init] = lastCall();
|
|
283
|
+
expect(header(init, "Authorization")).toBe("Bearer map_tok");
|
|
284
|
+
expect(header(init, "X-Extra")).toBe("1");
|
|
285
|
+
});
|
|
286
|
+
it("sends no auth header when getAuthHeader is omitted (same-origin BFF)", async () => {
|
|
287
|
+
const client = createShepherdClient({ baseUrl: BASE });
|
|
288
|
+
await client.listWorkspaces();
|
|
289
|
+
const [, init] = lastCall();
|
|
290
|
+
expect(header(init, "Authorization")).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
it("sends no auth header when getAuthHeader resolves to undefined", async () => {
|
|
293
|
+
const client = createShepherdClient({ baseUrl: BASE, getAuthHeader: () => undefined });
|
|
294
|
+
await client.listWorkspaces();
|
|
295
|
+
const [, init] = lastCall();
|
|
296
|
+
expect(header(init, "Authorization")).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
});
|