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,114 @@
|
|
|
1
|
+
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
2
|
+
import { Registry, registrySourceVersion } from "../registry.js";
|
|
3
|
+
import { closestName, collectDependencies, detectPackageManager, log, runInstall, writeItemFiles, } from "../utils.js";
|
|
4
|
+
export async function add(names, options) {
|
|
5
|
+
const { cwd } = options;
|
|
6
|
+
if (!hasConfig(cwd)) {
|
|
7
|
+
log.err("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (names.length === 0) {
|
|
12
|
+
log.err("Specify at least one component, e.g. `cronus-ui add button card`.");
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const config = await readConfig(cwd);
|
|
17
|
+
const registry = new Registry(options.registry ?? config.registry);
|
|
18
|
+
// Validate requested names against the registry index up front so a typo gets
|
|
19
|
+
// a "did you mean" hint instead of an opaque 404 from resolve(). If the index
|
|
20
|
+
// itself cannot be read we fall through and let resolve() report the failure.
|
|
21
|
+
try {
|
|
22
|
+
const available = (await registry.index()).map((i) => i.name);
|
|
23
|
+
const known = new Set(available);
|
|
24
|
+
const unknown = names.filter((name) => !known.has(name));
|
|
25
|
+
if (unknown.length > 0) {
|
|
26
|
+
for (const name of unknown) {
|
|
27
|
+
const suggestion = closestName(name, available);
|
|
28
|
+
log.err(suggestion
|
|
29
|
+
? `Unknown item "${name}". Did you mean "${suggestion}"?`
|
|
30
|
+
: `Unknown item "${name}".`);
|
|
31
|
+
}
|
|
32
|
+
log.step("Run `cronus-ui list` to see all available items.");
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Index unavailable (e.g. offline local registry) — defer to resolve() below.
|
|
39
|
+
}
|
|
40
|
+
let items;
|
|
41
|
+
try {
|
|
42
|
+
items = await registry.resolve(names);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
log.err(`Failed to resolve from registry: ${err.message}`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const requested = new Set(names);
|
|
50
|
+
// Transitive registry items pulled in to satisfy a requested item's
|
|
51
|
+
// registryDependencies. Blocks import the @cronus-ui/ui package rather than
|
|
52
|
+
// copied source, so they never pull in components — only ui/lib items do.
|
|
53
|
+
const pulledIn = items.filter((i) => !requested.has(i.name) && (i.type === "registry:ui" || i.type === "registry:lib"));
|
|
54
|
+
const blockCount = items.filter((i) => requested.has(i.name) && i.type === "registry:block").length;
|
|
55
|
+
const componentCount = names.length - blockCount;
|
|
56
|
+
const summary = [
|
|
57
|
+
componentCount > 0 ? `${componentCount} component(s)` : null,
|
|
58
|
+
blockCount > 0 ? `${blockCount} block(s)` : null,
|
|
59
|
+
]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" and ");
|
|
62
|
+
log.title(`Adding ${summary}`);
|
|
63
|
+
if (pulledIn.length > 0) {
|
|
64
|
+
log.step(`Pulling in dependencies: ${pulledIn.map((i) => i.name).join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
// The registry release these files come from — recorded in the install
|
|
67
|
+
// manifest so `upgrade` can later fetch the exact merge base. Sources without
|
|
68
|
+
// a version segment (e.g. a local dir) are pinned to the running CLI version.
|
|
69
|
+
const sourceUsed = options.registry ?? config.registry;
|
|
70
|
+
const installedVersion = registrySourceVersion(sourceUsed) ?? CLI_VERSION;
|
|
71
|
+
const installed = { ...config.installed };
|
|
72
|
+
let manifestChanged = false;
|
|
73
|
+
const allWritten = [];
|
|
74
|
+
const allSkipped = [];
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
const { written, skipped } = await writeItemFiles(item, config, cwd, {
|
|
77
|
+
overwrite: options.overwrite ?? false,
|
|
78
|
+
});
|
|
79
|
+
allWritten.push(...written);
|
|
80
|
+
allSkipped.push(...skipped);
|
|
81
|
+
// Only record an item whose files were ALL written this run. A partially
|
|
82
|
+
// skipped item keeps older (possibly edited) files on disk — pinning it to
|
|
83
|
+
// today's release would corrupt the 3-way merge base for `upgrade`.
|
|
84
|
+
if (written.length === item.files.length && written.length > 0) {
|
|
85
|
+
installed[item.name] = { version: installedVersion, files: written };
|
|
86
|
+
manifestChanged = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const path of allWritten)
|
|
90
|
+
log.ok(`Added ${path}`);
|
|
91
|
+
for (const path of allSkipped)
|
|
92
|
+
log.warn(`Skipped ${path} (exists — use --overwrite)`);
|
|
93
|
+
if (manifestChanged) {
|
|
94
|
+
await writeConfig(cwd, { ...config, installed });
|
|
95
|
+
}
|
|
96
|
+
const deps = collectDependencies(items);
|
|
97
|
+
if (deps.length > 0 && !options.skipInstall) {
|
|
98
|
+
const pm = detectPackageManager(cwd);
|
|
99
|
+
log.step(`Installing ${deps.length} dependencies with ${pm}…`);
|
|
100
|
+
try {
|
|
101
|
+
await runInstall(pm, deps, cwd);
|
|
102
|
+
log.ok("Installed dependencies");
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
log.warn(`Install failed: ${err.message}`);
|
|
106
|
+
log.step(`Install manually: ${pm} add ${deps.join(" ")}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (deps.length > 0) {
|
|
110
|
+
log.step(`Dependencies to install: ${deps.join(" ")}`);
|
|
111
|
+
}
|
|
112
|
+
log.title(`Done — ${allWritten.length} file(s) written.`);
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=add.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface AiOptions {
|
|
2
|
+
cwd: string;
|
|
3
|
+
assistants?: string;
|
|
4
|
+
preset?: string;
|
|
5
|
+
skills?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Scaffold the Cronus UI AI Kit (AGENTS.md doctrine + Claude Code / Cursor /
|
|
9
|
+
* Copilot config) into the project. Writes are idempotent: existing files are
|
|
10
|
+
* left untouched, so re-running only fills in what is missing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function aiAdd(options: AiOptions): Promise<void>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=ai.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { ASSISTANTS, DOCTRINE_PRESETS, parseList, SKILLS, writeAiKit, } from "@cronus-ui/ai-kit";
|
|
4
|
+
import { hasConfig, readConfig } from "../config.js";
|
|
5
|
+
import { log } from "../utils.js";
|
|
6
|
+
/** Read the project name from `<cwd>/package.json`, falling back to the dir name. */
|
|
7
|
+
async function projectName(cwd) {
|
|
8
|
+
try {
|
|
9
|
+
const raw = await readFile(join(cwd, "package.json"), "utf8");
|
|
10
|
+
const parsed = JSON.parse(raw);
|
|
11
|
+
if (typeof parsed.name === "string" && parsed.name.trim() !== "")
|
|
12
|
+
return parsed.name;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// No/invalid package.json — fall back to the directory basename below.
|
|
16
|
+
}
|
|
17
|
+
return basename(cwd);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Scaffold the Cronus UI AI Kit (AGENTS.md doctrine + Claude Code / Cursor /
|
|
21
|
+
* Copilot config) into the project. Writes are idempotent: existing files are
|
|
22
|
+
* left untouched, so re-running only fills in what is missing.
|
|
23
|
+
*/
|
|
24
|
+
export async function aiAdd(options) {
|
|
25
|
+
const { cwd } = options;
|
|
26
|
+
let assistants;
|
|
27
|
+
let skills;
|
|
28
|
+
try {
|
|
29
|
+
assistants = parseList(options.assistants, ASSISTANTS, "assistant");
|
|
30
|
+
skills = parseList(options.skills, SKILLS, "skill");
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
log.err(err.message);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const preset = options.preset ? options.preset : "standard";
|
|
38
|
+
if (!DOCTRINE_PRESETS.includes(preset)) {
|
|
39
|
+
log.err(`Unknown preset "${options.preset}". Use one of: ${DOCTRINE_PRESETS.join(", ")}.`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const name = await projectName(cwd);
|
|
44
|
+
const theme = hasConfig(cwd) ? (await readConfig(cwd)).theme?.name : undefined;
|
|
45
|
+
log.title(`Adding the Cronus UI AI Kit to ${name}`);
|
|
46
|
+
log.step(`Assistants: ${assistants.join(", ")} · doctrine: ${preset}`);
|
|
47
|
+
const { written, skipped } = writeAiKit({
|
|
48
|
+
targetDir: cwd,
|
|
49
|
+
name,
|
|
50
|
+
assistants,
|
|
51
|
+
preset,
|
|
52
|
+
skills,
|
|
53
|
+
theme,
|
|
54
|
+
});
|
|
55
|
+
for (const path of written)
|
|
56
|
+
log.ok(`Wrote ${path}`);
|
|
57
|
+
if (skipped.length > 0) {
|
|
58
|
+
log.step(`Skipped ${skipped.length} existing file(s) (idempotent — never clobbered):`);
|
|
59
|
+
for (const path of skipped)
|
|
60
|
+
log.step(` ${path}`);
|
|
61
|
+
}
|
|
62
|
+
if (written.length === 0) {
|
|
63
|
+
log.title("Nothing to do — the AI Kit is already in place.");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
log.title(`Done — ${written.length} file(s) written.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=ai.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type AppManifest } from "../compose/manifest.js";
|
|
2
|
+
import { type ComposeChoiceInput, type ComposeMeta } from "../compose/plan.js";
|
|
3
|
+
import { Registry, type RegistryItem } from "../registry.js";
|
|
4
|
+
export { listTemplates, loadManifestFile, loadTemplate } from "../compose/templates.js";
|
|
5
|
+
/** Read the chrome block sources named by the manifest's chrome map from the registry. */
|
|
6
|
+
export declare function readChromeSources(manifest: AppManifest, registry: Registry): Promise<Record<string, string>>;
|
|
7
|
+
/** The compose meta subset, loaded from the registry's `meta.json`. */
|
|
8
|
+
export declare function readComposeMeta(registry: Registry): Promise<ComposeMeta | null>;
|
|
9
|
+
/**
|
|
10
|
+
* The project's package name (from package.json), used as the default brand and
|
|
11
|
+
* as `__APP_NAME__`. Falls back to the directory basename when unreadable, so the
|
|
12
|
+
* composer never fails on a bare project. Derives the trailing segment of a scoped
|
|
13
|
+
* name ("@acme/shop" → "shop").
|
|
14
|
+
*/
|
|
15
|
+
export declare function readProjectName(cwd: string): Promise<string>;
|
|
16
|
+
/** Options accepted by the composeApp library entry + the CLI command. */
|
|
17
|
+
export interface ComposeAppOptions {
|
|
18
|
+
/** Project root (must contain cronus-ui.json). */
|
|
19
|
+
targetDir: string;
|
|
20
|
+
/** Bundled template name (mutually exclusive with `manifestPath`). */
|
|
21
|
+
template?: string;
|
|
22
|
+
/** Explicit manifest file path (mutually exclusive with `template`). */
|
|
23
|
+
manifestPath?: string;
|
|
24
|
+
/** Plan-shaping choices (brand/seed/pages/variants). */
|
|
25
|
+
choices?: ComposeChoiceInput;
|
|
26
|
+
/** Overwrite existing files instead of skipping (default false). */
|
|
27
|
+
overwrite?: boolean;
|
|
28
|
+
/** Skip the npm install step (default false). */
|
|
29
|
+
skipInstall?: boolean;
|
|
30
|
+
/** Override the registry source (else use the project config's). */
|
|
31
|
+
registry?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Result of a successful compose (for programmatic callers / tests). */
|
|
34
|
+
export interface ComposeAppResult {
|
|
35
|
+
/** The template/manifest name (the `composed{}` key). */
|
|
36
|
+
templateName: string;
|
|
37
|
+
/** The resolved project/brand name. */
|
|
38
|
+
appName: string;
|
|
39
|
+
installedBlocks: string[];
|
|
40
|
+
generatedFiles: string[];
|
|
41
|
+
skippedFiles: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Compose an app template into an existing Cronus UI project: resolve + install the
|
|
45
|
+
* blocks, customize the chrome copies (nav data + brand), write the generated
|
|
46
|
+
* pages/layouts/wrappers, snapshot the emitted bytes, and record `installed{}` +
|
|
47
|
+
* `composed{}`. Reuses the exact `add` install core — this is one more caller, not
|
|
48
|
+
* a new write path. Throws {@link ComposePlanError} / {@link ManifestError} on
|
|
49
|
+
* invalid input.
|
|
50
|
+
*/
|
|
51
|
+
export declare function composeApp(options: ComposeAppOptions): Promise<ComposeAppResult>;
|
|
52
|
+
/** CLI-facing options (flags parsed by commander in index.ts). */
|
|
53
|
+
export interface ComposeCommandOptions {
|
|
54
|
+
cwd: string;
|
|
55
|
+
registry?: string;
|
|
56
|
+
manifest?: string;
|
|
57
|
+
pages?: string;
|
|
58
|
+
variant?: string[];
|
|
59
|
+
brand?: string;
|
|
60
|
+
seed?: string;
|
|
61
|
+
overwrite?: boolean;
|
|
62
|
+
skipInstall?: boolean;
|
|
63
|
+
dryRun?: boolean;
|
|
64
|
+
yes?: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse `--variant login=split --variant footer=mega` into a `{ slug: variant }`
|
|
68
|
+
* record. Each pair MUST be `slug=variant` with a non-empty slug and variant;
|
|
69
|
+
* a malformed pair (no `=`, empty side) throws so a typo like `--variant login`
|
|
70
|
+
* fails loud instead of being silently dropped. A repeated slug keeps the last
|
|
71
|
+
* value (last flag wins).
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseVariants(pairs: string[] | undefined): Record<string, string>;
|
|
74
|
+
/**
|
|
75
|
+
* The `cronus-ui compose` command. Resolves a template (bundled or `--manifest`),
|
|
76
|
+
* plans + validates it (aggregating errors), and either prints a deterministic
|
|
77
|
+
* dry-run preview or applies it via {@link composeApp}. TTY-prompts for a template
|
|
78
|
+
* when none is given (unless `--yes`).
|
|
79
|
+
*/
|
|
80
|
+
export declare function compose(templateArg: string | undefined, options: ComposeCommandOptions): Promise<void>;
|
|
81
|
+
export type { RegistryItem };
|
|
82
|
+
//# sourceMappingURL=compose.d.ts.map
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeDesignDocuments } from "@cronus-ui/ai-kit";
|
|
5
|
+
import { manifestFingerprint } from "../compose/manifest.js";
|
|
6
|
+
import { buildComposePlan, ComposePlanError, } from "../compose/plan.js";
|
|
7
|
+
import { renderPreview } from "../compose/preview.js";
|
|
8
|
+
import { baseSnapshotDir } from "../compose/reload.js";
|
|
9
|
+
import { renderPlan } from "../compose/render.js";
|
|
10
|
+
import { defaultComposeTemplate, listTemplates, loadManifestFile, loadTemplate, } from "../compose/templates.js";
|
|
11
|
+
import { CLI_VERSION, hasConfig, readConfig, writeConfig, } from "../config.js";
|
|
12
|
+
import { Registry, registrySourceVersion } from "../registry.js";
|
|
13
|
+
import { collectDependencies, detectPackageManager, log, resolveSafeDest, runInstall, writeFileEnsured, writeItemFiles, } from "../utils.js";
|
|
14
|
+
export { listTemplates, loadManifestFile, loadTemplate } from "../compose/templates.js";
|
|
15
|
+
/** Read the chrome block sources named by the manifest's chrome map from the registry. */
|
|
16
|
+
export async function readChromeSources(manifest, registry) {
|
|
17
|
+
const slugs = new Set();
|
|
18
|
+
for (const group of Object.values(manifest.manifest.chrome)) {
|
|
19
|
+
if (group.navbar !== undefined)
|
|
20
|
+
slugs.add(group.navbar);
|
|
21
|
+
if (group.footer !== undefined)
|
|
22
|
+
slugs.add(group.footer);
|
|
23
|
+
if (group.block !== undefined)
|
|
24
|
+
slugs.add(group.block);
|
|
25
|
+
}
|
|
26
|
+
const sources = {};
|
|
27
|
+
for (const slug of slugs) {
|
|
28
|
+
try {
|
|
29
|
+
const item = await registry.item(slug);
|
|
30
|
+
const content = item.files[0]?.content;
|
|
31
|
+
if (content !== undefined)
|
|
32
|
+
sources[slug] = content;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Left absent → plan validation reports "shipped source unavailable".
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return sources;
|
|
39
|
+
}
|
|
40
|
+
/** The compose meta subset, loaded from the registry's `meta.json`. */
|
|
41
|
+
export async function readComposeMeta(registry) {
|
|
42
|
+
const meta = await registry.meta();
|
|
43
|
+
if (meta === null || meta.blocks === undefined)
|
|
44
|
+
return null;
|
|
45
|
+
return meta;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The project's package name (from package.json), used as the default brand and
|
|
49
|
+
* as `__APP_NAME__`. Falls back to the directory basename when unreadable, so the
|
|
50
|
+
* composer never fails on a bare project. Derives the trailing segment of a scoped
|
|
51
|
+
* name ("@acme/shop" → "shop").
|
|
52
|
+
*/
|
|
53
|
+
export async function readProjectName(cwd) {
|
|
54
|
+
const fallback = cwd.split(/[/\\]/).filter(Boolean).pop() ?? "app";
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
57
|
+
if (typeof pkg.name === "string" && pkg.name.length > 0) {
|
|
58
|
+
const slash = pkg.name.lastIndexOf("/");
|
|
59
|
+
return slash === -1 ? pkg.name : pkg.name.slice(slash + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// No/invalid package.json — fall back to the dir name.
|
|
64
|
+
}
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compose an app template into an existing Cronus UI project: resolve + install the
|
|
69
|
+
* blocks, customize the chrome copies (nav data + brand), write the generated
|
|
70
|
+
* pages/layouts/wrappers, snapshot the emitted bytes, and record `installed{}` +
|
|
71
|
+
* `composed{}`. Reuses the exact `add` install core — this is one more caller, not
|
|
72
|
+
* a new write path. Throws {@link ComposePlanError} / {@link ManifestError} on
|
|
73
|
+
* invalid input.
|
|
74
|
+
*/
|
|
75
|
+
export async function composeApp(options) {
|
|
76
|
+
const { targetDir } = options;
|
|
77
|
+
if (!hasConfig(targetDir)) {
|
|
78
|
+
throw new Error("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
79
|
+
}
|
|
80
|
+
const config = await readConfig(targetDir);
|
|
81
|
+
const sourceUsed = options.registry ?? config.registry;
|
|
82
|
+
const registry = new Registry(sourceUsed);
|
|
83
|
+
const manifest = options.manifestPath !== undefined
|
|
84
|
+
? await loadManifestFile(options.manifestPath)
|
|
85
|
+
: await loadTemplate(requireTemplate(options.template));
|
|
86
|
+
const meta = await readComposeMeta(registry);
|
|
87
|
+
if (meta === null) {
|
|
88
|
+
throw new Error("This registry does not ship a meta.json sidecar — compose needs it (upgrade the CLI/registry to v0.4.0+).");
|
|
89
|
+
}
|
|
90
|
+
const index = await registry.index();
|
|
91
|
+
const chromeSources = await readChromeSources(manifest, registry);
|
|
92
|
+
const choices = {
|
|
93
|
+
...options.choices,
|
|
94
|
+
appName: options.choices?.appName ?? (await readProjectName(targetDir)),
|
|
95
|
+
};
|
|
96
|
+
const plan = buildComposePlan(manifest, choices, index, meta, chromeSources);
|
|
97
|
+
// --- Resolve + install the blocks (unchanged install core) ----------------
|
|
98
|
+
const items = await registry.resolve(plan.blockSlugs);
|
|
99
|
+
const installedVersion = registrySourceVersion(sourceUsed) ?? CLI_VERSION;
|
|
100
|
+
const installed = { ...config.installed };
|
|
101
|
+
const overwrite = options.overwrite ?? false;
|
|
102
|
+
const installedBlocks = [];
|
|
103
|
+
// Files the install loop actually wrote this run (fresh install or --overwrite).
|
|
104
|
+
// Used to gate the chrome rewrite: only customize a chrome copy we just laid
|
|
105
|
+
// down pristine — never a pre-existing one that the user may have hand-edited.
|
|
106
|
+
const writtenThisRun = new Set();
|
|
107
|
+
for (const item of items) {
|
|
108
|
+
const { written } = await writeItemFiles(item, config, targetDir, { overwrite });
|
|
109
|
+
for (const path of written)
|
|
110
|
+
writtenThisRun.add(path);
|
|
111
|
+
if (written.length === item.files.length && written.length > 0) {
|
|
112
|
+
installed[item.name] = { version: installedVersion, files: written };
|
|
113
|
+
if (item.type === "registry:block")
|
|
114
|
+
installedBlocks.push(item.name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const generatedFiles = [];
|
|
118
|
+
const skippedFiles = [];
|
|
119
|
+
// --- Customize the installed chrome copies in place -----------------------
|
|
120
|
+
// The chrome block was just (re)installed pristine by the loop above. Honor
|
|
121
|
+
// the same collision-safety guarantee as the generated pages: if the install
|
|
122
|
+
// loop SKIPPED it (already existed, no --overwrite), the on-disk copy is the
|
|
123
|
+
// previously-customized bytes the user may have edited — do NOT clobber it.
|
|
124
|
+
// Only rewrite a chrome copy we actually wrote this run.
|
|
125
|
+
const { files: generated, chromeRewrites } = renderPlan(plan, config);
|
|
126
|
+
for (const rewrite of chromeRewrites) {
|
|
127
|
+
if (!writtenThisRun.has(rewrite.file)) {
|
|
128
|
+
skippedFiles.push(rewrite.file);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const dest = resolveSafeDest(targetDir, ".", rewrite.file);
|
|
132
|
+
await writeFileEnsured(dest, rewrite.content);
|
|
133
|
+
generatedFiles.push(rewrite.file);
|
|
134
|
+
}
|
|
135
|
+
// --- Write the generated pages/layouts/wrappers (safe writes) -------------
|
|
136
|
+
for (const file of generated) {
|
|
137
|
+
const dest = resolveSafeDest(targetDir, ".", file.path);
|
|
138
|
+
if (existsSync(dest) && !overwrite) {
|
|
139
|
+
skippedFiles.push(file.path);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
await writeFileEnsured(dest, file.content);
|
|
143
|
+
generatedFiles.push(file.path);
|
|
144
|
+
// Base snapshot: the exact bytes emitted, for the F4 3-way page upgrade.
|
|
145
|
+
// Keyed by template name (the composed{} key), not package.json name — a
|
|
146
|
+
// project can compose several templates.
|
|
147
|
+
const snapDest = resolveSafeDest(targetDir, baseSnapshotDir(plan.templateName), file.path);
|
|
148
|
+
await writeFileEnsured(snapDest, file.content);
|
|
149
|
+
}
|
|
150
|
+
// --- Record composed{} + installed{} --------------------------------------
|
|
151
|
+
// Keyed by TEMPLATE name (not project name): a project can compose several
|
|
152
|
+
// templates, and re-composing the same one updates its record in place.
|
|
153
|
+
//
|
|
154
|
+
// `files` must describe the app's COMPLETE generated surface, independent of
|
|
155
|
+
// what this particular run happened to write. On a benign re-compose (no
|
|
156
|
+
// --overwrite) every page + chrome copy already exists and goes to
|
|
157
|
+
// skippedFiles, so `generatedFiles` is empty; a straight assignment would
|
|
158
|
+
// clobber the record to `files: []` and orphan every composed page from its
|
|
159
|
+
// .cronus-ui/base/ snapshot (the F4 3-way upgrade merge base). Union with the
|
|
160
|
+
// prior record so a no-op re-run never empties the tracked file set.
|
|
161
|
+
const composed = { ...config.composed };
|
|
162
|
+
const priorFiles = config.composed?.[plan.templateName]?.files ?? [];
|
|
163
|
+
composed[plan.templateName] = {
|
|
164
|
+
version: installedVersion,
|
|
165
|
+
planVersion: plan.planVersion,
|
|
166
|
+
choices: plan.choices,
|
|
167
|
+
files: [...new Set([...priorFiles, ...generatedFiles])].sort(),
|
|
168
|
+
// Compose provenance: lets add-page verify a bundled reload is the SAME
|
|
169
|
+
// manifest this app was composed from (guards the bundled-name collision).
|
|
170
|
+
manifestHash: manifestFingerprint(manifest),
|
|
171
|
+
};
|
|
172
|
+
const nextConfig = { ...config, installed, composed };
|
|
173
|
+
await writeConfig(targetDir, nextConfig);
|
|
174
|
+
writeDesignDocuments(targetDir, { theme: nextConfig.theme?.name });
|
|
175
|
+
// --- Install npm deps (best-effort, like add) -----------------------------
|
|
176
|
+
const deps = collectDependencies(items);
|
|
177
|
+
if (deps.length > 0 && !(options.skipInstall ?? false)) {
|
|
178
|
+
const pm = detectPackageManager(targetDir);
|
|
179
|
+
try {
|
|
180
|
+
await runInstall(pm, deps, targetDir);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Non-fatal: caller/log surfaces the manual command.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
templateName: plan.templateName,
|
|
188
|
+
appName: plan.appName,
|
|
189
|
+
installedBlocks,
|
|
190
|
+
generatedFiles,
|
|
191
|
+
skippedFiles,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function requireTemplate(template) {
|
|
195
|
+
if (template === undefined || template.length === 0) {
|
|
196
|
+
throw new Error("No template given. Pass a template name, e.g. `cronus-ui compose store`.");
|
|
197
|
+
}
|
|
198
|
+
return template;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parse `--variant login=split --variant footer=mega` into a `{ slug: variant }`
|
|
202
|
+
* record. Each pair MUST be `slug=variant` with a non-empty slug and variant;
|
|
203
|
+
* a malformed pair (no `=`, empty side) throws so a typo like `--variant login`
|
|
204
|
+
* fails loud instead of being silently dropped. A repeated slug keeps the last
|
|
205
|
+
* value (last flag wins).
|
|
206
|
+
*/
|
|
207
|
+
export function parseVariants(pairs) {
|
|
208
|
+
const out = {};
|
|
209
|
+
for (const pair of pairs ?? []) {
|
|
210
|
+
const eq = pair.indexOf("=");
|
|
211
|
+
const slug = eq > 0 ? pair.slice(0, eq) : "";
|
|
212
|
+
const variant = eq > 0 ? pair.slice(eq + 1) : "";
|
|
213
|
+
if (slug.length === 0 || variant.length === 0) {
|
|
214
|
+
throw new Error(`Invalid --variant "${pair}" (expected "slug=variant", e.g. login=split).`);
|
|
215
|
+
}
|
|
216
|
+
out[slug] = variant;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/** Parse a comma-separated `--pages /,/products,login` into normalized routes. */
|
|
221
|
+
function parsePages(spec) {
|
|
222
|
+
if (spec === undefined)
|
|
223
|
+
return undefined;
|
|
224
|
+
return spec
|
|
225
|
+
.split(",")
|
|
226
|
+
.map((s) => s.trim())
|
|
227
|
+
.filter((s) => s.length > 0)
|
|
228
|
+
.map((s) => (s.startsWith("/") ? s : `/${s}`));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The `cronus-ui compose` command. Resolves a template (bundled or `--manifest`),
|
|
232
|
+
* plans + validates it (aggregating errors), and either prints a deterministic
|
|
233
|
+
* dry-run preview or applies it via {@link composeApp}. TTY-prompts for a template
|
|
234
|
+
* when none is given (unless `--yes`).
|
|
235
|
+
*/
|
|
236
|
+
export async function compose(templateArg, options) {
|
|
237
|
+
const { cwd } = options;
|
|
238
|
+
if (!hasConfig(cwd)) {
|
|
239
|
+
log.err("No cronus-ui.json found. Run `cronus-ui init` first.");
|
|
240
|
+
process.exitCode = 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const seed = options.seed !== undefined ? Number.parseInt(options.seed, 10) : undefined;
|
|
244
|
+
if (options.seed !== undefined && (seed === undefined || Number.isNaN(seed))) {
|
|
245
|
+
log.err(`Invalid --seed "${options.seed}" (expected an integer).`);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Parse `--variant slug=variant` up front so a malformed pair fails loud before
|
|
250
|
+
// any registry work (parseVariants throws on a bad shape).
|
|
251
|
+
let variants;
|
|
252
|
+
try {
|
|
253
|
+
variants = parseVariants(options.variant);
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
log.err(err.message);
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Resolve which template/manifest to use.
|
|
261
|
+
let template = templateArg;
|
|
262
|
+
const usingManifestFile = options.manifest !== undefined;
|
|
263
|
+
if (!usingManifestFile && template === undefined) {
|
|
264
|
+
const available = await listTemplates();
|
|
265
|
+
if (available.length === 0) {
|
|
266
|
+
log.err("No app templates are bundled with this CLI.");
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
template = await promptTemplate(available, options.yes ?? false);
|
|
271
|
+
}
|
|
272
|
+
const config = await readConfig(cwd);
|
|
273
|
+
const sourceUsed = options.registry ?? config.registry;
|
|
274
|
+
const registry = new Registry(sourceUsed);
|
|
275
|
+
// Load + validate the manifest.
|
|
276
|
+
let manifest;
|
|
277
|
+
try {
|
|
278
|
+
manifest = usingManifestFile
|
|
279
|
+
? await loadManifestFile(options.manifest)
|
|
280
|
+
: await loadTemplate(template);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
reportError(err);
|
|
284
|
+
process.exitCode = 1;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const meta = await readComposeMeta(registry);
|
|
288
|
+
if (meta === null) {
|
|
289
|
+
log.err("This registry does not ship a meta.json sidecar — compose needs it (upgrade to v0.4.0+).");
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const index = await registry.index();
|
|
294
|
+
const chromeSources = await readChromeSources(manifest, registry);
|
|
295
|
+
const parsedPages = parsePages(options.pages);
|
|
296
|
+
const choices = {
|
|
297
|
+
appName: await readProjectName(cwd),
|
|
298
|
+
...(options.brand !== undefined ? { brand: options.brand } : {}),
|
|
299
|
+
...(seed !== undefined ? { seed } : {}),
|
|
300
|
+
...(parsedPages !== undefined ? { pages: parsedPages } : {}),
|
|
301
|
+
variants,
|
|
302
|
+
};
|
|
303
|
+
// Plan + validate (aggregate ALL errors).
|
|
304
|
+
let plan;
|
|
305
|
+
try {
|
|
306
|
+
plan = buildComposePlan(manifest, choices, index, meta, chromeSources);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
reportError(err);
|
|
310
|
+
process.exitCode = 1;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// --- Dry-run: preview + per-file plan, write nothing ----------------------
|
|
314
|
+
if (options.dryRun === true) {
|
|
315
|
+
log.title(`Compose plan (dry-run) — ${plan.title}`);
|
|
316
|
+
console.log(renderPreview(plan));
|
|
317
|
+
console.log("");
|
|
318
|
+
const { files } = renderPlan(plan, config);
|
|
319
|
+
for (const file of files)
|
|
320
|
+
log.step(`Would write ${file.path}`);
|
|
321
|
+
for (const slug of plan.chromeSlugs) {
|
|
322
|
+
log.step(`Would customize ${config.paths.blocks}/${slug}.tsx (nav links + brand)`);
|
|
323
|
+
}
|
|
324
|
+
log.title(`Plan: ${plan.blockSlugs.length} block(s), ${plan.pages.length} page(s), ${plan.chromes.length} chrome group(s). Nothing written (dry-run).`);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// --- Apply -----------------------------------------------------------------
|
|
328
|
+
log.title(`Composing ${plan.title}`);
|
|
329
|
+
let result;
|
|
330
|
+
try {
|
|
331
|
+
result = await composeApp({
|
|
332
|
+
targetDir: cwd,
|
|
333
|
+
...(usingManifestFile ? { manifestPath: options.manifest } : { template }),
|
|
334
|
+
choices,
|
|
335
|
+
overwrite: options.overwrite ?? false,
|
|
336
|
+
skipInstall: options.skipInstall ?? false,
|
|
337
|
+
registry: options.registry,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
reportError(err);
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
for (const path of result.generatedFiles)
|
|
346
|
+
log.ok(`Generated ${path}`);
|
|
347
|
+
for (const path of result.skippedFiles)
|
|
348
|
+
log.warn(`Skipped ${path} (exists — use --overwrite)`);
|
|
349
|
+
if (result.installedBlocks.length > 0) {
|
|
350
|
+
log.step(`Installed ${result.installedBlocks.length} block(s): ${result.installedBlocks.join(", ")}`);
|
|
351
|
+
}
|
|
352
|
+
log.title(`Done — ${result.templateName}: ${result.installedBlocks.length} block(s) + ${result.generatedFiles.length} generated file(s).`);
|
|
353
|
+
log.title("Next steps");
|
|
354
|
+
log.step("npx cronus-ui add-page --route /pricing --blocks pricing,cta --nav Pricing");
|
|
355
|
+
log.step("npx cronus-ui theme set aurora --mode dark");
|
|
356
|
+
log.step("npx cronus-ui upgrade --all --dry-run");
|
|
357
|
+
}
|
|
358
|
+
/** Print a plan/manifest error's aggregated list, or a plain message. */
|
|
359
|
+
function reportError(err) {
|
|
360
|
+
if (err instanceof ComposePlanError) {
|
|
361
|
+
log.err("Compose plan is invalid:");
|
|
362
|
+
for (const e of err.errors)
|
|
363
|
+
log.step(e);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// ManifestError carries an `errors` array too (duck-typed to avoid a cyclic import cost).
|
|
367
|
+
const maybe = err;
|
|
368
|
+
if (Array.isArray(maybe.errors)) {
|
|
369
|
+
log.err("App manifest is invalid:");
|
|
370
|
+
for (const e of maybe.errors)
|
|
371
|
+
log.step(e);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
log.err(err.message);
|
|
375
|
+
}
|
|
376
|
+
/** TTY-prompt for a template name; `--yes`/non-TTY picks SaaS (not lexical first). */
|
|
377
|
+
async function promptTemplate(available, yes) {
|
|
378
|
+
const fallback = defaultComposeTemplate(available);
|
|
379
|
+
if (yes || !process.stdin.isTTY)
|
|
380
|
+
return fallback;
|
|
381
|
+
const { createInterface } = await import("node:readline/promises");
|
|
382
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
383
|
+
try {
|
|
384
|
+
console.log("Pick an app template:");
|
|
385
|
+
available.forEach((name, i) => {
|
|
386
|
+
console.log(` ${i + 1} ${name}`);
|
|
387
|
+
});
|
|
388
|
+
const answer = (await rl.question(`(1-${available.length} or name, default ${fallback}) `)).trim();
|
|
389
|
+
if (answer.length === 0)
|
|
390
|
+
return fallback;
|
|
391
|
+
const asNum = Number.parseInt(answer, 10);
|
|
392
|
+
if (Number.isInteger(asNum)) {
|
|
393
|
+
const picked = available[asNum - 1];
|
|
394
|
+
if (picked !== undefined)
|
|
395
|
+
return picked;
|
|
396
|
+
}
|
|
397
|
+
return available.includes(answer) ? answer : fallback;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
rl.close();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
//# sourceMappingURL=compose.js.map
|