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.
@@ -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
+ })