feat(stacks): one-click import for stray compose files (#1320)

* feat(stacks): move discovered import candidates into place

The guided import flow previewed loose and nested compose files but could
not act on them, so it only told the user where to move files by hand. Add
an opt-in "Move into place" action: relocate a loose-root file into its own
<name>/ subfolder, or promote a nested stack directory one level up, so
Sencho's filesystem discovery lists it as a stack. The file stays a plain
compose file on disk; nothing is captured into a store. The move re-derives
the candidate from a fresh scan and matches by location, validates the
destination name and containment, resolves symlinks before the rename, and
never overwrites an existing stack. Backend and frontend both gate the
action on stack:create.

Also fix the rescan flicker: scan results now stay on screen while a rescan
runs (only the Rescan button shows progress) instead of the whole panel
collapsing to a spinner, and an empty rescan surfaces a toast.

* fix(stacks): make import-move destination creation atomic

The loose-root branch created the destination directory with mkdir
recursive after an access() existence precheck. If the destination
appeared between the check and the create, recursive accepted the
existing directory and the following rename could overwrite a
same-named compose file inside it, so the intended conflict response
never fired. Use a non-recursive mkdir so a destination that already
exists raises a conflict instead of being merged into. Add a regression
test that forces the precheck to miss and asserts the existing file is
left intact.

* fix(stacks): only offer not-yet-imported compose files in the import tab

The import tab listed every compose file in the compose directory,
including ones that are already stacks (a top-level subfolder with a
compose file), which just duplicated the sidebar. The scan now skips
those and surfaces only files that still need importing: a compose file
loose at the compose-dir root, or one nested a folder too deep.

Also harden the move-into-place write path that turns a stray file into
a stack: a failed rename after the destination folder is created now
rolls back the empty folder, so a retry is not blocked by a false
"already exists" conflict, and the move switches on an exhaustive set of
placements so a new one cannot silently take the wrong branch. The
sidebar refreshes after a move so the imported stack appears right away,
and the docs describe import as relocating a file, not capturing running
containers.

* fix(stacks): reject a nested import whose compose file escapes the base

The move-into-place path for a nested compose file validated only the
parent directory's real path, not the compose file itself. A directory
that is real and inside the compose base but holds a compose file
symlinked outside the base would survive the directory move and become a
stack whose compose file still points outside the base, which the editor
read path would then follow. The move now resolves the compose file too
and refuses it unless it stays inside the resolved source directory,
matching the loose-root check and the scan's preview reader.

* fix(stacks): satisfy CodeQL path and log analysis in import-move

The import-move write path built its destination directory from the
user-provided stack name through resolveStackDir, whose containment
barrier is wrapped in a helper that static analysis does not credit, so
every filesystem sink on the destination was flagged as path injection.
Re-establish the resolve-against-the-safe-base plus startsWith barrier
inline at the sinks, matching the read and backup paths in the same file,
and route the relocated file path through the same check. The name is
already restricted to an alphanumeric, hyphen, and underscore allowlist,
so the containment can never actually fail; this only makes the existing
safety visible to the analyzer.

Also log the move route's error as a sanitized message rather than the
raw error object, so a name embedded in an error message cannot forge log
lines.
This commit is contained in:
Anso
2026-06-05 22:35:04 -04:00
committed by GitHub
parent 28ea610e81
commit f7f3afe05a
8 changed files with 762 additions and 86 deletions
@@ -344,7 +344,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
<div role="tabpanel" id={panelId('import')} aria-labelledby={tabId('import')}>
<ImportStackPanel
onClose={() => onOpenChange(false)}
onOpenStack={(name) => { void onStackCreated(name, activeNode?.id); }}
onImported={() => { void onStacksChanged(); }}
/>
</div>
)}
@@ -5,15 +5,20 @@ import {
RefreshCw,
ChevronDown,
ChevronRight,
ArrowUpRight,
AlertTriangle,
CheckCircle2,
FolderInput,
} from 'lucide-react';
import { ModalBody, ModalFooter } from '../ui/modal';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { useAuth } from '@/context/AuthContext';
// Mirrors backend isValidStackName so the move button stays disabled until the
// name the backend would accept; the backend remains authoritative.
const VALID_STACK_NAME = /^[a-zA-Z0-9_-]+$/;
interface ServicePreview {
name: string;
@@ -27,7 +32,7 @@ interface ImportCandidate {
name: string;
composeFile: string;
location: string;
status: 'listed' | 'loose-root' | 'nested';
status: 'loose-root' | 'nested';
services: ServicePreview[];
warnings: string[];
parseError?: string;
@@ -40,8 +45,9 @@ interface ImportScanResponse {
export interface ImportStackPanelProps {
onClose: () => void;
// Navigate to an already-listed stack (it is already in the sidebar).
onOpenStack: (name: string) => void;
// Refresh the sidebar stack list after a file is moved into place, so the
// newly imported stack shows up without closing the modal.
onImported: () => void;
}
// Join a host compose-dir path with extra segments for display only. The dir is
@@ -51,12 +57,18 @@ function joinPath(base: string, ...segments: string[]): string {
return [trimmed, ...segments].join('/');
}
export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps) {
export function ImportStackPanel({ onClose, onImported }: ImportStackPanelProps) {
const { can } = useAuth();
const canCreate = can('stack:create');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<ImportScanResponse | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [movingLocation, setMovingLocation] = useState<string | null>(null);
const scan = useCallback(async () => {
// `announce` toasts an empty result. The button-driven rescan keeps the
// existing list on screen (no full-panel swap), so without this the user has
// no signal that a scan that found nothing actually ran.
const scan = useCallback(async (opts?: { announce?: boolean }) => {
setLoading(true);
try {
const response = await apiFetch('/stacks/import/scan');
@@ -64,7 +76,11 @@ export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps
const body = await response.json().catch(() => ({}));
throw new Error((body as { error?: string })?.error || 'Failed to scan the compose directory.');
}
setData((await response.json()) as ImportScanResponse);
const parsed = (await response.json()) as ImportScanResponse;
setData(parsed);
if (opts?.announce && parsed.candidates.length === 0) {
toast.info('No compose files to import.');
}
} catch (error) {
console.error('Failed to scan compose directory:', error);
toast.error((error as Error).message || 'Failed to scan the compose directory.');
@@ -73,6 +89,33 @@ export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps
}
}, []);
const move = useCallback(async (location: string, name: string) => {
setMovingLocation(location);
try {
const response = await apiFetch('/stacks/import/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location, name }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error((body as { error?: string })?.error || 'Failed to move the compose file into place.');
}
const result = (await response.json().catch(() => ({}))) as { name?: unknown };
const importedName = typeof result.name === 'string' ? result.name : name;
toast.success(`Imported "${importedName}".`);
// Refresh both surfaces: the import list drops the now-placed file, and the
// sidebar picks up the new stack.
onImported();
await scan();
} catch (error) {
console.error('Failed to move compose file into place:', error);
toast.error((error as Error).message || 'Failed to move the compose file into place.');
} finally {
setMovingLocation(null);
}
}, [scan, onImported]);
useEffect(() => {
void scan();
}, [scan]);
@@ -112,7 +155,7 @@ export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps
</p>
</div>
{loading ? (
{loading && !data ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-stat-subtitle">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Scanning
@@ -120,25 +163,31 @@ export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps
) : candidates.length === 0 ? (
<div className="py-10 text-center">
<FolderSearch className="mx-auto h-6 w-6 text-stat-icon" strokeWidth={1.5} />
<p className="mt-3 text-sm text-stat-title">No compose files found.</p>
<p className="mt-3 text-sm text-stat-title">No compose files to import.</p>
<p className="mx-auto mt-1 max-w-sm text-xs leading-relaxed text-stat-subtitle">
Put each stack in its own subfolder inside the compose directory, then rescan. Or
pick another source above to create one from scratch.
Stacks already in their own subfolder show up in the sidebar. Drop a loose compose
file in the compose directory and rescan, or pick another source above to create one.
</p>
</div>
) : (
<div className="space-y-2">
// Keep the list mounted during a rescan (only the Rescan button
// animates) so the modal does not change height. aria-busy + the
// dimmed, click-blocked cue (opacity + pointer-events-none) signal
// the in-flight scan without a layout swap.
<div
className={`space-y-2${loading ? ' pointer-events-none opacity-60' : ''}`}
aria-busy={loading}
>
{candidates.map((c) => (
<CandidateCard
key={c.location}
candidate={c}
composeDir={composeDir}
expanded={expanded.has(c.location)}
canCreate={canCreate}
moving={movingLocation === c.location}
onToggle={() => toggle(c.location)}
onOpenStack={(name) => {
onClose();
onOpenStack(name);
}}
onMove={(name) => void move(c.location, name)}
/>
))}
</div>
@@ -146,14 +195,14 @@ export function ImportStackPanel({ onClose, onOpenStack }: ImportStackPanelProps
</ModalBody>
</ScrollArea>
<ModalFooter
hint="READ ONLY · NO FILES CHANGED"
hint="SCAN IS READ ONLY · MOVING ASKS FIRST"
secondary={
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
}
primary={
<Button onClick={() => void scan()} disabled={loading}>
<Button onClick={() => void scan({ announce: true })} disabled={loading}>
{loading ? (
<><Loader2 className="mr-1.5 h-4 w-4 animate-spin" strokeWidth={1.5} />Scanning</>
) : (
@@ -170,18 +219,28 @@ function CandidateCard({
candidate,
composeDir,
expanded,
canCreate,
moving,
onToggle,
onOpenStack,
onMove,
}: {
candidate: ImportCandidate;
composeDir: string;
expanded: boolean;
canCreate: boolean;
moving: boolean;
onToggle: () => void;
onOpenStack: (name: string) => void;
onMove: (name: string) => void;
}) {
const { name, composeFile, location, status, services, warnings, parseError } = candidate;
// Prefill the destination name: a nested stack already has a folder name worth
// keeping; a loose root file has none to derive, so the user supplies one.
const [destName, setDestName] = useState(status === 'nested' ? name : '');
const [confirming, setConfirming] = useState(false);
const trimmedName = destName.trim();
const nameValid = VALID_STACK_NAME.test(trimmedName);
const displayName = name || '<name>';
const target = joinPath(composeDir, displayName, composeFile);
const target = joinPath(composeDir, trimmedName || displayName, composeFile);
return (
<div className="rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
@@ -200,27 +259,69 @@ function CandidateCard({
<span className="block truncate font-mono text-sm text-stat-value">{displayName}</span>
<span className="block truncate font-mono text-[10px] text-stat-subtitle">{location}</span>
</span>
<StatusBadge status={status} />
<StatusBadge />
</button>
{expanded && (
<div className="border-t border-card-border/60 px-3 py-2.5 space-y-2.5">
{status === 'listed' ? (
<button
type="button"
onClick={() => onOpenStack(name)}
className="inline-flex items-center gap-1.5 text-xs text-brand hover:underline"
>
Open in sidebar
<ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</button>
) : (
<div className="flex gap-2 rounded-md border border-warning/30 bg-warning/5 px-2.5 py-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
<div className="text-xs leading-relaxed text-stat-subtitle">
Not in its own subfolder, so it will not show as a stack. Move it to{' '}
<span className="break-all font-mono text-stat-value">{target}</span>, then rescan.
</div>
<div className="flex gap-2 rounded-md border border-warning/30 bg-warning/5 px-2.5 py-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
<div className="text-xs leading-relaxed text-stat-subtitle">
Not in its own subfolder, so it will not show as a stack.{' '}
{canCreate ? (
'Move it into place below, or arrange it by hand and rescan.'
) : (
<>
Move it to <span className="break-all font-mono text-stat-value">{target}</span>, then
rescan.
</>
)}
</div>
</div>
{canCreate && (
<div className="space-y-2 rounded-md border border-card-border bg-card/60 px-2.5 py-2.5">
<Input
value={destName}
onChange={(e) => {
setDestName(e.target.value);
setConfirming(false);
}}
placeholder={status === 'nested' ? name : 'Stack name (e.g., myapp)'}
disabled={moving}
aria-label="Destination stack name"
className="font-mono text-xs"
/>
<div className="break-all font-mono text-[10px] text-stat-subtitle"> {target}</div>
{status === 'loose-root' && (
<p className="text-[10px] leading-relaxed text-stat-subtitle">
Only this file moves. Files it references by a relative path (like a root .env) stay
put.
</p>
)}
{confirming ? (
<div className="flex items-center gap-2">
<span className="flex-1 text-[11px] text-stat-subtitle">Move it on disk?</span>
<Button size="sm" variant="ghost" onClick={() => setConfirming(false)} disabled={moving}>
Cancel
</Button>
<Button size="sm" onClick={() => onMove(trimmedName)} disabled={moving || !nameValid}>
{moving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" strokeWidth={1.5} />
Moving
</>
) : (
'Confirm move'
)}
</Button>
</div>
) : (
<Button size="sm" onClick={() => setConfirming(true)} disabled={moving || !nameValid}>
<FolderInput className="mr-1.5 h-3.5 w-3.5" strokeWidth={1.5} />
Move into place
</Button>
)}
</div>
)}
@@ -239,15 +340,7 @@ function CandidateCard({
);
}
function StatusBadge({ status }: { status: ImportCandidate['status'] }) {
if (status === 'listed') {
return (
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-[10px] uppercase tracking-[0.12em] text-success">
<CheckCircle2 className="h-3 w-3" strokeWidth={1.5} />
In sidebar
</span>
);
}
function StatusBadge() {
return (
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-warning">
Needs move