feat(stacks): guided first stack import flow (#1285)

* feat(stacks): add guided first stack import flow

Add an Import mode to the Create Stack dialog and a zero-stacks empty
state so a new user who already has compose files on disk can land their
first stack without reading the docs first.

A read-only scan of the compose directory (GET /api/stacks/import/scan)
lists the compose files it finds with a dry preview of each file's
services, ports, volumes, and env files. Each result is labelled by
placement: already a stack, loose at the root of the compose directory,
or one folder too deep, with the exact path to move misplaced files to.
The scan never writes, moves, or changes any files.

Manual stack creation (Empty, From Git, From Docker Run) is unchanged.

* fix(stacks): read import-scan candidates via a single file handle

Open the compose file once and stat plus read on the same descriptor so
the size check and the read observe the same inode, instead of resolving
the path twice (stat then readFile), which is a time-of-check/time-of-use
race. Mirrors the existing handle-based readers in FileSystemService.

* fix(stacks): confine import scan to the compose dir and refine the empty state

Harden the read-only import scan:
- Resolve symlinks and confirm the real target stays inside the compose
  directory before reading a candidate, and reject non-regular files, so a
  symlinked compose file or parent cannot expose a file outside the compose
  directory through the preview (matches resolveSafeStackPath).
- Read at most the stat-reported size (bounded by the 1 MiB cap) from the open
  handle, so a file that grows after the size check cannot exceed the cap.
- Log when the compose directory or a subdirectory cannot be read, so an access
  failure is not silently reported as "no compose files found".

Only show the first-run "No stacks yet" prompt when no filter chip is active, so
a filter that matches nothing is not mistaken for an empty fleet.
This commit is contained in:
Anso
2026-06-02 16:10:05 -04:00
committed by GitHub
parent c82a39c65a
commit 06b25262cc
11 changed files with 963 additions and 12 deletions
+141
View File
@@ -2,6 +2,7 @@ import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { promises as fsPromises, createReadStream } from 'fs';
import type { Dirent } from 'fs';
import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
@@ -36,6 +37,29 @@ const PROTECTED_STACK_FILES = new Set([
'.env',
]);
// Compose filenames Sencho recognizes, in resolution-priority order. Mirrors the
// list FileSystemService uses elsewhere; named here for the import scan.
const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
const IMPORT_COMPOSE_FILENAME_SET = new Set<string>(IMPORT_COMPOSE_FILENAMES);
// Skip reading compose files larger than this into the import preview.
const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB
/**
* A compose file discovered on disk during the guided import scan. `status`
* records placement: a top-level subdirectory with a compose file is already a
* stack (`listed`); a compose file loose at the compose-dir root (`loose-root`)
* or one directory too deep (`nested`) will not auto-register and needs the user
* to move it. `content` is null when the file was oversized or unreadable.
*/
export interface ImportCandidateRaw {
name: string;
composeFile: string;
location: string;
status: 'listed' | 'loose-root' | 'nested';
content: string | null;
oversized: boolean;
}
// Strips at most one trailing slash. The upstream validator
// (isValidRelativeStackPath) rejects any '//' sequence, so a string reaching
// this helper can carry at most one trailing slash, and a single slice is
@@ -423,6 +447,123 @@ export class FileSystemService {
return this.baseDir;
}
private async firstComposeFilename(dir: string): Promise<string | null> {
this.assertWithinBase(dir);
for (const file of IMPORT_COMPOSE_FILENAMES) {
try {
await fsPromises.access(path.join(dir, file));
return file;
} catch {
// continue
}
}
return null;
}
private async readComposeCandidate(filePath: string): Promise<{ content: string | null; oversized: boolean }> {
this.assertWithinBase(filePath);
let fh: import('fs/promises').FileHandle | null = null;
try {
// Resolve symlinks and confirm the real target is still inside the compose
// directory before reading (matches resolveSafeStackPath). A symlinked
// compose file or symlinked parent must not expose a file outside the
// compose dir through the preview.
const realPath = await fsPromises.realpath(filePath);
if (!isPathWithinBase(realPath, this.baseDir)) {
console.warn('[FileSystemService] Skipping import candidate that escapes the compose directory:', sanitizeForLog(filePath));
return { content: null, oversized: false };
}
// Open the canonical path once and stat/read on the same descriptor so the
// size check and the read observe the same inode (no time-of-check/use race).
fh = await fsPromises.open(realPath, 'r');
const stat = await fh.stat();
if (!stat.isFile()) return { content: null, oversized: false };
if (stat.size > IMPORT_MAX_PREVIEW_BYTES) return { content: null, oversized: true };
// Read at most stat.size (<= cap) bytes so a file that grows after the
// stat cannot push this buffer past the cap.
const buffer = Buffer.alloc(stat.size);
const { bytesRead } = await fh.read(buffer, 0, stat.size, 0);
return { content: buffer.subarray(0, bytesRead).toString('utf-8'), oversized: false };
} catch (error) {
// The file existed at probe time, so a failure here (permission, I/O) is
// worth a server-side line even though the scan degrades gracefully and the
// route reports it to the user.
console.warn('[FileSystemService] Failed to read import candidate:', sanitizeForLog((error as Error)?.message ?? String(error)));
return { content: null, oversized: false };
} finally {
if (fh) await fh.close();
}
}
/**
* Scan the compose directory for compose files to surface in the guided import
* flow: loose files at the root, top-level stack subdirectories, and compose
* files one directory too deep. Read-only. Bounded by `maxCandidates` and by a
* single level of nesting so a deep tree cannot make this walk unbounded.
*/
async findImportCandidates(maxCandidates = 100): Promise<ImportCandidateRaw[]> {
const candidates: ImportCandidateRaw[] = [];
let entries: Dirent[];
try {
entries = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
} catch (error) {
// The compose dir itself is unreadable (missing, permissions). The scan
// degrades to an empty list, so log it rather than report "no files found"
// for what is really an access failure.
console.warn('[FileSystemService] Failed to scan compose directory for import:', sanitizeForLog((error as Error)?.message ?? String(error)));
return candidates;
}
for (const entry of entries) {
if (candidates.length >= maxCandidates) break;
if (!entry.name || typeof entry.name !== 'string') continue;
if (entry.isFile()) {
if (IMPORT_COMPOSE_FILENAME_SET.has(entry.name)) {
const loaded = await this.readComposeCandidate(path.join(this.baseDir, entry.name));
candidates.push({ name: '', composeFile: entry.name, location: entry.name, status: 'loose-root', ...loaded });
}
continue;
}
if (!entry.isDirectory()) continue;
const dir = path.join(this.baseDir, entry.name);
const topCompose = await this.firstComposeFilename(dir);
if (topCompose) {
const loaded = await this.readComposeCandidate(path.join(dir, topCompose));
candidates.push({ name: entry.name, composeFile: topCompose, location: `${entry.name}/${topCompose}`, status: 'listed', ...loaded });
continue;
}
// No compose at the top level: peek exactly one level deeper.
let children: Dirent[];
try {
children = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
console.warn('[FileSystemService] Failed to read subdirectory during import scan:', sanitizeForLog((error as Error)?.message ?? String(error)));
continue;
}
for (const child of children) {
if (candidates.length >= maxCandidates) break;
if (!child.isDirectory() || !child.name || typeof child.name !== 'string') continue;
const childDir = path.join(dir, child.name);
const childCompose = await this.firstComposeFilename(childDir);
if (childCompose) {
const loaded = await this.readComposeCandidate(path.join(childDir, childCompose));
candidates.push({
name: child.name,
composeFile: childCompose,
location: `${entry.name}/${child.name}/${childCompose}`,
status: 'nested',
...loaded,
});
}
}
}
return candidates;
}
async migrateFlatToDirectory(): Promise<void> {
try {
try {