@mainahq/core 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -33,5 +33,8 @@
33
33
  "@ai-sdk/openai": "^3.0.50",
34
34
  "ai": "^6.0.145",
35
35
  "drizzle-orm": "^0.45.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
36
39
  }
37
40
  }
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ clearAuthConfig,
6
+ getAuthConfigPath,
7
+ loadAuthConfig,
8
+ saveAuthConfig,
9
+ startDeviceFlow,
10
+ } from "../auth";
11
+
12
+ // ── Helpers ─────────────────────────────────────────────────────────────────
13
+
14
+ const originalFetch = globalThis.fetch;
15
+
16
+ let tmpDir: string;
17
+ let mockFetch: ReturnType<typeof mock>;
18
+
19
+ function jsonResponse(body: unknown, status = 200): Response {
20
+ return new Response(JSON.stringify(body), {
21
+ status,
22
+ headers: { "Content-Type": "application/json" },
23
+ });
24
+ }
25
+
26
+ // ── Setup / Teardown ────────────────────────────────────────────────────────
27
+
28
+ beforeEach(() => {
29
+ tmpDir = join(
30
+ import.meta.dir,
31
+ `tmp-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`,
32
+ );
33
+ mkdirSync(tmpDir, { recursive: true });
34
+
35
+ mockFetch = mock(() =>
36
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
37
+ );
38
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
39
+ });
40
+
41
+ afterEach(() => {
42
+ globalThis.fetch = originalFetch;
43
+ try {
44
+ rmSync(tmpDir, { recursive: true, force: true });
45
+ } catch {
46
+ // ignore
47
+ }
48
+ });
49
+
50
+ // ── Tests ───────────────────────────────────────────────────────────────────
51
+
52
+ describe("getAuthConfigPath", () => {
53
+ test("returns path inside custom config dir", () => {
54
+ const path = getAuthConfigPath("/custom/dir");
55
+ expect(path).toBe("/custom/dir/auth.json");
56
+ });
57
+
58
+ test("defaults to home directory", () => {
59
+ const path = getAuthConfigPath();
60
+ expect(path).toContain(".maina");
61
+ expect(path).toEndWith("auth.json");
62
+ });
63
+ });
64
+
65
+ describe("saveAuthConfig / loadAuthConfig", () => {
66
+ test("round-trips auth config", () => {
67
+ const config = {
68
+ accessToken: "tok-abc-123",
69
+ refreshToken: "ref-xyz",
70
+ expiresAt: "2026-12-31T23:59:59Z",
71
+ };
72
+
73
+ const saveResult = saveAuthConfig(config, tmpDir);
74
+ expect(saveResult.ok).toBe(true);
75
+
76
+ const loadResult = loadAuthConfig(tmpDir);
77
+ expect(loadResult.ok).toBe(true);
78
+ if (loadResult.ok) {
79
+ expect(loadResult.value.accessToken).toBe("tok-abc-123");
80
+ expect(loadResult.value.refreshToken).toBe("ref-xyz");
81
+ expect(loadResult.value.expiresAt).toBe("2026-12-31T23:59:59Z");
82
+ }
83
+ });
84
+
85
+ test("creates parent directories", () => {
86
+ const nested = join(tmpDir, "a", "b", "c");
87
+ const result = saveAuthConfig({ accessToken: "t" }, nested);
88
+ expect(result.ok).toBe(true);
89
+ expect(existsSync(join(nested, "auth.json"))).toBe(true);
90
+ });
91
+
92
+ test("returns error when not logged in", () => {
93
+ const result = loadAuthConfig(join(tmpDir, "nonexistent"));
94
+ expect(result.ok).toBe(false);
95
+ if (!result.ok) {
96
+ expect(result.error).toContain("Not logged in");
97
+ }
98
+ });
99
+
100
+ test("returns error for malformed config", () => {
101
+ const authPath = join(tmpDir, "auth.json");
102
+ writeFileSync(authPath, '{"no_token": true}', "utf-8");
103
+
104
+ const result = loadAuthConfig(tmpDir);
105
+ expect(result.ok).toBe(false);
106
+ if (!result.ok) {
107
+ expect(result.error).toContain("missing accessToken");
108
+ }
109
+ });
110
+ });
111
+
112
+ describe("clearAuthConfig", () => {
113
+ test("removes auth file", () => {
114
+ saveAuthConfig({ accessToken: "t" }, tmpDir);
115
+ const authPath = getAuthConfigPath(tmpDir);
116
+ expect(existsSync(authPath)).toBe(true);
117
+
118
+ const result = clearAuthConfig(tmpDir);
119
+ expect(result.ok).toBe(true);
120
+ expect(existsSync(authPath)).toBe(false);
121
+ });
122
+
123
+ test("succeeds when no file exists", () => {
124
+ const result = clearAuthConfig(join(tmpDir, "nonexistent"));
125
+ expect(result.ok).toBe(true);
126
+ });
127
+ });
128
+
129
+ describe("startDeviceFlow", () => {
130
+ test("returns device code response on success", async () => {
131
+ const deviceData = {
132
+ userCode: "ABCD-1234",
133
+ deviceCode: "dev-code-xyz",
134
+ verificationUri: "https://maina.dev/device",
135
+ interval: 5,
136
+ expiresIn: 900,
137
+ };
138
+ mockFetch.mockImplementation(() =>
139
+ Promise.resolve(jsonResponse({ data: deviceData })),
140
+ );
141
+
142
+ const result = await startDeviceFlow("https://api.maina.dev");
143
+
144
+ expect(result.ok).toBe(true);
145
+ if (result.ok) {
146
+ expect(result.value.userCode).toBe("ABCD-1234");
147
+ expect(result.value.deviceCode).toBe("dev-code-xyz");
148
+ expect(result.value.verificationUri).toBe("https://maina.dev/device");
149
+ }
150
+ });
151
+
152
+ test("returns error on API failure", async () => {
153
+ mockFetch.mockImplementation(() =>
154
+ Promise.resolve(jsonResponse({ error: "Service unavailable" }, 503)),
155
+ );
156
+
157
+ const result = await startDeviceFlow("https://api.maina.dev");
158
+
159
+ expect(result.ok).toBe(false);
160
+ if (!result.ok) {
161
+ expect(result.error).toContain("503");
162
+ }
163
+ });
164
+ });
@@ -0,0 +1,253 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { type CloudClient, createCloudClient } from "../client";
3
+ import type { CloudConfig } from "../types";
4
+
5
+ // ── Helpers ─────────────────────────────────────────────────────────────────
6
+
7
+ const originalFetch = globalThis.fetch;
8
+
9
+ let mockFetch: ReturnType<typeof mock>;
10
+
11
+ function jsonResponse(body: unknown, status = 200): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+ }
17
+
18
+ function setupClient(overrides?: Partial<CloudConfig>): CloudClient {
19
+ return createCloudClient({
20
+ baseUrl: "https://api.test.maina.dev",
21
+ token: "test-token-abc",
22
+ timeoutMs: 5_000,
23
+ maxRetries: 2,
24
+ ...overrides,
25
+ });
26
+ }
27
+
28
+ // ── Setup / Teardown ────────────────────────────────────────────────────────
29
+
30
+ beforeEach(() => {
31
+ mockFetch = mock(() =>
32
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
33
+ );
34
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
35
+ });
36
+
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch;
39
+ });
40
+
41
+ // ── Tests ───────────────────────────────────────────────────────────────────
42
+
43
+ describe("createCloudClient", () => {
44
+ test("health returns ok status", async () => {
45
+ mockFetch.mockImplementation(() =>
46
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
47
+ );
48
+
49
+ const client = setupClient();
50
+ const result = await client.health();
51
+
52
+ expect(result.ok).toBe(true);
53
+ if (result.ok) {
54
+ expect(result.value.status).toBe("ok");
55
+ }
56
+ });
57
+
58
+ test("attaches Authorization header when token is provided", async () => {
59
+ mockFetch.mockImplementation(() =>
60
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
61
+ );
62
+
63
+ const client = setupClient({ token: "my-secret-token" });
64
+ await client.health();
65
+
66
+ expect(mockFetch).toHaveBeenCalledTimes(1);
67
+ const call = mockFetch.mock.calls[0] as unknown[];
68
+ const requestInit = call[1] as RequestInit;
69
+ const authHeader = (requestInit.headers as Record<string, string>)
70
+ .Authorization;
71
+ expect(authHeader).toBe("Bearer my-secret-token");
72
+ });
73
+
74
+ test("omits Authorization header when no token", async () => {
75
+ mockFetch.mockImplementation(() =>
76
+ Promise.resolve(jsonResponse({ data: { status: "ok" } })),
77
+ );
78
+
79
+ const client = setupClient({ token: undefined });
80
+ await client.health();
81
+
82
+ const call = mockFetch.mock.calls[0] as unknown[];
83
+ const requestInit = call[1] as RequestInit;
84
+ const h = requestInit.headers as Record<string, string>;
85
+ expect(h.Authorization).toBeUndefined();
86
+ });
87
+
88
+ test("retries on 500 errors", async () => {
89
+ let attempts = 0;
90
+ mockFetch.mockImplementation(() => {
91
+ attempts++;
92
+ if (attempts < 3) {
93
+ return Promise.resolve(jsonResponse({ error: "Internal error" }, 500));
94
+ }
95
+ return Promise.resolve(jsonResponse({ data: { status: "ok" } }));
96
+ });
97
+
98
+ const client = setupClient({ maxRetries: 2 });
99
+ const result = await client.health();
100
+
101
+ expect(result.ok).toBe(true);
102
+ expect(attempts).toBe(3);
103
+ });
104
+
105
+ test("retries on 429 rate limit", async () => {
106
+ let attempts = 0;
107
+ mockFetch.mockImplementation(() => {
108
+ attempts++;
109
+ if (attempts === 1) {
110
+ return Promise.resolve(
111
+ jsonResponse({ error: "Too many requests" }, 429),
112
+ );
113
+ }
114
+ return Promise.resolve(jsonResponse({ data: { status: "ok" } }));
115
+ });
116
+
117
+ const client = setupClient({ maxRetries: 2 });
118
+ const result = await client.health();
119
+
120
+ expect(result.ok).toBe(true);
121
+ expect(attempts).toBe(2);
122
+ });
123
+
124
+ test("returns error after exhausting retries", async () => {
125
+ mockFetch.mockImplementation(() =>
126
+ Promise.resolve(jsonResponse({ error: "Server down" }, 503)),
127
+ );
128
+
129
+ const client = setupClient({ maxRetries: 1 });
130
+ const result = await client.health();
131
+
132
+ expect(result.ok).toBe(false);
133
+ if (!result.ok) {
134
+ expect(result.error).toContain("Server down");
135
+ }
136
+ });
137
+
138
+ test("returns error on non-retryable 4xx", async () => {
139
+ mockFetch.mockImplementation(() =>
140
+ Promise.resolve(jsonResponse({ error: "Forbidden" }, 403)),
141
+ );
142
+
143
+ const client = setupClient();
144
+ const result = await client.health();
145
+
146
+ expect(result.ok).toBe(false);
147
+ if (!result.ok) {
148
+ expect(result.error).toBe("Forbidden");
149
+ }
150
+ });
151
+
152
+ test("getPrompts returns prompt records", async () => {
153
+ const prompts = [
154
+ {
155
+ id: "p1",
156
+ path: "commit.md",
157
+ content: "# Commit",
158
+ hash: "abc",
159
+ updatedAt: "2026-01-01T00:00:00Z",
160
+ },
161
+ ];
162
+ mockFetch.mockImplementation(() =>
163
+ Promise.resolve(jsonResponse({ data: prompts })),
164
+ );
165
+
166
+ const client = setupClient();
167
+ const result = await client.getPrompts();
168
+
169
+ expect(result.ok).toBe(true);
170
+ if (result.ok) {
171
+ expect(result.value).toHaveLength(1);
172
+ expect(result.value[0]?.path).toBe("commit.md");
173
+ }
174
+ });
175
+
176
+ test("putPrompts sends prompts array", async () => {
177
+ mockFetch.mockImplementation(() =>
178
+ Promise.resolve(new Response(null, { status: 204 })),
179
+ );
180
+
181
+ const client = setupClient();
182
+ const result = await client.putPrompts([
183
+ {
184
+ id: "p1",
185
+ path: "commit.md",
186
+ content: "# Commit",
187
+ hash: "abc",
188
+ updatedAt: "2026-01-01T00:00:00Z",
189
+ },
190
+ ]);
191
+
192
+ expect(result.ok).toBe(true);
193
+
194
+ const call = mockFetch.mock.calls[0] as unknown[];
195
+ const requestInit = call[1] as RequestInit;
196
+ expect(requestInit.method).toBe("PUT");
197
+ const body = JSON.parse(requestInit.body as string);
198
+ expect(body.prompts).toHaveLength(1);
199
+ });
200
+
201
+ test("inviteTeamMember sends email and role", async () => {
202
+ mockFetch.mockImplementation(() =>
203
+ Promise.resolve(jsonResponse({ data: { invited: true } })),
204
+ );
205
+
206
+ const client = setupClient();
207
+ const result = await client.inviteTeamMember("new@example.com", "admin");
208
+
209
+ expect(result.ok).toBe(true);
210
+ if (result.ok) {
211
+ expect(result.value.invited).toBe(true);
212
+ }
213
+
214
+ const call = mockFetch.mock.calls[0] as unknown[];
215
+ const requestInit = call[1] as RequestInit;
216
+ const body = JSON.parse(requestInit.body as string);
217
+ expect(body.email).toBe("new@example.com");
218
+ expect(body.role).toBe("admin");
219
+ });
220
+
221
+ test("postFeedback sends payload", async () => {
222
+ mockFetch.mockImplementation(() =>
223
+ Promise.resolve(jsonResponse({ data: { recorded: true } })),
224
+ );
225
+
226
+ const client = setupClient();
227
+ const result = await client.postFeedback({
228
+ promptHash: "hash-123",
229
+ command: "commit",
230
+ accepted: true,
231
+ timestamp: "2026-01-01T00:00:00Z",
232
+ });
233
+
234
+ expect(result.ok).toBe(true);
235
+ if (result.ok) {
236
+ expect(result.value.recorded).toBe(true);
237
+ }
238
+ });
239
+
240
+ test("handles network errors gracefully", async () => {
241
+ mockFetch.mockImplementation(() =>
242
+ Promise.reject(new Error("Network unreachable")),
243
+ );
244
+
245
+ const client = setupClient({ maxRetries: 0 });
246
+ const result = await client.health();
247
+
248
+ expect(result.ok).toBe(false);
249
+ if (!result.ok) {
250
+ expect(result.error).toContain("Network unreachable");
251
+ }
252
+ });
253
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * OAuth device-flow authentication.
3
+ *
4
+ * Implements the device authorization grant (RFC 8628) for CLI login.
5
+ * Tokens are stored at ~/.maina/auth.json.
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import type { Result } from "../db/index";
18
+ import type { DeviceCodeResponse, TokenResponse } from "./types";
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────────────
21
+
22
+ function ok<T>(value: T): Result<T, string> {
23
+ return { ok: true, value };
24
+ }
25
+
26
+ function err(error: string): Result<never, string> {
27
+ return { ok: false, error };
28
+ }
29
+
30
+ function sleep(ms: number): Promise<void> {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
33
+
34
+ // ── Auth Config Path ────────────────────────────────────────────────────────
35
+
36
+ export interface AuthConfig {
37
+ /** Bearer access token. */
38
+ accessToken: string;
39
+ /** Refresh token (if available). */
40
+ refreshToken?: string;
41
+ /** ISO-8601 timestamp when the token expires. */
42
+ expiresAt?: string;
43
+ }
44
+
45
+ /**
46
+ * Return the path to the auth config file.
47
+ * Uses `configDir` override for testing; defaults to `~/.maina/auth.json`.
48
+ */
49
+ export function getAuthConfigPath(configDir?: string): string {
50
+ const dir = configDir ?? join(homedir(), ".maina");
51
+ return join(dir, "auth.json");
52
+ }
53
+
54
+ // ── Load / Save / Clear ─────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Load saved auth config from disk.
58
+ * Returns err if not logged in or the file is malformed.
59
+ */
60
+ export function loadAuthConfig(configDir?: string): Result<AuthConfig, string> {
61
+ const path = getAuthConfigPath(configDir);
62
+ if (!existsSync(path)) {
63
+ return err("Not logged in. Run `maina login` first.");
64
+ }
65
+
66
+ try {
67
+ const raw = readFileSync(path, "utf-8");
68
+ const parsed = JSON.parse(raw) as AuthConfig;
69
+ if (!parsed.accessToken) {
70
+ return err("Auth config is missing accessToken.");
71
+ }
72
+ return ok(parsed);
73
+ } catch (e) {
74
+ return err(
75
+ `Failed to read auth config: ${e instanceof Error ? e.message : String(e)}`,
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Persist auth config to disk.
82
+ */
83
+ export function saveAuthConfig(
84
+ config: AuthConfig,
85
+ configDir?: string,
86
+ ): Result<void, string> {
87
+ const path = getAuthConfigPath(configDir);
88
+
89
+ try {
90
+ mkdirSync(dirname(path), { recursive: true });
91
+ writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
92
+ return ok(undefined);
93
+ } catch (e) {
94
+ return err(
95
+ `Failed to save auth config: ${e instanceof Error ? e.message : String(e)}`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Remove auth config from disk (logout).
102
+ */
103
+ export function clearAuthConfig(configDir?: string): Result<void, string> {
104
+ const path = getAuthConfigPath(configDir);
105
+ if (!existsSync(path)) {
106
+ return ok(undefined);
107
+ }
108
+
109
+ try {
110
+ unlinkSync(path);
111
+ return ok(undefined);
112
+ } catch (e) {
113
+ return err(
114
+ `Failed to clear auth config: ${e instanceof Error ? e.message : String(e)}`,
115
+ );
116
+ }
117
+ }
118
+
119
+ // ── Device Flow ─────────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Initiate the device authorization flow.
123
+ *
124
+ * Calls `POST /auth/device` on the cloud API to obtain a user code and
125
+ * verification URI.
126
+ */
127
+ export async function startDeviceFlow(
128
+ baseUrl: string,
129
+ ): Promise<Result<DeviceCodeResponse, string>> {
130
+ try {
131
+ const response = await fetch(`${baseUrl}/auth/device`, {
132
+ method: "POST",
133
+ headers: {
134
+ "Content-Type": "application/json",
135
+ Accept: "application/json",
136
+ },
137
+ });
138
+
139
+ if (!response.ok) {
140
+ const text = await response.text();
141
+ return err(
142
+ `Device flow initiation failed (HTTP ${response.status}): ${text}`,
143
+ );
144
+ }
145
+
146
+ const body = (await response.json()) as {
147
+ data?: DeviceCodeResponse;
148
+ error?: string;
149
+ };
150
+ if (body.error) {
151
+ return err(body.error);
152
+ }
153
+ if (!body.data) {
154
+ return err("Invalid response: missing data");
155
+ }
156
+ return ok(body.data);
157
+ } catch (e) {
158
+ return err(
159
+ `Device flow request failed: ${e instanceof Error ? e.message : String(e)}`,
160
+ );
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Poll the cloud API until the user completes the device flow or the code
166
+ * expires.
167
+ *
168
+ * Respects the `interval` returned by the server. Returns the token
169
+ * response on success.
170
+ */
171
+ export async function pollForToken(
172
+ baseUrl: string,
173
+ deviceCode: string,
174
+ interval: number,
175
+ expiresIn: number,
176
+ ): Promise<Result<TokenResponse, string>> {
177
+ const deadline = Date.now() + expiresIn * 1000;
178
+ const pollInterval = Math.max(interval, 5) * 1000;
179
+
180
+ while (Date.now() < deadline) {
181
+ await sleep(pollInterval);
182
+
183
+ try {
184
+ const response = await fetch(`${baseUrl}/auth/token`, {
185
+ method: "POST",
186
+ headers: {
187
+ "Content-Type": "application/json",
188
+ Accept: "application/json",
189
+ },
190
+ body: JSON.stringify({
191
+ deviceCode,
192
+ grantType: "urn:ietf:params:oauth:grant-type:device_code",
193
+ }),
194
+ });
195
+
196
+ if (response.status === 428 || response.status === 400) {
197
+ // "authorization_pending" — the user hasn't completed login yet
198
+ continue;
199
+ }
200
+
201
+ if (!response.ok) {
202
+ const text = await response.text();
203
+ return err(`Token request failed (HTTP ${response.status}): ${text}`);
204
+ }
205
+
206
+ const body = (await response.json()) as {
207
+ data?: TokenResponse;
208
+ error?: string;
209
+ };
210
+ if (body.error) {
211
+ // "slow_down" → increase interval
212
+ if (body.error === "slow_down") {
213
+ continue;
214
+ }
215
+ return err(body.error);
216
+ }
217
+ if (!body.data) {
218
+ return err("Invalid token response: missing data");
219
+ }
220
+ return ok(body.data);
221
+ } catch (e) {
222
+ // Network errors during polling are transient — keep trying
223
+ if (Date.now() >= deadline) {
224
+ return err(
225
+ `Token polling failed: ${e instanceof Error ? e.message : String(e)}`,
226
+ );
227
+ }
228
+ }
229
+ }
230
+
231
+ return err("Device code expired. Please try logging in again.");
232
+ }