mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
feat: add per-stack project env file selection for Docker Compose (#1457)
* feat: add per-stack project env file selection for Docker Compose
Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.
Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry
Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts
Issue: #1454
* fix: add realpath validation, clear all stale backup files, reject nested paths
- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
only, matching Compose auto-discovery behavior)
* fix: add getStackProjectEnvFiles to compose-service mock
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).
* fix: add getStackProjectEnvFiles to remaining service mocks
The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).
* fix: apply inline path-injection barrier at fs sink for CodeQL
The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.
* fix: resolve stackDir from the same canonical root as safePath
Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.
* fix: remove unused isPathWithinBase import
The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
This commit is contained in:
@@ -165,20 +165,52 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
|
||||
|
||||
const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir);
|
||||
|
||||
// Physical env files, deduped by resolved absolute path. Seed with the project
|
||||
// `.env`: always the interpolation source, regardless of any env_file entry.
|
||||
// Physical env files, deduped by resolved absolute path.
|
||||
// Seed the project `.env` as the fallback interpolation source. When the stack
|
||||
// has configured project env files, they REPLACE `.env` as the interpolation
|
||||
// source (matching Docker Compose behavior: explicit --env-file suppresses
|
||||
// default .env auto-discovery). If the user wants `.env` in addition to custom
|
||||
// files, they must include it in the configured list.
|
||||
const configuredProjectFiles = DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, stackName);
|
||||
const hasConfiguredProjectFiles = configuredProjectFiles.length > 0;
|
||||
|
||||
const byPath = new Map<string, PhysicalEnvFile>();
|
||||
const dotenvPath = path.resolve(stackDir, '.env');
|
||||
const dotenv: PhysicalEnvFile = {
|
||||
resolvedPath: dotenvPath,
|
||||
rawPaths: ['.env'],
|
||||
existence: await existenceOf(fsService, dotenvPath, baseDir),
|
||||
required: false,
|
||||
isInterpolationSource: true,
|
||||
isInjectionSource: false,
|
||||
declaringServices: [],
|
||||
};
|
||||
byPath.set(dotenvPath, dotenv);
|
||||
|
||||
if (hasConfiguredProjectFiles) {
|
||||
for (const file of configuredProjectFiles) {
|
||||
const resolvedPath = path.resolve(stackDir, file);
|
||||
const exists = await existenceOf(fsService, resolvedPath, baseDir);
|
||||
const existing = byPath.get(resolvedPath);
|
||||
if (existing) {
|
||||
existing.isInterpolationSource = true;
|
||||
} else {
|
||||
byPath.set(resolvedPath, {
|
||||
resolvedPath,
|
||||
rawPaths: [file],
|
||||
existence: exists,
|
||||
required: false,
|
||||
isInterpolationSource: true,
|
||||
isInjectionSource: false,
|
||||
declaringServices: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the project `.env` as the fallback interpolation source when nothing is configured.
|
||||
if (!hasConfiguredProjectFiles) {
|
||||
const dotenvPath = path.resolve(stackDir, '.env');
|
||||
const dotenv: PhysicalEnvFile = {
|
||||
resolvedPath: dotenvPath,
|
||||
rawPaths: ['.env'],
|
||||
existence: await existenceOf(fsService, dotenvPath, baseDir),
|
||||
required: false,
|
||||
isInterpolationSource: true,
|
||||
isInjectionSource: false,
|
||||
declaringServices: [],
|
||||
};
|
||||
byPath.set(dotenvPath, dotenv);
|
||||
}
|
||||
|
||||
const unresolved: PhysicalEnvFile[] = [];
|
||||
const inlineEnvKeysByService: Record<string, string[]> = {};
|
||||
@@ -251,3 +283,40 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
|
||||
interpolationRefs: parseInterpolationRefs(authoredText),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the stack directory (non-recursive) for env-like files that could serve as
|
||||
* project env files. Matches three patterns: `.env`, `*.env` (e.g. `stack.env`),
|
||||
* and `.env.*` (e.g. `.env.production`, `.env.local`). Returns only regular files
|
||||
* (directories named `*.env` are excluded). Results are stack-relative paths sorted
|
||||
* alphabetically.
|
||||
*/
|
||||
export async function discoverStackLocalEnvFiles(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const fsService = FileSystemService.getInstance(nodeId);
|
||||
const baseDir = fsService.getBaseDir();
|
||||
|
||||
let entries: { name: string; type: string }[];
|
||||
try {
|
||||
entries = await fsService.listStackDirectory(stackName, '');
|
||||
} catch (err) {
|
||||
console.warn('[EnvFileResolution] Failed to list stack directory for env file discovery:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
|
||||
const stackDir = path.join(baseDir, stackName);
|
||||
const candidates: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const name = entry.name;
|
||||
if (name === '.env' || name.endsWith('.env') || name.startsWith('.env.')) {
|
||||
// Must be a regular file, not a directory.
|
||||
if (entry.type !== 'file') continue;
|
||||
// Validate containment (defense in depth).
|
||||
const absPath = path.resolve(stackDir, name);
|
||||
if (!isPathWithinBase(absPath, stackDir)) continue;
|
||||
candidates.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort();
|
||||
return candidates;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user