dungbeetle 0.1.2 → 0.1.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.
- package/README.md +1 -6
- package/dist/brand.d.ts +1 -1
- package/dist/brand.js +1 -1
- package/dist/cloud.d.ts +31 -2
- package/dist/cloud.js +196 -9
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +106 -0
- package/dist/index.js +86 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# Dungbeetle
|
|
6
|
-
|
|
7
6
|
<p align="center">
|
|
8
7
|
<a href="https://www.npmjs.com/package/dungbeetle"><img src="https://img.shields.io/npm/v/dungbeetle" alt="npm version"></a>
|
|
9
8
|
<a href="https://github.com/DungbeetleTech/client/actions/workflows/ci.yml"><img src="https://github.com/DungbeetleTech/client/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
@@ -11,7 +10,7 @@
|
|
|
11
10
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-FSL--1.1--ALv2-blue" alt="License: FSL-1.1-ALv2"></a>
|
|
12
11
|
</p>
|
|
13
12
|
|
|
14
|
-
>
|
|
13
|
+
> The regression safety net for AI agents and the humans they work for — web, desktop, terminal, anything. Zero adoption cost, runs anywhere.
|
|
15
14
|
|
|
16
15
|
Dungbeetle captures your app's output as stable, reviewable JSON and produces
|
|
17
16
|
**semantic diffs** instead of brittle pixel comparisons. Output is normalized (timestamps,
|
|
@@ -42,10 +41,6 @@ reference — lives at the Dungbeetle docs site:
|
|
|
42
41
|
|
|
43
42
|
**→ <https://dungbeetle.dev>**
|
|
44
43
|
|
|
45
|
-
<!-- The docs domain is a placeholder until the site goes live. When the
|
|
46
|
-
production URL is set, update this one link (and the matching pointers in
|
|
47
|
-
SUPPORT.md and CONTRIBUTING.md). -->
|
|
48
|
-
|
|
49
44
|
## Requirements
|
|
50
45
|
|
|
51
46
|
- **Node.js 22.5.0 or newer.**
|
package/dist/brand.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const BRAND_NAME: string;
|
|
2
|
-
export declare const BRAND_SUBTITLE = "
|
|
2
|
+
export declare const BRAND_SUBTITLE = "The regression safety net for AI agents and the humans they work for \u2014 web, desktop, terminal, anything. Zero adoption cost, runs anywhere.";
|
package/dist/brand.js
CHANGED
|
@@ -6,4 +6,4 @@
|
|
|
6
6
|
export const BRAND_NAME = process.env.BRAND_NAME?.trim() || "Dungbeetle";
|
|
7
7
|
// Shared brand copy — one source, mirrored by the README/docs (which build
|
|
8
8
|
// separately and can't import this).
|
|
9
|
-
export const BRAND_SUBTITLE = "
|
|
9
|
+
export const BRAND_SUBTITLE = "The regression safety net for AI agents and the humans they work for — web, desktop, terminal, anything. Zero adoption cost, runs anywhere.";
|
package/dist/cloud.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
export declare const DEFAULT_SERVER_URL = "https://dungbeetle.example.com";
|
|
1
2
|
export type ClientCredentials = {
|
|
2
3
|
clientId: string;
|
|
3
4
|
clientSecret: string;
|
|
4
5
|
};
|
|
5
|
-
export type
|
|
6
|
+
export type AgentTokenCredentials = {
|
|
7
|
+
token: string;
|
|
8
|
+
};
|
|
9
|
+
export type CloudCredentials = ClientCredentials | AgentTokenCredentials;
|
|
10
|
+
export type PushOptions = CloudCredentials & {
|
|
6
11
|
serverUrl: string;
|
|
7
12
|
reportPath: string;
|
|
8
13
|
branch?: string;
|
|
@@ -22,7 +27,7 @@ export type BaselineSource = {
|
|
|
22
27
|
snapshotPath: string;
|
|
23
28
|
screenshotPath?: string;
|
|
24
29
|
};
|
|
25
|
-
export type PushBaselinesOptions =
|
|
30
|
+
export type PushBaselinesOptions = CloudCredentials & {
|
|
26
31
|
serverUrl: string;
|
|
27
32
|
baselines: BaselineSource[];
|
|
28
33
|
};
|
|
@@ -40,3 +45,27 @@ export declare function pushAnonReport(options: {
|
|
|
40
45
|
serverUrl: string;
|
|
41
46
|
report: unknown;
|
|
42
47
|
}): Promise<AnonPushResult>;
|
|
48
|
+
export type DeviceAuthorization = {
|
|
49
|
+
deviceCode: string;
|
|
50
|
+
userCode: string;
|
|
51
|
+
verificationUri: string;
|
|
52
|
+
verificationUriComplete: string;
|
|
53
|
+
expiresIn: number;
|
|
54
|
+
interval: number;
|
|
55
|
+
};
|
|
56
|
+
export type IssuedAgentToken = {
|
|
57
|
+
token: string;
|
|
58
|
+
label: string;
|
|
59
|
+
scopes: string[];
|
|
60
|
+
repository: {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export declare function requestDeviceCode(options: {
|
|
66
|
+
serverUrl: string;
|
|
67
|
+
label: string;
|
|
68
|
+
scopes?: string[];
|
|
69
|
+
}): Promise<DeviceAuthorization>;
|
|
70
|
+
export declare function waitForDeviceToken(serverUrl: string, authorization: DeviceAuthorization): Promise<IssuedAgentToken>;
|
|
71
|
+
export declare function revokeAgentToken(serverUrl: string, token: string): Promise<boolean>;
|
package/dist/cloud.js
CHANGED
|
@@ -2,6 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { isErrno, isRecord } from "./guards.js";
|
|
4
4
|
import { BRAND_NAME } from "./brand.js";
|
|
5
|
+
import { stripControlChars } from "./terminal/ansi.js";
|
|
6
|
+
// The hosted Dungbeetle cloud — where every cloud command points by default.
|
|
7
|
+
// `--server` / `DUNGBEETLE_SERVER_URL` exist for self-hosted deployments only;
|
|
8
|
+
// users of the hosted service should never need to set them.
|
|
9
|
+
export const DEFAULT_SERVER_URL = "https://dungbeetle.example.com";
|
|
5
10
|
// The API major version this CLI speaks. The server echoes its own version in the
|
|
6
11
|
// `Dungbeetle-Api-Version` header on every API response; a mismatch means the CLI and
|
|
7
12
|
// server are incompatible, so we fail with a clear message rather than confusing
|
|
@@ -9,7 +14,10 @@ import { BRAND_NAME } from "./brand.js";
|
|
|
9
14
|
const CLIENT_API_VERSION = 1;
|
|
10
15
|
const API_BASE = `/api/v${CLIENT_API_VERSION}`;
|
|
11
16
|
function assertApiVersion(response, origin) {
|
|
12
|
-
const
|
|
17
|
+
const raw = response.headers.get("dungbeetle-api-version");
|
|
18
|
+
// The header is server-controlled; strip control chars before echoing it into
|
|
19
|
+
// a terminal error (a hostile server could otherwise inject ANSI escapes).
|
|
20
|
+
const serverVersion = raw === null ? null : stripControlChars(raw).slice(0, 32);
|
|
13
21
|
if (serverVersion !== null && serverVersion !== String(CLIENT_API_VERSION)) {
|
|
14
22
|
throw new Error(`${BRAND_NAME} API version mismatch: this CLI speaks v${CLIENT_API_VERSION} but ${origin} ` +
|
|
15
23
|
`speaks v${serverVersion}. Upgrade the CLI or the server so they match.`);
|
|
@@ -175,10 +183,11 @@ export async function pushBaselines(options) {
|
|
|
175
183
|
}
|
|
176
184
|
return uploaded;
|
|
177
185
|
}
|
|
178
|
-
//
|
|
179
|
-
// exposed to anyone on-path. Warn loudly for
|
|
180
|
-
// (local dev) stays quiet. Localhost over
|
|
181
|
-
// a real credential-exposure risk that
|
|
186
|
+
// Credentials (Basic pairs, agent tokens, device codes) travel in the clear
|
|
187
|
+
// over plaintext http://, exposed to anyone on-path. Warn loudly for
|
|
188
|
+
// non-loopback http targets; loopback (local dev) stays quiet. Localhost over
|
|
189
|
+
// http is fine; a remote http server is a real credential-exposure risk that
|
|
190
|
+
// deserves a heads-up.
|
|
182
191
|
function warnIfInsecure(endpoint) {
|
|
183
192
|
if (endpoint.protocol === "https:") {
|
|
184
193
|
return;
|
|
@@ -186,7 +195,7 @@ function warnIfInsecure(endpoint) {
|
|
|
186
195
|
const host = endpoint.hostname;
|
|
187
196
|
const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
188
197
|
if (!isLoopback) {
|
|
189
|
-
process.stderr.write(`warning: sending
|
|
198
|
+
process.stderr.write(`warning: sending credentials over plaintext ${endpoint.protocol}// to ${endpoint.host}. ` +
|
|
190
199
|
`Use https:// so they are not exposed on the network.\n`);
|
|
191
200
|
}
|
|
192
201
|
}
|
|
@@ -219,6 +228,11 @@ function basicAuthHeader(credentials) {
|
|
|
219
228
|
const basic = Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`, "utf8").toString("base64");
|
|
220
229
|
return `Basic ${basic}`;
|
|
221
230
|
}
|
|
231
|
+
// Client-credential pairs go out as HTTP Basic (unchanged); agent tokens as
|
|
232
|
+
// Bearer. Every authenticated call funnels through here.
|
|
233
|
+
function authHeader(credentials) {
|
|
234
|
+
return "token" in credentials ? `Bearer ${credentials.token}` : basicAuthHeader(credentials);
|
|
235
|
+
}
|
|
222
236
|
async function postJson(serverUrl, pathname, credentials, body) {
|
|
223
237
|
const endpoint = new URL(pathname, serverUrl);
|
|
224
238
|
warnIfInsecure(endpoint);
|
|
@@ -228,7 +242,7 @@ async function postJson(serverUrl, pathname, credentials, body) {
|
|
|
228
242
|
method: "POST",
|
|
229
243
|
headers: {
|
|
230
244
|
"content-type": "application/json",
|
|
231
|
-
authorization:
|
|
245
|
+
authorization: authHeader(credentials)
|
|
232
246
|
},
|
|
233
247
|
body: JSON.stringify(body)
|
|
234
248
|
});
|
|
@@ -271,6 +285,173 @@ export async function pushAnonReport(options) {
|
|
|
271
285
|
}
|
|
272
286
|
return { id: run.id, url: run.url };
|
|
273
287
|
}
|
|
288
|
+
// Start a device authorization: the server hands back a short user code for the
|
|
289
|
+
// human and a high-entropy device code this CLI polls with.
|
|
290
|
+
export async function requestDeviceCode(options) {
|
|
291
|
+
const endpoint = new URL("/auth/device/code", options.serverUrl);
|
|
292
|
+
warnIfInsecure(endpoint);
|
|
293
|
+
let response;
|
|
294
|
+
try {
|
|
295
|
+
response = await fetch(endpoint, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "content-type": "application/json" },
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
label: options.label,
|
|
300
|
+
...(options.scopes && options.scopes.length > 0 ? { scopes: options.scopes } : {})
|
|
301
|
+
})
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
throw new Error(`Could not reach ${BRAND_NAME} server at ${endpoint.origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
306
|
+
}
|
|
307
|
+
if (response.status === 429) {
|
|
308
|
+
const retryAfter = retryAfterSeconds(response);
|
|
309
|
+
throw new Error(`The server is rate-limiting sign-in requests; try again ${retryAfter !== undefined ? `in ${retryAfter}s` : "shortly"}.`);
|
|
310
|
+
}
|
|
311
|
+
if (!response.ok) {
|
|
312
|
+
const detail = await safeErrorMessage(response);
|
|
313
|
+
throw new Error(`Sign-in failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}.`);
|
|
314
|
+
}
|
|
315
|
+
const body = await readJsonCapped(response);
|
|
316
|
+
if (!isRecord(body) ||
|
|
317
|
+
typeof body.device_code !== "string" ||
|
|
318
|
+
typeof body.user_code !== "string" ||
|
|
319
|
+
typeof body.verification_uri !== "string" ||
|
|
320
|
+
typeof body.verification_uri_complete !== "string" ||
|
|
321
|
+
typeof body.expires_in !== "number" ||
|
|
322
|
+
typeof body.interval !== "number") {
|
|
323
|
+
throw new Error("Server response did not include a valid device authorization.");
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
deviceCode: body.device_code,
|
|
327
|
+
userCode: body.user_code,
|
|
328
|
+
verificationUri: body.verification_uri,
|
|
329
|
+
verificationUriComplete: body.verification_uri_complete,
|
|
330
|
+
expiresIn: body.expires_in,
|
|
331
|
+
interval: body.interval
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
// Poll until the human approves in the browser and the token is minted.
|
|
335
|
+
// Respects the server-suggested interval, backs off 5s on `slow_down` (honoring
|
|
336
|
+
// a Retry-After header when present), and gives up with a clear message when
|
|
337
|
+
// the authorization expires or is denied.
|
|
338
|
+
export async function waitForDeviceToken(serverUrl, authorization) {
|
|
339
|
+
// Clamp server-supplied timing so a hostile or buggy server can neither make
|
|
340
|
+
// us hot-loop (interval 0) nor hang for hours (absurd interval / expiry). The
|
|
341
|
+
// whole flow is bounded to at most an hour; each poll waits 1–120s.
|
|
342
|
+
const MIN_INTERVAL_S = 1;
|
|
343
|
+
const MAX_INTERVAL_S = 120;
|
|
344
|
+
const MAX_LIFETIME_MS = 60 * 60 * 1000;
|
|
345
|
+
const clampInterval = (s) => Number.isFinite(s) ? Math.min(Math.max(s, MIN_INTERVAL_S), MAX_INTERVAL_S) : MIN_INTERVAL_S;
|
|
346
|
+
const lifetimeMs = Number.isFinite(authorization.expiresIn)
|
|
347
|
+
? Math.min(Math.max(authorization.expiresIn, 0) * 1000, MAX_LIFETIME_MS)
|
|
348
|
+
: MAX_LIFETIME_MS;
|
|
349
|
+
const deadline = Date.now() + lifetimeMs;
|
|
350
|
+
let intervalSeconds = clampInterval(authorization.interval);
|
|
351
|
+
for (;;) {
|
|
352
|
+
// Never sleep past the deadline, so timeout is honored promptly even with a
|
|
353
|
+
// large interval.
|
|
354
|
+
const waitMs = Math.min(intervalSeconds * 1000, deadline - Date.now());
|
|
355
|
+
if (waitMs > 0) {
|
|
356
|
+
await sleep(waitMs);
|
|
357
|
+
}
|
|
358
|
+
if (Date.now() >= deadline) {
|
|
359
|
+
throw new Error("Timed out waiting for approval — the sign-in code expired. Run `dungbeetle login` to start over.");
|
|
360
|
+
}
|
|
361
|
+
const poll = await pollDeviceToken(serverUrl, authorization.deviceCode);
|
|
362
|
+
if (poll.status === "issued") {
|
|
363
|
+
return poll.issued;
|
|
364
|
+
}
|
|
365
|
+
if (poll.status === "slow_down") {
|
|
366
|
+
intervalSeconds = clampInterval(Math.max(intervalSeconds + 5, poll.retryAfterSeconds ?? 0));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async function pollDeviceToken(serverUrl, deviceCode) {
|
|
371
|
+
const endpoint = new URL("/auth/device/token", serverUrl);
|
|
372
|
+
let response;
|
|
373
|
+
try {
|
|
374
|
+
response = await fetch(endpoint, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "content-type": "application/json" },
|
|
377
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
378
|
+
// Bound each poll so a server that never finishes the response can't hang
|
|
379
|
+
// the loop indefinitely (the deadline is only checked between polls).
|
|
380
|
+
signal: AbortSignal.timeout(30_000)
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
throw new Error(`Could not reach ${BRAND_NAME} server at ${endpoint.origin}: ${error instanceof Error ? error.message : String(error)}`);
|
|
385
|
+
}
|
|
386
|
+
if (response.status === 429) {
|
|
387
|
+
return { status: "slow_down", retryAfterSeconds: retryAfterSeconds(response) };
|
|
388
|
+
}
|
|
389
|
+
if (response.ok) {
|
|
390
|
+
const body = await readJsonCapped(response);
|
|
391
|
+
if (!isRecord(body) ||
|
|
392
|
+
typeof body.token !== "string" ||
|
|
393
|
+
typeof body.label !== "string" ||
|
|
394
|
+
!Array.isArray(body.scopes) ||
|
|
395
|
+
!isRecord(body.repository) ||
|
|
396
|
+
typeof body.repository.id !== "string" ||
|
|
397
|
+
typeof body.repository.name !== "string") {
|
|
398
|
+
throw new Error("Server response did not include a valid agent token.");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
status: "issued",
|
|
402
|
+
issued: {
|
|
403
|
+
token: body.token,
|
|
404
|
+
label: body.label,
|
|
405
|
+
scopes: body.scopes.filter((scope) => typeof scope === "string"),
|
|
406
|
+
repository: { id: body.repository.id, name: body.repository.name }
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const error = await safeErrorMessage(response);
|
|
411
|
+
switch (error) {
|
|
412
|
+
case "authorization_pending":
|
|
413
|
+
return { status: "pending" };
|
|
414
|
+
case "slow_down":
|
|
415
|
+
return { status: "slow_down", retryAfterSeconds: retryAfterSeconds(response) };
|
|
416
|
+
case "access_denied":
|
|
417
|
+
throw new Error("The request was denied.");
|
|
418
|
+
case "expired_token":
|
|
419
|
+
throw new Error("The sign-in code expired before it was approved. Run `dungbeetle login` to start over.");
|
|
420
|
+
default:
|
|
421
|
+
throw new Error(`Sign-in failed (HTTP ${response.status})${error ? `: ${error}` : ""}.`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// Best-effort self-revoke of an agent token (a Bearer token may always revoke
|
|
425
|
+
// itself). Returns whether the server confirmed the revocation — `logout`
|
|
426
|
+
// removes the local credential either way.
|
|
427
|
+
export async function revokeAgentToken(serverUrl, token) {
|
|
428
|
+
try {
|
|
429
|
+
const endpoint = new URL(`${API_BASE}/token`, serverUrl);
|
|
430
|
+
warnIfInsecure(endpoint);
|
|
431
|
+
const response = await fetch(endpoint, {
|
|
432
|
+
method: "DELETE",
|
|
433
|
+
headers: { authorization: `Bearer ${token}` }
|
|
434
|
+
});
|
|
435
|
+
return response.ok;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function retryAfterSeconds(response) {
|
|
442
|
+
// headers.get() returns null when absent — Number(null) is 0, not NaN, so an
|
|
443
|
+
// explicit null check is needed to avoid reporting a bogus "0s" wait. Only the
|
|
444
|
+
// numeric (delta-seconds) form is honored; the HTTP-date form is ignored.
|
|
445
|
+
const header = response.headers.get("retry-after");
|
|
446
|
+
if (header === null) {
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
const value = Number(header);
|
|
450
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
451
|
+
}
|
|
452
|
+
function sleep(ms) {
|
|
453
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
454
|
+
}
|
|
274
455
|
// Upload one screenshot artifact as raw bytes, content-addressed by `digest`.
|
|
275
456
|
async function putArtifact(options, digest, data) {
|
|
276
457
|
const endpoint = new URL(`${API_BASE}/screenshots/${digest}`, options.serverUrl);
|
|
@@ -280,7 +461,7 @@ async function putArtifact(options, digest, data) {
|
|
|
280
461
|
method: "PUT",
|
|
281
462
|
headers: {
|
|
282
463
|
"content-type": "application/octet-stream",
|
|
283
|
-
authorization:
|
|
464
|
+
authorization: authHeader(options)
|
|
284
465
|
},
|
|
285
466
|
body: new Uint8Array(data)
|
|
286
467
|
});
|
|
@@ -326,7 +507,13 @@ async function readJsonCapped(response) {
|
|
|
326
507
|
async function safeErrorMessage(response) {
|
|
327
508
|
try {
|
|
328
509
|
const body = (await readJsonCapped(response));
|
|
329
|
-
|
|
510
|
+
if (!isRecord(body) || typeof body.error !== "string") {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
// The message is server-controlled and gets printed straight to the user's
|
|
514
|
+
// terminal, so strip control chars (ANSI-escape injection) and cap length —
|
|
515
|
+
// matching how server-supplied labels/URLs are already sanitized elsewhere.
|
|
516
|
+
return stripControlChars(body.error).slice(0, 500);
|
|
330
517
|
}
|
|
331
518
|
catch {
|
|
332
519
|
return undefined;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type StoredCredential = {
|
|
2
|
+
token: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
repository?: {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
createdAt?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function credentialsPath(): string;
|
|
11
|
+
export declare function loadStoredCredential(serverUrl: string): Promise<StoredCredential | null>;
|
|
12
|
+
export declare function saveStoredCredential(serverUrl: string, entry: StoredCredential): Promise<void>;
|
|
13
|
+
export declare function deleteStoredCredential(serverUrl: string): Promise<boolean>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Persistent agent-token store — the CLI's first on-disk credential.
|
|
2
|
+
//
|
|
3
|
+
// `dungbeetle login` saves the agent token minted by the device flow here, keyed
|
|
4
|
+
// by server origin, so `push`/`push-baselines` work without env vars. Deliberately
|
|
5
|
+
// a plain file (0600, in $XDG_CONFIG_HOME) rather than a keychain so it works
|
|
6
|
+
// headless — CI containers and remote agents — exactly like `gh`/`flyctl`.
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { isRecord } from "./guards.js";
|
|
12
|
+
// Where credentials live: $XDG_CONFIG_HOME/dungbeetle/credentials.json, falling
|
|
13
|
+
// back to ~/.config. Exported so commands can tell the user where their
|
|
14
|
+
// credential is stored (or was removed from).
|
|
15
|
+
export function credentialsPath() {
|
|
16
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
17
|
+
const base = xdg && xdg.trim() !== "" ? xdg : path.join(os.homedir(), ".config");
|
|
18
|
+
return path.join(base, "dungbeetle", "credentials.json");
|
|
19
|
+
}
|
|
20
|
+
// Credentials are keyed by URL origin so `--server http://srv:3000/` and
|
|
21
|
+
// `--server http://srv:3000` resolve to the same entry.
|
|
22
|
+
function serverOrigin(serverUrl) {
|
|
23
|
+
return new URL(serverUrl).origin;
|
|
24
|
+
}
|
|
25
|
+
export async function loadStoredCredential(serverUrl) {
|
|
26
|
+
const store = await readStore();
|
|
27
|
+
return store.servers[serverOrigin(serverUrl)] ?? null;
|
|
28
|
+
}
|
|
29
|
+
export async function saveStoredCredential(serverUrl, entry) {
|
|
30
|
+
const store = await readStore();
|
|
31
|
+
store.servers[serverOrigin(serverUrl)] = entry;
|
|
32
|
+
await writeStore(store);
|
|
33
|
+
}
|
|
34
|
+
// Remove the entry for a server. Returns whether anything was actually removed,
|
|
35
|
+
// so `logout` can distinguish "signed out" from "was never signed in".
|
|
36
|
+
export async function deleteStoredCredential(serverUrl) {
|
|
37
|
+
const store = await readStore();
|
|
38
|
+
const origin = serverOrigin(serverUrl);
|
|
39
|
+
if (!(origin in store.servers)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
delete store.servers[origin];
|
|
43
|
+
await writeStore(store);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// A corrupt or unreadable file must never brick the CLI: treat it as empty and
|
|
47
|
+
// let the next save rewrite it. Entries without a token string are dropped.
|
|
48
|
+
async function readStore() {
|
|
49
|
+
const empty = { version: 1, servers: {} };
|
|
50
|
+
let raw;
|
|
51
|
+
try {
|
|
52
|
+
raw = await readFile(credentialsPath(), "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return empty;
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return empty;
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(parsed) || !isRecord(parsed.servers)) {
|
|
65
|
+
return empty;
|
|
66
|
+
}
|
|
67
|
+
const servers = {};
|
|
68
|
+
for (const [origin, entry] of Object.entries(parsed.servers)) {
|
|
69
|
+
if (!isRecord(entry) || typeof entry.token !== "string" || entry.token.length === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const repository = isRecord(entry.repository) &&
|
|
73
|
+
typeof entry.repository.id === "string" &&
|
|
74
|
+
typeof entry.repository.name === "string"
|
|
75
|
+
? { id: entry.repository.id, name: entry.repository.name }
|
|
76
|
+
: undefined;
|
|
77
|
+
servers[origin] = {
|
|
78
|
+
token: entry.token,
|
|
79
|
+
...(typeof entry.label === "string" ? { label: entry.label } : {}),
|
|
80
|
+
...(repository ? { repository } : {}),
|
|
81
|
+
...(typeof entry.createdAt === "string" ? { createdAt: entry.createdAt } : {})
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { version: 1, servers };
|
|
85
|
+
}
|
|
86
|
+
// Write atomically (tmp file + rename) so a crash mid-write can never leave a
|
|
87
|
+
// truncated store, and keep the file owner-only: it holds live tokens.
|
|
88
|
+
async function writeStore(store) {
|
|
89
|
+
const filePath = credentialsPath();
|
|
90
|
+
const dir = path.dirname(filePath);
|
|
91
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
92
|
+
const tmpPath = path.join(dir, `.credentials.${randomBytes(6).toString("hex")}.tmp`);
|
|
93
|
+
await writeFile(tmpPath, `${JSON.stringify(store, null, 2)}\n`, {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
mode: 0o600
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await rename(tmpPath, filePath);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
await rm(tmpPath, { force: true });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
// The creation mode is masked by the process umask; chmod pins it regardless.
|
|
105
|
+
await chmod(filePath, 0o600);
|
|
106
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
3
3
|
// Dungbeetle CLI — Copyright 2026 DungbeetleDev. See LICENSE.
|
|
4
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import { Command } from "commander";
|
|
@@ -13,7 +14,8 @@ import { renderConsoleReport, writeHtmlReport, writeJsonReport } from "./reporte
|
|
|
13
14
|
import { buildScreenshotComparison, screenshotBuffer, testBaselines, updateBaselines } from "./runner.js";
|
|
14
15
|
import { startManagedLifecycle } from "./lifecycle.js";
|
|
15
16
|
import { Spinner } from "./tty.js";
|
|
16
|
-
import { pushAnonReport, pushBaselines, pushReport } from "./cloud.js";
|
|
17
|
+
import { DEFAULT_SERVER_URL, pushAnonReport, pushBaselines, pushReport, requestDeviceCode, revokeAgentToken, waitForDeviceToken } from "./cloud.js";
|
|
18
|
+
import { credentialsPath, deleteStoredCredential, loadStoredCredential, saveStoredCredential } from "./credentials.js";
|
|
17
19
|
import { baselinePathForTarget } from "./baselines.js";
|
|
18
20
|
import { captureTarget } from "./capture.js";
|
|
19
21
|
import { compareSnapshots } from "./compare.js";
|
|
@@ -191,8 +193,8 @@ program
|
|
|
191
193
|
.command("push")
|
|
192
194
|
.description(`Upload a run report to a ${BRAND_NAME} cloud server.`)
|
|
193
195
|
.option("--report <path>", "Path to a JSON report produced by test/ci")
|
|
194
|
-
.option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
|
|
195
|
-
.option("--client-id <id>", "Repository client id", process.env.DUNGBEETLE_CLIENT_ID)
|
|
196
|
+
.option("--server <url>", "Server base URL (set only when self-hosting)", process.env.DUNGBEETLE_SERVER_URL ?? DEFAULT_SERVER_URL)
|
|
197
|
+
.option("--client-id <id>", "Repository client id (optional after `dungbeetle login`)", process.env.DUNGBEETLE_CLIENT_ID)
|
|
196
198
|
.option("--client-secret <secret>", "Repository client secret (prefer DUNGBEETLE_CLIENT_SECRET — flags leak into shell history and process lists)", process.env.DUNGBEETLE_CLIENT_SECRET)
|
|
197
199
|
.option("--branch <name>", "Branch label to attach to the run")
|
|
198
200
|
.option("--commit <sha>", "Commit SHA to attach to the run")
|
|
@@ -200,7 +202,7 @@ program
|
|
|
200
202
|
if (!options.server) {
|
|
201
203
|
throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
|
|
202
204
|
}
|
|
203
|
-
const credentials =
|
|
205
|
+
const credentials = await resolveCloudCredentials(options.server, options);
|
|
204
206
|
if (!options.report) {
|
|
205
207
|
throw new Error("Missing report path; pass --report <path>.");
|
|
206
208
|
}
|
|
@@ -227,15 +229,15 @@ program
|
|
|
227
229
|
.description(`Upload local baselines to a ${BRAND_NAME} cloud server (versioned).`)
|
|
228
230
|
.option("--config <path>", "Config path")
|
|
229
231
|
.option("--cwd <path>", "Project directory", process.cwd())
|
|
230
|
-
.option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
|
|
231
|
-
.option("--client-id <id>", "Repository client id", process.env.DUNGBEETLE_CLIENT_ID)
|
|
232
|
+
.option("--server <url>", "Server base URL (set only when self-hosting)", process.env.DUNGBEETLE_SERVER_URL ?? DEFAULT_SERVER_URL)
|
|
233
|
+
.option("--client-id <id>", "Repository client id (optional after `dungbeetle login`)", process.env.DUNGBEETLE_CLIENT_ID)
|
|
232
234
|
.option("--client-secret <secret>", "Repository client secret (prefer DUNGBEETLE_CLIENT_SECRET — flags leak into shell history and process lists)", process.env.DUNGBEETLE_CLIENT_SECRET)
|
|
233
235
|
.option("--target <name...>", "Only upload selected target names")
|
|
234
236
|
.action(async (options) => {
|
|
235
237
|
if (!options.server) {
|
|
236
238
|
throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
|
|
237
239
|
}
|
|
238
|
-
const credentials =
|
|
240
|
+
const credentials = await resolveCloudCredentials(options.server, options);
|
|
239
241
|
const cwd = path.resolve(options.cwd);
|
|
240
242
|
const config = await loadConfig(options.config, cwd);
|
|
241
243
|
const selected = new Set(options.target ?? []);
|
|
@@ -271,6 +273,64 @@ program
|
|
|
271
273
|
console.log(`${item.target} → v${item.version} (${note})`);
|
|
272
274
|
}
|
|
273
275
|
});
|
|
276
|
+
program
|
|
277
|
+
.command("login")
|
|
278
|
+
.description(`Connect this machine to a ${BRAND_NAME} cloud server as an agent.`)
|
|
279
|
+
.option("--server <url>", "Server base URL (set only when self-hosting)", process.env.DUNGBEETLE_SERVER_URL ?? DEFAULT_SERVER_URL)
|
|
280
|
+
.option("--label <name>", "Agent label shown in the dashboard", `dungbeetle CLI on ${os.hostname()}`)
|
|
281
|
+
.option("--scopes <scopes...>", "Requested scopes (e.g. runs:write baselines:write)")
|
|
282
|
+
.action(async (options) => {
|
|
283
|
+
if (!options.server) {
|
|
284
|
+
throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
|
|
285
|
+
}
|
|
286
|
+
const authorization = await requestDeviceCode({
|
|
287
|
+
serverUrl: options.server,
|
|
288
|
+
label: options.label,
|
|
289
|
+
scopes: options.scopes
|
|
290
|
+
});
|
|
291
|
+
// The code and URL come from a server the user can point anywhere — strip
|
|
292
|
+
// control characters and validate the URL before echoing, like run URLs.
|
|
293
|
+
console.log(`First, copy your one-time code: ${stripControlChars(authorization.userCode)}`);
|
|
294
|
+
console.log(`Then approve this agent in your browser: ${safeUrl(authorization.verificationUriComplete)}`);
|
|
295
|
+
console.log("Waiting for approval…");
|
|
296
|
+
const issued = await waitForDeviceToken(options.server, authorization);
|
|
297
|
+
await saveStoredCredential(options.server, {
|
|
298
|
+
token: issued.token,
|
|
299
|
+
label: issued.label,
|
|
300
|
+
repository: issued.repository,
|
|
301
|
+
createdAt: new Date().toISOString()
|
|
302
|
+
});
|
|
303
|
+
console.log(`Connected as "${stripControlChars(issued.label)}" to repository ` +
|
|
304
|
+
`${stripControlChars(issued.repository.name)}.`);
|
|
305
|
+
console.log(`Scopes: ${issued.scopes.map((scope) => stripControlChars(scope)).join(" ")}`);
|
|
306
|
+
console.log(`Credential stored at ${credentialsPath()}.`);
|
|
307
|
+
});
|
|
308
|
+
program
|
|
309
|
+
.command("logout")
|
|
310
|
+
.description(`Revoke this machine's agent token on a ${BRAND_NAME} cloud server and forget it.`)
|
|
311
|
+
.option("--server <url>", "Server base URL (set only when self-hosting)", process.env.DUNGBEETLE_SERVER_URL ?? DEFAULT_SERVER_URL)
|
|
312
|
+
.action(async (options) => {
|
|
313
|
+
if (!options.server) {
|
|
314
|
+
throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
|
|
315
|
+
}
|
|
316
|
+
const origin = new URL(options.server).origin;
|
|
317
|
+
const stored = await loadStoredCredential(options.server);
|
|
318
|
+
if (!stored) {
|
|
319
|
+
console.log(`No stored credential for ${origin}; nothing to do.`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Best-effort remote revoke: the local credential is removed either way, so
|
|
323
|
+
// a dead server can't keep you "logged in".
|
|
324
|
+
const revoked = await revokeAgentToken(options.server, stored.token);
|
|
325
|
+
await deleteStoredCredential(options.server);
|
|
326
|
+
if (revoked) {
|
|
327
|
+
console.log(`Revoked the agent token on ${origin} and removed it from ${credentialsPath()}.`);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
console.log(`Could not revoke the token on ${origin} (it may already be revoked); ` +
|
|
331
|
+
`removed it from ${credentialsPath()}.`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
274
334
|
program
|
|
275
335
|
.command("anon")
|
|
276
336
|
.description(`Capture a target and share it on a ${BRAND_NAME} cloud server — no account needed.`)
|
|
@@ -279,7 +339,7 @@ program
|
|
|
279
339
|
.option("--screenshot", "Also capture full-page screenshots (needs a Chrome/Chromium)", false)
|
|
280
340
|
.option("--cmd <command>", "Capture a terminal command's output instead of a URL")
|
|
281
341
|
.option("--name <name>", "Label for the capture")
|
|
282
|
-
.option("--server <url>", "Server base URL", process.env.DUNGBEETLE_SERVER_URL)
|
|
342
|
+
.option("--server <url>", "Server base URL (set only when self-hosting)", process.env.DUNGBEETLE_SERVER_URL ?? DEFAULT_SERVER_URL)
|
|
283
343
|
.action(async (url, options) => {
|
|
284
344
|
if (!options.server) {
|
|
285
345
|
throw new Error("Missing server URL; pass --server or set DUNGBEETLE_SERVER_URL.");
|
|
@@ -438,16 +498,26 @@ program.parseAsync(process.argv).catch((error) => {
|
|
|
438
498
|
console.error(error instanceof Error ? error.message : String(error));
|
|
439
499
|
process.exitCode = 1;
|
|
440
500
|
});
|
|
441
|
-
// Both upload commands need
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
501
|
+
// Both upload commands need cloud credentials. Explicit client id/secret —
|
|
502
|
+
// flags or the DUNGBEETLE_CLIENT_ID / DUNGBEETLE_CLIENT_SECRET environment
|
|
503
|
+
// vars — win and go out as HTTP Basic, exactly as before. Otherwise fall back
|
|
504
|
+
// to an agent token stored by `dungbeetle login` for this server (Bearer).
|
|
505
|
+
async function resolveCloudCredentials(serverUrl, options) {
|
|
506
|
+
if (options.clientId || options.clientSecret) {
|
|
507
|
+
if (!options.clientId) {
|
|
508
|
+
throw new Error("Missing client id; pass --client-id or set DUNGBEETLE_CLIENT_ID.");
|
|
509
|
+
}
|
|
510
|
+
if (!options.clientSecret) {
|
|
511
|
+
throw new Error("Missing client secret; pass --client-secret or set DUNGBEETLE_CLIENT_SECRET.");
|
|
512
|
+
}
|
|
513
|
+
return { clientId: options.clientId, clientSecret: options.clientSecret };
|
|
446
514
|
}
|
|
447
|
-
|
|
448
|
-
|
|
515
|
+
const stored = await loadStoredCredential(serverUrl);
|
|
516
|
+
if (stored) {
|
|
517
|
+
return { token: stored.token };
|
|
449
518
|
}
|
|
450
|
-
|
|
519
|
+
throw new Error("No cloud credentials found; run `dungbeetle login`, or pass --client-id/--client-secret " +
|
|
520
|
+
"(or set DUNGBEETLE_CLIENT_ID / DUNGBEETLE_CLIENT_SECRET).");
|
|
451
521
|
}
|
|
452
522
|
async function exists(filePath) {
|
|
453
523
|
try {
|