feat(files): per-stack file explorer (#780)

* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
This commit is contained in:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
+126
View File
@@ -35,6 +35,7 @@
"isomorphic-git": "^1.37.5",
"jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.7",
"multer": "^2.1.1",
"node-pty": "^1.1.0",
"openid-client": "^6.8.2",
"otplib": "^12.0.1",
@@ -51,6 +52,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^1.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.3.0",
"@types/supertest": "^7.2.0",
"@types/yaml": "^1.9.6",
@@ -2219,6 +2221,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
@@ -2800,6 +2812,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -3056,6 +3074,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -3065,6 +3089,17 @@
"node": ">=10.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -3323,6 +3358,21 @@
"node": ">= 0.6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -5505,6 +5555,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nan": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
@@ -6534,6 +6646,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/strict-event-emitter-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
@@ -6949,6 +7069,12 @@
"node": ">= 0.4"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+2
View File
@@ -25,6 +25,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^1.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^25.3.0",
"@types/supertest": "^7.2.0",
"@types/yaml": "^1.9.6",
@@ -62,6 +63,7 @@
"isomorphic-git": "^1.37.5",
"jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.7",
"multer": "^2.1.1",
"node-pty": "^1.1.0",
"openid-client": "^6.8.2",
"otplib": "^12.0.1",
@@ -0,0 +1,64 @@
/**
* Unit tests for isBinaryBuffer from utils/binaryDetect.ts.
*
* The function uses byte-range sampling to classify buffers:
* - Empty buffers are text (returns false)
* - Any NUL byte triggers immediate binary detection
* - More than 30% non-printable bytes in the sample triggers binary detection
* - The sampleBytes parameter limits how many bytes are examined
*/
import { describe, it, expect } from 'vitest';
import { isBinaryBuffer } from '../utils/binaryDetect';
describe('isBinaryBuffer', () => {
it('returns false for empty buffer', () => {
expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
});
it('returns false for plain ASCII text', () => {
const buf = Buffer.from('hello world\nfoo: bar\n');
expect(isBinaryBuffer(buf)).toBe(false);
});
it('returns false for YAML content', () => {
const yaml = Buffer.from('services:\n app:\n image: nginx:latest\n');
expect(isBinaryBuffer(yaml)).toBe(false);
});
it('returns true when NUL byte present', () => {
const buf = Buffer.from([0x68, 0x65, 0x6c, 0x00, 0x6c, 0x6f]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns true for PNG header (binary)', () => {
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(100, 0xff)]);
expect(isBinaryBuffer(png)).toBe(true);
});
it('returns true when >30% bytes are non-printable', () => {
// 101 bytes: 70 printable 'a' (0x61) + 31 non-printable (0x01)
// ratio = 31/101 > 0.30
const buf = Buffer.concat([Buffer.alloc(31, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns false when exactly 30% are non-printable', () => {
// 100 bytes: 70 printable + 30 non-printable (0x01, not NUL)
// ratio = 30/100 = 0.30, which is NOT > 0.30, so should return false
const buf = Buffer.concat([Buffer.alloc(30, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(false);
});
it('respects sampleBytes=0 (empty sample returns false)', () => {
// sampleBytes=0 means subarray(0,0) which is empty, so no bytes to check
const buf = Buffer.from([0x00, 0x61, 0x61]);
expect(isBinaryBuffer(buf, 0)).toBe(false);
});
it('only samples the first N bytes', () => {
// First byte is printable 'a', second is NUL — only sample 1 byte, so no NUL detected
const buf = Buffer.from([0x61, 0x00, 0x61]);
expect(isBinaryBuffer(buf, 1)).toBe(false);
});
});
@@ -0,0 +1,332 @@
/**
* Tests for isValidRelativeStackPath (pure function) and the stack-scoped
* file methods on FileSystemService (listStackDirectory, readStackFile,
* writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath).
*
* FileSystemService stack methods are tested against a real temp directory so
* that realpath, stat, and fs I/O all run with actual OS semantics.
* NodeRegistry is mocked to redirect the composeDir to our temp location.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { isValidRelativeStackPath } from '../utils/validation';
// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR.
// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS).
// Skip those specific cases on Windows.
const isWindows = process.platform === 'win32';
// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it
// before any FileSystemService method runs.
const mockState = { composeDir: '' };
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => mockState.composeDir,
getDefaultNodeId: () => 1,
}),
},
}));
vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false }));
import { FileSystemService } from '../services/FileSystemService';
// ── isValidRelativeStackPath ──────────────────────────────────────────────────
describe('isValidRelativeStackPath', () => {
// Accepted inputs
it('accepts empty string (stack root)', () => expect(isValidRelativeStackPath('')).toBe(true));
it('accepts simple filename', () => expect(isValidRelativeStackPath('compose.yaml')).toBe(true));
it('accepts dotfile', () => expect(isValidRelativeStackPath('.env')).toBe(true));
it('accepts nested path', () => expect(isValidRelativeStackPath('config/app.conf')).toBe(true));
it('accepts deeply nested path', () => expect(isValidRelativeStackPath('a/b/c/d.txt')).toBe(true));
// Rejected inputs
it('rejects ..', () => expect(isValidRelativeStackPath('..')).toBe(false));
it('rejects ../etc/passwd traversal', () => expect(isValidRelativeStackPath('../etc/passwd')).toBe(false));
it('rejects a/../b', () => expect(isValidRelativeStackPath('a/../b')).toBe(false));
it('rejects absolute path', () => expect(isValidRelativeStackPath('/etc/passwd')).toBe(false));
it('rejects Windows drive path', () => expect(isValidRelativeStackPath('C:/windows')).toBe(false));
it('rejects NUL byte', () => expect(isValidRelativeStackPath('file\x00name')).toBe(false));
it('rejects backslash', () => expect(isValidRelativeStackPath('path\\file')).toBe(false));
it('rejects double-slash', () => expect(isValidRelativeStackPath('a//b')).toBe(false));
it('rejects bare dot segment', () => expect(isValidRelativeStackPath('a/./b')).toBe(false));
});
// ── FileSystemService stack methods ──────────────────────────────────────────
describe('FileSystemService stack methods', () => {
const STACK = 'mystack';
let tmpBase: string;
let stackDir: string;
beforeEach(async () => {
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsp-'));
stackDir = path.join(tmpBase, STACK);
mockState.composeDir = tmpBase;
await fs.mkdir(stackDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(tmpBase, { recursive: true, force: true });
});
// ── listStackDirectory ──────────────────────────────────────────────────
describe('listStackDirectory', () => {
it('returns entries with directories sorted before files', async () => {
await fs.mkdir(path.join(stackDir, 'config'));
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
await fs.writeFile(path.join(stackDir, '.env'), 'KEY=val\n');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
// Directories come first
expect(entries[0].type).toBe('directory');
expect(entries[0].name).toBe('config');
// Files follow, sorted alphabetically (case-insensitive)
const fileNames = entries.filter(e => e.type === 'file').map(e => e.name);
expect(fileNames).toEqual(['.env', 'compose.yaml']);
});
it('marks compose.yaml and .env as protected', async () => {
await fs.writeFile(path.join(stackDir, 'compose.yaml'), '');
await fs.writeFile(path.join(stackDir, '.env'), '');
await fs.writeFile(path.join(stackDir, 'custom.conf'), '');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
const byName = Object.fromEntries(entries.map(e => [e.name, e]));
expect(byName['compose.yaml'].isProtected).toBe(true);
expect(byName['.env'].isProtected).toBe(true);
expect(byName['custom.conf'].isProtected).toBe(false);
});
it('includes size and mtime for files', async () => {
await fs.writeFile(path.join(stackDir, 'test.txt'), 'hello');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
const file = entries.find(e => e.name === 'test.txt');
expect(file).toBeDefined();
expect(file!.size).toBe(5);
expect(file!.mtime).toBeGreaterThan(0);
});
it('returns empty array for an empty stack directory', async () => {
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, '');
expect(entries).toEqual([]);
});
it('lists a subdirectory when relPath is provided', async () => {
await fs.mkdir(path.join(stackDir, 'sub'));
await fs.writeFile(path.join(stackDir, 'sub', 'child.txt'), 'data');
const service = FileSystemService.getInstance();
const entries = await service.listStackDirectory(STACK, 'sub');
expect(entries.length).toBe(1);
expect(entries[0].name).toBe('child.txt');
});
});
// ── readStackFile ───────────────────────────────────────────────────────
describe('readStackFile', () => {
it('returns text content for a UTF-8 file', async () => {
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
const service = FileSystemService.getInstance();
const result = await service.readStackFile(STACK, 'compose.yaml');
expect(result.binary).toBe(false);
expect(result.oversized).toBe(false);
expect(result.content).toBe('services: {}\n');
});
it('returns binary:true and no content for a binary file', async () => {
// PNG magic bytes followed by non-printable data
const pngMagic = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(50, 0xff),
]);
await fs.writeFile(path.join(stackDir, 'icon.png'), pngMagic);
const service = FileSystemService.getInstance();
const result = await service.readStackFile(STACK, 'icon.png');
expect(result.binary).toBe(true);
expect(result.oversized).toBe(false);
expect(result.content).toBeUndefined();
});
it('returns oversized:true for files exceeding maxBytes', async () => {
// Write a text file larger than our small maxBytes limit
await fs.writeFile(path.join(stackDir, 'big.txt'), 'a'.repeat(200));
const service = FileSystemService.getInstance();
// maxBytes=100 forces the oversized path
const result = await service.readStackFile(STACK, 'big.txt', 100);
expect(result.oversized).toBe(true);
expect(result.size).toBe(200);
});
it('throws IS_DIRECTORY when path points to a directory', async () => {
await fs.mkdir(path.join(stackDir, 'subdir'));
const service = FileSystemService.getInstance();
await expect(service.readStackFile(STACK, 'subdir')).rejects.toMatchObject({ code: 'IS_DIRECTORY' });
});
});
// ── writeStackFile ──────────────────────────────────────────────────────
describe('writeStackFile', () => {
it('creates a new file with the given content', async () => {
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'new.txt', 'hello world');
const content = await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8');
expect(content).toBe('hello world');
});
it('overwrites an existing file', async () => {
await fs.writeFile(path.join(stackDir, 'data.txt'), 'original');
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'data.txt', 'updated');
const content = await fs.readFile(path.join(stackDir, 'data.txt'), 'utf-8');
expect(content).toBe('updated');
});
it('creates parent directories if they do not exist', async () => {
const service = FileSystemService.getInstance();
await service.writeStackFile(STACK, 'deep/nested/file.txt', 'content');
const content = await fs.readFile(path.join(stackDir, 'deep', 'nested', 'file.txt'), 'utf-8');
expect(content).toBe('content');
});
});
// ── writeStackFileBuffer ────────────────────────────────────────────────
describe('writeStackFileBuffer', () => {
it('writes raw bytes correctly', async () => {
const data = Buffer.from([0x01, 0x02, 0x03, 0xff]);
const service = FileSystemService.getInstance();
await service.writeStackFileBuffer(STACK, 'binary.bin', data);
const read = await fs.readFile(path.join(stackDir, 'binary.bin'));
expect(read).toEqual(data);
});
it('creates parent directories when needed', async () => {
const payload = Buffer.from([0xde, 0xad]);
const service = FileSystemService.getInstance();
await service.writeStackFileBuffer(STACK, 'sub/img.bin', payload);
const read = await fs.readFile(path.join(stackDir, 'sub', 'img.bin'));
expect(read).toEqual(payload);
});
});
// ── deleteStackPath ─────────────────────────────────────────────────────
describe('deleteStackPath', () => {
it('deletes a file', async () => {
await fs.writeFile(path.join(stackDir, 'todelete.txt'), '');
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'todelete.txt');
await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => {
await fs.mkdir(path.join(stackDir, 'emptydir'));
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'emptydir');
await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => {
await fs.mkdir(path.join(stackDir, 'nonempty'));
await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), '');
const service = FileSystemService.getInstance();
await expect(service.deleteStackPath(STACK, 'nonempty', false)).rejects.toMatchObject({ code: 'NOT_EMPTY' });
});
it('recursively deletes a non-empty directory when recursive=true', async () => {
await fs.mkdir(path.join(stackDir, 'tree'));
await fs.writeFile(path.join(stackDir, 'tree', 'child.txt'), '');
const service = FileSystemService.getInstance();
await service.deleteStackPath(STACK, 'tree', true);
await expect(fs.access(path.join(stackDir, 'tree'))).rejects.toMatchObject({ code: 'ENOENT' });
});
});
// ── mkdirStackPath ──────────────────────────────────────────────────────
describe('mkdirStackPath', () => {
it('creates a new directory', async () => {
const service = FileSystemService.getInstance();
await service.mkdirStackPath(STACK, 'newdir');
const stat = await fs.stat(path.join(stackDir, 'newdir'));
expect(stat.isDirectory()).toBe(true);
});
it('creates nested directories', async () => {
const service = FileSystemService.getInstance();
await service.mkdirStackPath(STACK, 'a/b/c');
const stat = await fs.stat(path.join(stackDir, 'a', 'b', 'c'));
expect(stat.isDirectory()).toBe(true);
});
it('does not throw when directory already exists', async () => {
await fs.mkdir(path.join(stackDir, 'existing'));
const service = FileSystemService.getInstance();
await expect(service.mkdirStackPath(STACK, 'existing')).resolves.toBeUndefined();
});
});
// ── path traversal ──────────────────────────────────────────────────────
describe('path traversal protection', () => {
it('throws INVALID_PATH when relPath escapes stack directory via ..', async () => {
// isValidRelativeStackPath rejects ".." before it reaches the service,
// but we also test the service-level guard with a stack name that would
// escape the compose dir (isPathWithinBase check in resolveSafeStackPath).
const service = FileSystemService.getInstance();
await expect(service.listStackDirectory('..', '')).rejects.toMatchObject({ code: 'INVALID_PATH' });
});
it('throws INVALID_PATH for a stack name with path separator', async () => {
const service = FileSystemService.getInstance();
await expect(service.readStackFile('../other', 'file.txt')).rejects.toMatchObject({ code: 'INVALID_PATH' });
});
it.skipIf(isWindows)('throws SYMLINK_ESCAPE when a symlink inside the stack points outside it (Linux/macOS only)', async () => {
const externalFile = path.join(tmpBase, 'secret.txt');
await fs.writeFile(externalFile, 'secret');
await fs.symlink(externalFile, path.join(stackDir, 'escape-link'));
const service = FileSystemService.getInstance();
await expect(service.readStackFile(STACK, 'escape-link')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
});
});
});
@@ -0,0 +1,447 @@
/**
* Route-level tests for the stack file explorer endpoints:
* GET /:stackName/files
* GET /:stackName/files/content
* GET /:stackName/files/download (Skipper+)
* POST /:stackName/files/upload (Skipper+)
* PUT /:stackName/files/content (Skipper+)
* DELETE /:stackName/files (Skipper+)
* POST /:stackName/files/folder (Skipper+)
*
* Covers: auth gating, tier gating (Community vs paid), input validation,
* upload size limit, and happy-path 204/200 responses.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { promises as fs } from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
// On Windows, fs.unlink on a directory returns EPERM instead of EISDIR so the
// NOT_EMPTY code path in deleteStackPath is never reached. Skip that test case
// on Windows; it is covered on Linux (CI).
const isWindows = process.platform === 'win32';
let tmpDir: string;
let app: import('express').Express;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
let stacksDir: string;
const STACK = 'teststack';
beforeAll(async () => {
tmpDir = await setupTestDb();
stacksDir = process.env.COMPOSE_DIR!;
// Create stack directory so file operations have something to work with
await fs.mkdir(path.join(stacksDir, STACK), { recursive: true });
await fs.writeFile(path.join(stacksDir, STACK, 'compose.yaml'), 'services: {}\n');
await fs.writeFile(path.join(stacksDir, STACK, '.env'), 'KEY=val\n');
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
// Default: paid tier so most tests pass the tier gate
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'files-viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'files-viewer', password: 'viewerpass' });
const viewerCookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(viewerCookies) ? viewerCookies[0] : viewerCookies;
});
afterAll(async () => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
// Restore all spies then re-establish the paid-tier default so per-test
// overrides via mockReturnValueOnce don't accumulate across tests.
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
// ── GET /:stackName/files ─────────────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/files`);
expect(res.status).toBe(401);
});
it('returns 200 with entries array for authenticated admin', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('includes compose.yaml and .env in the listing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
const names = res.body.map((e: { name: string }) => e.name);
expect(names).toContain('compose.yaml');
expect(names).toContain('.env');
});
it('returns 400 for an invalid stack name containing path traversal', async () => {
const res = await request(app)
.get('/api/stacks/../evil/files')
.set('Cookie', adminCookie);
// Express may normalise the URL before it reaches the handler;
// the important thing is we never get 200
expect([400, 404]).toContain(res.status);
});
it('returns 400 for a stack name with special characters', async () => {
const res = await request(app)
.get('/api/stacks/my%20stack/files')
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
});
// ── GET /:stackName/files/content ─────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
it('returns 200 with file content for an existing text file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.binary).toBe(false);
expect(res.body.oversized).toBe(false);
expect(typeof res.body.content).toBe('string');
});
it('returns 404 for a non-existent file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'nonexistent.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(404);
});
it('returns oversized:true for a file larger than the 2 MB read limit', async () => {
const bigPath = path.join(stacksDir, STACK, 'oversized.txt');
await fs.writeFile(bigPath, 'x'.repeat(3 * 1024 * 1024));
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'oversized.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.oversized).toBe(true);
expect(res.body.binary).toBe(false);
expect(res.body.content).toBeUndefined();
await fs.unlink(bigPath);
}, 15000);
});
// ── GET /:stackName/files/download ────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/download', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('streams the file for a paid tier user', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toMatch(/attachment/);
expect(res.text).toContain('services');
});
});
// ── POST /:stackName/files/upload ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/upload', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('returns 400 when no file is attached', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => {
// 26 MB buffer
const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61);
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', bigFile, 'toobig.txt');
expect(res.status).toBe(413);
expect(res.body.code).toBe('TOO_LARGE');
}, 20000);
it('returns 204 for a valid file upload (paid tier)', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('uploaded content'), 'uploaded.txt');
expect(res.status).toBe(204);
// Verify the file was written
const content = await fs.readFile(path.join(stacksDir, STACK, 'uploaded.txt'), 'utf-8');
expect(content).toBe('uploaded content');
});
it('returns 204 and writes into a subdirectory when ?path= is provided', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.query({ path: 'subdir' })
.set('Cookie', adminCookie)
.attach('file', Buffer.from('subdir content'), 'sub.txt');
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8');
expect(content).toBe('subdir content');
});
});
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
describe('PUT /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.send({ content: 'hello' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('returns 400 when content is not a string', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 42 });
expect(res.status).toBe(400);
});
it('returns 204 and writes the file for a paid tier admin', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'written.txt' })
.set('Cookie', adminCookie)
.send({ content: 'written via PUT' });
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8');
expect(content).toBe('written via PUT');
});
});
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
describe('DELETE /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 on successful file deletion', async () => {
// Create a disposable file first
await fs.writeFile(path.join(stacksDir, STACK, 'todelete.txt'), 'bye');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'todelete.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(path.join(stacksDir, STACK, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('returns 409 NOT_EMPTY when deleting a non-empty directory without recursive flag (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'nonemptydir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), '');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'nonemptydir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.code).toBe('NOT_EMPTY');
});
it.skipIf(isWindows)('returns 204 and removes a non-empty directory when recursive=1 (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'recursivedir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), 'data');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'recursivedir', recursive: '1' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(dirPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
// ── POST /:stackName/files/folder ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/folder', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 and creates the directory for a paid tier admin', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'mynewdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
const stat = await fs.stat(path.join(stacksDir, STACK, 'mynewdir'));
expect(stat.isDirectory()).toBe(true);
});
});
// ── permission gating ─────────────────────────────────────────────────────────
describe('permission gating', () => {
it('viewer receives 403 from PUT /files/content', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', viewerCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/upload', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', viewerCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('viewer receives 403 from DELETE /files', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/folder', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'somedir' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
});
+194 -2
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import { Router, type Request, type Response, type NextFunction } from 'express';
import path from 'path';
import YAML from 'yaml';
import multer from 'multer';
import { FileSystemService } from '../services/FileSystemService';
import { ComposeService } from '../services/ComposeService';
import DockerController from '../services/DockerController';
@@ -13,7 +14,7 @@ import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { requirePermission } from '../middleware/permissions';
import { requirePaid, requireAdmin } from '../middleware/tierGates';
import { NotificationService } from '../services/NotificationService';
import { isValidStackName, isValidServiceName, isPathWithinBase } from '../utils/validation';
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sendGitSourceError } from '../utils/gitSourceHttp';
@@ -100,6 +101,15 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
}
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 25 * 1024 * 1024, files: 1 },
});
function getRelPath(req: Request): string {
return typeof req.query.path === 'string' ? req.query.path : '';
}
export const stacksRouter = Router();
stacksRouter.get('/', async (req: Request, res: Response) => {
@@ -847,3 +857,185 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => {
res.status(500).json({ error: message });
}
});
// ── File explorer endpoints ──
type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE';
function sendFsError(
res: Response,
err: unknown,
fallback: string,
opts: { notFoundMessage?: string } = {},
): Response {
const e = err as NodeJS.ErrnoException & { code?: string };
if (e.code === 'INVALID_PATH' || e.code === 'SYMLINK_ESCAPE') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'IS_DIRECTORY') {
return res.status(400).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'NOT_EMPTY') {
return res.status(409).json({ error: e.message, code: e.code as FsErrorCode });
}
if (e.code === 'ENOENT') {
return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' });
}
console.error(`[files] ${fallback}:`, e.message);
return res.status(500).json({ error: fallback });
}
stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath);
return res.json(entries);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to list directory');
}
});
stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
return res.json(result);
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to read file');
}
});
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath);
res.setHeader('Content-Type', result.mime);
res.setHeader('Content-Length', result.size);
const encodedFilename = encodeURIComponent(result.filename);
const safeFilename = result.filename.replace(/[\\"]/g, '');
res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`);
result.stream.on('error', (streamErr) => {
console.error('[files] stream error:', streamErr);
if (!res.headersSent) res.status(500).end();
else res.destroy();
});
req.on('close', () => result.stream.destroy());
result.stream.pipe(res);
return;
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to download file');
}
});
stacksRouter.post(
'/:stackName/files/upload',
(req: Request, res: Response, next: NextFunction) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePaid(req, res)) return;
upload.single('file')(req, res, (err) => {
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
}
if (err) return res.status(500).json({ error: 'Upload failed' });
next();
});
},
async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const safeName = path.basename(req.file.originalname);
if (!safeName || safeName === '.' || safeName === '..') {
return res.status(400).json({ error: 'Invalid filename' });
}
const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName;
try {
await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' });
}
},
);
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath !== '' && !isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const { content } = req.body as { content?: unknown };
if (typeof content !== 'string') {
return res.status(400).json({ error: '"content" must be a string' });
}
try {
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to write file');
}
});
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required for delete' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
const recursive = req.query.recursive === '1';
try {
await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to delete path');
}
});
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
if (!requirePaid(req, res)) return;
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) return res.status(400).json({ error: 'Invalid stack name' });
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
const relPath = getRelPath(req);
if (relPath === '') return res.status(400).json({ error: 'Path is required to create a folder' });
if (!isValidRelativeStackPath(relPath)) {
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
}
try {
await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath);
return res.status(204).send();
} catch (err: unknown) {
return sendFsError(res, err, 'Failed to create folder');
}
});
+242 -1
View File
@@ -1,6 +1,17 @@
import path from 'path';
import { promises as fsPromises } from 'fs';
import { promises as fsPromises, createReadStream } from 'fs';
import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
export interface FileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: number;
isProtected: boolean;
}
/**
* Resolves the writable Sencho data directory (same one DatabaseService /
@@ -14,6 +25,22 @@ function getBackupBaseDir(): string {
import { isDebugEnabled } from '../utils/debug';
const PROTECTED_STACK_FILES = new Set([
'compose.yaml',
'compose.yml',
'docker-compose.yaml',
'docker-compose.yml',
'.env',
]);
const MIME_MAP: Record<string, string> = {
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
'.json': 'application/json',
'.sh': 'text/x-sh',
'.env': 'text/plain',
};
/**
* FileSystemService - local-only file I/O for compose stack management.
*
@@ -320,4 +347,218 @@ export class FileSystemService {
return { exists: false, timestamp: null };
}
}
// ---------------------------------------------------------------------------
// Stack-scoped file explorer methods
// ---------------------------------------------------------------------------
private guessMime(filePath: string): string {
if (path.basename(filePath) === '.env') return 'text/plain';
const ext = path.extname(filePath).toLowerCase();
return MIME_MAP[ext] ?? 'text/plain';
}
private async resolveSafeStackPath(stackName: string, relPath: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
if (!isPathWithinBase(stackDir, this.baseDir)) {
throw Object.assign(new Error('Stack name escapes compose directory'), { code: 'INVALID_PATH' });
}
const target = relPath === '' ? stackDir : path.resolve(stackDir, relPath);
if (!isPathWithinBase(target, stackDir)) {
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
}
let realTarget: string;
try {
realTarget = await fsPromises.realpath(target);
} catch (err: unknown) {
const fsErr = err as NodeJS.ErrnoException;
if (fsErr.code !== 'ENOENT') throw err;
// Walk up to the deepest existing ancestor, then reattach the suffix.
let existing = target;
const suffix: string[] = [];
while (true) {
const parent = path.dirname(existing);
if (parent === existing) {
// Reached filesystem root without finding an existing path.
throw Object.assign(new Error('Path escapes stack directory'), { code: 'INVALID_PATH' });
}
suffix.unshift(path.basename(existing));
existing = parent;
try {
const realExisting = await fsPromises.realpath(existing);
if (!isPathWithinBase(realExisting, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
}
realTarget = path.join(realExisting, ...suffix);
break;
} catch (innerErr: unknown) {
const innerFsErr = innerErr as NodeJS.ErrnoException;
if (innerFsErr.code !== 'ENOENT') throw innerErr;
// Continue walking up.
}
}
}
if (!isPathWithinBase(realTarget, stackDir)) {
throw Object.assign(new Error('Symlink escapes stack directory'), { code: 'SYMLINK_ESCAPE' });
}
return realTarget;
}
async listStackDirectory(stackName: string, relPath: string): Promise<FileEntry[]> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const dirents = await fsPromises.readdir(safePath, { withFileTypes: true });
const entries = await Promise.all(
dirents.map(async (dirent): Promise<FileEntry> => {
const entryPath = path.join(safePath, dirent.name);
let size = 0;
let mtime = 0;
try {
const st = await fsPromises.stat(entryPath);
size = dirent.isDirectory() ? 0 : st.size;
mtime = st.mtimeMs;
} catch {
// stat can fail for broken symlinks; use defaults.
}
const type: FileEntry['type'] = dirent.isDirectory()
? 'directory'
: dirent.isSymbolicLink()
? 'symlink'
: 'file';
return {
name: dirent.name,
type,
size,
mtime,
isProtected: PROTECTED_STACK_FILES.has(dirent.name),
};
})
);
return entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
async readStackFile(
stackName: string,
relPath: string,
maxBytes: number = 2 * 1024 * 1024
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
const mime = this.guessMime(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
if (stat.size > maxBytes) {
const fd = await fsPromises.open(safePath, 'r');
const probe = Buffer.allocUnsafe(8192);
const { bytesRead } = await fd.read(probe, 0, 8192, 0);
await fd.close();
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
return { binary, oversized: true, size: stat.size, mime };
}
const buf = await fsPromises.readFile(safePath);
if (isBinaryBuffer(buf)) {
return { binary: true, oversized: false, size: stat.size, mime };
}
return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') };
}
async streamStackFile(
stackName: string,
relPath: string
): Promise<{ stream: Readable; size: number; filename: string; mime: string }> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
const stat = await fsPromises.stat(safePath);
if (stat.isDirectory()) {
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
}
return {
stream: createReadStream(safePath),
size: stat.size,
filename: path.basename(safePath),
mime: this.guessMime(safePath),
};
}
async writeStackFile(stackName: string, relPath: string, content: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, content, 'utf-8');
}
async writeStackFileBuffer(stackName: string, relPath: string, buffer: Buffer): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
await fsPromises.writeFile(safePath, buffer);
}
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
if (recursive) {
await fsPromises.rm(safePath, { recursive: true, force: true });
return;
}
try {
await fsPromises.unlink(safePath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EISDIR') {
try {
await fsPromises.rmdir(safePath);
} catch (inner: unknown) {
const ie = inner as NodeJS.ErrnoException;
if (ie.code === 'ENOTEMPTY' || ie.code === 'EEXIST') {
throw Object.assign(new Error('Directory is not empty'), { code: 'NOT_EMPTY' });
}
throw inner;
}
} else {
throw err;
}
}
}
async mkdirStackPath(stackName: string, relPath: string): Promise<void> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
await fsPromises.mkdir(safePath, { recursive: true });
}
async statStackEntry(stackName: string, relPath: string): Promise<FileEntry> {
const safePath = await this.resolveSafeStackPath(stackName, relPath);
// Use lstat so symlinks are reported as 'symlink' rather than resolved to target type.
const stat = await fsPromises.lstat(safePath);
const name = path.basename(safePath);
const type: FileEntry['type'] = stat.isDirectory()
? 'directory'
: stat.isSymbolicLink()
? 'symlink'
: 'file';
return {
name,
type,
size: stat.isDirectory() ? 0 : stat.size,
mtime: stat.mtimeMs,
isProtected: PROTECTED_STACK_FILES.has(name),
};
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Heuristic binary detection based on byte-range sampling.
* Printable bytes: 0x09-0x0D (tab, LF, VT, FF, CR) and 0x20-0x7E.
* Any NUL byte is an immediate binary signal (text editors never produce them).
* If more than 30% of sampled bytes are non-printable the buffer is treated as binary.
*/
export function isBinaryBuffer(buf: Buffer, sampleBytes = 8192): boolean {
if (buf.length === 0) return false;
const sample = buf.subarray(0, sampleBytes);
let nonPrintable = 0;
for (let i = 0; i < sample.length; i++) {
const b = sample[i];
if (b === 0x00) return true;
const isPrintable = (b >= 0x09 && b <= 0x0d) || (b >= 0x20 && b <= 0x7e);
if (!isPrintable) nonPrintable++;
}
return nonPrintable / sample.length > 0.3;
}
+15
View File
@@ -79,6 +79,21 @@ export function isValidDockerResourceId(id: string): boolean {
export const isValidServiceName = (name: string): boolean =>
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name);
/**
* Validates a relative path supplied by the client for stack file operations.
* An empty string is allowed (it means the stack root directory).
* Rejects anything that could escape the stack directory or cause OS-level issues.
*/
export function isValidRelativeStackPath(rel: string): boolean {
if (rel === '') return true;
if (rel.includes('\0')) return false;
if (rel.includes('\\')) return false;
if (/^[a-zA-Z]:/.test(rel) || rel.startsWith('/')) return false;
if (rel.includes('//')) return false;
const segments = rel.split('/');
return !segments.some(seg => seg === '..' || seg === '.');
}
/**
* Asserts that a resolved file path stays within a given base directory.
* Returns true if the path is safe, false if it escapes the base.
+1
View File
@@ -99,6 +99,7 @@
"features/sidebar",
"features/stack-management",
"features/editor",
"features/stack-file-explorer",
"features/deploy-progress",
"features/resources",
"features/app-store",
+6
View File
@@ -9,6 +9,12 @@ Selecting a stack opens the editor view, a two-column layout. The left column sh
<img src="/images/editor/editor-overview.png" alt="Editor view showing command center on the left and Monaco editor on the right" />
</Frame>
## Files tab
The **Files** tab gives you a browseable directory tree for everything inside the stack's folder. Community users can view text files in read-only mode. Skipper and above can upload, download, edit and save, create folders, and delete files. Protected files (`compose.yaml`, `.env`, and their variants) redirect to their dedicated editor tabs when clicked.
For full details on permissions, viewing limits, upload caps, and troubleshooting, see the [Stack File Explorer](/features/stack-file-explorer) guide.
## Compose file editor
The right panel shows your `compose.yaml` with full syntax highlighting. By default the editor is **read-only** to prevent accidental changes.
+104
View File
@@ -0,0 +1,104 @@
---
title: Stack File Explorer
description: Browse, edit, upload, and manage files inside a stack's directory from the dashboard.
---
The file explorer gives you direct access to everything inside a stack's directory: configuration files, certificates, scripts, static assets, and any other files your containers depend on. It lives on the **Files** tab inside the stack editor.
<Note>
The explorer is scoped to the stack's own directory. You cannot browse other stacks or navigate above the stack root.
</Note>
## Opening the file explorer
Select any stack in the sidebar, then click the **Files** tab in the editor. The left panel shows the directory tree; clicking a file opens it in the viewer on the right.
## Tiers at a glance
<CardGroup cols={2}>
<Card title="Community" icon="eye">
Browse the directory tree and view text files in read-only mode.
</Card>
<Card title="Skipper" icon="pencil">
Full read/write access: upload, download, edit and save, create folders, and delete files or directories. Admiral tier includes the same access.
</Card>
</CardGroup>
## Browsing the directory tree
The tree lists all files and folders in the stack directory. Folders are sorted before files, and items within each group are sorted alphabetically.
Click a folder to expand or collapse it. Click a file to open it in the viewer panel on the right.
### Protected files
`compose.yaml`, `docker-compose.yaml`, `docker-compose.yml`, `.env`, and any `.env.*` variants are shown in the tree, but clicking them does not open the generic file viewer. Instead, Sencho navigates to the dedicated **Compose** or **Env** tab editor for that file. This ensures you always use the purpose-built editor with save-and-deploy controls for your main stack files.
## Viewing files
When you click a text file, its contents appear in the editor panel on the right.
**Viewing limits:**
| File type | Behaviour |
|-----------|-----------|
| Text file up to 2 MB | Displayed inline with syntax highlighting. |
| Text file over 2 MB | "File is too large to preview" message with a **Download** button (Skipper+). |
| Binary file | "Binary file detected" message with a **Download** button (Skipper+). |
On Community, the Download button is hidden for files that exceed the preview limit. To access large or binary files from the Community tier, use the host terminal or `docker cp` from your server.
## Editing and saving (Skipper+)
Text files open in read-only mode by default. Click **Edit** in the toolbar to enable editing. The editor accepts plain text input.
When you're done, click **Save** to write the file to disk. Unsaved changes are discarded when you navigate away or close the tab.
<Warning>
Editing a file does not restart any containers. If your stack reads the file at runtime (e.g. a config file mounted as a volume), restart the relevant service after saving for the change to take effect.
</Warning>
## Uploading files (Skipper+)
Click **Upload** in the file explorer toolbar, then select one or more files from your local machine. Files are uploaded into the currently selected directory.
**Limits:**
- Maximum 25 MB per file.
- Files larger than 25 MB are rejected with an error. Split large archives or use `scp` / `rsync` for bulk transfers.
If a file with the same name already exists in the target directory, it is overwritten.
## Downloading files (Skipper+)
Click the **Download** button in the file toolbar (for the currently open file) or right-click a file in the tree and choose **Download**. Files are streamed directly to your browser. The download limit is 100 MB per file.
<Tip>
For files larger than 100 MB, use `docker cp`, `scp`, or mount an export volume and copy from there.
</Tip>
## Creating folders (Skipper+)
Click the **New Folder** button in the tree toolbar, or right-click an existing folder and choose **New Folder**. Enter a name and confirm. The new folder is created immediately and appears in the tree.
Folder names follow the same rules as filenames on your host OS. Avoid leading dots unless you intend a hidden directory.
## Deleting files and folders (Skipper+)
Right-click a file or folder in the tree and choose **Delete**. A confirmation prompt appears before anything is removed.
**Deleting a folder** removes the folder and all of its contents recursively. The confirmation dialog lists the path so you can verify before proceeding.
<Warning>
Deletions are permanent. Sencho does not keep a trash bin or undo history for file operations.
</Warning>
## Troubleshooting
| Symptom | Cause | Resolution |
|---------|-------|------------|
| File shows as "Binary file detected" but it is a text file | The binary detection heuristic found null bytes or a high proportion of non-printable characters, which can happen with certain encodings or line endings | Download the file, edit it locally or via an SSH session, then re-upload. Alternatively, open a host terminal and edit with `nano` or `vim`. |
| Upload fails with a 413 error | The file exceeds the 25 MB per-file limit | Compress or split the file before uploading, or transfer it via `scp` / `rsync` directly to the stack directory on the host. |
| Cannot delete a folder | The UI requires confirmation before a recursive delete | Confirm the deletion prompt. If the error persists, check whether a running container has an open file handle inside that directory, which can block the delete on some OS configurations. Stop the relevant service and retry. |
| File saved but container still reads old content | The container caches the file at startup or the mount is read-only inside the container | Restart the service after saving so the container picks up the new file. |
| Download button not visible | You are on the Community tier, and the file exceeds the preview limit or is binary | Upgrade to Skipper to enable downloads, or access the file via the host terminal or `docker cp`. |
+1 -6
View File
@@ -9,7 +9,7 @@
* on a cold cache.
*/
import { test, expect, type Page } from '@playwright/test';
import { loginAs } from './helpers';
import { loginAs, waitForStacksLoaded } from './helpers';
const HAPPY_STACK = 'e2e-deploy-log-test';
const FAIL_STACK = 'e2e-deploy-log-fail-test';
@@ -25,11 +25,6 @@ const FAIL_COMPOSE = `services:
image: nginnnnx:notexist
`;
async function waitForStacksLoaded(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
}
async function createStackViaApi(page: Page, name: string, composeContent: string): Promise<void> {
await page.evaluate(
async ({ stackName, content }: { stackName: string; content: string }) => {
+10
View File
@@ -48,6 +48,16 @@ export async function isDashboard(page: Page): Promise<boolean> {
return page.locator(DASHBOARD_INDICATOR).isVisible().catch(() => false);
}
/**
* Wait for the stacks sidebar to finish loading. Waits for the Create Stack
* button and the data-stacks-loaded sentinel set by the CommandList after its
* async refreshStacks() call resolves.
*/
export async function waitForStacksLoaded(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
}
/**
* Navigate to the app root, complete first-run setup if needed, then log in.
* After this call the dashboard is guaranteed to be visible.
+351
View File
@@ -0,0 +1,351 @@
/**
* File explorer E2E tests.
*
* Two scenarios are covered:
*
* 1. Community flow (read-only): the license endpoint is intercepted so the
* frontend believes the instance is community tier. Only viewing files is
* allowed; the upgrade pill is visible in the left pane and the Save button
* is absent from the editor toolbar.
*
* 2. Skipper+ flow (full CRUD): the real license state (paid) is used.
* Upload, edit-and-save, delete, and download are exercised end-to-end.
* These tests skip gracefully when the instance is community tier (CI).
*
* Fixture files (config/app.conf and assets/logo.png) are seeded once via
* direct filesystem writes in a beforeAll hook so seeding works on any tier.
* The entire test stack is torn down in afterAll.
*/
import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path';
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
// Matches the COMPOSE_DIR passed to the backend in CI (start-app default) and
// in local dev. The test runner and the backend share the same host so both
// see the same path.
const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose';
const TEST_STACK = 'e2e-files-test';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Dismiss any "requires a paid license" or upgrade gate overlays that may
* appear when operating in community mode. These are informational overlays
* rendered by CapabilityGate for features like Auto-Heal Policies.
*/
async function dismissUpgradeOverlays(page: Page): Promise<void> {
const dismissBtn = page.getByRole('button', { name: /dismiss/i });
if (await dismissBtn.isVisible().catch(() => false)) {
await dismissBtn.click();
await page.waitForTimeout(200);
}
}
/**
* Click the test stack in the sidebar, then click the "files" button in the
* anatomy panel header to enter the Files tab. This works regardless of the
* current license tier because the Files panel itself is always rendered
* (isPaid only gates edit/upload/delete within the panel).
*/
async function openFilesTab(page: Page): Promise<void> {
await waitForStacksLoaded(page);
// Ensure the stack appears in the sidebar (reload if necessary after seeding)
const stackInSidebar = page.getByText(TEST_STACK, { exact: true }).first();
if (!await stackInSidebar.isVisible().catch(() => false)) {
await page.reload();
await loginAs(page);
await waitForStacksLoaded(page);
}
// Click the stack in the sidebar
await page.getByText(TEST_STACK, { exact: true }).first().click();
// Dismiss any upgrade/capability-gate overlays that block navigation
await dismissUpgradeOverlays(page);
// The anatomy panel header has a stable testid on the "files" button.
const filesBtn = page.getByTestId('anatomy-files-btn');
await expect(filesBtn).toBeVisible({ timeout: 8_000 });
await filesBtn.click();
// Wait for the file tree to populate: span.font-mono appears once the
// tree data has loaded for both paid and community tiers.
await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 });
}
/**
* Seed the test stack with fixture files.
*
* Stack creation uses the API (community-allowed) so the backend registry
* stays in sync. Fixture files are written directly via Node fs so seeding
* works on any tier without hitting the paid upload/mkdir endpoints.
*/
async function seedTestStack(page: Page): Promise<void> {
// Create the stack via API (community-OK; ignore 409 if already exists).
await page.evaluate(async (name: string) => {
const res = await fetch('/api/stacks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stackName: name }),
});
if (!res.ok && res.status !== 409) throw new Error(`stack create: ${res.status}`);
}, TEST_STACK);
// Write fixture files directly on the host filesystem.
const stackDir = nodePath.join(COMPOSE_DIR, TEST_STACK);
await fs.mkdir(nodePath.join(stackDir, 'config'), { recursive: true });
await fs.mkdir(nodePath.join(stackDir, 'assets'), { recursive: true });
await fs.writeFile(nodePath.join(stackDir, 'config', 'app.conf'), 'key=value\n');
const pngB64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==';
await fs.writeFile(nodePath.join(stackDir, 'assets', 'logo.png'), Buffer.from(pngB64, 'base64'));
}
/** Delete the test stack via the authenticated browser session. */
async function teardownTestStack(page: Page): Promise<void> {
await page.evaluate(async (name: string) => {
await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => {});
}, TEST_STACK);
}
/**
* Open a new page, login, seed the test stack, then close the page.
* Used as a beforeAll body so each describe suite shares one seed call.
*/
async function seedSuite(browser: import('@playwright/test').Browser): Promise<void> {
const page = await browser.newPage();
try {
await loginAs(page);
await seedTestStack(page);
} finally {
await page.close();
}
}
/**
* Open a new page, login, tear down the test stack, then close the page.
* Logs a warning on failure so a teardown error never masks test failures.
*/
async function teardownSuite(browser: import('@playwright/test').Browser, label: string): Promise<void> {
const page = await browser.newPage();
try {
await loginAs(page);
await teardownTestStack(page);
} catch (e) {
console.warn(`teardown failed (${label}):`, e);
} finally {
await page.close();
}
}
const COMMUNITY_LICENSE_BODY = JSON.stringify({
tier: 'community',
status: 'community',
variant: null,
customerName: null,
productName: null,
maskedKey: null,
validUntil: null,
trialDaysRemaining: null,
instanceId: 'test-instance',
portalUrl: null,
isLifetime: false,
});
/** Stub the /api/license endpoint so the frontend treats the session as community tier. */
async function mockCommunityLicense(context: BrowserContext): Promise<void> {
await context.route('/api/license', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: COMMUNITY_LICENSE_BODY });
});
}
// ---------------------------------------------------------------------------
// Community flow (read-only)
// ---------------------------------------------------------------------------
test.describe('File explorer - community (read-only)', () => {
// Increase timeout: seeding + navigation add overhead
test.setTimeout(60_000);
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'community'); });
test.beforeEach(async ({ page, context }) => {
// Login without the license stub first so cookies are established.
await loginAs(page);
// Install the community-tier stub and reload so the frontend picks it up.
await mockCommunityLicense(context);
await page.reload();
await loginAs(page);
await openFilesTab(page);
});
test.afterEach(async ({ context }) => {
// Remove the stub so subsequent requests use the real license state.
await context.unroute('/api/license');
});
test('upgrade pill is visible in the left pane', async ({ page }) => {
await expect(
page.getByRole('button', { name: /upgrade to unlock/i })
).toBeVisible({ timeout: 5_000 });
});
test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => {
// The config/ directory should be visible in the tree
const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first();
await expect(configNode).toBeVisible({ timeout: 8_000 });
// Click to expand the directory
await configNode.click();
// app.conf should now appear under config/
const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first();
await expect(appConfNode).toBeVisible({ timeout: 8_000 });
await appConfNode.click();
// The editor header should show "Read-only" badge (isPaid is false)
await expect(page.getByText('Read-only')).toBeVisible({ timeout: 10_000 });
// The Save button must NOT be present in community mode
await expect(page.getByRole('button', { name: /^save$/i })).not.toBeVisible();
});
});
// ---------------------------------------------------------------------------
// Skipper+ flow (full CRUD)
// ---------------------------------------------------------------------------
test.describe('File explorer - skipper+ (full CRUD)', () => {
test.setTimeout(60_000);
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'skipper'); });
test.beforeEach(async ({ page }) => {
await loginAs(page);
// Skip when the instance is community tier: upload/edit/delete/download
// all require Skipper+ and would 403. Same pattern as auto-heal-policies.
const tier = await page.evaluate(async () => {
const res = await fetch('/api/license', { credentials: 'include' });
const json = await res.json() as { tier?: string };
return json.tier;
});
test.skip(tier !== 'paid', 'Skipper+ tier required; instance is community.');
await openFilesTab(page);
});
test('upload a text file and verify it appears in the tree', async ({ page }) => {
// Guard: paid users see the upload dropzone, not the upgrade pill.
await expect(
page.locator('[role="button"]').filter({ hasText: /upload file/i }).first()
).toBeVisible({ timeout: 5_000 });
// Set a file on the hidden input
const input = page.locator('input[type="file"][aria-label="Upload file"]');
await input.setInputFiles({
name: 'e2e-upload-test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('hello e2e\n'),
});
// Success toast
await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });
// File appears in the tree at root level
await expect(
page.locator('span.font-mono').filter({ hasText: /^e2e-upload-test\.txt$/ }).first()
).toBeVisible({ timeout: 8_000 });
});
test('edit config/app.conf and save - success toast appears', async ({ page }) => {
// Expand config/ and open app.conf
const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first();
await expect(configNode).toBeVisible({ timeout: 8_000 });
await configNode.click();
const appConfNode = page.locator('span.font-mono').filter({ hasText: /^app\.conf$/ }).first();
await expect(appConfNode).toBeVisible({ timeout: 8_000 });
await appConfNode.click();
// Wait for Monaco to load
await page.waitForLoadState('networkidle', { timeout: 10_000 });
// Save button must be present and initially disabled (no changes yet)
const saveBtn = page.getByRole('button', { name: /^save$/i });
await expect(saveBtn).toBeVisible({ timeout: 8_000 });
await expect(saveBtn).toBeDisabled();
// Edit the file content via Monaco
const editorTextarea = page.locator('.monaco-editor textarea').first();
await editorTextarea.click({ force: true });
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.type('key=value\ne2e-edited=true\n');
// Save button should now be enabled
await expect(saveBtn).toBeEnabled({ timeout: 5_000 });
await saveBtn.click();
// Success toast
await expect(page.getByText(/saved/i).first()).toBeVisible({ timeout: 8_000 });
});
test('delete an uploaded file - it disappears from the tree', async ({ page }) => {
// Upload a disposable file first
const input = page.locator('input[type="file"][aria-label="Upload file"]');
await input.setInputFiles({
name: 'e2e-to-delete.txt',
mimeType: 'text/plain',
buffer: Buffer.from('delete me\n'),
});
await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });
// Click the file to select it
const fileNode = page.locator('span.font-mono').filter({ hasText: /^e2e-to-delete\.txt$/ }).first();
await expect(fileNode).toBeVisible({ timeout: 8_000 });
await fileNode.click();
// The action bar Delete button has a stable data-testid for reliable targeting.
const actionBarDeleteBtn = page.getByTestId('file-action-delete');
await expect(actionBarDeleteBtn).toBeVisible({ timeout: 5_000 });
await actionBarDeleteBtn.click();
// Wait for the DeleteFileConfirm dialog to open, then confirm deletion.
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 8_000 });
await page.getByTestId('delete-confirm-btn').click();
// Use the tree-specific class (text-sm) to avoid a strict-mode violation:
// the FileViewer header renders the same filename in a different span until
// handleDeleted() clears selectedPath.
await expect(
page.locator('span.font-mono.text-sm').filter({ hasText: /^e2e-to-delete\.txt$/ })
).not.toBeAttached({ timeout: 8_000 });
});
test('download assets/logo.png - response is 200 with attachment header', async ({ page, request }) => {
// Replay the browser's session cookies in a raw request to the download endpoint.
const cookies = await page.context().cookies();
const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
const res = await request.get(
`/api/stacks/${encodeURIComponent(TEST_STACK)}/files/download` +
`?path=${encodeURIComponent('assets/logo.png')}`,
{ headers: { cookie: cookieHeader } },
);
expect(res.status()).toBe(200);
const disposition = res.headers()['content-disposition'] ?? '';
expect(disposition).toMatch(/attachment/i);
});
});
+1 -9
View File
@@ -2,18 +2,10 @@
* Stack management E2E tests - happy path CRUD.
*/
import { test, expect } from '@playwright/test';
import { loginAs } from './helpers';
import { loginAs, waitForStacksLoaded } from './helpers';
const TEST_STACK = 'e2e-test-stack';
/** Wait for the stacks sidebar to finish loading (skeletons replaced with actual stack list). */
async function waitForStacksLoaded(page: import('@playwright/test').Page) {
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
// The CommandList has data-stacks-loaded="true" once the async refreshStacks() completes.
// Without this, tests can race against the loading state and miss newly created stacks.
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
}
/** Delete the test stack via the browser's authenticated fetch (so cookies are included). */
async function deleteTestStackViaApi(page: import('@playwright/test').Page) {
await page.evaluate(async (name) => {
+65 -42
View File
@@ -18,7 +18,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
import { springs } from '@/lib/motion';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { type Label as StackLabel, type LabelColor } from './label-types';
import { UserProfileDropdown } from './UserProfileDropdown';
@@ -73,6 +73,7 @@ import { usePinnedStacks } from '@/hooks/usePinnedStacks';
import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
import type { StackRowStatus } from '@/components/sidebar/StackRow';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
interface ContainerInfo {
Id: string;
@@ -237,7 +238,7 @@ export default function EditorLayout() {
// the delta is always computed against the most recent known value, avoiding
// the stale-closure bug that occurs when reading containerStats directly.
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
const [activeTab, setActiveTab] = useState<'compose' | 'env' | 'files'>('compose');
const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => {
if (typeof window === 'undefined') return 'structured';
return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured';
@@ -1107,6 +1108,7 @@ export default function EditorLayout() {
setIsFileLoading(true);
setIsEditing(false); // Reset to view mode when loading a new file
setEditingCompose(false); // Default back to anatomy on stack switch
setActiveTab('compose');
try {
const res = await apiFetch(`/stacks/${filename}`);
const text = await res.text();
@@ -1231,6 +1233,7 @@ export default function EditorLayout() {
};
const saveFile = async () => {
if (activeTab === 'files') return;
if (!selectedFile) return;
const currentContent = activeTab === 'compose' ? (content || '') : (envContent || '');
const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`;
@@ -1291,6 +1294,7 @@ export default function EditorLayout() {
};
const discardChanges = () => {
if (activeTab === 'files') return;
if (activeTab === 'compose') {
setContent(originalContent);
} else {
@@ -2713,7 +2717,7 @@ export default function EditorLayout() {
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-0 bg-card">
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env' | 'files')}>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="compose">
@@ -2722,6 +2726,12 @@ export default function EditorLayout() {
<TabsHighlightItem value="env">
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="files">
<TabsTrigger value="files">
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Files
</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</Tabs>
@@ -2742,7 +2752,7 @@ export default function EditorLayout() {
)}
</div>
<div className="flex items-center gap-2">
{can('stack:edit', 'stack', stackName) && (
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && (
<>
<Button
size="sm"
@@ -2805,45 +2815,57 @@ export default function EditorLayout() {
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'env' && (
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
<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.
</span>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { monacoEditorRef.current = editor; }}
onChange={(value) => {
if (!isEditing) return; // Prevent changes in view mode
if (activeTab === 'compose') {
setContent(value || '');
} else {
setEnvContent(value || '');
}
}}
options={{
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
}}
/>
)}
{isFileLoading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
{activeTab === 'files' ? (
<StackFileExplorer
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
isDarkMode={isDarkMode}
onNavigateToCompose={() => setActiveTab('compose')}
onNavigateToEnv={() => setActiveTab('env')}
/>
) : (
<>
{activeTab === 'env' && (
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
<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.
</span>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { monacoEditorRef.current = editor; }}
onChange={(value) => {
if (!isEditing) return; // Prevent changes in view mode
if (activeTab === 'compose') {
setContent(value || '');
} else {
setEnvContent(value || '');
}
}}
options={{
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 14,
padding: { top: 10 },
scrollBeyondLastLine: false,
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
}}
/>
)}
{isFileLoading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
</div>
)}
</div>
</>
)}
</div>
</Card>
) : (
@@ -2854,6 +2876,7 @@ export default function EditorLayout() {
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => setEditingCompose(true)}
onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
canEdit={can('stack:edit', 'stack', stackName)}
+26 -11
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { parse as parseYaml } from 'yaml';
import { GitBranch, Pencil, ExternalLink, Rocket } from 'lucide-react';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
import { Button } from './ui/button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -14,6 +14,7 @@ interface StackAnatomyPanelProps {
onEditCompose: () => void;
onOpenGitSource: () => void;
onApplyUpdate: () => void;
onOpenFiles?: () => void;
canEdit: boolean;
}
@@ -229,6 +230,7 @@ export default function StackAnatomyPanel({
onEditCompose,
onOpenGitSource,
onApplyUpdate,
onOpenFiles,
canEdit,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
@@ -327,16 +329,29 @@ export default function StackAnatomyPanel({
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">anatomy</span>
{canEdit && (
<button
type="button"
onClick={onEditCompose}
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<Pencil className="h-3 w-3" strokeWidth={1.5} />
edit compose.yaml
</button>
)}
<div className="flex items-center gap-3">
{onOpenFiles && (
<button
type="button"
data-testid="anatomy-files-btn"
onClick={onOpenFiles}
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<FolderOpen className="h-3 w-3" strokeWidth={1.5} />
files
</button>
)}
{canEdit && (
<button
type="button"
onClick={onEditCompose}
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<Pencil className="h-3 w-3" strokeWidth={1.5} />
edit compose.yaml
</button>
)}
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3">
{!anatomy ? (
@@ -0,0 +1,145 @@
import { useState, useEffect } from 'react';
import { AlertTriangle, Loader2, Trash2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
import { deleteStackPath } from '@/lib/stackFilesApi';
import type { FileEntry } from '@/lib/stackFilesApi';
interface DeleteFileConfirmProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
relPath: string;
entry: FileEntry | null;
onDeleted: () => void;
}
export function DeleteFileConfirm({
open,
onOpenChange,
stackName,
relPath,
entry,
onDeleted,
}: DeleteFileConfirmProps) {
const [deleting, setDeleting] = useState(false);
const [confirmInput, setConfirmInput] = useState('');
const [notEmpty, setNotEmpty] = useState(false);
const isProtected = entry?.isProtected ?? false;
const entryName = entry?.name ?? '';
useEffect(() => {
if (!open) {
setConfirmInput('');
setNotEmpty(false);
}
}, [open]);
const handleClose = (next: boolean) => {
if (deleting) return;
onOpenChange(next);
};
const executeDelete = async (recursive: boolean) => {
setDeleting(true);
try {
await deleteStackPath(stackName, relPath, recursive || undefined);
onDeleted();
onOpenChange(false);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Delete failed.';
if (!recursive && msg.toUpperCase().includes('NOT_EMPTY')) {
setNotEmpty(true);
} else {
toast.error(msg);
}
} finally {
setDeleting(false);
}
};
const handleDelete = () => void executeDelete(notEmpty);
const protectedOk = !isProtected || confirmInput === entryName;
const deleteLabel = notEmpty ? 'Delete all' : 'Delete';
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-4 h-4 text-destructive" strokeWidth={1.5} />
Delete {entryName ? `"${entryName}"` : 'item'}?
</DialogTitle>
<DialogDescription>
{notEmpty ? (
<span className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
This folder is not empty. Delete everything inside?
</span>
) : (
'This action cannot be undone.'
)}
</DialogDescription>
</DialogHeader>
{isProtected && (
<div className="space-y-2">
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
<p>This is a critical stack file. Type the filename to confirm.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="delete-confirm-input" className="text-xs">
Type <span className="font-mono">{entryName}</span> to confirm
</Label>
<Input
id="delete-confirm-input"
value={confirmInput}
onChange={(e) => setConfirmInput(e.target.value)}
placeholder={entryName}
disabled={deleting}
autoFocus
/>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleClose(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
size="sm"
data-testid="delete-confirm-btn"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleting || !protectedOk}
>
{deleting && (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
)}
{deleteLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useState, useEffect, useRef, Fragment } from 'react';
import type { ReactNode } from 'react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { listStackDirectory } from '@/lib/stackFilesApi';
import type { FileEntry } from '@/lib/stackFilesApi';
import { FileTreeNode } from './FileTreeNode';
interface FileTreeProps {
stackName: string;
refreshKey?: number;
selectedPath: string;
onSelectFile: (relPath: string, entry: FileEntry) => void;
onNavigateToCompose?: () => void;
onNavigateToEnv?: () => void;
}
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
const ENV_NAMES = new Set(['.env']);
const MAX_ENTRIES = 500;
export function FileTree({
stackName,
refreshKey,
selectedPath,
onSelectFile,
onNavigateToCompose,
onNavigateToEnv,
}: FileTreeProps) {
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
const [rootLoading, setRootLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const stackNameRef = useRef(stackName);
useEffect(() => {
stackNameRef.current = stackName;
let cancelled = false;
listStackDirectory(stackName, '')
.then((entries) => {
if (!cancelled) {
setRootEntries(entries);
setRootLoading(false);
}
})
.catch((err: unknown) => {
if (!cancelled) {
const msg = err instanceof Error ? err.message : 'Failed to load files.';
setError(msg);
setRootLoading(false);
toast.error('Failed to load files.');
}
});
return () => {
cancelled = true;
};
}, [stackName, refreshKey]);
function handleDirClick(dirRelPath: string) {
if (expandedDirs.has(dirRelPath)) {
setExpandedDirs((prev) => {
const next = new Set(prev);
next.delete(dirRelPath);
return next;
});
return;
}
if (dirContents.has(dirRelPath)) {
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
return;
}
setLoadingDirs((prev) => new Set(prev).add(dirRelPath));
const capturedStackName = stackName;
listStackDirectory(capturedStackName, dirRelPath)
.then((entries) => {
if (stackNameRef.current !== capturedStackName) return;
setDirContents((prev) => {
const next = new Map(prev);
next.set(dirRelPath, entries);
return next;
});
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to load directory.';
toast.error(msg);
})
.finally(() => {
setLoadingDirs((prev) => {
const next = new Set(prev);
next.delete(dirRelPath);
return next;
});
});
}
function handleFileClick(relPath: string, entry: FileEntry) {
if (COMPOSE_NAMES.has(entry.name)) {
if (onNavigateToCompose) onNavigateToCompose();
else toast.info('Open the Compose tab to edit this file.');
return;
}
if (ENV_NAMES.has(entry.name)) {
if (onNavigateToEnv) onNavigateToEnv();
else toast.info('Open the Env tab to edit this file.');
return;
}
onSelectFile(relPath, entry);
}
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
const capped = entries.length > MAX_ENTRIES;
const visible = capped ? entries.slice(0, MAX_ENTRIES) : entries;
return (
<>
{visible.map((entry) => {
const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
const isDir = entry.type === 'directory';
const isExpanded = expandedDirs.has(entryRelPath);
const isLoading = loadingDirs.has(entryRelPath);
const children = dirContents.get(entryRelPath);
return (
<Fragment key={entryRelPath}>
<FileTreeNode
entry={entry}
depth={depth}
isSelected={selectedPath === entryRelPath}
isExpanded={isExpanded}
isLoading={isLoading}
onClick={() => {
if (isDir) {
handleDirClick(entryRelPath);
} else {
handleFileClick(entryRelPath, entry);
}
}}
/>
{isDir && isExpanded && children !== undefined && (
children.length === 0
? (
<div className="text-xs text-muted-foreground pl-4 py-0.5 italic">
Empty folder
</div>
)
: renderEntries(children, entryRelPath, depth + 1)
)}
</Fragment>
);
})}
{capped && (
<div className="text-xs text-muted-foreground pl-4 py-0.5">
Showing {MAX_ENTRIES} of {entries.length} - refine in shell
</div>
)}
</>
);
}
if (rootLoading) {
return (
<div className="flex flex-col gap-1.5 p-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
);
}
if (error !== null) {
return (
<div className="p-2 text-xs text-destructive">
{error}
</div>
);
}
if (rootEntries === null || rootEntries.length === 0) {
return (
<div className="p-2 text-xs text-muted-foreground italic">
Empty folder
</div>
);
}
return (
<ScrollArea type="hover" className="h-full">
<div className="py-1">
{renderEntries(rootEntries, '', 0)}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,60 @@
import { ChevronRight, ChevronDown, Folder, File, Link, Loader2 } from 'lucide-react';
import type { FileEntry } from '@/lib/stackFilesApi';
import { cn } from '@/lib/utils';
interface FileTreeNodeProps {
entry: FileEntry;
depth: number;
isSelected: boolean;
isExpanded?: boolean;
isLoading?: boolean;
onClick: () => void;
}
export function FileTreeNode({
entry,
depth,
isSelected,
isExpanded,
isLoading,
onClick,
}: FileTreeNodeProps) {
const isDir = entry.type === 'directory';
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick();
}}
aria-expanded={isDir ? isExpanded : undefined}
className={cn(
'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm',
isSelected
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent/50 text-foreground'
)}
style={{ paddingLeft: depth * 16 + 8 }}
>
{isDir && (
isLoading
? <Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin" strokeWidth={1.5} />
: isExpanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
)}
{isDir
? <Folder className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: entry.type === 'symlink'
? <Link className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
: <File className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
}
<span className="font-mono text-sm truncate">{entry.name}</span>
{entry.isProtected && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { useRef } from 'react';
import { UploadCloud, Compass } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { uploadStackFile } from '@/lib/stackFilesApi';
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
interface FileUploadDropzoneProps {
stackName: string;
currentDir: string;
onUploaded: () => void;
}
export function FileUploadDropzone({
stackName,
currentDir,
onUploaded,
}: FileUploadDropzoneProps) {
const { isPaid } = useLicense();
const inputRef = useRef<HTMLInputElement>(null);
if (!isPaid) {
return (
<button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs cursor-pointer hover:border-brand/50 transition-colors"
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<Compass className="w-3 h-3" />
Upgrade to unlock upload, edit, and delete
</button>
);
}
const handleFile = async (file: File) => {
if (file.size > MAX_BYTES) {
toast.error('File exceeds 25 MB.');
return;
}
const loadingId = toast.loading(`Uploading ${file.name}...`);
try {
await uploadStackFile(stackName, currentDir, file);
toast.success('Uploaded.');
onUploaded();
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Upload failed.');
} finally {
toast.dismiss(loadingId);
}
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0];
if (file) void handleFile(file);
ev.target.value = '';
};
return (
<>
<input
ref={inputRef}
type="file"
className="sr-only"
onChange={handleChange}
aria-label="Upload file"
/>
<div
role="button"
tabIndex={0}
className="border border-dashed border-border rounded-md p-2 text-xs text-muted-foreground flex items-center gap-2 cursor-pointer hover:border-brand/50 transition-colors"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
}}
>
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
Upload file
</div>
</>
);
}
@@ -0,0 +1,292 @@
import { useState, useEffect, useMemo } from 'react';
import Editor from '@monaco-editor/react';
import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi';
import { extensionToLanguage } from '@/lib/monacoLanguages';
import { formatBytes } from '@/lib/utils';
interface FileViewerProps {
stackName: string;
selectedPath: string | null;
canEdit: boolean;
isDarkMode: boolean;
onSaved?: () => void;
}
function getFilename(path: string): string {
return path.split('/').pop() ?? path;
}
interface SpecialFilePanelProps {
filename: string;
size: number;
label: string;
stackName: string;
relPath: string;
canDownload: boolean;
}
function SpecialFilePanel({
filename,
size,
label,
stackName,
relPath,
canDownload,
}: SpecialFilePanelProps) {
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
if (!canDownload) return;
setDownloading(true);
try {
const res = await downloadStackFile(stackName, relPath);
if (!res.ok) {
toast.error('Download failed.');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Download failed.');
} finally {
setDownloading(false);
}
};
return (
<div className="flex flex-col items-center justify-center gap-4 h-full min-h-[200px] text-muted-foreground">
<FileIcon className="w-10 h-10 text-stat-icon" strokeWidth={1.25} />
<div className="text-center space-y-1">
<p className="font-mono text-sm text-stat-title">{filename}</p>
<p className="text-xs text-stat-subtitle">{label} &middot; {formatBytes(size)}</p>
</div>
{canDownload ? (
<Button
variant="outline"
size="sm"
onClick={() => void handleDownload()}
disabled={downloading}
>
{downloading ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
)}
Download
</Button>
) : (
<div title="Upgrade to download files">
<Button variant="outline" size="sm" disabled>
<Lock className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Download
</Button>
</div>
)}
</div>
);
}
export function FileViewer({
stackName,
selectedPath,
canEdit,
isDarkMode,
onSaved,
}: FileViewerProps) {
const { isPaid } = useLicense();
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const [isOversized, setIsOversized] = useState(false);
const [size, setSize] = useState(0);
const readOnly = !canEdit || !isPaid;
const editorOptions = useMemo(
() => ({
readOnly,
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 13,
padding: { top: 8 },
scrollBeyondLastLine: false,
}),
[readOnly],
);
useEffect(() => {
if (!selectedPath) {
setContent('');
setOriginalContent('');
setIsBinary(false);
setIsOversized(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setIsBinary(false);
setIsOversized(false);
readStackFile(stackName, selectedPath)
.then((result) => {
if (cancelled) return;
setSize(result.size);
if (result.binary) {
setIsBinary(true);
} else if (result.oversized) {
setIsOversized(true);
} else {
const text = result.content ?? '';
setContent(text);
setOriginalContent(text);
}
})
.catch((e: unknown) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : 'Failed to load file.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [stackName, selectedPath]);
const handleSave = async () => {
if (!selectedPath) return;
setSaving(true);
const loadingId = toast.loading('Saving...');
try {
await writeStackFile(stackName, selectedPath, content);
setOriginalContent(content);
toast.success('Saved.');
onSaved?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Save failed.');
} finally {
toast.dismiss(loadingId);
setSaving(false);
}
};
if (!selectedPath) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Select a file to view it
</div>
);
}
if (loading) {
return (
<div className="flex flex-col h-full p-3 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="flex-1 w-full" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground">
<AlertCircle className="w-6 h-6 text-destructive" strokeWidth={1.5} />
<p className="text-sm text-center px-4">{error}</p>
</div>
);
}
const filename = getFilename(selectedPath);
const language = extensionToLanguage(filename);
if (isBinary) {
return (
<SpecialFilePanel
filename={filename}
size={size}
label="Binary file"
stackName={stackName}
relPath={selectedPath}
canDownload={isPaid}
/>
);
}
if (isOversized) {
return (
<SpecialFilePanel
filename={filename}
size={size}
label="File too large to preview"
stackName={stackName}
relPath={selectedPath}
canDownload={isPaid}
/>
);
}
const hasChanges = content !== originalContent;
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-glass-border shrink-0">
<span className="font-mono text-xs text-stat-subtitle truncate">{filename}</span>
<div className="flex items-center gap-2 shrink-0">
{readOnly && (
<span className="text-[10px] uppercase tracking-widest text-muted-foreground border border-border rounded px-1.5 py-0.5">
Read-only
</span>
)}
{!readOnly && (
<Button
size="sm"
className="h-7"
onClick={() => void handleSave()}
disabled={saving || !hasChanges}
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" strokeWidth={1.5} />
) : (
<Save className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
)}
Save
</Button>
)}
</div>
</div>
<div className="flex-1 min-h-0">
<Editor
height="100%"
language={language}
value={content}
onChange={(val) => {
if (!readOnly) setContent(val ?? '');
}}
theme={isDarkMode ? 'vs-dark' : 'light'}
options={editorOptions}
/>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { useState } from 'react';
import { FolderPlus, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/toast-store';
import { mkdirStackPath } from '@/lib/stackFilesApi';
interface NewFolderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string;
currentDir: string;
onCreated: () => void;
}
function isValidFolderName(name: string): boolean {
if (!name || name === '.' || name === '..') return false;
return /^[^/\\]+$/.test(name);
}
export function NewFolderDialog({
open,
onOpenChange,
stackName,
currentDir,
onCreated,
}: NewFolderDialogProps) {
const [name, setName] = useState('');
const [creating, setCreating] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const handleClose = (next: boolean) => {
if (creating) return;
onOpenChange(next);
if (!next) {
setName('');
setValidationError(null);
}
};
const handleCreate = async () => {
const trimmed = name.trim();
if (!isValidFolderName(trimmed)) {
setValidationError('Folder name must not be empty, and must not contain / or \\.');
return;
}
setValidationError(null);
setCreating(true);
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
try {
await mkdirStackPath(stackName, relPath);
toast.success('Folder created.');
onCreated();
onOpenChange(false);
setName('');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create folder.');
} finally {
setCreating(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') void handleCreate();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderPlus className="w-4 h-4" strokeWidth={1.5} />
New Folder
</DialogTitle>
<DialogDescription className="sr-only">
Enter a name for the new folder.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label htmlFor="folder-name">Folder name</Label>
<Input
id="folder-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setValidationError(null);
}}
onKeyDown={handleKeyDown}
placeholder="my-folder"
disabled={creating}
autoFocus
/>
{validationError && (
<p className="text-xs text-destructive">{validationError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleClose(false)}
disabled={creating}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => void handleCreate()}
disabled={creating || !name.trim()}
>
{creating && (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,182 @@
import { useState, useEffect, useCallback } from 'react';
import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { useLicense } from '@/context/LicenseContext';
import { downloadStackFile } from '@/lib/stackFilesApi';
import { FileTree } from './FileTree';
import { FileViewer } from './FileViewer';
import { FileUploadDropzone } from './FileUploadDropzone';
import { NewFolderDialog } from './NewFolderDialog';
import { DeleteFileConfirm } from './DeleteFileConfirm';
import type { FileEntry } from '@/lib/stackFilesApi';
interface StackFileExplorerProps {
stackName: string;
canEdit: boolean;
isDarkMode: boolean;
onNavigateToCompose?: () => void;
onNavigateToEnv?: () => void;
}
export function StackFileExplorer({
stackName,
canEdit,
isDarkMode,
onNavigateToCompose,
onNavigateToEnv,
}: StackFileExplorerProps) {
const { isPaid } = useLicense();
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
const [currentDir, setCurrentDir] = useState('');
const [refreshKey, setRefreshKey] = useState(0);
const [deleteOpen, setDeleteOpen] = useState(false);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => {
setSelectedPath(null);
setSelectedEntry(null);
setCurrentDir('');
}, [stackName]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
const handleSelectFile = useCallback((relPath: string, entry: FileEntry) => {
setSelectedPath(relPath);
setSelectedEntry(entry);
const parts = relPath.split('/');
parts.pop();
setCurrentDir(parts.join('/'));
}, []);
const handleDeleted = useCallback(() => {
setSelectedPath(null);
setSelectedEntry(null);
refresh();
}, [refresh]);
const handleDownload = async () => {
if (!selectedPath) return;
setIsDownloading(true);
try {
const res = await downloadStackFile(stackName, selectedPath);
if (!res.ok) {
toast.error('Download failed.');
return;
}
const blob = await res.blob();
const filename = selectedPath.split('/').pop() ?? selectedPath;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Download failed.');
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex h-full min-h-0">
{/* Left pane: tree + upload + new folder */}
<div className="flex flex-col w-56 shrink-0 border-r border-glass-border min-h-0">
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-glass-border shrink-0">
<div className="flex-1 min-w-0">
<FileUploadDropzone
stackName={stackName}
currentDir={currentDir}
onUploaded={refresh}
/>
</div>
{isPaid && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
title="New folder"
onClick={() => setNewFolderOpen(true)}
>
<FolderPlus className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<FileTree
key={`${stackName}:${refreshKey}`}
stackName={stackName}
refreshKey={refreshKey}
selectedPath={selectedPath ?? ''}
onSelectFile={handleSelectFile}
onNavigateToCompose={onNavigateToCompose}
onNavigateToEnv={onNavigateToEnv}
/>
</div>
</div>
{/* Right pane: action bar + viewer */}
<div className="flex flex-col flex-1 min-h-0 min-w-0">
{selectedPath !== null && isPaid && (
<div className="flex items-center justify-end gap-1 px-2 py-1 border-b border-glass-border shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={() => void handleDownload()}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
)}
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="file-action-delete"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Delete
</Button>
</div>
)}
<div className="flex-1 min-h-0">
<FileViewer
stackName={stackName}
selectedPath={selectedPath}
canEdit={canEdit}
isDarkMode={isDarkMode}
onSaved={refresh}
/>
</div>
</div>
<DeleteFileConfirm
open={deleteOpen}
onOpenChange={setDeleteOpen}
stackName={stackName}
relPath={selectedPath ?? ''}
entry={selectedEntry}
onDeleted={handleDeleted}
/>
<NewFolderDialog
open={newFolderOpen}
onOpenChange={setNewFolderOpen}
stackName={stackName}
currentDir={currentDir}
onCreated={refresh}
/>
</div>
);
}
@@ -0,0 +1,170 @@
/**
* Coverage for FileTree.
*
* Locks the expand/collapse behavior: root directory loaded on mount,
* subdirectory fetched on first expand, collapsed on second click, and
* re-expanded from cache (no second fetch) on third click.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { FileEntry } from '@/lib/stackFilesApi';
vi.mock('@/lib/stackFilesApi', () => ({
listStackDirectory: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
// ScrollArea just renders children so the tree nodes are accessible in jsdom.
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
import { listStackDirectory } from '@/lib/stackFilesApi';
import { FileTree } from '../FileTree';
const mockListDir = listStackDirectory as unknown as ReturnType<typeof vi.fn>;
function makeFile(name: string): FileEntry {
return { name, type: 'file', size: 100, mtime: 0, isProtected: false };
}
function makeDir(name: string): FileEntry {
return { name, type: 'directory', size: 0, mtime: 0, isProtected: false };
}
const ROOT_ENTRIES: FileEntry[] = [makeDir('src'), makeFile('README.md')];
const SRC_ENTRIES: FileEntry[] = [makeFile('index.ts'), makeFile('app.ts')];
function fakeOk(entries: FileEntry[]): Promise<FileEntry[]> {
return Promise.resolve(entries);
}
const defaultProps = {
stackName: 'my-stack',
selectedPath: '',
onSelectFile: vi.fn(),
};
beforeEach(() => {
mockListDir.mockReset();
defaultProps.onSelectFile = vi.fn();
});
afterEach(() => vi.clearAllMocks());
describe('FileTree', () => {
it('fetches root entries on mount and renders them', async () => {
mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
render(<FileTree {...defaultProps} />);
await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', ''));
expect(await screen.findByText('src')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
it('fetches subdirectory on first expand and shows children', async () => {
mockListDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
await screen.findByText('src');
// One call so far: root fetch.
expect(mockListDir).toHaveBeenCalledTimes(1);
await user.click(screen.getByText('src'));
await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2));
expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src');
expect(await screen.findByText('index.ts')).toBeInTheDocument();
expect(screen.getByText('app.ts')).toBeInTheDocument();
});
it('collapses on second click (no additional fetch)', async () => {
mockListDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
await screen.findByText('src');
// Expand.
await user.click(screen.getByText('src'));
await screen.findByText('index.ts');
const callsAfterExpand = mockListDir.mock.calls.length;
// Collapse.
await user.click(screen.getByText('src'));
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
// No extra fetch should have happened.
expect(mockListDir).toHaveBeenCalledTimes(callsAfterExpand);
});
it('re-expands from cache on third click (no second fetch for that dir)', async () => {
mockListDir
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
const user = userEvent.setup();
render(<FileTree {...defaultProps} />);
await screen.findByText('src');
// First click: expand (fetches subdirectory).
await user.click(screen.getByText('src'));
await screen.findByText('index.ts');
// Second click: collapse.
await user.click(screen.getByText('src'));
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
const callsAfterCollapse = mockListDir.mock.calls.length;
// Third click: re-expand from cache.
await user.click(screen.getByText('src'));
await screen.findByText('index.ts');
// Fetch count must not have increased.
expect(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse);
});
it('shows error message when root fetch fails', async () => {
mockListDir.mockRejectedValue(new Error('Network error'));
render(<FileTree {...defaultProps} />);
expect(await screen.findByText('Network error')).toBeInTheDocument();
});
it('shows empty state when root returns no entries', async () => {
mockListDir.mockReturnValue(fakeOk([]));
render(<FileTree {...defaultProps} />);
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
/**
* Coverage for FileViewer.
*
* Locks the three content-render modes: Monaco editor for text files,
* binary panel for binary files, and oversized panel for files that
* exceed the preview limit.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { FileContentResult } from '@/lib/stackFilesApi';
vi.mock('@monaco-editor/react', () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock('@/lib/stackFilesApi', () => ({
readStackFile: vi.fn(),
writeStackFile: vi.fn(),
downloadStackFile: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => 'loading-id'),
dismiss: vi.fn(),
},
}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock('@/lib/utils', () => ({
formatBytes: (n: number) => `${n}B`,
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
vi.mock('@/lib/monacoLanguages', () => ({
extensionToLanguage: () => 'plaintext',
}));
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => licenseState,
}));
import { readStackFile } from '@/lib/stackFilesApi';
import { FileViewer } from '../FileViewer';
const mockReadFile = readStackFile as unknown as ReturnType<typeof vi.fn>;
function textResult(content = 'hello world'): FileContentResult {
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' };
}
function binaryResult(): FileContentResult {
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' };
}
function oversizedResult(): FileContentResult {
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' };
}
const defaultProps = {
stackName: 'my-stack',
canEdit: true,
isDarkMode: false,
};
beforeEach(() => {
mockReadFile.mockReset();
licenseState.isPaid = true;
});
afterEach(() => vi.clearAllMocks());
describe('FileViewer', () => {
it('shows "Select a file" placeholder when selectedPath is null', () => {
render(<FileViewer {...defaultProps} selectedPath={null} />);
expect(screen.getByText(/select a file/i)).toBeInTheDocument();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('renders Monaco editor for a regular text file', async () => {
mockReadFile.mockResolvedValue(textResult());
render(<FileViewer {...defaultProps} selectedPath="config/app.txt" />);
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
expect(screen.queryByText(/binary file/i)).not.toBeInTheDocument();
expect(screen.queryByText(/too large/i)).not.toBeInTheDocument();
});
it('calls readStackFile with the correct stack name and path', async () => {
mockReadFile.mockResolvedValue(textResult());
render(<FileViewer {...defaultProps} selectedPath="src/index.ts" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts'));
});
it('renders binary panel (not Monaco) for a binary file', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="assets/logo.png" />);
expect(await screen.findByText(/binary file/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders oversized panel (not Monaco) when file is too large to preview', async () => {
mockReadFile.mockResolvedValue(oversizedResult());
render(<FileViewer {...defaultProps} selectedPath="logs/huge.log" />);
expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders error message when readStackFile rejects', async () => {
mockReadFile.mockRejectedValue(new Error('Not found'));
render(<FileViewer {...defaultProps} selectedPath="missing.txt" />);
expect(await screen.findByText('Not found')).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('shows Download button when user has a paid tier', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).not.toBeDisabled();
});
it('shows disabled Download button for community tier', async () => {
licenseState.isPaid = false;
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).toBeDisabled();
});
it('re-fetches when selectedPath changes', async () => {
mockReadFile.mockResolvedValue(textResult());
const { rerender } = render(<FileViewer {...defaultProps} selectedPath="a.txt" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(1));
rerender(<FileViewer {...defaultProps} selectedPath="b.txt" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2));
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt');
});
});
+56
View File
@@ -0,0 +1,56 @@
export function extensionToLanguage(filename: string): string {
if (filename === 'Dockerfile') return 'dockerfile';
// Dotfiles with no secondary dot (e.g. ".gitignore") have no meaningful
// extension, but ".env*" files should be treated as ini.
const basename = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename;
if (basename === '.env' || /^\.env(\.|$)/.test(basename)) return 'ini';
const dotIndex = basename.lastIndexOf('.');
// No extension, or the only dot is the leading dot of a dotfile.
if (dotIndex <= 0) return 'plaintext';
const ext = basename.slice(dotIndex).toLowerCase();
switch (ext) {
case '.yaml':
case '.yml':
return 'yaml';
case '.json':
case '.jsonc':
return 'json';
case '.ts':
case '.tsx':
return 'typescript';
case '.js':
case '.jsx':
case '.mjs':
case '.cjs':
return 'javascript';
case '.sh':
case '.bash':
case '.zsh':
return 'shell';
case '.py':
return 'python';
case '.toml':
return 'ini';
case '.ini':
case '.conf':
case '.cfg':
return 'ini';
case '.xml':
case '.html':
case '.htm':
return 'html';
case '.css':
return 'css';
case '.md':
case '.mdx':
return 'markdown';
default:
return 'plaintext';
}
}
+134
View File
@@ -0,0 +1,134 @@
import { apiFetch } from './api';
export interface FileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: number;
isProtected: boolean;
}
export interface FileContentResult {
content?: string;
binary: boolean;
oversized: boolean;
size: number;
mime: string;
}
export async function parseApiError(res: Response): Promise<string> {
try {
const data = await res.json();
return (data as { error?: string }).error ?? `HTTP ${res.status}`;
} catch {
return `HTTP ${res.status}`;
}
}
function stackFilesUrl(stackName: string, suffix: string): string {
return `/stacks/${encodeURIComponent(stackName)}/files${suffix}`;
}
export async function listStackDirectory(
stackName: string,
relPath: string
): Promise<FileEntry[]> {
const res = await apiFetch(stackFilesUrl(stackName, `?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileEntry[]>;
}
export async function readStackFile(
stackName: string,
relPath: string
): Promise<FileContentResult> {
const res = await apiFetch(stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`));
if (!res.ok) throw new Error(await parseApiError(res));
return res.json() as Promise<FileContentResult>;
}
export async function downloadStackFile(
stackName: string,
relPath: string
): Promise<Response> {
return apiFetch(stackFilesUrl(stackName, `/download?path=${encodeURIComponent(relPath)}`));
}
export async function uploadStackFile(
stackName: string,
targetDir: string,
file: File,
options?: { localOnly?: boolean }
): Promise<void> {
const fd = new FormData();
fd.append('file', file, file.name);
const activeNodeId = options?.localOnly ? null : localStorage.getItem('sencho-active-node');
const headers: Record<string, string> = {};
if (activeNodeId) {
headers['x-node-id'] = activeNodeId;
}
// Use fetch directly: apiFetch always sets Content-Type: application/json,
// which breaks multipart boundary negotiation. The 401 side-effects are
// replicated manually below.
const res = await fetch(
`/api${stackFilesUrl(stackName, `/upload?path=${encodeURIComponent(targetDir)}`)}`,
{ method: 'POST', credentials: 'include', headers, body: fd }
);
if (res.status === 401) {
if (!res.headers.get('x-sencho-proxy')) {
window.dispatchEvent(new Event('sencho-unauthorized'));
}
throw new Error('Unauthorized');
}
if (!res.ok) {
if (res.status === 404) {
try {
const clone = res.clone();
const errData = await clone.json();
if (errData.error?.includes('not found') && errData.error?.includes('Node')) {
window.dispatchEvent(new Event('node-not-found'));
}
} catch { /* ignore */ }
}
throw new Error(await parseApiError(res));
}
}
export async function writeStackFile(
stackName: string,
relPath: string,
content: string
): Promise<void> {
const res = await apiFetch(
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
{ method: 'PUT', body: JSON.stringify({ content }) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function deleteStackPath(
stackName: string,
relPath: string,
recursive?: boolean
): Promise<void> {
const qs = recursive
? `path=${encodeURIComponent(relPath)}&recursive=1`
: `path=${encodeURIComponent(relPath)}`;
const res = await apiFetch(stackFilesUrl(stackName, `?${qs}`), { method: 'DELETE' });
if (!res.ok) throw new Error(await parseApiError(res));
}
export async function mkdirStackPath(
stackName: string,
relPath: string
): Promise<void> {
const res = await apiFetch(
stackFilesUrl(stackName, `/folder?path=${encodeURIComponent(relPath)}`),
{ method: 'POST', body: JSON.stringify({}) }
);
if (!res.ok) throw new Error(await parseApiError(res));
}