diff --git a/backend/src/__tests__/template-service.test.ts b/backend/src/__tests__/template-service.test.ts new file mode 100644 index 00000000..d4f58e35 --- /dev/null +++ b/backend/src/__tests__/template-service.test.ts @@ -0,0 +1,222 @@ +/** + * Unit tests for TemplateService: compose YAML generation, + * env string generation, conditional env_file, and cache clearing. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TemplateService, Template } from '../services/TemplateService'; + +describe('TemplateService', () => { + let service: TemplateService; + + beforeEach(() => { + service = new TemplateService(); + }); + + // ─── generateComposeFromTemplate ───────────────────────────────────── + + describe('generateComposeFromTemplate', () => { + it('generates minimal compose with just image and restart policy', () => { + const template: Template = { + title: 'nginx', + description: 'Web server', + image: 'nginx:latest', + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('image: nginx:latest'); + expect(yaml).toContain('restart: unless-stopped'); + expect(yaml).not.toContain('ports:'); + expect(yaml).not.toContain('volumes:'); + expect(yaml).not.toContain('env_file:'); + }); + + it('includes ports when template has port mappings', () => { + const template: Template = { + title: 'nginx', + description: 'Web server', + image: 'nginx:latest', + ports: ['80:80', '443:443/tcp'], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('ports:'); + expect(yaml).toContain('"80:80"'); + expect(yaml).toContain('"443:443/tcp"'); + }); + + it('handles string volumes with host:container format', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: ['/host/data:/container/data'], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('volumes:'); + expect(yaml).toContain('/host/data:/container/data'); + }); + + it('handles string volumes with single path (named volume)', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: ['/data'], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('- /data'); + }); + + it('handles object volumes with container and bind', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: [{ container: '/config', bind: './config' }], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('./config:/config'); + }); + + it('generates bind path from container folder when bind is not specified', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: [{ container: '/app/data' }], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('./data:/app/data'); + }); + + it('adds :ro suffix for readonly volumes', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: [{ container: '/config', bind: './config', readonly: true }], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('./config:/config:ro'); + }); + + it('includes env_file only when env vars are present', () => { + const withEnv: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + env: [{ name: 'TZ', default: 'UTC' }], + }; + + const withoutEnv: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + env: [], + }; + + expect(service.generateComposeFromTemplate(withEnv)).toContain('env_file:'); + expect(service.generateComposeFromTemplate(withoutEnv)).not.toContain('env_file:'); + }); + + it('does not include env_file when env is undefined', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + }; + + expect(service.generateComposeFromTemplate(template)).not.toContain('env_file:'); + }); + + it('handles string volumes with options (e.g., host:container:ro)', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: ['/host/config:/config:ro'], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toContain('/host/config:/config:ro'); + }); + + it('skips object volumes without container path', () => { + const template: Template = { + title: 'app', + description: 'Test', + image: 'test:latest', + volumes: [{ container: '' }], + }; + + const yaml = service.generateComposeFromTemplate(template); + // Empty container means `continue` is hit, no volume line emitted + expect(yaml).toContain('volumes:'); + // The volume header is added but no actual volume entry + const volumeLines = yaml.split('\n').filter(l => l.trim().startsWith('- ')); + expect(volumeLines).toHaveLength(0); + }); + + it('produces valid YAML structure starting with services key', () => { + const template: Template = { + title: 'full', + description: 'Full template', + image: 'app:v1', + ports: ['8080:80'], + volumes: [{ container: '/data', bind: './data' }], + env: [{ name: 'KEY', default: 'val' }], + }; + + const yaml = service.generateComposeFromTemplate(template); + expect(yaml).toMatch(/^services:\n/); + expect(yaml).toContain(' app:'); + }); + }); + + // ─── generateEnvString ─────────────────────────────────────────────── + + describe('generateEnvString', () => { + it('converts key-value pairs to env file format', () => { + const result = service.generateEnvString({ + TZ: 'America/New_York', + PUID: '1000', + PGID: '1000', + }); + + expect(result).toBe('TZ=America/New_York\nPUID=1000\nPGID=1000'); + }); + + it('returns empty string for empty object', () => { + expect(service.generateEnvString({})).toBe(''); + }); + + it('handles values with special characters', () => { + const result = service.generateEnvString({ + PASSWORD: 'p@ss=word!', + URL: 'http://localhost:3000', + }); + + expect(result).toContain('PASSWORD=p@ss=word!'); + expect(result).toContain('URL=http://localhost:3000'); + }); + }); + + // ─── clearCache ────────────────────────────────────────────────────── + + describe('clearCache', () => { + it('calls CacheService.invalidate with the correct key', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // clearCache should not throw even when cache is empty + expect(() => service.clearCache()).not.toThrow(); + expect(consoleSpy).toHaveBeenCalledWith('[Templates] Cache invalidated'); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 145b672a..edfc297b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -64,7 +64,7 @@ import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation'; import YAML from 'yaml'; -import fs, { promises as fsPromises } from 'fs'; +import { promises as fsPromises } from 'fs'; // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is @@ -5620,11 +5620,12 @@ app.post('/api/system/networks', async (req: Request, res: Response) => { // --- App Templates Routes --- -app.get('/api/templates', async (req: Request, res: Response) => { +app.get('/api/templates', authMiddleware, async (req: Request, res: Response) => { try { const templates = await templateService.getTemplates(); res.json(templates); } catch (error) { + console.error('[Templates] Failed to fetch:', error); res.status(500).json({ error: 'Failed to fetch templates' }); } }); @@ -5632,10 +5633,11 @@ app.get('/api/templates', async (req: Request, res: Response) => { app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; templateService.clearCache(); + console.log('[Templates] Cache cleared by', req.user?.username || 'unknown'); res.json({ success: true }); }); -app.post('/api/templates/deploy', async (req: Request, res: Response) => { +app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { const { stackName, template, envVars } = req.body; @@ -5644,26 +5646,42 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { return res.status(400).json({ error: 'stackName and template are required' }); } - const stackPath = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); - if (fs.existsSync(stackPath)) { + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' }); + } + + const fsService = FileSystemService.getInstance(req.nodeId); + const baseDir = fsService.getBaseDir(); + const stackPath = path.join(baseDir, stackName); + if (!isPathWithinBase(stackPath, baseDir)) { + return res.status(400).json({ error: 'Invalid stack path' }); + } + + try { + await fsPromises.access(stackPath); return res.status(409).json({ error: `A stack directory named '${stackName}' already exists. Please choose a different Stack Name.`, rolledBack: false }); + } catch { + // Directory does not exist; proceed with deploy } + const debug = isDebugEnabled(); + console.log(`[Templates] Deploy started: ${stackName}`); + if (debug) console.debug('[Templates:debug] Deploy payload', { stackName, templateTitle: template.title, envVarCount: envVars ? Object.keys(envVars).length : 0 }); + // 1. Create stack directory - await FileSystemService.getInstance(req.nodeId).createStack(stackName); + await fsService.createStack(stackName); // 2. Generate compose YAML and save const composeYaml = templateService.generateComposeFromTemplate(template); - await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, composeYaml); + await fsService.saveStackContent(stackName, composeYaml); // 3. Generate env string and save to default .env - if (envVars) { + if (envVars && Object.keys(envVars).length > 0) { const envString = templateService.generateEnvString(envVars); - const stackDir = path.join(FileSystemService.getInstance(req.nodeId).getBaseDir(), stackName); - const defaultEnvPath = path.join(stackDir, '.env'); + const defaultEnvPath = path.join(stackPath, '.env'); await fsPromises.writeFile(defaultEnvPath, envString, 'utf-8'); } @@ -5672,9 +5690,11 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { const atomic = LicenseService.getInstance().getTier() === 'paid'; await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); invalidateNodeCaches(req.nodeId); + console.log(`[Templates] Deploy completed: ${stackName}`); res.json({ success: true, message: 'Template deployed successfully' }); - } catch (deployError: any) { - const rawError = deployError.message || String(deployError); + } catch (deployError: unknown) { + const rawError = getErrorMessage(deployError, String(deployError)); + console.error(`[Templates] Deploy failed: ${stackName} -`, rawError); const parsed = ErrorParser.parse(rawError); const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; @@ -5684,14 +5704,14 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { // Stage 1: Tell Docker to clean up ghost networks/containers await ComposeService.getInstance(req.nodeId).downStack(stackName); } catch (downErr) { - console.error("Rollback Stage 1 (Docker down) failed:", downErr); + console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr); } try { - // Stage 2: Obliterate the files - await FileSystemService.getInstance(req.nodeId).deleteStack(stackName); + // Stage 2: Remove the stack files + await fsService.deleteStack(stackName); } catch (fsErr) { - console.error("Rollback Stage 2 (File deletion) failed:", fsErr); + console.error("[Templates] Rollback Stage 2 (File deletion) failed:", fsErr); } } @@ -5704,9 +5724,10 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { ruleId: parsed.rule?.id || 'UNKNOWN' }); } - } catch (error: any) { - console.error('Failed to deploy template:', error); - res.status(500).json({ error: error.message || 'Failed to deploy template' }); + } catch (error: unknown) { + const message = getErrorMessage(error, 'Failed to deploy template'); + console.error('[Templates] Deploy error:', message); + res.status(500).json({ error: message }); } }); diff --git a/backend/src/services/TemplateService.ts b/backend/src/services/TemplateService.ts index 46bc7432..0db591b4 100644 --- a/backend/src/services/TemplateService.ts +++ b/backend/src/services/TemplateService.ts @@ -1,15 +1,16 @@ import axios from 'axios'; import { DatabaseService } from './DatabaseService'; import { CacheService } from './CacheService'; +import { isDebugEnabled } from '../utils/debug'; -export interface TemplateEnv { +interface TemplateEnv { name: string; label?: string; default?: string; } -export interface TemplateVolume { +interface TemplateVolume { container: string; bind?: string; readonly?: boolean; @@ -37,11 +38,30 @@ export interface Template { }; } -export interface TemplatesResponse { +interface TemplatesResponse { version: string; templates: Template[]; } +// Typed shapes for the LinuxServer.io API response +interface LsioPort { external?: number; internal: number; protocol?: string } +interface LsioVolume { path: string } +interface LsioEnvVar { name: string; desc?: string; default?: string } +interface LsioAppConfig { ports?: LsioPort[]; volumes?: LsioVolume[]; environment?: LsioEnvVar[] } +interface LsioApp { + name: string; + description?: string; + logo?: string; + github?: string; + readme?: string; + arch?: string[]; + stars?: number; + config?: LsioAppConfig; +} +interface LsioApiResponse { + data?: { repositories?: { linuxserver?: Record } }; +} + // Static category map for LSIO apps (the LSIO API does not expose category metadata). // Apps can belong to multiple categories. Unmapped apps fall back to ['Other']. const LSIO_CATEGORY_MAP: Record = { @@ -197,6 +217,7 @@ export class TemplateService { public clearCache(): void { CacheService.getInstance().invalidate(TemplateService.CACHE_KEY); + console.log('[Templates] Cache invalidated'); } public async getTemplates(): Promise { @@ -209,13 +230,15 @@ export class TemplateService { // Default to a reliable LSIO Portainer v2 template registry if not set const registryUrl = settings.template_registry_url || 'https://api.linuxserver.io/api/v1/images?include_config=true'; - const response = await axios.get(registryUrl); + console.log(`[Templates] Fetching from registry: ${registryUrl}`); + const debug = isDebugEnabled(); if (registryUrl.includes('api.linuxserver.io')) { + const response = await axios.get(registryUrl, { timeout: 20_000 }); // Official LSIO API Schema Mapping - const lsioApps = response.data?.data?.repositories?.linuxserver || []; + const lsioApps = response.data?.data?.repositories?.linuxserver ?? {}; - return Object.values(lsioApps).map((app: any) => ({ + const templates: Template[] = Object.values(lsioApps).map((app: LsioApp) => ({ type: 1, title: app.name, description: app.description || '', @@ -228,31 +251,39 @@ export class TemplateService { categories: getCategoriesForApp(app.name), source: 'linuxserver', // Map configs if available, otherwise default to empty arrays - ports: (app.config?.ports || []).map((p: any) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), - volumes: (app.config?.volumes || []).map((v: any) => { + ports: (app.config?.ports ?? []).map((p: LsioPort) => `${p.external || p.internal}:${p.internal}/${p.protocol || 'tcp'}`), + volumes: (app.config?.volumes ?? []).map((v: LsioVolume) => { const folderName = v.path.split('/').filter(Boolean).pop() || 'data'; return { container: v.path, - bind: `./${folderName}` // Proactively create a clean relative path + bind: `./${folderName}` }; }), - env: (app.config?.environment || []).map((e: any) => ({ + env: (app.config?.environment ?? []).map((e: LsioEnvVar) => ({ name: e.name, label: e.desc || e.name, default: e.default || '' })) })); + + console.log(`[Templates] Fetched ${templates.length} templates from LSIO`); + if (debug) console.debug('[Templates:debug] LSIO sample:', templates.slice(0, 5).map(t => t.title)); + return templates; } // Legacy Portainer v2 Format (Fallback for custom registries) - // The Portainer v2 spec includes a native `categories` field - pass it through. - return (response.data.templates || []) + // The Portainer v2 spec includes a native `categories` field; pass it through. + const response = await axios.get(registryUrl, { timeout: 20_000 }); + const templates = (response.data.templates || []) .filter((t: Template) => !!t.image && t.type === 1) .map((t: Template) => ({ ...t, source: 'custom' })); + + console.log(`[Templates] Fetched ${templates.length} templates from custom registry`); + return templates; }, ); } catch (error) { - console.error('Failed to fetch templates', error); + console.error('[Templates] Failed to fetch from registry:', error); throw new Error('Could not fetch templates from registry'); } } @@ -304,7 +335,9 @@ export class TemplateService { } } - yaml += ` env_file:\n - .env\n`; + if (template.env && template.env.length > 0) { + yaml += ` env_file:\n - .env\n`; + } return yaml; } diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 6fcdbbfa..af2fcbd2 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -46,7 +46,7 @@ Pre-filled with the template name in lowercase. You can change it; the same [nam ### Ports -For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. +For each exposed port, the sheet shows an editable **host port** field (left side) and the fixed **container port** (right side). Edit the host port to avoid conflicts with other services on the same machine. Ports must be in the valid range (1 to 65535); invalid values are highlighted and block deployment. ### Volumes @@ -62,12 +62,12 @@ Each template declares the variables it needs. Sencho pre-fills sensible default ### Custom variables -Below the template variables, an **Add Custom Variable** section lets you define arbitrary key-value pairs not declared by the template. Type a key and value, then click **+** to add it. +Below the template variables, an **Add Custom Variable** section lets you define arbitrary key-value pairs not declared by the template. Type a key and value, then click **+** to add it. If you add a key that already exists in the template defaults, a warning will let you know your custom value will override the default. ### What happens on Deploy 1. Sencho creates a new stack directory in `COMPOSE_DIR` -2. Writes the generated `compose.yaml` and `.env` file +2. Writes the generated `compose.yaml` (and `.env` file if environment variables are configured) 3. Runs `docker compose up -d` 4. On success: switches you to the Editor view for that stack 5. On failure: rolls back by running `docker compose down` and deleting the stack directory diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 94e8bbbe..3d9a5701 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -8,11 +8,18 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react"; import { toast } from "@/components/ui/toast-store"; +import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; -export interface TemplateEnv { +function isValidPort(value: string): boolean { + if (!value) return true; + const num = Number(value); + return Number.isInteger(num) && num >= 1 && num <= 65535; +} + +interface TemplateEnv { name: string; label?: string; default?: string; @@ -23,7 +30,7 @@ interface TemplateVolume { bind?: string; } -export interface Template { +interface Template { type?: number; title: string; description: string; @@ -83,22 +90,24 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }; const handleSelectTemplate = (t: Template) => { - setSelectedTemplate(t); - const localEnvs = [...(t.env || [])]; + // Work on a copy to avoid mutating the template in state + const envsCopy = [...(t.env || [])]; // Inject LSIO standards if missing from the API - if (!localEnvs.find(e => e.name === 'PUID')) localEnvs.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' }); - if (!localEnvs.find(e => e.name === 'PGID')) localEnvs.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' }); - if (!localEnvs.find(e => e.name === 'TZ')) { + if (!envsCopy.find(e => e.name === 'PUID')) envsCopy.push({ name: 'PUID', label: 'User ID (PUID)', default: '1000' }); + if (!envsCopy.find(e => e.name === 'PGID')) envsCopy.push({ name: 'PGID', label: 'Group ID (PGID)', default: '1000' }); + if (!envsCopy.find(e => e.name === 'TZ')) { const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - localEnvs.push({ name: 'TZ', label: 'Timezone', default: browserTz }); + envsCopy.push({ name: 'TZ', label: 'Timezone', default: browserTz }); } + const templateCopy: Template = { ...t, env: envsCopy }; + setSelectedTemplate(templateCopy); + const initEnvs: Record = {}; - localEnvs.forEach(e => { + envsCopy.forEach(e => { initEnvs[e.name] = e.default || ''; }); setEnvVars(initEnvs); - t.env = localEnvs; // Mutate local template copy so they render // Initialize Volumes const initVols: Record = {}; @@ -139,6 +148,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { return; } + // Validate port numbers + const invalidPort = Object.entries(portVars).find(([, val]) => val && !isValidPort(val)); + if (invalidPort) { + toast.error(`Invalid port: ${invalidPort[1]}. Ports must be between 1 and 65535.`); + return; + } + // Pre-check for duplicate stack name try { const checkRes = await apiFetch('/stacks'); @@ -149,7 +165,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { return; } } - } catch { /* proceed to deploy; backend will catch duplicates */ } + } catch (checkErr) { + console.warn('[AppStore] Pre-deploy duplicate check failed, proceeding:', checkErr); + } setIsDeploying(true); @@ -254,7 +272,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { )} -
+ {loading ? (
@@ -264,13 +282,13 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { {filtered.map((t, idx) => ( handleSelectTemplate(t)} > -
- {!imgErrors[t.title] && t.logo ? ( - {t.title} setImgErrors(prev => ({ ...prev, [t.title]: true }))} /> +
+ {t.logo && !imgErrors[t.logo] ? ( + {t.title} setImgErrors(prev => ({ ...prev, [t.logo!]: true }))} /> ) : ( )} @@ -303,12 +321,16 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { {filtered.length === 0 && (
-

No apps found matching "{searchQuery}"

+ {templates.length === 0 ? ( +

Registry returned no templates. Check your registry URL in Settings.

+ ) : ( +

No apps found matching "{searchQuery}"

+ )}
)}
)} -
+ @@ -323,9 +345,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
)}
-
- {!imgErrors[selectedTemplate.title] && selectedTemplate.logo ? ( - {selectedTemplate.title} setImgErrors(prev => ({ ...prev, [selectedTemplate.title]: true }))} /> +
+ {selectedTemplate.logo && !imgErrors[selectedTemplate.logo] ? ( + {selectedTemplate.title} setImgErrors(prev => ({ ...prev, [selectedTemplate.logo!]: true }))} /> ) : ( )} @@ -357,7 +379,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { {selectedTemplate.stars !== undefined && (
- {selectedTemplate.stars} + {selectedTemplate.stars?.toLocaleString()}
)} {selectedTemplate.github_url && ( @@ -383,7 +405,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
setPortVars(prev => ({ ...prev, [p]: e.target.value }))} - className="w-24 text-center" + onChange={(e) => { + const val = e.target.value.replace(/[^0-9]/g, ''); + setPortVars(prev => ({ ...prev, [p]: val })); + }} + className={cn("w-24 text-center font-mono", portVars[p] && !isValidPort(portVars[p]) && "border-destructive")} /> : {parts[1]}
@@ -464,7 +489,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
- +
))}
@@ -472,6 +497,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" />