diff --git a/CHANGELOG.md b/CHANGELOG.md index 7279b1f0..8dcc8b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,24 @@ skips lint and `db:generate`, and lists the fix in `REPORT.md`: `pnpm clean --lockfile && pnpm install`, then lint, the tests and `db:generate` if the schema changed. +- The landing layout has a `#header-actions` target in its header, shown at + every width, so a page can add an action such as "New chat" with + `` instead of editing the layout. The + layout belongs to your project, so `pull` does not change it: the pull that + brings this in lists the two lines to add in `REPORT.md`. +- A prompt can be up to 100,000 characters instead of 10,000, and a prompt that + cannot be saved shows the reason in the toast instead of a bare 400. +- The AI settings e2e test sets the model from `NUXT_AI_GATEWAY_CHAT_MODEL` + instead of a hard-coded one, so a run against a dev server no longer leaves a + model the gateway key may not be allowed to use. +- The project's own Mastra agents are registered on boot again: Mastra 1.x + renamed `getAgents()` to `listAgents()`, so only `default` showed up in the AI + settings. Boot now warns when it finds no agents. +- `battlestack own` and `disown` apply to every feature that tracks a path, so + a file tracked by two features, such as `app/app.config.ts`, is really yours. + `pull`, `add`, `doctor` and `remove` treat a path owned under any feature as + owned, and `pull` never changes an owned file. Paths owned with an earlier + version need no migration. - A feature whose plugin did not load keeps its manifest record on `pull` instead of being dropped, so its files stay managed. - `shared:github` drops its records of the deploy workflows it no longer ships. diff --git a/packages/cli/src/commands/add-remove.ts b/packages/cli/src/commands/add-remove.ts index b8dea4a0..3e86a8f3 100644 --- a/packages/cli/src/commands/add-remove.ts +++ b/packages/cli/src/commands/add-remove.ts @@ -12,6 +12,7 @@ import { readManifest, recordOwned, run, + toPosixRel, MANIFEST_PATH, writeJson, writeManifest, @@ -101,10 +102,12 @@ export async function addCommand(args: ParsedArgs, loader: Ora, registries: Batt const pm = manifest.packageManager as PackageManager const fullEnabled = [...manifest.features.map((f) => f.id), fqid] const ctx = buildCtx(projectRoot, manifest, fullEnabled, pm, args, registries) - // Pre-primed so writeManifest preserves existing entries. Keyed bare. + // Pre-primed so writeManifest preserves existing entries, and the writers skip owned paths. Keyed bare. for (const f of manifest.features) { if (!registries.features.has(f.id)) continue - ctx.state[`files:${registries.features.get(f.id).id}`] = { ...f.files } + const bare = registries.features.get(f.id).id + ctx.state[`files:${bare}`] = { ...f.files } + if (f.ownedByUser) ctx.state[`owned:${bare}`] = [...f.ownedByUser] } ui.section(`Add ${fqid}`) @@ -255,10 +258,10 @@ export async function removeCommand(args: ParsedArgs, _loader: Ora, registries: // pristine → delete, drifted → leave. Owned files are skipped. const pristine: string[] = [] const drifted: string[] = [] - const ownedSet = new Set(record.ownedByUser ?? []) + const ownedSet = new Set(manifest.features.flatMap((f) => (f.ownedByUser ?? []).map(toPosixRel))) for (const [rel, recordedHash] of Object.entries(record.files)) { const abs = path.join(projectRoot, rel) - const state = await classifyFileState(abs, recordedHash, ownedSet.has(rel)) + const state = await classifyFileState(abs, recordedHash, ownedSet.has(toPosixRel(rel))) if (state === 'pristine' || state === 'missing') pristine.push(rel) else if (state === 'drifted') drifted.push(rel) } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 1fbccbe0..85f9e7fb 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -19,6 +19,7 @@ import { spawnSyncResolved as safeSpawnSync, STAGE_ORDER, SUPPORTED_PMS, + toPosixRel, type BattlestackRegistries, type ParsedArgs, type ProjectManifest, @@ -85,6 +86,8 @@ export async function doctorCommand(args: ParsedArgs, _loader: Ora, registries: return a.id.localeCompare(b.id) }) + // Owned under one feature means owned under every feature that tracks the path. + const ownedSet = new Set(manifest.features.flatMap((f) => (f.ownedByUser ?? []).map(toPosixRel))) const reports: FeatureReport[] = [] for (const record of sortedFeatures) { const installed = record.version @@ -101,11 +104,10 @@ export async function doctorCommand(args: ParsedArgs, _loader: Ora, registries: } } - const ownedSet = new Set(record.ownedByUser ?? []) const files: FileStatus[] = [] for (const [rel, recordedHash] of Object.entries(record.files ?? {})) { const abs = path.join(projectRoot, rel) - const state = await classifyFileState(abs, recordedHash, ownedSet.has(rel)) + const state = await classifyFileState(abs, recordedHash, ownedSet.has(toPosixRel(rel))) files.push({ rel, state }) } diff --git a/packages/cli/src/commands/own.ts b/packages/cli/src/commands/own.ts index 160fa5b9..1d1174c7 100644 --- a/packages/cli/src/commands/own.ts +++ b/packages/cli/src/commands/own.ts @@ -17,14 +17,9 @@ import { type ReservedCommand, } from '@battlestack/core' -function featureForPath( - manifest: ProjectManifest, - rel: string, -): InstalledFeatureRecord | null { - for (const f of manifest.features) { - if (rel in f.files) return f - } - return null +/** Ownership is per path, so `own` and `disown` act on every feature that tracks it. */ +function featuresForPath(manifest: ProjectManifest, rel: string): InstalledFeatureRecord[] { + return manifest.features.filter((f) => rel in f.files || (f.ownedByUser ?? []).includes(rel)) } function normalize(projectRoot: string, raw: string): string { @@ -73,8 +68,8 @@ export async function ownCommand(args: ParsedArgs, _loader: Ora): Promise const lines: string[] = [] for (const raw of paths) { const rel = normalize(projectRoot, raw) - const feature = featureForPath(manifest, rel) - if (!feature) { + const features = featuresForPath(manifest, rel) + if (features.length === 0) { const suggestions = suggest(manifest, rel) const hint = suggestions.length ? ` Did you mean: ${suggestions.slice(0, 3).join(', ')}?` @@ -84,16 +79,21 @@ export async function ownCommand(args: ParsedArgs, _loader: Ora): Promise `${rel} is not tracked by any feature.${hint}`, ) } - const list = feature.ownedByUser ?? [] - if (list.includes(rel)) { - const note = pc.dim(`${rel} already owned (${feature.id})`) + const claimed: string[] = [] + for (const feature of features) { + const list = feature.ownedByUser ?? [] + if (list.includes(rel)) continue + list.push(rel) + feature.ownedByUser = list + claimed.push(feature.id) + } + if (claimed.length === 0) { + const note = pc.dim(`${rel} already owned (${features.map((f) => f.id).join(', ')})`) lines.push(`${ui.sym.skip} ${note}`) continue } - list.push(rel) - feature.ownedByUser = list mutated = true - const owned = pc.dim(`owned (${feature.id})`) + const owned = pc.dim(`owned (${claimed.join(', ')})`) lines.push(`${ui.sym.ok} ${rel} ${owned}`) } @@ -125,16 +125,16 @@ export async function disownCommand(args: ParsedArgs, _loader: Ora): Promise (f.ownedByUser ?? []).includes(rel)) + if (owners.length === 0) { + const note = pc.dim(`was not owned (${features.map((f) => f.id).join(', ')}), no change`) lines.push(`${ui.sym.warn} ${rel} ${note}`) continue } @@ -146,11 +146,15 @@ export async function disownCommand(args: ParsedArgs, _loader: Ora): Promise p !== rel) - if (feature.ownedByUser.length === 0) delete feature.ownedByUser + // Every tracker's baseline moves, or a non-owner's stale hash reads as drift right away. + for (const feature of features) { + feature.files[rel] = currentHash + const list = (feature.ownedByUser ?? []).filter((p) => p !== rel) + if (list.length > 0) feature.ownedByUser = list + else delete feature.ownedByUser + } mutated = true - const note = pc.dim(`disowned (${feature.id}); baseline hash refreshed`) + const note = pc.dim(`disowned (${owners.map((f) => f.id).join(', ')}); baseline hash refreshed`) lines.push(`${ui.sym.ok} ${rel} ${note}`) } @@ -166,9 +170,9 @@ function collectPaths(args: ParsedArgs): string[] { function suggest(manifest: ProjectManifest, target: string): string[] { const all: string[] = [] - for (const f of manifest.features) all.push(...Object.keys(f.files)) + for (const f of manifest.features) all.push(...Object.keys(f.files), ...(f.ownedByUser ?? [])) const lower = target.toLowerCase() - return all + return [...new Set(all)] .filter((p) => p.toLowerCase().includes(lower) || lower.includes(path.basename(p).toLowerCase())) .slice(0, 5) } diff --git a/packages/cli/src/commands/pull-safety.ts b/packages/cli/src/commands/pull-safety.ts index 5eaa2336..674b877c 100644 --- a/packages/cli/src/commands/pull-safety.ts +++ b/packages/cli/src/commands/pull-safety.ts @@ -9,6 +9,7 @@ import { hashContent, hasConflictMarkers, isBinaryPath, + ownedAnywhere, pruneBases, recordedFiles, stageUpstream, @@ -101,7 +102,7 @@ export async function preflight( export interface OverwriteRepair { rel: string featureId: string - outcome: 'merged' | 'conflicts' | 'staged' | 'backed up' + outcome: 'merged' | 'conflicts' | 'staged' | 'backed up' | 'kept yours, owned' } /** Bare feature id for a manifest id, which may be an fqid. */ @@ -112,9 +113,10 @@ function bareId(registries: BattlestackRegistries, id: string): string { /** * Catches feature writes that bypassed the drift-aware writers: a tracked or untracked file * that held local edits at HEAD and was replaced. The local version is put back and the - * feature's output is merged into it, or staged when there is no base to merge against. - * `--force` keeps the feature output and saves the local version as a `.bak`, as the drift-aware - * writers do; `--overwrite` takes the output as asked. + * feature's output is merged into it, or staged when there is no base to merge against. A + * path owned under any feature only gets the local version back. `--force` keeps the feature + * output and saves the local version as a `.bak`, as the drift-aware writers do; `--overwrite` + * takes the output as asked. */ export async function repairOverwrites( ctx: RunContext, @@ -125,6 +127,7 @@ export async function repairOverwrites( const ledger = updateLedger(ctx) const handled = new Set(ledger.handled) const exempt = selfMergedPaths(ctx, registries) + const owned = ownedAnywhere(ctx) const changed = git(ctx.projectDir, ['diff', '--name-only', '-z', '--relative', 'HEAD']).out.toString().split('\0').filter(Boolean) const repairs: OverwriteRepair[] = [] @@ -137,6 +140,12 @@ export async function repairOverwrites( // `./` resolves against the project directory, and --filters gives the bytes checkout wrote. const head = git(ctx.projectDir, ['cat-file', '--filters', `HEAD:./${rel}`]) if (!head.ok) continue + // An owned file is the project's: pull never changes it, edited or not. + if (owned.has(rel) && ctx.state.overwrite !== true) { + await writeFile(abs, head.out) + repairs.push({ rel, featureId: trackers[0]!.featureId, outcome: 'kept yours, owned' }) + continue + } const startByFeature = start.get(rel) ?? new Map() const before = new Set(startByFeature.values()) if (before.has(hashContent(head.out))) continue @@ -266,7 +275,7 @@ export function renderReport(s: PullSummary): string { ), ...section( 'Local edits a feature wrote over', - 'These features replaced a file that had local edits. Pull put your version back and merged or staged theirs, as listed in the sections above, or under `--force` kept theirs and saved yours as `.battlestack/pull/.bak`. Check the result.', + 'These features replaced a file that had local edits. Pull put your version back and merged or staged theirs, as listed in the sections above, or under `--force` kept theirs and saved yours as `.battlestack/pull/.bak`. A file you own with `battlestack own` is simply put back. Check the result.', repaired, ), ...section( diff --git a/packages/cli/test/add.test.ts b/packages/cli/test/add.test.ts index 8e39b402..f6c010f3 100644 --- a/packages/cli/test/add.test.ts +++ b/packages/cli/test/add.test.ts @@ -515,6 +515,24 @@ describe('battlestack add into an existing file', () => { }) }) +describe('battlestack add over a path another feature owns', () => { + it('leaves the owned file alone and stages nothing', async () => { + const p = await project() + await writeFile(path.join(p.dir, 'add-opt.txt'), 'the project\'s own file\n') + const manifest = await p.manifest() + manifest.features[0]!.ownedByUser = ['add-opt.txt'] + await writeFile(path.join(p.dir, MANIFEST_PATH), JSON.stringify(manifest), 'utf8') + + process.exitCode = undefined + await withCwd(p.dir, () => add('add:opt')(p)) + + expect(await readFile(path.join(p.dir, 'add-opt.txt'), 'utf8')).toBe('the project\'s own file\n') + expect(await fileExists(p.dir, '.battlestack/pull/add-opt.txt.new')).toBe(false) + expect(process.exitCode).toBeUndefined() + process.exitCode = undefined + }) +}) + describe('battlestack add after a pull that left work', () => { it('keeps the pull\'s items in REPORT.md next to its own', async () => { const p = await project() diff --git a/packages/cli/test/doctor.test.ts b/packages/cli/test/doctor.test.ts index f8054ebc..d74d3f9e 100644 --- a/packages/cli/test/doctor.test.ts +++ b/packages/cli/test/doctor.test.ts @@ -105,7 +105,7 @@ async function writeProjectManifest( dir: string, opts: { packageManager?: string - features?: Array<{ id: string, version: string, files?: Record }> + features?: Array<{ id: string, version: string, files?: Record, ownedByUser?: string[] }> framework?: string template?: string optedOut?: string[] @@ -257,6 +257,29 @@ describe('doctorCommand: inside a project', () => { expect(text).toContain('missing.ts') }) + it('reports no drift for a path owned under another feature that tracks it', async () => { + await writeFile(path.join(tmpDir, 'shared.ts'), 'edited\n', 'utf8') + registries.features.register( + { id: 'feat:owner', label: 'x', version: '1.0.0', stage: 'FINALIZE', async execute() {} } as Feature, + origin, + ) + registries.features.register( + { id: 'feat:other', label: 'x', version: '1.0.0', stage: 'FINALIZE', async execute() {} } as Feature, + origin, + ) + await writeProjectManifest(tmpDir, { + packageManager: 'pnpm', + features: [ + { id: 'doctortest:feat:owner', version: '1.0.0', files: { 'shared.ts': 'old-hash' }, ownedByUser: ['shared.ts'] }, + { id: 'doctortest:feat:other', version: '1.0.0', files: { 'shared.ts': 'old-hash' } }, + ], + }) + mockCommands({ pnpm: true }) + + const text = (await run()).join('\n') + expect(text).not.toContain('edited since install') + }) + it('checks Docker only when nuxt4:database is enabled, and hard-fails a missing daemon', async () => { registries.features.register( { id: 'nuxt4:database', label: 'db', version: '1.0.0', stage: 'DATABASE', async execute() {} } as Feature, diff --git a/packages/cli/test/own.test.ts b/packages/cli/test/own.test.ts index 63e1cba8..d2e9accf 100644 --- a/packages/cli/test/own.test.ts +++ b/packages/cli/test/own.test.ts @@ -211,6 +211,50 @@ describe('battlestack own / disown', () => { expect(rec.files['a.ts']).toBe(liveHash) }) + it('own claims a path for every feature that tracks it, even when one already owns it', async () => { + const id = 'test:feat-own-7' + const h = await setup(id, { 'shared.ts': 'shared\n' }) + handles.push(h) + const hash = await hashFile(path.join(h.projectDir, 'shared.ts')) + const manifest = await h.readManifest() + // Listed first, so a lookup that stops at the first tracker only sees this one. + manifest.features.unshift({ + id: 'owntest:test:feat-own-7-owner', + version: '0.1.0', + files: { 'shared.ts': hash }, + ownedByUser: ['shared.ts'], + }) + await writeFile(path.join(h.projectDir, MANIFEST_PATH), JSON.stringify(manifest), 'utf8') + + await withCwd(h.projectDir, () => ownCommand(args(['own', 'shared.ts']), undefined as never)) + + const features = (await h.readManifest()).features + expect(features.map((f) => f.ownedByUser)).toEqual([['shared.ts'], ['shared.ts']]) + }) + + it('disown releases a path from every feature and refreshes each baseline', async () => { + const id = 'test:feat-own-8' + const h = await setup(id, { 'shared.ts': 'shared\n' }) + handles.push(h) + const manifest = await h.readManifest() + manifest.features.push({ + id: 'owntest:test:feat-own-8-other', + version: '0.1.0', + files: { 'shared.ts': 'stale-hash' }, + }) + await writeFile(path.join(h.projectDir, MANIFEST_PATH), JSON.stringify(manifest), 'utf8') + + await withCwd(h.projectDir, () => ownCommand(args(['own', 'shared.ts']), undefined as never)) + await writeFile(path.join(h.projectDir, 'shared.ts'), 'mine\n', 'utf8') + await withCwd(h.projectDir, () => disownCommand(args(['disown', 'shared.ts']), undefined as never)) + + const liveHash = await hashFile(path.join(h.projectDir, 'shared.ts')) + for (const rec of (await h.readManifest()).features) { + expect(rec.ownedByUser).toBeUndefined() + expect(rec.files['shared.ts']).toBe(liveHash) + } + }) + it('disown errors when the file does not exist on disk', async () => { const id = 'test:feat-own-6' const h = await setup(id, { 'a.ts': '1\n' }) diff --git a/packages/cli/test/pull-safety.test.ts b/packages/cli/test/pull-safety.test.ts index 0be6aa12..5a984569 100644 --- a/packages/cli/test/pull-safety.test.ts +++ b/packages/cli/test/pull-safety.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { BattlestackRegistries, updateLedger, type ProjectManifest, type RunContext } from '@battlestack/core' +import { BattlestackRegistries, exists, updateLedger, type ProjectManifest, type RunContext } from '@battlestack/core' import { baseDirIsTracked, needsAttention, @@ -141,6 +141,38 @@ describe('repairOverwrites', () => { expect((ctx.state['files:test:feat'] as Record).Dockerfile).toBe(sha256('FROM node\n')) }) + it('puts back an owned file a feature wrote over even when it was unedited', async () => { + git('init', '-q') + await write('Dockerfile', 'FROM node\n') + await commitAll() + // HEAD matches what battlestack recorded, so the file was never edited. + const start = recordedAtStart(manifestWith({ Dockerfile: sha256('FROM node\n') })) + + await write('Dockerfile', 'FROM node:24\n') + const ctx = ctxRecording({ Dockerfile: sha256('FROM node:24\n') }, projectDir, { 'owned:test:feat': ['Dockerfile'] }) + + expect((await repairOverwrites(ctx, start, ctx.registries)).map((r) => r.outcome)).toEqual(['kept yours, owned']) + expect(lf(await read('Dockerfile'))).toBe('FROM node\n') + }) + + it('puts back an owned file a feature wrote over, without staging anything', async () => { + git('init', '-q') + await write('Dockerfile', 'FROM node\nCOPY patches/ ./patches/\n') + git('add', '-A') + git('commit', '-q', '-m', 'init') + const start = recordedAtStart(manifestWith({ Dockerfile: sha256('FROM node\n') })) + + await write('Dockerfile', 'FROM node:24\n') + // Owned under a feature other than the one that wrote it. + const ctx = ctxRecording({ Dockerfile: sha256('FROM node:24\n') }, projectDir, { 'owned:other:feat': ['Dockerfile'] }) + + const repairs = await repairOverwrites(ctx, start, ctx.registries) + expect(repairs).toEqual([{ rel: 'Dockerfile', featureId: 'test:feat', outcome: 'kept yours, owned' }]) + expect(lf(await read('Dockerfile'))).toBe('FROM node\nCOPY patches/ ./patches/\n') + expect(await exists(path.join(projectDir, '.battlestack/pull/Dockerfile.new'))).toBe(false) + expect(updateLedger(ctx).staged).toEqual([]) + }) + it('leaves a file alone that was pristine before the pull', async () => { git('init', '-q') await write('a.ts', 'v1\n') @@ -223,6 +255,11 @@ describe('repairOverwrites', () => { expect((await repairOverwrites(force, start, force.registries)).map((r) => r.outcome)).toEqual(['backed up']) expect(await read('Dockerfile')).toBe('FROM node:24\n') expect(lf(await read(path.join('.battlestack', 'pull', 'Dockerfile.bak')))).toBe('FROM node\nCOPY patches/ ./patches/\n') + + // --overwrite also takes an owned file, as confirmed at its prompt. + const both = ctxRecording({ Dockerfile: sha256('FROM node:24\n') }, projectDir, { overwrite: true, force: true, 'owned:test:feat': ['Dockerfile'] }) + expect((await repairOverwrites(both, start, both.registries)).map((r) => r.outcome)).toEqual(['backed up']) + expect(await read('Dockerfile')).toBe('FROM node:24\n') }) it('puts back the record of the feature that wrote the file, not another tracker\'s', async () => { diff --git a/packages/cli/test/pull.test.ts b/packages/cli/test/pull.test.ts index e4b4c9e9..28993650 100644 --- a/packages/cli/test/pull.test.ts +++ b/packages/cli/test/pull.test.ts @@ -349,6 +349,52 @@ describe('battlestack pull: ownedByUser is inviolable', () => { process.exitCode = undefined }) + it('CORE PROPERTY: a path owned under one feature is left alone by every other feature that writes it', async () => { + const projectDir = await tmp('battlestack-pull-owned-shared-') + const ownerTpl = await tmp('battlestack-pull-owned-shared-owner-') + const otherV1 = await tmp('battlestack-pull-owned-shared-v1-') + const otherV2 = await tmp('battlestack-pull-owned-shared-v2-') + await writeTemplateFile(ownerTpl, 'shared.ts', 'v1\n') + await writeTemplateFile(otherV1, 'other.ts', 'other\n') + await writeTemplateFile(otherV2, 'other.ts', 'other\n') + await writeTemplateFile(otherV2, 'shared.ts', 'v2\n') + + // The owner runs first, so the other feature does not defer the shared path to it. + const ids = [fqid('test:feat-owner'), fqid('test:feat-other')] + const registriesV1 = makeRegistries({ requiredFeatures: ids }) + const owner = makeFileFeature('test:feat-owner', '1.0.0', ownerTpl, { stage: STAGE.BASE_CONFIG }) + const otherV1Feature = makeFileFeature('test:feat-other', '1.0.0', otherV1) + registriesV1.features.register(owner, origin) + registriesV1.features.register(otherV1Feature, origin) + const ctx = scaffoldCtx(projectDir, registriesV1, ids) + await owner.execute(ctx) + await otherV1Feature.execute(ctx) + await writeManifest(ctx) + + // The shape an older `own` left behind: both features track the path, only one owns it. + const manifest = await readManifestRaw(projectDir) + const sharedHash = await hashFile(path.join(projectDir, 'shared.ts')) + manifest.features.find((f) => f.id === fqid('test:feat-owner'))!.ownedByUser = ['shared.ts'] + manifest.features.find((f) => f.id === fqid('test:feat-other'))!.files['shared.ts'] = sharedHash + await writeFile(path.join(projectDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2), 'utf8') + const edited = 'mine\n' + await writeFile(path.join(projectDir, 'shared.ts'), edited, 'utf8') + + const registriesV2 = makeRegistries({ requiredFeatures: ids }) + registriesV2.features.register(makeFileFeature('test:feat-owner', '1.0.0', ownerTpl, { stage: STAGE.BASE_CONFIG }), origin) + registriesV2.features.register(makeFileFeature('test:feat-other', '2.0.0', otherV2), origin) + + process.exitCode = undefined + await runPull(projectDir, registriesV2) + + expect(await readFile(path.join(projectDir, 'shared.ts'), 'utf8')).toBe(edited) + expect(await exists(path.join(projectDir, '.battlestack', 'pull', 'shared.ts.new'))).toBe(false) + expect(process.exitCode).toBeUndefined() + const after = await readManifestRaw(projectDir) + expect(after.features.find((f) => f.id === fqid('test:feat-other'))!.version).toBe('2.0.0') + process.exitCode = undefined + }) + it('retires a moved template through real ownership and version-gated pulls without data loss', async () => { const projectDir = await tmp('battlestack-pull-owned-retirement-') const tplV1 = await tmp('battlestack-pull-owned-retirement-v1-') diff --git a/packages/core/src/utils/templates.ts b/packages/core/src/utils/templates.ts index 56551fba..5ddc35a6 100644 --- a/packages/core/src/utils/templates.ts +++ b/packages/core/src/utils/templates.ts @@ -252,10 +252,11 @@ export async function snapshotTrackedHashes( ctx: RunContext, features: TrackedFeatureFiles[], ): Promise> { + const owned = ownedAcross(ctx, features) const snap = new Map() for (const f of features) { for (const rel of Object.keys(f.recorded)) { - if (f.owned.has(rel)) continue + if (owned.has(toPosix(rel))) continue const abs = path.join(ctx.projectDir, rel) if (await exists(abs)) snap.set(snapKey(f.featureId, rel), await hashFile(abs)) } @@ -269,11 +270,12 @@ export async function reconcilePostFormat( features: TrackedFeatureFiles[], preHashes: Map, ): Promise { + const owned = ownedAcross(ctx, features) for (const f of features) { const stateKey = `files:${f.featureId}` if (!ctx.state[stateKey]) ctx.state[stateKey] = { ...f.recorded } for (const rel of Object.keys(f.recorded)) { - if (f.owned.has(rel)) continue + if (owned.has(toPosix(rel))) continue const pre = preHashes.get(snapKey(f.featureId, rel)) if (pre === undefined) continue // absent on disk before formatting const recordedHash = f.recorded[rel] @@ -376,11 +378,11 @@ async function classifyForUpdate( dest: string, rel: string, newContent: Buffer, - ownedSet: Set, + isOwned: boolean, recordedHashes: Set, ): Promise<{ state: UpdateState, newHash: string }> { const newHash = sha256(newContent) - if (ownedSet.has(rel)) return { state: 'owned', newHash } + if (isOwned) return { state: 'owned', newHash } if (!(await exists(dest))) return { state: 'missing', newHash } const currentHash = await hashFile(dest) @@ -442,9 +444,23 @@ function pushOnce(list: string[], rel: string): void { if (!list.includes(rel)) list.push(rel) } -/** The feature's owned rels, which `pull` seeds from the manifest's `ownedByUser`. */ -function ownedRelsFor(ctx: RunContext, featureId: string): Set { - return new Set((ctx.state[`owned:${featureId}`] as string[] | undefined) ?? []) +/** + * Posix rels owned under any feature, which `pull` seeds from the manifest's `ownedByUser`. + * Ownership is per path: a rel one feature owns is off limits to every feature's writes. + */ +export function ownedAnywhere(ctx: RunContext): Set { + const owned = new Set() + for (const [key, value] of Object.entries(ctx.state)) { + if (!key.startsWith('owned:') || !Array.isArray(value)) continue + for (const rel of value as string[]) owned.add(toPosix(rel)) + } + return owned +} + +function ownedAcross(ctx: RunContext, features: TrackedFeatureFiles[]): Set { + const owned = ownedAnywhere(ctx) + for (const f of features) for (const rel of f.owned) owned.add(toPosix(rel)) + return owned } /** @@ -462,7 +478,7 @@ export async function writeGuarded( ): Promise { const dest = path.join(ctx.projectDir, rel) const recordedHashes = recordedHashesFor(ctx, rel) - const owned = new Set([...ownedSet, ...ownedRelsFor(ctx, featureId)]) + const owned = ownedSet.has(rel) || ownedAnywhere(ctx).has(toPosix(rel)) const { state, newHash } = await classifyForUpdate(dest, rel, content, owned, recordedHashes) const recorded = (ctx.state[`files:${featureId}`] as Record | undefined)?.[rel] await applyUpdateState(ctx, featureId, { state, dest, rel, newContent: content, newHash, recordedHash: recorded, recordedHashes, report }) @@ -668,7 +684,7 @@ export async function deleteObsoleteFiles( if (trackedElsewhere.has(toPosix(rel))) continue const dest = path.join(ctx.projectDir, rel) if (!(await exists(dest))) continue - if (ownedSet.has(rel)) { + if (ownedSet.has(rel) || ownedAnywhere(ctx).has(toPosix(rel))) { report.notes.push( `file no longer shipped but user-owned, left in place: ${rel}; review and remove manually if no longer needed`, ) @@ -721,6 +737,7 @@ export async function updateFromTemplateDirs( } for (const p of ownedSet) recordOwned(ctx, featureId, p) + const owned = ownedAnywhere(ctx) const { deferredToLater, trackedElsewhere } = sharedRelOwnership(ctx, featureId) @@ -763,7 +780,7 @@ export async function updateFromTemplateDirs( dest, rel, newContent, - ownedSet, + owned.has(toPosix(rel)), recordedHashes, ) await applyUpdateState(ctx, featureId, { diff --git a/packages/preset-nuxt4/src/features/ai-tool-config.ts b/packages/preset-nuxt4/src/features/ai-tool-config.ts index 3dcd8f13..eb18d674 100644 --- a/packages/preset-nuxt4/src/features/ai-tool-config.ts +++ b/packages/preset-nuxt4/src/features/ai-tool-config.ts @@ -16,7 +16,7 @@ const SUPPORTED: AiTool[] = ['claude-code', 'gemini-cli', 'cursor', 'codex'] /** AI coding tool rules + MCP server scaffolding. Selection via `state.aiTool` (default: `claude-code`). */ export const aiToolConfigFeature: Feature = { id: 'shared:ai-tool-config', - version: '1.3.0', + version: '1.3.1', label: 'AI coding tool config', stage: STAGE.AI_TOOL_CONFIG, diff --git a/packages/preset-nuxt4/src/features/landing-shell.ts b/packages/preset-nuxt4/src/features/landing-shell.ts index 302d82b3..e720942f 100644 --- a/packages/preset-nuxt4/src/features/landing-shell.ts +++ b/packages/preset-nuxt4/src/features/landing-shell.ts @@ -1,13 +1,13 @@ import path from 'node:path' import { readFile, writeFile } from 'node:fs/promises' -import { STAGE, isFeatureEnabled, hashFile, rebaselineRecordedFile, recordFile, type Feature, type RunContext } from '@battlestack/core' +import { STAGE, exists, isFeatureEnabled, hashFile, rebaselineRecordedFile, recordFile, updateLedger, type Feature, type RunContext } from '@battlestack/core' import { emitTemplate, emitTemplateUpdate } from '../utils/emit-template.js' 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.2', + version: '1.4.3', label: 'Landing shell (layouts, public landing page)', description: 'Public landing page, layouts, and frontend app shell.', frameworks: ['nuxt4'], @@ -30,10 +30,40 @@ export const landingShellFeature: Feature = { const report = await emitTemplateUpdate(ctx, this.id, import.meta.url, 'landing-shell', prev) await applyVariants(ctx, this.id) if (isFeatureEnabled(ctx, 'nuxt4:battlestack-theme')) await applyColorAliases(ctx) + await suggestHeaderActions(ctx, prev?.version) return report }, } +const HEADER_ACTIONS_SINCE = '1.4.3' + +/** + * The layout is structural, so the project owns it and pull never adds the `#header-actions` + * target to it. Lists the step once, on the pull that crosses the version that introduced it. + */ +async function suggestHeaderActions(ctx: RunContext, fromVersion: string | undefined): Promise { + if (!fromVersion || !isOlder(fromVersion, HEADER_ACTIONS_SINCE)) return + const layout = path.join(ctx.projectDir, 'app', 'layouts', 'default.vue') + if (!(await exists(layout))) return + const content = await readFile(layout, 'utf8') + if (!content.includes('`, add ' + + '`
` inside `` in `app/layouts/default.vue`, and ' + + '`:ui="{ center: \'flex\' }"` on `` so it shows on mobile (give the `UNavigationMenu` there `class="hidden lg:flex"`). ' + + 'Pull leaves the layout alone because it is yours.', + ) +} + +function isOlder(version: string, than: string): boolean { + const a = version.split('.').map(Number) + const b = than.split('.').map(Number) + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) < (b[i] ?? 0) + } + return false +} + // Session UI sits between `battlestack:auth` markers, dropped when `nuxt4:auth` is off. const NO_AUTH_SCRIPT_STUB = [ '// No auth feature installed: landing shell renders without session UI.', diff --git a/packages/preset-nuxt4/src/features/mastra.ts b/packages/preset-nuxt4/src/features/mastra.ts index dafe31c7..0bfbfe3b 100644 --- a/packages/preset-nuxt4/src/features/mastra.ts +++ b/packages/preset-nuxt4/src/features/mastra.ts @@ -30,7 +30,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.7', + version: '2.1.8', label: 'Mastra AI runtime', frameworks: ['nuxt4'], stage: STAGE.AI_CORE, diff --git a/packages/preset-nuxt4/src/features/prompts.ts b/packages/preset-nuxt4/src/features/prompts.ts index 41b0b184..84e0737e 100644 --- a/packages/preset-nuxt4/src/features/prompts.ts +++ b/packages/preset-nuxt4/src/features/prompts.ts @@ -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.3', + version: '1.2.4', label: 'AI prompt management', description: 'Admin-editable agent prompts with shipped registry defaults.', frameworks: ['nuxt4'], diff --git a/packages/preset-nuxt4/templates/ai-tool-config/claude-code/.claude/skills/battlestack-ui/SKILL.md b/packages/preset-nuxt4/templates/ai-tool-config/claude-code/.claude/skills/battlestack-ui/SKILL.md index 203a6cc8..91738ede 100644 --- a/packages/preset-nuxt4/templates/ai-tool-config/claude-code/.claude/skills/battlestack-ui/SKILL.md +++ b/packages/preset-nuxt4/templates/ai-tool-config/claude-code/.claude/skills/battlestack-ui/SKILL.md @@ -28,6 +28,8 @@ description: Apply the Battlestack design system when building, modifying, revie State the classification in one line before implementing. For 6–7, read `references/component-selection.md`. +A page that needs an action in the app header, such as "New chat", teleports it into the layout's `#header-actions` target instead of editing the layout: ``. Render it client-side only (`` or a `v-if` on client state), because Nuxt teleports only to `body` during SSR. The layout is the project's shell, and editing it makes every later `battlestack pull` merge into it. + ### 3. Implement with tokens - Semantic utilities only in screens: `text-muted`, `bg-elevated`, `border-default`, `text-primary`, `rounded-md`. Never raw hex, arbitrary px, or Tailwind palette classes (`text-blue-600`) where a semantic name exists. diff --git a/packages/preset-nuxt4/templates/landing-shell/app/layouts/default.vue b/packages/preset-nuxt4/templates/landing-shell/app/layouts/default.vue index 099db314..9a1a48bc 100644 --- a/packages/preset-nuxt4/templates/landing-shell/app/layouts/default.vue +++ b/packages/preset-nuxt4/templates/landing-shell/app/layouts/default.vue @@ -3,7 +3,8 @@ - + + - +