cronus-ui 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/commands/add-page.d.ts +108 -0
- package/dist/commands/add-page.js +642 -0
- package/dist/commands/add.d.ts +9 -0
- package/dist/commands/add.js +114 -0
- package/dist/commands/ai.d.ts +14 -0
- package/dist/commands/ai.js +69 -0
- package/dist/commands/compose.d.ts +82 -0
- package/dist/commands/compose.js +403 -0
- package/dist/commands/diff.d.ts +8 -0
- package/dist/commands/diff.js +55 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +53 -0
- package/dist/commands/list.d.ts +7 -0
- package/dist/commands/list.js +28 -0
- package/dist/commands/theme.d.ts +23 -0
- package/dist/commands/theme.js +735 -0
- package/dist/commands/upgrade.d.ts +51 -0
- package/dist/commands/upgrade.js +840 -0
- package/dist/compose/data-slots.d.ts +71 -0
- package/dist/compose/data-slots.js +104 -0
- package/dist/compose/manifest.d.ts +90 -0
- package/dist/compose/manifest.js +224 -0
- package/dist/compose/plan.d.ts +164 -0
- package/dist/compose/plan.js +506 -0
- package/dist/compose/preview.d.ts +10 -0
- package/dist/compose/preview.js +48 -0
- package/dist/compose/reload.d.ts +56 -0
- package/dist/compose/reload.js +138 -0
- package/dist/compose/render.d.ts +123 -0
- package/dist/compose/render.js +404 -0
- package/dist/compose/templates.d.ts +22 -0
- package/dist/compose/templates.js +76 -0
- package/dist/compose.d.ts +10 -0
- package/dist/compose.js +8 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +38 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +184 -0
- package/dist/registry.d.ts +59 -0
- package/dist/registry.js +96 -0
- package/dist/utils.d.ts +72 -0
- package/dist/utils.js +186 -0
- package/package.json +68 -0
- package/templates/apps/chat.json +44 -0
- package/templates/apps/finance.json +44 -0
- package/templates/apps/landing-agency.json +31 -0
- package/templates/apps/landing-agents.json +32 -0
- package/templates/apps/landing-broadcast.json +29 -0
- package/templates/apps/landing-care.json +28 -0
- package/templates/apps/landing-coverage.json +23 -0
- package/templates/apps/landing-docs.json +29 -0
- package/templates/apps/landing-glass.json +28 -0
- package/templates/apps/landing-ops.json +29 -0
- package/templates/apps/landing-premium.json +31 -0
- package/templates/apps/landing-secure.json +31 -0
- package/templates/apps/landing-shop.json +27 -0
- package/templates/apps/landing-studio.json +30 -0
- package/templates/apps/landing.json +23 -0
- package/templates/apps/mail.json +44 -0
- package/templates/apps/saas.json +64 -0
- package/templates/apps/store.json +75 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { buildComposePlan } from "../compose/plan.js";
|
|
9
|
+
import { baseSnapshotDest, filterManifestToComposedPages, listBaseSnapshotRels, readBaseSnapshot, reloadManifest, } from "../compose/reload.js";
|
|
10
|
+
import { renderPlan } from "../compose/render.js";
|
|
11
|
+
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
12
|
+
import { Registry, registrySourceAtVersion, registrySourceVersion, } from "../registry.js";
|
|
13
|
+
import { closestName, log, resolveSafeDest, rewriteImports, targetDir, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
14
|
+
import { readChromeSources, readComposeMeta, readProjectName } from "./compose.js";
|
|
15
|
+
/** Where the human/agent-readable conflict report is written (project root). */
|
|
16
|
+
export const REPORT_FILE = "CRONUS-UPGRADE.md";
|
|
17
|
+
/* -------------------------------------------------------------------------- */
|
|
18
|
+
/* git machinery (node:child_process only — no new dependencies) */
|
|
19
|
+
/* -------------------------------------------------------------------------- */
|
|
20
|
+
function runGit(args) {
|
|
21
|
+
return new Promise((resolvePromise) => {
|
|
22
|
+
const child = spawn("git", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
23
|
+
let out = "";
|
|
24
|
+
child.stdout.on("data", (chunk) => {
|
|
25
|
+
out += chunk.toString("utf8");
|
|
26
|
+
});
|
|
27
|
+
child.on("error", () => resolvePromise({ code: -1, stdout: "" }));
|
|
28
|
+
child.on("close", (code) => resolvePromise({ code: code ?? -1, stdout: out }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Probe for a usable `git` binary once per process. */
|
|
32
|
+
let gitProbe;
|
|
33
|
+
function gitAvailable() {
|
|
34
|
+
gitProbe ??= runGit(["--version"]).then((r) => r.code === 0);
|
|
35
|
+
return gitProbe;
|
|
36
|
+
}
|
|
37
|
+
/** Run `fn` with the given named contents written to a fresh temp dir. */
|
|
38
|
+
async function withTempFiles(contents, fn) {
|
|
39
|
+
const dir = await mkdtemp(join(tmpdir(), "cronus-ui-upgrade-"));
|
|
40
|
+
try {
|
|
41
|
+
const paths = {};
|
|
42
|
+
for (const [name, content] of Object.entries(contents)) {
|
|
43
|
+
const path = join(dir, name);
|
|
44
|
+
await writeFile(path, content, "utf8");
|
|
45
|
+
paths[name] = path;
|
|
46
|
+
}
|
|
47
|
+
return await fn(paths);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await rm(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 3-way merge via `git merge-file -p --diff3`. Exit code 0 = clean merge,
|
|
55
|
+
* 1..127 = that many conflicts (stdout still holds the marked-up result),
|
|
56
|
+
* anything else (or no git at all) = unavailable → caller falls back to a
|
|
57
|
+
* whole-file "manual merge needed" report.
|
|
58
|
+
*/
|
|
59
|
+
export async function mergeThreeWay(base, local, target, labels) {
|
|
60
|
+
if (!(await gitAvailable()))
|
|
61
|
+
return { status: "unavailable" };
|
|
62
|
+
return withTempFiles({ base, local, target }, async (paths) => {
|
|
63
|
+
const { code, stdout } = await runGit([
|
|
64
|
+
"merge-file",
|
|
65
|
+
"-p",
|
|
66
|
+
"--diff3",
|
|
67
|
+
"-L",
|
|
68
|
+
"LOCAL (your edits)",
|
|
69
|
+
"-L",
|
|
70
|
+
`BASE (${labels.base})`,
|
|
71
|
+
"-L",
|
|
72
|
+
`UPSTREAM (${labels.target})`,
|
|
73
|
+
paths.local ?? "",
|
|
74
|
+
paths.base ?? "",
|
|
75
|
+
paths.target ?? "",
|
|
76
|
+
]);
|
|
77
|
+
if (code === 0)
|
|
78
|
+
return { status: "clean", content: stdout };
|
|
79
|
+
if (code > 0 && code < 128)
|
|
80
|
+
return { status: "conflict", content: stdout };
|
|
81
|
+
return { status: "unavailable" };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Unified diff (a→b) via `git diff --no-index`, with the temp-file headers
|
|
86
|
+
* replaced by the given labels. Returns undefined when git is unavailable or
|
|
87
|
+
* errors (exit 1 just means "files differ" and is expected).
|
|
88
|
+
*/
|
|
89
|
+
export async function unifiedDiff(a, b, labelA, labelB) {
|
|
90
|
+
if (!(await gitAvailable()))
|
|
91
|
+
return undefined;
|
|
92
|
+
return withTempFiles({ a, b }, async (paths) => {
|
|
93
|
+
const { code, stdout } = await runGit([
|
|
94
|
+
"diff",
|
|
95
|
+
"--no-index",
|
|
96
|
+
"--unified=3",
|
|
97
|
+
paths.a ?? "",
|
|
98
|
+
paths.b ?? "",
|
|
99
|
+
]);
|
|
100
|
+
if (code !== 0 && code !== 1)
|
|
101
|
+
return undefined;
|
|
102
|
+
const lines = stdout.split("\n");
|
|
103
|
+
const firstHunk = lines.findIndex((line) => line.startsWith("@@"));
|
|
104
|
+
if (firstHunk === -1)
|
|
105
|
+
return undefined;
|
|
106
|
+
return [`--- ${labelA}`, `+++ ${labelB}`, ...lines.slice(firstHunk)].join("\n").trimEnd();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
/* planning */
|
|
111
|
+
/* -------------------------------------------------------------------------- */
|
|
112
|
+
/** Whitespace-insensitive equality, matching how `diff` detects drift. */
|
|
113
|
+
function same(a, b) {
|
|
114
|
+
return a.trim() === b.trim();
|
|
115
|
+
}
|
|
116
|
+
/** Rel path + safe absolute dest for one registry file (throws on traversal). */
|
|
117
|
+
function fileDest(config, cwd, file) {
|
|
118
|
+
const dir = targetDir(config, file.target);
|
|
119
|
+
return { rel: join(dir, file.path), dest: resolveSafeDest(cwd, dir, file.path) };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decision matrix for one file of a 3-way upgrade (components AND composed
|
|
123
|
+
* pages). `base === undefined` means the file is not in the merge base
|
|
124
|
+
* (snapshot missing → treat as empty, same as an upstream-new collision).
|
|
125
|
+
*
|
|
126
|
+
* missing locally, new upstream → new-file (write upstream)
|
|
127
|
+
* missing locally, existed in base → locally-deleted (respect deletion)
|
|
128
|
+
* local == upstream → up-to-date
|
|
129
|
+
* base == upstream (only local moved) → local-edits (keep local)
|
|
130
|
+
* local == base (only upstream moved) → fast-forward (write upstream)
|
|
131
|
+
* all three differ → 3-way merge (merged | conflict)
|
|
132
|
+
*/
|
|
133
|
+
async function planThreeWayFile(args) {
|
|
134
|
+
const { item, relPath, dest, base, local, target, labels, baseVersion } = args;
|
|
135
|
+
const common = { item, relPath, dest, baseVersion };
|
|
136
|
+
if (local === undefined) {
|
|
137
|
+
return base === undefined
|
|
138
|
+
? { ...common, status: "new-file", content: target }
|
|
139
|
+
: { ...common, status: "locally-deleted" };
|
|
140
|
+
}
|
|
141
|
+
if (same(local, target)) {
|
|
142
|
+
return { ...common, status: "up-to-date" };
|
|
143
|
+
}
|
|
144
|
+
if (base !== undefined && same(base, target)) {
|
|
145
|
+
return { ...common, status: "local-edits" };
|
|
146
|
+
}
|
|
147
|
+
if (base !== undefined && same(local, base)) {
|
|
148
|
+
return { ...common, status: "fast-forward", content: target };
|
|
149
|
+
}
|
|
150
|
+
// All three versions differ (an upstream-new file colliding with an
|
|
151
|
+
// existing local file merges against an empty base).
|
|
152
|
+
const merged = await mergeThreeWay(base ?? "", local, target, labels);
|
|
153
|
+
if (merged.status === "clean") {
|
|
154
|
+
return { ...common, status: "merged", content: merged.content };
|
|
155
|
+
}
|
|
156
|
+
if (merged.status === "conflict") {
|
|
157
|
+
const upstreamDiff = await unifiedDiff(base ?? "", target, labels.base, labels.target);
|
|
158
|
+
return {
|
|
159
|
+
...common,
|
|
160
|
+
status: "conflict",
|
|
161
|
+
content: merged.content,
|
|
162
|
+
upstreamDiff,
|
|
163
|
+
targetContent: target,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ...common, status: "manual", targetContent: target };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Plan the upgrade of an item that HAS a merge base (install manifest + the
|
|
170
|
+
* registry at the installed version). Files present in base but dropped
|
|
171
|
+
* upstream are reported (removed-upstream) and never deleted — removing user
|
|
172
|
+
* files is not this command's call.
|
|
173
|
+
*/
|
|
174
|
+
async function planManifestItem(args) {
|
|
175
|
+
const { name, config, cwd, baseItem, targetItem, baseVersion, targetVersion } = args;
|
|
176
|
+
const labels = { base: `registry v${baseVersion}`, target: `registry v${targetVersion}` };
|
|
177
|
+
const plans = [];
|
|
178
|
+
const targetFiles = [];
|
|
179
|
+
const baseByRel = new Map();
|
|
180
|
+
for (const file of baseItem.files) {
|
|
181
|
+
const dir = targetDir(config, file.target);
|
|
182
|
+
baseByRel.set(join(dir, file.path), rewriteImports(file.content, config));
|
|
183
|
+
}
|
|
184
|
+
for (const file of targetItem.files) {
|
|
185
|
+
let rel;
|
|
186
|
+
let dest;
|
|
187
|
+
try {
|
|
188
|
+
({ rel, dest } = fileDest(config, cwd, file));
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
log.warn(`${name}: ${err.message}`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
targetFiles.push(rel);
|
|
195
|
+
const target = rewriteImports(file.content, config);
|
|
196
|
+
const base = baseByRel.get(rel);
|
|
197
|
+
const local = existsSync(dest) ? await readFile(dest, "utf8") : undefined;
|
|
198
|
+
plans.push(await planThreeWayFile({
|
|
199
|
+
item: name,
|
|
200
|
+
relPath: rel,
|
|
201
|
+
dest,
|
|
202
|
+
base,
|
|
203
|
+
local,
|
|
204
|
+
target,
|
|
205
|
+
labels,
|
|
206
|
+
baseVersion,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
for (const rel of baseByRel.keys()) {
|
|
210
|
+
if (!targetFiles.includes(rel)) {
|
|
211
|
+
plans.push({ item: name, relPath: rel, status: "removed-upstream", baseVersion });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { name, legacy: false, plans, targetFiles };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Plan an item installed before the manifest existed (or whose base registry
|
|
218
|
+
* release is unreachable): with no base there is nothing to merge against, so
|
|
219
|
+
* this is a 2-way local-vs-upstream comparison. Matching files adopt the
|
|
220
|
+
* manifest going forward; diverged files are only replaced under --overwrite
|
|
221
|
+
* (plus confirmation).
|
|
222
|
+
*/
|
|
223
|
+
async function planLegacyItem(args) {
|
|
224
|
+
const { name, config, cwd, targetItem, targetVersion } = args;
|
|
225
|
+
const plans = [];
|
|
226
|
+
const targetFiles = [];
|
|
227
|
+
for (const file of targetItem.files) {
|
|
228
|
+
let rel;
|
|
229
|
+
let dest;
|
|
230
|
+
try {
|
|
231
|
+
({ rel, dest } = fileDest(config, cwd, file));
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
log.warn(`${name}: ${err.message}`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
targetFiles.push(rel);
|
|
238
|
+
const target = rewriteImports(file.content, config);
|
|
239
|
+
const common = { item: name, relPath: rel, dest };
|
|
240
|
+
if (!existsSync(dest)) {
|
|
241
|
+
plans.push({ ...common, status: "new-file", content: target });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const local = await readFile(dest, "utf8");
|
|
245
|
+
if (same(local, target)) {
|
|
246
|
+
plans.push({ ...common, status: "legacy-match" });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const upstreamDiff = await unifiedDiff(local, target, "local (yours)", labelFor(targetVersion));
|
|
250
|
+
plans.push({
|
|
251
|
+
...common,
|
|
252
|
+
status: "legacy-differs",
|
|
253
|
+
content: target,
|
|
254
|
+
upstreamDiff,
|
|
255
|
+
targetContent: target,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return { name, legacy: true, plans, targetFiles };
|
|
259
|
+
}
|
|
260
|
+
function labelFor(version) {
|
|
261
|
+
return `registry v${version}`;
|
|
262
|
+
}
|
|
263
|
+
/** Rel paths owned by `installed{}` items — chrome blocks, not generated pages. */
|
|
264
|
+
function installedFileSet(config) {
|
|
265
|
+
const out = new Set();
|
|
266
|
+
for (const rec of Object.values(config.installed ?? {})) {
|
|
267
|
+
for (const file of rec.files)
|
|
268
|
+
out.add(file);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Plan a 3-way upgrade of one composed app's generated pages/layouts/wrappers.
|
|
274
|
+
* Reloads the manifest (same provenance guard as add-page), re-renders with
|
|
275
|
+
* recorded choices, and runs the same decision matrix as {@link planManifestItem}.
|
|
276
|
+
* Chrome rewrites are NOT applied — chrome blocks are `installed{}` items.
|
|
277
|
+
*/
|
|
278
|
+
async function planComposedApp(args) {
|
|
279
|
+
const { composedKey, record, config, cwd, registry, targetVersion, manifestPath } = args;
|
|
280
|
+
const item = `compose:${composedKey}`;
|
|
281
|
+
const labels = {
|
|
282
|
+
base: `registry v${record.version}`,
|
|
283
|
+
target: `registry v${targetVersion}`,
|
|
284
|
+
};
|
|
285
|
+
const base = await reloadManifest(composedKey, manifestPath, record);
|
|
286
|
+
const synthetic = filterManifestToComposedPages(base, record);
|
|
287
|
+
const meta = await readComposeMeta(registry);
|
|
288
|
+
if (meta === null) {
|
|
289
|
+
throw new Error("This registry does not ship a meta.json sidecar — compose upgrade needs it (upgrade to v0.4.0+).");
|
|
290
|
+
}
|
|
291
|
+
const index = await registry.index();
|
|
292
|
+
const chromeSources = await readChromeSources(synthetic, registry);
|
|
293
|
+
const appName = await readProjectName(cwd);
|
|
294
|
+
const keptRoutes = synthetic.manifest.pages.map((p) => p.route);
|
|
295
|
+
const choices = {
|
|
296
|
+
brand: record.choices.brand,
|
|
297
|
+
variants: record.choices.variants,
|
|
298
|
+
appName,
|
|
299
|
+
...(record.choices.seed !== undefined ? { seed: record.choices.seed } : {}),
|
|
300
|
+
...(keptRoutes.length > 0 ? { pages: keptRoutes } : {}),
|
|
301
|
+
};
|
|
302
|
+
const plan = buildComposePlan(synthetic, choices, index, meta, chromeSources);
|
|
303
|
+
// Pages + layouts + chrome WRAPPERS only. Chrome block bodies are installed{}
|
|
304
|
+
// items and already 3-way as local edits — do not re-run brand/nav rewrite.
|
|
305
|
+
const { files } = renderPlan(plan, config);
|
|
306
|
+
const plans = [];
|
|
307
|
+
const targetFiles = [];
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
let dest;
|
|
310
|
+
try {
|
|
311
|
+
dest = resolveSafeDest(cwd, ".", file.path);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
log.warn(`${item}: ${err.message}`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
targetFiles.push(file.path);
|
|
318
|
+
const target = file.content;
|
|
319
|
+
// Snapshot missing → empty base (same as upstream-new colliding with local).
|
|
320
|
+
const snap = await readBaseSnapshot(cwd, composedKey, plan.appName, file.path);
|
|
321
|
+
const local = existsSync(dest) ? await readFile(dest, "utf8") : undefined;
|
|
322
|
+
const filePlan = await planThreeWayFile({
|
|
323
|
+
item,
|
|
324
|
+
relPath: file.path,
|
|
325
|
+
dest,
|
|
326
|
+
base: snap,
|
|
327
|
+
local,
|
|
328
|
+
target,
|
|
329
|
+
labels,
|
|
330
|
+
baseVersion: record.version,
|
|
331
|
+
});
|
|
332
|
+
// Snapshot dest is always the composed key (new writes never go to appName).
|
|
333
|
+
let snapshotDest;
|
|
334
|
+
try {
|
|
335
|
+
snapshotDest = baseSnapshotDest(cwd, composedKey, file.path);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
snapshotDest = undefined;
|
|
339
|
+
}
|
|
340
|
+
plans.push({ ...filePlan, snapshotContent: target, snapshotDest });
|
|
341
|
+
}
|
|
342
|
+
const installedFiles = installedFileSet(config);
|
|
343
|
+
const tracked = new Set([
|
|
344
|
+
...targetFiles,
|
|
345
|
+
...(await listBaseSnapshotRels(cwd, composedKey, plan.appName)),
|
|
346
|
+
]);
|
|
347
|
+
for (const rel of record.files) {
|
|
348
|
+
if (!installedFiles.has(rel))
|
|
349
|
+
tracked.add(rel);
|
|
350
|
+
}
|
|
351
|
+
for (const rel of tracked) {
|
|
352
|
+
if (!targetFiles.includes(rel)) {
|
|
353
|
+
plans.push({
|
|
354
|
+
item,
|
|
355
|
+
relPath: rel,
|
|
356
|
+
status: "removed-upstream",
|
|
357
|
+
baseVersion: record.version,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Template pages may have grown a block the project never installed. Resolve
|
|
362
|
+
// those items (plus transitive registry deps) so apply can write them before
|
|
363
|
+
// the page that imports them. Already-installed slugs are left alone.
|
|
364
|
+
const missing = plan.blockSlugs.filter((slug) => config.installed?.[slug] === undefined);
|
|
365
|
+
const installItems = missing.length > 0 ? await registry.resolve(missing) : [];
|
|
366
|
+
return {
|
|
367
|
+
name: item,
|
|
368
|
+
legacy: false,
|
|
369
|
+
plans,
|
|
370
|
+
targetFiles,
|
|
371
|
+
composeKey: composedKey,
|
|
372
|
+
installItems,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/* -------------------------------------------------------------------------- */
|
|
376
|
+
/* apply + report */
|
|
377
|
+
/* -------------------------------------------------------------------------- */
|
|
378
|
+
async function askConfirmDefault(question) {
|
|
379
|
+
if (!process.stdin.isTTY)
|
|
380
|
+
return false;
|
|
381
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
382
|
+
try {
|
|
383
|
+
const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
|
|
384
|
+
return answer === "y" || answer === "yes";
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
rl.close();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Human label per status, shared by console output and the report table. */
|
|
391
|
+
function statusLabel(plan) {
|
|
392
|
+
switch (plan.status) {
|
|
393
|
+
case "up-to-date":
|
|
394
|
+
return "up to date";
|
|
395
|
+
case "fast-forward":
|
|
396
|
+
return "fast-forward (no local edits)";
|
|
397
|
+
case "new-file":
|
|
398
|
+
return "new upstream file";
|
|
399
|
+
case "local-edits":
|
|
400
|
+
return "upstream unchanged — kept your edits";
|
|
401
|
+
case "merged":
|
|
402
|
+
return "merged cleanly (your edits + upstream)";
|
|
403
|
+
case "conflict":
|
|
404
|
+
return plan.markersWritten
|
|
405
|
+
? "CONFLICT (markers written — resolve manually)"
|
|
406
|
+
: "CONFLICT (left untouched)";
|
|
407
|
+
case "manual":
|
|
408
|
+
return "CONFLICT (git unavailable — manual merge needed)";
|
|
409
|
+
case "removed-upstream":
|
|
410
|
+
return "removed upstream — left in place";
|
|
411
|
+
case "locally-deleted":
|
|
412
|
+
return "locally deleted — skipped";
|
|
413
|
+
case "legacy-match":
|
|
414
|
+
return "matches upstream (manifest recorded)";
|
|
415
|
+
case "legacy-differs":
|
|
416
|
+
return plan.overwritten
|
|
417
|
+
? "overwritten with upstream"
|
|
418
|
+
: "differs (legacy install — kept; use --overwrite)";
|
|
419
|
+
default:
|
|
420
|
+
return plan.status;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Statuses that write the planned content to disk unconditionally. */
|
|
424
|
+
const WRITE_STATUSES = new Set(["fast-forward", "new-file", "merged"]);
|
|
425
|
+
function fileWasWritten(plan) {
|
|
426
|
+
return WRITE_STATUSES.has(plan.status) || plan.markersWritten === true;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* After a composed-page write, refresh the snapshot to the NEW UPSTREAM render
|
|
430
|
+
* (not the merged local). The next upgrade's base must be "the bytes we emitted
|
|
431
|
+
* last time from the template".
|
|
432
|
+
*/
|
|
433
|
+
async function refreshComposeSnapshots(items) {
|
|
434
|
+
for (const item of items) {
|
|
435
|
+
if (item.composeKey === undefined)
|
|
436
|
+
continue;
|
|
437
|
+
for (const plan of item.plans) {
|
|
438
|
+
if (!fileWasWritten(plan))
|
|
439
|
+
continue;
|
|
440
|
+
if (plan.snapshotDest === undefined || plan.snapshotContent === undefined)
|
|
441
|
+
continue;
|
|
442
|
+
await writeFileEnsured(plan.snapshotDest, plan.snapshotContent);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Write registry items a composed re-plan newly requires. Never overwrites an
|
|
448
|
+
* existing file (same collision-safety as `add` without `--overwrite`). Returns
|
|
449
|
+
* the `installed{}` records for items whose files all landed.
|
|
450
|
+
*/
|
|
451
|
+
async function installPendingBlocks(items, config, cwd, version) {
|
|
452
|
+
const next = {};
|
|
453
|
+
const seen = new Set();
|
|
454
|
+
for (const item of items) {
|
|
455
|
+
for (const registryItem of item.installItems ?? []) {
|
|
456
|
+
if (seen.has(registryItem.name))
|
|
457
|
+
continue;
|
|
458
|
+
seen.add(registryItem.name);
|
|
459
|
+
const { written } = await writeItemFiles(registryItem, config, cwd, { overwrite: false });
|
|
460
|
+
if (written.length === registryItem.files.length && written.length > 0) {
|
|
461
|
+
next[registryItem.name] = { version, files: written };
|
|
462
|
+
log.ok(`${pc.dim(item.name)} install ${registryItem.name}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return next;
|
|
467
|
+
}
|
|
468
|
+
/** Execute a plan: write files, asking before conflict markers / overwrites. */
|
|
469
|
+
async function applyPlans(items, options, confirm) {
|
|
470
|
+
let written = 0;
|
|
471
|
+
for (const item of items) {
|
|
472
|
+
for (const plan of item.plans) {
|
|
473
|
+
if (plan.dest === undefined || plan.content === undefined)
|
|
474
|
+
continue;
|
|
475
|
+
if (WRITE_STATUSES.has(plan.status)) {
|
|
476
|
+
await writeFileEnsured(plan.dest, plan.content);
|
|
477
|
+
written += 1;
|
|
478
|
+
}
|
|
479
|
+
else if (plan.status === "conflict") {
|
|
480
|
+
const ok = options.yes === true ||
|
|
481
|
+
(await confirm(`Write ${plan.relPath} WITH conflict markers for manual resolution?`));
|
|
482
|
+
if (ok) {
|
|
483
|
+
await writeFileEnsured(plan.dest, plan.content);
|
|
484
|
+
plan.markersWritten = true;
|
|
485
|
+
written += 1;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
else if (plan.status === "legacy-differs" && options.overwrite === true) {
|
|
489
|
+
const ok = options.yes === true ||
|
|
490
|
+
(await confirm(`Overwrite ${plan.relPath} with upstream? Your local edits are lost.`));
|
|
491
|
+
if (ok) {
|
|
492
|
+
await writeFileEnsured(plan.dest, plan.content);
|
|
493
|
+
plan.overwritten = true;
|
|
494
|
+
written += 1;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return written;
|
|
500
|
+
}
|
|
501
|
+
/** True when every file landed in a state that embodies the target version. */
|
|
502
|
+
function itemFullyUpgraded(item) {
|
|
503
|
+
if (item.legacy) {
|
|
504
|
+
return item.plans.every((p) => p.status === "legacy-match" || p.status === "new-file" || p.overwritten === true);
|
|
505
|
+
}
|
|
506
|
+
return item.plans.every((p) => p.status !== "manual" && (p.status !== "conflict" || p.markersWritten === true));
|
|
507
|
+
}
|
|
508
|
+
/** A file the report should carry an agent prompt for. */
|
|
509
|
+
function needsAttention(plan) {
|
|
510
|
+
return (plan.status === "conflict" ||
|
|
511
|
+
plan.status === "manual" ||
|
|
512
|
+
(plan.status === "legacy-differs" && plan.overwritten !== true));
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Ready-to-paste prompt for a coding agent (Claude Code, Cursor, …) to finish
|
|
516
|
+
* one file's merge. Deliberately provider-agnostic: it is plain text plus the
|
|
517
|
+
* base→upstream diff, so any agent — or a patient human — can act on it.
|
|
518
|
+
*/
|
|
519
|
+
function agentPrompt(plan, targetVersion) {
|
|
520
|
+
const target = labelFor(targetVersion);
|
|
521
|
+
const diffBlock = plan.upstreamDiff !== undefined
|
|
522
|
+
? `\`\`\`diff\n${plan.upstreamDiff}\n\`\`\``
|
|
523
|
+
: `\`\`\`\n${(plan.targetContent ?? "").trimEnd()}\n\`\`\``;
|
|
524
|
+
if (plan.status === "legacy-differs") {
|
|
525
|
+
return [
|
|
526
|
+
`2-way merge \`${plan.relPath}\` (Cronus UI item "${plan.item}", upgrading to ${target}).`,
|
|
527
|
+
"This component predates install manifests, so there is no recorded base version.",
|
|
528
|
+
"The file on disk is my edited copy. Apply the upstream changes below onto it,",
|
|
529
|
+
"keeping my local intent (naming, added props, styling tweaks) and adopting the",
|
|
530
|
+
"upstream fixes. Do not reformat unrelated code.",
|
|
531
|
+
"",
|
|
532
|
+
plan.upstreamDiff !== undefined
|
|
533
|
+
? "Upstream change (my local file → upstream):"
|
|
534
|
+
: "Full upstream version of the file:",
|
|
535
|
+
"",
|
|
536
|
+
diffBlock,
|
|
537
|
+
].join("\n");
|
|
538
|
+
}
|
|
539
|
+
const base = labelFor(plan.baseVersion ?? "unknown");
|
|
540
|
+
const intro = `3-way merge \`${plan.relPath}\` (Cronus UI item "${plan.item}", ${base} → ${target}).`;
|
|
541
|
+
const state = plan.markersWritten === true
|
|
542
|
+
? [
|
|
543
|
+
"The file on disk contains git diff3 conflict markers:",
|
|
544
|
+
"- `<<<<<<< LOCAL (your edits)` … my customized version — KEEP my local intent.",
|
|
545
|
+
`- \`||||||| BASE (${base})\` … the version I originally installed.`,
|
|
546
|
+
`- \`>>>>>>> UPSTREAM (${target})\` … the new version — ADOPT its fixes.`,
|
|
547
|
+
"Merge both sides and remove every marker: re-apply my customizations on top",
|
|
548
|
+
"of the upstream structure. Do not reformat unrelated code.",
|
|
549
|
+
]
|
|
550
|
+
: [
|
|
551
|
+
"The file on disk is my unmodified local copy (no conflict markers were written).",
|
|
552
|
+
"Apply the upstream change below onto it, keeping my local edits intact and",
|
|
553
|
+
"adopting the upstream fixes. Do not reformat unrelated code.",
|
|
554
|
+
];
|
|
555
|
+
const reference = plan.upstreamDiff !== undefined
|
|
556
|
+
? "For reference, the upstream change (base → upstream) is:"
|
|
557
|
+
: "git was unavailable, so here is the FULL upstream version to merge against:";
|
|
558
|
+
return [intro, "", ...state, "", reference, "", diffBlock].join("\n");
|
|
559
|
+
}
|
|
560
|
+
/** Build CRONUS-UPGRADE.md: status table + one agent prompt per pending file. */
|
|
561
|
+
function buildReport(items, targetVersion) {
|
|
562
|
+
const rows = items.flatMap((item) => item.plans.map((plan) => `| ${plan.item} | \`${plan.relPath}\` | ${statusLabel(plan)} |`));
|
|
563
|
+
const pending = items.flatMap((item) => item.plans.filter(needsAttention));
|
|
564
|
+
const sections = pending.map((plan) => [
|
|
565
|
+
`### \`${plan.relPath}\` (${plan.item})`,
|
|
566
|
+
"",
|
|
567
|
+
"Copy-paste this prompt into your coding agent:",
|
|
568
|
+
"",
|
|
569
|
+
"````text",
|
|
570
|
+
agentPrompt(plan, targetVersion),
|
|
571
|
+
"````",
|
|
572
|
+
].join("\n"));
|
|
573
|
+
return [
|
|
574
|
+
"# Cronus UI upgrade report",
|
|
575
|
+
"",
|
|
576
|
+
`- Generated by \`cronus-ui upgrade\` on ${new Date().toISOString()}`,
|
|
577
|
+
`- Upgrade target: registry v${targetVersion}`,
|
|
578
|
+
"- This file is safe to delete once every conflict below is resolved.",
|
|
579
|
+
"",
|
|
580
|
+
"## File status",
|
|
581
|
+
"",
|
|
582
|
+
"| Item | File | Status |",
|
|
583
|
+
"| --- | --- | --- |",
|
|
584
|
+
...rows,
|
|
585
|
+
"",
|
|
586
|
+
"## Files needing attention",
|
|
587
|
+
"",
|
|
588
|
+
sections.length > 0 ? sections.join("\n\n") : "None — everything merged cleanly.",
|
|
589
|
+
"",
|
|
590
|
+
].join("\n");
|
|
591
|
+
}
|
|
592
|
+
/* -------------------------------------------------------------------------- */
|
|
593
|
+
/* command */
|
|
594
|
+
/* -------------------------------------------------------------------------- */
|
|
595
|
+
/** Resolve which item names this run should process. */
|
|
596
|
+
async function resolveTargets(names, options, installed, registry, config, cwd) {
|
|
597
|
+
if (names.length > 0) {
|
|
598
|
+
// Same typo guard as `add`: validate against the index when reachable.
|
|
599
|
+
try {
|
|
600
|
+
const available = (await registry.index()).map((i) => i.name);
|
|
601
|
+
const known = new Set(available);
|
|
602
|
+
const unknown = names.filter((name) => !known.has(name));
|
|
603
|
+
if (unknown.length > 0) {
|
|
604
|
+
for (const name of unknown) {
|
|
605
|
+
const suggestion = closestName(name, available);
|
|
606
|
+
log.err(suggestion
|
|
607
|
+
? `Unknown item "${name}". Did you mean "${suggestion}"?`
|
|
608
|
+
: `Unknown item "${name}".`);
|
|
609
|
+
}
|
|
610
|
+
process.exitCode = 1;
|
|
611
|
+
return undefined;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// Index unavailable — defer failures to the per-item fetch below.
|
|
616
|
+
}
|
|
617
|
+
return [...new Set(names)];
|
|
618
|
+
}
|
|
619
|
+
if (options.all !== true) {
|
|
620
|
+
log.err("Specify component names or use --all, e.g. `cronus-ui upgrade button` .");
|
|
621
|
+
process.exitCode = 1;
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
const manifestNames = Object.keys(installed);
|
|
625
|
+
if (manifestNames.length > 0)
|
|
626
|
+
return manifestNames;
|
|
627
|
+
// Pre-manifest project: adopt what is on disk by scanning the registry index
|
|
628
|
+
// for items with at least one locally present file (mirrors `diff`).
|
|
629
|
+
log.step("No install manifest found — scanning the registry for locally installed items…");
|
|
630
|
+
const found = [];
|
|
631
|
+
for (const entry of await registry.index()) {
|
|
632
|
+
let item;
|
|
633
|
+
try {
|
|
634
|
+
item = await registry.item(entry.name);
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const present = item.files.some((file) => {
|
|
640
|
+
try {
|
|
641
|
+
return existsSync(fileDest(config, cwd, file).dest);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
if (present)
|
|
648
|
+
found.push(entry.name);
|
|
649
|
+
}
|
|
650
|
+
return found;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* `cronus-ui upgrade` — pull upstream component (and, with `--all`, composed
|
|
654
|
+
* page) updates WITHOUT losing local edits. For every target it 3-way merges
|
|
655
|
+
* base, local, and upstream via `git merge-file --diff3`. Conflicts are never
|
|
656
|
+
* silently clobbered: markers are only written with consent, and
|
|
657
|
+
* CRONUS-UPGRADE.md gets a ready-to-paste agent prompt per unresolved file.
|
|
658
|
+
*/
|
|
659
|
+
export async function upgrade(names, options) {
|
|
660
|
+
const { cwd } = options;
|
|
661
|
+
if (!hasConfig(cwd)) {
|
|
662
|
+
log.err("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
663
|
+
process.exitCode = 1;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const config = await readConfig(cwd);
|
|
667
|
+
// Target = the registry at the RUNNING CLI's release. A cronus-ui.json written
|
|
668
|
+
// by an older CLI still carries its old pinned URL, so re-pin it; an explicit
|
|
669
|
+
// --registry is respected verbatim.
|
|
670
|
+
const targetSource = options.registry ?? registrySourceAtVersion(config.registry, CLI_VERSION) ?? config.registry;
|
|
671
|
+
const targetVersion = registrySourceVersion(targetSource) ?? CLI_VERSION;
|
|
672
|
+
const targetRegistry = new Registry(targetSource);
|
|
673
|
+
const installed = { ...config.installed };
|
|
674
|
+
const composed = { ...config.composed };
|
|
675
|
+
const composedKeys = Object.keys(composed).sort();
|
|
676
|
+
const upgradeComposed = options.all === true && composedKeys.length > 0;
|
|
677
|
+
const confirm = options.confirm ?? askConfirmDefault;
|
|
678
|
+
const targets = await resolveTargets(names, options, installed, targetRegistry, config, cwd);
|
|
679
|
+
if (targets === undefined)
|
|
680
|
+
return;
|
|
681
|
+
if (targets.length === 0 && !upgradeComposed) {
|
|
682
|
+
log.title("Nothing to upgrade — no installed components found.");
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
log.title(`${options.dryRun === true ? "Upgrade plan (dry-run)" : "Upgrading"} → registry v${targetVersion}`);
|
|
686
|
+
const baseRegistries = new Map();
|
|
687
|
+
const itemPlans = [];
|
|
688
|
+
for (const name of targets) {
|
|
689
|
+
let targetItem;
|
|
690
|
+
try {
|
|
691
|
+
targetItem = await targetRegistry.item(name);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
log.warn(`${name}: not in registry v${targetVersion} — skipped.`);
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const record = installed[name];
|
|
698
|
+
let baseItem;
|
|
699
|
+
let baseVersion;
|
|
700
|
+
if (record !== undefined) {
|
|
701
|
+
if (record.version === targetVersion) {
|
|
702
|
+
baseItem = targetItem; // same release → base and upstream coincide
|
|
703
|
+
baseVersion = record.version;
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const baseSource = registrySourceAtVersion(targetSource, record.version);
|
|
707
|
+
if (baseSource === undefined) {
|
|
708
|
+
log.warn(`${name}: registry source is not release-pinned, cannot fetch v${record.version} as merge base — falling back to a 2-way diff.`);
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
let baseRegistry = baseRegistries.get(baseSource);
|
|
712
|
+
if (baseRegistry === undefined) {
|
|
713
|
+
baseRegistry = new Registry(baseSource);
|
|
714
|
+
baseRegistries.set(baseSource, baseRegistry);
|
|
715
|
+
}
|
|
716
|
+
try {
|
|
717
|
+
baseItem = await baseRegistry.item(name);
|
|
718
|
+
baseVersion = record.version;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
log.warn(`${name}: version v${record.version} registry not found — was it published? Falling back to a 2-way diff.`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const itemPlan = baseItem !== undefined && baseVersion !== undefined
|
|
727
|
+
? await planManifestItem({
|
|
728
|
+
name,
|
|
729
|
+
config,
|
|
730
|
+
cwd,
|
|
731
|
+
baseItem,
|
|
732
|
+
targetItem,
|
|
733
|
+
baseVersion,
|
|
734
|
+
targetVersion,
|
|
735
|
+
})
|
|
736
|
+
: await planLegacyItem({ name, config, cwd, targetItem, targetVersion });
|
|
737
|
+
itemPlans.push(itemPlan);
|
|
738
|
+
}
|
|
739
|
+
// Composed pages: plan after components, fail loud before any write.
|
|
740
|
+
if (upgradeComposed) {
|
|
741
|
+
try {
|
|
742
|
+
for (const key of composedKeys) {
|
|
743
|
+
const record = composed[key];
|
|
744
|
+
if (record === undefined)
|
|
745
|
+
continue;
|
|
746
|
+
itemPlans.push(await planComposedApp({
|
|
747
|
+
composedKey: key,
|
|
748
|
+
record,
|
|
749
|
+
config,
|
|
750
|
+
cwd,
|
|
751
|
+
registry: targetRegistry,
|
|
752
|
+
targetVersion,
|
|
753
|
+
manifestPath: options.manifestPath,
|
|
754
|
+
}));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
log.err(err.message);
|
|
759
|
+
process.exitCode = 1;
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (itemPlans.length === 0) {
|
|
764
|
+
log.title("Nothing to upgrade.");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (options.dryRun === true) {
|
|
768
|
+
printPlan(itemPlans);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
// Install newly required blocks BEFORE writing pages that import them.
|
|
772
|
+
const newlyInstalled = await installPendingBlocks(itemPlans, config, cwd, targetVersion);
|
|
773
|
+
Object.assign(installed, newlyInstalled);
|
|
774
|
+
const written = await applyPlans(itemPlans, options, confirm);
|
|
775
|
+
await refreshComposeSnapshots(itemPlans);
|
|
776
|
+
// Manifest: record every item that now fully embodies the target release.
|
|
777
|
+
// Compose items update `composed{}` (never `installed{}`) and never drop
|
|
778
|
+
// add-page files from the tracked set.
|
|
779
|
+
let manifestChanged = false;
|
|
780
|
+
for (const item of itemPlans) {
|
|
781
|
+
if (item.composeKey !== undefined) {
|
|
782
|
+
const rec = composed[item.composeKey];
|
|
783
|
+
if (rec === undefined)
|
|
784
|
+
continue;
|
|
785
|
+
const writtenRels = item.plans.filter(fileWasWritten).map((p) => p.relPath);
|
|
786
|
+
const files = [...new Set([...rec.files, ...item.targetFiles, ...writtenRels])].sort();
|
|
787
|
+
const version = itemFullyUpgraded(item) ? targetVersion : rec.version;
|
|
788
|
+
composed[item.composeKey] = { ...rec, files, version };
|
|
789
|
+
manifestChanged = true;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (!itemFullyUpgraded(item))
|
|
793
|
+
continue;
|
|
794
|
+
installed[item.name] = { version: targetVersion, files: item.targetFiles };
|
|
795
|
+
manifestChanged = true;
|
|
796
|
+
}
|
|
797
|
+
if (manifestChanged) {
|
|
798
|
+
await writeConfig(cwd, { ...config, installed, composed });
|
|
799
|
+
}
|
|
800
|
+
for (const item of itemPlans) {
|
|
801
|
+
for (const plan of item.plans) {
|
|
802
|
+
const line = `${pc.dim(plan.item)} ${plan.relPath}: ${statusLabel(plan)}`;
|
|
803
|
+
if (needsAttention(plan))
|
|
804
|
+
log.warn(line);
|
|
805
|
+
else
|
|
806
|
+
log.ok(line);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const pending = itemPlans.flatMap((item) => item.plans.filter(needsAttention));
|
|
810
|
+
if (pending.length > 0) {
|
|
811
|
+
const reportPath = join(cwd, REPORT_FILE);
|
|
812
|
+
await writeFile(reportPath, buildReport(itemPlans, targetVersion), "utf8");
|
|
813
|
+
log.title(`${pending.length} file(s) need attention — see ${REPORT_FILE}`);
|
|
814
|
+
log.step("Each one has a ready-to-paste agent prompt (Claude Code, Cursor, …) in the report.");
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
log.title(`Done — ${written} file(s) written, everything merged cleanly.`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** Dry-run output: one line per file plus a status tally. Writes nothing. */
|
|
821
|
+
function printPlan(items) {
|
|
822
|
+
const counts = new Map();
|
|
823
|
+
for (const item of items) {
|
|
824
|
+
for (const plan of item.plans) {
|
|
825
|
+
const label = plan.status === "conflict" ? "CONFLICT" : statusLabel(plan);
|
|
826
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
827
|
+
const line = `${pc.dim(plan.item)} ${plan.relPath}: ${label}`;
|
|
828
|
+
if (needsAttention(plan))
|
|
829
|
+
log.warn(line);
|
|
830
|
+
else
|
|
831
|
+
log.step(line);
|
|
832
|
+
}
|
|
833
|
+
for (const registryItem of item.installItems ?? []) {
|
|
834
|
+
log.step(`${pc.dim(item.name)} would install ${registryItem.name}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const tally = [...counts.entries()].map(([label, count]) => `${count} ${label}`).join(", ");
|
|
838
|
+
log.title(`Plan: ${tally}. Nothing written (dry-run).`);
|
|
839
|
+
}
|
|
840
|
+
//# sourceMappingURL=upgrade.js.map
|