Skip to content
48 changes: 32 additions & 16 deletions apps/cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import pc from "picocolors";
import {
applyScaffoldUpgrade,
planScaffoldUpgrade,
recordUpgradeBaseline,
type UpgradePlan,
} from "../helpers/core/scaffold-upgrade";
import { readBtsConfig } from "../utils/bts-config";
import { handleError } from "../utils/errors";
import { renderTitle } from "../utils/render-title";
import { recordScaffoldManifest } from "../utils/scaffold-manifest";

export type UpdateCommandInput = {
projectDir?: string;
Expand Down Expand Up @@ -51,6 +51,15 @@ function reportManual(entries: UpgradePlan["manual"]): void {
}
}

function reportMerged(plan: UpgradePlan): void {
const merged = plan.files.filter((file) => file.category === "merged");
if (merged.length === 0) return;
log.message(`Structured merges (template changes folded into your file) (${merged.length}):`);
for (const entry of merged) {
log.message(pc.dim(` ± ${entry.path}${entry.reason ? ` — ${entry.reason}` : ""}`));
}
}

function reportRemoved(plan: UpgradePlan): void {
const removed = plan.files.filter((file) => file.category === "removed");
if (removed.length === 0) return;
Expand All @@ -74,6 +83,7 @@ function renderPlan(plan: UpgradePlan): void {
log.message("");

reportGroup("Template drift (safe to patch)", "~", plan.drift);
reportMerged(plan);
reportGroup("New files from templates", "+", plan.newFiles);
reportGroup("Locally edited (kept as-is)", "*", plan.userEdited);
reportGroup("Conflicts (template + local both changed)", "!", plan.conflicts);
Expand All @@ -83,8 +93,9 @@ function renderPlan(plan: UpgradePlan): void {
log.message("");
log.message(
pc.dim(
`${plan.unchanged.length} up to date · ${plan.drift.length} drift · ${plan.newFiles.length} new · ` +
`${plan.userEdited.length} local · ${plan.conflicts.length} conflict · ${plan.manual.length} manual`,
`${plan.unchanged.length} up to date · ${plan.drift.length} drift · ${plan.merged.length} merge · ` +
`${plan.newFiles.length} new · ${plan.userEdited.length} local · ${plan.conflicts.length} conflict · ` +
`${plan.manual.length} manual`,
),
);
}
Expand All @@ -97,17 +108,21 @@ function toJsonPlan(plan: UpgradePlan) {
summary: {
unchanged: plan.unchanged.length,
drift: plan.drift.length,
merged: plan.merged.length,
newFiles: plan.newFiles.length,
userEdited: plan.userEdited.length,
conflicts: plan.conflicts.length,
manual: plan.manual.length,
removed: plan.removed.length,
},
drift: plan.drift,
merged: plan.files
.filter((file) => file.category === "merged")
.map(({ path: filePath, reason }) => ({ path: filePath, reason })),
newFiles: plan.newFiles,
userEdited: plan.userEdited,
conflicts: plan.conflicts,
manual: plan.manual,
manual: plan.manual.map(({ path: filePath, reason }) => ({ path: filePath, reason })),
removed: plan.removed,
actionable: plan.actionable,
};
Expand Down Expand Up @@ -162,7 +177,7 @@ export async function updateCommand(input: UpdateCommandInput): Promise<void> {
}

if (recordBaseline) {
const manifest = await recordScaffoldManifest(projectDir);
const manifest = await recordUpgradeBaseline(projectDir);
if (json) {
console.log(
JSON.stringify(
Expand Down Expand Up @@ -196,7 +211,7 @@ export async function updateCommand(input: UpdateCommandInput): Promise<void> {
}

let plan: UpgradePlan;
let applied: { patched: string[]; added: string[] } | undefined;
let applied: { patched: string[]; added: string[]; merged: string[] } | undefined;
if (apply) {
const result = await applyScaffoldUpgrade(projectDir);
if (!result.success) return failUpdate(projectDir, result.error, json);
Expand Down Expand Up @@ -238,24 +253,24 @@ export async function updateCommand(input: UpdateCommandInput): Promise<void> {
log.message("");

if (applied) {
const total = applied.patched.length + applied.added.length;
const total = applied.patched.length + applied.added.length + applied.merged.length;
if (total === 0) {
log.success(pc.green("Already up to date. No template-drift patches to apply."));
} else {
log.success(
pc.green(
`Applied ${formatCount(applied.patched.length, "patch")} and added ${formatCount(
applied.added.length,
"file",
)}.`,
`Applied ${formatCount(applied.patched.length, "patch")}, ${formatCount(
applied.merged.length,
"structured merge",
)}, and added ${formatCount(applied.added.length, "file")}.`,
),
);
}
const leftover = plan.conflicts.length + plan.manual.length;
if (leftover > 0) {
log.warn(
pc.yellow(
`${formatCount(leftover, "file")} still need manual review (conflicts + post-processed files).`,
`${formatCount(leftover, "file")} still need manual review (conflicts + lockfiles/manual files).`,
),
);
}
Expand All @@ -268,10 +283,11 @@ export async function updateCommand(input: UpdateCommandInput): Promise<void> {
} else {
log.info(
pc.cyan(
`Run \`bfs update --apply\` to patch ${formatCount(
plan.drift.length,
"drift file",
)} and add ${formatCount(plan.newFiles.length, "new file")}.`,
`Run \`bfs update --apply\` to patch ${formatCount(plan.drift.length, "drift file")}, ` +
`apply ${formatCount(plan.merged.length, "structured merge")}, and add ${formatCount(
plan.newFiles.length,
"new file",
)}.`,
),
);
}
Expand Down
13 changes: 9 additions & 4 deletions apps/cli/src/helpers/core/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isSilent } from "../../utils/context";
import { applyDependencyVersionChannel } from "../../utils/dependency-version-channel";
import { CLIError } from "../../utils/errors";
import { formatProject } from "../../utils/file-formatter";
import { recordScaffoldManifest } from "../../utils/scaffold-manifest";
import { collectStructuredBaselines, recordScaffoldManifest } from "../../utils/scaffold-manifest";
import { setupAddons } from "../addons/addons-setup";
import { setupDatabase } from "../core/db-setup";
import { initializeGit } from "./git";
Expand Down Expand Up @@ -83,9 +83,14 @@ export async function createProject(options: ProjectConfig, cliInput: CreateProj

// Record the scaffold baseline (bts.lock.json) from the final formatted,
// pre-install bytes so `bfs update` can later tell template drift apart from
// user edits. Best-effort: recordScaffoldManifest never throws, so a failure
// here disables update auto-patching without breaking scaffolding.
await recordScaffoldManifest(projectDir);
// user edits. The pure-render content of package.json / *.env.example (before
// the post-processing above mutated them on disk) is kept alongside the
// hashes so `bfs update` can structurally merge future template changes.
// Best-effort: recordScaffoldManifest never throws, so a failure here
// disables update auto-patching without breaking scaffolding.
await recordScaffoldManifest(projectDir, {
baselines: collectStructuredBaselines(result.tree),
});

if (!isSilent()) log.success("Project template successfully scaffolded!");

Expand Down
Loading
Loading