@modelworks/quack 0.0.0-stage → 0.2.9

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/dist/auth.d.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * OAuth flow, PKCE, DCR, token storage, and refresh.
3
+ *
4
+ * Uses only Node 18+ built-ins (`crypto`, `node:fs`, `node:path`, `node:os`,
5
+ * `node:http`, `node:child_process`, global `fetch`).
6
+ *
7
+ * @module
8
+ */
9
+ import type { ClientRegistration, Credentials } from "./types.js";
10
+ /**
11
+ * Resolve the quack config directory.
12
+ *
13
+ * Honors `$XDG_CONFIG_HOME` when set; falls back to `~/.config/quack/`.
14
+ */
15
+ export declare function getConfigDir(): string;
16
+ /** Path to `credentials.json`. */
17
+ export declare function credentialsPath(): string;
18
+ /** Path to `client.json` (DCR record). */
19
+ export declare function clientPath(): string;
20
+ /**
21
+ * Resolve the effective API base.
22
+ *
23
+ * Order of precedence: `QUACK_API_BASE` env var > stored `credentials.api_base`
24
+ * > {@link DEFAULT_API_BASE}.
25
+ *
26
+ * Pass the stored credentials to honor a previously-set override.
27
+ */
28
+ export declare function getApiBase(stored?: Credentials | null): string;
29
+ /** Load stored credentials, or `null` if not logged in. */
30
+ export declare function loadCredentials(): Credentials | null;
31
+ /** Persist credentials to disk with `0600` permissions. */
32
+ export declare function saveCredentials(creds: Credentials): void;
33
+ /** Delete stored credentials (used by `quack logout`). */
34
+ export declare function deleteCredentials(): void;
35
+ /** Load the stored DCR client, or `null` if not yet registered. */
36
+ export declare function loadClient(): ClientRegistration | null;
37
+ /** Persist the DCR client record. */
38
+ export declare function saveClient(client: ClientRegistration): void;
39
+ /**
40
+ * Ensure a DCR client exists. Registers one via `POST /oauth/register`
41
+ * if `client.json` is missing. Returns the `client_id`.
42
+ *
43
+ * Re-registration on every login is wasteful and creates orphan rows;
44
+ * we register once and reuse.
45
+ */
46
+ export declare function ensureClient(apiBase: string): Promise<string>;
47
+ /** Generate a random `code_verifier` (43+ chars, RFC 7636). */
48
+ export declare function generateVerifier(): string;
49
+ /** Compute the S256 `code_challenge` for a given `code_verifier`. */
50
+ export declare function computeChallenge(verifier: string): Promise<string>;
51
+ /** Generate a random `state` parameter. */
52
+ export declare function generateState(): string;
53
+ /**
54
+ * Refresh the access token using the stored refresh token.
55
+ *
56
+ * Updates `credentials.json` in place. Returns `true` on success.
57
+ * If the server returns a new `refresh_token`, it replaces the old one
58
+ * (rotation); otherwise the old refresh token is retained.
59
+ */
60
+ export declare function refreshToken(): Promise<boolean>;
61
+ /**
62
+ * Return a valid access token, refreshing first if it's expired.
63
+ *
64
+ * Used by the proxy (on 401) and by any direct API call.
65
+ */
66
+ export declare function getValidAccessToken(): Promise<string | null>;
67
+ /** Result of a successful login. */
68
+ export interface LoginResult {
69
+ user_uid: string;
70
+ role: string;
71
+ }
72
+ /**
73
+ * Run the full OAuth Authorization Code + PKCE flow.
74
+ *
75
+ * 1. Ensure a DCR client exists.
76
+ * 2. Generate PKCE verifier + challenge + state.
77
+ * 3. Start a local callback server on port 8421.
78
+ * 4. Open the browser to the authorize endpoint.
79
+ * 5. Receive the authorization code at the callback.
80
+ * 6. Exchange the code for tokens at the token endpoint.
81
+ * 7. Persist credentials.
82
+ */
83
+ export declare function login(apiBaseOverride?: string): Promise<LoginResult>;
84
+ /** Decode the `sub` claim from a JWT without verifying the signature. */
85
+ /**
86
+ * Revoke the access token and delete stored credentials.
87
+ *
88
+ * Gracefully handles already-revoked tokens (the revoke endpoint returns
89
+ * success per RFC 7009 even for invalid tokens).
90
+ */
91
+ export declare function logout(): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * OAuth flow, PKCE, DCR, token storage, and refresh.
3
+ *
4
+ * Uses only Node 18+ built-ins (`crypto`, `node:fs`, `node:path`, `node:os`,
5
+ * `node:http`, `node:child_process`, global `fetch`).
6
+ *
7
+ * @module
8
+ */
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import http from "node:http";
14
+ import { exec } from "node:child_process";
15
+ /** Default callback port. May change if the API requires an exact match. */
16
+ const CALLBACK_PORT = 8421;
17
+ /** Redirect URI registered via DCR. */
18
+ const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;
19
+ /** Default ModelWorks API base (host root; all API routes are under `/api`). */
20
+ const DEFAULT_API_BASE = "https://modelworks.ai";
21
+ /**
22
+ * Resolve the quack config directory.
23
+ *
24
+ * Honors `$XDG_CONFIG_HOME` when set; falls back to `~/.config/quack/`.
25
+ */
26
+ export function getConfigDir() {
27
+ const xdg = process.env.XDG_CONFIG_HOME;
28
+ if (xdg)
29
+ return path.join(xdg, "quack");
30
+ return path.join(os.homedir(), ".config", "quack");
31
+ }
32
+ /** Path to `credentials.json`. */
33
+ export function credentialsPath() {
34
+ return path.join(getConfigDir(), "credentials.json");
35
+ }
36
+ /** Path to `client.json` (DCR record). */
37
+ export function clientPath() {
38
+ return path.join(getConfigDir(), "client.json");
39
+ }
40
+ /**
41
+ * Resolve the effective API base.
42
+ *
43
+ * Order of precedence: `QUACK_API_BASE` env var > stored `credentials.api_base`
44
+ * > {@link DEFAULT_API_BASE}.
45
+ *
46
+ * Pass the stored credentials to honor a previously-set override.
47
+ */
48
+ export function getApiBase(stored) {
49
+ const fromEnv = process.env.QUACK_API_BASE;
50
+ if (fromEnv)
51
+ return fromEnv.replace(/\/$/, "");
52
+ if (stored?.api_base)
53
+ return stored.api_base.replace(/\/$/, "");
54
+ return DEFAULT_API_BASE;
55
+ }
56
+ /** Write a JSON file with `0o600` permissions, creating parent dirs. */
57
+ function writeJsonSecure(filePath, data) {
58
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
59
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), {
60
+ mode: 0o600,
61
+ });
62
+ }
63
+ /** Read a JSON file, returning `null` if missing or unparseable. */
64
+ function readJson(filePath) {
65
+ try {
66
+ const raw = fs.readFileSync(filePath, "utf8");
67
+ return JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Credentials storage
75
+ // ---------------------------------------------------------------------------
76
+ /** Load stored credentials, or `null` if not logged in. */
77
+ export function loadCredentials() {
78
+ return readJson(credentialsPath());
79
+ }
80
+ /** Persist credentials to disk with `0600` permissions. */
81
+ export function saveCredentials(creds) {
82
+ writeJsonSecure(credentialsPath(), creds);
83
+ }
84
+ /** Delete stored credentials (used by `quack logout`). */
85
+ export function deleteCredentials() {
86
+ try {
87
+ fs.unlinkSync(credentialsPath());
88
+ }
89
+ catch (err) {
90
+ if (err.code !== "ENOENT")
91
+ throw err;
92
+ }
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // DCR (Dynamic Client Registration)
96
+ // ---------------------------------------------------------------------------
97
+ /** Load the stored DCR client, or `null` if not yet registered. */
98
+ export function loadClient() {
99
+ return readJson(clientPath());
100
+ }
101
+ /** Persist the DCR client record. */
102
+ export function saveClient(client) {
103
+ writeJsonSecure(clientPath(), client);
104
+ }
105
+ /**
106
+ * Ensure a DCR client exists. Registers one via `POST /oauth/register`
107
+ * if `client.json` is missing. Returns the `client_id`.
108
+ *
109
+ * Re-registration on every login is wasteful and creates orphan rows;
110
+ * we register once and reuse.
111
+ */
112
+ export async function ensureClient(apiBase) {
113
+ const existing = loadClient();
114
+ if (existing?.client_id)
115
+ return existing.client_id;
116
+ const res = await fetch(`${apiBase}/api/oauth/register`, {
117
+ method: "POST",
118
+ headers: { "content-type": "application/json" },
119
+ body: JSON.stringify({
120
+ client_name: "quack",
121
+ redirect_uris: [REDIRECT_URI],
122
+ grant_types: ["authorization_code", "refresh_token"],
123
+ response_types: ["code"],
124
+ token_endpoint_auth_method: "none",
125
+ }),
126
+ });
127
+ if (!res.ok) {
128
+ const text = await res.text().catch(() => "");
129
+ throw new Error(`DCR failed: HTTP ${res.status} ${res.statusText}${text ? ` — ${text}` : ""}`);
130
+ }
131
+ const body = (await res.json());
132
+ saveClient({ client_id: body.client_id });
133
+ return body.client_id;
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // PKCE
137
+ // ---------------------------------------------------------------------------
138
+ /** Generate a random `code_verifier` (43+ chars, RFC 7636). */
139
+ export function generateVerifier() {
140
+ // 32 random bytes → base64url (~43 chars)
141
+ return crypto.randomBytes(32).toString("base64url");
142
+ }
143
+ /** Compute the S256 `code_challenge` for a given `code_verifier`. */
144
+ export async function computeChallenge(verifier) {
145
+ const hash = crypto.createHash("sha256").update(verifier).digest();
146
+ return hash.toString("base64url");
147
+ }
148
+ /** Generate a random `state` parameter. */
149
+ export function generateState() {
150
+ return crypto.randomBytes(16).toString("hex");
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Token refresh
154
+ // ---------------------------------------------------------------------------
155
+ /**
156
+ * Refresh the access token using the stored refresh token.
157
+ *
158
+ * Updates `credentials.json` in place. Returns `true` on success.
159
+ * If the server returns a new `refresh_token`, it replaces the old one
160
+ * (rotation); otherwise the old refresh token is retained.
161
+ */
162
+ export async function refreshToken() {
163
+ const creds = loadCredentials();
164
+ if (!creds?.refresh_token)
165
+ return false;
166
+ const apiBase = getApiBase(creds);
167
+ const client = loadClient();
168
+ if (!client?.client_id)
169
+ return false;
170
+ const form = new URLSearchParams({
171
+ grant_type: "refresh_token",
172
+ refresh_token: creds.refresh_token,
173
+ client_id: client.client_id,
174
+ });
175
+ const res = await fetch(`${apiBase}/api/oauth/token`, {
176
+ method: "POST",
177
+ headers: { "content-type": "application/x-www-form-urlencoded" },
178
+ body: form.toString(),
179
+ });
180
+ if (!res.ok)
181
+ return false;
182
+ const body = (await res.json());
183
+ const updated = {
184
+ ...creds,
185
+ access_token: body.access_token,
186
+ token_type: body.token_type,
187
+ expires_at: Math.floor(Date.now() / 1000) + body.expires_in,
188
+ refresh_token: body.refresh_token ?? creds.refresh_token,
189
+ };
190
+ saveCredentials(updated);
191
+ return true;
192
+ }
193
+ /**
194
+ * Return a valid access token, refreshing first if it's expired.
195
+ *
196
+ * Used by the proxy (on 401) and by any direct API call.
197
+ */
198
+ export async function getValidAccessToken() {
199
+ const creds = loadCredentials();
200
+ if (!creds)
201
+ return null;
202
+ // Refresh if expired (with a 60s safety margin).
203
+ const now = Math.floor(Date.now() / 1000);
204
+ if (creds.expires_at - 60 <= now) {
205
+ const ok = await refreshToken();
206
+ if (!ok)
207
+ return null;
208
+ return loadCredentials()?.access_token ?? null;
209
+ }
210
+ return creds.access_token;
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Browser open (cross-platform)
214
+ // ---------------------------------------------------------------------------
215
+ /** Open a URL in the user's default browser. Best-effort, never throws. */
216
+ function openBrowser(url) {
217
+ const cmd = process.platform === "darwin"
218
+ ? "open"
219
+ : process.platform === "win32"
220
+ ? "start"
221
+ : "xdg-open";
222
+ exec(`${cmd} "${url}"`, () => {
223
+ // Ignore errors — the user can copy the URL manually.
224
+ });
225
+ }
226
+ /**
227
+ * Run the full OAuth Authorization Code + PKCE flow.
228
+ *
229
+ * 1. Ensure a DCR client exists.
230
+ * 2. Generate PKCE verifier + challenge + state.
231
+ * 3. Start a local callback server on port 8421.
232
+ * 4. Open the browser to the authorize endpoint.
233
+ * 5. Receive the authorization code at the callback.
234
+ * 6. Exchange the code for tokens at the token endpoint.
235
+ * 7. Persist credentials.
236
+ */
237
+ export async function login(apiBaseOverride) {
238
+ // Determine API base (override > env > default).
239
+ const storedCreds = loadCredentials();
240
+ const apiBase = apiBaseOverride?.replace(/\/$/, "") ?? getApiBase(storedCreds);
241
+ const clientId = await ensureClient(apiBase);
242
+ const verifier = generateVerifier();
243
+ const challenge = await computeChallenge(verifier);
244
+ const state = generateState();
245
+ // Start the local callback server. Returns a promise that resolves
246
+ // when the OAuth redirect arrives — do NOT await before opening the
247
+ // browser, or the browser will never open.
248
+ const codePromise = waitForCallback(state);
249
+ // Build the authorize URL and open it in the browser.
250
+ const authorizeUrl = new URL(`${apiBase}/api/oauth/authorize`);
251
+ authorizeUrl.searchParams.set("response_type", "code");
252
+ authorizeUrl.searchParams.set("client_id", clientId);
253
+ authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI);
254
+ authorizeUrl.searchParams.set("code_challenge", challenge);
255
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
256
+ authorizeUrl.searchParams.set("state", state);
257
+ authorizeUrl.searchParams.set("scope", "default");
258
+ console.log(`Opening browser to:\n ${authorizeUrl.toString()}`);
259
+ openBrowser(authorizeUrl.toString());
260
+ // Now wait for the callback to arrive.
261
+ const { code } = await codePromise;
262
+ // Exchange the authorization code for tokens.
263
+ const form = new URLSearchParams({
264
+ grant_type: "authorization_code",
265
+ code,
266
+ redirect_uri: REDIRECT_URI,
267
+ client_id: clientId,
268
+ code_verifier: verifier,
269
+ });
270
+ const tokenRes = await fetch(`${apiBase}/api/oauth/token`, {
271
+ method: "POST",
272
+ headers: { "content-type": "application/x-www-form-urlencoded" },
273
+ body: form.toString(),
274
+ });
275
+ if (!tokenRes.ok) {
276
+ const text = await tokenRes.text().catch(() => "");
277
+ throw new Error(`Token exchange failed: HTTP ${tokenRes.status} ${tokenRes.statusText}${text ? ` — ${text}` : ""}`);
278
+ }
279
+ const tokenBody = (await tokenRes.json());
280
+ // The token response has `token_uid` (the `ut-` UID) but NOT the
281
+ // user's `usr-` UID. Fetch it from `/users/token`.
282
+ const userRes = await fetch(`${apiBase}/api/users/token`, {
283
+ headers: { authorization: `Bearer ${tokenBody.access_token}` },
284
+ });
285
+ let user_uid = "";
286
+ if (userRes.ok) {
287
+ const userBody = await userRes.json();
288
+ const payload = userBody.payload ?? userBody;
289
+ user_uid = payload.uid ?? "";
290
+ }
291
+ if (!user_uid) {
292
+ throw new Error("Could not determine user UID after login.");
293
+ }
294
+ const creds = {
295
+ access_token: tokenBody.access_token,
296
+ refresh_token: tokenBody.refresh_token ?? "",
297
+ expires_at: Math.floor(Date.now() / 1000) + tokenBody.expires_in,
298
+ token_type: tokenBody.token_type,
299
+ user_uid,
300
+ api_base: apiBase,
301
+ };
302
+ saveCredentials(creds);
303
+ return { user_uid, role: tokenBody.role };
304
+ }
305
+ /**
306
+ * Start a local callback server and resolve with the authorization `code`.
307
+ *
308
+ * Rejects if the `state` mismatched or if no callback arrives within 5 minutes.
309
+ */
310
+ function waitForCallback(expectedState) {
311
+ return new Promise((resolve, reject) => {
312
+ const server = http.createServer((req, res) => {
313
+ const url = new URL(req.url ?? "/", `http://localhost:${CALLBACK_PORT}`);
314
+ if (url.pathname !== "/callback") {
315
+ res.writeHead(404);
316
+ res.end("Not found");
317
+ return;
318
+ }
319
+ const code = url.searchParams.get("code");
320
+ const state = url.searchParams.get("state");
321
+ const error = url.searchParams.get("error");
322
+ if (error) {
323
+ res.writeHead(400, { "content-type": "text/html" });
324
+ res.end(`<h1>Login failed</h1><p>${error}</p>`);
325
+ clearTimeout(timer);
326
+ server.close();
327
+ reject(new Error(`OAuth error: ${error}`));
328
+ return;
329
+ }
330
+ if (!code || state !== expectedState) {
331
+ res.writeHead(400, { "content-type": "text/html" });
332
+ res.end("<h1>Login failed</h1><p>Missing or mismatched state.</p>");
333
+ clearTimeout(timer);
334
+ server.close();
335
+ reject(new Error("OAuth callback: missing code or state mismatch"));
336
+ return;
337
+ }
338
+ res.writeHead(200, { "content-type": "text/html" });
339
+ res.end("<h1>Logged in to quack</h1><p>You can close this tab and return to your terminal.</p>");
340
+ clearTimeout(timer);
341
+ server.close();
342
+ resolve({ code });
343
+ });
344
+ server.listen(CALLBACK_PORT, "127.0.0.1", () => {
345
+ // Server is ready; the login() caller opens the browser next.
346
+ });
347
+ server.on("error", (err) => {
348
+ clearTimeout(timer);
349
+ reject(new Error(`Callback server failed: ${err.message}`));
350
+ });
351
+ // Timeout after 5 minutes.
352
+ const timer = setTimeout(() => {
353
+ server.close();
354
+ reject(new Error("Login timed out after 5 minutes"));
355
+ }, 5 * 60 * 1000);
356
+ });
357
+ }
358
+ /** Decode the `sub` claim from a JWT without verifying the signature. */
359
+ // Removed — the ModelWorks access token is opaque (`mw_...`), not a JWT.
360
+ // The user UID is fetched from `/users/token` after the token exchange instead.
361
+ // ---------------------------------------------------------------------------
362
+ // `quack logout`
363
+ // ---------------------------------------------------------------------------
364
+ /**
365
+ * Revoke the access token and delete stored credentials.
366
+ *
367
+ * Gracefully handles already-revoked tokens (the revoke endpoint returns
368
+ * success per RFC 7009 even for invalid tokens).
369
+ */
370
+ export async function logout() {
371
+ const creds = loadCredentials();
372
+ if (!creds) {
373
+ console.log("Not logged in.");
374
+ return;
375
+ }
376
+ const apiBase = getApiBase(creds);
377
+ try {
378
+ await fetch(`${apiBase}/api/oauth/revoke`, {
379
+ method: "POST",
380
+ headers: { "content-type": "application/json" },
381
+ body: JSON.stringify({ token: creds.access_token }),
382
+ });
383
+ }
384
+ catch {
385
+ // Best-effort — delete credentials regardless.
386
+ }
387
+ deleteCredentials();
388
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * quack — Launch AI coding agents with ModelWorks circuits.
4
+ *
5
+ * @module
6
+ */
7
+ export interface TableColumn {
8
+ /** Column header text. */
9
+ header: string;
10
+ /** Cell values, one per row. */
11
+ values: string[];
12
+ /**
13
+ * Can this column be truncated with `…` when the terminal is too narrow?
14
+ * Defaults to `false` — only columns marked `true` are shrunk.
15
+ * Typically used for long UID columns.
16
+ */
17
+ truncatable?: boolean;
18
+ /**
19
+ * Minimum visible width when truncating (default 10).
20
+ * Truncated values get `…` appended, so this includes the ellipsis.
21
+ */
22
+ minWidth?: number;
23
+ }
24
+ /**
25
+ * Print a formatted table with dynamic column widths that fit the terminal.
26
+ *
27
+ * Natural widths are computed from header + values (measuring visible
28
+ * text, ignoring ANSI codes). If the total fits in the terminal, natural
29
+ * widths are used (no truncation). If it overflows, truncatable columns
30
+ * are shrunk (with `…`) until it fits; non-truncatable columns keep their
31
+ * natural width.
32
+ *
33
+ * When `process.stdout` is not a TTY (piped), defaults to 80 columns.
34
+ */
35
+ export declare function printTable(columns: TableColumn[]): void;