swarmkit 0.0.1 → 0.0.3

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +130 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +33 -0
  5. package/dist/commands/add.d.ts +2 -0
  6. package/dist/commands/add.js +98 -0
  7. package/dist/commands/doctor.d.ts +2 -0
  8. package/dist/commands/doctor.js +100 -0
  9. package/dist/commands/hive.d.ts +2 -0
  10. package/dist/commands/hive.js +248 -0
  11. package/dist/commands/init/phases/configure.d.ts +2 -0
  12. package/dist/commands/init/phases/configure.js +85 -0
  13. package/dist/commands/init/phases/global-setup.d.ts +2 -0
  14. package/dist/commands/init/phases/global-setup.js +81 -0
  15. package/dist/commands/init/phases/packages.d.ts +2 -0
  16. package/dist/commands/init/phases/packages.js +30 -0
  17. package/dist/commands/init/phases/project.d.ts +2 -0
  18. package/dist/commands/init/phases/project.js +56 -0
  19. package/dist/commands/init/phases/use-case.d.ts +2 -0
  20. package/dist/commands/init/phases/use-case.js +41 -0
  21. package/dist/commands/init/state.d.ts +13 -0
  22. package/dist/commands/init/state.js +9 -0
  23. package/dist/commands/init/state.test.d.ts +1 -0
  24. package/dist/commands/init/state.test.js +21 -0
  25. package/dist/commands/init/wizard.d.ts +4 -0
  26. package/dist/commands/init/wizard.js +108 -0
  27. package/dist/commands/init.d.ts +2 -0
  28. package/dist/commands/init.js +11 -0
  29. package/dist/commands/login.d.ts +2 -0
  30. package/dist/commands/login.js +91 -0
  31. package/dist/commands/logout.d.ts +2 -0
  32. package/dist/commands/logout.js +19 -0
  33. package/dist/commands/remove.d.ts +2 -0
  34. package/dist/commands/remove.js +55 -0
  35. package/dist/commands/status.d.ts +2 -0
  36. package/dist/commands/status.js +87 -0
  37. package/dist/commands/update.d.ts +2 -0
  38. package/dist/commands/update.js +54 -0
  39. package/dist/commands/whoami.d.ts +2 -0
  40. package/dist/commands/whoami.js +40 -0
  41. package/dist/config/global.d.ts +26 -0
  42. package/dist/config/global.js +71 -0
  43. package/dist/config/global.test.d.ts +1 -0
  44. package/dist/config/global.test.js +167 -0
  45. package/dist/config/keys.d.ts +10 -0
  46. package/dist/config/keys.js +47 -0
  47. package/dist/config/keys.test.d.ts +1 -0
  48. package/dist/config/keys.test.js +87 -0
  49. package/dist/doctor/checks.d.ts +31 -0
  50. package/dist/doctor/checks.js +226 -0
  51. package/dist/doctor/checks.test.d.ts +1 -0
  52. package/dist/doctor/checks.test.js +301 -0
  53. package/dist/doctor/types.d.ts +29 -0
  54. package/dist/doctor/types.js +1 -0
  55. package/dist/hub/auth-flow.d.ts +16 -0
  56. package/dist/hub/auth-flow.js +118 -0
  57. package/dist/hub/auth-flow.test.d.ts +1 -0
  58. package/dist/hub/auth-flow.test.js +98 -0
  59. package/dist/hub/client.d.ts +51 -0
  60. package/dist/hub/client.js +107 -0
  61. package/dist/hub/client.test.d.ts +1 -0
  62. package/dist/hub/client.test.js +177 -0
  63. package/dist/hub/credentials.d.ts +14 -0
  64. package/dist/hub/credentials.js +41 -0
  65. package/dist/hub/credentials.test.d.ts +1 -0
  66. package/dist/hub/credentials.test.js +102 -0
  67. package/dist/index.d.ts +17 -1
  68. package/dist/index.js +10 -2
  69. package/dist/packages/installer.d.ts +42 -0
  70. package/dist/packages/installer.js +158 -0
  71. package/dist/packages/installer.test.d.ts +1 -0
  72. package/dist/packages/installer.test.js +283 -0
  73. package/dist/packages/plugin.d.ts +13 -0
  74. package/dist/packages/plugin.js +33 -0
  75. package/dist/packages/plugin.test.d.ts +1 -0
  76. package/dist/packages/plugin.test.js +99 -0
  77. package/dist/packages/registry.d.ts +37 -0
  78. package/dist/packages/registry.js +154 -0
  79. package/dist/packages/registry.test.d.ts +1 -0
  80. package/dist/packages/registry.test.js +188 -0
  81. package/dist/packages/setup.d.ts +55 -0
  82. package/dist/packages/setup.js +414 -0
  83. package/dist/packages/setup.test.d.ts +1 -0
  84. package/dist/packages/setup.test.js +808 -0
  85. package/dist/utils/ui.d.ts +10 -0
  86. package/dist/utils/ui.js +47 -0
  87. package/dist/utils/ui.test.d.ts +1 -0
  88. package/dist/utils/ui.test.js +102 -0
  89. package/package.json +29 -6
@@ -0,0 +1,118 @@
1
+ import { createServer } from "node:http";
2
+ import { URL } from "node:url";
3
+ import { exchangeAuthCode, getHubUrl } from "./client.js";
4
+ import { writeCredentials } from "./credentials.js";
5
+ const CALLBACK_PORT_START = 9876;
6
+ const CALLBACK_PORT_END = 9886;
7
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
8
+ /**
9
+ * Run the login flow on a given port:
10
+ * 1. Start a local HTTP server to receive the OAuth callback
11
+ * 2. Wait for the auth code callback
12
+ * 3. Exchange the code for a JWT
13
+ * 4. Store credentials locally
14
+ */
15
+ export async function runLoginFlow(port) {
16
+ const { code, shutdown } = await waitForCallback(port);
17
+ try {
18
+ const token = await exchangeAuthCode(code);
19
+ const hubUrl = getHubUrl();
20
+ writeCredentials({ token, apiUrl: hubUrl });
21
+ return { token, apiUrl: hubUrl };
22
+ }
23
+ finally {
24
+ await shutdown();
25
+ }
26
+ }
27
+ /** Build the URL the user should open in their browser */
28
+ export function getLoginUrl(callbackPort) {
29
+ const callbackUrl = `http://localhost:${callbackPort}/callback`;
30
+ const hubBase = getHubUrl();
31
+ return `${hubBase}/auth/cli?return_to=${encodeURIComponent(callbackUrl)}`;
32
+ }
33
+ /** Find an available port in our range */
34
+ export async function findAvailablePort() {
35
+ for (let port = CALLBACK_PORT_START; port <= CALLBACK_PORT_END; port++) {
36
+ const available = await isPortAvailable(port);
37
+ if (available)
38
+ return port;
39
+ }
40
+ throw new Error(`No available port found in range ${CALLBACK_PORT_START}-${CALLBACK_PORT_END}`);
41
+ }
42
+ function isPortAvailable(port) {
43
+ return new Promise((resolve) => {
44
+ const server = createServer();
45
+ server.once("error", () => resolve(false));
46
+ server.once("listening", () => {
47
+ server.close(() => resolve(true));
48
+ });
49
+ server.listen(port, "127.0.0.1");
50
+ });
51
+ }
52
+ /** Start a local server and wait for the OAuth callback with an auth code */
53
+ function waitForCallback(port) {
54
+ return new Promise((resolve, reject) => {
55
+ const server = createServer((req, res) => {
56
+ if (!req.url?.startsWith("/callback")) {
57
+ res.writeHead(404);
58
+ res.end("Not found");
59
+ return;
60
+ }
61
+ const url = new URL(req.url, `http://localhost:${port}`);
62
+ const code = url.searchParams.get("code");
63
+ const error = url.searchParams.get("error");
64
+ if (error) {
65
+ res.writeHead(200, { "Content-Type": "text/html" });
66
+ res.end(errorPage(error));
67
+ clearTimeout(timer);
68
+ reject(new Error(`Authentication failed: ${error}`));
69
+ return;
70
+ }
71
+ if (!code) {
72
+ res.writeHead(400, { "Content-Type": "text/html" });
73
+ res.end(errorPage("No authorization code received"));
74
+ return;
75
+ }
76
+ res.writeHead(200, { "Content-Type": "text/html" });
77
+ res.end(successPage());
78
+ clearTimeout(timer);
79
+ resolve({
80
+ code,
81
+ shutdown: () => new Promise((res) => server.close(() => res())),
82
+ });
83
+ });
84
+ server.listen(port, "127.0.0.1");
85
+ const timer = setTimeout(() => {
86
+ server.close();
87
+ reject(new Error("Login timed out — no callback received within 5 minutes"));
88
+ }, CALLBACK_TIMEOUT_MS);
89
+ });
90
+ }
91
+ function successPage() {
92
+ return `<!DOCTYPE html>
93
+ <html>
94
+ <head><title>swarmkit — logged in</title></head>
95
+ <body style="font-family: system-ui, sans-serif; text-align: center; padding: 60px 20px;">
96
+ <h1>Logged in to SwarmHub</h1>
97
+ <p>You can close this window and return to your terminal.</p>
98
+ </body>
99
+ </html>`;
100
+ }
101
+ function errorPage(message) {
102
+ return `<!DOCTYPE html>
103
+ <html>
104
+ <head><title>swarmkit — login failed</title></head>
105
+ <body style="font-family: system-ui, sans-serif; text-align: center; padding: 60px 20px;">
106
+ <h1>Login Failed</h1>
107
+ <p>${escapeHtml(message)}</p>
108
+ <p>Please close this window and try again.</p>
109
+ </body>
110
+ </html>`;
111
+ }
112
+ function escapeHtml(str) {
113
+ return str
114
+ .replace(/&/g, "&amp;")
115
+ .replace(/</g, "&lt;")
116
+ .replace(/>/g, "&gt;")
117
+ .replace(/"/g, "&quot;");
118
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import http from "node:http";
7
+ // Mock homedir
8
+ let testDir;
9
+ vi.mock("node:os", async () => {
10
+ const actual = await import("node:os");
11
+ return {
12
+ ...actual,
13
+ homedir: () => testDir,
14
+ };
15
+ });
16
+ const { findAvailablePort, getLoginUrl, runLoginFlow } = await import("./auth-flow.js");
17
+ beforeEach(() => {
18
+ testDir = join(tmpdir(), `swarmkit-auth-test-${randomUUID()}`);
19
+ mkdirSync(testDir, { recursive: true });
20
+ });
21
+ afterEach(() => {
22
+ rmSync(testDir, { recursive: true, force: true });
23
+ vi.restoreAllMocks();
24
+ });
25
+ /** Make an HTTP request without using fetch (to avoid mock interference) */
26
+ function httpGet(url) {
27
+ return new Promise((resolve, reject) => {
28
+ http.get(url, (res) => {
29
+ let body = "";
30
+ res.on("data", (chunk) => (body += chunk));
31
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
32
+ }).on("error", reject);
33
+ });
34
+ }
35
+ describe("findAvailablePort", () => {
36
+ it("returns a port number", async () => {
37
+ const port = await findAvailablePort();
38
+ expect(port).toBeGreaterThanOrEqual(9876);
39
+ expect(port).toBeLessThanOrEqual(9886);
40
+ });
41
+ });
42
+ describe("getLoginUrl", () => {
43
+ it("includes the callback URL as return_to", () => {
44
+ const url = getLoginUrl(9876);
45
+ expect(url).toContain("/auth/cli");
46
+ expect(url).toContain("return_to=");
47
+ expect(url).toContain(encodeURIComponent("http://localhost:9876/callback"));
48
+ });
49
+ });
50
+ describe("runLoginFlow", () => {
51
+ it("exchanges code received via callback", async () => {
52
+ // Mock the code exchange API call (called by runLoginFlow after receiving code)
53
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
54
+ ok: true,
55
+ status: 200,
56
+ json: async () => ({ token: "jwt-from-exchange" }),
57
+ headers: new Headers(),
58
+ });
59
+ const port = await findAvailablePort();
60
+ // Start the login flow (it will wait for a callback)
61
+ const loginPromise = runLoginFlow(port);
62
+ // Give the server a moment to start
63
+ await new Promise((resolve) => setTimeout(resolve, 50));
64
+ // Use http.get to hit local server (avoids mocked fetch)
65
+ const res = await httpGet(`http://localhost:${port}/callback?code=test-auth-code`);
66
+ expect(res.status).toBe(200);
67
+ const result = await loginPromise;
68
+ expect(result.token).toBe("jwt-from-exchange");
69
+ expect(result.apiUrl).toBe("https://hub.swarmkit.ai");
70
+ });
71
+ it("rejects on error callback", async () => {
72
+ const port = await findAvailablePort();
73
+ const loginPromise = runLoginFlow(port);
74
+ // Attach a no-op catch so Node doesn't flag the rejection as unhandled
75
+ // before our expect() call processes it
76
+ loginPromise.catch(() => { });
77
+ await new Promise((resolve) => setTimeout(resolve, 50));
78
+ await httpGet(`http://localhost:${port}/callback?error=access_denied`);
79
+ await expect(loginPromise).rejects.toThrow("access_denied");
80
+ });
81
+ it("returns 404 for non-callback paths", async () => {
82
+ // Mock fetch for the code exchange that will happen when we clean up
83
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
84
+ ok: true,
85
+ status: 200,
86
+ json: async () => ({ token: "cleanup" }),
87
+ headers: new Headers(),
88
+ });
89
+ const port = await findAvailablePort();
90
+ const loginPromise = runLoginFlow(port);
91
+ await new Promise((resolve) => setTimeout(resolve, 50));
92
+ const res = await httpGet(`http://localhost:${port}/other`);
93
+ expect(res.status).toBe(404);
94
+ // Clean up — send a valid callback to resolve the promise
95
+ await httpGet(`http://localhost:${port}/callback?code=cleanup`);
96
+ await loginPromise;
97
+ });
98
+ });
@@ -0,0 +1,51 @@
1
+ export interface HubUser {
2
+ id: string;
3
+ email: string;
4
+ name: string;
5
+ avatar_url: string | null;
6
+ bio: string | null;
7
+ }
8
+ export interface Hive {
9
+ id: string;
10
+ name: string;
11
+ slug: string;
12
+ owner_type: "user" | "organization";
13
+ owner_id: string;
14
+ status: HiveStatus;
15
+ tier: HiveTier;
16
+ endpoint_url: string | null;
17
+ image_version: string;
18
+ health_status: "healthy" | "unhealthy" | "unknown";
19
+ created_at: string;
20
+ updated_at: string;
21
+ }
22
+ export type HiveStatus = "provisioning" | "running" | "stopped" | "suspended" | "waking" | "error" | "destroyed" | "destroy_failed";
23
+ export type HiveTier = "free" | "starter" | "pro" | "enterprise";
24
+ export interface CreateHiveOptions {
25
+ name: string;
26
+ tier?: HiveTier;
27
+ }
28
+ export declare class HubApiError extends Error {
29
+ readonly statusCode: number;
30
+ constructor(statusCode: number, message: string);
31
+ }
32
+ /** Get the SwarmHub base URL (serves both API at /v1 and frontend) */
33
+ export declare function getHubUrl(): string;
34
+ /** Exchange a temporary auth code for a JWT token */
35
+ export declare function exchangeAuthCode(code: string): Promise<string>;
36
+ /** Refresh an existing JWT token */
37
+ export declare function refreshToken(): Promise<string>;
38
+ /** Get the current authenticated user */
39
+ export declare function getMe(): Promise<HubUser>;
40
+ /** Create a new hive */
41
+ export declare function createHive(options: CreateHiveOptions): Promise<Hive>;
42
+ /** List all hives for the authenticated user */
43
+ export declare function listHives(): Promise<Hive[]>;
44
+ /** Get a single hive by slug */
45
+ export declare function getHive(slug: string): Promise<Hive>;
46
+ /** Start a stopped hive */
47
+ export declare function startHive(slug: string): Promise<Hive>;
48
+ /** Stop a running hive */
49
+ export declare function stopHive(slug: string): Promise<Hive>;
50
+ /** Destroy a hive permanently */
51
+ export declare function destroyHive(slug: string): Promise<Hive>;
@@ -0,0 +1,107 @@
1
+ import { readCredentials } from "./credentials.js";
2
+ const DEFAULT_HUB_URL = "https://hub.swarmkit.ai";
3
+ export class HubApiError extends Error {
4
+ statusCode;
5
+ constructor(statusCode, message) {
6
+ super(message);
7
+ this.statusCode = statusCode;
8
+ this.name = "HubApiError";
9
+ }
10
+ }
11
+ /** Get the SwarmHub base URL (serves both API at /v1 and frontend) */
12
+ export function getHubUrl() {
13
+ return process.env.SWARMKIT_HUB_URL ?? readCredentials()?.apiUrl ?? DEFAULT_HUB_URL;
14
+ }
15
+ async function request(path, options = {}) {
16
+ const { method = "GET", body, token } = options;
17
+ const url = `${getHubUrl()}${path}`;
18
+ const headers = {
19
+ "Accept": "application/json",
20
+ };
21
+ if (token) {
22
+ headers["Authorization"] = `Bearer ${token}`;
23
+ }
24
+ else {
25
+ const creds = readCredentials();
26
+ if (creds?.token) {
27
+ headers["Authorization"] = `Bearer ${creds.token}`;
28
+ }
29
+ }
30
+ if (body !== undefined) {
31
+ headers["Content-Type"] = "application/json";
32
+ }
33
+ const response = await fetch(url, {
34
+ method,
35
+ headers,
36
+ body: body !== undefined ? JSON.stringify(body) : undefined,
37
+ });
38
+ if (!response.ok) {
39
+ let message;
40
+ try {
41
+ const err = (await response.json());
42
+ message = err.error ?? response.statusText;
43
+ }
44
+ catch {
45
+ message = response.statusText;
46
+ }
47
+ throw new HubApiError(response.status, message);
48
+ }
49
+ // Handle 204 No Content
50
+ if (response.status === 204) {
51
+ return undefined;
52
+ }
53
+ return (await response.json());
54
+ }
55
+ // ─── Auth ────────────────────────────────────────────────────────────────────
56
+ /** Exchange a temporary auth code for a JWT token */
57
+ export async function exchangeAuthCode(code) {
58
+ const result = await request("/v1/auth/code/exchange", {
59
+ method: "POST",
60
+ body: { code },
61
+ });
62
+ return result.token;
63
+ }
64
+ /** Refresh an existing JWT token */
65
+ export async function refreshToken() {
66
+ const result = await request("/v1/auth/refresh", {
67
+ method: "POST",
68
+ });
69
+ return result.token;
70
+ }
71
+ // ─── User ────────────────────────────────────────────────────────────────────
72
+ /** Get the current authenticated user */
73
+ export async function getMe() {
74
+ return request("/v1/users/me");
75
+ }
76
+ // ─── Hives ───────────────────────────────────────────────────────────────────
77
+ /** Create a new hive */
78
+ export async function createHive(options) {
79
+ return request("/v1/hives", {
80
+ method: "POST",
81
+ body: {
82
+ name: options.name,
83
+ owner_type: "user",
84
+ tier: options.tier ?? "free",
85
+ },
86
+ });
87
+ }
88
+ /** List all hives for the authenticated user */
89
+ export async function listHives() {
90
+ return request("/v1/hives");
91
+ }
92
+ /** Get a single hive by slug */
93
+ export async function getHive(slug) {
94
+ return request(`/v1/hives/${encodeURIComponent(slug)}`);
95
+ }
96
+ /** Start a stopped hive */
97
+ export async function startHive(slug) {
98
+ return request(`/v1/hives/${encodeURIComponent(slug)}/start`, { method: "POST" });
99
+ }
100
+ /** Stop a running hive */
101
+ export async function stopHive(slug) {
102
+ return request(`/v1/hives/${encodeURIComponent(slug)}/stop`, { method: "POST" });
103
+ }
104
+ /** Destroy a hive permanently */
105
+ export async function destroyHive(slug) {
106
+ return request(`/v1/hives/${encodeURIComponent(slug)}`, { method: "DELETE" });
107
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ // Mock homedir so credentials go to temp dir
7
+ let testDir;
8
+ vi.mock("node:os", async () => {
9
+ const actual = await import("node:os");
10
+ return {
11
+ ...actual,
12
+ homedir: () => testDir,
13
+ };
14
+ });
15
+ const { exchangeAuthCode, getMe, createHive, listHives, getHive, startHive, stopHive, destroyHive, getHubUrl, HubApiError, } = await import("./client.js");
16
+ const { writeCredentials } = await import("./credentials.js");
17
+ beforeEach(() => {
18
+ testDir = join(tmpdir(), `swarmkit-client-test-${randomUUID()}`);
19
+ mkdirSync(testDir, { recursive: true });
20
+ });
21
+ afterEach(() => {
22
+ rmSync(testDir, { recursive: true, force: true });
23
+ delete process.env.SWARMKIT_HUB_URL;
24
+ vi.restoreAllMocks();
25
+ });
26
+ function mockFetch(response, status = 200) {
27
+ return vi.spyOn(globalThis, "fetch").mockResolvedValue({
28
+ ok: status >= 200 && status < 300,
29
+ status,
30
+ statusText: status === 200 ? "OK" : "Error",
31
+ json: async () => response,
32
+ headers: new Headers(),
33
+ });
34
+ }
35
+ describe("getHubUrl", () => {
36
+ it("returns default when no credentials", () => {
37
+ expect(getHubUrl()).toBe("https://hub.swarmkit.ai");
38
+ });
39
+ it("returns stored url when logged in", () => {
40
+ writeCredentials({ token: "t", apiUrl: "https://hub.custom.com" });
41
+ expect(getHubUrl()).toBe("https://hub.custom.com");
42
+ });
43
+ it("prefers SWARMKIT_HUB_URL env var", () => {
44
+ process.env.SWARMKIT_HUB_URL = "http://localhost:4000";
45
+ writeCredentials({ token: "t", apiUrl: "https://hub.custom.com" });
46
+ expect(getHubUrl()).toBe("http://localhost:4000");
47
+ delete process.env.SWARMKIT_HUB_URL;
48
+ });
49
+ });
50
+ describe("exchangeAuthCode", () => {
51
+ it("sends code and returns token", async () => {
52
+ const fetchSpy = mockFetch({ token: "jwt-123" });
53
+ const token = await exchangeAuthCode("auth-code-abc");
54
+ expect(token).toBe("jwt-123");
55
+ expect(fetchSpy).toHaveBeenCalledWith("https://hub.swarmkit.ai/v1/auth/code/exchange", expect.objectContaining({
56
+ method: "POST",
57
+ body: JSON.stringify({ code: "auth-code-abc" }),
58
+ }));
59
+ });
60
+ it("throws HubApiError on failure", async () => {
61
+ mockFetch({ error: "Invalid code" }, 400);
62
+ await expect(exchangeAuthCode("bad")).rejects.toThrow(HubApiError);
63
+ await expect(exchangeAuthCode("bad")).rejects.toThrow("Invalid code");
64
+ });
65
+ });
66
+ describe("getMe", () => {
67
+ it("returns user profile", async () => {
68
+ writeCredentials({ token: "my-jwt", apiUrl: "https://hub.swarmkit.ai" });
69
+ const user = {
70
+ id: "usr_123",
71
+ email: "test@example.com",
72
+ name: "testuser",
73
+ avatar_url: null,
74
+ bio: null,
75
+ };
76
+ const fetchSpy = mockFetch(user);
77
+ const result = await getMe();
78
+ expect(result).toEqual(user);
79
+ // Check auth header was sent
80
+ const headers = fetchSpy.mock.calls[0][1]?.headers;
81
+ expect(headers["Authorization"]).toBe("Bearer my-jwt");
82
+ });
83
+ it("throws 401 when not authenticated", async () => {
84
+ mockFetch({ error: "Unauthorized" }, 401);
85
+ try {
86
+ await getMe();
87
+ expect.unreachable();
88
+ }
89
+ catch (err) {
90
+ expect(err).toBeInstanceOf(HubApiError);
91
+ expect(err.statusCode).toBe(401);
92
+ }
93
+ });
94
+ });
95
+ describe("hive operations", () => {
96
+ beforeEach(() => {
97
+ writeCredentials({ token: "jwt", apiUrl: "https://hub.swarmkit.ai" });
98
+ });
99
+ const sampleHive = {
100
+ id: "hive_abc",
101
+ name: "My Hive",
102
+ slug: "my-hive",
103
+ owner_type: "user",
104
+ owner_id: "usr_123",
105
+ status: "running",
106
+ tier: "free",
107
+ endpoint_url: "https://my-hive.swarmkit.ai",
108
+ image_version: "latest",
109
+ health_status: "healthy",
110
+ created_at: "2025-01-01T00:00:00Z",
111
+ updated_at: "2025-01-01T00:00:00Z",
112
+ };
113
+ it("createHive sends name and tier", async () => {
114
+ const fetchSpy = mockFetch({ ...sampleHive, status: "provisioning" }, 201);
115
+ const hive = await createHive({ name: "My Hive", tier: "starter" });
116
+ expect(hive.name).toBe("My Hive");
117
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body);
118
+ expect(body.name).toBe("My Hive");
119
+ expect(body.tier).toBe("starter");
120
+ });
121
+ it("listHives returns array", async () => {
122
+ mockFetch([sampleHive]);
123
+ const hives = await listHives();
124
+ expect(hives).toHaveLength(1);
125
+ expect(hives[0].slug).toBe("my-hive");
126
+ });
127
+ it("getHive returns single hive", async () => {
128
+ mockFetch(sampleHive);
129
+ const hive = await getHive("my-hive");
130
+ expect(hive.slug).toBe("my-hive");
131
+ });
132
+ it("getHive encodes slug", async () => {
133
+ const fetchSpy = mockFetch(sampleHive);
134
+ await getHive("my hive");
135
+ expect(fetchSpy.mock.calls[0][0]).toContain("my%20hive");
136
+ });
137
+ it("startHive sends POST", async () => {
138
+ const fetchSpy = mockFetch(sampleHive);
139
+ await startHive("my-hive");
140
+ expect(fetchSpy.mock.calls[0][1]?.method).toBe("POST");
141
+ expect(fetchSpy.mock.calls[0][0]).toContain("/my-hive/start");
142
+ });
143
+ it("stopHive sends POST", async () => {
144
+ const fetchSpy = mockFetch({ ...sampleHive, status: "stopped" });
145
+ const hive = await stopHive("my-hive");
146
+ expect(hive.status).toBe("stopped");
147
+ expect(fetchSpy.mock.calls[0][0]).toContain("/my-hive/stop");
148
+ });
149
+ it("destroyHive sends DELETE", async () => {
150
+ const fetchSpy = mockFetch({ ...sampleHive, status: "destroyed" });
151
+ const hive = await destroyHive("my-hive");
152
+ expect(hive.status).toBe("destroyed");
153
+ expect(fetchSpy.mock.calls[0][1]?.method).toBe("DELETE");
154
+ });
155
+ it("throws 404 for missing hive", async () => {
156
+ mockFetch({ error: "Not found" }, 404);
157
+ try {
158
+ await getHive("nonexistent");
159
+ expect.unreachable();
160
+ }
161
+ catch (err) {
162
+ expect(err).toBeInstanceOf(HubApiError);
163
+ expect(err.statusCode).toBe(404);
164
+ }
165
+ });
166
+ it("throws 403 for tier limit exceeded", async () => {
167
+ mockFetch({ error: "Hive limit reached for free tier" }, 403);
168
+ try {
169
+ await createHive({ name: "Another" });
170
+ expect.unreachable();
171
+ }
172
+ catch (err) {
173
+ expect(err).toBeInstanceOf(HubApiError);
174
+ expect(err.statusCode).toBe(403);
175
+ }
176
+ });
177
+ });
@@ -0,0 +1,14 @@
1
+ export interface HubCredentials {
2
+ /** JWT token from SwarmHub */
3
+ token: string;
4
+ /** SwarmHub API base URL that issued the token */
5
+ apiUrl: string;
6
+ }
7
+ /** Read stored SwarmHub credentials, or null if not logged in */
8
+ export declare function readCredentials(): HubCredentials | null;
9
+ /** Store SwarmHub credentials (written with 0600 permissions) */
10
+ export declare function writeCredentials(credentials: HubCredentials): void;
11
+ /** Remove stored SwarmHub credentials */
12
+ export declare function deleteCredentials(): void;
13
+ /** Check if the user is logged in to SwarmHub */
14
+ export declare function isLoggedIn(): boolean;
@@ -0,0 +1,41 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfigDir, ensureConfigDir } from "../config/global.js";
4
+ function getCredentialsPath() {
5
+ return join(getConfigDir(), "credentials.json");
6
+ }
7
+ /** Read stored SwarmHub credentials, or null if not logged in */
8
+ export function readCredentials() {
9
+ const path = getCredentialsPath();
10
+ if (!existsSync(path))
11
+ return null;
12
+ try {
13
+ const raw = readFileSync(path, "utf-8");
14
+ const parsed = JSON.parse(raw);
15
+ if (!parsed.token || !parsed.apiUrl)
16
+ return null;
17
+ return { token: parsed.token, apiUrl: parsed.apiUrl };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ /** Store SwarmHub credentials (written with 0600 permissions) */
24
+ export function writeCredentials(credentials) {
25
+ ensureConfigDir();
26
+ const path = getCredentialsPath();
27
+ writeFileSync(path, JSON.stringify(credentials, null, 2) + "\n", {
28
+ mode: 0o600,
29
+ });
30
+ }
31
+ /** Remove stored SwarmHub credentials */
32
+ export function deleteCredentials() {
33
+ const path = getCredentialsPath();
34
+ if (existsSync(path)) {
35
+ unlinkSync(path);
36
+ }
37
+ }
38
+ /** Check if the user is logged in to SwarmHub */
39
+ export function isLoggedIn() {
40
+ return readCredentials() !== null;
41
+ }
@@ -0,0 +1 @@
1
+ export {};