dondo-donuts 0.1.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/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ const env = (key: string) => {
5
+ const value = process.env[key]?.trim();
6
+ return value ? value : undefined;
7
+ };
8
+
9
+ const appDataDir = () => {
10
+ if (process.platform === 'darwin') {
11
+ return join(homedir(), 'Library', 'Application Support', 'Dondo');
12
+ }
13
+ if (process.platform === 'win32') {
14
+ return join(env('LOCALAPPDATA') ?? join(homedir(), 'AppData', 'Local'), 'Dondo', 'Data');
15
+ }
16
+ return join(env('XDG_DATA_HOME') ?? join(homedir(), '.local', 'share'), 'dondo');
17
+ };
18
+
19
+ const parsePort = () => {
20
+ const raw = env('DONDO_PORT') ?? env('PORT') ?? '3000';
21
+ const port = Number(raw);
22
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
23
+ throw new Error(`Invalid port: ${raw}`);
24
+ }
25
+ return port;
26
+ };
27
+
28
+ export const HOST = '127.0.0.1';
29
+ export const PORT = parsePort();
30
+ export const DATA_DIR = env('DONDO_DATA_DIR') ?? appDataDir();
31
+ export const VAULT_PATH = env('DONDO_VAULT') ?? env('ANTIGRAVITY_VAULT') ?? join(DATA_DIR, 'vault.json');
32
+ export const CODEX_AUTH_PATH = env('CODEX_AUTH_PATH') ?? join(homedir(), '.codex', 'auth.json');
33
+
34
+ export const VAULT_KEY_SERVICE = 'dondo';
35
+ export const VAULT_KEY_ACCOUNT = 'vault-key';
36
+
37
+ export const ANTIGRAVITY_KEYCHAIN = env('ANTIGRAVITY_KEYCHAIN') ?? 'login.keychain-db';
38
+ export const ANTIGRAVITY_SERVICE = env('ANTIGRAVITY_SERVICE') ?? 'gemini';
39
+ export const ANTIGRAVITY_ACCOUNT = env('ANTIGRAVITY_ACCOUNT') ?? 'antigravity';
40
+
41
+ export const ANTIGRAVITY_VERSION = env('ANTIGRAVITY_VERSION') ?? '2.0.3';
42
+ export const GOOGLE_CLIENT_ID = env('GOOGLE_CLIENT_ID') ?? '';
43
+ export const GOOGLE_CLIENT_SECRET = env('GOOGLE_CLIENT_SECRET') ?? '';
44
+ export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
45
+ export const LOAD_PROJECT_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
46
+ export const QUOTA_URLS = [
47
+ 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
48
+ 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
49
+ 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
50
+ ];
51
+
52
+ export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
53
+ export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
54
+ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
55
+ export const CODEX_USER_AGENT = 'codex-cli/1.0.0';
@@ -0,0 +1,20 @@
1
+ import { expect, it } from 'bun:test';
2
+ import { assertAccountKey, redactSecrets } from './errors.ts';
3
+
4
+ it('should redact token-shaped values from public errors', () => {
5
+ const redacted = redactSecrets(
6
+ 'Bearer ya29.secret_token {"access_token":"abc","refresh_token":"def"} password: "plain"',
7
+ );
8
+
9
+ expect(redacted).not.toContain('secret_token');
10
+ expect(redacted).not.toContain('abc');
11
+ expect(redacted).not.toContain('def');
12
+ expect(redacted).not.toContain('plain');
13
+ expect(redacted).toContain('Bearer [redacted]');
14
+ });
15
+
16
+ it('should reject whitespace-only and padded account keys', () => {
17
+ expect(() => assertAccountKey(' ')).toThrow();
18
+ expect(() => assertAccountKey(' account ')).toThrow();
19
+ expect(assertAccountKey('account.one@example.com')).toBe('account.one@example.com');
20
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { LimitResult } from './types.ts';
2
+
3
+ export type PublicError = Error & {
4
+ status: number;
5
+ };
6
+
7
+ export const publicError = (status: number, message: string): PublicError => {
8
+ return Object.assign(new Error(message), { status });
9
+ };
10
+
11
+ export const redactSecrets = (value: unknown) => {
12
+ return String(value)
13
+ .replace(/Bearer\s+[^"\s]+/g, 'Bearer [redacted]')
14
+ .replace(/password:\s*"[^"]*"/gi, 'password: "[redacted]"')
15
+ .replace(
16
+ /(["']?(?:access_token|refresh_token|id_token|OPENAI_API_KEY)["']?\s*[:=]\s*["'])[^"']+(["'])/gi,
17
+ '$1[redacted]$2',
18
+ );
19
+ };
20
+
21
+ export const errorMessage = (error: unknown) => {
22
+ return redactSecrets(error instanceof Error ? error.message : String(error));
23
+ };
24
+
25
+ export const errorStatus = (error: unknown) => {
26
+ return typeof error === 'object' && error !== null && 'status' in error
27
+ ? Number((error as { status: unknown }).status) || 500
28
+ : 500;
29
+ };
30
+
31
+ export const cleanLimitError = (error: unknown): LimitResult => ({
32
+ error: errorMessage(error),
33
+ ok: false,
34
+ });
35
+
36
+ export const assertAccountKey = (key: string) => {
37
+ const trimmed = key.trim();
38
+ if (trimmed !== key || !/^[\w .@-]{1,80}$/.test(trimmed)) {
39
+ throw publicError(400, 'Use 1-80 letters, numbers, spaces, dots, @, _ or - with no leading/trailing spaces');
40
+ }
41
+ return trimmed;
42
+ };
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { createServer } from 'node:net';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ const HOST = '127.0.0.1';
8
+ const TIMEOUT_MS = 30_000;
9
+
10
+ type PackageManifest = {
11
+ name: string;
12
+ version: string;
13
+ };
14
+
15
+ type Probe = {
16
+ bodyText: string;
17
+ contentType: string | null;
18
+ ok: boolean;
19
+ status: number;
20
+ };
21
+
22
+ const packageTarballPath = (dir: string, manifest: PackageManifest) =>
23
+ join(dir, `${manifest.name}-${manifest.version}.tgz`);
24
+
25
+ const getAvailablePort = async () =>
26
+ new Promise<number>((resolve, reject) => {
27
+ const server = createServer();
28
+ server.unref();
29
+ server.once('error', reject);
30
+ server.listen(0, HOST, () => {
31
+ const address = server.address();
32
+ if (!address || typeof address === 'string') {
33
+ server.close(() => reject(new Error('Could not allocate a smoke-test port')));
34
+ return;
35
+ }
36
+ server.close(() => resolve(address.port));
37
+ });
38
+ });
39
+
40
+ const runCommand = async (argv: string[], cwd: string) => {
41
+ const proc = Bun.spawn(argv, { cwd, stderr: 'pipe', stdout: 'pipe' });
42
+ const [exitCode, stdoutText, stderrText] = await Promise.all([
43
+ proc.exited,
44
+ new Response(proc.stdout).text(),
45
+ new Response(proc.stderr).text(),
46
+ ]);
47
+ if (exitCode !== 0) {
48
+ throw new Error(`${argv.join(' ')} failed\n${stdoutText}\n${stderrText}`.trim());
49
+ }
50
+ };
51
+
52
+ const waitForHealthyUi = async (url: string) => {
53
+ const deadline = Date.now() + TIMEOUT_MS;
54
+ let lastError = '';
55
+
56
+ while (Date.now() < deadline) {
57
+ try {
58
+ const response = await fetch(url);
59
+ const probe: Probe = {
60
+ bodyText: await response.text(),
61
+ contentType: response.headers.get('content-type'),
62
+ ok: response.ok,
63
+ status: response.status,
64
+ };
65
+ if (
66
+ probe.ok &&
67
+ probe.contentType?.includes('text/html') &&
68
+ probe.bodyText.includes('<title>Dondo</title>') &&
69
+ probe.bodyText.includes('Dondo') &&
70
+ !probe.bodyText.includes('Welcome to Bun!')
71
+ ) {
72
+ return probe;
73
+ }
74
+ lastError = `HTTP ${probe.status}: ${probe.bodyText.slice(0, 120)}`;
75
+ } catch (error) {
76
+ lastError = error instanceof Error ? error.message : String(error);
77
+ }
78
+
79
+ await Bun.sleep(250);
80
+ }
81
+
82
+ throw new Error(`Timed out waiting for Dondo UI at ${url}${lastError ? ` (${lastError})` : ''}`);
83
+ };
84
+
85
+ describe('packaged UI smoke', () => {
86
+ it('should launch the UI server through the packaged bunx dondo path', async () => {
87
+ const manifest = (await Bun.file('package.json').json()) as PackageManifest;
88
+ const tempDir = await mkdtemp(join(tmpdir(), 'dondo-packaged-ui-smoke-'));
89
+ const port = await getAvailablePort();
90
+
91
+ try {
92
+ await runCommand(['bun', 'pm', 'pack', '--destination', tempDir], process.cwd());
93
+ await Bun.write(join(tempDir, 'package.json'), '{"name":"dondo-smoke","private":true}\n');
94
+
95
+ const proc = Bun.spawn(['bunx', '--package', packageTarballPath(tempDir, manifest), 'dondo'], {
96
+ cwd: tempDir,
97
+ env: {
98
+ ...process.env,
99
+ CODEX_AUTH_PATH: join(tempDir, 'auth.json'),
100
+ DONDO_DATA_DIR: join(tempDir, 'data'),
101
+ DONDO_PORT: String(port),
102
+ },
103
+ stderr: 'pipe',
104
+ stdout: 'pipe',
105
+ });
106
+ const stdoutPromise = new Response(proc.stdout).text();
107
+ const stderrPromise = new Response(proc.stderr).text();
108
+
109
+ try {
110
+ const probe = await waitForHealthyUi(`http://${HOST}:${port}/`);
111
+ expect(probe.status).toBe(200);
112
+ } catch (error) {
113
+ proc.kill();
114
+ const [stdoutText, stderrText] = await Promise.all([
115
+ stdoutPromise.catch(() => ''),
116
+ stderrPromise.catch(() => ''),
117
+ proc.exited.catch(() => undefined),
118
+ ]);
119
+ throw new Error(
120
+ [
121
+ error instanceof Error ? error.message : String(error),
122
+ stdoutText.trim() ? `stdout:\n${stdoutText}` : '',
123
+ stderrText.trim() ? `stderr:\n${stderrText}` : '',
124
+ ]
125
+ .filter(Boolean)
126
+ .join('\n\n'),
127
+ );
128
+ } finally {
129
+ proc.kill();
130
+ await Promise.all([
131
+ proc.exited.catch(() => undefined),
132
+ stdoutPromise.catch(() => ''),
133
+ stderrPromise.catch(() => ''),
134
+ ]);
135
+ }
136
+ } finally {
137
+ await rm(tempDir, { force: true, recursive: true });
138
+ }
139
+ }, 60_000);
140
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, it } from 'bun:test';
2
+ import { createFetch } from './server.ts';
3
+
4
+ const app = createFetch({
5
+ appJs: 'console.log("ok");',
6
+ css: 'body{}',
7
+ iconPng: Bun.file(new URL('../icon.png', import.meta.url).pathname),
8
+ iconSvg: '<svg />',
9
+ });
10
+
11
+ const json = async (response: Response) => (await response.json()) as { error?: string };
12
+
13
+ it('should apply security headers to the UI shell', async () => {
14
+ const response = await app(new Request('http://127.0.0.1:3000/'));
15
+
16
+ expect(response.status).toBe(200);
17
+ expect(response.headers.get('x-content-type-options')).toBe('nosniff');
18
+ expect(response.headers.get('x-frame-options')).toBe('DENY');
19
+ expect(response.headers.get('content-security-policy')).toContain("default-src 'self'");
20
+ });
21
+
22
+ it('should reject non-local API origins', async () => {
23
+ const response = await app(
24
+ new Request('http://127.0.0.1:3000/api/codex/state', {
25
+ headers: { Origin: 'https://example.com' },
26
+ }),
27
+ );
28
+
29
+ expect(response.status).toBe(403);
30
+ });
31
+
32
+ it('should reject unsupported API methods before reading a body', async () => {
33
+ const response = await app(new Request('http://127.0.0.1:3000/api/antigravity/save'));
34
+
35
+ expect(response.status).toBe(405);
36
+ expect(await json(response)).toEqual({ error: 'Method not allowed' });
37
+ });
38
+
39
+ it('should reject malformed JSON bodies before service calls', async () => {
40
+ const response = await app(
41
+ new Request('http://127.0.0.1:3000/api/antigravity/save', {
42
+ body: '{',
43
+ method: 'POST',
44
+ }),
45
+ );
46
+
47
+ expect(response.status).toBe(400);
48
+ expect(await json(response)).toEqual({ error: 'Invalid JSON body' });
49
+ });
50
+
51
+ it('should not expose token-shaped fields in API error responses', async () => {
52
+ const response = await app(
53
+ new Request('http://127.0.0.1:3000/api/antigravity/load', {
54
+ body: JSON.stringify({ key: 'Bearer ya29.secret' }),
55
+ method: 'POST',
56
+ }),
57
+ );
58
+ const text = await response.text();
59
+
60
+ expect(text).not.toContain('ya29.secret');
61
+ expect(text).not.toContain('access_token');
62
+ expect(text).not.toContain('refresh_token');
63
+ });
package/src/server.ts ADDED
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { antigravityState, clearAntigravity, loadAntigravity, saveAntigravity } from './antigravity/service.ts';
4
+ import { codexState, loadCodex, saveCodex } from './codex/service.ts';
5
+ import { HOST, PORT } from './config.ts';
6
+ import { errorMessage, errorStatus, publicError } from './errors.ts';
7
+ import { renderHtml } from './ui/html.ts';
8
+
9
+ type Assets = {
10
+ appJs: string;
11
+ css: string;
12
+ iconPng: ReturnType<typeof Bun.file>;
13
+ iconSvg: string;
14
+ };
15
+
16
+ type Route = {
17
+ handler: (req: Request) => Promise<Response>;
18
+ };
19
+
20
+ const SECURITY_HEADERS = {
21
+ 'Content-Security-Policy':
22
+ "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
23
+ 'X-Content-Type-Options': 'nosniff',
24
+ 'X-Frame-Options': 'DENY',
25
+ };
26
+
27
+ const API_RATE_LIMIT = {
28
+ hits: [] as number[],
29
+ max: 120,
30
+ windowMs: 10_000,
31
+ };
32
+
33
+ const buildAssets = async (): Promise<Assets> => {
34
+ const result = await Bun.build({
35
+ entrypoints: [new URL('./ui/client.tsx', import.meta.url).pathname],
36
+ jsx: {
37
+ importSource: 'preact',
38
+ runtime: 'automatic',
39
+ },
40
+ target: 'browser',
41
+ });
42
+ if (!result.success) {
43
+ throw new Error(result.logs.map((log) => log.message).join('\n') || 'Failed to build UI assets');
44
+ }
45
+
46
+ const js = result.outputs.find((output) => output.path.endsWith('.js'));
47
+ if (!js) {
48
+ throw new Error('UI build did not produce JavaScript');
49
+ }
50
+
51
+ return {
52
+ appJs: await js.text(),
53
+ css: await Bun.file(new URL('./ui/styles.css', import.meta.url).pathname).text(),
54
+ iconPng: Bun.file(new URL('../icon.png', import.meta.url).pathname),
55
+ iconSvg: await Bun.file(new URL('../icon.svg', import.meta.url).pathname).text(),
56
+ };
57
+ };
58
+
59
+ const withHeaders = (response: Response, headers: Record<string, string>) => {
60
+ const merged = new Headers(response.headers);
61
+ for (const [key, value] of Object.entries({ ...SECURITY_HEADERS, ...headers })) {
62
+ merged.set(key, value);
63
+ }
64
+ return new Response(response.body, {
65
+ headers: merged,
66
+ status: response.status,
67
+ statusText: response.statusText,
68
+ });
69
+ };
70
+
71
+ const json = (value: unknown, status = 200) => {
72
+ return withHeaders(Response.json(value, { status }), {});
73
+ };
74
+
75
+ const jsonError = (error: unknown) => {
76
+ return json({ error: errorMessage(error) }, errorStatus(error));
77
+ };
78
+
79
+ const localName = (host: string | null) => {
80
+ const lower = host?.toLowerCase();
81
+ const value = lower?.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower?.split(':')[0];
82
+ return value === 'localhost' || value === '127.0.0.1' || value === '[::1]' || value === '::1';
83
+ };
84
+
85
+ const assertLocalRequest = (req: Request) => {
86
+ const host = req.headers.get('host') ?? new URL(req.url).host;
87
+ if (!localName(host)) {
88
+ throw publicError(403, 'Only localhost requests are allowed');
89
+ }
90
+
91
+ const origin = req.headers.get('origin');
92
+ if (origin) {
93
+ try {
94
+ if (!localName(new URL(origin).host)) {
95
+ throw publicError(403, 'Only localhost origins are allowed');
96
+ }
97
+ } catch {
98
+ throw publicError(403, 'Only localhost origins are allowed');
99
+ }
100
+ }
101
+ };
102
+
103
+ const assertRateLimit = () => {
104
+ const now = Date.now();
105
+ API_RATE_LIMIT.hits = API_RATE_LIMIT.hits.filter((hit) => hit > now - API_RATE_LIMIT.windowMs);
106
+ if (API_RATE_LIMIT.hits.length >= API_RATE_LIMIT.max) {
107
+ throw publicError(429, 'Too many local API requests; wait a moment and try again');
108
+ }
109
+ API_RATE_LIMIT.hits.push(now);
110
+ };
111
+
112
+ const body = async (req: Request) => {
113
+ const text = await req.text();
114
+ if (!text.trim()) {
115
+ return {};
116
+ }
117
+ try {
118
+ const parsed = JSON.parse(text) as unknown;
119
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
120
+ throw publicError(400, 'JSON body must be an object');
121
+ }
122
+ return parsed as { key?: unknown };
123
+ } catch (error) {
124
+ if (errorStatus(error) !== 500) {
125
+ throw error;
126
+ }
127
+ throw publicError(400, 'Invalid JSON body');
128
+ }
129
+ };
130
+
131
+ const optionalKey = async (req: Request) => {
132
+ const key = (await body(req)).key;
133
+ if (key === undefined) {
134
+ return undefined;
135
+ }
136
+ if (typeof key !== 'string') {
137
+ throw publicError(400, 'key must be a string');
138
+ }
139
+ return key;
140
+ };
141
+
142
+ const requiredKey = async (req: Request) => {
143
+ const key = await optionalKey(req);
144
+ if (!key) {
145
+ throw publicError(400, 'key is required');
146
+ }
147
+ return key;
148
+ };
149
+
150
+ const routes = new Map<string, Route>([
151
+ ['GET /api/antigravity/state', { handler: async () => json(await antigravityState()) }],
152
+ [
153
+ 'POST /api/antigravity/limits/refresh',
154
+ {
155
+ handler: async (req) =>
156
+ json(await antigravityState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
157
+ },
158
+ ],
159
+ [
160
+ 'POST /api/antigravity/save',
161
+ {
162
+ handler: async (req) => {
163
+ await saveAntigravity(await requiredKey(req));
164
+ return json({ ok: true });
165
+ },
166
+ },
167
+ ],
168
+ [
169
+ 'POST /api/antigravity/load',
170
+ {
171
+ handler: async (req) => {
172
+ await loadAntigravity(await requiredKey(req));
173
+ return json({ ok: true });
174
+ },
175
+ },
176
+ ],
177
+ [
178
+ 'POST /api/antigravity/clear',
179
+ {
180
+ handler: async () => {
181
+ await clearAntigravity();
182
+ return json({ ok: true });
183
+ },
184
+ },
185
+ ],
186
+ ['GET /api/codex/state', { handler: async () => json(await codexState()) }],
187
+ [
188
+ 'POST /api/codex/limits/refresh',
189
+ {
190
+ handler: async (req) =>
191
+ json(await codexState({ refreshLimitKey: await optionalKey(req), refreshLimits: true })),
192
+ },
193
+ ],
194
+ [
195
+ 'POST /api/codex/save',
196
+ {
197
+ handler: async (req) => {
198
+ await saveCodex(await requiredKey(req));
199
+ return json({ ok: true });
200
+ },
201
+ },
202
+ ],
203
+ [
204
+ 'POST /api/codex/load',
205
+ {
206
+ handler: async (req) => {
207
+ await loadCodex(await requiredKey(req));
208
+ return json({ ok: true });
209
+ },
210
+ },
211
+ ],
212
+ ]);
213
+
214
+ const handleApi = async (url: URL, req: Request) => {
215
+ if (!url.pathname.startsWith('/api/')) {
216
+ return null;
217
+ }
218
+ assertLocalRequest(req);
219
+ assertRateLimit();
220
+ const route = routes.get(`${req.method} ${url.pathname}`);
221
+ if (!route) {
222
+ const hasPath = [...routes.keys()].some((key) => key.endsWith(` ${url.pathname}`));
223
+ return hasPath ? json({ error: 'Method not allowed' }, 405) : json({ error: 'Not found' }, 404);
224
+ }
225
+ return route.handler(req);
226
+ };
227
+
228
+ const handleAsset = (url: URL, assets: Assets) => {
229
+ if (url.pathname === '/') {
230
+ return withHeaders(new Response(renderHtml()), { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' });
231
+ }
232
+ if (url.pathname === '/assets/app.js') {
233
+ return withHeaders(new Response(assets.appJs), {
234
+ 'Cache-Control': 'max-age=3600',
235
+ 'Content-Type': 'text/javascript',
236
+ });
237
+ }
238
+ if (url.pathname === '/assets/styles.css') {
239
+ return withHeaders(new Response(assets.css), { 'Cache-Control': 'max-age=3600', 'Content-Type': 'text/css' });
240
+ }
241
+ if (url.pathname === '/icon.svg') {
242
+ return withHeaders(new Response(assets.iconSvg), {
243
+ 'Cache-Control': 'max-age=86400',
244
+ 'Content-Type': 'image/svg+xml',
245
+ });
246
+ }
247
+ if (url.pathname === '/icon.png' || url.pathname === '/favicon.ico') {
248
+ return withHeaders(new Response(assets.iconPng), {
249
+ 'Cache-Control': 'max-age=86400',
250
+ 'Content-Type': 'image/png',
251
+ });
252
+ }
253
+ return null;
254
+ };
255
+
256
+ export const createFetch = (assets: Assets) => {
257
+ return async (req: Request) => {
258
+ const url = new URL(req.url);
259
+ try {
260
+ const asset = handleAsset(url, assets);
261
+ if (asset) {
262
+ return asset;
263
+ }
264
+ const api = await handleApi(url, req);
265
+ if (api) {
266
+ return api;
267
+ }
268
+ return json({ error: 'Not found' }, 404);
269
+ } catch (error) {
270
+ return jsonError(error);
271
+ }
272
+ };
273
+ };
274
+
275
+ export const startServer = async () => {
276
+ const assets = await buildAssets();
277
+ const server = Bun.serve({
278
+ fetch: createFetch(assets),
279
+ hostname: HOST,
280
+ port: PORT,
281
+ });
282
+
283
+ console.log(`Dondo running at http://${HOST}:${server.port}`);
284
+ return server;
285
+ };
286
+
287
+ if (import.meta.main) {
288
+ await startServer();
289
+ }
@@ -0,0 +1,8 @@
1
+ import { expect, it } from 'bun:test';
2
+ import { run } from './shell.ts';
3
+
4
+ it('should redact sensitive subprocess stderr in failure messages', async () => {
5
+ await expect(run('bun', ['-e', 'console.error(`password: "secret"`); process.exit(2)'])).rejects.toThrow(
6
+ 'password: "[redacted]"',
7
+ );
8
+ });
package/src/shell.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { errorMessage, redactSecrets } from './errors.ts';
2
+
3
+ export type RunError = Error & {
4
+ code: number;
5
+ stderr: string;
6
+ stdout: string;
7
+ };
8
+
9
+ type RunOptions = {
10
+ timeoutMs?: number;
11
+ };
12
+
13
+ const DEFAULT_TIMEOUT_MS = 15_000;
14
+
15
+ const safeArgs = (args: string[]) => {
16
+ return args.map((arg, index) => (args[index - 1] === '-w' ? '[redacted]' : arg));
17
+ };
18
+
19
+ export const isRunError = (error: unknown): error is RunError => {
20
+ return error instanceof Error && 'code' in error && 'stderr' in error && 'stdout' in error;
21
+ };
22
+
23
+ export const run = async (cmd: string, args: string[], options: RunOptions = {}) => {
24
+ const proc = Bun.spawn([cmd, ...args], { stderr: 'pipe', stdout: 'pipe' });
25
+ let timedOut = false;
26
+ const timer = setTimeout(() => {
27
+ timedOut = true;
28
+ proc.kill();
29
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
30
+
31
+ const [stdout, stderr, code] = await Promise.all([
32
+ new Response(proc.stdout).text(),
33
+ new Response(proc.stderr).text(),
34
+ proc.exited,
35
+ ]).finally(() => clearTimeout(timer));
36
+
37
+ if (code !== 0) {
38
+ const message = timedOut
39
+ ? `${cmd} ${safeArgs(args).join(' ')} timed out`
40
+ : `${cmd} ${safeArgs(args).join(' ')} failed (${code}): ${errorMessage(stderr || stdout)}`;
41
+ throw Object.assign(new Error(message), {
42
+ code,
43
+ stderr: redactSecrets(stderr),
44
+ stdout: redactSecrets(stdout),
45
+ });
46
+ }
47
+
48
+ return { stderr, stdout };
49
+ };