Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions __tests__/fullstack-init-next-flatten.test.ts
Original file line number Diff line number Diff line change
@@ -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'), '<template><div>app</div></template>\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<string, unknown>) => {
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<string, unknown> {
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('<template>');

// 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'/);
});
});
74 changes: 74 additions & 0 deletions src/extensions/frontend-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading