opencastle 0.25.0 → 0.26.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/dist/cli/bootstrap.d.ts +8 -0
- package/dist/cli/bootstrap.d.ts.map +1 -0
- package/dist/cli/bootstrap.js +358 -0
- package/dist/cli/bootstrap.js.map +1 -0
- package/dist/cli/bootstrap.test.d.ts +6 -0
- package/dist/cli/bootstrap.test.d.ts.map +1 -0
- package/dist/cli/bootstrap.test.js +196 -0
- package/dist/cli/bootstrap.test.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +63 -0
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/export.d.ts.map +1 -1
- package/dist/cli/convoy/export.js +4 -0
- package/dist/cli/convoy/export.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +15 -9
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +4 -0
- package/dist/cli/run.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/bootstrap.test.ts +286 -0
- package/src/cli/bootstrap.ts +472 -0
- package/src/cli/convoy/engine.ts +63 -0
- package/src/cli/convoy/export.ts +8 -0
- package/src/cli/init.ts +17 -12
- package/src/cli/run.ts +4 -0
- package/src/dashboard/dist/_astro/{index.Cq68OHaZ.css → index.DtnyD8a5.css} +1 -1
- package/src/dashboard/dist/index.html +61 -4
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/src/pages/index.astro +60 -3
- package/src/dashboard/src/styles/dashboard.css +4 -0
- package/src/orchestrator/agents/team-lead.agent.md +4 -2
- package/src/orchestrator/customizations/README.md +3 -3
- package/src/orchestrator/customizations/agents/agent-registry.md +1 -1
- package/src/orchestrator/customizations/project/docs-structure.md +1 -1
- package/src/orchestrator/customizations/project/roadmap.md +1 -1
- package/src/orchestrator/customizations/project/tracker-config.md +1 -1
- package/src/orchestrator/customizations/project.instructions.md +2 -2
- package/src/orchestrator/customizations/stack/api-config.md +1 -1
- package/src/orchestrator/customizations/stack/cms-config.md +2 -2
- package/src/orchestrator/customizations/stack/data-pipeline-config.md +1 -1
- package/src/orchestrator/customizations/stack/database-config.md +2 -2
- package/src/orchestrator/customizations/stack/deployment-config.md +1 -1
- package/src/orchestrator/customizations/stack/notifications-config.md +1 -1
- package/src/orchestrator/customizations/stack/testing-config.md +1 -1
- package/src/orchestrator/instructions/general.instructions.md +2 -0
- package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +127 -132
- package/src/orchestrator/skills/agent-hooks/SKILL.md +7 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink, readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import type { RepoInfo, StackConfig } from './types.js'
|
|
5
|
+
|
|
6
|
+
export interface BootstrapResult {
|
|
7
|
+
populated: string[]
|
|
8
|
+
removed: string[]
|
|
9
|
+
renamed: string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── Internal types ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface PackageJson {
|
|
15
|
+
name?: string
|
|
16
|
+
description?: string
|
|
17
|
+
scripts?: Record<string, string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface WorkspacePkg {
|
|
21
|
+
path: string
|
|
22
|
+
name: string
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Utilities ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
async function tryReadJson<T>(p: string): Promise<T | null> {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(await readFile(p, 'utf8')) as T
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Replace the first empty table row within a named markdown section.
|
|
38
|
+
* Scopes the search from `sectionMarker` to the next `## ` heading.
|
|
39
|
+
*/
|
|
40
|
+
function fillTableSection(
|
|
41
|
+
content: string,
|
|
42
|
+
sectionMarker: string,
|
|
43
|
+
emptyRow: string,
|
|
44
|
+
rows: string[],
|
|
45
|
+
): string {
|
|
46
|
+
if (rows.length === 0) return content
|
|
47
|
+
const sIdx = content.indexOf(sectionMarker)
|
|
48
|
+
if (sIdx === -1) return content
|
|
49
|
+
const nextSectionIdx = content.indexOf('\n## ', sIdx + sectionMarker.length)
|
|
50
|
+
const end = nextSectionIdx === -1 ? content.length : nextSectionIdx
|
|
51
|
+
const slice = content.slice(sIdx, end)
|
|
52
|
+
const needle = '\n' + emptyRow
|
|
53
|
+
const needleIdx = slice.indexOf(needle)
|
|
54
|
+
if (needleIdx === -1) return content
|
|
55
|
+
const abs = sIdx + needleIdx + 1 // position of first char of emptyRow (skip \n)
|
|
56
|
+
return content.slice(0, abs) + rows.join('\n') + content.slice(abs + emptyRow.length)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function replaceMarker(content: string, marker: string, replacement: string): string {
|
|
60
|
+
if (!content.includes(marker)) return content
|
|
61
|
+
return content.replace(marker, replacement)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function filterConfigFiles(configFiles: string[] | undefined, patterns: string[]): string[] {
|
|
65
|
+
if (!configFiles?.length) return []
|
|
66
|
+
return configFiles.filter(f => patterns.some(p => f === p || f.endsWith('/' + p) || f.includes(p)))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Workspace scanning ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
async function scanWorkspace(projectRoot: string): Promise<WorkspacePkg[]> {
|
|
72
|
+
const packages: WorkspacePkg[] = []
|
|
73
|
+
for (const dir of ['apps', 'packages', 'libs']) {
|
|
74
|
+
const dirPath = join(projectRoot, dir)
|
|
75
|
+
if (!existsSync(dirPath)) continue
|
|
76
|
+
let entries: Array<{ name: string; isDirectory(): boolean }> = []
|
|
77
|
+
try {
|
|
78
|
+
entries = await readdir(dirPath, { withFileTypes: true })
|
|
79
|
+
} catch {
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
for (const e of entries) {
|
|
83
|
+
if (!e.isDirectory()) continue
|
|
84
|
+
const pkg = await tryReadJson<PackageJson>(join(dirPath, e.name, 'package.json'))
|
|
85
|
+
packages.push({
|
|
86
|
+
path: `${dir}/${e.name}`,
|
|
87
|
+
name: pkg?.name ?? `${dir}/${e.name}`,
|
|
88
|
+
description: pkg?.description,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return packages
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Builders ───────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function buildStackRows(info: RepoInfo): string[] {
|
|
98
|
+
const rows: string[] = []
|
|
99
|
+
const addList = (layer: string, values: string[] | undefined) => {
|
|
100
|
+
if (!values?.length) return
|
|
101
|
+
for (const v of values) rows.push(`| ${layer} | ${v} | <!-- TODO: verify --> | |`)
|
|
102
|
+
}
|
|
103
|
+
if (info.language) rows.push(`| Language | ${info.language} | | |`)
|
|
104
|
+
if (info.packageManager) rows.push(`| Package Manager | ${info.packageManager} | | |`)
|
|
105
|
+
addList('Framework', info.frameworks)
|
|
106
|
+
addList('Database', info.databases)
|
|
107
|
+
addList('CMS', info.cms)
|
|
108
|
+
addList('Deployment', info.deployment)
|
|
109
|
+
addList('Testing', info.testing)
|
|
110
|
+
addList('CI/CD', info.cicd)
|
|
111
|
+
addList('Styling', info.styling)
|
|
112
|
+
addList('Auth', info.auth)
|
|
113
|
+
return rows
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildKeyCommandsBlock(pm: string, scripts: Record<string, string>): string {
|
|
117
|
+
const commands = (['dev', 'build', 'test', 'lint', 'start'] as const)
|
|
118
|
+
.filter(k => scripts[k])
|
|
119
|
+
.map(k => `${pm} run ${k}`)
|
|
120
|
+
if (commands.length === 0) return ''
|
|
121
|
+
return `**Package manager:** \`${pm}\`\n\n\`\`\`bash\n${commands.join('\n')}\n\`\`\``
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Populators ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function populateProjectInstructions(
|
|
127
|
+
opencastleDir: string,
|
|
128
|
+
projectRoot: string,
|
|
129
|
+
info: RepoInfo,
|
|
130
|
+
pkg: PackageJson,
|
|
131
|
+
result: BootstrapResult,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const filePath = join(opencastleDir, 'project.instructions.md')
|
|
134
|
+
if (!existsSync(filePath)) return
|
|
135
|
+
let content = await readFile(filePath, 'utf8')
|
|
136
|
+
const orig = content
|
|
137
|
+
|
|
138
|
+
// Overview: project name + description
|
|
139
|
+
if (pkg.name || pkg.description) {
|
|
140
|
+
const parts: string[] = []
|
|
141
|
+
if (pkg.name) parts.push(`**Project:** ${pkg.name}`)
|
|
142
|
+
if (pkg.description) parts.push(`**Description:** ${pkg.description}`)
|
|
143
|
+
content = replaceMarker(
|
|
144
|
+
content,
|
|
145
|
+
'<!-- Project name, description, and current status -->',
|
|
146
|
+
parts.join('\n\n'),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Tech stack table
|
|
151
|
+
content = fillTableSection(content, '## Tech Stack', '| | | | |', buildStackRows(info))
|
|
152
|
+
|
|
153
|
+
// Key commands
|
|
154
|
+
if (pkg.scripts) {
|
|
155
|
+
const pm = info.packageManager ?? 'npm'
|
|
156
|
+
const cmdBlock = buildKeyCommandsBlock(pm, pkg.scripts)
|
|
157
|
+
if (cmdBlock) {
|
|
158
|
+
content = replaceMarker(
|
|
159
|
+
content,
|
|
160
|
+
'<!-- Package manager and common development commands -->',
|
|
161
|
+
'<!-- Package manager and common development commands -->\n\n' + cmdBlock,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Monorepo workspace packages
|
|
167
|
+
if (info.monorepo) {
|
|
168
|
+
const workspaces = await scanWorkspace(projectRoot)
|
|
169
|
+
if (workspaces.length > 0) {
|
|
170
|
+
const pkgRows = workspaces.map(w => {
|
|
171
|
+
const cell =
|
|
172
|
+
w.name !== w.path ? `\`${w.path}\` (\`${w.name}\`)` : `\`${w.path}\``
|
|
173
|
+
return `| ${cell} | ${w.description ?? '<!-- TODO: verify -->'} |`
|
|
174
|
+
})
|
|
175
|
+
content = fillTableSection(content, '## Project Structure', '| | |', pkgRows)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (content !== orig) {
|
|
180
|
+
await writeFile(filePath, content, 'utf8')
|
|
181
|
+
result.populated.push('project.instructions.md')
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function populateTestingConfig(
|
|
186
|
+
opencastleDir: string,
|
|
187
|
+
info: RepoInfo,
|
|
188
|
+
result: BootstrapResult,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const filePath = join(opencastleDir, 'stack', 'testing-config.md')
|
|
191
|
+
if (!existsSync(filePath)) return
|
|
192
|
+
|
|
193
|
+
if (!info.testing?.length) {
|
|
194
|
+
await unlink(filePath)
|
|
195
|
+
result.removed.push('stack/testing-config.md')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let content = await readFile(filePath, 'utf8')
|
|
200
|
+
const orig = content
|
|
201
|
+
|
|
202
|
+
const introLine = 'Project-specific testing details referenced by the `browser-testing` skill.'
|
|
203
|
+
if (content.includes(introLine)) {
|
|
204
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
205
|
+
'vitest.config.ts',
|
|
206
|
+
'vitest.config.js',
|
|
207
|
+
'jest.config.ts',
|
|
208
|
+
'jest.config.js',
|
|
209
|
+
'playwright.config.ts',
|
|
210
|
+
'playwright.config.js',
|
|
211
|
+
])
|
|
212
|
+
let addition = `\n\n**Test frameworks:** ${info.testing.join(', ')}`
|
|
213
|
+
if (cfg.length > 0) {
|
|
214
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
215
|
+
}
|
|
216
|
+
content = content.replace(introLine, introLine + addition)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (content !== orig) {
|
|
220
|
+
await writeFile(filePath, content, 'utf8')
|
|
221
|
+
result.populated.push('stack/testing-config.md')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function populateDeploymentConfig(
|
|
226
|
+
opencastleDir: string,
|
|
227
|
+
info: RepoInfo,
|
|
228
|
+
result: BootstrapResult,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const filePath = join(opencastleDir, 'stack', 'deployment-config.md')
|
|
231
|
+
if (!existsSync(filePath)) return
|
|
232
|
+
|
|
233
|
+
if (!info.deployment?.length) {
|
|
234
|
+
await unlink(filePath)
|
|
235
|
+
result.removed.push('stack/deployment-config.md')
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let content = await readFile(filePath, 'utf8')
|
|
240
|
+
const orig = content
|
|
241
|
+
|
|
242
|
+
const archMarker =
|
|
243
|
+
'<!-- Describe the deployment platform, CI/CD pipeline, and trigger mechanism. -->'
|
|
244
|
+
if (content.includes(archMarker)) {
|
|
245
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
246
|
+
'vercel.json',
|
|
247
|
+
'netlify.toml',
|
|
248
|
+
'Dockerfile',
|
|
249
|
+
'docker-compose.yml',
|
|
250
|
+
'docker-compose.yaml',
|
|
251
|
+
'fly.toml',
|
|
252
|
+
'render.yaml',
|
|
253
|
+
])
|
|
254
|
+
let addition = `**Platform:** ${info.deployment.join(', ')}`
|
|
255
|
+
if (cfg.length > 0) {
|
|
256
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
257
|
+
}
|
|
258
|
+
content = content.replace(archMarker, addition + '\n\n' + archMarker)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (content !== orig) {
|
|
262
|
+
await writeFile(filePath, content, 'utf8')
|
|
263
|
+
result.populated.push('stack/deployment-config.md')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function handleDatabaseConfig(
|
|
268
|
+
opencastleDir: string,
|
|
269
|
+
info: RepoInfo,
|
|
270
|
+
result: BootstrapResult,
|
|
271
|
+
): Promise<void> {
|
|
272
|
+
const filePath = join(opencastleDir, 'stack', 'database-config.md')
|
|
273
|
+
if (!existsSync(filePath)) return
|
|
274
|
+
|
|
275
|
+
if (!info.databases?.length) {
|
|
276
|
+
await unlink(filePath)
|
|
277
|
+
result.removed.push('stack/database-config.md')
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let content = await readFile(filePath, 'utf8')
|
|
282
|
+
const provider = info.databases[0]
|
|
283
|
+
|
|
284
|
+
const integrationMarker =
|
|
285
|
+
'<!-- Auth library path, migration directory, session pattern, role system overview. -->'
|
|
286
|
+
if (content.includes(integrationMarker)) {
|
|
287
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
288
|
+
'supabase/config.toml',
|
|
289
|
+
'prisma/schema.prisma',
|
|
290
|
+
'drizzle.config.ts',
|
|
291
|
+
'drizzle.config.js',
|
|
292
|
+
])
|
|
293
|
+
let addition = `**Provider:** ${provider}`
|
|
294
|
+
if (cfg.length > 0) {
|
|
295
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
296
|
+
}
|
|
297
|
+
addition += '\n\n<!-- TODO: verify -->'
|
|
298
|
+
content = content.replace(integrationMarker, addition + '\n\n' + integrationMarker)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (info.databases.length === 1) {
|
|
302
|
+
const newName = `${provider}-config.md`
|
|
303
|
+
const newPath = join(opencastleDir, 'stack', newName)
|
|
304
|
+
await writeFile(newPath, content, 'utf8')
|
|
305
|
+
await unlink(filePath)
|
|
306
|
+
result.renamed.push(`stack/database-config.md \u2192 stack/${newName}`)
|
|
307
|
+
} else {
|
|
308
|
+
await writeFile(filePath, content, 'utf8')
|
|
309
|
+
result.populated.push('stack/database-config.md')
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function handleCmsConfig(
|
|
314
|
+
opencastleDir: string,
|
|
315
|
+
info: RepoInfo,
|
|
316
|
+
result: BootstrapResult,
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const filePath = join(opencastleDir, 'stack', 'cms-config.md')
|
|
319
|
+
if (!existsSync(filePath)) return
|
|
320
|
+
|
|
321
|
+
if (!info.cms?.length) {
|
|
322
|
+
await unlink(filePath)
|
|
323
|
+
result.removed.push('stack/cms-config.md')
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let content = await readFile(filePath, 'utf8')
|
|
328
|
+
const provider = info.cms[0]
|
|
329
|
+
|
|
330
|
+
const configMarker = '<!-- CMS project IDs, dataset, API version, studio location, etc. -->'
|
|
331
|
+
if (content.includes(configMarker)) {
|
|
332
|
+
const cfg = filterConfigFiles(info.configFiles, [
|
|
333
|
+
'sanity.config.ts',
|
|
334
|
+
'sanity.config.js',
|
|
335
|
+
'.contentful.json',
|
|
336
|
+
'payload.config.ts',
|
|
337
|
+
])
|
|
338
|
+
let addition = `**Provider:** ${provider}`
|
|
339
|
+
if (cfg.length > 0) {
|
|
340
|
+
addition += `\n\n**Config files:** ${cfg.map(f => `\`${f}\``).join(', ')}`
|
|
341
|
+
}
|
|
342
|
+
addition += '\n\n<!-- TODO: verify -->'
|
|
343
|
+
content = content.replace(configMarker, addition + '\n\n' + configMarker)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (info.cms.length === 1) {
|
|
347
|
+
const newName = `${provider}-config.md`
|
|
348
|
+
const newPath = join(opencastleDir, 'stack', newName)
|
|
349
|
+
await writeFile(newPath, content, 'utf8')
|
|
350
|
+
await unlink(filePath)
|
|
351
|
+
result.renamed.push(`stack/cms-config.md \u2192 stack/${newName}`)
|
|
352
|
+
} else {
|
|
353
|
+
await writeFile(filePath, content, 'utf8')
|
|
354
|
+
result.populated.push('stack/cms-config.md')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function removeNotificationsIfUnused(
|
|
359
|
+
opencastleDir: string,
|
|
360
|
+
info: RepoInfo,
|
|
361
|
+
result: BootstrapResult,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
const filePath = join(opencastleDir, 'stack', 'notifications-config.md')
|
|
364
|
+
if (!existsSync(filePath) || info.notifications?.length) return
|
|
365
|
+
await unlink(filePath)
|
|
366
|
+
result.removed.push('stack/notifications-config.md')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function handleApiConfig(
|
|
370
|
+
opencastleDir: string,
|
|
371
|
+
info: RepoInfo,
|
|
372
|
+
result: BootstrapResult,
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
const filePath = join(opencastleDir, 'stack', 'api-config.md')
|
|
375
|
+
if (!existsSync(filePath)) return
|
|
376
|
+
|
|
377
|
+
if (!info.frameworks?.length) {
|
|
378
|
+
await unlink(filePath)
|
|
379
|
+
result.removed.push('stack/api-config.md')
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let content = await readFile(filePath, 'utf8')
|
|
384
|
+
const orig = content
|
|
385
|
+
|
|
386
|
+
const initMarker =
|
|
387
|
+
'<!-- Populated by `opencastle init` based on detected API routes and Server Actions. -->'
|
|
388
|
+
if (content.includes(initMarker)) {
|
|
389
|
+
const frameworkList = info.frameworks.join(', ')
|
|
390
|
+
content = content.replace(
|
|
391
|
+
initMarker,
|
|
392
|
+
`<!-- Populated by \`opencastle init\`. Framework: ${frameworkList} -->`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (content !== orig) {
|
|
397
|
+
await writeFile(filePath, content, 'utf8')
|
|
398
|
+
result.populated.push('stack/api-config.md')
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function removeDataPipelineConfig(
|
|
403
|
+
opencastleDir: string,
|
|
404
|
+
result: BootstrapResult,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
const filePath = join(opencastleDir, 'stack', 'data-pipeline-config.md')
|
|
407
|
+
if (!existsSync(filePath)) return
|
|
408
|
+
await unlink(filePath)
|
|
409
|
+
result.removed.push('stack/data-pipeline-config.md')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const TRACKER_TOOLS = new Set<string>(['linear', 'jira'])
|
|
413
|
+
|
|
414
|
+
async function handleTrackerConfig(
|
|
415
|
+
opencastleDir: string,
|
|
416
|
+
info: RepoInfo,
|
|
417
|
+
stack: StackConfig,
|
|
418
|
+
result: BootstrapResult,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
const filePath = join(opencastleDir, 'project', 'tracker-config.md')
|
|
421
|
+
if (!existsSync(filePath)) return
|
|
422
|
+
|
|
423
|
+
const tracker =
|
|
424
|
+
stack.teamTools.find(t => TRACKER_TOOLS.has(t)) ??
|
|
425
|
+
info.pm?.find(p => TRACKER_TOOLS.has(p))
|
|
426
|
+
|
|
427
|
+
if (!tracker) {
|
|
428
|
+
await unlink(filePath)
|
|
429
|
+
result.removed.push('project/tracker-config.md')
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let content = await readFile(filePath, 'utf8')
|
|
434
|
+
const displayName = tracker.charAt(0).toUpperCase() + tracker.slice(1)
|
|
435
|
+
|
|
436
|
+
content = content.replace('# Task Tracker Configuration', `# ${displayName} Configuration`)
|
|
437
|
+
|
|
438
|
+
const renameComment =
|
|
439
|
+
'<!-- Populated by `opencastle init`.\n Rename this file to match your tracker: linear-config.md, jira-config.md, etc. -->'
|
|
440
|
+
content = content.replace(renameComment + '\n', '')
|
|
441
|
+
|
|
442
|
+
const newName = `${tracker}-config.md`
|
|
443
|
+
const newPath = join(opencastleDir, 'project', newName)
|
|
444
|
+
await writeFile(newPath, content, 'utf8')
|
|
445
|
+
await unlink(filePath)
|
|
446
|
+
result.renamed.push(`project/tracker-config.md \u2192 project/${newName}`)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Main export ────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
export async function bootstrapCustomizations(
|
|
452
|
+
projectRoot: string,
|
|
453
|
+
repoInfo: RepoInfo,
|
|
454
|
+
stack: StackConfig,
|
|
455
|
+
): Promise<BootstrapResult> {
|
|
456
|
+
const opencastleDir = join(projectRoot, '.opencastle')
|
|
457
|
+
const result: BootstrapResult = { populated: [], removed: [], renamed: [] }
|
|
458
|
+
|
|
459
|
+
const pkg = (await tryReadJson<PackageJson>(join(projectRoot, 'package.json'))) ?? {}
|
|
460
|
+
|
|
461
|
+
await populateProjectInstructions(opencastleDir, projectRoot, repoInfo, pkg, result)
|
|
462
|
+
await populateTestingConfig(opencastleDir, repoInfo, result)
|
|
463
|
+
await populateDeploymentConfig(opencastleDir, repoInfo, result)
|
|
464
|
+
await handleDatabaseConfig(opencastleDir, repoInfo, result)
|
|
465
|
+
await handleCmsConfig(opencastleDir, repoInfo, result)
|
|
466
|
+
await removeNotificationsIfUnused(opencastleDir, repoInfo, result)
|
|
467
|
+
await handleApiConfig(opencastleDir, repoInfo, result)
|
|
468
|
+
await removeDataPipelineConfig(opencastleDir, result)
|
|
469
|
+
await handleTrackerConfig(opencastleDir, repoInfo, stack, result)
|
|
470
|
+
|
|
471
|
+
return result
|
|
472
|
+
}
|
package/src/cli/convoy/engine.ts
CHANGED
|
@@ -271,6 +271,27 @@ async function runConvoy(
|
|
|
271
271
|
{ reason: 'timeout', worker_id: workerId },
|
|
272
272
|
{ convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
|
|
273
273
|
)
|
|
274
|
+
events.emit('session', {
|
|
275
|
+
agent: taskRecord.agent,
|
|
276
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
277
|
+
task: taskRecord.id,
|
|
278
|
+
outcome: 'failed',
|
|
279
|
+
duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
|
|
280
|
+
files_changed: 0,
|
|
281
|
+
retries: freshRecord.retries,
|
|
282
|
+
convoy_id: convoyId,
|
|
283
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
284
|
+
events.emit('delegation', {
|
|
285
|
+
session_id: convoyId,
|
|
286
|
+
agent: taskRecord.agent,
|
|
287
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
288
|
+
tier: 'standard',
|
|
289
|
+
mechanism: 'convoy',
|
|
290
|
+
outcome: 'failed',
|
|
291
|
+
retries: freshRecord.retries,
|
|
292
|
+
phase: taskRecord.phase,
|
|
293
|
+
convoy_id: convoyId,
|
|
294
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
274
295
|
cascadeFailure(taskRecord.id)
|
|
275
296
|
}
|
|
276
297
|
taskAdapterMap.delete(taskRecord.id)
|
|
@@ -315,6 +336,27 @@ async function runConvoy(
|
|
|
315
336
|
{ exit_code: result.exitCode, worker_id: workerId },
|
|
316
337
|
{ convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
|
|
317
338
|
)
|
|
339
|
+
events.emit('session', {
|
|
340
|
+
agent: taskRecord.agent,
|
|
341
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
342
|
+
task: taskRecord.id,
|
|
343
|
+
outcome: 'success',
|
|
344
|
+
duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
|
|
345
|
+
files_changed: 0,
|
|
346
|
+
retries: taskRecord.retries,
|
|
347
|
+
convoy_id: convoyId,
|
|
348
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
349
|
+
events.emit('delegation', {
|
|
350
|
+
session_id: convoyId,
|
|
351
|
+
agent: taskRecord.agent,
|
|
352
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
353
|
+
tier: 'standard',
|
|
354
|
+
mechanism: 'convoy',
|
|
355
|
+
outcome: 'success',
|
|
356
|
+
retries: taskRecord.retries,
|
|
357
|
+
phase: taskRecord.phase,
|
|
358
|
+
convoy_id: convoyId,
|
|
359
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
318
360
|
taskAdapterMap.delete(taskRecord.id)
|
|
319
361
|
return
|
|
320
362
|
}
|
|
@@ -354,6 +396,27 @@ async function runConvoy(
|
|
|
354
396
|
{ reason: 'error', exit_code: result.exitCode, worker_id: workerId },
|
|
355
397
|
{ convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
|
|
356
398
|
)
|
|
399
|
+
events.emit('session', {
|
|
400
|
+
agent: taskRecord.agent,
|
|
401
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
402
|
+
task: taskRecord.id,
|
|
403
|
+
outcome: 'failed',
|
|
404
|
+
duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
|
|
405
|
+
files_changed: 0,
|
|
406
|
+
retries: freshRecord.retries,
|
|
407
|
+
convoy_id: convoyId,
|
|
408
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
409
|
+
events.emit('delegation', {
|
|
410
|
+
session_id: convoyId,
|
|
411
|
+
agent: taskRecord.agent,
|
|
412
|
+
model: taskRecord.model ?? taskAdapter.name,
|
|
413
|
+
tier: 'standard',
|
|
414
|
+
mechanism: 'convoy',
|
|
415
|
+
outcome: 'failed',
|
|
416
|
+
retries: freshRecord.retries,
|
|
417
|
+
phase: taskRecord.phase,
|
|
418
|
+
convoy_id: convoyId,
|
|
419
|
+
}, { convoy_id: convoyId, task_id: taskRecord.id })
|
|
357
420
|
cascadeFailure(taskRecord.id)
|
|
358
421
|
}
|
|
359
422
|
taskAdapterMap.delete(taskRecord.id)
|
package/src/cli/convoy/export.ts
CHANGED
|
@@ -62,6 +62,13 @@ export async function exportConvoyToNdjson(
|
|
|
62
62
|
timedOut: tasks.filter((t) => t.status === 'timed-out').length,
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const durationSec =
|
|
66
|
+
convoy.started_at && convoy.finished_at
|
|
67
|
+
? Math.round(
|
|
68
|
+
(new Date(convoy.finished_at).getTime() - new Date(convoy.started_at).getTime()) / 1_000,
|
|
69
|
+
)
|
|
70
|
+
: undefined
|
|
71
|
+
|
|
65
72
|
const record = {
|
|
66
73
|
id: convoy.id,
|
|
67
74
|
name: convoy.name,
|
|
@@ -70,6 +77,7 @@ export async function exportConvoyToNdjson(
|
|
|
70
77
|
created_at: convoy.created_at,
|
|
71
78
|
started_at: convoy.started_at,
|
|
72
79
|
finished_at: convoy.finished_at,
|
|
80
|
+
duration_sec: durationSec,
|
|
73
81
|
summary,
|
|
74
82
|
tasks: tasks.map((t) => ({
|
|
75
83
|
id: t.id,
|
package/src/cli/init.ts
CHANGED
|
@@ -11,11 +11,7 @@ import { detectRepoInfo, mergeStackIntoRepoInfo, formatRepoInfo, buildDetectedTo
|
|
|
11
11
|
import { IDE_ADAPTERS } from './adapters/index.js'
|
|
12
12
|
import { IDE_LABELS } from './types.js'
|
|
13
13
|
import type { CliContext, IdeChoice, TechTool, TeamTool, StackConfig } from './types.js'
|
|
14
|
-
|
|
15
|
-
/** OSC 8 terminal hyperlink — clickable in VS Code integrated terminal and modern terminals */
|
|
16
|
-
function termLink(text: string, url: string): string {
|
|
17
|
-
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
|
18
|
-
}
|
|
14
|
+
import { bootstrapCustomizations } from './bootstrap.js'
|
|
19
15
|
|
|
20
16
|
export default async function init({ pkgRoot, args }: CliContext): Promise<void> {
|
|
21
17
|
const projectRoot = process.cwd()
|
|
@@ -232,6 +228,22 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
232
228
|
totalSkipped += sub.skipped.length
|
|
233
229
|
}
|
|
234
230
|
|
|
231
|
+
// ── Project scan ────────────────────────────────────────────────
|
|
232
|
+
console.log(`\n ${c.dim('Configuring project...')}`)
|
|
233
|
+
const bootstrapResult = await bootstrapCustomizations(projectRoot, combinedRepoInfo, stack)
|
|
234
|
+
|
|
235
|
+
if (bootstrapResult.populated.length > 0) {
|
|
236
|
+
console.log(` ${c.green('✓')} Populated ${c.bold(String(bootstrapResult.populated.length))} config files`)
|
|
237
|
+
}
|
|
238
|
+
if (bootstrapResult.renamed.length > 0) {
|
|
239
|
+
for (const r of bootstrapResult.renamed) {
|
|
240
|
+
console.log(` ${c.dim('→')} Renamed ${r}`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (bootstrapResult.removed.length > 0) {
|
|
244
|
+
console.log(` ${c.dim('→')} Removed ${bootstrapResult.removed.length} unused template(s)`)
|
|
245
|
+
}
|
|
246
|
+
|
|
235
247
|
// ── Write manifest ──────────────────────────────────────────────
|
|
236
248
|
const manifest = createManifest(pkg.version, ides[0], ides)
|
|
237
249
|
manifest.managedPaths = allManagedPaths
|
|
@@ -322,13 +334,6 @@ export default async function init({ pkgRoot, args }: CliContext): Promise<void>
|
|
|
322
334
|
)
|
|
323
335
|
}
|
|
324
336
|
step++
|
|
325
|
-
const bootstrapLabel = ides.includes('vscode')
|
|
326
|
-
? termLink(c.cyan('"Bootstrap Customizations"'), 'vscode://GitHub.Copilot-Chat/chat?mode=Team%20Lead%20(OpenCastle)&prompt=%2Fbootstrap-customizations')
|
|
327
|
-
: c.cyan('"Bootstrap Customizations"')
|
|
328
|
-
console.log(
|
|
329
|
-
` ${step}. Run the ${bootstrapLabel} prompt to configure for your project`
|
|
330
|
-
)
|
|
331
|
-
step++
|
|
332
337
|
console.log(` ${step}. Commit the .opencastle/ folder to your repository`)
|
|
333
338
|
console.log()
|
|
334
339
|
|
package/src/cli/run.ts
CHANGED
|
@@ -496,6 +496,8 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
496
496
|
const pipelineResult = await pipelineOrchestrator.run()
|
|
497
497
|
printPipelineResult(pipelineResult)
|
|
498
498
|
if (pipelineDashboardResult) {
|
|
499
|
+
console.log(`\n ${c.dim('Results saved to .opencastle/logs/convoys.ndjson')}`)
|
|
500
|
+
console.log(` ${c.dim('View again:')} opencastle dashboard`)
|
|
499
501
|
pipelineDashboardResult.server.close()
|
|
500
502
|
}
|
|
501
503
|
process.exit(pipelineResult.status !== 'done' ? 1 : 0)
|
|
@@ -536,6 +538,8 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
|
|
|
536
538
|
const result = await engine.run()
|
|
537
539
|
printConvoyResult(result)
|
|
538
540
|
if (dashboardResult) {
|
|
541
|
+
console.log(`\n ${c.dim('Results saved to .opencastle/logs/convoys.ndjson')}`)
|
|
542
|
+
console.log(` ${c.dim('View again:')} opencastle dashboard`)
|
|
539
543
|
dashboardResult.server.close()
|
|
540
544
|
}
|
|
541
545
|
process.exit(result.status !== 'done' ? 1 : 0)
|