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
@@ -0,0 +1,134 @@
/**
* Route tests for POST /api/stacks/import/move: the guided-import write path.
*
* The service-level filesystem behavior is covered in import-move.test.ts; this
* suite exercises the route's own logic against the real app: the stack:create
* permission gate, body validation, the candidate re-match (404 for a location the
* scan does not surface, including one that is already a stack), the
* error-code-to-status mapping, and the success shape. Fixtures are written
* into the per-file COMPOSE_DIR that setupTestDb wires the default node to.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import fs from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let composeDir: string;
let app: import('express').Express;
let adminCookie: string;
let viewerCookie: string;
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
beforeAll(async () => {
tmpDir = await setupTestDb();
composeDir = process.env.COMPOSE_DIR as string;
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
// A viewer has no stack:create permission, so the route must 403 before it
// touches the filesystem.
const bcrypt = (await import('bcrypt')).default;
const { DatabaseService } = await import('../services/DatabaseService');
DatabaseService.getInstance().addUser({
username: 'viewer1',
password_hash: await bcrypt.hash('viewerpass', 1),
role: 'viewer',
});
const login = await request(app)
.post('/api/auth/login')
.send({ username: 'viewer1', password: 'viewerpass' });
const cookies = login.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('POST /api/stacks/import/move', () => {
it('returns 403 for a user without stack:create', async () => {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', viewerCookie)
.send({ location: 'compose.yaml', name: 'webapp' });
expect(res.status).toBe(403);
});
it('rejects a missing location with a 400', async () => {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ name: 'webapp' });
expect(res.status).toBe(400);
});
it('rejects an invalid stack name with a 400', async () => {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ location: 'compose.yaml', name: 'has space' });
expect(res.status).toBe(400);
});
it('returns 404 when the location matches no scanned candidate', async () => {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ location: 'ghost/compose.yaml', name: 'ghost' });
expect(res.status).toBe(404);
});
it('returns 404 when the location is a stack already in the sidebar', async () => {
// A top-level subdir with a compose file is already a stack, so the scan does
// not surface it as a candidate; the location matches nothing and 404s.
fs.mkdirSync(path.join(composeDir, 'already'), { recursive: true });
fs.writeFileSync(path.join(composeDir, 'already', 'compose.yaml'), COMPOSE);
try {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ location: 'already/compose.yaml', name: 'already2' });
expect(res.status).toBe(404);
} finally {
fs.rmSync(path.join(composeDir, 'already'), { recursive: true, force: true });
}
});
it('returns 409 when the destination stack already exists', async () => {
fs.writeFileSync(path.join(composeDir, 'compose.yaml'), COMPOSE);
fs.mkdirSync(path.join(composeDir, 'taken'), { recursive: true });
fs.writeFileSync(path.join(composeDir, 'taken', 'compose.yaml'), COMPOSE);
try {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ location: 'compose.yaml', name: 'taken' });
expect(res.status).toBe(409);
// The loose file is left in place by the failed move.
expect(fs.existsSync(path.join(composeDir, 'compose.yaml'))).toBe(true);
} finally {
fs.rmSync(path.join(composeDir, 'compose.yaml'), { force: true });
fs.rmSync(path.join(composeDir, 'taken'), { recursive: true, force: true });
}
});
it('moves a loose-root file, trims the name, and returns the created stack name', async () => {
fs.writeFileSync(path.join(composeDir, 'docker-compose.yml'), COMPOSE);
try {
const res = await request(app)
.post('/api/stacks/import/move')
.set('Cookie', adminCookie)
.send({ location: 'docker-compose.yml', name: ' spaced ' });
expect(res.status).toBe(200);
expect(res.body).toEqual({ name: 'spaced' });
expect(fs.existsSync(path.join(composeDir, 'spaced', 'docker-compose.yml'))).toBe(true);
expect(fs.existsSync(path.join(composeDir, 'docker-compose.yml'))).toBe(false);
} finally {
fs.rmSync(path.join(composeDir, 'spaced'), { recursive: true, force: true });
fs.rmSync(path.join(composeDir, 'docker-compose.yml'), { force: true });
}
});
});
+270
View File
@@ -0,0 +1,270 @@
/**
* Tests for FileSystemService.importCandidateIntoStack: the single write path of
* the guided import flow. Uses a real temp directory so the on-disk rename and
* the containment guards are exercised against the actual filesystem.
*/
import { describe, it, expect, afterAll, vi } from 'vitest';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import path from 'path';
const { tmpRoot } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodeOs = require('os');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodePath = require('path');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodeFs = require('fs');
const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-import-move-'));
return { tmpRoot };
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getComposeDir: () => tmpRoot,
getDefaultNodeId: () => 1,
}),
},
}));
import { FileSystemService } from '../services/FileSystemService';
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
afterAll(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
describe('FileSystemService.importCandidateIntoStack', () => {
it('moves a loose-root file into its own stack subfolder', async () => {
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
await FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'docker-compose.yml', composeFile: 'docker-compose.yml', status: 'loose-root' },
'webapp',
);
// The file now lives under <base>/webapp/ and the root copy is gone.
expect(fs.existsSync(path.join(tmpRoot, 'webapp', 'docker-compose.yml'))).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'docker-compose.yml'))).toBe(false);
// Auto-discovery now lists it as a stack.
expect(await FileSystemService.getInstance().getStacks()).toContain('webapp');
});
it('leaves sibling root files (a root .env) untouched when moving a loose-root file', async () => {
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
fs.writeFileSync(path.join(tmpRoot, '.env'), 'TOKEN=keep-me\n');
await FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'compose.yaml', composeFile: 'compose.yaml', status: 'loose-root' },
'onlyfile',
);
expect(fs.existsSync(path.join(tmpRoot, 'onlyfile', 'compose.yaml'))).toBe(true);
// The root .env is not assumed to belong to this compose, so it stays put.
expect(fs.existsSync(path.join(tmpRoot, '.env'))).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'onlyfile', '.env'))).toBe(false);
fs.rmSync(path.join(tmpRoot, '.env'), { force: true });
});
it('promotes a nested stack directory whole, preserving its .env and siblings', async () => {
fs.mkdirSync(path.join(tmpRoot, 'apps', 'vault'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', 'compose.yaml'), COMPOSE);
fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', '.env'), 'SECRET=1\n');
await FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'apps/vault/compose.yaml', composeFile: 'compose.yaml', status: 'nested' },
'vault',
);
expect(fs.existsSync(path.join(tmpRoot, 'vault', 'compose.yaml'))).toBe(true);
// The whole directory moves, so its .env comes along.
expect(fs.existsSync(path.join(tmpRoot, 'vault', '.env'))).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'apps', 'vault'))).toBe(false);
expect(await FileSystemService.getInstance().getStacks()).toContain('vault');
});
it('honors a destination name different from the nested folder name', async () => {
fs.mkdirSync(path.join(tmpRoot, 'group', 'api'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'group', 'api', 'compose.yaml'), COMPOSE);
await FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'group/api/compose.yaml', composeFile: 'compose.yaml', status: 'nested' },
'renamed-api',
);
expect(fs.existsSync(path.join(tmpRoot, 'renamed-api', 'compose.yaml'))).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'group', 'api'))).toBe(false);
});
it('refuses to overwrite an existing destination stack', async () => {
fs.mkdirSync(path.join(tmpRoot, 'taken'), { recursive: true });
fs.writeFileSync(path.join(tmpRoot, 'taken', 'compose.yaml'), 'services:\n existing: {}\n');
fs.writeFileSync(path.join(tmpRoot, 'compose.yml'), COMPOSE);
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'compose.yml', composeFile: 'compose.yml', status: 'loose-root' },
'taken',
),
).rejects.toMatchObject({ code: 'DEST_EXISTS' });
// The source file is left in place; nothing was moved or overwritten.
expect(fs.existsSync(path.join(tmpRoot, 'compose.yml'))).toBe(true);
expect(fs.readFileSync(path.join(tmpRoot, 'taken', 'compose.yaml'), 'utf-8')).toContain('existing');
fs.rmSync(path.join(tmpRoot, 'compose.yml'), { force: true });
});
it('does not clobber a destination that appears between the existence check and the move', async () => {
// Simulate the TOCTOU race: the existence precheck sees nothing, but the
// destination (with a same-named compose file) exists by the time the move
// creates it. The non-recursive mkdir must reject rather than merge and let
// the rename overwrite the existing file.
const dest = path.join(tmpRoot, 'racewin');
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, 'docker-compose.yml'), 'services:\n existing: {}\n');
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
// Force only the destination existence precheck to report "absent".
const accessSpy = vi
.spyOn(fsPromises, 'access')
.mockRejectedValueOnce(Object.assign(new Error('not found'), { code: 'ENOENT' }));
try {
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'docker-compose.yml', composeFile: 'docker-compose.yml', status: 'loose-root' },
'racewin',
),
).rejects.toMatchObject({ code: 'EEXIST' });
// The pre-existing file is intact and the source loose file is untouched.
expect(fs.readFileSync(path.join(dest, 'docker-compose.yml'), 'utf-8')).toContain('existing');
expect(fs.existsSync(path.join(tmpRoot, 'docker-compose.yml'))).toBe(true);
} finally {
accessSpy.mockRestore();
fs.rmSync(dest, { recursive: true, force: true });
fs.rmSync(path.join(tmpRoot, 'docker-compose.yml'), { force: true });
}
});
it('rolls back the empty destination directory when the loose-root rename fails', async () => {
// mkdir succeeds, then the rename fails. The empty destDir it created must be
// removed so a retry with the same name is not blocked by a phantom
// "already exists" conflict on the access() precheck.
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
const renameSpy = vi
.spyOn(fsPromises, 'rename')
.mockRejectedValueOnce(Object.assign(new Error('disk error'), { code: 'EIO' }));
try {
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'docker-compose.yml', composeFile: 'docker-compose.yml', status: 'loose-root' },
'rollback',
),
).rejects.toMatchObject({ code: 'EIO' });
// The empty directory the failed move created was cleaned up...
expect(fs.existsSync(path.join(tmpRoot, 'rollback'))).toBe(false);
// ...and the source loose file is left in place for a retry.
expect(fs.existsSync(path.join(tmpRoot, 'docker-compose.yml'))).toBe(true);
} finally {
renameSpy.mockRestore();
fs.rmSync(path.join(tmpRoot, 'docker-compose.yml'), { force: true });
fs.rmSync(path.join(tmpRoot, 'rollback'), { recursive: true, force: true });
}
});
it('rejects an invalid destination name without touching the filesystem', async () => {
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
try {
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'compose.yaml', composeFile: 'compose.yaml', status: 'loose-root' },
'../escape',
),
).rejects.toMatchObject({ code: 'INVALID_STACK_NAME' });
expect(fs.existsSync(path.join(tmpRoot, 'compose.yaml'))).toBe(true);
} finally {
fs.rmSync(path.join(tmpRoot, 'compose.yaml'), { force: true });
}
});
it('refuses to move a loose-root file that symlinks outside the compose directory', async () => {
const outside = path.join(path.dirname(tmpRoot), `sencho-outside-loose-${Date.now()}.yaml`);
fs.writeFileSync(outside, COMPOSE);
let linked = true;
try {
fs.symlinkSync(outside, path.join(tmpRoot, 'docker-compose.yml'));
} catch {
// Symlink creation needs privilege on some platforms; the assertion runs
// for real on the Linux CI runners.
linked = false;
}
try {
if (!linked) return;
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'docker-compose.yml', composeFile: 'docker-compose.yml', status: 'loose-root' },
'escaped',
),
).rejects.toMatchObject({ code: 'INVALID_PATH' });
// The out-of-tree file is left in place and no destination stack was made.
expect(fs.existsSync(outside)).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'escaped'))).toBe(false);
} finally {
fs.rmSync(path.join(tmpRoot, 'docker-compose.yml'), { force: true });
fs.rmSync(outside, { force: true });
}
});
it('refuses to promote a nested directory that symlinks outside the compose directory', async () => {
const outsideDir = path.join(path.dirname(tmpRoot), `sencho-outside-dir-${Date.now()}`);
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(path.join(outsideDir, 'compose.yaml'), COMPOSE);
const parent = path.join(tmpRoot, 'parent');
fs.mkdirSync(parent, { recursive: true });
let linked = true;
try {
fs.symlinkSync(outsideDir, path.join(parent, 'child'), 'dir');
} catch {
linked = false;
}
try {
if (!linked) return;
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'parent/child/compose.yaml', composeFile: 'compose.yaml', status: 'nested' },
'escaped-dir',
),
).rejects.toMatchObject({ code: 'INVALID_PATH' });
expect(fs.existsSync(path.join(outsideDir, 'compose.yaml'))).toBe(true);
expect(fs.existsSync(path.join(tmpRoot, 'escaped-dir'))).toBe(false);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it('refuses to promote a nested directory whose compose file resolves outside the base', async () => {
// The directory is real and within the base, but the compose file inside it is a
// symlink to an out-of-base file. Without validating the file (only the dir), the
// move would relocate the directory and leave a stack whose compose.yaml still
// points outside the compose directory, which the editor read path would follow.
// Symlink creation is unprivileged on some platforms, so the escaping symlink is
// simulated by resolving the compose file's realpath to an out-of-base target;
// the directory still resolves to itself.
const childDir = path.join(tmpRoot, 'realparent', 'realchild');
fs.mkdirSync(childDir, { recursive: true });
fs.writeFileSync(path.join(childDir, 'compose.yaml'), COMPOSE);
const composePath = path.join(childDir, 'compose.yaml');
const outsideTarget = path.join(path.dirname(tmpRoot), 'sencho-outside-target.yaml');
const realpathSpy = vi
.spyOn(fsPromises, 'realpath')
.mockImplementation(async (p) => {
const s = typeof p === 'string' ? p : p.toString();
return s === composePath ? outsideTarget : s;
});
try {
await expect(
FileSystemService.getInstance().importCandidateIntoStack(
{ location: 'realparent/realchild/compose.yaml', composeFile: 'compose.yaml', status: 'nested' },
'escaped-nested',
),
).rejects.toMatchObject({ code: 'INVALID_PATH' });
// No destination stack was created and the source directory is left in place.
expect(fs.existsSync(path.join(tmpRoot, 'escaped-nested'))).toBe(false);
expect(fs.existsSync(childDir)).toBe(true);
} finally {
realpathSpy.mockRestore();
fs.rmSync(path.join(tmpRoot, 'realparent'), { recursive: true, force: true });
}
});
});
+32 -18
View File
@@ -50,42 +50,48 @@ describe('FileSystemService.findImportCandidates', () => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('classifies listed, loose-root, and nested compose files and ignores the rest', async () => {
it('surfaces loose-root and nested files and skips directories already a stack', async () => {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const listed = candidates.find((c) => c.status === 'listed');
expect(listed).toMatchObject({ name: 'immich', composeFile: 'compose.yaml', location: 'immich/compose.yaml' });
expect(listed?.content).toContain('services:');
// immich is a top-level subdir with a compose file, so it is already a stack
// (it shows in the sidebar) and is not offered as an import candidate.
expect(candidates.some((c) => c.name === 'immich')).toBe(false);
const loose = candidates.find((c) => c.status === 'loose-root');
expect(loose).toMatchObject({ name: '', composeFile: 'docker-compose.yml', location: 'docker-compose.yml' });
expect(loose?.content).toContain('services:');
const nested = candidates.find((c) => c.status === 'nested');
expect(nested).toMatchObject({ name: 'vault', composeFile: 'compose.yaml', location: 'apps/vault/compose.yaml' });
// The directory with only a README produced no candidate.
expect(candidates.some((c) => c.name === 'notes')).toBe(false);
expect(candidates).toHaveLength(3);
expect(candidates).toHaveLength(2);
});
it('flags oversized compose files instead of reading them', async () => {
const bigDir = path.join(tmpRoot, 'big');
// Nested under a wrapper with no top-level compose, so the scan descends and
// surfaces the inner file (a top-level dir with a compose file is a stack and
// would be skipped).
const bigDir = path.join(tmpRoot, 'oversized-wrap', 'big');
fs.mkdirSync(bigDir, { recursive: true });
fs.writeFileSync(path.join(bigDir, 'compose.yaml'), 'x'.repeat(1_048_577));
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const big = candidates.find((c) => c.name === 'big');
expect(big?.status).toBe('nested');
expect(big?.oversized).toBe(true);
expect(big?.content).toBeNull();
} finally {
fs.rmSync(bigDir, { recursive: true, force: true });
fs.rmSync(path.join(tmpRoot, 'oversized-wrap'), { recursive: true, force: true });
}
});
it('skips a non-regular file (a directory named compose.yaml) without reading it', async () => {
// A directory named compose.yaml passes the access() probe; the isFile()
// guard means it is reported as unreadable rather than read as content.
const weirdDir = path.join(tmpRoot, 'weird');
// Nested under a wrapper so it surfaces as a candidate at all.
const weirdDir = path.join(tmpRoot, 'weird-wrap', 'weird');
fs.mkdirSync(path.join(weirdDir, 'compose.yaml'), { recursive: true });
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
@@ -94,7 +100,7 @@ describe('FileSystemService.findImportCandidates', () => {
expect(weird?.content).toBeNull();
expect(weird?.oversized).toBe(false);
} finally {
fs.rmSync(weirdDir, { recursive: true, force: true });
fs.rmSync(path.join(tmpRoot, 'weird-wrap'), { recursive: true, force: true });
}
});
@@ -102,7 +108,8 @@ describe('FileSystemService.findImportCandidates', () => {
// Sibling of the temp compose root, i.e. outside the base dir.
const outside = path.join(path.dirname(tmpRoot), `sencho-outside-${Date.now()}.yaml`);
fs.writeFileSync(outside, COMPOSE);
const escDir = path.join(tmpRoot, 'escape');
// Nested under a wrapper so the symlinked compose file surfaces as a candidate.
const escDir = path.join(tmpRoot, 'escape-wrap', 'escape');
fs.mkdirSync(escDir, { recursive: true });
let linked = true;
try {
@@ -122,14 +129,15 @@ describe('FileSystemService.findImportCandidates', () => {
expect(warnSpy).toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
fs.rmSync(escDir, { recursive: true, force: true });
fs.rmSync(path.join(tmpRoot, 'escape-wrap'), { recursive: true, force: true });
fs.rmSync(outside, { force: true });
}
});
it('keeps the top-level listing and does not also descend into it', async () => {
// A directory that is already a stack and also has a compose file one level
// deeper yields only the listed entry, never the nested child.
it('skips a directory that is already a stack and does not descend into it', async () => {
// A directory that is already a stack (top-level compose) and also has a
// compose file one level deeper yields no candidates: it is skipped as an
// existing stack, and the scan does not descend into it to surface the child.
const dir = path.join(tmpRoot, 'both');
fs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
@@ -137,15 +145,21 @@ describe('FileSystemService.findImportCandidates', () => {
try {
const candidates = await FileSystemService.getInstance().findImportCandidates();
const fromBoth = candidates.filter((c) => c.location.startsWith('both/'));
expect(fromBoth).toHaveLength(1);
expect(fromBoth[0]).toMatchObject({ name: 'both', status: 'listed', location: 'both/compose.yaml' });
expect(fromBoth).toHaveLength(0);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it('truncates at maxCandidates', async () => {
const candidates = await FileSystemService.getInstance().findImportCandidates(2);
expect(candidates).toHaveLength(2);
// Base fixtures yield 2 candidates (loose-root + nested); add a third loose
// file so a cap of 2 actually truncates rather than coincidentally matching.
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
try {
const candidates = await FileSystemService.getInstance().findImportCandidates(2);
expect(candidates).toHaveLength(2);
} finally {
fs.rmSync(path.join(tmpRoot, 'compose.yaml'), { force: true });
}
});
});
+48 -2
View File
@@ -256,8 +256,8 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => {
});
// Read-only scan of the compose directory for the guided first-import flow.
// Surfaces compose files found on disk (loose at the root, already a stack, or
// one directory too deep) with a dry preview so a new user can land their first
// Surfaces compose files that are not yet stacks (loose at the root, or one
// directory too deep) with a dry preview so a new user can land their first
// stack without reading the docs first. Performs no writes.
stacksRouter.get('/import/scan', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
@@ -290,6 +290,52 @@ stacksRouter.get('/import/scan', async (req: Request, res: Response) => {
}
});
// Move a discovered import candidate into its own stack directory so Sencho
// picks it up. The single write path of the guided import flow: it relocates a
// loose or nested compose file on disk and never captures it into a store. The
// source is re-derived from a fresh scan and matched by location, so the client
// cannot point the move at an arbitrary path.
stacksRouter.post('/import/move', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:create')) return;
const { location, name } = req.body as { location?: unknown; name?: unknown };
if (typeof location !== 'string' || !location) {
return res.status(400).json({ error: 'A candidate location is required' });
}
if (typeof name !== 'string' || !isValidStackName(name.trim())) {
return res.status(400).json({ error: 'Name must be alphanumeric, hyphens, or underscores only' });
}
const destName = name.trim();
try {
const fsSvc = FileSystemService.getInstance(req.nodeId);
const match = (await fsSvc.findImportCandidates()).find((c) => c.location === location);
if (!match) {
return res.status(404).json({ error: 'That compose file was not found. Rescan and try again.' });
}
await fsSvc.importCandidateIntoStack(match, destName);
invalidateNodeCaches(req.nodeId);
dlog(`[Stacks] Imported compose file into stack: ${sanitizeForLog(destName)}`);
res.json({ name: destName });
} catch (error) {
const code = (error as { code?: string })?.code;
// A destination that already exists (our own DEST_EXISTS) or that appeared
// between the existence check and the rename (EEXIST/ENOTEMPTY) is a clean
// conflict, not a server error.
if (code === 'DEST_EXISTS' || code === 'EEXIST' || code === 'ENOTEMPTY') {
return res.status(409).json({ error: `A stack named "${destName}" already exists` });
}
if (code === 'INVALID_PATH' || code === 'INVALID_STACK_NAME') {
return res.status(400).json({ error: 'Invalid path' });
}
// The candidate vanished between the scan above and the move (e.g. deleted
// on disk): treat it like a stale candidate rather than a server fault.
if (code === 'ENOENT') {
return res.status(404).json({ error: 'That compose file was not found. Rescan and try again.' });
}
console.error('Failed to import compose file into stack:', sanitizeForLog((error as Error)?.message ?? String(error)));
res.status(500).json({ error: 'Failed to move the compose file into place' });
}
});
type BulkLifecycleAction = 'start' | 'stop' | 'restart' | 'update';
const VALID_BULK_ACTIONS: ReadonlySet<BulkLifecycleAction> = new Set(['start', 'stop', 'restart', 'update']);
const BULK_PARALLELISM = 4;
+134 -12
View File
@@ -45,21 +45,29 @@ const IMPORT_COMPOSE_FILENAME_SET = new Set<string>(IMPORT_COMPOSE_FILENAMES);
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.
* A compose file discovered on disk during the guided import scan that is not yet
* a stack. `status` records why: 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. A top-level subdirectory with a compose file is
* already a stack (it shows in the sidebar), so the scan skips it and it never
* appears here. `content` is null when the file was oversized or unreadable.
*/
export interface ImportCandidateRaw {
name: string;
composeFile: string;
location: string;
status: 'listed' | 'loose-root' | 'nested';
status: 'loose-root' | 'nested';
content: string | null;
oversized: boolean;
}
/**
* The subset of an import candidate the move path needs: where the compose file
* sits and whether it is loose at the root or nested. The scan only surfaces
* these two placements, so any candidate it returns can be moved into place.
*/
export type MovableImportCandidate = Pick<ImportCandidateRaw, 'location' | 'composeFile' | 'status'>;
// 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
@@ -496,10 +504,11 @@ export class FileSystemService {
}
/**
* 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.
* Scan the compose directory for compose files that are not yet stacks: loose
* files at the root and compose files one directory too deep. A top-level
* subdirectory with a compose file is already a stack, so it is skipped, not
* surfaced. 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[] = [];
@@ -530,8 +539,9 @@ export class FileSystemService {
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 });
// A top-level subdirectory with a compose file is already a stack (it
// shows in the sidebar), so it is not an import candidate. Skip it and do
// not descend: any compose files deeper inside belong to this stack.
continue;
}
@@ -564,6 +574,118 @@ export class FileSystemService {
return candidates;
}
/**
* Move a discovered import candidate into its own top-level stack directory so
* auto-discovery (getStacks) picks it up. This is the single write path of the
* guided import flow and only runs on an explicit, per-file user action.
*
* A `loose-root` file is moved into <base>/<destName>/<composeFile>: only the
* chosen compose file moves, so sibling files referenced by a relative path
* (e.g. a root .env) stay where they are. A `nested` stack directory
* (<parent>/<child>) is promoted whole to <base>/<destName>, preserving its
* .env and any other files.
*
* Never overwrites: a pre-existing destination is a conflict. Source and
* destination are both confirmed to resolve inside the compose directory
* before the rename, mirroring readComposeCandidate / resolveSafeStackPath.
*/
async importCandidateIntoStack(
candidate: MovableImportCandidate,
destName: string,
): Promise<void> {
// Validate the name, then re-establish containment inline at the sinks below
// (path.resolve against the safe base + a single startsWith). resolveStackDir
// applies the same check, but only the inline form is credited by static
// analysis, matching the read and backup paths in this file.
if (!isValidStackName(destName)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
const baseResolved = path.resolve(this.baseDir);
const destDir = path.resolve(baseResolved, destName);
if (!destDir.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Invalid stack name'), { code: 'INVALID_STACK_NAME' });
}
// No overwrite: the destination stack must not already exist. ENOENT is the
// expected happy path; any other access error (e.g. EACCES) should surface.
try {
await fsPromises.access(destDir);
throw Object.assign(new Error(`A stack named "${destName}" already exists`), { code: 'DEST_EXISTS' });
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === 'DEST_EXISTS') throw error;
if (code !== 'ENOENT') throw error;
}
// The on-disk source the candidate points at, confirmed within the base.
const source = path.resolve(this.baseDir, candidate.location);
this.assertWithinBase(source);
if (candidate.status === 'loose-root') {
const realSource = await this.realPathWithinBase(source);
// Build the relocated file path through the same inline containment barrier so
// the rename target is a credited safe path (candidate.composeFile is an
// allowlisted compose filename, but it is traced from the request).
const destComposePath = path.resolve(baseResolved, destName, candidate.composeFile);
if (!destComposePath.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' });
}
// Non-recursive mkdir is the atomic no-overwrite guard: if the destination
// appeared between the access() precheck above and here, this throws
// EEXIST (mapped to a 409 conflict) instead of merging into the existing
// directory and letting the rename clobber a same-named file.
await fsPromises.mkdir(destDir);
try {
await fsPromises.rename(realSource, destComposePath);
} catch (error) {
// mkdir just created destDir empty; a failed rename would otherwise strand
// it, and a retry with the same name would then hit the access() precheck
// and 409 for a stack that was never created. Remove the empty dir we made
// (best-effort, only ever empty) and rethrow the original failure.
await fsPromises.rmdir(destDir).catch(() => undefined);
throw error;
}
return;
}
if (candidate.status === 'nested') {
// Promote the whole child directory (<parent>/<child>) one level up so the
// stack keeps its .env and any sibling files.
const sourceDir = path.dirname(source);
this.assertWithinBase(sourceDir);
const realSourceDir = await this.realPathWithinBase(sourceDir);
// The directory can be real and within the base while the compose file inside
// it symlinks out of the base. Confirm the compose file resolves within the
// (real) source directory, otherwise the symlink rides the directory move into
// a stack folder and the editor would later follow it to the out-of-base file.
const realCompose = await this.realPathWithinBase(source);
if (!isPathWithinBase(realCompose, realSourceDir)) {
throw Object.assign(new Error('Compose file escapes the import directory'), { code: 'INVALID_PATH' });
}
await fsPromises.rename(realSourceDir, destDir);
return;
}
// Exhaustiveness guard: the union is loose-root | nested. A status added later
// fails to compile here until it is handled, rather than silently taking a move
// path that does not fit it.
const unhandled: never = candidate.status;
throw new Error(`Unhandled import candidate status: ${String(unhandled)}`);
}
/**
* Resolve symlinks and confirm the real target is still inside the compose
* directory before a write moves it, so a symlinked source cannot relocate a
* file from outside the base. Returns the canonical path to operate on.
*/
private async realPathWithinBase(p: string): Promise<string> {
const real = await fsPromises.realpath(p);
if (!isPathWithinBase(real, this.baseDir)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
}
return real;
}
async migrateFlatToDirectory(): Promise<void> {
try {
try {
+2 -5
View File
@@ -33,12 +33,9 @@ Sencho creates a new directory inside `COMPOSE_DIR` with the chosen seed file. Y
### Import existing files
If you already have compose files on disk, the **Import** tab helps you land your first stack without reading the rest of this page. It scans your compose directory and lists every compose file it finds, with a dry preview of each file's services, ports, volumes, and env files. The scan is read only: it never moves, writes, or changes any of your files.
If you already have compose files on disk, the **Import** tab helps you land your first stack without reading the rest of this page. It scans your compose directory for compose files that are not yet stacks and lists them with a dry preview of each file's services, ports, volumes, and env files. Scanning is read only: it never moves, writes, or changes anything. The only change to disk is the **Move into place** action, which you trigger per file and confirm before it runs. Stacks that already sit in their own subfolder are not listed here; they appear in the sidebar.
Each result shows where the file sits:
- **In sidebar**: the file is in its own subfolder inside the compose directory, so it is already a stack. Expand it to preview its services, or open it directly in the sidebar.
- **Needs move**: the file is loose at the root of the compose directory, or one folder too deep, so it will not show up as a stack on its own. Sencho shows the exact path to move it to (its own subfolder inside the compose directory) and a **Rescan** button. Move the file there, click **Rescan**, and it appears.
Each listed file is one that will not show up as a stack on its own, because it is loose at the root of the compose directory or one folder too deep. Expand it, give the stack a name, and click **Move into place** to relocate it into its own subfolder; confirm the move, and it appears as a stack in the sidebar. This only relocates the compose file on disk: it does not adopt or capture running containers, and containers start only when you deploy the stack. Moving a loose root file relocates only that file, so anything it references by a relative path (such as a root `.env`) stays where it is. You can also arrange the file by hand and click **Rescan** instead.
The top of the panel shows the directory Sencho is scanning and a reminder of the path rule: each stack lives in its own subfolder, and the host mount path must match the path inside the container so relative volume paths resolve. See [Configuration](/getting-started/configuration) for the full explanation.
@@ -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