mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -38,7 +38,7 @@ vi.mock('../services/DockerController', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined }) },
|
||||
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined, getStackProjectEnvFiles: () => [] }) },
|
||||
}));
|
||||
|
||||
vi.mock('../services/RegistryService', () => ({
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
getRegistries: mockGetRegistries,
|
||||
getGlobalSettings: mockGetGlobalSettings,
|
||||
getGitSource: () => undefined,
|
||||
getStackProjectEnvFiles: () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
getGlobalSettings: mockGetGlobalSettings,
|
||||
getNodes: () => [],
|
||||
getGitSource: () => undefined,
|
||||
getStackProjectEnvFiles: () => [],
|
||||
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
|
||||
getStackUpdateStatus: mockGetStackUpdateStatus,
|
||||
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
||||
@@ -553,6 +554,7 @@ describe('ImageUpdateService - check() concurrency guard', () => {
|
||||
getGlobalSettings: () => ({ developer_mode: developerMode }),
|
||||
getNodes: () => [{ type: 'local', id: 1, name: 'local', mode: 'proxy', compose_dir: '/tmp/compose', is_default: true, status: 'online', created_at: 1 }],
|
||||
getGitSource: () => undefined,
|
||||
getStackProjectEnvFiles: () => [],
|
||||
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
|
||||
getStackUpdateStatus: mockGetStackUpdateStatus,
|
||||
clearStackUpdateStatus: mockClearStackUpdateStatus,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+112
-12
@@ -46,7 +46,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP
|
||||
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
|
||||
import { resolveStackEnvSources } from '../helpers/envFileResolution';
|
||||
import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution';
|
||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||
|
||||
@@ -147,20 +147,24 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared env-source resolver. Returns the absolute paths of
|
||||
// the env files Compose would consult for this stack: the existing declared
|
||||
// `env_file:` paths when any are declared (no project `.env` fallback in that
|
||||
// case), otherwise the project `.env` when it exists. The multi-file Git case and
|
||||
// path validation live in resolveStackEnvSources so every consumer agrees.
|
||||
// the env files Compose would consult for this stack: existing declared `env_file:`
|
||||
// paths (injection sources) plus the project interpolation source(s). Configured
|
||||
// project env files are included as interpolation sources. The multi-file Git case
|
||||
// and path validation live in resolveStackEnvSources so every consumer agrees.
|
||||
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
|
||||
const sources = await resolveStackEnvSources(nodeId, stackName);
|
||||
const injection = sources.envFiles.filter(f => f.isInjectionSource);
|
||||
if (injection.length > 0) {
|
||||
return injection
|
||||
.filter(f => f.existence === 'present' && f.resolvedPath)
|
||||
.map(f => f.resolvedPath as string);
|
||||
const present = sources.envFiles.filter(f => f.existence === 'present' && f.resolvedPath);
|
||||
const injection = present.filter(f => f.isInjectionSource).map(f => f.resolvedPath as string);
|
||||
const interpolation = present.filter(f => f.isInterpolationSource && !f.isInjectionSource).map(f => f.resolvedPath as string);
|
||||
// Dedup: interpolation sources may also appear as injection sources.
|
||||
const seen = new Set(injection);
|
||||
for (const p of interpolation) {
|
||||
if (!seen.has(p)) {
|
||||
seen.add(p);
|
||||
injection.push(p);
|
||||
}
|
||||
}
|
||||
const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath);
|
||||
return dotenv ? [dotenv.resolvedPath as string] : [];
|
||||
return injection;
|
||||
}
|
||||
|
||||
// Uploads spool to disk (not memory) so a 25 MB upload is never held in RAM.
|
||||
@@ -665,6 +669,101 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Project env files: per-stack ordered list of env files that serve as the Docker
|
||||
// Compose project environment file(s) for ${VAR} interpolation. An empty array means
|
||||
// "use the default .env behavior".
|
||||
stacksRouter.get('/:stackName/project-env-files', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
try {
|
||||
const files = DatabaseService.getInstance().getStackProjectEnvFiles(req.nodeId, stackName);
|
||||
res.json({ envFiles: files });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to read project env files:', error);
|
||||
res.status(500).json({ error: 'Failed to read project env files' });
|
||||
}
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/project-env-files', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
try {
|
||||
const { envFiles } = req.body as { envFiles?: unknown };
|
||||
if (!Array.isArray(envFiles)) {
|
||||
return res.status(400).json({ error: 'envFiles must be an array' });
|
||||
}
|
||||
const files = envFiles as string[];
|
||||
const fsService = FileSystemService.getInstance(req.nodeId);
|
||||
|
||||
// Dedup while preserving order. Skip validation for duplicates: already
|
||||
// confirmed on first occurrence and the file cannot change mid-handler.
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
for (const file of files) {
|
||||
if (typeof file !== 'string' || file.length === 0) {
|
||||
return res.status(400).json({ error: 'Each env file must be a non-empty string' });
|
||||
}
|
||||
if (seen.has(file)) continue;
|
||||
if (path.isAbsolute(file) || file.includes('..') || file.startsWith('.git')) {
|
||||
return res.status(400).json({ error: `Invalid env file path: "${file}"` });
|
||||
}
|
||||
if (file.includes('/') || file.includes('\\')) {
|
||||
return res.status(400).json({ error: `Project env files must be at the stack root: "${file}"` });
|
||||
}
|
||||
if (!isValidRelativeStackPath(file)) {
|
||||
return res.status(400).json({ error: `Invalid env file path: "${file}"` });
|
||||
}
|
||||
// Canonical js/path-injection barrier inline with the fs sink: resolve both
|
||||
// the compose root and the stack directory from a single canonical root so
|
||||
// containment is unambiguous even when the compose dir is a symlink. The
|
||||
// pattern mirrors authoredComposeArgs.ts and every FileSystemService sink.
|
||||
const baseResolved = path.resolve(fsService.getBaseDir());
|
||||
const stackDir = path.resolve(baseResolved, stackName);
|
||||
if (!stackDir.startsWith(baseResolved + path.sep)) {
|
||||
return res.status(400).json({ error: `Stack directory escapes the compose directory: "${stackName}"` });
|
||||
}
|
||||
const safePath = path.resolve(stackDir, file);
|
||||
if (!safePath.startsWith(baseResolved + path.sep)) {
|
||||
return res.status(400).json({ error: `Env file path escapes the compose directory: "${file}"` });
|
||||
}
|
||||
try {
|
||||
await fsService.access(safePath);
|
||||
const stat = await fsp.stat(safePath);
|
||||
if (!stat.isFile()) {
|
||||
return res.status(400).json({ error: `Not a regular file: "${file}"` });
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return res.status(400).json({ error: `Env file not found: "${file}"` });
|
||||
}
|
||||
return res.status(400).json({ error: `Cannot access env file "${file}"` });
|
||||
}
|
||||
seen.add(file);
|
||||
deduped.push(file);
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().setStackProjectEnvFiles(req.nodeId, stackName, deduped);
|
||||
res.json({ envFiles: deduped });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to save project env files:', error);
|
||||
res.status(500).json({ error: 'Failed to save project env files' });
|
||||
}
|
||||
});
|
||||
|
||||
// Candidates for project env file selection: stack-local env-like files.
|
||||
stacksRouter.get('/:stackName/project-env-files/candidates', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
try {
|
||||
const files = await discoverStackLocalEnvFiles(req.nodeId, stackName);
|
||||
res.json({ envFiles: files });
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to discover project env file candidates:', error);
|
||||
res.status(500).json({ error: 'Failed to discover project env file candidates' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stack Dossier: operator-authored documentation persisted per (node, stack).
|
||||
// All fields default to '' so a PUT is a full-document save (an omitted field
|
||||
// clears it) and a GET for a stack with no dossier yet returns a clean blank.
|
||||
@@ -947,6 +1046,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
||||
DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName);
|
||||
DatabaseService.getInstance().deleteStackProjectEnvFiles(req.nodeId, stackName);
|
||||
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
|
||||
} catch (dbErr) {
|
||||
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
|
||||
|
||||
@@ -39,6 +39,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'project-env-files',
|
||||
'compose-storage',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1875,6 +1875,22 @@ export class DatabaseService {
|
||||
} catch (e) {
|
||||
console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message);
|
||||
}
|
||||
try {
|
||||
this.db.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS stack_project_env_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
env_file TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
UNIQUE(node_id, stack_name, env_file),
|
||||
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||
)
|
||||
`).run();
|
||||
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_stack_project_env_files_lookup ON stack_project_env_files(node_id, stack_name)').run();
|
||||
} catch (e) {
|
||||
console.warn('[DatabaseService] stack_project_env_files migration:', (e as Error).message);
|
||||
}
|
||||
// mesh_centrals was the peer-side cache of the reverse-callback JWT
|
||||
// (central → peer bootstrap material). Peer→central traffic now
|
||||
// multiplexes over the existing forward WS via `tcp_open_reverse`,
|
||||
@@ -2025,6 +2041,33 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Project env files ---
|
||||
|
||||
public getStackProjectEnvFiles(nodeId: number, stackName: string): string[] {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT env_file FROM stack_project_env_files WHERE node_id = ? AND stack_name = ? ORDER BY position ASC'
|
||||
).all(nodeId, stackName) as Array<{ env_file: string }>;
|
||||
return rows.map(r => r.env_file);
|
||||
}
|
||||
|
||||
public setStackProjectEnvFiles(nodeId: number, stackName: string, files: string[]): void {
|
||||
const deleteStmt = this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?');
|
||||
const insertStmt = this.db.prepare(
|
||||
'INSERT INTO stack_project_env_files (node_id, stack_name, env_file, position) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const tx = this.db.transaction((ordered: string[]) => {
|
||||
deleteStmt.run(nodeId, stackName);
|
||||
ordered.forEach((file, idx) => {
|
||||
insertStmt.run(nodeId, stackName, file, idx);
|
||||
});
|
||||
});
|
||||
tx(files);
|
||||
}
|
||||
|
||||
public deleteStackProjectEnvFiles(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
public setNodeMeshEnabled(nodeId: number, enabled: boolean): void {
|
||||
this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Dirent } from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { isBinaryBuffer } from '../utils/binaryDetect';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -953,24 +954,32 @@ export class FileSystemService {
|
||||
}
|
||||
await fsPromises.mkdir(backupDir, { recursive: true });
|
||||
|
||||
// Clear stale managed files from the backup slot before writing the current
|
||||
// ones. The slot is reused across runs, so a managed file removed from the
|
||||
// stack since the last backup (e.g. a deleted .env or a switched compose
|
||||
// variant) would otherwise linger here and a later restore would resurrect
|
||||
// it, breaking the faithful-revert guarantee. Scope is the protected set
|
||||
// Sencho writes; .timestamp is rewritten below. A clear failure is logged but
|
||||
// not fatal: it only risks a stale future rollback, so it should not block an
|
||||
// otherwise valid deploy.
|
||||
for (const file of PROTECTED_STACK_FILES) {
|
||||
const stale = path.resolve(backupRoot, path.join(backupDir, file));
|
||||
if (!stale.startsWith(backupRoot + path.sep)) continue;
|
||||
try {
|
||||
await fsPromises.unlink(stale);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.warn(`[FileSystemService] Could not clear stale backup ${file}:`, (e as Error).message);
|
||||
// Clear ALL non-marker files from the backup slot before writing the current
|
||||
// set. The slot is reused across runs, so a file removed from the stack since
|
||||
// the last backup (e.g. a deleted .env, a switched compose variant, or a
|
||||
// removed project env file like old.env) would otherwise linger here and a
|
||||
// later restore would resurrect it, breaking the faithful-revert guarantee.
|
||||
// Marker files (.timestamp, .checksums) are preserved until rewritten below.
|
||||
// A clear failure is logged but not fatal: it only risks a stale future
|
||||
// rollback, so it should not block an otherwise valid deploy.
|
||||
try {
|
||||
const existing = await fsPromises.readdir(backupDir);
|
||||
for (const item of existing) {
|
||||
if (BACKUP_MARKER_FILES.has(item)) continue;
|
||||
const stale = path.resolve(backupRoot, path.join(backupDir, item));
|
||||
if (!stale.startsWith(backupRoot + path.sep)) continue;
|
||||
try {
|
||||
await fsPromises.unlink(stale);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.warn(`[FileSystemService] Could not clear stale backup ${item}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||
console.warn('[FileSystemService] Could not read backup directory for stale cleanup:', (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy each managed file by reading it into memory, writing it to the backup
|
||||
@@ -1028,6 +1037,23 @@ export class FileSystemService {
|
||||
}
|
||||
await writeManagedBackupFile('.env', envSrc);
|
||||
|
||||
// Backup configured project env files (e.g. stack.env, .env.production).
|
||||
// Dedup against .env (already in PROTECTED_STACK_FILES) so it is not
|
||||
// duplicated. Stale dynamic files from a prior backup are cleared below so a
|
||||
// previously-backed-up old.env does not linger.
|
||||
let projectEnvFiles: string[] = [];
|
||||
try {
|
||||
projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
|
||||
} catch {
|
||||
// DB read failure is not fatal to the deploy; skip project env file backup.
|
||||
}
|
||||
for (const file of projectEnvFiles) {
|
||||
if (file === '.env') continue; // already handled above
|
||||
const src = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!src.startsWith(baseResolved + path.sep)) continue;
|
||||
await writeManagedBackupFile(file, src);
|
||||
}
|
||||
|
||||
// Write the integrity manifest before the timestamp marker, so a crash
|
||||
// between the two leaves the checksums present (a backup that restore can
|
||||
// verify) rather than a timestamp with no integrity data. Only files that
|
||||
@@ -1142,9 +1168,10 @@ export class FileSystemService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current managed stack files (PROTECTED_STACK_FILES) in memory and
|
||||
* return a function that puts them back, faithfully (writing the captured
|
||||
* contents and removing any managed file that did not exist when captured).
|
||||
* Capture the current managed stack files (PROTECTED_STACK_FILES plus configured
|
||||
* project env files) in memory and return a function that puts them back,
|
||||
* faithfully (writing the captured contents and removing any managed file that
|
||||
* did not exist when captured).
|
||||
*
|
||||
* Used by the rollback route to undo a restored backup when the policy gate
|
||||
* blocks before the deploy commits: restoreStackFiles has already overwritten
|
||||
@@ -1154,12 +1181,21 @@ export class FileSystemService {
|
||||
async snapshotStackFiles(stackName: string): Promise<() => Promise<void>> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
await this.assertRealWithinBase(stackDir);
|
||||
// Canonical js/path-injection barrier inline with the read/write sinks, the
|
||||
// same pattern restoreStackFiles uses: resolve against the base and confirm
|
||||
// containment so static analysis credits the barrier.
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
const snapshot = new Map<string, Buffer>();
|
||||
for (const file of PROTECTED_STACK_FILES) {
|
||||
|
||||
// Collect the unified set: PROTECTED_STACK_FILES + configured project env files.
|
||||
const files = new Set(PROTECTED_STACK_FILES);
|
||||
try {
|
||||
const projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
|
||||
for (const f of projectEnvFiles) {
|
||||
if (f !== '.env') files.add(f); // .env already in PROTECTED_STACK_FILES
|
||||
}
|
||||
} catch {
|
||||
// DB read failure: snapshot without project env files (safe fallback).
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
@@ -1171,7 +1207,7 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
return async () => {
|
||||
for (const file of PROTECTED_STACK_FILES) {
|
||||
for (const file of files) {
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) continue;
|
||||
const saved = snapshot.get(file);
|
||||
|
||||
@@ -58,32 +58,86 @@ export function authoredComposeFileArgs(stackName: string, nodeId?: number): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `--env-file <stackDir>/.env` flag a multi-file Git deploy needs when
|
||||
* the applied spec sets a context dir, or `[]` otherwise.
|
||||
* Build `--env-file` arguments for the stack's configured project env files.
|
||||
*
|
||||
* When `authoredComposeFileArgs` emits `--project-directory <contextDir>`, Docker
|
||||
* Compose treats the context dir as the project directory and looks for `.env`
|
||||
* there, not at the stack root where Sencho writes it. `validateCompose` passes
|
||||
* the root `.env` explicitly with `--env-file` whenever the stack has env content,
|
||||
* so without the same flag at deploy/render/scan time a Git source could validate
|
||||
* with one effective config and deploy or render another. This flag makes every
|
||||
* compose invocation resolve env from the same root `.env` the validator used.
|
||||
* When the stack has one or more project env files configured (via the
|
||||
* project-env-files API), each file is resolved against the stack directory,
|
||||
* validated for containment and file type, and emitted as a repeated
|
||||
* `--env-file <absPath>` flag. Configured files apply to ALL stack types
|
||||
* (single-file, multi-file Git, non-Git).
|
||||
*
|
||||
* Scoped to the context-dir case on purpose: with no `--project-directory`, the
|
||||
* project directory stays the stack dir (the compose command's cwd) and Compose
|
||||
* auto-discovers the root `.env`, so single-file / no-context stacks need no flag
|
||||
* and keep their existing behavior. An explicit `--env-file` to a missing file
|
||||
* errors, so the flag is only added when a root `.env` actually exists.
|
||||
* When no project env files are configured, fall back to the legacy behavior:
|
||||
* pass `--env-file <stackDir>/.env` only for multi-file Git stacks whose
|
||||
* deploy spec sets a contextDir and whose root `.env` actually exists. This
|
||||
* preserves byte-identical behavior for existing stacks.
|
||||
*/
|
||||
export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: number): Promise<string[]> {
|
||||
const resolvedNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
|
||||
const db = DatabaseService.getInstance();
|
||||
const configuredFiles = db.getStackProjectEnvFiles(resolvedNodeId, stackName);
|
||||
|
||||
if (configuredFiles.length > 0) {
|
||||
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId));
|
||||
const stackDir = path.resolve(baseResolved, stackName);
|
||||
if (!stackDir.startsWith(baseResolved + path.sep)) return [];
|
||||
|
||||
const args: string[] = [];
|
||||
for (const file of configuredFiles) {
|
||||
if (!file || !isValidRelativeStackPath(file)) {
|
||||
throw new Error(`Invalid project env file path for stack "${stackName}": "${file}"`);
|
||||
}
|
||||
// Reject paths with directory separators: project env files live at the
|
||||
// stack root, matching Compose's auto-discovery behavior.
|
||||
if (file.includes('/') || file.includes('\\')) {
|
||||
throw new Error(
|
||||
`Project env file "${file}" for stack "${stackName}" must be at the stack root. ` +
|
||||
`Update the project env file selection in the Environment tab.`
|
||||
);
|
||||
}
|
||||
const envPath = path.resolve(stackDir, file);
|
||||
if (!isPathWithinBase(envPath, stackDir)) {
|
||||
throw new Error(`Project env file path escapes stack directory for stack "${stackName}": "${file}"`);
|
||||
}
|
||||
// Verify the real path stays within the stack directory, defending against
|
||||
// symlinks that were created or swapped after configuration.
|
||||
let realEnvPath: string;
|
||||
try {
|
||||
realEnvPath = await fsPromises.realpath(envPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`Project env file "${file}" configured for stack "${stackName}" is missing. ` +
|
||||
`Restore the file or update the project env file selection in the Environment tab.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!isPathWithinBase(realEnvPath, stackDir)) {
|
||||
throw new Error(
|
||||
`Project env file "${file}" for stack "${stackName}" resolves outside the stack directory. ` +
|
||||
`Update the project env file selection in the Environment tab.`
|
||||
);
|
||||
}
|
||||
const stat = await fsPromises.stat(realEnvPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(
|
||||
`Project env file "${file}" configured for stack "${stackName}" is not a regular file. ` +
|
||||
`Update the project env file selection in the Environment tab.`
|
||||
);
|
||||
}
|
||||
args.push('--env-file', realEnvPath);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// Legacy fallback: --env-file .env only for multi-file Git stacks with contextDir.
|
||||
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
|
||||
if (!spec || spec.files.length === 0 || !spec.contextDir) return [];
|
||||
|
||||
// Inline js/path-injection barrier at the fs sink: resolve against a known-safe
|
||||
// base and assert containment with startsWith right here. CodeQL does not credit
|
||||
// the wrapped isPathWithinBase helper or a check separated from the sink, matching
|
||||
// the inline guards in renderConfig and validateCompose. `.env` is a fixed name.
|
||||
// the inline guards in renderConfig and validateCompose.
|
||||
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId));
|
||||
const stackDir = path.resolve(baseResolved, stackName);
|
||||
if (!stackDir.startsWith(baseResolved + path.sep)) return [];
|
||||
@@ -91,10 +145,6 @@ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: num
|
||||
try {
|
||||
await fsPromises.access(envPath);
|
||||
} catch (err) {
|
||||
// A missing `.env` is the normal "nothing to pass" case. Any other error
|
||||
// (e.g. EACCES on an existing but unreadable `.env`) is a real fault: surface
|
||||
// it rather than silently dropping the flag and deploying a different effective
|
||||
// config than the one validated.
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ export function EditorView(props: EditorViewProps) {
|
||||
{activeTab === 'env' && (
|
||||
<div className="bg-brand/8 border-b border-brand/20 px-4 py-2 flex items-center gap-2 text-xs text-brand">
|
||||
<span>
|
||||
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
|
||||
Variables defined in the project environment file are available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Lock, Copy, Info, TriangleAlert, ShieldAlert } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Lock, Copy, Info, TriangleAlert, ShieldAlert, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import {
|
||||
buildEnvChecklistMarkdown,
|
||||
SOURCE_LABELS,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type EnvItemStatus,
|
||||
type EnvFileExistence,
|
||||
} from '@/lib/envChecklist';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
const ACTION_CLASS =
|
||||
@@ -91,39 +93,114 @@ const STATUS_GROUPS: { status: EnvItemStatus; label: string }[] = [
|
||||
];
|
||||
|
||||
export default function EnvironmentPanel({ stackName }: { stackName: string }) {
|
||||
const { activeNode } = useNodes();
|
||||
const { activeNode, hasCapability } = useNodes();
|
||||
const { can } = useAuth();
|
||||
const nodeId = activeNode?.id;
|
||||
const [inventory, setInventory] = useState<EnvInventory | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
// Project env file selection
|
||||
const projectEnvCapable = hasCapability('project-env-files');
|
||||
const canEdit = can('stack:edit', 'stack', stackName);
|
||||
const [projectEnvFiles, setProjectEnvFiles] = useState<string[]>([]);
|
||||
const [candidates, setCandidates] = useState<string[]>([]);
|
||||
const [savingProjectEnv, setSavingProjectEnv] = useState(false);
|
||||
|
||||
const fetchJsonEnvFiles = useCallback(async (suffix: string, setter: (v: string[]) => void) => {
|
||||
if (!projectEnvCapable) return;
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/project-env-files${suffix}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { envFiles: string[] };
|
||||
setter(data.envFiles);
|
||||
}
|
||||
} catch { /* silently skip on older nodes */ }
|
||||
}, [stackName, projectEnvCapable]);
|
||||
|
||||
const fetchProjectEnvFiles = useCallback(
|
||||
() => fetchJsonEnvFiles('', setProjectEnvFiles),
|
||||
[fetchJsonEnvFiles],
|
||||
);
|
||||
|
||||
const fetchCandidates = useCallback(
|
||||
() => fetchJsonEnvFiles('/candidates', setCandidates),
|
||||
[fetchJsonEnvFiles],
|
||||
);
|
||||
|
||||
const fetchInventory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/env-inventory`);
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the environment inventory.');
|
||||
return;
|
||||
}
|
||||
setInventory((await res.json()) as EnvInventory);
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the environment inventory.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stackName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/env-inventory`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the environment inventory.');
|
||||
return;
|
||||
}
|
||||
setInventory((await res.json()) as EnvInventory);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
toast.error('Failed to load the environment inventory.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
await fetchInventory();
|
||||
if (cancelled) return;
|
||||
await fetchProjectEnvFiles();
|
||||
if (cancelled) return;
|
||||
await fetchCandidates();
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId]);
|
||||
}, [stackName, nodeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveProjectEnvFiles = async (files: string[]) => {
|
||||
setSavingProjectEnv(true);
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/project-env-files`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ envFiles: files }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json() as { error?: string };
|
||||
toast.error(data.error ?? 'Failed to save project env file selection.');
|
||||
return;
|
||||
}
|
||||
setProjectEnvFiles(files);
|
||||
toast.success('Project env file selection saved.');
|
||||
// Refresh inventory to reflect new interpolation source.
|
||||
await fetchInventory();
|
||||
} catch {
|
||||
toast.error('Failed to save project env file selection.');
|
||||
} finally {
|
||||
setSavingProjectEnv(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addProjectEnvFile = (file: string) => {
|
||||
if (!file || projectEnvFiles.includes(file)) return;
|
||||
saveProjectEnvFiles([...projectEnvFiles, file]);
|
||||
};
|
||||
|
||||
const removeProjectEnvFile = (idx: number) => {
|
||||
saveProjectEnvFiles(projectEnvFiles.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const moveProjectEnvFile = (idx: number, dir: -1 | 1) => {
|
||||
const newFiles = [...projectEnvFiles];
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= newFiles.length) return;
|
||||
[newFiles[idx], newFiles[target]] = [newFiles[target], newFiles[idx]];
|
||||
saveProjectEnvFiles(newFiles);
|
||||
};
|
||||
|
||||
const copyChecklist = async () => {
|
||||
if (!inventory) return;
|
||||
@@ -154,11 +231,85 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-stat-subtitle">
|
||||
Compose reads <span className="font-mono">.env</span> and the shell for <span className="font-mono">{'${VAR}'}</span> interpolation,
|
||||
Compose reads the project environment file and the shell for <span className="font-mono">{'${VAR}'}</span> interpolation,
|
||||
while <span className="font-mono">env_file</span> and inline <span className="font-mono">environment</span> are injected into the container.
|
||||
Values are never read or shown: likely secrets show presence only.
|
||||
</p>
|
||||
|
||||
{projectEnvCapable && (
|
||||
<section data-testid="project-env-section">
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>project environment file</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-2">
|
||||
{projectEnvFiles.length === 0 ? (
|
||||
<p className="text-[11px] text-stat-subtitle py-1">
|
||||
Using <span className="font-mono">.env</span> (default). Compose auto-discovers <span className="font-mono">.env</span> in the project directory.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{projectEnvFiles.map((file, idx) => (
|
||||
<div key={file} className="flex items-center gap-1.5 py-1 border-t border-muted first:border-t-0">
|
||||
<span className="font-mono text-[12px] text-foreground/90 flex-1">{file}</span>
|
||||
{canEdit && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, -1)}
|
||||
disabled={idx === 0 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveProjectEnvFile(idx, 1)}
|
||||
disabled={idx === projectEnvFiles.length - 1 || savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProjectEnvFile(idx)}
|
||||
disabled={savingProjectEnv}
|
||||
className="p-0.5 text-stat-subtitle hover:text-destructive disabled:opacity-30"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && candidates.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-muted">
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={addProjectEnvFile}
|
||||
disabled={savingProjectEnv}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[11px] bg-muted border-none w-full">
|
||||
<SelectValue placeholder="Add env file…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates
|
||||
.filter(f => !projectEnvFiles.includes(f))
|
||||
.map(file => (
|
||||
<SelectItem key={file} value={file} className="text-[11px]">
|
||||
{file}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<ShieldAlert className="h-4 w-4 text-destructive" strokeWidth={1.5} />
|
||||
|
||||
@@ -6,7 +6,8 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' }, hasCapability: () => false }) }));
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => false }) }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
@@ -27,6 +27,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'project-env-files',
|
||||
'compose-storage',
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user