@hanmariyang/drafting 1.6.2

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -0
  3. package/api/dist/db/index.js +107 -0
  4. package/api/dist/db/repos.js +670 -0
  5. package/api/dist/index.js +89 -0
  6. package/api/dist/lib/ai.js +314 -0
  7. package/api/dist/lib/config.js +57 -0
  8. package/api/dist/lib/crypto.js +71 -0
  9. package/api/dist/lib/design-system-gen.js +332 -0
  10. package/api/dist/lib/fixtures.js +150 -0
  11. package/api/dist/lib/gateway.js +55 -0
  12. package/api/dist/lib/handoff.js +283 -0
  13. package/api/dist/lib/items-gen.js +211 -0
  14. package/api/dist/lib/lint-service.js +118 -0
  15. package/api/dist/lib/lint.js +141 -0
  16. package/api/dist/lib/mockup-gen.js +136 -0
  17. package/api/dist/lib/numbering.js +75 -0
  18. package/api/dist/lib/provider-errors.js +31 -0
  19. package/api/dist/lib/render.js +154 -0
  20. package/api/dist/lib/style-guide.js +47 -0
  21. package/api/dist/lib/templates.js +85 -0
  22. package/api/dist/lib/types.js +1 -0
  23. package/api/dist/lib/wireframes.js +137 -0
  24. package/api/dist/providers/byok/anthropic.js +75 -0
  25. package/api/dist/providers/byok/openai-compat.js +95 -0
  26. package/api/dist/providers/cli.js +391 -0
  27. package/api/dist/providers/index.js +68 -0
  28. package/api/dist/providers/managed.js +22 -0
  29. package/api/dist/providers/sse.js +37 -0
  30. package/api/dist/providers/stub.js +55 -0
  31. package/api/dist/providers/types.js +1 -0
  32. package/api/dist/routes/backup.js +24 -0
  33. package/api/dist/routes/deliverables.js +588 -0
  34. package/api/dist/routes/documents.js +205 -0
  35. package/api/dist/routes/helpers.js +43 -0
  36. package/api/dist/routes/interview.js +141 -0
  37. package/api/dist/routes/keys.js +70 -0
  38. package/api/dist/routes/projects.js +103 -0
  39. package/api/dist/routes/settings.js +134 -0
  40. package/api/dist/routes/share.js +39 -0
  41. package/api/dist/routes/suggestions.js +144 -0
  42. package/api/templates/design-system.json +17 -0
  43. package/api/templates/feature-spec.json +73 -0
  44. package/api/templates/ia.json +52 -0
  45. package/api/templates/prd.json +60 -0
  46. package/api/templates/user-flow.json +61 -0
  47. package/bin/drafting.mjs +79 -0
  48. package/db/schema.sql +154 -0
  49. package/package.json +62 -0
  50. package/web/dist/assets/index-CS06cWP3.js +125 -0
  51. package/web/dist/assets/index-DWoYeaZU.css +1 -0
  52. package/web/dist/index.html +14 -0
@@ -0,0 +1,391 @@
1
+ /**
2
+ * CLI(데몬) 프로바이더 — 사용자의 로컬 에이전트 CLI(Claude Code)를 스폰해
3
+ * 구독 인증으로 생성한다. API 키가 필요 없다 (coxpit-oss providers.ts 계보).
4
+ *
5
+ * 격리 원칙 (2026-08-20 실측): CLI 는 cwd 의 CLAUDE.md·메모리·스킬을 주입하므로
6
+ * - 빈 전용 cwd (dataDir/agent)
7
+ * - --setting-sources "" (사용자/프로젝트 설정 차단)
8
+ * - --system-prompt 교체 (+ 동적 섹션 제외)
9
+ * 없이는 워크스페이스 맥락이 문서에 새어 들어간다.
10
+ */
11
+ import { spawn, spawnSync } from 'node:child_process';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { config } from "../lib/config.js";
16
+ import { getSetting } from "../db/repos.js";
17
+ const HOME = os.homedir();
18
+ /** 설치 관리자와 무관한 표준 위치들(존재하면 바로 사용). */
19
+ const FIXED_CANDIDATES = [
20
+ '/opt/homebrew/bin/claude',
21
+ '/usr/local/bin/claude',
22
+ path.join(HOME, '.local', 'bin', 'claude'),
23
+ path.join(HOME, '.claude', 'local', 'claude'),
24
+ path.join(HOME, '.volta', 'bin', 'claude'),
25
+ path.join(HOME, '.asdf', 'shims', 'claude'),
26
+ ];
27
+ function safeExists(p) {
28
+ try {
29
+ return !!p && fs.existsSync(p);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /** semver-ish 문자열 내림차순 비교 (최신 노드 버전 우선). */
36
+ function cmpVersionDesc(a, b) {
37
+ const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
38
+ const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
39
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
40
+ const d = (pb[i] ?? 0) - (pa[i] ?? 0);
41
+ if (d)
42
+ return d;
43
+ }
44
+ return 0;
45
+ }
46
+ /**
47
+ * nvm·fnm·asdf·n 처럼 노드 버전 디렉터리 안에 claude 를 심는 매니저를 글롭한다.
48
+ * GUI 앱은 이 경로들을 PATH 로 못 보므로 직접 훑는다. 버전이 여럿이면 최신을 앞에.
49
+ * 순수하게 테스트하도록 home 을 주입 가능.
50
+ */
51
+ export function nodeManagerBins(home = HOME) {
52
+ const roots = [
53
+ { dir: path.join(home, '.nvm', 'versions', 'node'), suffix: ['bin', 'claude'] },
54
+ { dir: path.join(home, '.fnm', 'node-versions'), suffix: ['installation', 'bin', 'claude'] },
55
+ { dir: path.join(home, 'Library', 'Application Support', 'fnm', 'node-versions'), suffix: ['installation', 'bin', 'claude'] },
56
+ { dir: path.join(home, '.asdf', 'installs', 'nodejs'), suffix: ['bin', 'claude'] },
57
+ { dir: path.join(home, 'n', 'versions', 'node'), suffix: ['bin', 'claude'] },
58
+ ];
59
+ const out = [];
60
+ for (const { dir, suffix } of roots) {
61
+ let versions;
62
+ try {
63
+ versions = fs.readdirSync(dir);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ versions.sort(cmpVersionDesc);
69
+ for (const v of versions) {
70
+ const p = path.join(dir, v, ...suffix);
71
+ if (safeExists(p))
72
+ out.push(p);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /** 설정에 저장된 수동 경로(최우선). DB 미초기화(테스트)면 조용히 무시. */
78
+ function settingBin() {
79
+ try {
80
+ const v = getSetting('agent_bin_path');
81
+ return typeof v === 'string' ? v.trim() : '';
82
+ }
83
+ catch {
84
+ return '';
85
+ }
86
+ }
87
+ let cachedBin;
88
+ /**
89
+ * claude 바이너리를 찾는다. 우선순위:
90
+ * 1) 설정 수동 경로 → 2) DRAFTING_AGENT_BIN → 3) PATH(which) →
91
+ * 4) 표준 위치 → 5) 노드 버전 매니저(nvm/fnm/asdf/n) 글롭
92
+ * GUI 앱은 셸 PATH 를 못 물려받으므로 4·5 의 직접 탐색이 핵심이다.
93
+ */
94
+ export function resolveCliBin() {
95
+ if (cachedBin !== undefined)
96
+ return cachedBin;
97
+ for (const cand of [settingBin(), (process.env.DRAFTING_AGENT_BIN ?? '').trim()]) {
98
+ if (safeExists(cand))
99
+ return (cachedBin = cand);
100
+ }
101
+ const onPath = spawnSync('which', ['claude'], { encoding: 'utf8' });
102
+ if (onPath.status === 0 && onPath.stdout.trim())
103
+ return (cachedBin = onPath.stdout.trim());
104
+ for (const cand of FIXED_CANDIDATES) {
105
+ if (safeExists(cand))
106
+ return (cachedBin = cand);
107
+ }
108
+ const managed = nodeManagerBins();
109
+ if (managed.length)
110
+ return (cachedBin = managed[0]);
111
+ return (cachedBin = null);
112
+ }
113
+ export function cliAvailable() {
114
+ return resolveCliBin() !== null;
115
+ }
116
+ /** 세션 캐시 무효화 (설정에서 경로를 바꾼 직후 등) */
117
+ export function resetCliBinCache() {
118
+ cachedBin = undefined;
119
+ }
120
+ /**
121
+ * CLI 스폰용 env. GUI 앱의 빈약한 PATH 로는 nvm/fnm 의 claude 셔뱅
122
+ * (`#!/usr/bin/env node`)이 node 를 못 찾아 실패한다 → 바이너리와 같은 dir
123
+ * (그 안에 node 가 함께 있다) + 표준 위치를 PATH 앞에 얹는다. 테스트 가능하도록 분리.
124
+ */
125
+ export function cliSpawnEnv(bin, baseEnv = process.env) {
126
+ const prepend = [
127
+ path.dirname(bin),
128
+ '/opt/homebrew/bin',
129
+ '/usr/local/bin',
130
+ path.join(HOME, '.local', 'bin'),
131
+ '/usr/bin',
132
+ '/bin',
133
+ ];
134
+ const existing = baseEnv.PATH ? baseEnv.PATH.split(path.delimiter) : [];
135
+ const seen = new Set();
136
+ const merged = [...prepend, ...existing].filter((d) => d && !seen.has(d) && seen.add(d));
137
+ const env = { ...baseEnv, PATH: merged.join(path.delimiter) };
138
+ // 헤드리스 한 방(one-shot) 생성에서 확장 사고(extended thinking)가 스톨/토큰 소진으로
139
+ // 빈 결과·is_error 를 내는 것을 막는다(회고 교훈). 사용자가 명시 설정하면 존중.
140
+ if (env.MAX_THINKING_TOKENS === undefined)
141
+ env.MAX_THINKING_TOKENS = '0';
142
+ return env;
143
+ }
144
+ /** API 모델 id → CLI 별칭. CLI 는 풀 id 도 받지만 별칭이 구독 기본값과 정합. */
145
+ function cliModel(model) {
146
+ const m = model.toLowerCase();
147
+ if (m.includes('haiku'))
148
+ return 'haiku';
149
+ if (m.includes('opus'))
150
+ return 'opus';
151
+ if (m.includes('sonnet'))
152
+ return 'sonnet';
153
+ return model || 'sonnet';
154
+ }
155
+ /** stream-json 한 줄 → 텍스트 델타 (없으면 null). 테스트에서 직접 검증한다. */
156
+ export function deltaFromLine(line) {
157
+ let ev;
158
+ try {
159
+ ev = JSON.parse(line);
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ if (ev.type === 'stream_event' && ev.event?.delta?.type === 'text_delta') {
165
+ return ev.event.delta.text ?? null;
166
+ }
167
+ return null;
168
+ }
169
+ /** 최종 assistant 텍스트 (partial 미지원 CLI 폴백용). */
170
+ export function textFromAssistantLine(line) {
171
+ let ev;
172
+ try {
173
+ ev = JSON.parse(line);
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ if (ev.type !== 'assistant')
179
+ return null;
180
+ const parts = (ev.message?.content ?? [])
181
+ .filter((b) => b.type === 'text')
182
+ .map((b) => b.text ?? '');
183
+ return parts.length ? parts.join('') : null;
184
+ }
185
+ export function errorFromResultLine(line) {
186
+ let ev;
187
+ try {
188
+ ev = JSON.parse(line);
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ if (ev.type === 'result' && ev.is_error)
194
+ return ev.result || 'agent CLI returned an error';
195
+ return null;
196
+ }
197
+ /**
198
+ * 구독 차단(조직이 Claude Code 구독 접근을 끈 경우)·미로그인 등 인증 계열 에러에
199
+ * 앱 안에서의 해결 경로를 덧붙인다. 원문은 보존한다.
200
+ */
201
+ export function actionableCliError(msg) {
202
+ if (/disabled Claude subscription|API key instead|log ?in|authenticat|OAuth|credential/i.test(msg)) {
203
+ return (`${msg} — 설정(⌘,)에서 엔진을 API 키(BYOK) 모드로 전환해 Anthropic/OpenRouter 키를 등록하거나, ` +
204
+ `이 머신의 claude CLI 를 구독이 허용된 계정으로 다시 로그인(claude /login)하세요.`);
205
+ }
206
+ return msg;
207
+ }
208
+ function agentCwd() {
209
+ const dir = path.join(path.dirname(config.databasePath), 'agent');
210
+ fs.mkdirSync(dir, { recursive: true });
211
+ return dir;
212
+ }
213
+ const AUTH_BLOCK_RE = /disabled Claude subscription|subscription access|API key instead|log ?in|not logged|authenticat|OAuth|credential|invalid API key|unauthorized|401|403/i;
214
+ /**
215
+ * `--version`(존재 확인)을 넘어 실제 생성 권한을 검증한다. 조직이 Claude Code
216
+ * 구독 접근을 끈 계정은 CLI 가 설치·로그인돼 있어도 첫 생성에서 거부되므로,
217
+ * 온보딩에서 '키 없이 시작'을 누르는 즉시 이 검증으로 미리 잡아 BYOK 로 보낸다.
218
+ * 검증용 최소 프롬프트 + haiku 로 토큰을 아낀다.
219
+ */
220
+ export async function verifyCliAccess(binOverride) {
221
+ const bin = binOverride ?? resolveCliBin();
222
+ if (!bin) {
223
+ return { ok: false, blocked: false, detail: 'Claude Code CLI 를 찾지 못했습니다 (claude 설치·로그인 필요)' };
224
+ }
225
+ return new Promise((resolve) => {
226
+ const child = spawn(bin, ['-p', '응답으로 OK 한 단어만 출력', '--output-format', 'json', '--max-turns', '1', '--model', 'haiku', '--setting-sources', ''], { cwd: agentCwd(), env: cliSpawnEnv(bin), stdio: ['ignore', 'pipe', 'pipe'] });
227
+ let out = '';
228
+ let err = '';
229
+ const timer = setTimeout(() => {
230
+ try {
231
+ child.kill('SIGTERM');
232
+ }
233
+ catch { /* gone */ }
234
+ resolve({ ok: false, blocked: false, detail: 'CLI 응답 시간 초과 — 네트워크·로그인 상태를 확인하세요.' });
235
+ }, 30000);
236
+ child.stdout.on('data', (c) => { out += c.toString(); });
237
+ child.stderr.on('data', (c) => { err += c.toString(); });
238
+ child.on('error', (e) => {
239
+ clearTimeout(timer);
240
+ resolve({ ok: false, blocked: false, detail: `CLI 실행 실패: ${e.message}` });
241
+ });
242
+ child.on('close', (code) => {
243
+ clearTimeout(timer);
244
+ let isErr = false;
245
+ let resultText = '';
246
+ for (const line of out.split('\n')) {
247
+ const t = line.trim();
248
+ if (!t)
249
+ continue;
250
+ try {
251
+ const ev = JSON.parse(t);
252
+ if (ev.type === 'result') {
253
+ isErr = !!ev.is_error;
254
+ resultText = ev.result ?? '';
255
+ }
256
+ }
257
+ catch { /* not json */ }
258
+ }
259
+ const combined = `${resultText}\n${err}`;
260
+ const blocked = AUTH_BLOCK_RE.test(combined);
261
+ if (code === 0 && !isErr && !blocked) {
262
+ return resolve({ ok: true, blocked: false, detail: 'Claude Code 생성 권한 확인됨' });
263
+ }
264
+ resolve({ ok: false, blocked, detail: actionableCliError(resultText || err || `CLI 종료 코드 ${code}`) });
265
+ });
266
+ });
267
+ }
268
+ export class CliProvider {
269
+ id = 'cli';
270
+ bin;
271
+ constructor(bin) {
272
+ const resolved = bin ?? resolveCliBin();
273
+ if (!resolved) {
274
+ throw new Error('Claude Code CLI 를 찾지 못했습니다. 설치 후 로그인하거나(claude), 설정에서 API 키(BYOK) 모드로 전환하세요.');
275
+ }
276
+ this.bin = resolved;
277
+ }
278
+ async *streamChat(params) {
279
+ const system = params.messages
280
+ .filter((m) => m.role === 'system')
281
+ .map((m) => m.content)
282
+ .join('\n\n');
283
+ const prompt = params.messages
284
+ .filter((m) => m.role !== 'system')
285
+ .map((m) => m.content)
286
+ .join('\n\n');
287
+ const args = [
288
+ '-p', prompt,
289
+ '--output-format', 'stream-json',
290
+ '--verbose',
291
+ '--include-partial-messages',
292
+ '--max-turns', '1',
293
+ '--model', cliModel(params.model),
294
+ '--setting-sources', '',
295
+ ];
296
+ if (system)
297
+ args.push('--system-prompt', system, '--exclude-dynamic-system-prompt-sections');
298
+ const child = spawn(this.bin, args, {
299
+ cwd: agentCwd(),
300
+ env: cliSpawnEnv(this.bin),
301
+ stdio: ['ignore', 'pipe', 'pipe'],
302
+ });
303
+ if (params.signal) {
304
+ const onAbort = () => { try {
305
+ child.kill('SIGTERM');
306
+ }
307
+ catch { /* gone */ } };
308
+ if (params.signal.aborted)
309
+ onAbort();
310
+ else
311
+ params.signal.addEventListener('abort', onAbort, { once: true });
312
+ }
313
+ let stderrTail = '';
314
+ child.stderr.on('data', (c) => {
315
+ stderrTail = (stderrTail + c.toString()).slice(-2000);
316
+ });
317
+ let buffer = '';
318
+ let sawDelta = false;
319
+ let fallbackText = '';
320
+ let resultError = null;
321
+ const lines = [];
322
+ let resolveMore = null;
323
+ let done = false;
324
+ let spawnError = null;
325
+ child.stdout.on('data', (c) => {
326
+ buffer += c.toString();
327
+ const parts = buffer.split('\n');
328
+ buffer = parts.pop() ?? '';
329
+ for (const p of parts)
330
+ if (p.trim())
331
+ lines.push(p);
332
+ resolveMore?.();
333
+ });
334
+ child.on('error', (e) => { spawnError = e; done = true; resolveMore?.(); });
335
+ child.on('close', () => { if (buffer.trim())
336
+ lines.push(buffer); done = true; resolveMore?.(); });
337
+ while (!done || lines.length) {
338
+ if (!lines.length) {
339
+ await new Promise((res) => { resolveMore = res; });
340
+ resolveMore = null;
341
+ continue;
342
+ }
343
+ const line = lines.shift();
344
+ const delta = deltaFromLine(line);
345
+ if (delta !== null) {
346
+ sawDelta = true;
347
+ yield delta;
348
+ continue;
349
+ }
350
+ const full = textFromAssistantLine(line);
351
+ if (full !== null)
352
+ fallbackText += full;
353
+ resultError = resultError ?? errorFromResultLine(line);
354
+ }
355
+ if (spawnError)
356
+ throw new Error(`agent CLI 실행 실패: ${spawnError.message}`);
357
+ if (resultError) {
358
+ // 일반 메시지("agent CLI returned an error")만으로는 원인을 알 수 없다 → stderr 꼬리를 덧붙여 노출.
359
+ const tail = stderrTail.trim();
360
+ const detail = tail ? `${resultError} · ${tail.slice(-300)}` : resultError;
361
+ throw new Error(actionableCliError(detail));
362
+ }
363
+ if (!sawDelta && fallbackText)
364
+ yield fallbackText;
365
+ if (!sawDelta && !fallbackText && child.exitCode !== 0) {
366
+ throw new Error(actionableCliError(`agent CLI 종료 코드 ${child.exitCode}: ${stderrTail.slice(-300)}`));
367
+ }
368
+ }
369
+ async testConnection(_model) {
370
+ return new Promise((resolve) => {
371
+ const child = spawn(this.bin, ['--version'], { env: cliSpawnEnv(this.bin), stdio: ['ignore', 'pipe', 'pipe'] });
372
+ let out = '';
373
+ const timer = setTimeout(() => {
374
+ try {
375
+ child.kill();
376
+ }
377
+ catch { /* gone */ }
378
+ resolve({ ok: false, detail: 'CLI 응답 시간 초과' });
379
+ }, 5000);
380
+ child.stdout.on('data', (c) => { out += c.toString(); });
381
+ child.on('close', (code) => {
382
+ clearTimeout(timer);
383
+ resolve(code === 0 ? { ok: true, detail: out.trim() } : { ok: false, detail: `종료 코드 ${code}` });
384
+ });
385
+ child.on('error', (e) => {
386
+ clearTimeout(timer);
387
+ resolve({ ok: false, detail: e.message });
388
+ });
389
+ });
390
+ }
391
+ }
@@ -0,0 +1,68 @@
1
+ import { config } from "../lib/config.js";
2
+ import { getDecryptedKey } from "../db/repos.js";
3
+ import { AnthropicProvider } from "./byok/anthropic.js";
4
+ import { OpenAIProvider, OpenRouterProvider } from "./byok/openai-compat.js";
5
+ import { StubProvider } from "./stub.js";
6
+ import { ManagedProvider } from "./managed.js";
7
+ import { CliProvider, cliAvailable } from "./cli.js";
8
+ import { getSetting } from "../db/repos.js";
9
+ export class ProviderKeyError extends Error {
10
+ provider;
11
+ constructor(provider) {
12
+ super(`No API key configured for provider "${provider}". Add one in Settings.`);
13
+ this.name = 'ProviderKeyError';
14
+ this.provider = provider;
15
+ }
16
+ }
17
+ /**
18
+ * Resolve an AIProvider for a given provider id. This is the ONLY place a
19
+ * concrete provider is constructed — all AI calls funnel through here (G-07).
20
+ * - MANAGED_TIER=true -> ManagedProvider (v2, interface only)
21
+ * - AI_STUB=1 -> StubProvider (offline, deterministic)
22
+ * - otherwise -> BYOK provider using the decrypted key
23
+ */
24
+ /** 엔진 모드: 'cli'(Claude Code 구독, 기본) | 'byok'(API 키). 미설정 시 CLI 감지로 결정. */
25
+ export function aiMode() {
26
+ const saved = getSetting('ai_mode');
27
+ if (saved === 'cli' || saved === 'byok')
28
+ return saved;
29
+ return cliAvailable() ? 'cli' : 'byok';
30
+ }
31
+ export function resolveProvider(providerId, opts) {
32
+ if (config.managedTier)
33
+ return new ManagedProvider();
34
+ if (config.aiStub)
35
+ return new StubProvider();
36
+ if (!opts?.forceByok && aiMode() === 'cli')
37
+ return new CliProvider();
38
+ const key = getDecryptedKey(providerId);
39
+ if (!key)
40
+ throw new ProviderKeyError(providerId);
41
+ switch (providerId) {
42
+ case 'anthropic':
43
+ return new AnthropicProvider(key);
44
+ case 'openai': {
45
+ // OpenAI 호환 게이트웨이(LiteLLM 등) 지원: 인앱 설정 > env > 표준 OpenAI 순.
46
+ const base = getSetting('openai_base_url') || config.openaiBaseUrl || undefined;
47
+ const headers = getSetting('openai_headers') || undefined;
48
+ return new OpenAIProvider(key, base, headers);
49
+ }
50
+ case 'openrouter': {
51
+ // 게이트웨이 base 를 설정하면 openrouter 슬롯도 그 게이트웨이로 라우팅(키를 여기 넣은 경우).
52
+ // 안 하면 표준 openrouter.ai.
53
+ const base = getSetting('openai_base_url') || config.openaiBaseUrl || undefined;
54
+ const headers = getSetting('openai_headers') || undefined;
55
+ return new OpenRouterProvider(key, base, headers);
56
+ }
57
+ default:
58
+ throw new Error(`Unknown provider: ${providerId}`);
59
+ }
60
+ }
61
+ /** True if we can produce SOME provider (stub, managed, or a stored key). */
62
+ export function hasUsableProvider(providerId) {
63
+ if (config.managedTier || config.aiStub)
64
+ return true;
65
+ if (aiMode() === 'cli')
66
+ return cliAvailable();
67
+ return getDecryptedKey(providerId) !== null;
68
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * v2 managed-tier provider (P-03 / §5). Interface only in v1: the constructor
3
+ * and shape are defined so the factory can switch to it when MANAGED_TIER=true,
4
+ * but the implementation is intentionally not shipped. This keeps the AI-call
5
+ * abstraction ready for the managed cloud tier without pulling it into v1 scope.
6
+ */
7
+ export class ManagedProvider {
8
+ id = 'managed';
9
+ base;
10
+ key;
11
+ constructor(base = process.env.MANAGED_API_BASE, key = process.env.MANAGED_API_KEY) {
12
+ this.base = base;
13
+ this.key = key;
14
+ }
15
+ // eslint-disable-next-line require-yield
16
+ async *streamChat(_params) {
17
+ throw new Error('ManagedProvider is not implemented in v1. Set MANAGED_TIER=false and use BYOK keys.');
18
+ }
19
+ async testConnection(_model) {
20
+ return { ok: false, detail: 'managed tier not implemented in v1' };
21
+ }
22
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Consume a fetch() streaming Response body and yield each SSE `data:` payload
3
+ * as a string. Events are separated by a blank line; multiple `data:` lines in
4
+ * one event are joined with '\n'. Yields the raw data text — JSON parsing is the
5
+ * caller's job, since payload shape differs per provider.
6
+ */
7
+ export async function* iterateSse(res) {
8
+ if (!res.body)
9
+ return;
10
+ const reader = res.body.getReader();
11
+ const decoder = new TextDecoder();
12
+ let buffer = '';
13
+ try {
14
+ while (true) {
15
+ const { value, done } = await reader.read();
16
+ if (done)
17
+ break;
18
+ buffer += decoder.decode(value, { stream: true });
19
+ buffer = buffer.replace(/\r\n/g, '\n');
20
+ let idx;
21
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
22
+ const rawEvent = buffer.slice(0, idx);
23
+ buffer = buffer.slice(idx + 2);
24
+ const data = rawEvent
25
+ .split('\n')
26
+ .filter((l) => l.startsWith('data:'))
27
+ .map((l) => l.slice(5).trimStart())
28
+ .join('\n');
29
+ if (data)
30
+ yield data;
31
+ }
32
+ }
33
+ }
34
+ finally {
35
+ reader.releaseLock();
36
+ }
37
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Deterministic, offline provider. Enabled via AI_STUB=1 or auto-selected when
3
+ * no BYOK key is configured for a provider. Lets the whole pipeline (interview
4
+ * -> streaming draft -> editor -> export) run in tests and demos with no network
5
+ * and no real API key. Output is derived from the prompt so it is reproducible.
6
+ */
7
+ export class StubProvider {
8
+ id = 'stub';
9
+ async *streamChat(params) {
10
+ const userMsg = [...params.messages].reverse().find((m) => m.role === 'user');
11
+ const heading = extractHeading(userMsg?.content ?? '') ?? '섹션';
12
+ const answers = extractAnswers(userMsg?.content ?? '');
13
+ const lines = [];
14
+ lines.push(`이 섹션은 "${heading}" 에 대한 초안입니다.`);
15
+ lines.push('');
16
+ if (answers.length) {
17
+ lines.push('인터뷰 답변을 반영한 핵심 항목:');
18
+ for (const a of answers.slice(0, 4))
19
+ lines.push(`- ${a}`);
20
+ }
21
+ else {
22
+ lines.push('- 핵심 가치 제안을 명확히 한다.');
23
+ lines.push('- 대상 사용자와 문제를 정의한다.');
24
+ lines.push('- 성공 기준을 측정 가능하게 둔다.');
25
+ }
26
+ lines.push('');
27
+ lines.push('> (스텁 프로바이더 출력 · 실제 AI 키를 등록하면 대체됩니다.)');
28
+ const text = lines.join('\n');
29
+ // stream word/token-ish chunks so the SSE section boundaries exercise.
30
+ // STUB_STREAM_DELAY_MS(>0) 로 청크 사이 지연 — 데모 녹화에서 스트리밍이 보이게(기본 0).
31
+ const delay = Number(process.env.STUB_STREAM_DELAY_MS) || 0;
32
+ for (const chunk of text.match(/\S+\s*|\n/g) ?? [text]) {
33
+ yield chunk;
34
+ if (delay > 0)
35
+ await new Promise((r) => setTimeout(r, delay));
36
+ }
37
+ }
38
+ async testConnection(_model) {
39
+ return { ok: true, detail: 'stub provider · always ok' };
40
+ }
41
+ }
42
+ function extractHeading(prompt) {
43
+ const m = prompt.match(/섹션 제목[::]\s*(.+)/) ?? prompt.match(/heading[::]\s*(.+)/i);
44
+ return m ? m[1].trim() : null;
45
+ }
46
+ function extractAnswers(prompt) {
47
+ // answers are embedded as "- Q: ... / A: ..." style lines in the draft prompt
48
+ const out = [];
49
+ for (const line of prompt.split('\n')) {
50
+ const m = line.match(/^A[::]\s*(.+)/);
51
+ if (m && m[1].trim())
52
+ out.push(m[1].trim());
53
+ }
54
+ return out;
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { HttpError, parse } from "./helpers.js";
3
+ import { backupBytes, restoreFromBytes } from "../db/index.js";
4
+ /**
5
+ * 워크스페이스 백업/복원 (v0.5 데이터 안전). 전체 DB 파일을 스냅샷으로 내려받고,
6
+ * 검증 후 통째로 복원한다. 복원은 파괴적이므로 프론트에서 확인을 받는다.
7
+ */
8
+ export async function backupRoutes(app) {
9
+ app.get('/api/backup', async (_req, reply) => {
10
+ const bytes = backupBytes();
11
+ reply.header('Content-Type', 'application/octet-stream');
12
+ reply.header('Content-Disposition', 'attachment; filename="drafting-backup.sqlite"');
13
+ return reply.send(bytes);
14
+ });
15
+ // 파일은 base64 로 받는다(멀티파트 의존성 회피). 라우트 한정 바디 상한 상향.
16
+ app.post('/api/restore', { bodyLimit: 256 * 1024 * 1024 }, async (req) => {
17
+ const { data } = parse(z.object({ data: z.string().min(1) }), req.body);
18
+ const buf = Buffer.from(data, 'base64');
19
+ if (buf.length < 100)
20
+ throw new HttpError(400, '백업 파일이 비었거나 손상되었습니다');
21
+ restoreFromBytes(buf);
22
+ return { ok: true };
23
+ });
24
+ }