swarmkit 0.0.1 → 0.0.2
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/LICENSE +21 -0
- package/README.md +194 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +33 -0
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +55 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -0
- package/dist/commands/hive.d.ts +2 -0
- package/dist/commands/hive.js +248 -0
- package/dist/commands/init/phases/configure.d.ts +2 -0
- package/dist/commands/init/phases/configure.js +85 -0
- package/dist/commands/init/phases/global-setup.d.ts +2 -0
- package/dist/commands/init/phases/global-setup.js +81 -0
- package/dist/commands/init/phases/packages.d.ts +2 -0
- package/dist/commands/init/phases/packages.js +30 -0
- package/dist/commands/init/phases/project.d.ts +2 -0
- package/dist/commands/init/phases/project.js +54 -0
- package/dist/commands/init/phases/use-case.d.ts +2 -0
- package/dist/commands/init/phases/use-case.js +41 -0
- package/dist/commands/init/state.d.ts +11 -0
- package/dist/commands/init/state.js +8 -0
- package/dist/commands/init/state.test.d.ts +1 -0
- package/dist/commands/init/state.test.js +20 -0
- package/dist/commands/init/wizard.d.ts +1 -0
- package/dist/commands/init/wizard.js +56 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +10 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +91 -0
- package/dist/commands/logout.d.ts +2 -0
- package/dist/commands/logout.js +19 -0
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +49 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +87 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +54 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +40 -0
- package/dist/config/global.d.ts +24 -0
- package/dist/config/global.js +71 -0
- package/dist/config/global.test.d.ts +1 -0
- package/dist/config/global.test.js +167 -0
- package/dist/config/keys.d.ts +10 -0
- package/dist/config/keys.js +47 -0
- package/dist/config/keys.test.d.ts +1 -0
- package/dist/config/keys.test.js +87 -0
- package/dist/doctor/checks.d.ts +31 -0
- package/dist/doctor/checks.js +210 -0
- package/dist/doctor/checks.test.d.ts +1 -0
- package/dist/doctor/checks.test.js +276 -0
- package/dist/doctor/types.d.ts +29 -0
- package/dist/doctor/types.js +1 -0
- package/dist/hub/auth-flow.d.ts +16 -0
- package/dist/hub/auth-flow.js +118 -0
- package/dist/hub/auth-flow.test.d.ts +1 -0
- package/dist/hub/auth-flow.test.js +98 -0
- package/dist/hub/client.d.ts +51 -0
- package/dist/hub/client.js +107 -0
- package/dist/hub/client.test.d.ts +1 -0
- package/dist/hub/client.test.js +177 -0
- package/dist/hub/credentials.d.ts +14 -0
- package/dist/hub/credentials.js +41 -0
- package/dist/hub/credentials.test.d.ts +1 -0
- package/dist/hub/credentials.test.js +102 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +9 -2
- package/dist/packages/installer.d.ts +33 -0
- package/dist/packages/installer.js +127 -0
- package/dist/packages/installer.test.d.ts +1 -0
- package/dist/packages/installer.test.js +200 -0
- package/dist/packages/registry.d.ts +37 -0
- package/dist/packages/registry.js +179 -0
- package/dist/packages/registry.test.d.ts +1 -0
- package/dist/packages/registry.test.js +199 -0
- package/dist/packages/setup.d.ts +48 -0
- package/dist/packages/setup.js +309 -0
- package/dist/packages/setup.test.d.ts +1 -0
- package/dist/packages/setup.test.js +717 -0
- package/dist/utils/ui.d.ts +10 -0
- package/dist/utils/ui.js +47 -0
- package/dist/utils/ui.test.d.ts +1 -0
- package/dist/utils/ui.test.js +102 -0
- package/package.json +29 -6
|
@@ -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 {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, } 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 config writes go to a 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 { readCredentials, writeCredentials, deleteCredentials, isLoggedIn, } = await import("./credentials.js");
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
testDir = join(tmpdir(), `swarmkit-cred-test-${randomUUID()}`);
|
|
18
|
+
mkdirSync(testDir, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
describe("readCredentials", () => {
|
|
24
|
+
it("returns null when no credentials file exists", () => {
|
|
25
|
+
expect(readCredentials()).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it("returns credentials when file exists", () => {
|
|
28
|
+
writeCredentials({
|
|
29
|
+
token: "jwt-token-123",
|
|
30
|
+
apiUrl: "https://api.swarmkit.ai",
|
|
31
|
+
});
|
|
32
|
+
const creds = readCredentials();
|
|
33
|
+
expect(creds).toEqual({
|
|
34
|
+
token: "jwt-token-123",
|
|
35
|
+
apiUrl: "https://api.swarmkit.ai",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("returns null for malformed JSON", () => {
|
|
39
|
+
const dir = join(testDir, ".swarmkit");
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeFileSync(join(dir, "credentials.json"), "not json");
|
|
42
|
+
expect(readCredentials()).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it("returns null when token is missing", () => {
|
|
45
|
+
const dir = join(testDir, ".swarmkit");
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
writeFileSync(join(dir, "credentials.json"), JSON.stringify({ apiUrl: "https://api.swarmkit.ai" }));
|
|
48
|
+
expect(readCredentials()).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("writeCredentials", () => {
|
|
52
|
+
it("creates the credentials file", () => {
|
|
53
|
+
writeCredentials({
|
|
54
|
+
token: "test-token",
|
|
55
|
+
apiUrl: "https://api.example.com",
|
|
56
|
+
});
|
|
57
|
+
const path = join(testDir, ".swarmkit", "credentials.json");
|
|
58
|
+
expect(existsSync(path)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it("writes valid JSON", () => {
|
|
61
|
+
writeCredentials({
|
|
62
|
+
token: "test-token",
|
|
63
|
+
apiUrl: "https://api.example.com",
|
|
64
|
+
});
|
|
65
|
+
const path = join(testDir, ".swarmkit", "credentials.json");
|
|
66
|
+
const raw = readFileSync(path, "utf-8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
expect(parsed.token).toBe("test-token");
|
|
69
|
+
expect(parsed.apiUrl).toBe("https://api.example.com");
|
|
70
|
+
});
|
|
71
|
+
it("overwrites existing credentials", () => {
|
|
72
|
+
writeCredentials({ token: "old", apiUrl: "https://old.com" });
|
|
73
|
+
writeCredentials({ token: "new", apiUrl: "https://new.com" });
|
|
74
|
+
const creds = readCredentials();
|
|
75
|
+
expect(creds?.token).toBe("new");
|
|
76
|
+
expect(creds?.apiUrl).toBe("https://new.com");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe("deleteCredentials", () => {
|
|
80
|
+
it("removes the credentials file", () => {
|
|
81
|
+
writeCredentials({ token: "test", apiUrl: "https://api.test.com" });
|
|
82
|
+
deleteCredentials();
|
|
83
|
+
expect(readCredentials()).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it("does nothing when no credentials exist", () => {
|
|
86
|
+
expect(() => deleteCredentials()).not.toThrow();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("isLoggedIn", () => {
|
|
90
|
+
it("returns false when not logged in", () => {
|
|
91
|
+
expect(isLoggedIn()).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
it("returns true when credentials exist", () => {
|
|
94
|
+
writeCredentials({ token: "test", apiUrl: "https://api.test.com" });
|
|
95
|
+
expect(isLoggedIn()).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it("returns false after logout", () => {
|
|
98
|
+
writeCredentials({ token: "test", apiUrl: "https://api.test.com" });
|
|
99
|
+
deleteCredentials();
|
|
100
|
+
expect(isLoggedIn()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,16 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { PACKAGES, BUNDLES, INTEGRATIONS, getBundlePackages, getActiveIntegrations, getNewIntegrations, getLostIntegrations, getNpmName, isKnownPackage, getAllPackageNames, } from "./packages/registry.js";
|
|
2
|
+
export type { PackageDefinition, BundleDefinition, Integration, } from "./packages/registry.js";
|
|
3
|
+
export { installPackages, uninstallPackage, getInstalledVersion, getLatestVersion, updatePackages, } from "./packages/installer.js";
|
|
4
|
+
export type { InstallResult, UpdateResult } from "./packages/installer.js";
|
|
5
|
+
export { PROJECT_CONFIG_DIRS, PROJECT_INIT_ORDER, GLOBAL_CONFIG_DIRS, isProjectInit, isGlobalInit, initProjectPackage, initGlobalPackage, } from "./packages/setup.js";
|
|
6
|
+
export type { InitContext, GlobalContext, OpenhiveOptions, SetupResult, } from "./packages/setup.js";
|
|
7
|
+
export { readConfig, writeConfig, isFirstRun, getConfigDir, addInstalledPackages, removeInstalledPackage, } from "./config/global.js";
|
|
8
|
+
export type { GlobalConfig } from "./config/global.js";
|
|
9
|
+
export { readKey, writeKey, deleteKey, listKeys, hasKey, } from "./config/keys.js";
|
|
10
|
+
export { runAllChecks } from "./doctor/checks.js";
|
|
11
|
+
export type { CheckResult, CheckContext, CheckStatus, } from "./doctor/types.js";
|
|
12
|
+
export type { DoctorReport } from "./doctor/checks.js";
|
|
13
|
+
export { readCredentials, writeCredentials, deleteCredentials, isLoggedIn, } from "./hub/credentials.js";
|
|
14
|
+
export type { HubCredentials } from "./hub/credentials.js";
|
|
15
|
+
export { exchangeAuthCode, refreshToken, getMe, createHive, listHives, getHive, startHive, stopHive, destroyHive, getHubUrl, HubApiError, } from "./hub/client.js";
|
|
16
|
+
export type { HubUser, Hive, HiveStatus, HiveTier, CreateHiveOptions, } from "./hub/client.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Public API — re-export modules for programmatic use
|
|
2
|
+
export { PACKAGES, BUNDLES, INTEGRATIONS, getBundlePackages, getActiveIntegrations, getNewIntegrations, getLostIntegrations, getNpmName, isKnownPackage, getAllPackageNames, } from "./packages/registry.js";
|
|
3
|
+
export { installPackages, uninstallPackage, getInstalledVersion, getLatestVersion, updatePackages, } from "./packages/installer.js";
|
|
4
|
+
export { PROJECT_CONFIG_DIRS, PROJECT_INIT_ORDER, GLOBAL_CONFIG_DIRS, isProjectInit, isGlobalInit, initProjectPackage, initGlobalPackage, } from "./packages/setup.js";
|
|
5
|
+
export { readConfig, writeConfig, isFirstRun, getConfigDir, addInstalledPackages, removeInstalledPackage, } from "./config/global.js";
|
|
6
|
+
export { readKey, writeKey, deleteKey, listKeys, hasKey, } from "./config/keys.js";
|
|
7
|
+
export { runAllChecks } from "./doctor/checks.js";
|
|
8
|
+
export { readCredentials, writeCredentials, deleteCredentials, isLoggedIn, } from "./hub/credentials.js";
|
|
9
|
+
export { exchangeAuthCode, refreshToken, getMe, createHive, listHives, getHive, startHive, stopHive, destroyHive, getHubUrl, HubApiError, } from "./hub/client.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface InstallResult {
|
|
2
|
+
package: string;
|
|
3
|
+
success: boolean;
|
|
4
|
+
version?: string;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface UpdateResult {
|
|
8
|
+
package: string;
|
|
9
|
+
previousVersion: string | null;
|
|
10
|
+
newVersion: string | null;
|
|
11
|
+
updated: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Install packages globally via npm.
|
|
16
|
+
*/
|
|
17
|
+
export declare function installPackages(packages: string[]): Promise<InstallResult[]>;
|
|
18
|
+
/**
|
|
19
|
+
* Uninstall a package globally via npm.
|
|
20
|
+
*/
|
|
21
|
+
export declare function uninstallPackage(packageName: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Get the currently installed global version of a package, or null if not installed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getInstalledVersion(packageName: string): Promise<string | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Get the latest published version of a package from the registry.
|
|
28
|
+
*/
|
|
29
|
+
export declare function getLatestVersion(packageName: string): Promise<string | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Update installed packages to their latest versions.
|
|
32
|
+
*/
|
|
33
|
+
export declare function updatePackages(packages: string[]): Promise<UpdateResult[]>;
|