fix(env): return empty body for missing .env files; surface non-OK responses cleanly (#767)

Previously, fetching the .env file for a stack with no env files at all
returned a 404 with a JSON error body. The frontend's secondary loader
(changeEnvFile) called res.text() without checking res.ok, which caused
the error body to be stuffed directly into the editor as if it were
file content.

Two-part fix:

Backend (routes/stacks.ts):
- For the default GET /stacks/:name/env (no ?file= query) when the
  stack has no env files, respond 200 with an empty body and an
  X-Env-Exists: false header instead of 404.
- For an explicit ?file= query that resolves to a missing file, keep
  the 404 (the caller asked for something specific).
- Catch a TOCTOU ENOENT between access() and readFile() and return the
  same friendly empty-body shape, not a generic 500.

Frontend (EditorLayout.tsx::changeEnvFile):
- Check res.ok before reading the body. On a non-OK response, clear the
  editor content and surface a friendly toast instead of pasting the
  server's JSON error string into the file.
This commit is contained in:
Anso
2026-04-24 23:37:54 -04:00
committed by GitHub
parent 584cda7182
commit a962654a3b
2 changed files with 42 additions and 4 deletions
+33 -4
View File
@@ -185,7 +185,7 @@ stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => {
const requestedFile = req.query.file as string | undefined;
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
let envPath = envPaths[0];
let envPath: string | undefined = envPaths[0];
if (requestedFile) {
if (envPaths.includes(requestedFile)) {
@@ -195,6 +195,15 @@ stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => {
}
}
// Default path with no env files yet: reply 200 with an empty body and a
// header the frontend can read. This avoids surfacing a 404 for the
// legitimate "stack has no .env yet" case, which previous flows
// sometimes echoed back to the user as a confusing error string.
if (!envPath) {
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
const fsService = FileSystemService.getInstance(req.nodeId);
try {
@@ -204,11 +213,31 @@ stacksRouter.get('/:stackName/env', async (req: Request, res: Response) => {
if (code !== 'ENOENT') {
console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message);
}
return res.status(404).json({ error: 'Env file not found' });
// No env file at the resolved path. For an explicit ?file= query we
// surface a 404 (the caller asked for something specific). Otherwise
// treat it as the empty-stack case above.
if (requestedFile) {
return res.status(404).json({ error: 'Env file not found' });
}
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
const content = await fsService.readFile(envPath, 'utf-8');
res.send(content);
try {
const content = await fsService.readFile(envPath, 'utf-8');
res.setHeader('X-Env-Exists', 'true');
return res.send(content);
} catch (e: unknown) {
// TOCTOU: the file existed at access() but vanished before readFile().
// Return the same friendly empty-body shape rather than a generic 500
// that the frontend would otherwise echo as an opaque error.
const code = (e as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT' && !requestedFile) {
res.setHeader('X-Env-Exists', 'false');
return res.send('');
}
throw e;
}
} catch (error) {
console.error('Failed to read env file:', error);
res.status(500).json({ error: 'Failed to read env file' });
+9
View File
@@ -1159,11 +1159,20 @@ export default function EditorLayout() {
setIsFileLoading(true);
try {
const res = await apiFetch(`/stacks/${selectedFile}/env?file=${encodeURIComponent(file)}`);
if (!res.ok) {
// Don't stuff a JSON error body into the editor on a non-OK response.
setEnvContent('');
setOriginalEnvContent('');
toast.error('Could not load env file');
return;
}
const text = await res.text();
setEnvContent(text || '');
setOriginalEnvContent(text || '');
} catch (e) {
console.error('Failed to switch env file', e);
setEnvContent('');
setOriginalEnvContent('');
} finally {
setIsFileLoading(false);
}