zugzbot 1.0.18 → 1.0.19
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/.opencode/agents/sdd-coder.md +37 -5
- package/.opencode/agents/sdd-deployer.md +4 -1
- package/.opencode/agents/sdd-orchestrator.md +33 -12
- package/.opencode/agents/sdd-reviewer.md +53 -0
- package/.opencode/agents/sdd-spec-writer.md +14 -2
- package/.opencode/agents/sdd-tester.md +27 -4
- package/.opencode/commands/fast.md +19 -0
- package/.opencode/commands/reset.md +1 -1
- package/.opencode/commands/review.md +18 -0
- package/.opencode/contract-schema.json +8 -3
- package/.opencode/plugins/sdd-bridge.ts +96 -1
- package/.opencode/tools/brain.ts +26 -15
- package/.opencode/tools/fast-track-init.js +36 -0
- package/.opencode/tools/sdd_bootstrap.ts +625 -0
- package/.opencode/tools/sdd_core.ts +673 -0
- package/.opencode/tools/sdd_design.ts +303 -0
- package/.opencode/tools/sdd_docker.ts +254 -0
- package/.opencode/tools/sdd_network.ts +152 -0
- package/.opencode/tools/sdd_testing.ts +362 -0
- package/opencode.json +0 -108
- package/package.json +1 -1
- package/tui.json +9 -1
- package/.opencode/tools/sdd.ts +0 -2072
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { execSync } from "child_process"
|
|
5
|
+
|
|
6
|
+
// Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
|
|
7
|
+
const getRoot = (context: any) => {
|
|
8
|
+
if (context?.directory && context.directory !== "/") return context.directory;
|
|
9
|
+
if (context?.worktree && context.worktree !== "/") return context.worktree;
|
|
10
|
+
if (context?.cwd && context.cwd !== "/") return context.cwd;
|
|
11
|
+
return process.cwd();
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Curated subset of brands with HTML+CSS interactive previews.
|
|
15
|
+
export const RECOMMENDED_BRANDS: Record<string, { id: string; name: string; category: string; vibe: string }[]> = {
|
|
16
|
+
saas: [
|
|
17
|
+
{ id: "supabase", name: "Supabase", category: "saas", vibe: "Open-source Firebase alternative, dark-mode-first, dense data UI" },
|
|
18
|
+
{ id: "linear.app", name: "Linear", category: "saas", vibe: "Issue tracking premium, ultra-fast keyboard-first, monochrome" },
|
|
19
|
+
{ id: "vercel", name: "Vercel", category: "saas", vibe: "Vercel/Next.js native, mono+geist font, gradient accents" },
|
|
20
|
+
{ id: "raycast", name: "Raycast", category: "saas", vibe: "Productivity launcher, sharp corners, command-bar UX" },
|
|
21
|
+
{ id: "posthog", name: "PostHog", category: "saas", vibe: "Product analytics, orange accent, fun playful tone" },
|
|
22
|
+
],
|
|
23
|
+
fintech: [
|
|
24
|
+
{ id: "stripe", name: "Stripe", category: "fintech", vibe: "Premium payments, gradient brand, dense docs" },
|
|
25
|
+
{ id: "revolut", name: "Revolut", category: "fintech", vibe: "Neobank, dark+violet, large numbers" },
|
|
26
|
+
{ id: "wise", name: "Wise", category: "fintech", vibe: "Cross-border money, bright green, friendly tone" },
|
|
27
|
+
{ id: "toss", name: "Toss", category: "fintech", vibe: "Korean super-app, blue primary, imperative microcopy" },
|
|
28
|
+
],
|
|
29
|
+
ecommerce: [
|
|
30
|
+
{ id: "airbnb", name: "Airbnb", category: "ecommerce", vibe: "Travel marketplace, coral accent, photographic" },
|
|
31
|
+
{ id: "apple", name: "Apple", category: "ecommerce", vibe: "Hardware store, ultra-minimal, SF Pro typography" },
|
|
32
|
+
{ id: "nike", name: "Nike", category: "ecommerce", vibe: "Athletic brand, black+volt, UPPERCASE bold, flat" },
|
|
33
|
+
{ id: "shopify", name: "Shopify", category: "ecommerce", vibe: "E-commerce platform, green primary, merchant-focused" },
|
|
34
|
+
],
|
|
35
|
+
consumer: [
|
|
36
|
+
{ id: "spotify", name: "Spotify", category: "consumer", vibe: "Music streaming, green+dark, immersive cards" },
|
|
37
|
+
{ id: "figma", name: "Figma", category: "consumer", vibe: "Design tool, multi-color, playful professional" },
|
|
38
|
+
{ id: "notion", name: "Notion", category: "consumer", vibe: "Productivity, minimal, document-first" },
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Tool: sdd_select_design
|
|
43
|
+
export const select_design = tool({
|
|
44
|
+
description: "Copia fielmente el archivo DESIGN.md y sus ejemplos y recursos interactivos asociados desde el catálogo original de oh-my-design a la carpeta .openspec/ del proyecto.",
|
|
45
|
+
args: {
|
|
46
|
+
brandId: tool.schema.string().describe("ID exacto del diseño en oh-my-design (ej: 'linear.app', 'vercel', 'stripe')")
|
|
47
|
+
},
|
|
48
|
+
async execute(args, context) {
|
|
49
|
+
const root = getRoot(context)
|
|
50
|
+
const brandId = args.brandId.trim()
|
|
51
|
+
const sourceDir = path.resolve(root, ".opencode/oh-my-design/design-md", brandId)
|
|
52
|
+
|
|
53
|
+
if (!fs.existsSync(sourceDir)) {
|
|
54
|
+
// Try resolving with substring match if not found exactly
|
|
55
|
+
const catalogDir = path.resolve(root, ".opencode/oh-my-design/design-md")
|
|
56
|
+
if (fs.existsSync(catalogDir)) {
|
|
57
|
+
const brands = fs.readdirSync(catalogDir)
|
|
58
|
+
const match = brands.find(b => b.toLowerCase() === brandId.toLowerCase() || b.toLowerCase().includes(brandId.toLowerCase()))
|
|
59
|
+
if (match && match !== brandId) {
|
|
60
|
+
return selectDesignHelper(match, context)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return JSON.stringify({
|
|
64
|
+
status: "ERROR",
|
|
65
|
+
message: `No se encontró el directorio de diseño para la marca "${brandId}" en ${sourceDir}`
|
|
66
|
+
}, null, 2)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return selectDesignHelper(brandId, context)
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Helper extracted to allow substring-match recursion without `this.execute` typing issues.
|
|
74
|
+
async function selectDesignHelper(brandId: string, context: any) {
|
|
75
|
+
const root = getRoot(context)
|
|
76
|
+
const sourceDir = path.resolve(root, ".opencode/oh-my-design/design-md", brandId)
|
|
77
|
+
const targetDir = path.resolve(root, ".openspec")
|
|
78
|
+
|
|
79
|
+
if (!fs.existsSync(sourceDir)) {
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
status: "ERROR",
|
|
82
|
+
message: `No se encontró el directorio de diseño para la marca "${brandId}" en ${sourceDir}`
|
|
83
|
+
}, null, 2)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 1. Copy accompanying files (HTML previews, README, DESIGN.md, etc.) to a brand subfolder in .openspec
|
|
87
|
+
const targetBrandDir = path.join(targetDir, "design-assets", brandId)
|
|
88
|
+
if (!fs.existsSync(targetBrandDir)) {
|
|
89
|
+
fs.mkdirSync(targetBrandDir, { recursive: true })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const copiedFiles: string[] = []
|
|
93
|
+
const sourceDesign = path.join(sourceDir, "DESIGN.md")
|
|
94
|
+
if (!fs.existsSync(sourceDesign)) {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
status: "ERROR",
|
|
97
|
+
message: `No se encontró el archivo DESIGN.md en la ruta origen: ${sourceDesign}`
|
|
98
|
+
}, null, 2)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const files = fs.readdirSync(sourceDir)
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
if (file.startsWith(".")) continue
|
|
104
|
+
const srcFile = path.join(sourceDir, file)
|
|
105
|
+
const dstFile = path.join(targetBrandDir, file)
|
|
106
|
+
if (fs.statSync(srcFile).isFile()) {
|
|
107
|
+
fs.copyFileSync(srcFile, dstFile)
|
|
108
|
+
copiedFiles.push(file)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. Overwrite .openspec/DESIGN.md so that opencode.json static references always resolve and agents get the active design guidelines
|
|
113
|
+
const legacyDesign = path.join(targetDir, "DESIGN.md")
|
|
114
|
+
try {
|
|
115
|
+
fs.copyFileSync(sourceDesign, legacyDesign)
|
|
116
|
+
} catch (e) {
|
|
117
|
+
/* ignore */
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return JSON.stringify({
|
|
121
|
+
status: "SUCCESS",
|
|
122
|
+
message: `Diseño "${brandId}" copiado a la ruta canónica .openspec/design-assets/${brandId}/ y actualizado en .openspec/DESIGN.md`,
|
|
123
|
+
copiedFiles,
|
|
124
|
+
designAssetsDir: path.relative(root, targetBrandDir),
|
|
125
|
+
canonicalDesignPath: path.relative(root, path.join(targetBrandDir, "DESIGN.md")),
|
|
126
|
+
}, null, 2)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Tool: sdd_list_design_recommendations
|
|
130
|
+
export const list_design_recommendations = tool({
|
|
131
|
+
description: "Devuelve una lista curada y corta de marcas de diseño recomendadas (con assets HTML+CSS interactivos), filtrada por categoría de uso. Reemplaza 3-4 llamadas a oh-my-design_list_references en F0.",
|
|
132
|
+
args: {
|
|
133
|
+
use_case: tool.schema.enum(["saas", "fintech", "ecommerce", "consumer", "all"]).default("all").describe("Categoría del proyecto para filtrar marcas relevantes"),
|
|
134
|
+
max_per_category: tool.schema.number().default(3).describe("Máximo de marcas a devolver por categoría"),
|
|
135
|
+
},
|
|
136
|
+
async execute(args, context) {
|
|
137
|
+
const useCase = args.use_case
|
|
138
|
+
const maxPer = Math.max(1, Math.min(args.max_per_category || 3, 6))
|
|
139
|
+
|
|
140
|
+
if (useCase === "all") {
|
|
141
|
+
const result: Record<string, any[]> = {}
|
|
142
|
+
for (const cat of Object.keys(RECOMMENDED_BRANDS)) {
|
|
143
|
+
result[cat] = RECOMMENDED_BRANDS[cat].slice(0, maxPer)
|
|
144
|
+
}
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
status: "SUCCESS",
|
|
147
|
+
message: `Recomendaciones de diseño curadas (top ${maxPer} por categoría)`,
|
|
148
|
+
recommendations: result,
|
|
149
|
+
note: "Estas marcas tienen preview.html/preview-dark.html en .opencode/oh-my-design/design-md/. Para marcas adicionales usa oh-my-design_list_references.",
|
|
150
|
+
}, null, 2)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const list = RECOMMENDED_BRANDS[useCase] || []
|
|
154
|
+
return JSON.stringify({
|
|
155
|
+
status: "SUCCESS",
|
|
156
|
+
message: `Recomendaciones de diseño para use_case="${useCase}"`,
|
|
157
|
+
recommendations: { [useCase]: list.slice(0, maxPer) },
|
|
158
|
+
note: "Estas marcas tienen preview.html/preview-dark.html en .opencode/oh-my-design/design-md/.",
|
|
159
|
+
}, null, 2)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Tool: sdd_apply_brand_tokens
|
|
164
|
+
export const apply_brand_tokens = tool({
|
|
165
|
+
description: "Inyecta tokens de diseño de marca (colores, tipografía, radius) en src/app/globals.css preservando las variables shadcn (--color-border, --color-background, etc.). Usar en F2 cuando se necesite aplicar el tema de marca.",
|
|
166
|
+
args: {
|
|
167
|
+
tokens: tool.schema.string().describe("JSON stringificado con {colors: {...}, typography: {...}, radius: {...}} extraído de contract.json design.tokens"),
|
|
168
|
+
},
|
|
169
|
+
async execute(args, context) {
|
|
170
|
+
const root = getRoot(context)
|
|
171
|
+
const globalsPath = path.resolve(root, "src/app/globals.css")
|
|
172
|
+
|
|
173
|
+
if (!fs.existsSync(globalsPath)) {
|
|
174
|
+
return JSON.stringify({
|
|
175
|
+
status: "ERROR",
|
|
176
|
+
message: `No se encontró src/app/globals.css. Ejecuta primero el bootstrap de nextjs-shadcn.`,
|
|
177
|
+
}, null, 2)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let brandTokens: any
|
|
181
|
+
try {
|
|
182
|
+
brandTokens = JSON.parse(args.tokens)
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
status: "ERROR",
|
|
186
|
+
message: `tokens no es JSON válido: ${(e as Error).message}`,
|
|
187
|
+
}, null, 2)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const colors = brandTokens.colors || {}
|
|
191
|
+
const typography = brandTokens.typography?.family || {}
|
|
192
|
+
const radius = brandTokens.radius || {}
|
|
193
|
+
|
|
194
|
+
// Build the brand CSS variables block. Preserves shadcn vars.
|
|
195
|
+
const brandVarLines: string[] = []
|
|
196
|
+
for (const [k, v] of Object.entries(colors)) {
|
|
197
|
+
const safeName = String(k).replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase()
|
|
198
|
+
brandVarLines.push(` --color-brand-${safeName}: ${v};`)
|
|
199
|
+
}
|
|
200
|
+
if (typography.sans) {
|
|
201
|
+
brandVarLines.push(` --font-sans: ${typography.sans};`)
|
|
202
|
+
brandVarLines.push(` --font-heading: ${typography.sans};`)
|
|
203
|
+
}
|
|
204
|
+
for (const [k, v] of Object.entries(radius)) {
|
|
205
|
+
brandVarLines.push(` --radius-${k}: ${v}px;`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const brandBlock = `/* Brand tokens (injected by sdd_apply_brand_tokens) */
|
|
209
|
+
@theme inline {
|
|
210
|
+
${brandVarLines.join("\n")}
|
|
211
|
+
}
|
|
212
|
+
`
|
|
213
|
+
|
|
214
|
+
let css = fs.readFileSync(globalsPath, "utf8")
|
|
215
|
+
|
|
216
|
+
// Remove any previous brand block to keep this idempotent.
|
|
217
|
+
css = css.replace(/\/\* Brand tokens[\s\S]*?\}\s*\n/g, "")
|
|
218
|
+
|
|
219
|
+
// Append at the end. Existing shadcn @theme inline block remains untouched.
|
|
220
|
+
css = css.trimEnd() + "\n\n" + brandBlock
|
|
221
|
+
|
|
222
|
+
fs.writeFileSync(globalsPath, css, "utf8")
|
|
223
|
+
|
|
224
|
+
return JSON.stringify({
|
|
225
|
+
status: "SUCCESS",
|
|
226
|
+
message: `Inyectados ${brandVarLines.length} tokens de marca en src/app/globals.css (preservando variables shadcn)`,
|
|
227
|
+
globalsPath: path.relative(root, globalsPath),
|
|
228
|
+
brandVariables: brandVarLines.length,
|
|
229
|
+
}, null, 2)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Tool: sdd_validate_lucide_icons_batch
|
|
234
|
+
export const validate_lucide_icons_batch = tool({
|
|
235
|
+
description: "Valida un lote de nombres de iconos de Lucide React de forma rápida e idempotente. Si node_modules/lucide-react existe, verifica contra los exports reales; de lo contrario, valida contra un listado estático de los iconos más comunes.",
|
|
236
|
+
args: {
|
|
237
|
+
icons: tool.schema.array(tool.schema.string()).describe("Lista de nombres de iconos a validar (ej: ['Sun', 'Moon', 'Plus'])"),
|
|
238
|
+
},
|
|
239
|
+
async execute(args, context) {
|
|
240
|
+
const root = getRoot(context)
|
|
241
|
+
const results: Record<string, { valid: boolean; source: string }> = {}
|
|
242
|
+
|
|
243
|
+
// Lista de iconos comunes como fallback
|
|
244
|
+
const commonIcons = new Set([
|
|
245
|
+
"Sun", "Moon", "Plus", "Trash2", "History", "Clock", "Calculator", "User", "Settings",
|
|
246
|
+
"Home", "ChevronLeft", "ChevronRight", "ChevronUp", "ChevronDown", "Search", "X", "Check",
|
|
247
|
+
"Edit", "Menu", "LogOut", "LogIn", "Lock", "Mail", "Phone", "MapPin", "Calendar", "Upload",
|
|
248
|
+
"Download", "ExternalLink", "Eye", "EyeOff", "AlertCircle", "CheckCircle", "Info", "HelpCircle",
|
|
249
|
+
"Bell", "Share2", "Copy", "File", "Folder", "Image", "Video", "Music", "Play", "Pause",
|
|
250
|
+
"Server", "Database", "Terminal", "Code", "Grid", "List", "Filter", "Heart", "Star",
|
|
251
|
+
"ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", "RefreshCw", "Send", "Activity", "Briefcase",
|
|
252
|
+
"Camera", "Cloud", "Compass", "Cpu", "CreditCard", "DollarSign", "DownloadCloud", "Edit2", "Edit3",
|
|
253
|
+
"FileText", "Gift", "Globe", "Hash", "Key", "Laptop", "Map", "MessageCircle", "MessageSquare",
|
|
254
|
+
"Mic", "Monitor", "Package", "Paperclip", "Percent", "Power", "Printer", "Radio", "RotateCcw",
|
|
255
|
+
"Save", "Scissors", "Shield", "ShoppingBag", "ShoppingCart", "Slider", "Sliders", "Smartphone",
|
|
256
|
+
"Speaker", "Tablet", "Tag", "Target", "ThumbsUp", "ThumbsDown", "ToggleLeft", "ToggleRight",
|
|
257
|
+
"Trash", "TrendingUp", "TrendingDown", "Truck", "Tv", "Type", "Umbrella", "Unlock", "UploadCloud",
|
|
258
|
+
"Users", "Volume2", "VolumeX", "Wifi", "Wind", "Zap"
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
let hasNodeModules = false
|
|
262
|
+
let exportsList: Set<string> | null = null
|
|
263
|
+
|
|
264
|
+
// Intentar buscar lucide-react en node_modules
|
|
265
|
+
const lucidePath = path.resolve(root, "node_modules/lucide-react")
|
|
266
|
+
if (fs.existsSync(lucidePath)) {
|
|
267
|
+
try {
|
|
268
|
+
const nodeScript = `
|
|
269
|
+
const lucide = require('lucide-react');
|
|
270
|
+
console.log(JSON.stringify(Object.keys(lucide)));
|
|
271
|
+
`
|
|
272
|
+
const output = execSync(`node -e "${nodeScript.replace(/"/g, '\\"')}"`, { cwd: root, stdio: "pipe" }).toString()
|
|
273
|
+
const keys = JSON.parse(output)
|
|
274
|
+
exportsList = new Set(keys)
|
|
275
|
+
hasNodeModules = true
|
|
276
|
+
} catch (e) {
|
|
277
|
+
// Fallback
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (const icon of args.icons) {
|
|
282
|
+
if (hasNodeModules && exportsList) {
|
|
283
|
+
results[icon] = {
|
|
284
|
+
valid: exportsList.has(icon),
|
|
285
|
+
source: "node_modules/lucide-react"
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
const isPascalCase = /^[A-Z][a-zA-Z0-9]*$/.test(icon)
|
|
289
|
+
const inCommon = commonIcons.has(icon)
|
|
290
|
+
results[icon] = {
|
|
291
|
+
valid: inCommon || isPascalCase,
|
|
292
|
+
source: inCommon ? "static_common_list" : "pascal_case_fallback"
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return JSON.stringify({
|
|
298
|
+
status: "SUCCESS",
|
|
299
|
+
validatedAt: new Date().toISOString(),
|
|
300
|
+
results
|
|
301
|
+
}, null, 2)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { execSync } from "child_process"
|
|
5
|
+
|
|
6
|
+
// Helper to safely resolve root directory (avoiding OpenCode bug where worktree is '/' in non-git repos)
|
|
7
|
+
const getRoot = (context: any) => {
|
|
8
|
+
if (context?.directory && context.directory !== "/") return context.directory;
|
|
9
|
+
if (context?.worktree && context.worktree !== "/") return context.worktree;
|
|
10
|
+
if (context?.cwd && context.cwd !== "/") return context.cwd;
|
|
11
|
+
return process.cwd();
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Tool: sdd_clean_docker_environment
|
|
15
|
+
export const clean_docker_environment = tool({
|
|
16
|
+
description: "Limpia de forma agresiva y segura el entorno de Docker: remueve contenedores detenidos, imágenes huérfanas/dangling creadas por compilaciones fallidas, y redes no utilizadas. Se debe ejecutar antes de desplegar un nuevo contenedor para liberar recursos y evitar colisiones.",
|
|
17
|
+
args: {},
|
|
18
|
+
async execute(args, context) {
|
|
19
|
+
const results: Record<string, string> = {}
|
|
20
|
+
try {
|
|
21
|
+
results.containers = execSync("docker container prune -f", { encoding: "utf8" }).toString().trim()
|
|
22
|
+
} catch (e: any) {
|
|
23
|
+
results.containers = `Error: ${e.message}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
results.images = execSync("docker image prune -f", { encoding: "utf8" }).toString().trim()
|
|
28
|
+
} catch (e: any) {
|
|
29
|
+
results.images = `Error: ${e.message}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
results.networks = execSync("docker network prune -f", { encoding: "utf8" }).toString().trim()
|
|
34
|
+
} catch (e: any) {
|
|
35
|
+
results.networks = `Error: ${e.message}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
status: "SUCCESS",
|
|
40
|
+
message: "Entorno de Docker limpiado de forma segura y exitosa.",
|
|
41
|
+
details: results
|
|
42
|
+
}, null, 2)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Tool: sdd_generate_dockerfile
|
|
47
|
+
export const generate_dockerfile = tool({
|
|
48
|
+
description: "Genera Dockerfile multi-stage, .dockerignore y docker-compose.yml optimizados a partir del stack del proyecto (nextjs|fastapi|agnostic). Detecta package manager desde package.json.",
|
|
49
|
+
args: {
|
|
50
|
+
stack: tool.schema.enum(["nextjs", "fastapi", "agnostic"]).describe("Stack del proyecto"),
|
|
51
|
+
port: tool.schema.number().default(3000).describe("Puerto de la aplicación"),
|
|
52
|
+
},
|
|
53
|
+
async execute(args, context) {
|
|
54
|
+
const root = getRoot(context)
|
|
55
|
+
|
|
56
|
+
if (args.stack === "agnostic") {
|
|
57
|
+
const dockerfileAg = `FROM node:20-alpine
|
|
58
|
+
WORKDIR /app
|
|
59
|
+
COPY . .
|
|
60
|
+
RUN npm install --prefer-offline || true
|
|
61
|
+
CMD ["node", "src/index.js"]
|
|
62
|
+
`
|
|
63
|
+
const filesWritten: string[] = []
|
|
64
|
+
let content = dockerfileAg
|
|
65
|
+
let targetName = "src/index.js"
|
|
66
|
+
if (fs.existsSync(path.resolve(root, "src/main.py"))) {
|
|
67
|
+
content = `FROM python:3.11-slim
|
|
68
|
+
WORKDIR /app
|
|
69
|
+
COPY . .
|
|
70
|
+
RUN pip install -r requirements.txt || true
|
|
71
|
+
CMD ["python", "src/main.py"]
|
|
72
|
+
`
|
|
73
|
+
targetName = "src/main.py"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fullPath = path.resolve(root, "Dockerfile")
|
|
77
|
+
fs.writeFileSync(fullPath, content, "utf8")
|
|
78
|
+
filesWritten.push("Dockerfile")
|
|
79
|
+
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
status: "SUCCESS",
|
|
82
|
+
message: `Docker artifacts generados de forma agnóstica para el script de entrada: ${targetName}`,
|
|
83
|
+
filesWritten
|
|
84
|
+
}, null, 2)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (args.stack === "nextjs") {
|
|
88
|
+
const specsDir = path.resolve(root, ".openspec/specs")
|
|
89
|
+
let filesAffected: string[] = []
|
|
90
|
+
|
|
91
|
+
const p = "npm"
|
|
92
|
+
let pm = "npm"
|
|
93
|
+
let installCmd = "npm ci --frozen-lockfile"
|
|
94
|
+
let buildCmd = "npm run build"
|
|
95
|
+
if (fs.existsSync(path.resolve(root, "pnpm-lock.yaml"))) {
|
|
96
|
+
pm = "pnpm"
|
|
97
|
+
installCmd = "pnpm install --frozen-lockfile"
|
|
98
|
+
buildCmd = "pnpm build"
|
|
99
|
+
} else if (fs.existsSync(path.resolve(root, "yarn.lock"))) {
|
|
100
|
+
pm = "yarn"
|
|
101
|
+
installCmd = "yarn install --frozen-lockfile"
|
|
102
|
+
buildCmd = "yarn build"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nodeImage = "node:20-alpine"
|
|
106
|
+
|
|
107
|
+
const dockerfile = `# Stage 1: Dependencias
|
|
108
|
+
FROM ${nodeImage} AS deps
|
|
109
|
+
WORKDIR /app
|
|
110
|
+
COPY package.json ${pm === "npm" ? "package-lock.json* " : pm === "pnpm" ? "pnpm-lock.yaml* " : "yarn.lock* "}./
|
|
111
|
+
RUN ${installCmd}
|
|
112
|
+
|
|
113
|
+
# Stage 2: Constructor
|
|
114
|
+
FROM ${nodeImage} AS builder
|
|
115
|
+
WORKDIR /app
|
|
116
|
+
RUN corepack enable || true
|
|
117
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
118
|
+
COPY . .
|
|
119
|
+
RUN ${buildCmd}
|
|
120
|
+
|
|
121
|
+
# Stage 3: Ejecutor
|
|
122
|
+
FROM ${nodeImage} AS runner
|
|
123
|
+
WORKDIR /app
|
|
124
|
+
ENV NODE_ENV=production
|
|
125
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
126
|
+
|
|
127
|
+
RUN addgroup --system --gid 1001 nodejs \
|
|
128
|
+
&& adduser --system --uid 1001 nextjs \
|
|
129
|
+
&& mkdir -p public
|
|
130
|
+
|
|
131
|
+
COPY --from=builder /app/public ./public
|
|
132
|
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
133
|
+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
134
|
+
|
|
135
|
+
USER nextjs
|
|
136
|
+
EXPOSE ${args.port}
|
|
137
|
+
ENV PORT=${args.port}
|
|
138
|
+
ENV HOSTNAME="0.0.0.0"
|
|
139
|
+
|
|
140
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
141
|
+
CMD node -e "require('http').get('http://localhost:${args.port}', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
|
|
142
|
+
|
|
143
|
+
CMD ["node", "server.js"]
|
|
144
|
+
`
|
|
145
|
+
|
|
146
|
+
const dockerignore = `node_modules
|
|
147
|
+
.next
|
|
148
|
+
.git
|
|
149
|
+
.opencode
|
|
150
|
+
.openspec
|
|
151
|
+
.env*
|
|
152
|
+
*.md
|
|
153
|
+
vitest.config.ts
|
|
154
|
+
*.test.ts
|
|
155
|
+
*.spec.ts
|
|
156
|
+
coverage
|
|
157
|
+
playwright-report
|
|
158
|
+
test-results
|
|
159
|
+
`
|
|
160
|
+
|
|
161
|
+
const compose = `services:
|
|
162
|
+
web:
|
|
163
|
+
build:
|
|
164
|
+
context: .
|
|
165
|
+
dockerfile: Dockerfile
|
|
166
|
+
ports:
|
|
167
|
+
- "${args.port}:${args.port}"
|
|
168
|
+
environment:
|
|
169
|
+
- NODE_ENV=production
|
|
170
|
+
- NEXT_TELEMETRY_DISABLED=1
|
|
171
|
+
restart: unless-stopped
|
|
172
|
+
healthcheck:
|
|
173
|
+
test: ["CMD", "node", "-e", "require('http').get('http://localhost:${args.port}', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
|
|
174
|
+
interval: 30s
|
|
175
|
+
timeout: 10s
|
|
176
|
+
retries: 3
|
|
177
|
+
start_period: 30s
|
|
178
|
+
`
|
|
179
|
+
|
|
180
|
+
const filesWritten: string[] = []
|
|
181
|
+
for (const [name, content] of [
|
|
182
|
+
["Dockerfile", dockerfile],
|
|
183
|
+
[".dockerignore", dockerignore],
|
|
184
|
+
["docker-compose.yml", compose],
|
|
185
|
+
] as const) {
|
|
186
|
+
const fullPath = path.resolve(root, name)
|
|
187
|
+
fs.writeFileSync(fullPath, content, "utf8")
|
|
188
|
+
filesWritten.push(path.relative(root, fullPath))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return JSON.stringify({
|
|
192
|
+
status: "SUCCESS",
|
|
193
|
+
message: `Docker artifacts generados (${pm}, ${nodeImage}, puerto ${args.port})`,
|
|
194
|
+
filesWritten,
|
|
195
|
+
packageManager: pm,
|
|
196
|
+
nodeImage,
|
|
197
|
+
}, null, 2)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// fastapi path
|
|
201
|
+
const dockerfilePy = `FROM python:3.11-slim
|
|
202
|
+
WORKDIR /app
|
|
203
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
204
|
+
build-essential \
|
|
205
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
206
|
+
COPY requirements.txt .
|
|
207
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
208
|
+
COPY src/ ./src/
|
|
209
|
+
EXPOSE ${args.port}
|
|
210
|
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "${args.port}"]
|
|
211
|
+
`
|
|
212
|
+
const dockerignorePy = `__pycache__/
|
|
213
|
+
*.pyc
|
|
214
|
+
*.pyo
|
|
215
|
+
*.pyd
|
|
216
|
+
.Python
|
|
217
|
+
env/
|
|
218
|
+
venv/
|
|
219
|
+
.venv/
|
|
220
|
+
.git
|
|
221
|
+
.opencode
|
|
222
|
+
.openspec
|
|
223
|
+
.pytest_cache/
|
|
224
|
+
tests/
|
|
225
|
+
`
|
|
226
|
+
const composePy = `services:
|
|
227
|
+
api:
|
|
228
|
+
build:
|
|
229
|
+
context: .
|
|
230
|
+
dockerfile: Dockerfile
|
|
231
|
+
ports:
|
|
232
|
+
- "${args.port}:${args.port}"
|
|
233
|
+
environment:
|
|
234
|
+
- ENV=production
|
|
235
|
+
restart: unless-stopped
|
|
236
|
+
`
|
|
237
|
+
const filesWritten: string[] = []
|
|
238
|
+
for (const [name, content] of [
|
|
239
|
+
["Dockerfile", dockerfilePy],
|
|
240
|
+
[".dockerignore", dockerignorePy],
|
|
241
|
+
["docker-compose.yml", composePy],
|
|
242
|
+
] as const) {
|
|
243
|
+
const fullPath = path.resolve(root, name)
|
|
244
|
+
fs.writeFileSync(fullPath, content, "utf8")
|
|
245
|
+
filesWritten.push(path.relative(root, fullPath))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return JSON.stringify({
|
|
249
|
+
status: "SUCCESS",
|
|
250
|
+
message: `Docker artifacts generados para FastAPI (puerto ${args.port})`,
|
|
251
|
+
filesWritten,
|
|
252
|
+
}, null, 2)
|
|
253
|
+
}
|
|
254
|
+
})
|