@setzkasten-cms/astro-admin 0.6.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.
Files changed (49) hide show
  1. package/LICENSE +37 -0
  2. package/package.json +70 -0
  3. package/src/admin-page.astro +148 -0
  4. package/src/api-routes/__tests__/add-section-helpers.test.ts +383 -0
  5. package/src/api-routes/__tests__/catalog-api.test.ts +115 -0
  6. package/src/api-routes/__tests__/deferred-operations.test.ts +232 -0
  7. package/src/api-routes/__tests__/deploy-hook.test.ts +134 -0
  8. package/src/api-routes/__tests__/patch-page-file.test.ts +193 -0
  9. package/src/api-routes/__tests__/scan-page-helpers.test.ts +162 -0
  10. package/src/api-routes/__tests__/section-management.test.ts +284 -0
  11. package/src/api-routes/_storage-config.ts +54 -0
  12. package/src/api-routes/asset-proxy.ts +76 -0
  13. package/src/api-routes/auth-callback.ts +105 -0
  14. package/src/api-routes/auth-login.ts +87 -0
  15. package/src/api-routes/auth-logout.ts +9 -0
  16. package/src/api-routes/auth-session.ts +36 -0
  17. package/src/api-routes/catalog-add.ts +151 -0
  18. package/src/api-routes/catalog-export.ts +86 -0
  19. package/src/api-routes/catalog-helpers.ts +83 -0
  20. package/src/api-routes/catalog-list.ts +12 -0
  21. package/src/api-routes/config.ts +30 -0
  22. package/src/api-routes/deploy-hook.ts +69 -0
  23. package/src/api-routes/github-proxy.ts +111 -0
  24. package/src/api-routes/init-add-section.ts +511 -0
  25. package/src/api-routes/init-apply.ts +270 -0
  26. package/src/api-routes/init-migrate.ts +262 -0
  27. package/src/api-routes/init-scan-page.ts +336 -0
  28. package/src/api-routes/init-scan.ts +162 -0
  29. package/src/api-routes/pages.ts +17 -0
  30. package/src/api-routes/section-add.ts +189 -0
  31. package/src/api-routes/section-commit-pending.ts +147 -0
  32. package/src/api-routes/section-delete.ts +141 -0
  33. package/src/api-routes/section-duplicate.ts +144 -0
  34. package/src/api-routes/section-management.ts +95 -0
  35. package/src/api-routes/section-prepare-copy.ts +93 -0
  36. package/src/api-routes/section-prepare.ts +121 -0
  37. package/src/env.d.ts +7 -0
  38. package/src/init/__tests__/page-level.test.ts +1033 -0
  39. package/src/init/__tests__/page-list-coverage.test.ts +474 -0
  40. package/src/init/__tests__/patcher-edge-cases.test.ts +434 -0
  41. package/src/init/__tests__/patcher-page-mode.test.ts +272 -0
  42. package/src/init/__tests__/section-pipeline.test.ts +393 -0
  43. package/src/init/analyzer-types.ts +92 -0
  44. package/src/init/astro-config-patcher.ts +98 -0
  45. package/src/init/astro-detector.ts +207 -0
  46. package/src/init/astro-section-analyzer-v2.ts +1663 -0
  47. package/src/init/field-label-enricher.ts +72 -0
  48. package/src/init/template-patcher-v2.ts +1957 -0
  49. package/tsconfig.json +9 -0
@@ -0,0 +1,511 @@
1
+ import type { APIRoute } from 'astro'
2
+ import type { InferredSection } from '@setzkasten-cms/core/init'
3
+ import { addSectionToConfig } from '@setzkasten-cms/core/init'
4
+ import { resolveStorageConfig, prefixPath } from './_storage-config'
5
+ import { patchTemplateForFields, stripTemplateFallbacks } from '../init/template-patcher-v2'
6
+ import type { RepeatedGroup } from '../init/analyzer-types'
7
+
8
+ /**
9
+ * POST /api/setzkasten/init/add-section
10
+ *
11
+ * Adds a single section to an already-initialized Setzkasten project.
12
+ * Updates setzkasten.config.ts, creates the content JSON, and updates the page config.
13
+ * All changes are committed as a single batch via Git Trees API.
14
+ *
15
+ * Body: { owner, repo, branch?, projectRoot, section, pageKey, contentPath? }
16
+ */
17
+ export const POST: APIRoute = async ({ request, cookies }) => {
18
+ const session = cookies.get('setzkasten_session')?.value
19
+ if (!session) {
20
+ return new Response('Unauthorized', { status: 401 })
21
+ }
22
+
23
+ const githubToken = import.meta.env.GITHUB_TOKEN ?? process.env.GITHUB_TOKEN
24
+ if (!githubToken) {
25
+ return new Response('GitHub token not configured', { status: 500 })
26
+ }
27
+
28
+ try {
29
+ const body = await request.json() as {
30
+ owner?: string
31
+ repo?: string
32
+ branch?: string
33
+ projectRoot?: string
34
+ section: InferredSection & { allFields?: InferredSection['fields']; isPageLevel?: boolean }
35
+ pageKey: string
36
+ pagePath?: string
37
+ contentPath?: string
38
+ }
39
+
40
+ const storage = resolveStorageConfig(body)
41
+ if (!storage) {
42
+ return Response.json({ error: 'Could not resolve owner/repo. Set SETZKASTEN_OWNER and SETZKASTEN_REPO env vars.' }, { status: 400 })
43
+ }
44
+ const { owner, repo, branch, projectPrefix } = storage
45
+ const serverConfig = (globalThis as any).__SETZKASTEN_CONFIG__
46
+ const contentPath = body.contentPath || serverConfig?.storage?.contentPath || 'content'
47
+ const { section, pageKey } = body
48
+
49
+ if (!section || !pageKey) {
50
+ return Response.json({ error: 'section and pageKey are required' }, { status: 400 })
51
+ }
52
+
53
+ const headers = {
54
+ Authorization: `Bearer ${githubToken}`,
55
+ Accept: 'application/vnd.github+json',
56
+ 'X-GitHub-Api-Version': '2022-11-28',
57
+ 'Content-Type': 'application/json',
58
+ }
59
+
60
+ const filesToCommit: Array<{ path: string; content: string }> = []
61
+
62
+ // 1. Read and update setzkasten.config.ts
63
+ const configPath = prefixPath('setzkasten.config.ts', projectPrefix)
64
+ const existingConfig = await fetchFileContent(owner, repo, branch, configPath, githubToken)
65
+
66
+ if (existingConfig) {
67
+ const updatedConfig = addSectionToConfig(existingConfig, section.key, section, section.allFields)
68
+ if (updatedConfig) {
69
+ filesToCommit.push({ path: configPath, content: updatedConfig })
70
+ }
71
+ }
72
+
73
+ // 2. Generate content JSON — merge new fields into existing data (if any)
74
+ const sectionJsonPath = `${contentPath}/_sections/${section.key}.json`
75
+ const existingSectionJson = await fetchFileContent(owner, repo, branch, sectionJsonPath, githubToken)
76
+
77
+ let sectionData: Record<string, unknown> = {}
78
+ if (existingSectionJson) {
79
+ try {
80
+ sectionData = JSON.parse(existingSectionJson)
81
+ } catch { /* start fresh if invalid */ }
82
+ }
83
+
84
+ // Only add values for fields that don't already exist
85
+ for (const field of section.fields) {
86
+ if (!(field.key in sectionData)) {
87
+ sectionData[field.key] = field.defaultValue ?? getDefaultValue(field.type)
88
+ }
89
+ }
90
+
91
+ // Reorder JSON keys to match template order (allFields if available, else fields)
92
+ const orderedKeys = (section.allFields ?? section.fields).map(f => f.key)
93
+ const orderedData: Record<string, unknown> = {}
94
+ for (const key of orderedKeys) {
95
+ if (key in sectionData) {
96
+ orderedData[key] = sectionData[key]
97
+ }
98
+ }
99
+ // Append any remaining keys not in the ordered list
100
+ for (const key of Object.keys(sectionData)) {
101
+ if (!(key in orderedData)) {
102
+ orderedData[key] = sectionData[key]
103
+ }
104
+ }
105
+
106
+ filesToCommit.push({
107
+ path: sectionJsonPath,
108
+ content: JSON.stringify(orderedData, null, 2),
109
+ })
110
+
111
+ // 3. Update page config (add section to the end)
112
+ const configKey = '_' + pageKey.replace(/\//g, '_')
113
+ const pageConfigPath = `${contentPath}/pages/${configKey}.json`
114
+ const existingPageConfig = await fetchFileContent(owner, repo, branch, pageConfigPath, githubToken)
115
+
116
+ let pageConfig: { sections: Array<{ key: string; enabled: boolean }> }
117
+ if (existingPageConfig) {
118
+ pageConfig = JSON.parse(existingPageConfig)
119
+ // Only add if not already present (normalize keys to handle kebab vs camelCase)
120
+ const normalizeKey = (k: string) => k.replace(/[-_]/g, '').toLowerCase()
121
+ const normalizedNewKey = normalizeKey(section.key)
122
+ if (!pageConfig.sections.some((s) => normalizeKey(s.key) === normalizedNewKey)) {
123
+ pageConfig.sections.push({ key: section.key, enabled: true })
124
+ }
125
+ } else {
126
+ pageConfig = {
127
+ sections: [{ key: section.key, enabled: true }],
128
+ }
129
+ }
130
+ filesToCommit.push({
131
+ path: pageConfigPath,
132
+ content: JSON.stringify(pageConfig, null, 2),
133
+ })
134
+
135
+ // 4. Patch template — replace hardcoded content with CMS variables + data-sk-field
136
+ if (section.isPageLevel && body.pagePath) {
137
+ // Page-level: patch the PAGE file directly (not a separate component)
138
+ // Directory routes: UI sends src/pages/docs.astro but actual file is docs/index.astro.
139
+ // resolvedPagePath tracks the ACTUAL file found so the sk-preview clone is placed
140
+ // at the correct path (sk-preview/docs/index.astro, not sk-preview/docs.astro).
141
+ // This matters for the "../" depth adjustment in buildPreviewClone.
142
+ let fullPagePath = prefixPath(body.pagePath, projectPrefix)
143
+ let pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
144
+ let resolvedPagePath = body.pagePath
145
+ if (!pageSource && body.pagePath.endsWith('.astro')) {
146
+ const indexPath = body.pagePath.replace(/\.astro$/, '/index.astro')
147
+ fullPagePath = prefixPath(indexPath, projectPrefix)
148
+ pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
149
+ if (pageSource) resolvedPagePath = indexPath
150
+ }
151
+ if (pageSource) {
152
+ const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
153
+ let patchedSource = await patchTemplateForFields(
154
+ pageSource, section.key, section.allFields ?? section.fields, repeatedGroups,
155
+ { mode: 'page' },
156
+ )
157
+
158
+ // Strip ?? 'fallback' expressions from script-patched templates.
159
+ // Values go into the content JSON (JSON wins if already set).
160
+ const { source: strippedSource, fallbacks } = stripTemplateFallbacks(patchedSource)
161
+ if (strippedSource !== patchedSource) {
162
+ patchedSource = strippedSource
163
+ // Merge extracted fallback values — only write to JSON where not already set
164
+ let jsonUpdated = false
165
+ for (const [key, value] of Object.entries(fallbacks)) {
166
+ const existing = sectionData[key]
167
+ // HTML extracted from template wins over a plain-text defaultValue that was
168
+ // written by buildSectionData. This preserves links (<a href="mailto:">),
169
+ // inline code etc. that are present in the template fallback but not in the
170
+ // analyzer's text-only defaultValue.
171
+ const existingIsPlain = typeof existing === 'string' && !/<[a-z]/.test(existing)
172
+ const newIsHtml = typeof value === 'string' && /<[a-z]/.test(value as string)
173
+ if (!(key in sectionData) || existing === '' || existing === null || (existingIsPlain && newIsHtml)) {
174
+ sectionData[key] = value
175
+ jsonUpdated = true
176
+ }
177
+ }
178
+ // Update the content JSON entry if we added values
179
+ if (jsonUpdated) {
180
+ const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
181
+ if (jsonIdx !== -1) {
182
+ filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
183
+ }
184
+ }
185
+ }
186
+
187
+ if (patchedSource !== pageSource) {
188
+ filesToCommit.push({ path: fullPagePath, content: patchedSource })
189
+
190
+ // Create an SSR clone under sk-preview/ so the live preview iframe works.
191
+ // The patched source already uses getSection() which is draft-aware.
192
+ // The clone lives one directory deeper (sk-preview/<page> vs <page>),
193
+ // so all relative imports need one extra "../" level.
194
+ // Use resolvedPagePath (not body.pagePath) to get correct clone depth for
195
+ // directory routes (docs/index.astro → sk-preview/docs/index.astro).
196
+ const previewCopySource = patchedSource
197
+ .replace(/\bexport\s+const\s+prerender\s*=\s*true\s*;?\s*\n?/, '')
198
+ .replace(/(from\s+')(\.\.\/)/g, '$1../$2')
199
+ .replace(/(from\s+")(\.\.\/)/g, '$1../$2')
200
+ // resolvedPagePath: e.g. "src/pages/docs/index.astro" (for directory routes)
201
+ const relativePage = resolvedPagePath.replace(/^src\/pages\//, '') // "docs/index.astro"
202
+ const previewCopyPath = prefixPath(`src/pages/sk-preview/${relativePage}`, projectPrefix)
203
+ filesToCommit.push({ path: previewCopyPath, content: previewCopySource })
204
+ }
205
+
206
+ // Update content JSON with any _classN fields from patcher
207
+ const fields = section.allFields ?? section.fields
208
+ for (const g of repeatedGroups) {
209
+ const topField = fields.find(f => f.key === g.fieldKey)
210
+ if (!topField || !Array.isArray(topField.defaultValue)) continue
211
+ sectionData[g.fieldKey] = topField.defaultValue
212
+ const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
213
+ if (jsonIdx !== -1) {
214
+ filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
215
+ }
216
+ }
217
+ }
218
+ // No patchPageFile / SECTION_COMPONENTS for page-level sections
219
+ } else if (section.componentPath) {
220
+ const componentSource = await fetchFileContent(owner, repo, branch, section.componentPath, githubToken)
221
+ if (componentSource) {
222
+ const repeatedGroups: RepeatedGroup[] = (section as any)._analyzerResult?.repeatedGroups ?? []
223
+ const patchedSource = await patchTemplateForFields(componentSource, section.key, section.allFields ?? section.fields, repeatedGroups)
224
+ if (patchedSource !== componentSource) {
225
+ filesToCommit.push({ path: section.componentPath, content: patchedSource })
226
+ }
227
+
228
+ // Patcher may have added _classN fields to repeatedGroups (dynamic CSS classes).
229
+ // These were not in the content JSON yet (built before patcher ran).
230
+ // Re-read the now-mutated fields and update the content JSON entry.
231
+ const fields = section.allFields ?? section.fields
232
+ for (const g of repeatedGroups) {
233
+ const topField = fields.find(f => f.key === g.fieldKey)
234
+ if (!topField || !Array.isArray(topField.defaultValue)) continue
235
+ const items = topField.defaultValue as Array<Record<string, unknown>>
236
+
237
+ // Update sectionData with the enriched items array
238
+ sectionData[g.fieldKey] = items
239
+
240
+ // Rebuild and replace the content JSON in filesToCommit
241
+ const jsonIdx = filesToCommit.findIndex(f => f.path === sectionJsonPath)
242
+ if (jsonIdx !== -1) {
243
+ filesToCommit[jsonIdx]!.content = JSON.stringify(sectionData, null, 2)
244
+ }
245
+ }
246
+ }
247
+
248
+ // 5. Patch page file — add import and registry entry for new section
249
+ if (body.pagePath && section.componentName) {
250
+ const fullPagePath = prefixPath(body.pagePath, projectPrefix)
251
+ const pageSource = await fetchFileContent(owner, repo, branch, fullPagePath, githubToken)
252
+ if (pageSource) {
253
+ const patched = patchPageFile(pageSource, section.key, section.componentName, section.componentPath, body.pagePath)
254
+ if (patched && patched !== pageSource) {
255
+ filesToCommit.push({ path: fullPagePath, content: patched })
256
+ }
257
+ }
258
+
259
+ // 5b. Also patch the SSR preview route (sk-preview/[...page].astro)
260
+ const pageDir = body.pagePath.replace(/\/[^/]+$/, '') // e.g. "src/pages"
261
+ const previewPath = prefixPath(`${pageDir}/sk-preview/[...page].astro`, projectPrefix)
262
+ const previewSource = await fetchFileContent(owner, repo, branch, previewPath, githubToken)
263
+ if (previewSource) {
264
+ const patchedPreview = patchPageFile(previewSource, section.key, section.componentName, section.componentPath, `${pageDir}/sk-preview/[...page].astro`)
265
+ if (patchedPreview && patchedPreview !== previewSource) {
266
+ filesToCommit.push({ path: previewPath, content: patchedPreview })
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ if (filesToCommit.length === 0) {
273
+ return Response.json({ error: 'Nothing to commit' }, { status: 400 })
274
+ }
275
+
276
+ // 6. Batch commit via Git Trees API
277
+ const commitResult = await batchCommit(
278
+ owner,
279
+ repo,
280
+ branch,
281
+ filesToCommit,
282
+ existingSectionJson
283
+ ? `content: update ${section.key} section — add new fields`
284
+ : `content: add ${section.key} section to Setzkasten`,
285
+ headers,
286
+ )
287
+
288
+ if (!commitResult.ok) {
289
+ return Response.json({ error: commitResult.error }, { status: 500 })
290
+ }
291
+
292
+ return Response.json({
293
+ success: true,
294
+ commitSha: commitResult.sha,
295
+ filesWritten: filesToCommit.map((f) => f.path),
296
+ })
297
+ } catch (error) {
298
+ console.error('[setzkasten] add-section error:', error)
299
+ return Response.json(
300
+ { error: error instanceof Error ? error.message : 'Add section failed' },
301
+ { status: 500 },
302
+ )
303
+ }
304
+ }
305
+
306
+ function getDefaultValue(fieldType: string): unknown {
307
+ switch (fieldType) {
308
+ case 'text': return ''
309
+ case 'number': return 0
310
+ case 'boolean': return false
311
+ case 'image': return { path: '', alt: '' }
312
+ case 'array': return []
313
+ case 'color': return '#000000'
314
+ case 'date': return ''
315
+ case 'icon': return ''
316
+ default: return ''
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Add import statement and SECTION_COMPONENTS registry entry to a page file.
322
+ * Returns the patched source, or null if no changes needed.
323
+ */
324
+ export function patchPageFile(
325
+ source: string,
326
+ sectionKey: string,
327
+ componentName: string,
328
+ componentPath: string,
329
+ pagePath: string,
330
+ ): string | null {
331
+ // Normalize for comparison: strip dashes/underscores and lowercase
332
+ const normalize = (k: string) => k.replace(/[-_]/g, '').toLowerCase()
333
+
334
+ // Check if component is already imported
335
+ if (source.includes(`import ${componentName}`) || source.includes(`'${componentName}'`)) {
336
+ // Already imported — check if also in registry
337
+ if (source.includes(normalize(sectionKey))) return null
338
+ }
339
+
340
+ // Calculate relative import path from page to component
341
+ const pageDir = pagePath.replace(/\/[^/]+$/, '') // e.g. "src/pages"
342
+ const relPath = calculateRelativePath(pageDir, componentPath)
343
+
344
+ let patched = source
345
+
346
+ // 1. Add import after last section import (before setzkasten:content import)
347
+ const lastImportMatch = patched.match(/import\s+\w+\s+from\s+['"][^'"]*Section\.astro['"];?\s*\n/)
348
+ if (lastImportMatch) {
349
+ // Find the LAST such import
350
+ let lastIdx = 0
351
+ const importRegex = /import\s+\w+\s+from\s+['"][^'"]*Section\.astro['"];?\s*\n/g
352
+ let m: RegExpExecArray | null
353
+ while ((m = importRegex.exec(patched)) !== null) {
354
+ lastIdx = m.index + m[0].length
355
+ }
356
+ const importLine = `import ${componentName} from '${relPath}';\n`
357
+ if (!patched.includes(`import ${componentName}`)) {
358
+ patched = patched.slice(0, lastIdx) + importLine + patched.slice(lastIdx)
359
+ }
360
+ }
361
+
362
+ // 2. Add to SECTION_COMPONENTS registry
363
+ const registryPattern = /SECTION_COMPONENTS[^{]*\{([^}]*)\}/s
364
+ const registryMatch = patched.match(registryPattern)
365
+ if (registryMatch) {
366
+ const registryContent = registryMatch[1]!
367
+ // Check if key (normalized) is already in registry
368
+ if (!registryContent.includes(normalize(sectionKey))) {
369
+ // Find the last entry and add after it
370
+ const lastEntryMatch = registryContent.match(/.*\w+Section,?\s*$/m)
371
+ if (lastEntryMatch && lastEntryMatch.index !== undefined) {
372
+ const insertPos = registryMatch.index! + registryMatch[0].indexOf(registryContent) + lastEntryMatch.index + lastEntryMatch[0].length
373
+ const newEntry = `\n [normalize('${sectionKey}')]: ${componentName},`
374
+ patched = patched.slice(0, insertPos) + newEntry + patched.slice(insertPos)
375
+ }
376
+ }
377
+ }
378
+
379
+ // 3. Remove hardcoded <ComponentName /> from template (now rendered via loop)
380
+ const hardcodedPattern = new RegExp(`\\s*<${componentName}\\s*/?>\\s*\\n?`, 'g')
381
+ patched = patched.replace(hardcodedPattern, '\n')
382
+
383
+ return patched !== source ? patched : null
384
+ }
385
+
386
+ /**
387
+ * Calculate a relative import path from a page directory to a component file.
388
+ */
389
+ export function calculateRelativePath(fromDir: string, toPath: string): string {
390
+ const fromParts = fromDir.split('/')
391
+ const toParts = toPath.split('/')
392
+
393
+ // Find common prefix length
394
+ let common = 0
395
+ while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
396
+ common++
397
+ }
398
+
399
+ const ups = fromParts.length - common
400
+ const remaining = toParts.slice(common).join('/')
401
+
402
+ if (ups === 0) return './' + remaining
403
+ return '../'.repeat(ups) + remaining
404
+ }
405
+
406
+ async function fetchFileContent(
407
+ owner: string,
408
+ repo: string,
409
+ branch: string,
410
+ path: string,
411
+ token: string,
412
+ ): Promise<string | null> {
413
+ try {
414
+ const response = await fetch(
415
+ `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`,
416
+ {
417
+ headers: {
418
+ Authorization: `Bearer ${token}`,
419
+ Accept: 'application/vnd.github+json',
420
+ 'X-GitHub-Api-Version': '2022-11-28',
421
+ },
422
+ },
423
+ )
424
+ if (!response.ok) return null
425
+ const data = await response.json() as { content: string; encoding: string }
426
+ return data.encoding === 'base64'
427
+ ? Buffer.from(data.content, 'base64').toString('utf-8')
428
+ : data.content
429
+ } catch {
430
+ return null
431
+ }
432
+ }
433
+
434
+ async function batchCommit(
435
+ owner: string,
436
+ repo: string,
437
+ branch: string,
438
+ files: Array<{ path: string; content: string }>,
439
+ message: string,
440
+ headers: Record<string, string>,
441
+ ): Promise<{ ok: true; sha: string } | { ok: false; error: string }> {
442
+ try {
443
+ // 1. Get HEAD ref
444
+ const refRes = await fetch(
445
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
446
+ { headers },
447
+ )
448
+ if (!refRes.ok) return { ok: false, error: `Failed to get HEAD: ${refRes.status}` }
449
+ const refData = await refRes.json() as { object: { sha: string } }
450
+ const headSha = refData.object.sha
451
+
452
+ // 2. Get base tree
453
+ const commitRes = await fetch(
454
+ `https://api.github.com/repos/${owner}/${repo}/git/commits/${headSha}`,
455
+ { headers },
456
+ )
457
+ if (!commitRes.ok) return { ok: false, error: `Failed to get commit: ${commitRes.status}` }
458
+ const commitData = await commitRes.json() as { tree: { sha: string } }
459
+
460
+ // 3. Create new tree
461
+ const treeRes = await fetch(
462
+ `https://api.github.com/repos/${owner}/${repo}/git/trees`,
463
+ {
464
+ method: 'POST',
465
+ headers,
466
+ body: JSON.stringify({
467
+ base_tree: commitData.tree.sha,
468
+ tree: files.map((f) => ({
469
+ path: f.path,
470
+ mode: '100644',
471
+ type: 'blob',
472
+ content: f.content,
473
+ })),
474
+ }),
475
+ },
476
+ )
477
+ if (!treeRes.ok) return { ok: false, error: `Failed to create tree: ${treeRes.status}` }
478
+ const treeData = await treeRes.json() as { sha: string }
479
+
480
+ // 4. Create commit
481
+ const newCommitRes = await fetch(
482
+ `https://api.github.com/repos/${owner}/${repo}/git/commits`,
483
+ {
484
+ method: 'POST',
485
+ headers,
486
+ body: JSON.stringify({
487
+ tree: treeData.sha,
488
+ parents: [headSha],
489
+ message,
490
+ }),
491
+ },
492
+ )
493
+ if (!newCommitRes.ok) return { ok: false, error: `Failed to create commit: ${newCommitRes.status}` }
494
+ const newCommitData = await newCommitRes.json() as { sha: string }
495
+
496
+ // 5. Update ref
497
+ const updateRes = await fetch(
498
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`,
499
+ {
500
+ method: 'PATCH',
501
+ headers,
502
+ body: JSON.stringify({ sha: newCommitData.sha }),
503
+ },
504
+ )
505
+ if (!updateRes.ok) return { ok: false, error: `Failed to update ref: ${updateRes.status}` }
506
+
507
+ return { ok: true, sha: newCommitData.sha }
508
+ } catch (error) {
509
+ return { ok: false, error: error instanceof Error ? error.message : 'Commit failed' }
510
+ }
511
+ }