tissues 0.5.2 → 0.6.1
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 +94 -40
- package/package.json +3 -4
- package/src/cli.js +26 -22
- package/src/commands/ai.js +268 -0
- package/src/commands/auth.js +4 -4
- package/src/commands/config.js +1035 -12
- package/src/commands/create.js +523 -157
- package/src/commands/drafts.js +288 -0
- package/src/commands/enhancements.js +282 -0
- package/src/commands/list.js +7 -5
- package/src/commands/status.js +81 -19
- package/src/commands/templates.js +157 -0
- package/src/lib/ai/adapters/anthropic.js +52 -0
- package/src/lib/ai/adapters/base.js +45 -0
- package/src/lib/ai/adapters/command.js +68 -0
- package/src/lib/ai/adapters/gemini.js +56 -0
- package/src/lib/ai/adapters/ollama.js +60 -0
- package/src/lib/ai/adapters/openai-compat.js +51 -0
- package/src/lib/ai/adapters/openai.js +44 -0
- package/src/lib/ai/body-template.js +75 -0
- package/src/lib/ai/enhance.js +107 -0
- package/src/lib/ai/enhancement-adapter.js +109 -0
- package/src/lib/ai/index.js +122 -0
- package/src/lib/ai/pipeline.js +97 -0
- package/src/lib/ai/prompt.js +39 -0
- package/src/lib/ai/router.js +216 -0
- package/src/lib/ai/steps.js +492 -0
- package/src/lib/attribution.js +18 -179
- package/src/lib/clipboard.js +147 -0
- package/src/lib/color.js +9 -0
- package/src/lib/dedup.js +67 -32
- package/src/lib/defaults.js +54 -2
- package/src/lib/drafts.js +439 -0
- package/src/lib/enhancements.js +436 -0
- package/src/lib/gh.js +102 -21
- package/src/lib/repo-picker.js +2 -0
- package/src/lib/safety.js +1 -1
- package/src/lib/templates.js +8 -12
- package/src/lib/theme.js +9 -0
- package/src/commands/use.js +0 -19
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write-ahead drafts for pending issues.
|
|
3
|
+
*
|
|
4
|
+
* ZERO DATA LOSS GUARANTEE:
|
|
5
|
+
* Every issue is written to disk the moment we have user input — before
|
|
6
|
+
* template rendering, AI enhancement, dedup, or safety checks. The draft
|
|
7
|
+
* file is the source of truth until GitHub confirms the issue exists.
|
|
8
|
+
*
|
|
9
|
+
* Files live in `.tissues/drafts/{slug}.json` where slug is derived from
|
|
10
|
+
* the issue title. If a file with the same slug already exists, new attempt
|
|
11
|
+
* data is appended (append-only — we never overwrite user content).
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle: draft → pending → complete (deleted)
|
|
14
|
+
* → duplicate (kept)
|
|
15
|
+
* → aborted (kept)
|
|
16
|
+
* → failed (kept)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import os from 'node:os'
|
|
22
|
+
import crypto from 'node:crypto'
|
|
23
|
+
import { findRepoRoot } from './defaults.js'
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Directory resolution
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Auto-migrate old `.tissues/outbox/` to `.tissues/drafts/` on first access.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} draftsDir
|
|
33
|
+
* @param {string} base
|
|
34
|
+
*/
|
|
35
|
+
function migrateOutboxDir(draftsDir, base) {
|
|
36
|
+
const oldDir = path.join(base, '.tissues', 'outbox')
|
|
37
|
+
if (fs.existsSync(oldDir) && !fs.existsSync(draftsDir)) {
|
|
38
|
+
fs.renameSync(oldDir, draftsDir)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the drafts directory. Creates it if it doesn't exist.
|
|
44
|
+
*
|
|
45
|
+
* @param {string|null} [repoRoot] - repo root path; auto-detected if omitted
|
|
46
|
+
* @returns {string} absolute path to the drafts directory
|
|
47
|
+
*/
|
|
48
|
+
export function getDraftsDir(repoRoot) {
|
|
49
|
+
const base = repoRoot ?? findRepoRoot()
|
|
50
|
+
const dir = base
|
|
51
|
+
? path.join(base, '.tissues', 'drafts')
|
|
52
|
+
: path.join(os.homedir(), '.config', 'tissues', 'drafts')
|
|
53
|
+
if (base) migrateOutboxDir(dir, base)
|
|
54
|
+
else {
|
|
55
|
+
const oldGlobal = path.join(os.homedir(), '.config', 'tissues', 'outbox')
|
|
56
|
+
if (fs.existsSync(oldGlobal) && !fs.existsSync(dir)) {
|
|
57
|
+
fs.renameSync(oldGlobal, dir)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
61
|
+
return dir
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compute the drafts dir path without creating it (for existence checks).
|
|
66
|
+
*
|
|
67
|
+
* @param {string|null} [repoRoot]
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function resolveDraftsDir(repoRoot) {
|
|
71
|
+
const base = repoRoot ?? findRepoRoot()
|
|
72
|
+
return base
|
|
73
|
+
? path.join(base, '.tissues', 'drafts')
|
|
74
|
+
: path.join(os.homedir(), '.config', 'tissues', 'drafts')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Slug helper — title → filesystem-safe identifier
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Convert a title to a filesystem-safe slug.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} title
|
|
85
|
+
* @returns {string} slug (max 60 chars, lowercase, hyphens)
|
|
86
|
+
*/
|
|
87
|
+
function slugify(title) {
|
|
88
|
+
const slug = title
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
91
|
+
.replace(/^-|-$/g, '')
|
|
92
|
+
.slice(0, 60)
|
|
93
|
+
return slug || crypto.randomUUID().replace(/-/g, '').slice(0, 10)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Public API
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Write a draft immediately with raw user input.
|
|
102
|
+
* Called as soon as we have title + repo — before any processing.
|
|
103
|
+
*
|
|
104
|
+
* If a file with the same title slug already exists, appends a new attempt
|
|
105
|
+
* entry (append-only — never overwrites user content).
|
|
106
|
+
*
|
|
107
|
+
* @param {{ repo: string, title: string, description?: string, instructions?: string, labels?: string[] }} item
|
|
108
|
+
* @param {string|null} [repoRoot]
|
|
109
|
+
* @returns {{ id: string, filePath: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function writeDraft(item, repoRoot) {
|
|
112
|
+
const dir = getDraftsDir(repoRoot)
|
|
113
|
+
const id = slugify(item.title)
|
|
114
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
115
|
+
const now = new Date().toISOString()
|
|
116
|
+
|
|
117
|
+
if (fs.existsSync(filePath)) {
|
|
118
|
+
// Append new attempt to existing file — never lose what's already there
|
|
119
|
+
try {
|
|
120
|
+
const existing = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
121
|
+
const attempt = {
|
|
122
|
+
createdAt: now,
|
|
123
|
+
description: item.description || '',
|
|
124
|
+
instructions: item.instructions || '',
|
|
125
|
+
}
|
|
126
|
+
if (!existing.attempts) existing.attempts = []
|
|
127
|
+
existing.attempts.push(attempt)
|
|
128
|
+
// Reset to draft so it's visible in the drafts list
|
|
129
|
+
existing.status = 'draft'
|
|
130
|
+
existing.updatedAt = now
|
|
131
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
132
|
+
return { id: existing.id, filePath }
|
|
133
|
+
} catch {
|
|
134
|
+
// Corrupt file — overwrite it
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const record = {
|
|
139
|
+
id,
|
|
140
|
+
repo: item.repo,
|
|
141
|
+
title: item.title,
|
|
142
|
+
description: item.description || '',
|
|
143
|
+
instructions: item.instructions || '',
|
|
144
|
+
body: '',
|
|
145
|
+
labels: item.labels ?? [],
|
|
146
|
+
createdAt: now,
|
|
147
|
+
status: 'draft',
|
|
148
|
+
attemptCount: 0,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
152
|
+
return { id, filePath }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Update a draft entry with the processed body and labels.
|
|
157
|
+
* Called after template rendering + AI enhancement + attribution.
|
|
158
|
+
* Transitions status from 'draft' to 'pending' (ready to submit).
|
|
159
|
+
*
|
|
160
|
+
* @param {string} id - draft item ID (slug)
|
|
161
|
+
* @param {{ body: string, labels?: string[] }} updates
|
|
162
|
+
* @param {string|null} [repoRoot]
|
|
163
|
+
*/
|
|
164
|
+
export function updateDraftItem(id, updates, repoRoot) {
|
|
165
|
+
const dir = getDraftsDir(repoRoot)
|
|
166
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
167
|
+
try {
|
|
168
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
169
|
+
const record = JSON.parse(raw)
|
|
170
|
+
if (updates.body) record.body = updates.body
|
|
171
|
+
if (updates.labels) record.labels = updates.labels
|
|
172
|
+
record.status = 'pending'
|
|
173
|
+
record.updatedAt = new Date().toISOString()
|
|
174
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
175
|
+
} catch {
|
|
176
|
+
// File may have been deleted manually — that's ok
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Write a new pending item to drafts (legacy API, still used by batch).
|
|
182
|
+
* For single creates, prefer writeDraft() + updateDraftItem().
|
|
183
|
+
*
|
|
184
|
+
* @param {{ repo: string, title: string, body: string, labels?: string[] }} item
|
|
185
|
+
* @param {string|null} [repoRoot]
|
|
186
|
+
* @returns {{ id: string, repo: string, title: string, body: string, labels: string[], createdAt: string, status: string, attemptCount: number }}
|
|
187
|
+
*/
|
|
188
|
+
export function writeToDrafts(item, repoRoot) {
|
|
189
|
+
const dir = getDraftsDir(repoRoot)
|
|
190
|
+
const id = slugify(item.title)
|
|
191
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
192
|
+
|
|
193
|
+
// If slug exists, append
|
|
194
|
+
if (fs.existsSync(filePath)) {
|
|
195
|
+
try {
|
|
196
|
+
const existing = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
197
|
+
existing.body = item.body
|
|
198
|
+
existing.labels = item.labels ?? existing.labels
|
|
199
|
+
existing.status = 'pending'
|
|
200
|
+
existing.updatedAt = new Date().toISOString()
|
|
201
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
202
|
+
return existing
|
|
203
|
+
} catch { /* corrupt — fall through to overwrite */ }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const record = {
|
|
207
|
+
id,
|
|
208
|
+
repo: item.repo,
|
|
209
|
+
title: item.title,
|
|
210
|
+
body: item.body,
|
|
211
|
+
labels: item.labels ?? [],
|
|
212
|
+
createdAt: new Date().toISOString(),
|
|
213
|
+
status: 'pending',
|
|
214
|
+
attemptCount: 0,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
218
|
+
return record
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Read all draft items, sorted by createdAt ascending.
|
|
223
|
+
* Skips non-JSON files and corrupt entries gracefully.
|
|
224
|
+
*
|
|
225
|
+
* @param {string|null} [repoRoot]
|
|
226
|
+
* @returns {Array<object>}
|
|
227
|
+
*/
|
|
228
|
+
export function readDrafts(repoRoot) {
|
|
229
|
+
const dir = getDraftsDir(repoRoot)
|
|
230
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json'))
|
|
231
|
+
const items = []
|
|
232
|
+
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
try {
|
|
235
|
+
const raw = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
236
|
+
const parsed = JSON.parse(raw)
|
|
237
|
+
// Skip non-object values (null, arrays, primitives)
|
|
238
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
239
|
+
items.push(parsed)
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// Skip corrupt or unreadable files
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return items.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Delete a draft item (issue successfully created and verified).
|
|
251
|
+
* This is the ONLY function that removes a draft file.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} id - draft item ID
|
|
254
|
+
* @param {object} [_meta] - unused (issueNumber, issueUrl) — reserved for future logging
|
|
255
|
+
* @param {string|null} [repoRoot]
|
|
256
|
+
*/
|
|
257
|
+
export function markComplete(id, _meta, repoRoot) {
|
|
258
|
+
const dir = getDraftsDir(repoRoot)
|
|
259
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
260
|
+
try {
|
|
261
|
+
fs.unlinkSync(filePath)
|
|
262
|
+
} catch {
|
|
263
|
+
// Already gone — that's fine
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Mark a draft item as a duplicate. File is KEPT — user data is never lost.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} id
|
|
271
|
+
* @param {string} reason - e.g. "Fuzzy title match (100%) with #36"
|
|
272
|
+
* @param {string|null} [repoRoot]
|
|
273
|
+
*/
|
|
274
|
+
export function markDuplicate(id, reason, repoRoot) {
|
|
275
|
+
const dir = getDraftsDir(repoRoot)
|
|
276
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
277
|
+
try {
|
|
278
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
279
|
+
const record = JSON.parse(raw)
|
|
280
|
+
record.status = 'duplicate'
|
|
281
|
+
record.duplicateReason = reason
|
|
282
|
+
record.duplicateAt = new Date().toISOString()
|
|
283
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
284
|
+
} catch {
|
|
285
|
+
// File may have been deleted already
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Mark a draft item as aborted by the user. File is KEPT.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} id
|
|
293
|
+
* @param {string|null} [repoRoot]
|
|
294
|
+
*/
|
|
295
|
+
export function markAborted(id, repoRoot) {
|
|
296
|
+
const dir = getDraftsDir(repoRoot)
|
|
297
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
298
|
+
try {
|
|
299
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
300
|
+
const record = JSON.parse(raw)
|
|
301
|
+
record.status = 'aborted'
|
|
302
|
+
record.abortedAt = new Date().toISOString()
|
|
303
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
304
|
+
} catch {
|
|
305
|
+
// File may have been deleted already
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Mark a draft item as failed (network error, API rejection, etc.).
|
|
311
|
+
* Keeps the file so the user can retry with `tissues drafts publish`.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} id - draft item ID
|
|
314
|
+
* @param {string} errorMessage
|
|
315
|
+
* @param {string|null} [repoRoot]
|
|
316
|
+
*/
|
|
317
|
+
export function markFailed(id, errorMessage, repoRoot) {
|
|
318
|
+
const dir = getDraftsDir(repoRoot)
|
|
319
|
+
const filePath = path.join(dir, `${id}.json`)
|
|
320
|
+
try {
|
|
321
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
322
|
+
const record = JSON.parse(raw)
|
|
323
|
+
record.status = 'failed'
|
|
324
|
+
record.error = errorMessage
|
|
325
|
+
record.failedAt = new Date().toISOString()
|
|
326
|
+
fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8')
|
|
327
|
+
} catch {
|
|
328
|
+
// File may have been deleted already
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Fast check: are there any pending draft items?
|
|
334
|
+
* Does NOT create the drafts dir if it doesn't exist.
|
|
335
|
+
*
|
|
336
|
+
* @param {string|null} [repoRoot]
|
|
337
|
+
* @returns {boolean}
|
|
338
|
+
*/
|
|
339
|
+
export function hasPending(repoRoot) {
|
|
340
|
+
try {
|
|
341
|
+
const dir = resolveDraftsDir(repoRoot)
|
|
342
|
+
if (!fs.existsSync(dir)) return false
|
|
343
|
+
return fs.readdirSync(dir).some((f) => f.endsWith('.json'))
|
|
344
|
+
} catch {
|
|
345
|
+
return false
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Count pending draft items.
|
|
351
|
+
*
|
|
352
|
+
* @param {string|null} [repoRoot]
|
|
353
|
+
* @returns {number}
|
|
354
|
+
*/
|
|
355
|
+
export function countPending(repoRoot) {
|
|
356
|
+
try {
|
|
357
|
+
const dir = resolveDraftsDir(repoRoot)
|
|
358
|
+
if (!fs.existsSync(dir)) return 0
|
|
359
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).length
|
|
360
|
+
} catch {
|
|
361
|
+
return 0
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
// Attachments
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve the attachment directory for a given draft item.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} id - draft item ID (slug)
|
|
373
|
+
* @param {string|null} [repoRoot]
|
|
374
|
+
* @returns {string} absolute path to the attachment directory
|
|
375
|
+
*/
|
|
376
|
+
export function getAttachmentDir(id, repoRoot) {
|
|
377
|
+
const dir = getDraftsDir(repoRoot)
|
|
378
|
+
return path.join(dir, id)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Copy a file into the draft attachment directory and record metadata
|
|
383
|
+
* in the draft JSON.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} id - draft item ID (slug)
|
|
386
|
+
* @param {string} filePath - absolute or relative path to the file
|
|
387
|
+
* @param {string|null} [repoRoot]
|
|
388
|
+
* @returns {{ filename: string, size: number, type: string }}
|
|
389
|
+
*/
|
|
390
|
+
export function attachFile(id, filePath, repoRoot) {
|
|
391
|
+
const resolved = path.resolve(filePath.replace(/^~/, os.homedir()))
|
|
392
|
+
if (!fs.existsSync(resolved)) {
|
|
393
|
+
throw new Error(`File not found: ${filePath}`)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const stat = fs.statSync(resolved)
|
|
397
|
+
if (!stat.isFile()) {
|
|
398
|
+
throw new Error(`Not a file: ${filePath}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const filename = path.basename(resolved)
|
|
402
|
+
const attachDir = getAttachmentDir(id, repoRoot)
|
|
403
|
+
fs.mkdirSync(attachDir, { recursive: true })
|
|
404
|
+
|
|
405
|
+
const destPath = path.join(attachDir, filename)
|
|
406
|
+
fs.copyFileSync(resolved, destPath)
|
|
407
|
+
|
|
408
|
+
const ext = path.extname(filename).toLowerCase()
|
|
409
|
+
const typeMap = {
|
|
410
|
+
'.png': 'image/png',
|
|
411
|
+
'.jpg': 'image/jpeg',
|
|
412
|
+
'.jpeg': 'image/jpeg',
|
|
413
|
+
'.gif': 'image/gif',
|
|
414
|
+
'.webp': 'image/webp',
|
|
415
|
+
'.svg': 'image/svg+xml',
|
|
416
|
+
'.pdf': 'application/pdf',
|
|
417
|
+
'.txt': 'text/plain',
|
|
418
|
+
'.log': 'text/plain',
|
|
419
|
+
}
|
|
420
|
+
const type = typeMap[ext] || 'application/octet-stream'
|
|
421
|
+
|
|
422
|
+
const meta = { filename, size: stat.size, type }
|
|
423
|
+
|
|
424
|
+
// Update draft JSON with attachment metadata
|
|
425
|
+
const draftsDir = getDraftsDir(repoRoot)
|
|
426
|
+
const jsonPath = path.join(draftsDir, `${id}.json`)
|
|
427
|
+
try {
|
|
428
|
+
const raw = fs.readFileSync(jsonPath, 'utf8')
|
|
429
|
+
const record = JSON.parse(raw)
|
|
430
|
+
if (!record.attachments) record.attachments = []
|
|
431
|
+
record.attachments.push(meta)
|
|
432
|
+
record.updatedAt = new Date().toISOString()
|
|
433
|
+
fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2), 'utf8')
|
|
434
|
+
} catch {
|
|
435
|
+
// JSON may be corrupt — attachment is still saved to disk
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return meta
|
|
439
|
+
}
|