@aventara/cli 0.0.0-stage → 0.1.0-pilot.1
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 +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +120 -2
- package/dist/apply/app-module.anchor.d.ts +30 -0
- package/dist/apply/app-module.anchor.js +185 -0
- package/dist/apply/conflict.confirmer.d.ts +24 -0
- package/dist/apply/conflict.confirmer.js +39 -0
- package/dist/apply/e2e-spec.anchor.d.ts +18 -0
- package/dist/apply/e2e-spec.anchor.js +40 -0
- package/dist/apply/manifest.merger.d.ts +47 -0
- package/dist/apply/manifest.merger.js +139 -0
- package/dist/aventara.bin.d.ts +2 -0
- package/dist/aventara.bin.js +15 -0
- package/dist/catalog/adapter.catalog.generated.d.ts +19 -0
- package/dist/catalog/adapter.catalog.generated.js +35 -0
- package/dist/catalog/catalog-entry.interface.d.ts +46 -0
- package/dist/catalog/catalog-entry.interface.js +1 -0
- package/dist/catalog/catalog.matcher.d.ts +46 -0
- package/dist/catalog/catalog.matcher.js +83 -0
- package/dist/catalog/range.reader.d.ts +17 -0
- package/dist/catalog/range.reader.js +77 -0
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +96 -0
- package/dist/command/command.parser.d.ts +36 -0
- package/dist/command/command.parser.js +163 -0
- package/dist/node-version.guard.d.ts +8 -0
- package/dist/node-version.guard.js +59 -0
- package/dist/plan/project.planner.d.ts +59 -0
- package/dist/plan/project.planner.js +201 -0
- package/dist/project/package-manager.detector.d.ts +12 -0
- package/dist/project/package-manager.detector.js +14 -0
- package/dist/project/project.inspector.d.ts +38 -0
- package/dist/project/project.inspector.js +84 -0
- package/dist/project/service.detector.d.ts +45 -0
- package/dist/project/service.detector.js +106 -0
- package/dist/project/source.scanner.d.ts +16 -0
- package/dist/project/source.scanner.js +134 -0
- package/dist/run/command.runner.d.ts +15 -0
- package/dist/run/command.runner.js +20 -0
- package/dist/run/init.orchestrator.d.ts +29 -0
- package/dist/run/init.orchestrator.js +222 -0
- package/dist/run/new.orchestrator.d.ts +21 -0
- package/dist/run/new.orchestrator.js +88 -0
- package/dist/templates/prisma7/prisma7.templates.d.ts +34 -0
- package/dist/templates/prisma7/prisma7.templates.js +397 -0
- package/dist/templates/template.registry.d.ts +156 -0
- package/dist/templates/template.registry.js +17 -0
- package/dist/wizard/answer.resolver.d.ts +49 -0
- package/dist/wizard/answer.resolver.js +106 -0
- package/dist/wizard/readline.prompter.d.ts +24 -0
- package/dist/wizard/readline.prompter.js +76 -0
- package/dist/wizard/wizard.questions.d.ts +114 -0
- package/dist/wizard/wizard.questions.js +261 -0
- package/package.json +28 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R6 — finding the developer's own ORM service, lexically, over `src/**\/*.ts`:
|
|
3
|
+
* no compiler (`aventara init` may run before anything is installed).
|
|
4
|
+
*
|
|
5
|
+
* 1. **Candidates**: exported classes that `extends` the ORM's client class,
|
|
6
|
+
* where the file imports that class from a specifier the ORM's templates
|
|
7
|
+
* recognise (the generated client, or the ORM package).
|
|
8
|
+
* 2. **Compositions**: exported classes that hold a client instead of extending
|
|
9
|
+
* one (`client = new PrismaClient(…)`, Phase 11 S7's shape) — reported with
|
|
10
|
+
* the member, never wired.
|
|
11
|
+
* 3. **Modules**: each `@Module({…})`-decorated exported class with the
|
|
12
|
+
* identifiers its `providers` and `exports` list, and whether it is `@Global()`.
|
|
13
|
+
*
|
|
14
|
+
* Which of them is *the* service is the caller's question (the wizard's).
|
|
15
|
+
*/
|
|
16
|
+
export type ExportReference = {
|
|
17
|
+
/** Relative to the project root, POSIX separators. */
|
|
18
|
+
readonly file: string;
|
|
19
|
+
readonly exportName: string;
|
|
20
|
+
};
|
|
21
|
+
export type ServiceComposition = ExportReference & {
|
|
22
|
+
/** The member that holds the client. */
|
|
23
|
+
readonly member: string;
|
|
24
|
+
};
|
|
25
|
+
export type ModuleDeclaration = ExportReference & {
|
|
26
|
+
readonly providers: readonly string[];
|
|
27
|
+
readonly exports: readonly string[];
|
|
28
|
+
readonly global: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type ServiceScan = {
|
|
31
|
+
readonly candidates: readonly ExportReference[];
|
|
32
|
+
readonly compositions: readonly ServiceComposition[];
|
|
33
|
+
readonly modules: readonly ModuleDeclaration[];
|
|
34
|
+
};
|
|
35
|
+
export type ClientShape = {
|
|
36
|
+
/** The client class a service extends, e.g. the ORM's generated client. */
|
|
37
|
+
readonly clientClass: string;
|
|
38
|
+
/** Whether `specifier`, imported by `file` (project-relative), is the client's module. */
|
|
39
|
+
readonly importsClient: (specifier: string, file: string) => boolean;
|
|
40
|
+
/** Directories under `src/` that hold generated code, skipped. */
|
|
41
|
+
readonly skip: readonly string[];
|
|
42
|
+
};
|
|
43
|
+
export declare function scanServices(directory: string, shape: ClientShape): ServiceScan;
|
|
44
|
+
/** The modules that make `service` injectable elsewhere: providing and exporting it, or global. */
|
|
45
|
+
export declare function modulesProviding(scan: ServiceScan, service: ExportReference): readonly ModuleDeclaration[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { closing, codeIndexes, codeMask, nextCode, OPENERS, } from "./source.scanner.js";
|
|
4
|
+
function sourceFiles(directory, root, skip) {
|
|
5
|
+
if (!existsSync(directory)) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
9
|
+
const full = path.join(directory, entry.name);
|
|
10
|
+
const relative = path.relative(root, full).split(path.sep).join("/");
|
|
11
|
+
if (entry.isDirectory()) {
|
|
12
|
+
return entry.name === "node_modules" || skip.includes(relative)
|
|
13
|
+
? []
|
|
14
|
+
: sourceFiles(full, root, skip);
|
|
15
|
+
}
|
|
16
|
+
return /\.ts$/.test(entry.name) && !/\.(spec|test|d)\.ts$/.test(entry.name)
|
|
17
|
+
? [relative]
|
|
18
|
+
: [];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** The identifiers the array under `key`, at `object`'s own level, names (`providers: [A, B]`). */
|
|
22
|
+
function arrayNames(text, code, object, key) {
|
|
23
|
+
const end = closing(text, code, object);
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let at = object + 1; at < end; at += 1) {
|
|
26
|
+
if (!code[at]) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const char = text[at];
|
|
30
|
+
if (OPENERS[char] !== undefined) {
|
|
31
|
+
depth += 1;
|
|
32
|
+
}
|
|
33
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
34
|
+
depth -= 1;
|
|
35
|
+
}
|
|
36
|
+
else if (depth === 0 &&
|
|
37
|
+
text.startsWith(key, at) &&
|
|
38
|
+
!/[\w$]/.test(text[at - 1]) &&
|
|
39
|
+
/^\s*:/.test(text.slice(at + key.length))) {
|
|
40
|
+
const open = nextCode(text, code, text.indexOf(":", at) + 1);
|
|
41
|
+
if (text[open] !== "[") {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return text
|
|
45
|
+
.slice(open + 1, closing(text, code, open))
|
|
46
|
+
.split(",")
|
|
47
|
+
.map((item) => item.trim())
|
|
48
|
+
.filter((item) => /^[A-Za-z_$][\w$]*$/.test(item));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
export function scanServices(directory, shape) {
|
|
54
|
+
const candidates = [];
|
|
55
|
+
const compositions = [];
|
|
56
|
+
const modules = [];
|
|
57
|
+
for (const file of sourceFiles(path.join(directory, "src"), directory, shape.skip)) {
|
|
58
|
+
const text = readFileSync(path.join(directory, file), "utf8");
|
|
59
|
+
const code = codeMask(text);
|
|
60
|
+
const importsClient = [
|
|
61
|
+
...text.matchAll(new RegExp(`^import\\s*\\{[^}]*\\b${shape.clientClass}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, "gm")),
|
|
62
|
+
].some((match) => code[match.index] && shape.importsClient(match[1], file));
|
|
63
|
+
for (const match of text.matchAll(/^export\s+class\s+([A-Za-z_$][\w$]*)([^{]*)\{/gm)) {
|
|
64
|
+
if (!code[match.index]) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const exportName = match[1];
|
|
68
|
+
const heritage = match[2];
|
|
69
|
+
if (importsClient &&
|
|
70
|
+
new RegExp(`\\bextends\\s+${shape.clientClass}\\b`).test(heritage)) {
|
|
71
|
+
candidates.push({ file, exportName });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bodyStart = match.index + match[0].length - 1;
|
|
75
|
+
const body = text.slice(bodyStart, closing(text, code, bodyStart));
|
|
76
|
+
const held = new RegExp(`([A-Za-z_$][\\w$]*)\\s*(?::\\s*${shape.clientClass}\\b[^=;]*)?=\\s*new\\s+${shape.clientClass}\\s*\\(`).exec(body);
|
|
77
|
+
if (importsClient && held !== null) {
|
|
78
|
+
compositions.push({ file, exportName, member: held[1] });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const decorator of codeIndexes(text, code, /@Module\s*\(/g)) {
|
|
82
|
+
const open = text.indexOf("(", decorator);
|
|
83
|
+
const object = nextCode(text, code, open + 1);
|
|
84
|
+
const close = closing(text, code, open);
|
|
85
|
+
const declared = /^\s*(?:@[\w$.]+\([^)]*\)\s*)*export\s+class\s+([A-Za-z_$][\w$]*)/.exec(text.slice(close + 1));
|
|
86
|
+
if (text[object] !== "{" || declared === null) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const before = text.slice(Math.max(0, decorator - 200), decorator);
|
|
90
|
+
modules.push({
|
|
91
|
+
file,
|
|
92
|
+
exportName: declared[1],
|
|
93
|
+
providers: arrayNames(text, code, object, "providers"),
|
|
94
|
+
exports: arrayNames(text, code, object, "exports"),
|
|
95
|
+
global: /@Global\s*\(\s*\)\s*$/.test(before) ||
|
|
96
|
+
/^\s*@Global\s*\(\s*\)/.test(text.slice(close + 1)),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { candidates, compositions, modules };
|
|
101
|
+
}
|
|
102
|
+
/** The modules that make `service` injectable elsewhere: providing and exporting it, or global. */
|
|
103
|
+
export function modulesProviding(scan, service) {
|
|
104
|
+
return scan.modules.filter((module) => module.providers.includes(service.exportName) &&
|
|
105
|
+
(module.exports.includes(service.exportName) || module.global));
|
|
106
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lexical view of TypeScript source — enough to find decorators, classes,
|
|
3
|
+
* imports and brackets **in code**, never inside a comment, a string or a
|
|
4
|
+
* template literal's text — with no compiler: `aventara new` runs before
|
|
5
|
+
* anything (TypeScript included) is installed in the project, and the CLI
|
|
6
|
+
* carries no TypeScript of its own. Shared by the anchored `app.module.ts` edit
|
|
7
|
+
* and the detection of a developer's ORM service.
|
|
8
|
+
*/
|
|
9
|
+
/** For each character: is it code (not inside a comment, string or template text)? */
|
|
10
|
+
export declare function codeMask(text: string): boolean[];
|
|
11
|
+
/** Every index where `token` starts in code. */
|
|
12
|
+
export declare function codeIndexes(text: string, code: boolean[], token: RegExp): number[];
|
|
13
|
+
export declare const OPENERS: Readonly<Record<string, string>>;
|
|
14
|
+
/** The index of the bracket closing the one at `open`, counting code brackets only. */
|
|
15
|
+
export declare function closing(text: string, code: boolean[], open: number): number;
|
|
16
|
+
export declare function nextCode(text: string, code: boolean[], from: number): number;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lexical view of TypeScript source — enough to find decorators, classes,
|
|
3
|
+
* imports and brackets **in code**, never inside a comment, a string or a
|
|
4
|
+
* template literal's text — with no compiler: `aventara new` runs before
|
|
5
|
+
* anything (TypeScript included) is installed in the project, and the CLI
|
|
6
|
+
* carries no TypeScript of its own. Shared by the anchored `app.module.ts` edit
|
|
7
|
+
* and the detection of a developer's ORM service.
|
|
8
|
+
*/
|
|
9
|
+
/** For each character: is it code (not inside a comment, string or template text)? */
|
|
10
|
+
export function codeMask(text) {
|
|
11
|
+
const code = new Array(text.length).fill(true);
|
|
12
|
+
/** Open template literals' brace depths: `${` re-enters code until its `}`. */
|
|
13
|
+
const templates = [];
|
|
14
|
+
let braces = 0;
|
|
15
|
+
let index = 0;
|
|
16
|
+
const mark = (from, to) => {
|
|
17
|
+
for (let at = from; at < to && at < text.length; at += 1) {
|
|
18
|
+
code[at] = false;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const templateText = (from) => {
|
|
22
|
+
// Scans template text from `from` until "`" (closes) or "${" (re-enters code).
|
|
23
|
+
let at = from;
|
|
24
|
+
while (at < text.length) {
|
|
25
|
+
if (text[at] === "\\") {
|
|
26
|
+
at += 2;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (text[at] === "`") {
|
|
30
|
+
mark(from, at + 1);
|
|
31
|
+
return at + 1;
|
|
32
|
+
}
|
|
33
|
+
if (text[at] === "$" && text[at + 1] === "{") {
|
|
34
|
+
mark(from, at + 2);
|
|
35
|
+
templates.push(braces);
|
|
36
|
+
braces += 1;
|
|
37
|
+
return at + 2;
|
|
38
|
+
}
|
|
39
|
+
at += 1;
|
|
40
|
+
}
|
|
41
|
+
mark(from, at);
|
|
42
|
+
return at;
|
|
43
|
+
};
|
|
44
|
+
while (index < text.length) {
|
|
45
|
+
const char = text[index];
|
|
46
|
+
const next = text[index + 1];
|
|
47
|
+
if (char === "/" && next === "/") {
|
|
48
|
+
const end = text.indexOf("\n", index);
|
|
49
|
+
const stop = end === -1 ? text.length : end;
|
|
50
|
+
mark(index, stop);
|
|
51
|
+
index = stop;
|
|
52
|
+
}
|
|
53
|
+
else if (char === "/" && next === "*") {
|
|
54
|
+
const end = text.indexOf("*/", index + 2);
|
|
55
|
+
const stop = end === -1 ? text.length : end + 2;
|
|
56
|
+
mark(index, stop);
|
|
57
|
+
index = stop;
|
|
58
|
+
}
|
|
59
|
+
else if (char === "'" || char === '"') {
|
|
60
|
+
let at = index + 1;
|
|
61
|
+
while (at < text.length && text[at] !== char && text[at] !== "\n") {
|
|
62
|
+
at += text[at] === "\\" ? 2 : 1;
|
|
63
|
+
}
|
|
64
|
+
mark(index, at + 1);
|
|
65
|
+
index = at + 1;
|
|
66
|
+
}
|
|
67
|
+
else if (char === "`") {
|
|
68
|
+
index = templateText(index);
|
|
69
|
+
}
|
|
70
|
+
else if (char === "{") {
|
|
71
|
+
braces += 1;
|
|
72
|
+
index += 1;
|
|
73
|
+
}
|
|
74
|
+
else if (char === "}") {
|
|
75
|
+
braces -= 1;
|
|
76
|
+
if (templates.length > 0 && templates[templates.length - 1] === braces) {
|
|
77
|
+
templates.pop();
|
|
78
|
+
code[index] = false;
|
|
79
|
+
index = templateText(index + 1);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
index += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
index += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return code;
|
|
90
|
+
}
|
|
91
|
+
/** Every index where `token` starts in code. */
|
|
92
|
+
export function codeIndexes(text, code, token) {
|
|
93
|
+
const found = [];
|
|
94
|
+
for (const match of text.matchAll(token)) {
|
|
95
|
+
if (code[match.index]) {
|
|
96
|
+
found.push(match.index);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
export const OPENERS = {
|
|
102
|
+
"(": ")",
|
|
103
|
+
"[": "]",
|
|
104
|
+
"{": "}",
|
|
105
|
+
};
|
|
106
|
+
/** The index of the bracket closing the one at `open`, counting code brackets only. */
|
|
107
|
+
export function closing(text, code, open) {
|
|
108
|
+
const stack = [];
|
|
109
|
+
for (let at = open; at < text.length; at += 1) {
|
|
110
|
+
if (!code[at]) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const char = text[at];
|
|
114
|
+
if (OPENERS[char] !== undefined) {
|
|
115
|
+
stack.push(OPENERS[char]);
|
|
116
|
+
}
|
|
117
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
118
|
+
if (stack.pop() !== char) {
|
|
119
|
+
return -1;
|
|
120
|
+
}
|
|
121
|
+
if (stack.length === 0) {
|
|
122
|
+
return at;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return -1;
|
|
127
|
+
}
|
|
128
|
+
export function nextCode(text, code, from) {
|
|
129
|
+
let at = from;
|
|
130
|
+
while (at < text.length && (!code[at] || /\s/.test(text[at]))) {
|
|
131
|
+
at += 1;
|
|
132
|
+
}
|
|
133
|
+
return at;
|
|
134
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one external process seam (§4.2): a command, its arguments, a working
|
|
3
|
+
* directory. Its stdout is always **piped**, never inherited — `nest new` asks a
|
|
4
|
+
* question it has no flag for when its stdout is a terminal (B25) — and both
|
|
5
|
+
* streams are collected, so a failure can be reported with what it said.
|
|
6
|
+
*/
|
|
7
|
+
export type CommandResult = {
|
|
8
|
+
readonly code: number;
|
|
9
|
+
readonly stdout: string;
|
|
10
|
+
readonly stderr: string;
|
|
11
|
+
};
|
|
12
|
+
export type CommandRunner = (command: string, args: readonly string[], options: {
|
|
13
|
+
readonly cwd: string;
|
|
14
|
+
}) => Promise<CommandResult>;
|
|
15
|
+
export declare const runCommand: CommandRunner;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export const runCommand = (command, args, options) => new Promise((resolve, reject) => {
|
|
3
|
+
const child = spawn(command, args, {
|
|
4
|
+
cwd: options.cwd,
|
|
5
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
6
|
+
env: process.env,
|
|
7
|
+
});
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
11
|
+
stdout += chunk;
|
|
12
|
+
});
|
|
13
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
14
|
+
stderr += chunk;
|
|
15
|
+
});
|
|
16
|
+
child.once("error", reject);
|
|
17
|
+
child.once("close", (code) => {
|
|
18
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AdapterCatalog } from "../catalog/catalog-entry.interface.js";
|
|
2
|
+
import type { ScaffoldCommand } from "../command/command.parser.js";
|
|
3
|
+
import { type Prompter } from "../wizard/answer.resolver.js";
|
|
4
|
+
import type { CommandRunner } from "./command.runner.js";
|
|
5
|
+
/**
|
|
6
|
+
* `aventara init` in §4.1's order — inspect, ask, plan, confirm, and only then
|
|
7
|
+
* write and install. Every step before the write reads; so a refusal anywhere
|
|
8
|
+
* before it leaves the project exactly as it was. A failure after it (the
|
|
9
|
+
* install) names the command, its exit code and what to re-run; nothing is
|
|
10
|
+
* rolled back.
|
|
11
|
+
*/
|
|
12
|
+
export type InitIo = {
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
15
|
+
readonly stdout: (text: string) => void;
|
|
16
|
+
readonly stderr: (text: string) => void;
|
|
17
|
+
readonly prompter: Prompter;
|
|
18
|
+
readonly runner: CommandRunner;
|
|
19
|
+
readonly catalog: AdapterCatalog;
|
|
20
|
+
/** This CLI's own version (D1). */
|
|
21
|
+
readonly aventaraVersion: string;
|
|
22
|
+
/** Next steps that come before init's own (`new`'s `cd <name>`). */
|
|
23
|
+
readonly firstSteps?: readonly string[];
|
|
24
|
+
};
|
|
25
|
+
/** The install step failed after the files were written. Reported, exit 1. */
|
|
26
|
+
export declare class InstallFailedError extends Error {
|
|
27
|
+
readonly name = "InstallFailedError";
|
|
28
|
+
}
|
|
29
|
+
export declare function runInit(command: ScaffoldCommand, io: InitIo): Promise<number>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { confirmConflicts } from "../apply/conflict.confirmer.js";
|
|
4
|
+
import { matchInstalledOrm, matchOrmSetup, } from "../catalog/catalog.matcher.js";
|
|
5
|
+
import { planInit } from "../plan/project.planner.js";
|
|
6
|
+
import { detectPackageManager } from "../project/package-manager.detector.js";
|
|
7
|
+
import { inspectProject, ProjectRefusedError, } from "../project/project.inspector.js";
|
|
8
|
+
import { scanServices } from "../project/service.detector.js";
|
|
9
|
+
import { templatesFor, } from "../templates/template.registry.js";
|
|
10
|
+
import { resolveAnswers } from "../wizard/answer.resolver.js";
|
|
11
|
+
import { INIT_QUESTIONS, REUSE_QUESTIONS, } from "../wizard/wizard.questions.js";
|
|
12
|
+
/** The install step failed after the files were written. Reported, exit 1. */
|
|
13
|
+
export class InstallFailedError extends Error {
|
|
14
|
+
name = "InstallFailedError";
|
|
15
|
+
}
|
|
16
|
+
export async function runInit(command, io) {
|
|
17
|
+
const inspection = inspectProject(io.directory, io.catalog);
|
|
18
|
+
const read = (file) => {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(path.join(io.directory, file), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const detectedPackageManager = detectPackageManager({
|
|
27
|
+
lockfile: inspection.lockfile,
|
|
28
|
+
userAgent: io.env.npm_config_user_agent,
|
|
29
|
+
});
|
|
30
|
+
const prepared = inspection.orm.kind === "present"
|
|
31
|
+
? await prepareReuse(command, io, inspection, detectedPackageManager)
|
|
32
|
+
: await prepareFresh(command, io, inspection, detectedPackageManager);
|
|
33
|
+
const { entry, provider, scaffold, packageManager } = prepared;
|
|
34
|
+
const plan = planInit({
|
|
35
|
+
inspection,
|
|
36
|
+
entry,
|
|
37
|
+
scaffold,
|
|
38
|
+
packageManager,
|
|
39
|
+
aventaraVersion: io.aventaraVersion,
|
|
40
|
+
skipInstall: command.skipInstall,
|
|
41
|
+
read,
|
|
42
|
+
...(prepared.wiringByHand === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: { wiringByHand: prepared.wiringByHand }),
|
|
45
|
+
warnings: prepared.warnings,
|
|
46
|
+
});
|
|
47
|
+
await confirmConflicts(plan.conflicts, {
|
|
48
|
+
yes: command.yes,
|
|
49
|
+
prompter: io.prompter,
|
|
50
|
+
});
|
|
51
|
+
const writes = plan.conflicts.length === 0 ? plan.keeping : plan.replacing;
|
|
52
|
+
// The only file I/O (§4.1 `apply`).
|
|
53
|
+
for (const write of writes) {
|
|
54
|
+
const target = path.join(io.directory, write.path);
|
|
55
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
56
|
+
writeFileSync(target, write.content);
|
|
57
|
+
}
|
|
58
|
+
for (const warning of plan.warnings) {
|
|
59
|
+
io.stderr(`aventara: warning: ${warning}.\n`);
|
|
60
|
+
}
|
|
61
|
+
io.stdout(`aventara: initialized ${io.directory} for ${entry.orm.label} ${entry.orm.major} on ${provider}: ${writes
|
|
62
|
+
.map((write) => `${write.change === "create" ? "created" : "updated"} ${write.path}`)
|
|
63
|
+
.join(", ")}.\n`);
|
|
64
|
+
if (plan.instructions !== undefined) {
|
|
65
|
+
io.stdout(`${plan.instructions}\n`);
|
|
66
|
+
}
|
|
67
|
+
if (!command.skipInstall) {
|
|
68
|
+
io.stdout(`aventara: running ${packageManager} install…\n`);
|
|
69
|
+
const installed = await io.runner(packageManager, ["install"], {
|
|
70
|
+
cwd: io.directory,
|
|
71
|
+
});
|
|
72
|
+
if (installed.code !== 0) {
|
|
73
|
+
throw new InstallFailedError(`\`${packageManager} install\` exited with code ${installed.code} after the files were written; fix what it reports and run \`${packageManager} install\` again:\n${installed.stderr.trimEnd()}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
io.stdout(`Next:\n${[...(io.firstSteps ?? []), ...plan.nextSteps].map((step, index) => ` ${index + 1}. ${step}`).join("\n")}\n`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
/** S6 — no ORM: the ORM and database are asked, and the entry's templates write it all. */
|
|
80
|
+
async function prepareFresh(command, io, inspection, detectedPackageManager) {
|
|
81
|
+
const answers = await resolveAnswers(INIT_QUESTIONS, {
|
|
82
|
+
given: command.given,
|
|
83
|
+
yes: command.yes,
|
|
84
|
+
prompter: io.prompter,
|
|
85
|
+
context: { catalog: io.catalog, detectedPackageManager },
|
|
86
|
+
});
|
|
87
|
+
const { orm: entry, db, packageManager } = answers;
|
|
88
|
+
if (entry === undefined || db === undefined || packageManager === undefined) {
|
|
89
|
+
throw new Error("the init questions settled without an ORM, database and package manager");
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
entry,
|
|
93
|
+
provider: db,
|
|
94
|
+
packageManager,
|
|
95
|
+
scaffold: templatesFor(entry).scaffold({
|
|
96
|
+
entry,
|
|
97
|
+
provider: db,
|
|
98
|
+
moduleKind: inspection.moduleKind,
|
|
99
|
+
packageManager,
|
|
100
|
+
}),
|
|
101
|
+
warnings: [],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** The installed version of `name`, looked up the way Node resolves it: upward from the project. */
|
|
105
|
+
function installedVersion(directory, name) {
|
|
106
|
+
let current = directory;
|
|
107
|
+
for (;;) {
|
|
108
|
+
const manifest = path.join(current, "node_modules", name, "package.json");
|
|
109
|
+
if (existsSync(manifest)) {
|
|
110
|
+
const version = JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
111
|
+
return typeof version === "string" ? version : undefined;
|
|
112
|
+
}
|
|
113
|
+
const parent = path.dirname(current);
|
|
114
|
+
if (parent === current) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
current = parent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* S7 — the ORM is already set up (R6, R7): the catalog entry is matched against
|
|
122
|
+
* what is installed, the setup against what the entry declares, and the
|
|
123
|
+
* developer's own service is found or asked for. Nothing of theirs is written.
|
|
124
|
+
*/
|
|
125
|
+
async function prepareReuse(command, io, inspection, detectedPackageManager) {
|
|
126
|
+
const dependencies = [
|
|
127
|
+
...Object.keys(inspection.manifest.dependencies ?? {}),
|
|
128
|
+
...Object.keys(inspection.manifest.devDependencies ?? {}),
|
|
129
|
+
];
|
|
130
|
+
const verdict = matchInstalledOrm(io.catalog, {
|
|
131
|
+
dependencies,
|
|
132
|
+
installedVersion: (name) => installedVersion(io.directory, name),
|
|
133
|
+
installCommand: `${detectedPackageManager} install`,
|
|
134
|
+
});
|
|
135
|
+
if (verdict.kind === "refused") {
|
|
136
|
+
throw new ProjectRefusedError(verdict.sentence);
|
|
137
|
+
}
|
|
138
|
+
const { entry } = verdict;
|
|
139
|
+
const templates = templatesFor(entry);
|
|
140
|
+
const facts = templates.readProject(io.directory, inspection.manifest);
|
|
141
|
+
const problem = matchOrmSetup(entry, facts);
|
|
142
|
+
if (problem !== undefined) {
|
|
143
|
+
throw new ProjectRefusedError(problem);
|
|
144
|
+
}
|
|
145
|
+
const provider = facts.provider;
|
|
146
|
+
if (facts.clientOutput === undefined) {
|
|
147
|
+
throw new ProjectRefusedError(`this project's ${facts.generator} generator block in ${facts.schema} has no output, and the generate step reads the client from it; give it one, such as ../src/generated/${entry.orm.family}`);
|
|
148
|
+
}
|
|
149
|
+
// A flag naming the ORM or database must name what the project has.
|
|
150
|
+
for (const [flag, given, found] of [
|
|
151
|
+
["--orm", command.given.orm, `${entry.orm.family}@${entry.orm.major}`],
|
|
152
|
+
["--db", command.given.db, provider],
|
|
153
|
+
]) {
|
|
154
|
+
if (given !== undefined &&
|
|
155
|
+
given.trim().toLowerCase() !== found &&
|
|
156
|
+
!(flag === "--orm" && given.trim().toLowerCase() === entry.orm.family)) {
|
|
157
|
+
throw new ProjectRefusedError(`${flag} ${given} does not match this project, which already uses ${found}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const scan = scanServices(io.directory, templates.clientShape(facts));
|
|
161
|
+
const answers = await resolveAnswers(REUSE_QUESTIONS, {
|
|
162
|
+
given: command.given,
|
|
163
|
+
yes: command.yes,
|
|
164
|
+
prompter: io.prompter,
|
|
165
|
+
context: {
|
|
166
|
+
catalog: io.catalog,
|
|
167
|
+
detectedPackageManager,
|
|
168
|
+
services: {
|
|
169
|
+
scan,
|
|
170
|
+
clientClass: templates.clientShape(facts).clientClass,
|
|
171
|
+
newService: templates.newService.service,
|
|
172
|
+
newModule: templates.newService.module,
|
|
173
|
+
newServiceFileExists: existsSync(path.join(io.directory, templates.newService.service.file)),
|
|
174
|
+
label: entry.orm.label,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const { service, module, packageManager } = answers;
|
|
179
|
+
if (service === undefined ||
|
|
180
|
+
module === undefined ||
|
|
181
|
+
packageManager === undefined) {
|
|
182
|
+
throw new Error("the reuse questions settled without a service, module and package manager");
|
|
183
|
+
}
|
|
184
|
+
// Q16-21: beside the client's output, unless that output contains it (the adapter refuses that).
|
|
185
|
+
const preferred = "src/generated/aventara/discovery.artifact.ts";
|
|
186
|
+
const artifact = `${preferred}/`.startsWith(`${facts.clientOutput}/`)
|
|
187
|
+
? "src/aventara/discovery.artifact.ts"
|
|
188
|
+
: preferred;
|
|
189
|
+
const warnings = [
|
|
190
|
+
...(facts.clientOutput.startsWith("src/")
|
|
191
|
+
? []
|
|
192
|
+
: [
|
|
193
|
+
`the ${entry.orm.label} client is generated into ${facts.clientOutput}, outside src/, which nest build does not compile (B6); your build is your own, and init does not change it`,
|
|
194
|
+
]),
|
|
195
|
+
...(service.clientMember === undefined
|
|
196
|
+
? []
|
|
197
|
+
: [
|
|
198
|
+
`${service.file}#${service.exportName} holds its client in .${service.clientMember} instead of extending ${templates.clientShape(facts).clientClass}, so src/aventara.config.ts passes ${service.exportName}.${service.clientMember}`,
|
|
199
|
+
]),
|
|
200
|
+
];
|
|
201
|
+
return {
|
|
202
|
+
entry,
|
|
203
|
+
provider,
|
|
204
|
+
packageManager,
|
|
205
|
+
scaffold: templates.reuse({
|
|
206
|
+
entry,
|
|
207
|
+
facts: { ...facts, provider, clientOutput: facts.clientOutput },
|
|
208
|
+
moduleKind: inspection.moduleKind,
|
|
209
|
+
packageManager,
|
|
210
|
+
service,
|
|
211
|
+
module,
|
|
212
|
+
writeService: service.isNew,
|
|
213
|
+
artifact,
|
|
214
|
+
}),
|
|
215
|
+
...(service.clientMember === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: {
|
|
218
|
+
wiringByHand: `${service.exportName} holds its ${entry.orm.label} client instead of extending it`,
|
|
219
|
+
}),
|
|
220
|
+
warnings,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ScaffoldCommand } from "../command/command.parser.js";
|
|
2
|
+
import { type InitIo } from "./init.orchestrator.js";
|
|
3
|
+
/**
|
|
4
|
+
* `aventara new <name>` (R2, D1): the pinned `nest new`, then **exactly**
|
|
5
|
+
* `aventara init`'s pipeline over the fresh project, with the wizard's answers
|
|
6
|
+
* carried in — so every `new` run exercises the anchored edit, the merges and
|
|
7
|
+
* the templates `init` uses.
|
|
8
|
+
*
|
|
9
|
+
* Nest authors its own scaffold (D1): the CLI never writes Nest's files. Its
|
|
10
|
+
* output is pinned by digest (B24), so a `@nestjs/cli` that writes something
|
|
11
|
+
* else stops the run before anything of Aventara's is written over it.
|
|
12
|
+
*/
|
|
13
|
+
/** D1: the `nest new` this CLI was measured against (B1, B24). */
|
|
14
|
+
export declare const NEST_CLI = "@nestjs/cli@12.0.8";
|
|
15
|
+
/** B24: `src/app.module.ts` as `@nestjs/cli` 12.0.8 writes it — the anchored edit's file. */
|
|
16
|
+
export declare const NEST_APP_MODULE_SHA256 = "fe98c4cd2552342272494652287c02e4ef58db3072fcb18513ea831c60f85778";
|
|
17
|
+
export type NewIo = Omit<InitIo, "directory"> & {
|
|
18
|
+
/** Where `new` creates the project directory. */
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function runNew(command: ScaffoldCommand, io: NewIo): Promise<number>;
|