@1agh/maude 0.43.0 → 0.45.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/commands/init.mjs +7 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// generation/jobs.ts — the background AI-media generation job queue (DDR-16x).
|
|
2
|
+
//
|
|
3
|
+
// Modelled on exporters/jobs.ts: `enqueue()` returns immediately with a job id +
|
|
4
|
+
// a Promise, the job progresses queued → running → done|failed in the
|
|
5
|
+
// background, and `bus.emit('generate:job', job)` fires on every transition so
|
|
6
|
+
// ws.ts can push a live snapshot to the notification center (reusing the
|
|
7
|
+
// export-center chrome). Concurrency is capped by the same hand-rolled counting
|
|
8
|
+
// semaphore (no new dependency).
|
|
9
|
+
//
|
|
10
|
+
// Unlike exports there is NO per-job byte store — a generation's output is a set
|
|
11
|
+
// of `assets/<sha8>.<ext>` rel paths that already live in the durable,
|
|
12
|
+
// content-addressed asset store (the job just records the paths). The only
|
|
13
|
+
// on-disk state is a small `_generate-history.json` ledger (DDR-115 runtime
|
|
14
|
+
// state — registered in all three ignore lists) so the notification center
|
|
15
|
+
// survives a restart. Job records themselves are in-memory only.
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import type { Bus } from '../context.ts';
|
|
21
|
+
import type { Modality } from './types.ts';
|
|
22
|
+
|
|
23
|
+
export type GenerationJobStatus = 'queued' | 'running' | 'done' | 'failed';
|
|
24
|
+
|
|
25
|
+
export interface GenerationJob {
|
|
26
|
+
id: string;
|
|
27
|
+
provider: string;
|
|
28
|
+
modality: Modality;
|
|
29
|
+
model?: string;
|
|
30
|
+
status: GenerationJobStatus;
|
|
31
|
+
progress?: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
startedAt?: string;
|
|
34
|
+
finishedAt?: string;
|
|
35
|
+
/** `assets/<sha8>.<ext>` rel paths produced by this job. */
|
|
36
|
+
assets?: string[];
|
|
37
|
+
usage?: { cost?: number; ms?: number };
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The one job's work — submit → result → localize each asset. Given the abort
|
|
42
|
+
* signal so a timed-out/cancelled job tears down its provider request. */
|
|
43
|
+
export type GenerationRun = (
|
|
44
|
+
signal: AbortSignal
|
|
45
|
+
) => Promise<{ assets: string[]; usage?: { cost?: number; ms?: number } }>;
|
|
46
|
+
|
|
47
|
+
export interface EnqueueGenArgs {
|
|
48
|
+
provider: string;
|
|
49
|
+
modality: Modality;
|
|
50
|
+
model?: string;
|
|
51
|
+
run: GenerationRun;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GenerationHistoryEntry {
|
|
55
|
+
id: string;
|
|
56
|
+
provider: string;
|
|
57
|
+
modality: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
status: 'done' | 'failed';
|
|
60
|
+
assets?: string[];
|
|
61
|
+
at: string;
|
|
62
|
+
error?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GenerationJobQueue {
|
|
66
|
+
enqueue(args: EnqueueGenArgs): { id: string; result: Promise<GenerationJob> };
|
|
67
|
+
get(id: string): GenerationJob | undefined;
|
|
68
|
+
list(): GenerationJob[];
|
|
69
|
+
loadHistory(): GenerationHistoryEntry[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const HISTORY_DEPTH = 20;
|
|
73
|
+
const MAX_JOB_AGE_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
// Generation spans sync image (seconds) → async video (minutes). Size generously
|
|
75
|
+
// but bound it so a wedged provider request can't hold a slot forever.
|
|
76
|
+
const DEFAULT_JOB_TIMEOUT_MS = Math.max(
|
|
77
|
+
30_000,
|
|
78
|
+
Number(process.env.MAUDE_GENERATE_TIMEOUT_MS) || 10 * 60 * 1000
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export class GenerationQueueFullError extends Error {
|
|
82
|
+
constructor() {
|
|
83
|
+
super('generation queue is full — too many pending jobs, try again shortly');
|
|
84
|
+
this.name = 'GenerationQueueFullError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Small counting semaphore — mirrors exporters/jobs.ts's Semaphore. */
|
|
89
|
+
class Semaphore {
|
|
90
|
+
private active = 0;
|
|
91
|
+
private readonly waiters: Array<() => void> = [];
|
|
92
|
+
constructor(private readonly max: number) {}
|
|
93
|
+
async acquire(): Promise<() => void> {
|
|
94
|
+
if (this.active >= this.max) {
|
|
95
|
+
await new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
96
|
+
}
|
|
97
|
+
this.active += 1;
|
|
98
|
+
let released = false;
|
|
99
|
+
return () => {
|
|
100
|
+
if (released) return;
|
|
101
|
+
released = true;
|
|
102
|
+
this.active -= 1;
|
|
103
|
+
const next = this.waiters.shift();
|
|
104
|
+
if (next) next();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isFinished(job: GenerationJob): boolean {
|
|
110
|
+
return job.status === 'done' || job.status === 'failed';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createGenerationJobQueue(bus: Bus, designRoot: string): GenerationJobQueue {
|
|
114
|
+
const historyPath = path.join(designRoot, '_generate-history.json');
|
|
115
|
+
const maxConcurrent = Math.max(1, Number(process.env.MAUDE_GENERATE_MAX_CONCURRENT) || 2);
|
|
116
|
+
// Bound queued+running jobs — a flood of POSTs must not grow the Map unbounded
|
|
117
|
+
// (the backpressure the background export queue added). Read per-instance so
|
|
118
|
+
// it's env-overridable at construction (mirrors maxConcurrent).
|
|
119
|
+
const maxPending = Math.max(1, Number(process.env.MAUDE_GENERATE_MAX_QUEUED) || 12);
|
|
120
|
+
const semaphore = new Semaphore(maxConcurrent);
|
|
121
|
+
const jobs = new Map<string, GenerationJob>();
|
|
122
|
+
|
|
123
|
+
// Seed the ledger from disk ONCE (the only read). Every later persist derives
|
|
124
|
+
// fresh from `jobs` and overwrites — no read-modify-write, so concurrent
|
|
125
|
+
// completions can't drop entries.
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(readFileSync(historyPath, 'utf8')) as unknown;
|
|
128
|
+
if (Array.isArray(parsed)) {
|
|
129
|
+
for (const entry of parsed) {
|
|
130
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
131
|
+
const e = entry as Record<string, unknown>;
|
|
132
|
+
const id = typeof e.id === 'string' && e.id ? e.id : crypto.randomUUID();
|
|
133
|
+
jobs.set(id, {
|
|
134
|
+
id,
|
|
135
|
+
provider: String(e.provider ?? ''),
|
|
136
|
+
modality: (e.modality as Modality) ?? 'image',
|
|
137
|
+
model: e.model as string | undefined,
|
|
138
|
+
status: e.status === 'failed' ? 'failed' : 'done',
|
|
139
|
+
createdAt: (e.at as string) ?? new Date().toISOString(),
|
|
140
|
+
finishedAt: e.at as string | undefined,
|
|
141
|
+
assets: Array.isArray(e.assets) ? (e.assets as string[]) : undefined,
|
|
142
|
+
error: e.error as string | undefined,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
/* no ledger yet — start empty */
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function emit(job: GenerationJob): void {
|
|
151
|
+
bus.emit('generate:job', { ...job });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deriveHistory(): GenerationHistoryEntry[] {
|
|
155
|
+
return Array.from(jobs.values())
|
|
156
|
+
.filter(isFinished)
|
|
157
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''))
|
|
158
|
+
.slice(0, HISTORY_DEPTH)
|
|
159
|
+
.map((j) => ({
|
|
160
|
+
id: j.id,
|
|
161
|
+
provider: j.provider,
|
|
162
|
+
modality: j.modality,
|
|
163
|
+
model: j.model,
|
|
164
|
+
status: j.status as 'done' | 'failed',
|
|
165
|
+
assets: j.assets,
|
|
166
|
+
at: j.finishedAt ?? j.createdAt,
|
|
167
|
+
error: j.error,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function persistAndEvict(): Promise<void> {
|
|
172
|
+
await Bun.write(historyPath, JSON.stringify(deriveHistory(), null, 2));
|
|
173
|
+
const finished = Array.from(jobs.values())
|
|
174
|
+
.filter(isFinished)
|
|
175
|
+
.sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
for (const [i, job] of finished.entries()) {
|
|
178
|
+
const finishedAt = job.finishedAt ? Date.parse(job.finishedAt) : Number.NaN;
|
|
179
|
+
const aged = Number.isFinite(finishedAt) && now - finishedAt > MAX_JOB_AGE_MS;
|
|
180
|
+
if (i >= HISTORY_DEPTH || aged) jobs.delete(job.id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function enqueue(args: EnqueueGenArgs): { id: string; result: Promise<GenerationJob> } {
|
|
185
|
+
let pending = 0;
|
|
186
|
+
for (const job of jobs.values()) {
|
|
187
|
+
if (job.status === 'queued' || job.status === 'running') pending += 1;
|
|
188
|
+
}
|
|
189
|
+
if (pending >= maxPending) throw new GenerationQueueFullError();
|
|
190
|
+
|
|
191
|
+
const id = `gen_${crypto.randomUUID()}`;
|
|
192
|
+
const job: GenerationJob = {
|
|
193
|
+
id,
|
|
194
|
+
provider: args.provider,
|
|
195
|
+
modality: args.modality,
|
|
196
|
+
model: args.model,
|
|
197
|
+
status: 'queued',
|
|
198
|
+
createdAt: new Date().toISOString(),
|
|
199
|
+
};
|
|
200
|
+
jobs.set(id, job);
|
|
201
|
+
emit(job);
|
|
202
|
+
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
const result = (async (): Promise<GenerationJob> => {
|
|
205
|
+
const release = await semaphore.acquire();
|
|
206
|
+
const timer = setTimeout(() => controller.abort(), DEFAULT_JOB_TIMEOUT_MS);
|
|
207
|
+
try {
|
|
208
|
+
job.status = 'running';
|
|
209
|
+
job.startedAt = new Date().toISOString();
|
|
210
|
+
emit(job);
|
|
211
|
+
|
|
212
|
+
const out = await args.run(controller.signal);
|
|
213
|
+
|
|
214
|
+
job.status = 'done';
|
|
215
|
+
job.finishedAt = new Date().toISOString();
|
|
216
|
+
job.assets = out.assets;
|
|
217
|
+
job.usage = out.usage;
|
|
218
|
+
emit(job);
|
|
219
|
+
await persistAndEvict();
|
|
220
|
+
return job;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
job.status = 'failed';
|
|
223
|
+
job.finishedAt = new Date().toISOString();
|
|
224
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
225
|
+
emit(job);
|
|
226
|
+
await persistAndEvict();
|
|
227
|
+
throw err;
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
release();
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
|
|
234
|
+
return { id, result };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
enqueue,
|
|
239
|
+
get: (id) => jobs.get(id),
|
|
240
|
+
list: () => Array.from(jobs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
241
|
+
loadHistory: deriveHistory,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// generation/keys.test.ts — key custody: 0600 mode assert, round-trip, and
|
|
2
|
+
// missing-key returns null. Points MAUDE_GEN_KEYS_PATH at a sandbox file so the
|
|
3
|
+
// user's real ~/.config/maude/keys.json is never touched.
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
7
|
+
import { platform, tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
configuredProviders,
|
|
12
|
+
deleteProviderKey,
|
|
13
|
+
getProviderKey,
|
|
14
|
+
isConfigured,
|
|
15
|
+
keysConfigPath,
|
|
16
|
+
setProviderKey,
|
|
17
|
+
} from './keys.ts';
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
const prevPath = process.env.MAUDE_GEN_KEYS_PATH;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), 'maude-keys-'));
|
|
24
|
+
process.env.MAUDE_GEN_KEYS_PATH = join(dir, 'keys.json');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (prevPath === undefined) delete process.env.MAUDE_GEN_KEYS_PATH;
|
|
29
|
+
else process.env.MAUDE_GEN_KEYS_PATH = prevPath;
|
|
30
|
+
rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('generation key custody', () => {
|
|
34
|
+
test('missing key resolves to null; isConfigured false', async () => {
|
|
35
|
+
expect(await getProviderKey('gemini')).toBeNull();
|
|
36
|
+
expect(isConfigured('gemini')).toBe(false);
|
|
37
|
+
expect(configuredProviders()).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('set → get round-trips and reports configured', async () => {
|
|
41
|
+
setProviderKey('gemini', 'AIza-secret-123');
|
|
42
|
+
expect(await getProviderKey('gemini')).toBe('AIza-secret-123');
|
|
43
|
+
expect(isConfigured('gemini')).toBe(true);
|
|
44
|
+
expect(configuredProviders()).toContain('gemini');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the keys file is written mode 0600', () => {
|
|
48
|
+
setProviderKey('elevenlabs', 'sk-eleven');
|
|
49
|
+
const path = keysConfigPath();
|
|
50
|
+
expect(existsSync(path)).toBe(true);
|
|
51
|
+
if (platform() !== 'win32') {
|
|
52
|
+
const mode = statSync(path).mode & 0o777;
|
|
53
|
+
expect(mode).toBe(0o600);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('set trims whitespace and rejects empties', () => {
|
|
58
|
+
setProviderKey('gemini', ' padded-key ');
|
|
59
|
+
expect(isConfigured('gemini')).toBe(true);
|
|
60
|
+
expect(() => setProviderKey('gemini', ' ')).toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('delete removes the key', async () => {
|
|
64
|
+
setProviderKey('gemini', 'k');
|
|
65
|
+
deleteProviderKey('gemini');
|
|
66
|
+
expect(await getProviderKey('gemini')).toBeNull();
|
|
67
|
+
expect(isConfigured('gemini')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a malformed provider id never resolves or writes', async () => {
|
|
71
|
+
expect(await getProviderKey('../etc')).toBeNull();
|
|
72
|
+
expect(() => setProviderKey('../etc', 'x')).toThrow();
|
|
73
|
+
expect(isConfigured('../etc')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// generation/keys.ts — BYOK provider-key custody for AI media generation (DDR-16x).
|
|
2
|
+
//
|
|
3
|
+
// Keys live OUTSIDE the served `.design/` tree so a canvas file-route can never
|
|
4
|
+
// read them: `~/.config/maude/keys.json`, mode 0600 (XDG-aware, same location
|
|
5
|
+
// discipline as sync/hubs-config.ts's hubs.json). A provider key is resolved at
|
|
6
|
+
// REQUEST TIME and NEVER cached, logged, or returned to the canvas realm — the
|
|
7
|
+
// exact template is github/token.ts (fetch-at-request-time, null when absent).
|
|
8
|
+
//
|
|
9
|
+
// Native tier (Phase 5.1, DDR-16x): when the Tauri keychain bridge env is
|
|
10
|
+
// present (`MAUDE_GEN_KEY_ENDPOINT`/`_KEY`, mirroring MAUDE_TOKEN_ENDPOINT for
|
|
11
|
+
// GitHub), `getProviderKey` prefers the keychain over the file. The bridge is
|
|
12
|
+
// not wired in Phase 0 — the hook is here so `keys.ts` never has to change when
|
|
13
|
+
// the native keychain lands; the file store is the whole Phase-0 custody.
|
|
14
|
+
//
|
|
15
|
+
// The Settings panel POSTs a key to a PRIVILEGED main-origin route which calls
|
|
16
|
+
// `setProviderKey`; that route NEVER echoes a key back (it returns
|
|
17
|
+
// `{ configured: true }`, mirroring `github_is_signed_in`). `isConfigured` backs
|
|
18
|
+
// the GET status probe — it reveals only presence, never the value.
|
|
19
|
+
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { homedir, platform } from 'node:os';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** Slug-shaped provider id — the JSON key + keychain lookup. Never a path. */
|
|
25
|
+
const PROVIDER_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
26
|
+
|
|
27
|
+
/** Resolve the on-disk path to keys.json (mirrors hubs-config.ts's XDG logic). */
|
|
28
|
+
export function keysConfigPath(): string {
|
|
29
|
+
if (process.env.MAUDE_GEN_KEYS_PATH) return process.env.MAUDE_GEN_KEYS_PATH;
|
|
30
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
31
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
|
|
32
|
+
return join(base, 'maude', 'keys.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface KeysFile {
|
|
36
|
+
keys: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let _modeWarnedFor: string | null = null;
|
|
40
|
+
function warnIfWorldOrGroupReadable(path: string): void {
|
|
41
|
+
if (platform() === 'win32') return; // POSIX-mode semantics don't apply
|
|
42
|
+
if (_modeWarnedFor === path) return;
|
|
43
|
+
try {
|
|
44
|
+
const mode = statSync(path).mode & 0o777;
|
|
45
|
+
if ((mode & 0o077) !== 0) {
|
|
46
|
+
console.warn(
|
|
47
|
+
`[generation] ${path} is mode ${mode.toString(8)} — recommend 'chmod 600 ${path}' (only owner can read provider keys).`
|
|
48
|
+
);
|
|
49
|
+
_modeWarnedFor = path;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
/* raced with a delete — next read retries */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readKeysFile(): KeysFile {
|
|
57
|
+
const path = keysConfigPath();
|
|
58
|
+
if (!existsSync(path)) return { keys: {} };
|
|
59
|
+
warnIfWorldOrGroupReadable(path);
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
62
|
+
if (!parsed || typeof parsed.keys !== 'object' || parsed.keys === null) return { keys: {} };
|
|
63
|
+
return parsed as KeysFile;
|
|
64
|
+
} catch {
|
|
65
|
+
return { keys: {} };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Write keys.json atomically-ish with a hard 0600 mode (create + chmod). */
|
|
70
|
+
function writeKeysFile(data: KeysFile): void {
|
|
71
|
+
const path = keysConfigPath();
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
// Write then chmod — writeFileSync's mode is masked by umask, so we assert
|
|
74
|
+
// 0600 explicitly afterwards (same guarantee cli/lib/hubs-config.mjs makes).
|
|
75
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
76
|
+
if (platform() !== 'win32') {
|
|
77
|
+
try {
|
|
78
|
+
chmodSync(path, 0o600);
|
|
79
|
+
} catch {
|
|
80
|
+
/* best-effort — the warn-on-read path is the backstop */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether the native keychain bridge is configured (Phase 5.1 / DDR-16x). */
|
|
86
|
+
function keychainBridgeAvailable(): boolean {
|
|
87
|
+
return Boolean(process.env.MAUDE_GEN_KEY_ENDPOINT && process.env.MAUDE_GEN_KEY_KEY);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fetch a key from the Tauri keychain bridge over loopback (Phase 5.1). */
|
|
91
|
+
async function getKeyFromBridge(providerId: string): Promise<string | null> {
|
|
92
|
+
const endpoint = process.env.MAUDE_GEN_KEY_ENDPOINT;
|
|
93
|
+
const key = process.env.MAUDE_GEN_KEY_KEY;
|
|
94
|
+
if (!endpoint || !key) return null;
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`${endpoint}?provider=${encodeURIComponent(providerId)}`, {
|
|
97
|
+
headers: { 'X-Maude-Token-Key': key },
|
|
98
|
+
signal: AbortSignal.timeout(3000),
|
|
99
|
+
});
|
|
100
|
+
if (res.status !== 200) return null; // 404 = not set; 403 = bad key
|
|
101
|
+
const token = (await res.text()).trim();
|
|
102
|
+
return token.length > 0 ? token : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve a provider's API key at request time. Prefers the native keychain
|
|
110
|
+
* bridge when present, otherwise the 0600 file. Returns null when unset (the
|
|
111
|
+
* caller degrades to "add a key in Settings"). NEVER cache the result — a key
|
|
112
|
+
* can be rotated in Settings and the store is the single source of truth.
|
|
113
|
+
*/
|
|
114
|
+
export async function getProviderKey(providerId: string): Promise<string | null> {
|
|
115
|
+
if (!PROVIDER_ID_RE.test(providerId)) return null;
|
|
116
|
+
if (keychainBridgeAvailable()) {
|
|
117
|
+
const fromBridge = await getKeyFromBridge(providerId);
|
|
118
|
+
if (fromBridge) return fromBridge;
|
|
119
|
+
// Fall through to the file store even under the bridge, so a key added via
|
|
120
|
+
// the browser Settings panel still resolves in the native app.
|
|
121
|
+
}
|
|
122
|
+
const key = readKeysFile().keys[providerId];
|
|
123
|
+
return typeof key === 'string' && key.length > 0 ? key : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Persist a provider key to the 0600 file store (Settings panel write path). */
|
|
127
|
+
export function setProviderKey(providerId: string, value: string): void {
|
|
128
|
+
if (!PROVIDER_ID_RE.test(providerId)) throw new Error('invalid provider id');
|
|
129
|
+
const trimmed = value.trim();
|
|
130
|
+
if (!trimmed) throw new Error('empty key');
|
|
131
|
+
const file = readKeysFile();
|
|
132
|
+
file.keys[providerId] = trimmed;
|
|
133
|
+
writeKeysFile(file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove a provider key (Settings "remove key"). No-op when absent. */
|
|
137
|
+
export function deleteProviderKey(providerId: string): void {
|
|
138
|
+
if (!PROVIDER_ID_RE.test(providerId)) return;
|
|
139
|
+
const file = readKeysFile();
|
|
140
|
+
if (providerId in file.keys) {
|
|
141
|
+
delete file.keys[providerId];
|
|
142
|
+
writeKeysFile(file);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Presence-only check for the status GET. Reveals whether a key exists (file or
|
|
148
|
+
* bridge) — NEVER the value. Synchronous file check + a cheap bridge-present
|
|
149
|
+
* signal (the bridge itself is only queried at real request time).
|
|
150
|
+
*/
|
|
151
|
+
export function isConfigured(providerId: string): boolean {
|
|
152
|
+
if (!PROVIDER_ID_RE.test(providerId)) return false;
|
|
153
|
+
const key = readKeysFile().keys[providerId];
|
|
154
|
+
return typeof key === 'string' && key.length > 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The set of provider ids that currently have a key configured (file store). */
|
|
158
|
+
export function configuredProviders(): string[] {
|
|
159
|
+
return Object.entries(readKeysFile().keys)
|
|
160
|
+
.filter(([, v]) => typeof v === 'string' && v.length > 0)
|
|
161
|
+
.map(([id]) => id);
|
|
162
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// prefs.test.ts — non-secret generation preferences (Task 2.6). The transcription
|
|
2
|
+
// engine choice must round-trip through `.design/config.json`, default to whisper
|
|
3
|
+
// when unset, reject an invalid engine, and NEVER clobber other config fields.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
isTranscriptionProvider,
|
|
12
|
+
readTranscriptionProvider,
|
|
13
|
+
writeTranscriptionProvider,
|
|
14
|
+
} from './prefs.ts';
|
|
15
|
+
|
|
16
|
+
function tmpRepo(config?: unknown): string {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'maude-prefs-'));
|
|
18
|
+
mkdirSync(join(root, '.design'), { recursive: true });
|
|
19
|
+
if (config !== undefined)
|
|
20
|
+
writeFileSync(join(root, '.design', 'config.json'), JSON.stringify(config, null, 2));
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('isTranscriptionProvider', () => {
|
|
25
|
+
test('accepts the three engines, rejects anything else', () => {
|
|
26
|
+
expect(isTranscriptionProvider('whisper')).toBe(true);
|
|
27
|
+
expect(isTranscriptionProvider('elevenlabs')).toBe(true);
|
|
28
|
+
expect(isTranscriptionProvider('groq')).toBe(true);
|
|
29
|
+
expect(isTranscriptionProvider('openai')).toBe(false);
|
|
30
|
+
expect(isTranscriptionProvider('')).toBe(false);
|
|
31
|
+
expect(isTranscriptionProvider(null)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('readTranscriptionProvider', () => {
|
|
36
|
+
test('defaults to whisper when no config exists', () => {
|
|
37
|
+
expect(readTranscriptionProvider(tmpRepo())).toBe('whisper');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('defaults to whisper when the block is absent', () => {
|
|
41
|
+
expect(readTranscriptionProvider(tmpRepo({ name: 'x' }))).toBe('whisper');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('reads a configured engine', () => {
|
|
45
|
+
const repo = tmpRepo({ generation: { transcription: { provider: 'groq' } } });
|
|
46
|
+
expect(readTranscriptionProvider(repo)).toBe('groq');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('ignores a malformed engine value', () => {
|
|
50
|
+
const repo = tmpRepo({ generation: { transcription: { provider: 'bogus' } } });
|
|
51
|
+
expect(readTranscriptionProvider(repo)).toBe('whisper');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('writeTranscriptionProvider', () => {
|
|
56
|
+
test('round-trips the choice', async () => {
|
|
57
|
+
const repo = tmpRepo({ name: 'x' });
|
|
58
|
+
await writeTranscriptionProvider(repo, 'elevenlabs');
|
|
59
|
+
expect(readTranscriptionProvider(repo)).toBe('elevenlabs');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('preserves other config fields and other generation keys', async () => {
|
|
63
|
+
const repo = tmpRepo({
|
|
64
|
+
name: 'proj',
|
|
65
|
+
generation: { defaultImageProvider: 'gemini', transcription: { model: 'scribe_v1' } },
|
|
66
|
+
});
|
|
67
|
+
await writeTranscriptionProvider(repo, 'groq');
|
|
68
|
+
const cfg = JSON.parse(readFileSync(join(repo, '.design', 'config.json'), 'utf8'));
|
|
69
|
+
expect(cfg.name).toBe('proj');
|
|
70
|
+
expect(cfg.generation.defaultImageProvider).toBe('gemini');
|
|
71
|
+
expect(cfg.generation.transcription.model).toBe('scribe_v1'); // sibling key kept
|
|
72
|
+
expect(cfg.generation.transcription.provider).toBe('groq');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('creates the generation block when absent', async () => {
|
|
76
|
+
const repo = tmpRepo(); // no config file at all
|
|
77
|
+
await writeTranscriptionProvider(repo, 'whisper');
|
|
78
|
+
expect(readTranscriptionProvider(repo)).toBe('whisper');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('rejects an invalid engine', async () => {
|
|
82
|
+
const repo = tmpRepo();
|
|
83
|
+
await expect(writeTranscriptionProvider(repo, 'openai')).rejects.toThrow(/invalid/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('fails closed on an existing-but-corrupt config (never clobbers it)', async () => {
|
|
87
|
+
const repo = mkdtempSync(join(tmpdir(), 'maude-prefs-'));
|
|
88
|
+
mkdirSync(join(repo, '.design'), { recursive: true });
|
|
89
|
+
const corrupt = '{ this is not json ';
|
|
90
|
+
writeFileSync(join(repo, '.design', 'config.json'), corrupt);
|
|
91
|
+
await expect(writeTranscriptionProvider(repo, 'groq')).rejects.toThrow(/valid JSON/);
|
|
92
|
+
// The corrupt file is left untouched, not overwritten with a partial config.
|
|
93
|
+
expect(readFileSync(join(repo, '.design', 'config.json'), 'utf8')).toBe(corrupt);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// generation/prefs.ts — read/write the NON-SECRET generation preferences in
|
|
2
|
+
// `.design/config.json` (Task 2.6, DDR-164). The Settings panel's transcription-
|
|
3
|
+
// engine selector persists here so the choice survives restarts and the
|
|
4
|
+
// `maude design transcribe` CLI reads the same default.
|
|
5
|
+
//
|
|
6
|
+
// SECRET-FREE by construction: this module only ever touches the `generation`
|
|
7
|
+
// block's non-secret routing/UI keys (provider ids, model ids, toggles). Keys
|
|
8
|
+
// live in the OS keychain / ~/.config/maude/keys.json — NEVER here. The writer
|
|
9
|
+
// preserves every other config field verbatim (additive merge over the parsed
|
|
10
|
+
// JSON), so a hand-authored config is never clobbered.
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** The transcription engines the selector offers (mirrors the config schema enum). */
|
|
16
|
+
export const TRANSCRIPTION_PROVIDERS = ['whisper', 'elevenlabs', 'groq'] as const;
|
|
17
|
+
export type TranscriptionProvider = (typeof TRANSCRIPTION_PROVIDERS)[number];
|
|
18
|
+
|
|
19
|
+
export function isTranscriptionProvider(v: unknown): v is TranscriptionProvider {
|
|
20
|
+
return typeof v === 'string' && (TRANSCRIPTION_PROVIDERS as readonly string[]).includes(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function configPath(repoRoot: string): string {
|
|
24
|
+
return join(repoRoot, '.design', 'config.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse `.design/config.json` (or {} when missing / unreadable). */
|
|
28
|
+
function readConfig(repoRoot: string): Record<string, unknown> {
|
|
29
|
+
const p = configPath(repoRoot);
|
|
30
|
+
if (!existsSync(p)) return {};
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
33
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
34
|
+
? (parsed as Record<string, unknown>)
|
|
35
|
+
: {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The current transcription-engine preference, or 'whisper' (the default). */
|
|
42
|
+
export function readTranscriptionProvider(repoRoot: string): TranscriptionProvider {
|
|
43
|
+
const cfg = readConfig(repoRoot);
|
|
44
|
+
const gen = cfg.generation as Record<string, unknown> | undefined;
|
|
45
|
+
const t = gen?.transcription as Record<string, unknown> | undefined;
|
|
46
|
+
return isTranscriptionProvider(t?.provider) ? (t?.provider as TranscriptionProvider) : 'whisper';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Persist the transcription-engine choice into `.design/config.json`, preserving
|
|
51
|
+
* every other field. Returns true on a successful write. Throws on an invalid
|
|
52
|
+
* provider (the route validates first, so this is a defensive guard).
|
|
53
|
+
*/
|
|
54
|
+
export async function writeTranscriptionProvider(
|
|
55
|
+
repoRoot: string,
|
|
56
|
+
provider: string
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
if (!isTranscriptionProvider(provider))
|
|
59
|
+
throw new Error(`invalid transcription provider: ${provider}`);
|
|
60
|
+
// Fail CLOSED on an existing-but-unparseable config: never overwrite a corrupt
|
|
61
|
+
// file with a generation-only block (that would silently drop the user's other
|
|
62
|
+
// settings). A MISSING file is fine — we start from {}. (Defender note, 2026-07-11.)
|
|
63
|
+
const p = configPath(repoRoot);
|
|
64
|
+
if (existsSync(p)) {
|
|
65
|
+
try {
|
|
66
|
+
JSON.parse(readFileSync(p, 'utf8'));
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'.design/config.json is present but not valid JSON — fix it before changing generation prefs (refusing to overwrite it)'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const cfg = readConfig(repoRoot);
|
|
74
|
+
const gen = (cfg.generation as Record<string, unknown>) ?? {};
|
|
75
|
+
const transcription = (gen.transcription as Record<string, unknown>) ?? {};
|
|
76
|
+
const next = {
|
|
77
|
+
...cfg,
|
|
78
|
+
generation: { ...gen, transcription: { ...transcription, provider } },
|
|
79
|
+
};
|
|
80
|
+
await Bun.write(configPath(repoRoot), `${JSON.stringify(next, null, 2)}\n`);
|
|
81
|
+
return true;
|
|
82
|
+
}
|