Skip to content
Open
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<Teleport to="#header-actions" defer>` 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.
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/commands/add-remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
readManifest,
recordOwned,
run,
toPosixRel,
MANIFEST_PATH,
writeJson,
writeManifest,
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
spawnSyncResolved as safeSpawnSync,
STAGE_ORDER,
SUPPORTED_PMS,
toPosixRel,
type BattlestackRegistries,
type ParsedArgs,
type ProjectManifest,
Expand Down Expand Up @@ -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
Expand All @@ -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 })
}

Expand Down
58 changes: 31 additions & 27 deletions packages/cli/src/commands/own.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -73,8 +68,8 @@ export async function ownCommand(args: ParsedArgs, _loader: Ora): Promise<void>
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(', ')}?`
Expand All @@ -84,16 +79,21 @@ export async function ownCommand(args: ParsedArgs, _loader: Ora): Promise<void>
`${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}`)
}

Expand Down Expand Up @@ -125,16 +125,16 @@ export async function disownCommand(args: ParsedArgs, _loader: Ora): Promise<voi
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) {
throw new CLIError(
ErrorCode.UNKNOWN_FEATURE,
`${rel} is not tracked by any feature.`,
)
}
const list = feature.ownedByUser ?? []
if (!list.includes(rel)) {
const note = pc.dim(`was not owned (${feature.id}), no change`)
const owners = features.filter((f) => (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
}
Expand All @@ -146,11 +146,15 @@ export async function disownCommand(args: ParsedArgs, _loader: Ora): Promise<voi
)
}
const currentHash = await hashFile(abs)
feature.files[rel] = currentHash
feature.ownedByUser = list.filter((p) => 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}`)
}

Expand All @@ -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)
}
Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/commands/pull-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
hashContent,
hasConflictMarkers,
isBinaryPath,
ownedAnywhere,
pruneBases,
recordedFiles,
stageUpstream,
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -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[] = []

Expand All @@ -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<string, string>()
const before = new Set(startByFeature.values())
if (before.has(hashContent(head.out))) continue
Expand Down Expand Up @@ -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/<path>.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/<path>.bak`. A file you own with `battlestack own` is simply put back. Check the result.',
repaired,
),
...section(
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/test/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 24 additions & 1 deletion packages/cli/test/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async function writeProjectManifest(
dir: string,
opts: {
packageManager?: string
features?: Array<{ id: string, version: string, files?: Record<string, string> }>
features?: Array<{ id: string, version: string, files?: Record<string, string>, ownedByUser?: string[] }>
framework?: string
template?: string
optedOut?: string[]
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/test/own.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
Loading
Loading