mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
Generated
+126
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"features/sidebar",
|
||||
"features/stack-management",
|
||||
"features/editor",
|
||||
"features/stack-file-explorer",
|
||||
"features/deploy-progress",
|
||||
"features/resources",
|
||||
"features/app-store",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} · {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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user