diff --git a/__tests__/fullstack-init-next-flatten.test.ts b/__tests__/fullstack-init-next-flatten.test.ts
new file mode 100644
index 0000000..5337e2f
--- /dev/null
+++ b/__tests__/fullstack-init-next-flatten.test.ts
@@ -0,0 +1,219 @@
+/**
+ * `lt fullstack init --next` clones `lenneTech/nuxt-base-starter` into
+ * `projects/app/`, but the actual Nuxt app lives one level deeper at
+ * `projects/app/nuxt-base-template/`. The repo's root `package.json`
+ * is the npm scaffolder `create-nuxt-base` (a wrapper CLI), not the
+ * app, so `cd projects/app && pnpm install && pnpm dev` (per the
+ * generated README) does not work — the friction-author had to use
+ * `cd projects/app/nuxt-base-template && pnpm install --ignore-workspace
+ * && pnpm dev` (LLM-test 2026-05-03 friction #3 entry 20:30).
+ *
+ * After cloning, the CLI flattens the layout so `projects/app/` IS
+ * the Nuxt app:
+ *
+ * 1. If `projects/app/nuxt-base-template/` exists, its contents
+ * (including dotfiles like `.env.example`, `.gitignore`) replace
+ * the cloned root.
+ * 2. Wrapper-only files at the root (the scaffolder `package.json`
+ * with `name: "create-nuxt-base"`, `index.js`, `pnpm-lock.yaml`,
+ * etc.) disappear.
+ * 3. The `nuxt-base-template/` subdirectory itself is removed.
+ *
+ * Defense-in-depth: if extraction fails (e.g. `nuxt-base-template/`
+ * isn't a directory), `projects/app/` stays untouched. The pre-flatten
+ * layout is annoying but functional — better than wiping the user's
+ * clone when a future repo reshape removes the wrapper.
+ *
+ * Both branches of `nuxt-base-starter` (`main` and `next`) currently
+ * ship the wrapper layout, so this fix applies to both `--next` and
+ * the legacy default-branch path. We name the test file after `--next`
+ * because that's the friction surface that prompted the change.
+ *
+ * Implementation note: ts-jest treats every test file as part of one
+ * TypeScript program, so a top-level `const { filesystem } = require(
+ * 'gluegun')` collides with the same declaration in
+ * `fullstack-claude-md-patching.test.ts` (TS2451). We require gluegun
+ * lazily inside the describe block — the same pattern the other
+ * `fullstack-init-next-*.test.ts` files use.
+ */
+
+describe('Fullstack init nuxt-base-template flatten', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { filesystem } = require('gluegun');
+
+ let tempDir: string;
+
+ beforeEach(() => {
+ // Each test gets its own temp dir to mirror the post-clone state.
+ tempDir = filesystem.path('__tests__', `temp-flatten-${Date.now()}-${Math.floor(Math.random() * 1e6)}`);
+ filesystem.dir(tempDir);
+ });
+
+ afterEach(() => {
+ filesystem.remove(tempDir);
+ });
+
+ /**
+ * Build a fixture that mirrors `git clone nuxt-base-starter` output:
+ * scaffolder package.json + index.js + pnpm-lock.yaml at the root,
+ * plus the actual Nuxt app under `nuxt-base-template/`.
+ */
+ function seedClonedLayout(dest: string): void {
+ filesystem.dir(dest);
+
+ // Wrapper / scaffolder files at the cloned root — these must be
+ // removed by the flatten so the Nuxt app's own files surface.
+ filesystem.write(filesystem.path(dest, 'package.json'), {
+ name: 'create-nuxt-base',
+ version: '2.6.7',
+ bin: { 'create-nuxt-base': 'index.js' },
+ });
+ filesystem.write(filesystem.path(dest, 'index.js'), '#!/usr/bin/env node\n// scaffolder\n');
+ filesystem.write(filesystem.path(dest, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
+ filesystem.write(filesystem.path(dest, 'README.md'), '# create-nuxt-base wrapper\n');
+
+ // The actual Nuxt app, including a dotfile to verify hidden-file
+ // movement works (gluegun copy / fs.cp behaviour varies).
+ const tmplDir = filesystem.path(dest, 'nuxt-base-template');
+ filesystem.dir(tmplDir);
+ filesystem.write(filesystem.path(tmplDir, 'package.json'), {
+ name: 'nuxt-base-template',
+ private: true,
+ type: 'module',
+ });
+ filesystem.write(filesystem.path(tmplDir, 'nuxt.config.ts'), 'export default defineNuxtConfig({})\n');
+ filesystem.write(filesystem.path(tmplDir, '.env.example'), 'NUXT_PUBLIC_API_URL=http://localhost:3000\n');
+ filesystem.write(filesystem.path(tmplDir, '.gitignore'), 'node_modules\n.nuxt\n');
+ filesystem.dir(filesystem.path(tmplDir, 'app'));
+ filesystem.write(filesystem.path(tmplDir, 'app', 'app.vue'), 'app
\n');
+ }
+
+ /**
+ * Lazy import of FrontendHelper so the test file doesn't pay the
+ * cost of loading every extension at module-eval time, and so a
+ * `tsc --noEmit` of the test suite doesn't pick up the helper's
+ * full toolbox-shaped surface.
+ */
+ function loadFrontendHelper(): {
+ FrontendHelper: new (toolbox: Record) => {
+ flattenNuxtBaseTemplate: (dest: string) => Promise<{ flattened: boolean; reason?: string }>;
+ };
+ } {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require('../src/extensions/frontend-helper');
+ }
+
+ /**
+ * Minimal toolbox stub — flatten only needs filesystem + a quiet
+ * print surface for diagnostics. We re-use gluegun's real filesystem
+ * so the test exercises the same path-handling the CLI does.
+ */
+ function makeToolbox(): Record {
+ return {
+ filesystem,
+ print: { error: () => {}, info: () => {}, warning: () => {} },
+ };
+ }
+
+ test('flattens nuxt-base-template/ contents into the project app dir', async () => {
+ const dest = filesystem.path(tempDir, 'projects', 'app');
+ seedClonedLayout(dest);
+
+ const { FrontendHelper } = loadFrontendHelper();
+ const helper = new FrontendHelper(makeToolbox());
+
+ const result = await helper.flattenNuxtBaseTemplate(dest);
+
+ expect(result.flattened).toBe(true);
+
+ // The wrapper sub-dir is gone …
+ expect(filesystem.exists(filesystem.path(dest, 'nuxt-base-template'))).toBe(false);
+
+ // … and the template's package.json is now at the project root,
+ // overwriting the scaffolder's `create-nuxt-base` package.
+ const pkg = filesystem.read(filesystem.path(dest, 'package.json'), 'json');
+ expect(pkg.name).toBe('nuxt-base-template');
+
+ // Nested files survive the move.
+ expect(filesystem.read(filesystem.path(dest, 'app', 'app.vue'))).toContain('');
+
+ // Dotfiles (commonly missed by naive copy implementations) survive too.
+ expect(filesystem.read(filesystem.path(dest, '.env.example'))).toContain('NUXT_PUBLIC_API_URL');
+ expect(filesystem.read(filesystem.path(dest, '.gitignore'))).toContain('node_modules');
+
+ // Wrapper-only files that aren't in the template are gone.
+ expect(filesystem.exists(filesystem.path(dest, 'index.js'))).toBe(false);
+ expect(filesystem.exists(filesystem.path(dest, 'pnpm-lock.yaml'))).toBe(false);
+ });
+
+ test('is a no-op when nuxt-base-template/ does not exist', async () => {
+ // Already-flat layouts (older starters, manually flattened repos,
+ // or future reshapes) must round-trip untouched.
+ const dest = filesystem.path(tempDir, 'projects', 'app');
+ filesystem.dir(dest);
+ filesystem.write(filesystem.path(dest, 'package.json'), { name: 'already-flat' });
+ filesystem.write(filesystem.path(dest, 'nuxt.config.ts'), 'export default defineNuxtConfig({})\n');
+
+ const { FrontendHelper } = loadFrontendHelper();
+ const helper = new FrontendHelper(makeToolbox());
+
+ const result = await helper.flattenNuxtBaseTemplate(dest);
+
+ expect(result.flattened).toBe(false);
+ expect(result.reason).toMatch(/no.*subdirectory/i);
+
+ // Layout is unchanged — `package.json` still has the original name.
+ const pkg = filesystem.read(filesystem.path(dest, 'package.json'), 'json');
+ expect(pkg.name).toBe('already-flat');
+ // gluegun's `filesystem.exists` returns the type ('file' / 'dir')
+ // when the path exists, not a strict boolean.
+ expect(filesystem.exists(filesystem.path(dest, 'nuxt.config.ts'))).toBe('file');
+ });
+
+ test('leaves the original layout intact when the subdir is a file, not a directory', async () => {
+ // Defense-in-depth: if `nuxt-base-template` somehow exists but
+ // isn't a directory (corrupt clone, name collision in a future
+ // repo reshape), we must NOT wipe `projects/app/`.
+ const dest = filesystem.path(tempDir, 'projects', 'app');
+ filesystem.dir(dest);
+ filesystem.write(filesystem.path(dest, 'package.json'), { name: 'create-nuxt-base' });
+ filesystem.write(filesystem.path(dest, 'nuxt-base-template'), 'this is a stray file\n');
+
+ const { FrontendHelper } = loadFrontendHelper();
+ const helper = new FrontendHelper(makeToolbox());
+
+ const result = await helper.flattenNuxtBaseTemplate(dest);
+
+ expect(result.flattened).toBe(false);
+ // The pre-flatten layout is annoying but functional — better than
+ // wiping a user's clone when something unexpected is in the way.
+ const pkg = filesystem.read(filesystem.path(dest, 'package.json'), 'json');
+ expect(pkg.name).toBe('create-nuxt-base');
+ // The stray file is preserved as-is (gluegun returns 'file' for the
+ // type, not a boolean).
+ expect(filesystem.exists(filesystem.path(dest, 'nuxt-base-template'))).toBe('file');
+ });
+
+ test('setupNuxt invokes the flatten after a successful clone', () => {
+ // Source-introspection guard: a future refactor could quietly
+ // drop the flatten call from setupNuxt. The friction is invisible
+ // until a user runs `pnpm dev`, so we pin it via the source.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const fs = require('fs');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const path = require('path');
+ const helperSource: string = fs.readFileSync(
+ path.join(__dirname, '..', 'src', 'extensions', 'frontend-helper.ts'),
+ 'utf8',
+ );
+
+ // The setupNuxt method must call flattenNuxtBaseTemplate.
+ const setupNuxtBlock = helperSource.match(/public async setupNuxt[\s\S]*?\n \}/);
+ expect(setupNuxtBlock).not.toBeNull();
+ expect(setupNuxtBlock![0]).toMatch(/flattenNuxtBaseTemplate/);
+
+ // And it must only run on clone (not on link — a symlink to the
+ // user's local checkout must not have its template subdir torn out).
+ expect(setupNuxtBlock![0]).toMatch(/method\s*===?\s*'clone'/);
+ });
+});
diff --git a/src/extensions/frontend-helper.ts b/src/extensions/frontend-helper.ts
index 895ffeb..97aa38d 100644
--- a/src/extensions/frontend-helper.ts
+++ b/src/extensions/frontend-helper.ts
@@ -90,6 +90,71 @@ export class FrontendHelper {
filesystem.write(envPath, content);
}
+ /**
+ * Flatten the cloned nuxt-base-starter wrapper layout so the project's
+ * `projects/app/` directory IS the Nuxt app.
+ *
+ * `lenneTech/nuxt-base-starter` ships a wrapper repo: the root
+ * `package.json` is the `create-nuxt-base` scaffolder (a separate npm
+ * package — `bin/create-nuxt-base` lives at `index.js`), and the
+ * actual Nuxt app lives one level deeper at `nuxt-base-template/`.
+ * Without this flatten, the generated monorepo's `pnpm-workspace.yaml`
+ * and the README's `cd projects/app && pnpm install && pnpm dev`
+ * point at the wrapper, not the app, so `pnpm install` resolves the
+ * wrong dependencies and `pnpm dev` has nothing to run
+ * (LLM-test 2026-05-03 friction #3 entry 20:30).
+ *
+ * Defense-in-depth: only mutate the layout if extraction succeeds.
+ * If `nuxt-base-template/` is missing or isn't a directory (corrupt
+ * clone, future repo reshape that drops the wrapper), we return
+ * `{ flattened: false, reason }` and leave the original tree alone.
+ * The pre-flatten layout is annoying but functional — better than
+ * wiping a user's clone over an unexpected layout.
+ *
+ * @param dest - The cloned `projects/app/` directory.
+ * @returns Whether the flatten ran, plus a reason if it didn't.
+ */
+ public async flattenNuxtBaseTemplate(dest: string): Promise<{ flattened: boolean; reason?: string }> {
+ const { filesystem } = this.toolbox;
+ const subdir = filesystem.path(dest, 'nuxt-base-template');
+
+ if (!filesystem.exists(subdir)) {
+ return { flattened: false, reason: 'no nuxt-base-template subdirectory' };
+ }
+ if (!filesystem.isDirectory(subdir)) {
+ // Stray file at the path we'd flatten — abort to avoid clobbering
+ // the user's tree on a corrupt clone.
+ return { flattened: false, reason: 'nuxt-base-template path exists but is not a directory' };
+ }
+
+ // Stage the template into a sibling directory before touching `dest`,
+ // so a copy failure leaves the original layout intact.
+ const parent = filesystem.path(dest, '..');
+ const stage = filesystem.path(parent, `.nuxt-base-template-staging-${Date.now()}-${process.pid}`);
+ try {
+ filesystem.copy(subdir, stage, { overwrite: true });
+ } catch (err) {
+ // Couldn't stage — leave `dest` untouched and bubble the reason up.
+ filesystem.remove(stage);
+ return { flattened: false, reason: `failed to stage template: ${(err as Error).message}` };
+ }
+
+ try {
+ // Wipe the cloned root (wrapper package.json, index.js, lock file,
+ // README, etc.) and replace it with the staged template contents.
+ // gluegun's `filesystem.remove(dest)` removes the directory, so
+ // we re-create it before copying back so dotfiles land at the
+ // right level.
+ filesystem.remove(dest);
+ filesystem.dir(dest);
+ filesystem.copy(stage, dest, { overwrite: true });
+ } finally {
+ filesystem.remove(stage);
+ }
+
+ return { flattened: true };
+ }
+
/**
* Setup Nuxt frontend
* Handles template setup (link/copy/clone) and optional npm install
@@ -116,6 +181,15 @@ export class FrontendHelper {
return { method: result.method, path: result.path, success: false };
}
+ // After a clone, flatten the wrapper layout so `projects/app/`
+ // IS the Nuxt app (the cloned root is the `create-nuxt-base`
+ // scaffolder, not the app — see flattenNuxtBaseTemplate).
+ // Skip on link mode: a symlink points at the user's local
+ // checkout and must not have its template subdir torn out.
+ if (result.method === 'clone') {
+ await this.flattenNuxtBaseTemplate(dest);
+ }
+
// Run install if not skipped and not a symlink
if (!skipInstall && result.method !== 'link') {
try {