@mizen-labs/cli 0.0.0-stage → 0.1.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 +351 -2
- package/dist/catalog.js +1 -0
- package/dist/catalog.json +8827 -0
- package/dist/cli.js +212 -0
- package/dist/discover.js +108 -0
- package/dist/doctor/doctor.js +44 -0
- package/dist/doctor/files.js +77 -0
- package/dist/doctor/packages.js +114 -0
- package/dist/doctor/project.js +133 -0
- package/dist/doctor/report.js +94 -0
- package/dist/doctor/setup.js +107 -0
- package/dist/doctor/syntax.js +148 -0
- package/dist/doctor/workspace-config.js +210 -0
- package/dist/doctor/workspace.js +169 -0
- package/dist/errors.js +81 -0
- package/dist/help.js +192 -0
- package/dist/init/config.js +75 -0
- package/dist/init/dependencies.js +130 -0
- package/dist/init/edit.js +119 -0
- package/dist/init/imports.js +83 -0
- package/dist/init/init.js +193 -0
- package/dist/init/plan.js +88 -0
- package/dist/init/release.js +60 -0
- package/dist/init/styles.js +96 -0
- package/dist/init/theme.js +176 -0
- package/dist/init/transaction.js +72 -0
- package/dist/output.js +78 -0
- package/dist/vite/bootstrap.js +43 -0
- package/dist/vite/doctor.js +50 -0
- package/dist/vite/entry.js +46 -0
- package/dist/vite/init.js +66 -0
- package/dist/vite/render.js +144 -0
- package/package.json +56 -3
- package/schema/v1.json +684 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createTwoFilesPatch } from "diff";
|
|
4
|
+
import { readOptional } from "../doctor/files.js";
|
|
5
|
+
import { isNode, parseSource } from "../doctor/syntax.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
/** Stops init before any change. The headline names what init can't do. */
|
|
8
|
+
export function conflict(message, detail) {
|
|
9
|
+
throw new CliError("conflict", `Can't set up Mizen. ${message}`, 1, detail);
|
|
10
|
+
}
|
|
11
|
+
export function position(value, key) {
|
|
12
|
+
if (!isNode(value) || typeof value[key] !== "number") {
|
|
13
|
+
return conflict("The parser did not provide a safe edit range.");
|
|
14
|
+
}
|
|
15
|
+
return value[key];
|
|
16
|
+
}
|
|
17
|
+
const TAB_INDENT = /^\t/mu;
|
|
18
|
+
// Wraps the element at start..end in <name>. An element that starts its
|
|
19
|
+
// line is wrapped as a block, one indent deeper; one inline stays inline.
|
|
20
|
+
export function wrapElement(source, start, end, name) {
|
|
21
|
+
const element = source.slice(start, end);
|
|
22
|
+
const lineStart = source.lastIndexOf("\n", start - 1) + 1;
|
|
23
|
+
const indent = source.slice(lineStart, start);
|
|
24
|
+
if (indent.trim()) {
|
|
25
|
+
return { end, start, text: `<${name}>${element}</${name}>` };
|
|
26
|
+
}
|
|
27
|
+
const unit = TAB_INDENT.test(source) ? "\t" : " ";
|
|
28
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
29
|
+
const inner = element.split(newline).join(`${newline}${unit}`);
|
|
30
|
+
return {
|
|
31
|
+
end,
|
|
32
|
+
start,
|
|
33
|
+
text: `<${name}>${newline}${indent}${unit}${inner}${newline}${indent}</${name}>`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function changes(source, edits) {
|
|
37
|
+
let cursor = source.length;
|
|
38
|
+
let result = source;
|
|
39
|
+
for (const edit of edits.toSorted((a, b) => b.start - a.start || b.end - a.end)) {
|
|
40
|
+
if (edit.end > cursor) {
|
|
41
|
+
return conflict("Overlapping source edits require manual setup.");
|
|
42
|
+
}
|
|
43
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
44
|
+
cursor = edit.start;
|
|
45
|
+
}
|
|
46
|
+
parseSource(result);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
export function fileEdit(path, before, after) {
|
|
50
|
+
if (before === after) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
after,
|
|
55
|
+
before,
|
|
56
|
+
diff: createTwoFilesPatch(path, path, before ?? "", after),
|
|
57
|
+
path,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function addProperty(object, key, value) {
|
|
61
|
+
if (!Array.isArray(object.properties)) {
|
|
62
|
+
return conflict("Computed configuration needs manual setup.");
|
|
63
|
+
}
|
|
64
|
+
const last = object.properties.at(-1);
|
|
65
|
+
if (!last) {
|
|
66
|
+
return {
|
|
67
|
+
end: position(object, "start") + 1,
|
|
68
|
+
start: position(object, "start") + 1,
|
|
69
|
+
text: `${key}: ${value}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Insert before the first member, leaving trailing comments and commas untouched.
|
|
73
|
+
const at = position(object.properties[0], "start");
|
|
74
|
+
return { end: at, start: at, text: `${key}: ${value}, ` };
|
|
75
|
+
}
|
|
76
|
+
export async function sourceFile(project, candidates, create) {
|
|
77
|
+
const results = await Promise.all(candidates.map(async (path) => ({
|
|
78
|
+
path,
|
|
79
|
+
source: await readOptional(join(project, path)),
|
|
80
|
+
})));
|
|
81
|
+
const found = results.filter((item) => item.source !== null);
|
|
82
|
+
if (found.length > 1) {
|
|
83
|
+
return conflict(`Multiple candidates exist: ${found.map((candidate) => candidate.path).join(", ")}. Select one before setup.`);
|
|
84
|
+
}
|
|
85
|
+
if (!(found.length || create)) {
|
|
86
|
+
return conflict(`No conventional root file found: ${candidates.join(", ")}.`);
|
|
87
|
+
}
|
|
88
|
+
if (!found.length) {
|
|
89
|
+
return { before: null, path: candidates[0], source: create };
|
|
90
|
+
}
|
|
91
|
+
const [file] = found;
|
|
92
|
+
const metadata = await lstat(join(project, file.path));
|
|
93
|
+
if (!metadata.isFile() || metadata.nlink !== 1) {
|
|
94
|
+
return conflict(`Use a regular, unlinked source file for ${file.path}.`);
|
|
95
|
+
}
|
|
96
|
+
validateSyntax(file.source, file.path);
|
|
97
|
+
return {
|
|
98
|
+
before: file.source,
|
|
99
|
+
path: file.path,
|
|
100
|
+
source: file.source,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export async function unchanged(path, expected) {
|
|
104
|
+
const source = await readOptional(path);
|
|
105
|
+
if (source !== expected) {
|
|
106
|
+
conflict(`File changed since planning: ${path}. Rerun init to review a fresh plan.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function validateSyntax(source, path) {
|
|
110
|
+
try {
|
|
111
|
+
parseSource(source);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!(error instanceof SyntaxError)) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
conflict(`${path} has invalid or unsupported syntax. Repair it before setup.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { imports, isNode, literal, nameOf, nodes, } from "../doctor/syntax.js";
|
|
2
|
+
import { conflict, position } from "./edit.js";
|
|
3
|
+
export function uniqueName(ast, preferred) {
|
|
4
|
+
const used = new Set(nodes(ast).map((node) => nameOf(node)));
|
|
5
|
+
let name = preferred;
|
|
6
|
+
let suffix = 1;
|
|
7
|
+
while (used.has(name)) {
|
|
8
|
+
name = `${preferred}${suffix}`;
|
|
9
|
+
suffix += 1;
|
|
10
|
+
}
|
|
11
|
+
return name;
|
|
12
|
+
}
|
|
13
|
+
/** The export's own name when the module leaves it free, else a Mizen alias. */
|
|
14
|
+
export function bindingName(ast, exported) {
|
|
15
|
+
const used = new Set(nodes(ast).map((node) => nameOf(node)));
|
|
16
|
+
if (!used.has(exported)) {
|
|
17
|
+
return exported;
|
|
18
|
+
}
|
|
19
|
+
const alias = `Mizen${exported[0]?.toUpperCase()}${exported.slice(1)}`;
|
|
20
|
+
return uniqueName(ast, alias);
|
|
21
|
+
}
|
|
22
|
+
/** An import specifier, aliased only when the local name differs. */
|
|
23
|
+
export function specifier(exported, local) {
|
|
24
|
+
return exported === local ? exported : `${exported} as ${local}`;
|
|
25
|
+
}
|
|
26
|
+
export function importedName(ast, exported, source) {
|
|
27
|
+
const found = imports(ast)
|
|
28
|
+
.filter((node) => literal(node.source) === source)
|
|
29
|
+
.flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
|
|
30
|
+
.filter((node) => isNode(node) &&
|
|
31
|
+
node.importKind !== "type" &&
|
|
32
|
+
nameOf(node.imported) === exported);
|
|
33
|
+
if (found.length > 1) {
|
|
34
|
+
return conflict(`Multiple ${exported} imports require manual review.`);
|
|
35
|
+
}
|
|
36
|
+
return found.length ? nameOf(found[0].local) : null;
|
|
37
|
+
}
|
|
38
|
+
export function defaultImport(ast, source) {
|
|
39
|
+
const specifiers = imports(ast)
|
|
40
|
+
.filter((node) => literal(node.source) === source)
|
|
41
|
+
.flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
|
|
42
|
+
.filter((node) => isNode(node) && node.type === "ImportDefaultSpecifier");
|
|
43
|
+
return specifiers.length === 1
|
|
44
|
+
? nameOf(specifiers[0].local)
|
|
45
|
+
: null;
|
|
46
|
+
}
|
|
47
|
+
function insertAt(at, text) {
|
|
48
|
+
return { end: at, start: at, text };
|
|
49
|
+
}
|
|
50
|
+
// Where a module with no imports takes its first one: after its directives,
|
|
51
|
+
// else before its first statement.
|
|
52
|
+
function firstImport(program, statement, newline) {
|
|
53
|
+
const directives = Array.isArray(program.directives)
|
|
54
|
+
? program.directives
|
|
55
|
+
: [];
|
|
56
|
+
const directive = directives.at(-1);
|
|
57
|
+
if (isNode(directive) && typeof directive.end === "number") {
|
|
58
|
+
return insertAt(directive.end, `${newline}${newline}${statement}`);
|
|
59
|
+
}
|
|
60
|
+
const body = Array.isArray(program.body) ? program.body : [];
|
|
61
|
+
const [first] = body;
|
|
62
|
+
if (isNode(first) && typeof first.start === "number") {
|
|
63
|
+
return insertAt(first.start, `${statement}${newline}${newline}`);
|
|
64
|
+
}
|
|
65
|
+
return insertAt(0, `${statement}${newline}`);
|
|
66
|
+
}
|
|
67
|
+
// New imports go after the module's last import, or before its first
|
|
68
|
+
// statement when it has none, so the file keeps its own spacing. A
|
|
69
|
+
// stylesheet goes first instead, so the app's own CSS can override it.
|
|
70
|
+
export function importEdit(ast, statement, source, place = "last") {
|
|
71
|
+
const { program } = ast;
|
|
72
|
+
if (!isNode(program)) {
|
|
73
|
+
return conflict("No editable module body was found.");
|
|
74
|
+
}
|
|
75
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
76
|
+
const found = (Array.isArray(program.body) ? program.body : []).filter((node) => isNode(node) && node.type === "ImportDeclaration");
|
|
77
|
+
if (!found.length) {
|
|
78
|
+
return firstImport(program, statement, newline);
|
|
79
|
+
}
|
|
80
|
+
return place === "first"
|
|
81
|
+
? insertAt(position(found[0], "start"), `${statement}${newline}`)
|
|
82
|
+
: insertAt(position(found.at(-1), "end"), `${newline}${statement}`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import spawn from "cross-spawn";
|
|
5
|
+
import { doctor } from "../doctor/doctor.js";
|
|
6
|
+
import { formatReport } from "../doctor/report.js";
|
|
7
|
+
import { CliError, commandLine } from "../errors.js";
|
|
8
|
+
import { plural, row, status } from "../output.js";
|
|
9
|
+
import { conflict, unchanged } from "./edit.js";
|
|
10
|
+
import { JOURNAL, planInit } from "./plan.js";
|
|
11
|
+
import { applyFiles, restoreFiles } from "./transaction.js";
|
|
12
|
+
async function validate(plan) {
|
|
13
|
+
await unchanged(join(plan.project, "package.json"), plan.manifestBefore);
|
|
14
|
+
await validateInputs(plan);
|
|
15
|
+
await Promise.all(plan.edits.map((edit) => unchanged(join(plan.project, edit.path), edit.before)));
|
|
16
|
+
const archive = plan.install?.archive;
|
|
17
|
+
if (archive &&
|
|
18
|
+
createHash("sha256")
|
|
19
|
+
.update(await readFile(archive.path))
|
|
20
|
+
.digest("hex") !== archive.sha256) {
|
|
21
|
+
conflict("The package archive changed after planning. Rerun init.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function installDependencies(plan) {
|
|
25
|
+
if (!plan.install) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const result = spawn.sync(plan.install.command, plan.install.args, {
|
|
29
|
+
cwd: plan.project,
|
|
30
|
+
killSignal: "SIGKILL",
|
|
31
|
+
maxBuffer: 2_000_000,
|
|
32
|
+
timeout: 600_000,
|
|
33
|
+
});
|
|
34
|
+
return !result.error && result.status === 0;
|
|
35
|
+
}
|
|
36
|
+
export async function init(options, catalog) {
|
|
37
|
+
const plan = await planInit(options, catalog);
|
|
38
|
+
const empty = plan.edits.length === 0 && !plan.install;
|
|
39
|
+
if (empty) {
|
|
40
|
+
return verifyUnchanged(plan, catalog);
|
|
41
|
+
}
|
|
42
|
+
if (options.dryRun || !options.yes) {
|
|
43
|
+
return {
|
|
44
|
+
message: plannedMessage(plan),
|
|
45
|
+
plan,
|
|
46
|
+
status: empty ? "unchanged" : "planned",
|
|
47
|
+
verification: null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return applyPlan(plan, catalog);
|
|
51
|
+
}
|
|
52
|
+
async function applyPlan(plan, catalog) {
|
|
53
|
+
await validate(plan);
|
|
54
|
+
const journal = join(plan.project, JOURNAL);
|
|
55
|
+
await writeFile(journal, `${JSON.stringify({ plan, schemaVersion: 1 }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
56
|
+
let keepJournal = false;
|
|
57
|
+
try {
|
|
58
|
+
if (!installDependencies(plan)) {
|
|
59
|
+
return {
|
|
60
|
+
message: "Couldn't install dependencies. No source files changed, but package.json, lockfiles, or node_modules may have.",
|
|
61
|
+
plan,
|
|
62
|
+
status: "install-failed",
|
|
63
|
+
verification: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const before = await doctor(plan.project, catalog.nodeRange);
|
|
67
|
+
if (before.summary.errors) {
|
|
68
|
+
return {
|
|
69
|
+
message: "Doctor found errors after installing dependencies. No source files changed.",
|
|
70
|
+
plan,
|
|
71
|
+
status: "verification-failed",
|
|
72
|
+
verification: before,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
await validateInputs(plan);
|
|
76
|
+
await applyFiles(plan.project, plan.edits);
|
|
77
|
+
const verification = await doctor(plan.project, catalog.nodeRange);
|
|
78
|
+
if (verification.status !== "healthy") {
|
|
79
|
+
await restoreFiles(plan.project, plan.edits);
|
|
80
|
+
return {
|
|
81
|
+
message: "Couldn't verify setup. Source edits were rolled back; dependency changes stay for review.",
|
|
82
|
+
plan,
|
|
83
|
+
status: "verification-failed",
|
|
84
|
+
verification,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
message: "Set up Mizen. Doctor passed.",
|
|
89
|
+
plan,
|
|
90
|
+
status: "applied",
|
|
91
|
+
verification,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
keepJournal = true;
|
|
96
|
+
throw recoveryFailure(journal, error);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (!keepJournal) {
|
|
100
|
+
await rm(journal);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function plannedMessage(plan) {
|
|
105
|
+
const parts = [
|
|
106
|
+
...(plan.edits.length ? [plural(plan.edits.length, "file edit")] : []),
|
|
107
|
+
...(plan.install ? ["1 install"] : []),
|
|
108
|
+
];
|
|
109
|
+
return `Planned ${parts.join(" and ")}. Nothing changed yet.`;
|
|
110
|
+
}
|
|
111
|
+
const PATCH_HEADER = /^(?:Index: |={10,})/u;
|
|
112
|
+
// The unified diff without its banner lines, added lines green and removed
|
|
113
|
+
// lines red.
|
|
114
|
+
function formatDiff(style, diff) {
|
|
115
|
+
return diff
|
|
116
|
+
.split("\n")
|
|
117
|
+
.filter((line) => !PATCH_HEADER.test(line))
|
|
118
|
+
.map((line) => {
|
|
119
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
120
|
+
return style.pass(line);
|
|
121
|
+
}
|
|
122
|
+
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
123
|
+
return style.fail(line);
|
|
124
|
+
}
|
|
125
|
+
return line;
|
|
126
|
+
})
|
|
127
|
+
.join("\n")
|
|
128
|
+
.trimEnd();
|
|
129
|
+
}
|
|
130
|
+
const FRAMEWORKS = {
|
|
131
|
+
next: "Next.js App Router",
|
|
132
|
+
start: "TanStack Start",
|
|
133
|
+
vite: "React + Vite",
|
|
134
|
+
};
|
|
135
|
+
function outcome(style, result) {
|
|
136
|
+
if (result.status === "planned") {
|
|
137
|
+
return result.message;
|
|
138
|
+
}
|
|
139
|
+
const kind = ["applied", "unchanged"].includes(result.status)
|
|
140
|
+
? "pass"
|
|
141
|
+
: "fail";
|
|
142
|
+
return status(style, kind, result.message);
|
|
143
|
+
}
|
|
144
|
+
export function formatInit(style, result) {
|
|
145
|
+
const { install } = result.plan;
|
|
146
|
+
return [
|
|
147
|
+
row("Project", result.plan.project),
|
|
148
|
+
row("Framework", FRAMEWORKS[result.plan.framework]),
|
|
149
|
+
"",
|
|
150
|
+
...result.plan.edits.flatMap((edit) => [formatDiff(style, edit.diff), ""]),
|
|
151
|
+
...(install
|
|
152
|
+
? [
|
|
153
|
+
row("Install", [
|
|
154
|
+
commandLine([install.command, ...install.args]),
|
|
155
|
+
"May update package.json, lockfiles, and node_modules.",
|
|
156
|
+
]),
|
|
157
|
+
"",
|
|
158
|
+
]
|
|
159
|
+
: []),
|
|
160
|
+
outcome(style, result),
|
|
161
|
+
...(result.verification && result.verification.status !== "healthy"
|
|
162
|
+
? ["", formatReport(style, result.verification)]
|
|
163
|
+
: []),
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
async function verifyUnchanged(plan, catalog) {
|
|
167
|
+
const verification = await doctor(plan.project, catalog.nodeRange);
|
|
168
|
+
const healthy = verification.status === "healthy";
|
|
169
|
+
return {
|
|
170
|
+
message: healthy
|
|
171
|
+
? "Mizen is already set up. Doctor passed."
|
|
172
|
+
: "Existing setup needs review. Resolve the doctor findings before init.",
|
|
173
|
+
plan,
|
|
174
|
+
status: healthy ? "unchanged" : "verification-failed",
|
|
175
|
+
verification,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function recoveryFailure(journal, cause) {
|
|
179
|
+
const failure = new CliError("recovery_required", "Couldn't finish setup. Source edits may be partly applied.", 1, {
|
|
180
|
+
rows: [
|
|
181
|
+
["Journal", journal],
|
|
182
|
+
[
|
|
183
|
+
"Recovery",
|
|
184
|
+
"Restore or finish the recorded edits, then remove the journal.",
|
|
185
|
+
],
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
failure.cause = cause;
|
|
189
|
+
return failure;
|
|
190
|
+
}
|
|
191
|
+
async function validateInputs(plan) {
|
|
192
|
+
await Promise.all((plan.inputs ?? []).map((input) => unchanged(join(plan.project, input.path), input.before)));
|
|
193
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { readOptional } from "../doctor/files.js";
|
|
4
|
+
import { versionCheck } from "../doctor/packages.js";
|
|
5
|
+
import { frameworkOf, targetProject } from "../doctor/project.js";
|
|
6
|
+
import { diagnosticDetail } from "../doctor/report.js";
|
|
7
|
+
import { CliError } from "../errors.js";
|
|
8
|
+
import { configEdit } from "./config.js";
|
|
9
|
+
import { installation, managerFor } from "./dependencies.js";
|
|
10
|
+
import { conflict } from "./edit.js";
|
|
11
|
+
import { rootEdit } from "./styles.js";
|
|
12
|
+
export const JOURNAL = ".mizen-init-journal.json";
|
|
13
|
+
async function safePath(project, edit) {
|
|
14
|
+
const path = join(project, edit.path);
|
|
15
|
+
const resolved = edit.before === null ? path : await realpath(path);
|
|
16
|
+
const within = relative(project, resolved);
|
|
17
|
+
if (within.startsWith(`..${sep}`) || within === "..") {
|
|
18
|
+
conflict(`Source path leaves the selected app: ${edit.path}.`);
|
|
19
|
+
}
|
|
20
|
+
// biome-ignore lint/suspicious/noBitwiseOperators: POSIX owner-write permission bit.
|
|
21
|
+
if (edit.before !== null && !((await lstat(path)).mode & 0o200)) {
|
|
22
|
+
conflict(`Source file is read-only: ${edit.path}.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function projectContext(options, catalog) {
|
|
26
|
+
const target = await targetProject(resolve(options.cwd ?? process.cwd()));
|
|
27
|
+
if (!(target.project && target.manifest)) {
|
|
28
|
+
throw new CliError("project_not_found", `Couldn't find an app to set up. ${target.diagnostic?.message ?? ""}`.trim(), 1, {
|
|
29
|
+
next: {
|
|
30
|
+
argv: ["mizen", "init", "--cwd", "<APP_DIR>"],
|
|
31
|
+
benefit: "Select the app",
|
|
32
|
+
},
|
|
33
|
+
...(target.diagnostic ? diagnosticDetail(target.diagnostic) : {}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const project = await realpath(target.project);
|
|
37
|
+
await validateProjectFiles(project);
|
|
38
|
+
const framework = frameworkOf(target.manifest);
|
|
39
|
+
if (framework === "unknown") {
|
|
40
|
+
return conflict("Init supports an unambiguous Next.js App Router, TanStack Start, or React + Vite app.");
|
|
41
|
+
}
|
|
42
|
+
if (versionCheck("node", process.versions.node, catalog.nodeRange, [])
|
|
43
|
+
.status !== "pass") {
|
|
44
|
+
return conflict(`Use Node ${catalog.nodeRange} before init.`);
|
|
45
|
+
}
|
|
46
|
+
return { framework, manifest: target.manifest, project };
|
|
47
|
+
}
|
|
48
|
+
async function validateProjectFiles(project) {
|
|
49
|
+
const manifestStat = await lstat(join(project, "package.json"));
|
|
50
|
+
if (!manifestStat.isFile() || manifestStat.nlink !== 1) {
|
|
51
|
+
return conflict("The app manifest must be a regular, unlinked file before init.");
|
|
52
|
+
}
|
|
53
|
+
if ((await readOptional(join(project, JOURNAL))) !== null) {
|
|
54
|
+
return conflict(`An interrupted init journal exists at ${join(project, JOURNAL)}. Review its recorded edits and restore or finish them before removing the journal.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function planInit(options, catalog) {
|
|
58
|
+
const { project, manifest, framework } = await projectContext(options, catalog);
|
|
59
|
+
const inputs = framework === "vite"
|
|
60
|
+
? [
|
|
61
|
+
{
|
|
62
|
+
before: await readOptional(join(project, "index.html")),
|
|
63
|
+
path: "index.html",
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
: [];
|
|
67
|
+
const manager = await managerFor(project, options.packageManager);
|
|
68
|
+
const [config, root] = await Promise.all([
|
|
69
|
+
configEdit(project, framework),
|
|
70
|
+
rootEdit(project, framework),
|
|
71
|
+
]);
|
|
72
|
+
const edits = [config, root].filter((edit) => edit !== null);
|
|
73
|
+
await Promise.all(edits.map((edit) => safePath(project, edit)));
|
|
74
|
+
const install = await installation(project, manifest, catalog.uiVersion, options.package, options.feature ?? [], manager);
|
|
75
|
+
const manifestBefore = await readOptional(join(project, "package.json"));
|
|
76
|
+
if (!manifestBefore) {
|
|
77
|
+
return conflict("The app manifest changed while planning. Rerun init.");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
edits,
|
|
81
|
+
framework,
|
|
82
|
+
inputs,
|
|
83
|
+
install,
|
|
84
|
+
manifestBefore,
|
|
85
|
+
project,
|
|
86
|
+
uiVersion: catalog.uiVersion,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { list } from "tar";
|
|
5
|
+
import { parseManifest } from "../doctor/files.js";
|
|
6
|
+
import { CliError } from "../errors.js";
|
|
7
|
+
import { conflict } from "./edit.js";
|
|
8
|
+
async function inspectArchive(specifier, expectedVersion) {
|
|
9
|
+
const path = resolve(specifier);
|
|
10
|
+
const info = await stat(path);
|
|
11
|
+
if (!info.isFile() || info.size > 50_000_000) {
|
|
12
|
+
return conflict("Use a local Mizen .tgz archive smaller than 50 MB.");
|
|
13
|
+
}
|
|
14
|
+
const sources = [];
|
|
15
|
+
let invalid = false;
|
|
16
|
+
await list({
|
|
17
|
+
file: path,
|
|
18
|
+
filter: (entry) => entry === "package/package.json",
|
|
19
|
+
onReadEntry(entry) {
|
|
20
|
+
if (entry.type !== "File" || entry.size > 1_000_000) {
|
|
21
|
+
invalid = true;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const chunks = [];
|
|
25
|
+
entry.on("data", (data) => {
|
|
26
|
+
chunks.push(Buffer.from(data));
|
|
27
|
+
});
|
|
28
|
+
entry.on("end", () => {
|
|
29
|
+
sources.push(Buffer.concat(chunks).toString("utf8"));
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
strict: true,
|
|
33
|
+
});
|
|
34
|
+
if (invalid || sources.length !== 1) {
|
|
35
|
+
return conflict("The archive must contain one package/package.json.");
|
|
36
|
+
}
|
|
37
|
+
const manifest = parseManifest(sources[0], path);
|
|
38
|
+
if (manifest.name !== "@mizen-labs/ui" ||
|
|
39
|
+
manifest.version !== expectedVersion) {
|
|
40
|
+
return conflict(`Use an @mizen-labs/ui ${expectedVersion} archive matching this CLI's catalog.`);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
manifest,
|
|
44
|
+
path,
|
|
45
|
+
sha256: createHash("sha256")
|
|
46
|
+
.update(await readFile(path))
|
|
47
|
+
.digest("hex"),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function releaseArchive(specifier, version) {
|
|
51
|
+
try {
|
|
52
|
+
return await inspectArchive(specifier, version);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof CliError) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return conflict(`Cannot read a valid Mizen archive at ${resolve(specifier)}. Check the file and retry.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { imports, isNode, literal, nameOf, nodes, parseSource, property, } from "../doctor/syntax.js";
|
|
2
|
+
import { viteRootEdit } from "../vite/init.js";
|
|
3
|
+
import { member } from "./config.js";
|
|
4
|
+
import { addProperty, changes, conflict, fileEdit, position, sourceFile, } from "./edit.js";
|
|
5
|
+
import { defaultImport, importEdit, uniqueName } from "./imports.js";
|
|
6
|
+
import { themeSource } from "./theme.js";
|
|
7
|
+
function startRoute(ast) {
|
|
8
|
+
const bindings = imports(ast)
|
|
9
|
+
.filter((node) => literal(node.source) === "@tanstack/react-router")
|
|
10
|
+
.flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
|
|
11
|
+
.filter((node) => isNode(node) && nameOf(node.imported) === "createRootRoute")
|
|
12
|
+
.map((node) => (isNode(node) ? nameOf(node.local) : null));
|
|
13
|
+
const calls = nodes(ast).filter((node) => node.type === "CallExpression" && bindings.includes(nameOf(node.callee)));
|
|
14
|
+
if (calls.length !== 1 || !Array.isArray(calls[0].arguments)) {
|
|
15
|
+
return conflict("A single conventional createRootRoute call is required.");
|
|
16
|
+
}
|
|
17
|
+
const [config] = calls[0].arguments;
|
|
18
|
+
if (!isNode(config) || config.type !== "ObjectExpression") {
|
|
19
|
+
return conflict("Computed root route configuration requires manual setup.");
|
|
20
|
+
}
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
function headObject(head) {
|
|
24
|
+
if (!isNode(head) ||
|
|
25
|
+
head.type !== "ArrowFunctionExpression" ||
|
|
26
|
+
!isNode(head.body) ||
|
|
27
|
+
head.body.type !== "ObjectExpression") {
|
|
28
|
+
return conflict("Root head must be an arrow returning a literal object. Configure the stylesheet link manually.");
|
|
29
|
+
}
|
|
30
|
+
return head.body;
|
|
31
|
+
}
|
|
32
|
+
function linksChange(head, binding) {
|
|
33
|
+
const object = headObject(head);
|
|
34
|
+
const link = `{ href: ${binding}, rel: "stylesheet" }`;
|
|
35
|
+
if (!member(object, "links")) {
|
|
36
|
+
return addProperty(object, "links", `[${link}]`);
|
|
37
|
+
}
|
|
38
|
+
const links = property(object, "links");
|
|
39
|
+
if (!isNode(links) ||
|
|
40
|
+
links.type !== "ArrayExpression" ||
|
|
41
|
+
!Array.isArray(links.elements)) {
|
|
42
|
+
return conflict("Computed head links require manual setup.");
|
|
43
|
+
}
|
|
44
|
+
if (links.elements.some((node) => nameOf(property(node, "href")) === binding &&
|
|
45
|
+
literal(property(node, "rel")) === "stylesheet")) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const at = position(links, "start") + 1;
|
|
49
|
+
return {
|
|
50
|
+
end: at,
|
|
51
|
+
start: at,
|
|
52
|
+
text: `${link}${links.elements.length ? ", " : ""}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function startStyles(source) {
|
|
56
|
+
const ast = parseSource(source);
|
|
57
|
+
const imported = defaultImport(ast, "@mizen-labs/ui/styles.css?url");
|
|
58
|
+
const binding = imported ?? uniqueName(ast, "mizenStylesheet");
|
|
59
|
+
const config = startRoute(ast);
|
|
60
|
+
const edits = [];
|
|
61
|
+
if (!imported) {
|
|
62
|
+
edits.push(importEdit(ast, `import ${binding} from "@mizen-labs/ui/styles.css?url";`, source));
|
|
63
|
+
}
|
|
64
|
+
if (member(config, "head")) {
|
|
65
|
+
const link = linksChange(property(config, "head"), binding);
|
|
66
|
+
if (link) {
|
|
67
|
+
edits.push(link);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
edits.push(addProperty(config, "head", `() => ({ links: [{ href: ${binding}, rel: "stylesheet" }] })`));
|
|
72
|
+
}
|
|
73
|
+
return changes(source, edits);
|
|
74
|
+
}
|
|
75
|
+
function nextStyles(source) {
|
|
76
|
+
const ast = parseSource(source);
|
|
77
|
+
const existing = imports(ast).filter((node) => literal(node.source) === "@mizen-labs/ui/styles.css");
|
|
78
|
+
if (existing.length) {
|
|
79
|
+
return source;
|
|
80
|
+
}
|
|
81
|
+
return changes(source, [
|
|
82
|
+
importEdit(ast, 'import "@mizen-labs/ui/styles.css";', source, "first"),
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
export async function rootEdit(project, framework) {
|
|
86
|
+
if (framework === "vite") {
|
|
87
|
+
return viteRootEdit(project);
|
|
88
|
+
}
|
|
89
|
+
const roots = framework === "next"
|
|
90
|
+
? ["app/layout", "src/app/layout"]
|
|
91
|
+
: ["src/routes/__root", "app/routes/__root"];
|
|
92
|
+
const file = await sourceFile(project, roots.flatMap((path) => ["tsx", "jsx", "ts", "js"].map((extension) => `${path}.${extension}`)));
|
|
93
|
+
const themed = themeSource(file.source, framework);
|
|
94
|
+
const after = framework === "next" ? nextStyles(themed) : startStyles(themed);
|
|
95
|
+
return fileEdit(file.path, file.before, after);
|
|
96
|
+
}
|