@aventara/cli 0.0.0-stage → 0.1.0-pilot.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 +91 -0
- package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
- package/README.md +88 -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/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 +31 -0
- package/dist/command/command.parser.js +138 -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 +52 -0
- package/dist/plan/project.planner.js +178 -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 +396 -0
- package/dist/templates/template.registry.d.ts +151 -0
- package/dist/templates/template.registry.js +12 -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,59 @@
|
|
|
1
|
+
// biome-ignore lint/style/useNodejsImportProtocol: Node 14.0–14.13.0 resolves no "node:" specifier in an ES module, and this module runs on the Nodes the packages do not support.
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
function versionOf(text) {
|
|
4
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
5
|
+
if (match === null) {
|
|
6
|
+
throw new Error(`"${text}" is not a Node version`);
|
|
7
|
+
}
|
|
8
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
9
|
+
}
|
|
10
|
+
function compareVersions(left, right) {
|
|
11
|
+
for (let index = 0; index < 3; index += 1) {
|
|
12
|
+
const difference = left[index] - right[index];
|
|
13
|
+
if (difference !== 0) {
|
|
14
|
+
return difference;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether `range` admits `version`. Reads `^x.y.z` (the same major, from the
|
|
21
|
+
* floor; majors ≥ 1) and `>=x.y.z`, joined by `||`; anything else throws rather
|
|
22
|
+
* than admit or refuse by guess.
|
|
23
|
+
*/
|
|
24
|
+
function admits(range, version) {
|
|
25
|
+
return range.split("||").some((part) => {
|
|
26
|
+
const comparator = /^\s*(\^|>=)(\d+\.\d+\.\d+)\s*$/.exec(part);
|
|
27
|
+
if (comparator === null) {
|
|
28
|
+
throw new Error(`engines.node "${range}": the bin's Node guard reads only "^x.y.z" and ">=x.y.z" joined by "||"`);
|
|
29
|
+
}
|
|
30
|
+
const floor = versionOf(comparator[2]);
|
|
31
|
+
return (compareVersions(version, floor) >= 0 &&
|
|
32
|
+
(comparator[1] === ">=" || version[0] === floor[0]));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** The sentence a bin answers on `version`, or `undefined` when `range` admits it. */
|
|
36
|
+
export function nodeVersionRefusal(bin, packageName, range, version) {
|
|
37
|
+
return admits(range, versionOf(version))
|
|
38
|
+
? undefined
|
|
39
|
+
: `${bin}: Node ${version} is not supported; ${packageName} needs Node ${range}.`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reads the manifest at `manifestUrl`; on a Node its `engines.node` does not
|
|
43
|
+
* admit, writes the sentence to stderr, sets exit code 1 and answers `true` —
|
|
44
|
+
* the bin then loads nothing else.
|
|
45
|
+
*/
|
|
46
|
+
export function refuseUnsupportedNode(bin, manifestUrl) {
|
|
47
|
+
const manifest = JSON.parse(readFileSync(manifestUrl, "utf8"));
|
|
48
|
+
const range = manifest.engines === undefined ? undefined : manifest.engines.node;
|
|
49
|
+
if (range === undefined) {
|
|
50
|
+
throw new Error(`${manifest.name} declares no engines.node for its bin's Node guard`);
|
|
51
|
+
}
|
|
52
|
+
const refusal = nodeVersionRefusal(bin, manifest.name, range, process.version);
|
|
53
|
+
if (refusal === undefined) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
process.stderr.write(`${refusal}\n`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { CatalogEntry } from "../catalog/catalog-entry.interface.js";
|
|
2
|
+
import { type ProjectInspection } from "../project/project.inspector.js";
|
|
3
|
+
import type { OrmScaffold } from "../templates/template.registry.js";
|
|
4
|
+
import type { PackageManager } from "../wizard/wizard.questions.js";
|
|
5
|
+
/**
|
|
6
|
+
* §4.1's `plan`: everything `aventara init` will write, decided before anything
|
|
7
|
+
* is written — the files, the anchored `app.module.ts` edit (or the printed
|
|
8
|
+
* instructions), the merged manifests — and every **conflict**, computed both
|
|
9
|
+
* ways so the confirmation can choose. Pure apart from reading the files it
|
|
10
|
+
* would replace.
|
|
11
|
+
*
|
|
12
|
+
* What is Nest's (its default start scripts, `AventaraModule`'s import) and what
|
|
13
|
+
* is Aventara's (its packages at this CLI's version, D1; the `aventara:prepare`
|
|
14
|
+
* and `postinstall` scripts, Q16-22, D2) is decided here; what is the ORM's
|
|
15
|
+
* comes from its templates (D7).
|
|
16
|
+
*/
|
|
17
|
+
/** The name of the script that regenerates the ORM client and the discovery artifact (Q16-22). */
|
|
18
|
+
export declare const PREPARE_SCRIPT = "aventara:prepare";
|
|
19
|
+
export type PlannedWrite = {
|
|
20
|
+
readonly path: string;
|
|
21
|
+
readonly content: string;
|
|
22
|
+
/** `create` a new file, or `replace` one whose content changes. */
|
|
23
|
+
readonly change: "create" | "replace";
|
|
24
|
+
};
|
|
25
|
+
export type ProjectPlan = {
|
|
26
|
+
/** What is written when the conflicts are kept as they are. */
|
|
27
|
+
readonly keeping: readonly PlannedWrite[];
|
|
28
|
+
/** What is written once the conflicts are confirmed (`--yes`, or a yes). */
|
|
29
|
+
readonly replacing: readonly PlannedWrite[];
|
|
30
|
+
/** Existing content that differs from what would be written. */
|
|
31
|
+
readonly conflicts: readonly string[];
|
|
32
|
+
readonly warnings: readonly string[];
|
|
33
|
+
/** Printed when the anchored edit could not be made. */
|
|
34
|
+
readonly instructions: string | undefined;
|
|
35
|
+
readonly nextSteps: readonly string[];
|
|
36
|
+
};
|
|
37
|
+
export type PlanInput = {
|
|
38
|
+
readonly inspection: ProjectInspection;
|
|
39
|
+
readonly entry: CatalogEntry;
|
|
40
|
+
readonly scaffold: OrmScaffold;
|
|
41
|
+
readonly packageManager: PackageManager;
|
|
42
|
+
/** This CLI's version: every Aventara package is pinned to it (D1). */
|
|
43
|
+
readonly aventaraVersion: string;
|
|
44
|
+
readonly skipInstall: boolean;
|
|
45
|
+
/** The current text of a project file, or `undefined` when absent. */
|
|
46
|
+
readonly read: (file: string) => string | undefined;
|
|
47
|
+
/** The anchored edit is not attempted; the wiring is printed (a service holding its client, §4.4.4). */
|
|
48
|
+
readonly wiringByHand?: string;
|
|
49
|
+
/** Said before the write, beside the planner's own. */
|
|
50
|
+
readonly warnings?: readonly string[];
|
|
51
|
+
};
|
|
52
|
+
export declare function planInit(input: PlanInput): ProjectPlan;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { spliceAppModule, wiringInstructions, } from "../apply/app-module.anchor.js";
|
|
2
|
+
import { mergeEnvFile, mergeGitignore, mergePackageManifest, mergePnpmWorkspace, } from "../apply/manifest.merger.js";
|
|
3
|
+
import { APP_MODULE, } from "../project/project.inspector.js";
|
|
4
|
+
/**
|
|
5
|
+
* §4.1's `plan`: everything `aventara init` will write, decided before anything
|
|
6
|
+
* is written — the files, the anchored `app.module.ts` edit (or the printed
|
|
7
|
+
* instructions), the merged manifests — and every **conflict**, computed both
|
|
8
|
+
* ways so the confirmation can choose. Pure apart from reading the files it
|
|
9
|
+
* would replace.
|
|
10
|
+
*
|
|
11
|
+
* What is Nest's (its default start scripts, `AventaraModule`'s import) and what
|
|
12
|
+
* is Aventara's (its packages at this CLI's version, D1; the `aventara:prepare`
|
|
13
|
+
* and `postinstall` scripts, Q16-22, D2) is decided here; what is the ORM's
|
|
14
|
+
* comes from its templates (D7).
|
|
15
|
+
*/
|
|
16
|
+
/** The name of the script that regenerates the ORM client and the discovery artifact (Q16-22). */
|
|
17
|
+
export const PREPARE_SCRIPT = "aventara:prepare";
|
|
18
|
+
/** D3: `nest new`'s start scripts (B1), and what each becomes so `.env` reaches the server. */
|
|
19
|
+
const START_SCRIPTS = {
|
|
20
|
+
start: { from: "nest start", to: "nest start --env-file .env" },
|
|
21
|
+
"start:dev": {
|
|
22
|
+
from: "nest start --watch",
|
|
23
|
+
to: "nest start --watch --env-file .env",
|
|
24
|
+
},
|
|
25
|
+
"start:debug": {
|
|
26
|
+
from: "nest start --debug --watch",
|
|
27
|
+
to: "nest start --debug --watch --env-file .env",
|
|
28
|
+
},
|
|
29
|
+
"start:prod": {
|
|
30
|
+
from: "node dist/main",
|
|
31
|
+
to: "node --env-file-if-exists=.env dist/main",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* F-853: `nest new`'s e2e script per module kind (B1, B28), and the same run with
|
|
36
|
+
* the `.env` the start scripts load — the e2e spec boots `AppModule`, whose
|
|
37
|
+
* service needs `DATABASE_URL`, and neither vitest nor jest loads `.env`.
|
|
38
|
+
*/
|
|
39
|
+
const E2E_SCRIPTS = {
|
|
40
|
+
esm: {
|
|
41
|
+
from: "vitest run --config ./vitest.config.e2e.ts",
|
|
42
|
+
to: "node --env-file-if-exists=.env ./node_modules/vitest/vitest.mjs run --config ./vitest.config.e2e.ts",
|
|
43
|
+
},
|
|
44
|
+
cjs: {
|
|
45
|
+
from: "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./test/jest-e2e.json",
|
|
46
|
+
to: "node --env-file-if-exists=.env --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./test/jest-e2e.json",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const NEST_HOST = "@aventara/nest";
|
|
50
|
+
const CORE = "@aventara/core";
|
|
51
|
+
function writeOf(path, before, after) {
|
|
52
|
+
return before === after
|
|
53
|
+
? []
|
|
54
|
+
: [
|
|
55
|
+
{
|
|
56
|
+
path,
|
|
57
|
+
content: after,
|
|
58
|
+
change: before === undefined ? "create" : "replace",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
export function planInit(input) {
|
|
63
|
+
const { inspection, scaffold, packageManager } = input;
|
|
64
|
+
const conflicts = new Set();
|
|
65
|
+
const keeping = [];
|
|
66
|
+
const replacing = [];
|
|
67
|
+
const warnings = [...(input.warnings ?? [])];
|
|
68
|
+
const both = (path, before, merge) => {
|
|
69
|
+
const kept = merge(false);
|
|
70
|
+
const replaced = merge(true);
|
|
71
|
+
for (const conflict of replaced.conflicts) {
|
|
72
|
+
conflicts.add(conflict);
|
|
73
|
+
}
|
|
74
|
+
keeping.push(...writeOf(path, before, kept.text));
|
|
75
|
+
replacing.push(...writeOf(path, before, replaced.text));
|
|
76
|
+
};
|
|
77
|
+
// The ORM's new files: identical content is no change (P3), other content a conflict.
|
|
78
|
+
for (const file of scaffold.files) {
|
|
79
|
+
const before = input.read(file.path);
|
|
80
|
+
if (before === undefined) {
|
|
81
|
+
keeping.push({
|
|
82
|
+
path: file.path,
|
|
83
|
+
content: file.content,
|
|
84
|
+
change: "create",
|
|
85
|
+
});
|
|
86
|
+
replacing.push({
|
|
87
|
+
path: file.path,
|
|
88
|
+
content: file.content,
|
|
89
|
+
change: "create",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
else if (before !== file.content) {
|
|
93
|
+
conflicts.add(file.path);
|
|
94
|
+
replacing.push({
|
|
95
|
+
path: file.path,
|
|
96
|
+
content: file.content,
|
|
97
|
+
change: "replace",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The one edit to developer code (R2).
|
|
102
|
+
const wiring = {
|
|
103
|
+
imports: [
|
|
104
|
+
`import { AventaraModule } from '${NEST_HOST}';`,
|
|
105
|
+
...scaffold.appModule.imports,
|
|
106
|
+
],
|
|
107
|
+
entries: scaffold.appModule.entries,
|
|
108
|
+
};
|
|
109
|
+
const appModule = input.read(APP_MODULE);
|
|
110
|
+
const edit = input.wiringByHand !== undefined
|
|
111
|
+
? { kind: "not-anchored", reason: input.wiringByHand }
|
|
112
|
+
: appModule === undefined
|
|
113
|
+
? {
|
|
114
|
+
kind: "not-anchored",
|
|
115
|
+
reason: `there is no ${APP_MODULE}`,
|
|
116
|
+
}
|
|
117
|
+
: spliceAppModule(appModule, wiring);
|
|
118
|
+
let instructions;
|
|
119
|
+
if (edit.kind === "edited") {
|
|
120
|
+
keeping.push(...writeOf(APP_MODULE, appModule, edit.text));
|
|
121
|
+
replacing.push(...writeOf(APP_MODULE, appModule, edit.text));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
instructions = wiringInstructions(wiring);
|
|
125
|
+
warnings.push(`${APP_MODULE} was not edited: ${edit.reason}; add AventaraModule by hand, as printed below`);
|
|
126
|
+
}
|
|
127
|
+
const versions = {
|
|
128
|
+
[CORE]: input.aventaraVersion,
|
|
129
|
+
[NEST_HOST]: input.aventaraVersion,
|
|
130
|
+
[input.entry.adapterPackage]: input.aventaraVersion,
|
|
131
|
+
};
|
|
132
|
+
both("package.json", inspection.manifestText, (replace) => mergePackageManifest(inspection.manifestText, {
|
|
133
|
+
dependencies: { ...versions, ...scaffold.dependencies },
|
|
134
|
+
devDependencies: scaffold.devDependencies,
|
|
135
|
+
scripts: {
|
|
136
|
+
[PREPARE_SCRIPT]: scaffold.prepare,
|
|
137
|
+
// D2: the generated trees are ignored and rebuilt on every install.
|
|
138
|
+
postinstall: `${packageManager} run ${PREPARE_SCRIPT}`,
|
|
139
|
+
},
|
|
140
|
+
rewrites: inspection.usesNestConfig
|
|
141
|
+
? {}
|
|
142
|
+
: {
|
|
143
|
+
...START_SCRIPTS,
|
|
144
|
+
"test:e2e": E2E_SCRIPTS[inspection.moduleKind],
|
|
145
|
+
},
|
|
146
|
+
}, replace));
|
|
147
|
+
const env = input.read(".env");
|
|
148
|
+
if (Object.keys(scaffold.env).length > 0) {
|
|
149
|
+
both(".env", env, (replace) => mergeEnvFile(env, scaffold.env, replace));
|
|
150
|
+
}
|
|
151
|
+
const gitignore = input.read(".gitignore");
|
|
152
|
+
both(".gitignore", gitignore, () => mergeGitignore(gitignore, scaffold.gitignore));
|
|
153
|
+
if (packageManager === "pnpm" && scaffold.nativeBuilds.length > 0) {
|
|
154
|
+
const workspace = input.read("pnpm-workspace.yaml");
|
|
155
|
+
both("pnpm-workspace.yaml", workspace, () => mergePnpmWorkspace(workspace, scaffold.nativeBuilds));
|
|
156
|
+
}
|
|
157
|
+
const run = packageManager === "pnpm" ? "pnpm" : "npm";
|
|
158
|
+
return {
|
|
159
|
+
keeping,
|
|
160
|
+
replacing,
|
|
161
|
+
conflicts: [...conflicts],
|
|
162
|
+
warnings,
|
|
163
|
+
instructions,
|
|
164
|
+
nextSteps: [
|
|
165
|
+
...(input.skipInstall
|
|
166
|
+
? [`Install, which also runs ${PREPARE_SCRIPT}: ${run} install`]
|
|
167
|
+
: []),
|
|
168
|
+
...scaffold.nextSteps,
|
|
169
|
+
...(inspection.usesNestConfig
|
|
170
|
+
? [
|
|
171
|
+
"The start scripts were left as they are: @nestjs/config loads .env, so make sure it does before Prisma connects.",
|
|
172
|
+
]
|
|
173
|
+
: []),
|
|
174
|
+
`Start the server: ${run} run start:dev`,
|
|
175
|
+
"In your frontend: npx @aventara/client init",
|
|
176
|
+
],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PackageManager } from "../wizard/wizard.questions.js";
|
|
2
|
+
/**
|
|
3
|
+
* Q16-5's default package manager: a lockfile says what a project already uses
|
|
4
|
+
* (`init`); otherwise the package manager that launched this CLI says what the
|
|
5
|
+
* developer uses — `npm_config_user_agent` is `npm/…` under `npx` and
|
|
6
|
+
* `npm exec`, `pnpm/…` under `pnpm dlx` and `pnpm exec`, and unset when the bin
|
|
7
|
+
* runs directly (B11); otherwise npm.
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectPackageManager(signals: {
|
|
10
|
+
readonly lockfile?: PackageManager | undefined;
|
|
11
|
+
readonly userAgent?: string | undefined;
|
|
12
|
+
}): PackageManager;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Q16-5's default package manager: a lockfile says what a project already uses
|
|
3
|
+
* (`init`); otherwise the package manager that launched this CLI says what the
|
|
4
|
+
* developer uses — `npm_config_user_agent` is `npm/…` under `npx` and
|
|
5
|
+
* `npm exec`, `pnpm/…` under `pnpm dlx` and `pnpm exec`, and unset when the bin
|
|
6
|
+
* runs directly (B11); otherwise npm.
|
|
7
|
+
*/
|
|
8
|
+
export function detectPackageManager(signals) {
|
|
9
|
+
if (signals.lockfile !== undefined) {
|
|
10
|
+
return signals.lockfile;
|
|
11
|
+
}
|
|
12
|
+
const launcher = signals.userAgent?.split("/")[0];
|
|
13
|
+
return launcher === "pnpm" ? "pnpm" : "npm";
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PackageManifest } from "../apply/manifest.merger.js";
|
|
2
|
+
import type { AdapterCatalog, CatalogEntry } from "../catalog/catalog-entry.interface.js";
|
|
3
|
+
import type { ModuleKind } from "../templates/template.registry.js";
|
|
4
|
+
import type { PackageManager } from "../wizard/wizard.questions.js";
|
|
5
|
+
/**
|
|
6
|
+
* §4.1's `inspect`: what `aventara init` needs to know about the project in the
|
|
7
|
+
* working directory, read before anything is asked or written — so every
|
|
8
|
+
* refusal here leaves the directory exactly as it was (it is a property of the
|
|
9
|
+
* order, not of a rollback).
|
|
10
|
+
*/
|
|
11
|
+
/** The project is not one `aventara init` serves. A refusal: one sentence, nothing written. */
|
|
12
|
+
export declare class ProjectRefusedError extends Error {
|
|
13
|
+
readonly name = "ProjectRefusedError";
|
|
14
|
+
}
|
|
15
|
+
/** The ORM a project already has, by the catalog's family packages (R14, D10). */
|
|
16
|
+
export type OrmPresence = {
|
|
17
|
+
readonly kind: "absent";
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: "present";
|
|
20
|
+
/** The family packages found among its dependencies. */
|
|
21
|
+
readonly packages: readonly string[];
|
|
22
|
+
/** The catalog entries whose family they belong to. */
|
|
23
|
+
readonly families: readonly CatalogEntry["orm"]["label"][];
|
|
24
|
+
};
|
|
25
|
+
export type ProjectInspection = {
|
|
26
|
+
readonly directory: string;
|
|
27
|
+
readonly manifest: PackageManifest;
|
|
28
|
+
readonly manifestText: string;
|
|
29
|
+
readonly moduleKind: ModuleKind;
|
|
30
|
+
/** The package manager the lockfile names, if exactly one does. */
|
|
31
|
+
readonly lockfile: PackageManager | undefined;
|
|
32
|
+
readonly orm: OrmPresence;
|
|
33
|
+
/** `@nestjs/config` loads `.env` itself, so the start scripts are left alone (P5). */
|
|
34
|
+
readonly usesNestConfig: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** The file the anchored edit opens (R2). */
|
|
37
|
+
export declare const APP_MODULE = "src/app.module.ts";
|
|
38
|
+
export declare function inspectProject(directory: string, catalog: AdapterCatalog): ProjectInspection;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lowestMajorOf } from "../catalog/range.reader.js";
|
|
4
|
+
/**
|
|
5
|
+
* §4.1's `inspect`: what `aventara init` needs to know about the project in the
|
|
6
|
+
* working directory, read before anything is asked or written — so every
|
|
7
|
+
* refusal here leaves the directory exactly as it was (it is a property of the
|
|
8
|
+
* order, not of a rollback).
|
|
9
|
+
*/
|
|
10
|
+
/** The project is not one `aventara init` serves. A refusal: one sentence, nothing written. */
|
|
11
|
+
export class ProjectRefusedError extends Error {
|
|
12
|
+
name = "ProjectRefusedError";
|
|
13
|
+
}
|
|
14
|
+
/** Nest 12 only (Phase 11 Q16). */
|
|
15
|
+
const NEST_MAJOR = 12;
|
|
16
|
+
/** D4: TypeScript 7 has no classic compiler API. */
|
|
17
|
+
const FIRST_UNSUPPORTED_TYPESCRIPT = 7;
|
|
18
|
+
/** The file the anchored edit opens (R2). */
|
|
19
|
+
export const APP_MODULE = "src/app.module.ts";
|
|
20
|
+
const LOCKFILES = [
|
|
21
|
+
["package-lock.json", "npm"],
|
|
22
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
23
|
+
["yarn.lock", "yarn"],
|
|
24
|
+
];
|
|
25
|
+
function dependencyRange(manifest, name) {
|
|
26
|
+
return manifest.dependencies?.[name] ?? manifest.devDependencies?.[name];
|
|
27
|
+
}
|
|
28
|
+
function matchesFamily(pattern, name) {
|
|
29
|
+
return pattern.endsWith("*")
|
|
30
|
+
? name.startsWith(pattern.slice(0, -1))
|
|
31
|
+
: name === pattern;
|
|
32
|
+
}
|
|
33
|
+
export function inspectProject(directory, catalog) {
|
|
34
|
+
const manifestPath = path.join(directory, "package.json");
|
|
35
|
+
if (!existsSync(manifestPath)) {
|
|
36
|
+
throw new ProjectRefusedError(`aventara init runs in a NestJS ${NEST_MAJOR} project, and there is no package.json in ${directory}`);
|
|
37
|
+
}
|
|
38
|
+
const manifestText = readFileSync(manifestPath, "utf8");
|
|
39
|
+
const manifest = JSON.parse(manifestText);
|
|
40
|
+
const nest = dependencyRange(manifest, "@nestjs/core");
|
|
41
|
+
if (nest === undefined) {
|
|
42
|
+
throw new ProjectRefusedError(`aventara init runs in a NestJS ${NEST_MAJOR} project, and this package.json has no @nestjs/core dependency`);
|
|
43
|
+
}
|
|
44
|
+
if (lowestMajorOf(nest) !== NEST_MAJOR) {
|
|
45
|
+
throw new ProjectRefusedError(`aventara init supports NestJS ${NEST_MAJOR} only, and this project depends on @nestjs/core ${nest}`);
|
|
46
|
+
}
|
|
47
|
+
const typescript = dependencyRange(manifest, "typescript");
|
|
48
|
+
const typescriptMajor = typescript === undefined ? undefined : lowestMajorOf(typescript);
|
|
49
|
+
if (typescriptMajor !== undefined &&
|
|
50
|
+
typescriptMajor >= FIRST_UNSUPPORTED_TYPESCRIPT) {
|
|
51
|
+
throw new ProjectRefusedError(`this project declares typescript ${typescript}, and TypeScript ${FIRST_UNSUPPORTED_TYPESCRIPT} has no classic compiler API for Aventara's generate steps to read types with; use TypeScript 6 or 5`);
|
|
52
|
+
}
|
|
53
|
+
const lockfiles = LOCKFILES.filter(([file]) => existsSync(path.join(directory, file))).map(([, manager]) => manager);
|
|
54
|
+
const known = lockfiles.filter((manager) => manager !== "yarn");
|
|
55
|
+
if (lockfiles.includes("yarn") && known.length === 0) {
|
|
56
|
+
throw new ProjectRefusedError("this project has only a yarn.lock, and aventara init supports npm and pnpm");
|
|
57
|
+
}
|
|
58
|
+
const appModule = path.join(directory, APP_MODULE);
|
|
59
|
+
if (existsSync(appModule) &&
|
|
60
|
+
/\bAventaraModule\b/.test(readFileSync(appModule, "utf8"))) {
|
|
61
|
+
throw new ProjectRefusedError(`this project is already initialized: ${APP_MODULE} uses AventaraModule`);
|
|
62
|
+
}
|
|
63
|
+
const dependencies = [
|
|
64
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
65
|
+
...Object.keys(manifest.devDependencies ?? {}),
|
|
66
|
+
];
|
|
67
|
+
const present = catalog.filter((entry) => entry.familyPackages.some((pattern) => dependencies.some((name) => matchesFamily(pattern, name))));
|
|
68
|
+
const orm = present.length === 0
|
|
69
|
+
? { kind: "absent" }
|
|
70
|
+
: {
|
|
71
|
+
kind: "present",
|
|
72
|
+
packages: dependencies.filter((name) => present.some((entry) => entry.familyPackages.some((pattern) => matchesFamily(pattern, name)))),
|
|
73
|
+
families: [...new Set(present.map((entry) => entry.orm.label))],
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
directory,
|
|
77
|
+
manifest,
|
|
78
|
+
manifestText,
|
|
79
|
+
moduleKind: manifest.type === "module" ? "esm" : "cjs",
|
|
80
|
+
lockfile: known.length === 1 ? known[0] : undefined,
|
|
81
|
+
orm,
|
|
82
|
+
usesNestConfig: dependencyRange(manifest, "@nestjs/config") !== undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -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;
|