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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ jobs:
pnpm exec nuxi prepare
pnpm run lint

# The scaffold's own `eslint --fix` pass hides a template that breaks the
# project's style, but `pull` merges the raw template into the user's
# formatted copy, so every such difference becomes a conflict. Pages and
# layouts sit outside the paths that exempt them from multi-word names here.
- name: Lint the raw templates with the project's config
working-directory: ${{ runner.temp }}/scaffold/ci-app
run: |
cp -r "$GITHUB_WORKSPACE/packages/preset-nuxt4/templates" __templates
pnpm exec eslint --rule '{"vue/multi-word-component-names":"off"}' __templates
rm -rf __templates

- name: Typecheck
working-directory: ${{ runner.temp }}/scaffold/ci-app
run: pnpm run typecheck
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
- Pin generated projects to TypeScript 6: `vue-tsc` and typescript-eslint do not
support TypeScript 7 yet. A feature's explicit dependency range now also wins
over a peer pin that `nuxi module add` wrote into the other bucket.
- Install the lefthook git hooks: generated projects get a
`prepare: lefthook install || exit 0` script, and `pull` runs it once.
- Format the template files with the generated project's own ESLint config, so
`pull` no longer turns style differences into merge conflicts. CI now lints
the raw templates with that config.
- `battlestack <command> --help` prints that command's usage, and `--help`
inside a project lists the core commands (`pull`, `own`, `doctor`, …).

## v1.3.0 (2026-09-24)

Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ export async function printHelp(
}
}

/** Dispatched in `index.ts` before project detection, so it is not a reserved project command. */
const SELF_UPDATE_HELP = {
name: 'self-update',
usage: 'battlestack self-update [--force]',
label: 'upgrade the globally-installed CLI to the latest release',
helpExtra: [['battlestack self-update --force', 'opt out of pnpm release-age gate; install true latest now']] as [string, string][],
}

/** `battlestack <command> --help` for a reserved command or `self-update`. False when `name` is neither. */
export function printCommandHelp(name: string | undefined): boolean {
const cmd = [...RESERVED_COMMANDS, SELF_UPDATE_HELP].find((c) => c.name === name)
if (!cmd) return false
ui.plain(pc.bold('Usage'))
ui.dim(` ${cmd.usage}`)
ui.blank()
ui.plain(` ${cmd.label}`)
if (cmd.helpExtra) {
ui.blank()
ui.kv(cmd.helpExtra, ' ')
}
ui.blank()
return true
}

/**
* Plugin-contributed commands, one block per owning plugin. Scaffold-only ids
* are excluded, plus anything in `claimed` that would lose dispatch anyway.
Expand Down
16 changes: 8 additions & 8 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { installUiPort, ui } from '@battlestack/tui'
import { parseArgs, stripCommandToken } from './cli/args.js'
import { dispatchPluginCommand } from './cli/plugin-commands.js'
import { printHelp } from './cli/help.js'
import { printCommandHelp, printHelp } from './cli/help.js'
import { bootstrapProject } from './commands/install.js'
import { doctorCommand } from './commands/doctor.js'
import { gatewayUp, registerProject } from './commands/gateway.js'
Expand Down Expand Up @@ -149,6 +149,10 @@ async function main(): Promise<void> {

// Runs inside a project or not: it upgrades the globally-installed CLI, not deps.
if (args.projectName === 'self-update' || args.projectName === 'update') {
if (args.help) {
printCommandHelp('self-update')
return
}
try {
await selfUpdateCommand({
packageManager: args.packageManager,
Expand Down Expand Up @@ -247,13 +251,9 @@ async function main(): Promise<void> {
}

if (args.help) {
if (!projectRoot) {
ui.banner(VERSION)
await printHelp('scaffold', registries)
return
}
// Inside a project, `--help` lists rather than executes.
await projectCommand({ ...args, projectName: undefined }, loader, projectRoot, registries, rawArgv)
if (printCommandHelp(args.projectName)) return
ui.banner(VERSION)
await printHelp(projectRoot ? 'project' : 'scaffold', registries, projectRoot ?? undefined)
return
}

Expand Down
21 changes: 20 additions & 1 deletion packages/cli/test/help.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BattlestackRegistries } from '@battlestack/core'
import { printHelp } from '../src/cli/help.js'
import { printCommandHelp, printHelp } from '../src/cli/help.js'

afterEach(() => {
vi.restoreAllMocks()
Expand Down Expand Up @@ -50,4 +50,23 @@ describe('printHelp', () => {
it('defaults to scaffold mode', async () => {
expect(await capture()).toContain('--template')
})

it('prints a reserved command\'s own usage for `<command> --help`', () => {
const lines: string[] = []
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' '))
})
expect(printCommandHelp('own')).toBe(true)
expect(lines.join('\n')).toContain('battlestack own <path...>')
expect(printCommandHelp('my-app')).toBe(false)
})

it('prints self-update\'s usage, which is dispatched before the reserved commands', () => {
const lines: string[] = []
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.join(' '))
})
expect(printCommandHelp('self-update')).toBe(true)
expect(lines.join('\n')).toContain('battlestack self-update [--force]')
})
})
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/audit-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { STAGE } from '@battlestack/core'
/** Append-only audit trail. Best-effort on insert. */
export const auditLogFeature: Feature = {
id: 'nuxt4:audit-log',
version: '1.0.1',
version: '1.0.2',
label: 'Audit log (security events)',
description: 'Append-only trail of login, signup, role changes, and passkey events.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/auth-2fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { STAGE } from '@battlestack/core'
/** TOTP-based two-factor auth. Secrets encrypted at rest with AES-256-GCM via `NUXT_TOTP_ENCRYPTION_KEY`. */
export const auth2faFeature: Feature = {
id: 'nuxt4:auth-2fa',
version: '1.2.2',
version: '1.2.3',
label: 'Two-factor auth (TOTP)',
description: 'Authenticator-app 2FA; TOTP secrets encrypted at rest.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/auth-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { STAGE } from '@battlestack/core'
/** Forgot-password and reset-password. Tokens sha256-hashed at rest. */
export const authRecoveryFeature: Feature = {
id: 'nuxt4:auth-recovery',
version: '1.1.2',
version: '1.1.3',
label: 'Password recovery',
description: 'Forgot-password and reset flows with one-time, hashed tokens.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/auth-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { STAGE } from '@battlestack/core'
/** Email verification: a signup token verified via a one-time link, optionally blocking login. */
export const authVerificationFeature: Feature = {
id: 'nuxt4:auth-verification',
version: '1.0.2',
version: '1.0.3',
label: 'Email verification',
frameworks: ['nuxt4'],
stage: STAGE.AUTH_EXTRAS,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function titleCase(slug: string): string {
/** Session auth on `nuxt-auth-utils`. The sealed cookie carries an opaque sessionId. */
export const authFeature: Feature = {
id: 'nuxt4:auth',
version: '1.10.1',
version: '1.10.2',
label: 'Session-based auth (argon2id)',
frameworks: ['nuxt4'],
stage: STAGE.AUTH,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/build-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { patchNuxtConfig } from '../utils/nuxt-config.js'
*/
export const buildInfoFeature: Feature = {
id: 'nuxt4:build-info',
version: '1.0.0',
version: '1.0.1',
label: 'Build info (version + commit in the footer)',
description: 'Version and commit sha in the app footer, from the image\'s build args.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { saveFeatureState, STAGE, type Feature, type ChatTransport } from '@batt
/** Streaming chat on Mastra's default agent. Transport from `state.chatTransport`. */
export const chatFeature: Feature = {
id: 'nuxt4:chat',
version: '1.3.0',
version: '1.3.1',
label: 'Streaming chat (Mastra)',
description: 'WebSocket streaming chat UI backed by the Mastra default agent.',
frameworks: ['nuxt4'],
Expand Down
29 changes: 27 additions & 2 deletions packages/preset-nuxt4/src/features/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,23 @@ import {
type RunContext,
type PackageManager,
} from '@battlestack/core'
import { writeFileEnsured } from '@battlestack/core/utils/fs.js'
import { readJson, writeFileEnsured, writeJson } from '@battlestack/core/utils/fs.js'
import { run } from '@battlestack/core/utils/run.js'
import { templatesDir } from '@battlestack/core/utils/templates.js'
import { applyVars, renderPmVars } from '../utils/pm-template.js'
import { STAGE } from '@battlestack/core/constants/stages.js'

const FILES = ['lefthook.yml'] as const

// lefthook's own postinstall runs at scaffold time, before the project has a `.git`.
// `|| exit 0` keeps installs without a `.git` (Docker) or without dev deps from failing.
const PREPARE = 'lefthook install || exit 0'
const PREPARE_TAKEN = 'package.json has its own `prepare` script, so `lefthook install` was not added; make sure it runs it.'

/** Lefthook git hooks: pre-commit eslint. Typecheck runs in CI, not on push. */
export const ciFeature: Feature = {
id: 'shared:ci',
version: '1.1.1',
version: '1.1.2',
label: 'Git hooks (lefthook)',
description: 'Pre-commit eslint on staged files via lefthook.',
stage: STAGE.GITIGNORE,
Expand All @@ -43,10 +49,17 @@ export const ciFeature: Feature = {

async execute(ctx) {
await emit(ctx)
await wirePrepare(ctx)
},

async update(ctx, _prev) {
await emit(ctx)
if (!await wirePrepare(ctx)) {
return { written: [...FILES], skipped: [], notes: [PREPARE_TAKEN] }
}
// pull's install takes pnpm's up-to-date fast path, which skips `prepare`.
const pm = String(ctx.state.packageManager ?? 'pnpm') as PackageManager
await run(pm, ['run', 'prepare'], { cwd: ctx.projectDir, inherit: ctx.debug }).catch(() => {})
return { written: [...FILES], skipped: [], notes: [] }
},
}
Expand All @@ -64,3 +77,15 @@ async function emit(ctx: RunContext): Promise<void> {
recordFile(ctx, 'shared:ci', rel, await hashFile(dest))
}
}

/** Adds the `prepare` hook installer. False when the project's own `prepare` is in the way. */
async function wirePrepare(ctx: RunContext): Promise<boolean> {
const pkgPath = path.join(ctx.projectDir, 'package.json')
const pkg = await readJson<Record<string, unknown>>(pkgPath)
const scripts = (pkg.scripts as Record<string, string> | undefined) ?? {}
if (scripts.prepare) return scripts.prepare === PREPARE
scripts.prepare = PREPARE
pkg.scripts = scripts
await writeJson(pkgPath, pkg)
return true
}
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/dashboard-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { patchNuxtConfig } from '../utils/nuxt-config.js'
/** Authenticated app shell under `/dashboard/*`. */
export const dashboardShellFeature: Feature = {
id: 'nuxt4:dashboard-shell',
version: '1.3.1',
version: '1.3.2',
label: 'Authenticated app shell (/dashboard/*)',
frameworks: ['nuxt4'],
stage: STAGE.AUTH,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ async function registerRuntimeConfig(projectDir: string): Promise<void> {
/** PostgreSQL 18 in Docker, Drizzle ORM, users schema, seed script. */
export const databaseFeature: Feature = {
id: 'nuxt4:database',
version: '1.7.1',
version: '1.7.2',
label: 'PostgreSQL + Drizzle ORM (Docker)',
frameworks: ['nuxt4'],
stage: STAGE.DATABASE,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/essentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const ICON_FILES = [
/** Always-on Nuxt essentials: `@nuxt/eslint`, `@nuxt/fonts`, `@nuxt/image`, datepicker, `@iconify/vue`. */
export const essentialsFeature: Feature = {
id: 'nuxt4:essentials',
version: '1.2.1',
version: '1.2.2',
label: 'Nuxt essentials (eslint, fonts, image, iconify, datepicker, nodemailer)',
frameworks: ['nuxt4'],
stage: STAGE.BASE_CONFIG,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { patchNuxtConfig } from '../utils/nuxt-config.js'
/** `/api/health` + probe split (`/live`, `/ready`) + runtimeConfig knobs. Picks `with-db` or `env-only` variant at scaffold time. */
export const healthFeature: Feature = {
id: 'nuxt4:health',
version: '1.5.0',
version: '1.5.1',
label: 'Health endpoints (/api/health, /live, /ready)',
frameworks: ['nuxt4'],
stage: STAGE.BASE_CONFIG,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { patchNuxtConfig } from '../utils/nuxt-config.js'
/** @nuxtjs/i18n with per-locale namespace JSONs under `i18n/locales/<lang>/<ns>.json`. */
export const i18nFeature: Feature = {
id: 'nuxt4:i18n',
version: '1.1.1',
version: '1.1.2',
label: 'i18n (Dutch + English)',
frameworks: ['nuxt4'],
stage: STAGE.I18N,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/landing-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { applyColorAliases } from './battlestack-theme.js'
/** Frontend shell: `app.vue`, `app.config.ts`, layouts, landing page. All emitted files are structural. */
export const landingShellFeature: Feature = {
id: 'nuxt4:landing-shell',
version: '1.4.0',
version: '1.4.1',
label: 'Landing shell (layouts, public landing page)',
description: 'Public landing page, layouts, and frontend app shell.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/mastra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
/** Mastra AI runtime. Talks to an OpenAI-compatible AI gateway (sluis.ai preset, or any compatible URL) via `@ai-sdk/openai-compatible`. */
export const mastraFeature: Feature = {
id: 'nuxt4:mastra',
version: '2.1.3',
version: '2.1.4',
label: 'Mastra AI runtime',
frameworks: ['nuxt4'],
stage: STAGE.AI_CORE,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/nuxt-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { patchNuxtConfig } from '../utils/nuxt-config.js'
/** Nuxt UI v4 + Tailwind v4. Registers `@nuxt/ui` module and wires `assets/css/main.css`. */
export const nuxtUiFeature: Feature = {
id: 'nuxt4:nuxt-ui',
version: '1.2.2',
version: '1.2.3',
label: 'Nuxt UI v4 + Tailwind v4',
frameworks: ['nuxt4'],
stage: STAGE.STYLING,
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { Feature } from '@battlestack/core'
/** Admin-editable AI agent prompts. */
export const promptsFeature: Feature = {
id: 'nuxt4:prompts',
version: '1.2.1',
version: '1.2.2',
label: 'AI prompt management',
description: 'Admin-editable agent prompts with shipped registry defaults.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function defaultDimensionsFor(model: string | undefined): number {
/** RAG on Mastra: chunk → embedMany → PgVector.upsert/query. pgvector ships with `nuxt4:database`. */
export const ragFeature: Feature = {
id: 'nuxt4:rag',
version: '1.4.1',
version: '1.4.2',
label: 'RAG (Mastra + pgvector)',
description: 'Ingest, chunk, embed, and query documents with Mastra + pgvector.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { allocatePort, STAGE, type EnvVar, type Feature } from '@battlestack/cor
*/
export const storageFeature: Feature = {
id: 'nuxt4:storage',
version: '1.3.0',
version: '1.3.1',
label: 'Object storage (RustFS dev / S3 prod)',
description: 'File uploads and presigned downloads; RustFS locally, S3-compatible provider in prod.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/user-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { STAGE } from '@battlestack/core'
/** Admin-gated user CRUD + admin pages + admin route middleware. */
export const userAdminFeature: Feature = {
id: 'nuxt4:user-admin',
version: '1.0.6',
version: '1.0.7',
label: 'User administration (admin-gated CRUD)',
description: 'Admin-only /dashboard/users for listing, creating, editing, and deleting users.',
frameworks: ['nuxt4'],
Expand Down
2 changes: 1 addition & 1 deletion packages/preset-nuxt4/src/features/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ui } from '@battlestack/tui'
/** Vitest config, a sample unit test and `test` scripts. Three projects: unit, nuxt, e2e. */
export const vitestFeature: Feature = {
id: 'nuxt4:vitest',
version: '1.0.7',
version: '1.0.8',
label: 'Vitest config + scripts',
frameworks: ['nuxt4'],
stage: STAGE.NAMING, // run after naming so package.json exists
Expand Down
Loading
Loading