@nage-api/cli 1.0.0-beta.2
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 +202 -0
- package/README.md +196 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +276 -0
- package/dist/commands/create.d.ts +56 -0
- package/dist/commands/create.js +219 -0
- package/dist/commands/doctor.d.ts +47 -0
- package/dist/commands/doctor.js +208 -0
- package/dist/commands/features.d.ts +56 -0
- package/dist/commands/features.js +229 -0
- package/dist/commands/generate.d.ts +37 -0
- package/dist/commands/generate.js +151 -0
- package/dist/fs/file-tree.d.ts +57 -0
- package/dist/fs/file-tree.js +136 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +80 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +43 -0
- package/dist/naming.d.ts +36 -0
- package/dist/naming.js +72 -0
- package/dist/templates/app.template.d.ts +19 -0
- package/dist/templates/app.template.js +601 -0
- package/dist/templates/resource.template.d.ts +39 -0
- package/dist/templates/resource.template.js +600 -0
- package/dist/templates/workspace.template.d.ts +22 -0
- package/dist/templates/workspace.template.js +457 -0
- package/dist/workspace/manifest.d.ts +70 -0
- package/dist/workspace/manifest.js +162 -0
- package/dist/workspace/wiring.d.ts +33 -0
- package/dist/workspace/wiring.js +112 -0
- package/package.json +51 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `nage doctor` (PLAN.md §10.1, §12).
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of check, one report:
|
|
6
|
+
* - **workspace integrity** — duplicate app names or ports, orphan packages,
|
|
7
|
+
* apps missing from disk, version skew across apps;
|
|
8
|
+
* - **security** — delegated to `auditSecurity` in `@nage-api/core`, so the
|
|
9
|
+
* checks `doctor` reports and the ones that block a production boot are the
|
|
10
|
+
* same code rather than two lists that drift.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.runDoctor = runDoctor;
|
|
14
|
+
exports.readDotEnv = readDotEnv;
|
|
15
|
+
exports.checkWorkspaceIntegrity = checkWorkspaceIntegrity;
|
|
16
|
+
const promises_1 = require("node:fs/promises");
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const core_1 = require("@nage-api/core");
|
|
20
|
+
const BLOCKING = ['critical', 'high'];
|
|
21
|
+
async function runDoctor(options) {
|
|
22
|
+
const findings = [
|
|
23
|
+
...checkWorkspaceIntegrity(options.manifest),
|
|
24
|
+
...(await checkAppsExist(options.root, options.manifest)),
|
|
25
|
+
...(await checkOrphanPackages(options.root, options.manifest)),
|
|
26
|
+
];
|
|
27
|
+
findings.push(...(0, core_1.auditSecurity)({
|
|
28
|
+
// Audited against the strictest bar: what is safe in production is safe
|
|
29
|
+
// everywhere, and `doctor` is meant to be run before a deploy.
|
|
30
|
+
config: { app: { name: options.manifest.name, environment: 'production' } },
|
|
31
|
+
env: options.env ?? (await readDotEnv(options.root)),
|
|
32
|
+
}));
|
|
33
|
+
if (options.legacy === true) {
|
|
34
|
+
findings.push(...(await scanLegacySources(options.root)));
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
findings,
|
|
38
|
+
healthy: !findings.some((finding) => BLOCKING.includes(finding.severity)),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Read the workspace's `.env`, if it has one.
|
|
43
|
+
*
|
|
44
|
+
* Just enough of the format to judge secret strength: `KEY=value`, `export`
|
|
45
|
+
* prefixes, `#` comments and one level of quoting. Nothing is interpolated and
|
|
46
|
+
* nothing is exported — the values are read, weighed and dropped.
|
|
47
|
+
*/
|
|
48
|
+
async function readDotEnv(root) {
|
|
49
|
+
const path = (0, node_path_1.join)(root, '.env');
|
|
50
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
51
|
+
return {};
|
|
52
|
+
const values = {};
|
|
53
|
+
for (const line of (await (0, promises_1.readFile)(path, 'utf8')).split('\n')) {
|
|
54
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
55
|
+
if (match === null)
|
|
56
|
+
continue;
|
|
57
|
+
const [, key = '', rawValue = ''] = match;
|
|
58
|
+
const unquoted = /^(['"])(.*)\1\s*$/.exec(rawValue.trim());
|
|
59
|
+
values[key] = unquoted?.[2] ?? rawValue.trim().replace(/\s+#.*$/, '');
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
/** Duplicate names and ports — the two failures that only appear at run time. */
|
|
64
|
+
function checkWorkspaceIntegrity(manifest) {
|
|
65
|
+
const findings = [];
|
|
66
|
+
const byName = new Map();
|
|
67
|
+
for (const app of manifest.apps)
|
|
68
|
+
byName.set(app.name, (byName.get(app.name) ?? 0) + 1);
|
|
69
|
+
for (const [name, count] of byName) {
|
|
70
|
+
if (count > 1) {
|
|
71
|
+
findings.push({
|
|
72
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
73
|
+
severity: 'high',
|
|
74
|
+
location: `nage.workspace.json → apps.${name}`,
|
|
75
|
+
message: `"${name}" is registered ${String(count)} times.`,
|
|
76
|
+
remediation: 'Remove the duplicate entry; app names identify directories and must be unique.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const byPort = new Map();
|
|
81
|
+
for (const app of manifest.apps) {
|
|
82
|
+
byPort.set(app.port, [...(byPort.get(app.port) ?? []), app.name]);
|
|
83
|
+
}
|
|
84
|
+
for (const [port, apps] of byPort) {
|
|
85
|
+
if (apps.length > 1) {
|
|
86
|
+
findings.push({
|
|
87
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
88
|
+
severity: 'high',
|
|
89
|
+
location: `nage.workspace.json → port ${String(port)}`,
|
|
90
|
+
message: `${apps.join(' and ')} all listen on port ${String(port)}.`,
|
|
91
|
+
remediation: 'Give each app its own port; only the first to start would bind otherwise.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const aliases = new Map();
|
|
96
|
+
for (const entry of manifest.packages) {
|
|
97
|
+
aliases.set(entry.alias, [...(aliases.get(entry.alias) ?? []), entry.name]);
|
|
98
|
+
}
|
|
99
|
+
for (const [alias, packages] of aliases) {
|
|
100
|
+
if (packages.length > 1) {
|
|
101
|
+
findings.push({
|
|
102
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
103
|
+
severity: 'medium',
|
|
104
|
+
location: `nage.workspace.json → ${alias}`,
|
|
105
|
+
message: `${packages.join(' and ')} share the import alias "${alias}".`,
|
|
106
|
+
remediation: 'Give each package a distinct alias; imports would resolve unpredictably.',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
/** An app in the manifest whose directory is gone, or vice versa. */
|
|
113
|
+
async function checkAppsExist(root, manifest) {
|
|
114
|
+
const findings = [];
|
|
115
|
+
for (const app of manifest.apps) {
|
|
116
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(root, 'apps', app.name))) {
|
|
117
|
+
findings.push({
|
|
118
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
119
|
+
severity: 'medium',
|
|
120
|
+
location: `apps/${app.name}`,
|
|
121
|
+
message: `"${app.name}" is registered but its directory is missing.`,
|
|
122
|
+
remediation: 'Restore the directory, or unregister it with `nage remove app`.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const appsDir = (0, node_path_1.join)(root, 'apps');
|
|
127
|
+
if ((0, node_fs_1.existsSync)(appsDir)) {
|
|
128
|
+
const entries = await (0, promises_1.readdir)(appsDir, { withFileTypes: true });
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (!entry.isDirectory() || entry.name.endsWith('.removed'))
|
|
131
|
+
continue;
|
|
132
|
+
if (manifest.apps.some((app) => app.name === entry.name))
|
|
133
|
+
continue;
|
|
134
|
+
findings.push({
|
|
135
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
136
|
+
severity: 'low',
|
|
137
|
+
location: `apps/${entry.name}`,
|
|
138
|
+
message: `"${entry.name}" exists on disk but is not registered in the manifest.`,
|
|
139
|
+
remediation: 'Register it, or remove the directory; the CLI will not manage it otherwise.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
/** A shared package nothing imports is dead weight nobody notices. */
|
|
146
|
+
async function checkOrphanPackages(root, manifest) {
|
|
147
|
+
if (manifest.packages.length === 0)
|
|
148
|
+
return [];
|
|
149
|
+
const sources = await collectSources((0, node_path_1.join)(root, 'apps'));
|
|
150
|
+
const findings = [];
|
|
151
|
+
for (const entry of manifest.packages) {
|
|
152
|
+
const used = sources.some((source) => source.includes(`'${entry.alias}`));
|
|
153
|
+
if (!used) {
|
|
154
|
+
findings.push({
|
|
155
|
+
code: 'SEC_LEGACY_PATTERN',
|
|
156
|
+
severity: 'low',
|
|
157
|
+
location: `packages/${entry.name}`,
|
|
158
|
+
message: `No app imports "${entry.alias}".`,
|
|
159
|
+
remediation: 'Use it, or remove it — an unused shared package still has to be maintained.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return findings;
|
|
164
|
+
}
|
|
165
|
+
async function scanLegacySources(root) {
|
|
166
|
+
const files = [];
|
|
167
|
+
for (const directory of ['apps', 'packages', 'src', 'libs']) {
|
|
168
|
+
const absolute = (0, node_path_1.join)(root, directory);
|
|
169
|
+
if (!(0, node_fs_1.existsSync)(absolute))
|
|
170
|
+
continue;
|
|
171
|
+
for (const path of await listFiles(absolute)) {
|
|
172
|
+
files.push({
|
|
173
|
+
// Reported with `/` separators on every platform. This string becomes a
|
|
174
|
+
// finding's `location`, which is output a human reads and a diff records,
|
|
175
|
+
// and every other location this command produces is built as a literal
|
|
176
|
+
// `apps/${name}`. Leaving the native separator here would print
|
|
177
|
+
// `apps\api\src\main.ts` next to `apps/api` in the same report, and make
|
|
178
|
+
// the same workspace scan differently on Windows than in CI.
|
|
179
|
+
path: (0, node_path_1.relative)(root, path).split(node_path_1.sep).join('/'),
|
|
180
|
+
content: await (0, promises_1.readFile)(path, 'utf8'),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return (0, core_1.scanForLegacyPatterns)(files);
|
|
185
|
+
}
|
|
186
|
+
async function collectSources(directory) {
|
|
187
|
+
if (!(0, node_fs_1.existsSync)(directory))
|
|
188
|
+
return [];
|
|
189
|
+
const contents = [];
|
|
190
|
+
for (const path of await listFiles(directory))
|
|
191
|
+
contents.push(await (0, promises_1.readFile)(path, 'utf8'));
|
|
192
|
+
return contents;
|
|
193
|
+
}
|
|
194
|
+
const SKIP = new Set(['node_modules', 'dist', 'coverage', '.turbo', '.git']);
|
|
195
|
+
async function listFiles(directory) {
|
|
196
|
+
const found = [];
|
|
197
|
+
for (const entry of await (0, promises_1.readdir)(directory, { withFileTypes: true })) {
|
|
198
|
+
if (SKIP.has(entry.name))
|
|
199
|
+
continue;
|
|
200
|
+
const path = (0, node_path_1.join)(directory, entry.name);
|
|
201
|
+
if (entry.isDirectory())
|
|
202
|
+
found.push(...(await listFiles(path)));
|
|
203
|
+
else if (/\.(?:ts|mts|cts|js|mjs|cjs)$/.test(entry.name))
|
|
204
|
+
found.push(path);
|
|
205
|
+
}
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nage add/remove <feature>`, `nage list`, `nage info`, `nage remove app`
|
|
3
|
+
* (PLAN.md §10.1).
|
|
4
|
+
*
|
|
5
|
+
* A feature is enabled per app, not per workspace: `api` can have auth and
|
|
6
|
+
* cache while `worker` has queue only, and a disabled feature contributes no
|
|
7
|
+
* dependency and no provider (§11.1 item 6).
|
|
8
|
+
*/
|
|
9
|
+
import { FileTree } from '../fs/file-tree.js';
|
|
10
|
+
import { type AppEntry, type FeatureName, type WorkspaceManifest } from '../workspace/manifest.js';
|
|
11
|
+
export declare const FEATURES: readonly FeatureName[];
|
|
12
|
+
export interface FeatureChange {
|
|
13
|
+
readonly tree: FileTree;
|
|
14
|
+
readonly manifest: WorkspaceManifest;
|
|
15
|
+
readonly notes: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export interface FeatureOptions {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
readonly manifest: WorkspaceManifest;
|
|
20
|
+
readonly feature: FeatureName;
|
|
21
|
+
/** Apps to change; every app when `--all` was passed. */
|
|
22
|
+
readonly apps: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
/** Enable a feature: add the dependency and record it in the manifest. */
|
|
25
|
+
export declare function planAddFeature(options: FeatureOptions): Promise<FeatureChange>;
|
|
26
|
+
/** Disable a feature: drop the dependency and the manifest entry. */
|
|
27
|
+
export declare function planRemoveFeature(options: FeatureOptions): Promise<FeatureChange>;
|
|
28
|
+
export interface RemoveAppOptions {
|
|
29
|
+
readonly root: string;
|
|
30
|
+
readonly manifest: WorkspaceManifest;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Unregister an app.
|
|
35
|
+
*
|
|
36
|
+
* The directory is archived rather than deleted by the caller, and cross-app
|
|
37
|
+
* references are reported: removing an app that another one imports from should
|
|
38
|
+
* be a decision, not a surprise.
|
|
39
|
+
*/
|
|
40
|
+
export declare function planRemoveApp(options: RemoveAppOptions): Promise<FeatureChange>;
|
|
41
|
+
/** Drop a shared package's alias as well as its registration. */
|
|
42
|
+
export declare function planRemovePackage(options: {
|
|
43
|
+
root: string;
|
|
44
|
+
manifest: WorkspaceManifest;
|
|
45
|
+
name: string;
|
|
46
|
+
}): Promise<FeatureChange>;
|
|
47
|
+
/** `nage list` — apps and packages, their presets, ports and features. */
|
|
48
|
+
export declare function formatList(manifest: WorkspaceManifest): string;
|
|
49
|
+
/** `nage info` — what a bug report should contain. */
|
|
50
|
+
export declare function formatInfo(manifest: WorkspaceManifest, cliVersion: string): string;
|
|
51
|
+
/** Which apps a `--app` / `--all` pair selects. */
|
|
52
|
+
export declare function selectApps(manifest: WorkspaceManifest, options: {
|
|
53
|
+
app?: string;
|
|
54
|
+
all?: boolean;
|
|
55
|
+
}): AppEntry[];
|
|
56
|
+
//# sourceMappingURL=features.d.ts.map
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `nage add/remove <feature>`, `nage list`, `nage info`, `nage remove app`
|
|
4
|
+
* (PLAN.md §10.1).
|
|
5
|
+
*
|
|
6
|
+
* A feature is enabled per app, not per workspace: `api` can have auth and
|
|
7
|
+
* cache while `worker` has queue only, and a disabled feature contributes no
|
|
8
|
+
* dependency and no provider (§11.1 item 6).
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.FEATURES = void 0;
|
|
12
|
+
exports.planAddFeature = planAddFeature;
|
|
13
|
+
exports.planRemoveFeature = planRemoveFeature;
|
|
14
|
+
exports.planRemoveApp = planRemoveApp;
|
|
15
|
+
exports.planRemovePackage = planRemovePackage;
|
|
16
|
+
exports.formatList = formatList;
|
|
17
|
+
exports.formatInfo = formatInfo;
|
|
18
|
+
exports.selectApps = selectApps;
|
|
19
|
+
const promises_1 = require("node:fs/promises");
|
|
20
|
+
const node_path_1 = require("node:path");
|
|
21
|
+
const core_1 = require("@nage-api/core");
|
|
22
|
+
const file_tree_js_1 = require("../fs/file-tree.js");
|
|
23
|
+
const workspace_template_js_1 = require("../templates/workspace.template.js");
|
|
24
|
+
const app_template_js_1 = require("../templates/app.template.js");
|
|
25
|
+
const wiring_js_1 = require("../workspace/wiring.js");
|
|
26
|
+
const manifest_js_1 = require("../workspace/manifest.js");
|
|
27
|
+
exports.FEATURES = [
|
|
28
|
+
'auth',
|
|
29
|
+
'cache',
|
|
30
|
+
'queue',
|
|
31
|
+
'storage',
|
|
32
|
+
'realtime',
|
|
33
|
+
'notify',
|
|
34
|
+
'observability',
|
|
35
|
+
];
|
|
36
|
+
/** Enable a feature: add the dependency and record it in the manifest. */
|
|
37
|
+
async function planAddFeature(options) {
|
|
38
|
+
assertKnownFeature(options.feature);
|
|
39
|
+
const tree = new file_tree_js_1.FileTree();
|
|
40
|
+
const notes = [];
|
|
41
|
+
const apps = options.manifest.apps.map((app) => options.apps.includes(app.name) && !app.features.includes(options.feature)
|
|
42
|
+
? { ...app, features: [...app.features, options.feature] }
|
|
43
|
+
: app);
|
|
44
|
+
for (const app of apps) {
|
|
45
|
+
if (!options.apps.includes(app.name))
|
|
46
|
+
continue;
|
|
47
|
+
const manifestFile = (0, node_path_1.join)(options.root, 'apps', app.name, 'package.json');
|
|
48
|
+
const contents = JSON.parse(await (0, promises_1.readFile)(manifestFile, 'utf8'));
|
|
49
|
+
const dependencies = { ...(contents['dependencies'] ?? {}) };
|
|
50
|
+
const packageName = (0, app_template_js_1.featurePackage)(options.feature);
|
|
51
|
+
dependencies[packageName] ??= options.manifest.frameworkVersion;
|
|
52
|
+
tree.add({
|
|
53
|
+
path: `apps/${app.name}/package.json`,
|
|
54
|
+
contents: (0, workspace_template_js_1.json)({
|
|
55
|
+
...contents,
|
|
56
|
+
dependencies: Object.fromEntries(Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right))),
|
|
57
|
+
}),
|
|
58
|
+
onConflict: 'overwrite',
|
|
59
|
+
});
|
|
60
|
+
notes.push(`Enabled ${options.feature} for ${app.name}.`, ` Add its block to apps/${app.name}/src/config/nage.config.ts, then run pnpm install.`);
|
|
61
|
+
}
|
|
62
|
+
const manifest = { ...options.manifest, apps };
|
|
63
|
+
tree.add({
|
|
64
|
+
path: 'nage.workspace.json',
|
|
65
|
+
contents: (0, manifest_js_1.serialiseManifest)(manifest),
|
|
66
|
+
onConflict: 'overwrite',
|
|
67
|
+
});
|
|
68
|
+
return { tree, manifest, notes };
|
|
69
|
+
}
|
|
70
|
+
/** Disable a feature: drop the dependency and the manifest entry. */
|
|
71
|
+
async function planRemoveFeature(options) {
|
|
72
|
+
assertKnownFeature(options.feature);
|
|
73
|
+
const tree = new file_tree_js_1.FileTree();
|
|
74
|
+
const notes = [];
|
|
75
|
+
const apps = options.manifest.apps.map((app) => options.apps.includes(app.name)
|
|
76
|
+
? { ...app, features: app.features.filter((feature) => feature !== options.feature) }
|
|
77
|
+
: app);
|
|
78
|
+
for (const app of apps) {
|
|
79
|
+
if (!options.apps.includes(app.name))
|
|
80
|
+
continue;
|
|
81
|
+
const manifestFile = (0, node_path_1.join)(options.root, 'apps', app.name, 'package.json');
|
|
82
|
+
const contents = JSON.parse(await (0, promises_1.readFile)(manifestFile, 'utf8'));
|
|
83
|
+
const removed = (0, app_template_js_1.featurePackage)(options.feature);
|
|
84
|
+
const dependencies = Object.fromEntries(Object.entries((contents['dependencies'] ?? {})).filter(([name]) => name !== removed));
|
|
85
|
+
tree.add({
|
|
86
|
+
path: `apps/${app.name}/package.json`,
|
|
87
|
+
contents: (0, workspace_template_js_1.json)({ ...contents, dependencies }),
|
|
88
|
+
onConflict: 'overwrite',
|
|
89
|
+
});
|
|
90
|
+
notes.push(`Disabled ${options.feature} for ${app.name}.`, ` Remove its block from apps/${app.name}/src/config/nage.config.ts and any code that used it.`);
|
|
91
|
+
}
|
|
92
|
+
const manifest = { ...options.manifest, apps };
|
|
93
|
+
tree.add({
|
|
94
|
+
path: 'nage.workspace.json',
|
|
95
|
+
contents: (0, manifest_js_1.serialiseManifest)(manifest),
|
|
96
|
+
onConflict: 'overwrite',
|
|
97
|
+
});
|
|
98
|
+
return { tree, manifest, notes };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Unregister an app.
|
|
102
|
+
*
|
|
103
|
+
* The directory is archived rather than deleted by the caller, and cross-app
|
|
104
|
+
* references are reported: removing an app that another one imports from should
|
|
105
|
+
* be a decision, not a surprise.
|
|
106
|
+
*/
|
|
107
|
+
async function planRemoveApp(options) {
|
|
108
|
+
const app = options.manifest.apps.find((entry) => entry.name === options.name);
|
|
109
|
+
if (app === undefined) {
|
|
110
|
+
throw new core_1.ConfigurationError({
|
|
111
|
+
detail: `This workspace has no app named "${options.name}"`,
|
|
112
|
+
meta: { app: options.name, available: options.manifest.apps.map((entry) => entry.name) },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const manifest = {
|
|
116
|
+
...options.manifest,
|
|
117
|
+
apps: options.manifest.apps.filter((entry) => entry.name !== options.name),
|
|
118
|
+
};
|
|
119
|
+
const tree = new file_tree_js_1.FileTree();
|
|
120
|
+
tree.add({
|
|
121
|
+
path: 'nage.workspace.json',
|
|
122
|
+
contents: (0, manifest_js_1.serialiseManifest)(manifest),
|
|
123
|
+
onConflict: 'overwrite',
|
|
124
|
+
});
|
|
125
|
+
const rootTsconfig = await (0, promises_1.readFile)((0, node_path_1.join)(options.root, 'tsconfig.json'), 'utf8');
|
|
126
|
+
tree.add({
|
|
127
|
+
path: 'tsconfig.json',
|
|
128
|
+
contents: (0, wiring_js_1.removeProjectReference)(rootTsconfig, `apps/${options.name}`),
|
|
129
|
+
onConflict: 'overwrite',
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
tree,
|
|
133
|
+
manifest,
|
|
134
|
+
notes: [
|
|
135
|
+
`Unregistered app "${options.name}".`,
|
|
136
|
+
`Its directory was archived as apps/${options.name}.removed — delete it when you are sure.`,
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Drop a shared package's alias as well as its registration. */
|
|
141
|
+
async function planRemovePackage(options) {
|
|
142
|
+
const entry = options.manifest.packages.find((candidate) => candidate.name === options.name);
|
|
143
|
+
if (entry === undefined) {
|
|
144
|
+
throw new core_1.ConfigurationError({
|
|
145
|
+
detail: `This workspace has no package named "${options.name}"`,
|
|
146
|
+
meta: { package: options.name },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const manifest = {
|
|
150
|
+
...options.manifest,
|
|
151
|
+
packages: options.manifest.packages.filter((candidate) => candidate.name !== options.name),
|
|
152
|
+
};
|
|
153
|
+
const tree = new file_tree_js_1.FileTree();
|
|
154
|
+
tree.add({
|
|
155
|
+
path: 'nage.workspace.json',
|
|
156
|
+
contents: (0, manifest_js_1.serialiseManifest)(manifest),
|
|
157
|
+
onConflict: 'overwrite',
|
|
158
|
+
});
|
|
159
|
+
const tsconfigBase = await (0, promises_1.readFile)((0, node_path_1.join)(options.root, 'tsconfig.base.json'), 'utf8');
|
|
160
|
+
tree.add({
|
|
161
|
+
path: 'tsconfig.base.json',
|
|
162
|
+
contents: (0, wiring_js_1.removePathAlias)(tsconfigBase, entry.alias),
|
|
163
|
+
onConflict: 'overwrite',
|
|
164
|
+
});
|
|
165
|
+
return { tree, manifest, notes: [`Unregistered package "${options.name}" (${entry.alias}).`] };
|
|
166
|
+
}
|
|
167
|
+
/** `nage list` — apps and packages, their presets, ports and features. */
|
|
168
|
+
function formatList(manifest) {
|
|
169
|
+
const lines = [`${manifest.name} — engine: ${manifest.engine}`, ''];
|
|
170
|
+
lines.push(`Apps (${String(manifest.apps.length)}):`);
|
|
171
|
+
if (manifest.apps.length === 0)
|
|
172
|
+
lines.push(' (none — add one with `nage new app <name>`)');
|
|
173
|
+
for (const app of [...manifest.apps].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
174
|
+
const features = app.features.length === 0 ? 'no features' : app.features.join(', ');
|
|
175
|
+
lines.push(` ${app.name.padEnd(16)} ${app.preset.padEnd(14)} :${String(app.port)} ${features}`);
|
|
176
|
+
}
|
|
177
|
+
lines.push('', `Packages (${String(manifest.packages.length)}):`);
|
|
178
|
+
if (manifest.packages.length === 0)
|
|
179
|
+
lines.push(' (none)');
|
|
180
|
+
for (const entry of [...manifest.packages].sort((l, r) => l.name.localeCompare(r.name))) {
|
|
181
|
+
lines.push(` ${entry.name.padEnd(16)} ${entry.alias}`);
|
|
182
|
+
}
|
|
183
|
+
return `${lines.join('\n')}\n`;
|
|
184
|
+
}
|
|
185
|
+
/** `nage info` — what a bug report should contain. */
|
|
186
|
+
function formatInfo(manifest, cliVersion) {
|
|
187
|
+
const features = new Set(manifest.apps.flatMap((app) => app.features));
|
|
188
|
+
return [
|
|
189
|
+
`@nage-api/cli ${cliVersion}`,
|
|
190
|
+
`framework line ${manifest.frameworkVersion}`,
|
|
191
|
+
`node ${process.version}`,
|
|
192
|
+
`workspace ${manifest.name}`,
|
|
193
|
+
`engine ${manifest.engine}`,
|
|
194
|
+
`apps ${String(manifest.apps.length)}`,
|
|
195
|
+
`packages ${String(manifest.packages.length)}`,
|
|
196
|
+
`features in use ${features.size === 0 ? '(none)' : [...features].sort().join(', ')}`,
|
|
197
|
+
'',
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
function assertKnownFeature(feature) {
|
|
201
|
+
if (!exports.FEATURES.includes(feature)) {
|
|
202
|
+
throw new core_1.ConfigurationError({
|
|
203
|
+
detail: `"${feature}" is not a known feature`,
|
|
204
|
+
meta: { feature, available: exports.FEATURES },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Which apps a `--app` / `--all` pair selects. */
|
|
209
|
+
function selectApps(manifest, options) {
|
|
210
|
+
if (options.all === true)
|
|
211
|
+
return [...manifest.apps];
|
|
212
|
+
if (options.app !== undefined) {
|
|
213
|
+
const found = manifest.apps.find((app) => app.name === options.app);
|
|
214
|
+
if (found === undefined) {
|
|
215
|
+
throw new core_1.ConfigurationError({
|
|
216
|
+
detail: `This workspace has no app named "${options.app}"`,
|
|
217
|
+
meta: { app: options.app },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return [found];
|
|
221
|
+
}
|
|
222
|
+
if (manifest.apps.length === 1 && manifest.apps[0] !== undefined)
|
|
223
|
+
return [manifest.apps[0]];
|
|
224
|
+
throw new core_1.ConfigurationError({
|
|
225
|
+
detail: 'Several apps exist — pass --app <name> or --all',
|
|
226
|
+
meta: { available: manifest.apps.map((app) => app.name) },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=features.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nage generate <schematic> <name>` (PLAN.md §10.3).
|
|
3
|
+
*
|
|
4
|
+
* `resource` composes the sub-generators; the sub-generators exist so a module
|
|
5
|
+
* can be extended later without regenerating it. Every schematic emits a test —
|
|
6
|
+
* the reverse of the legacy CLI's `--noSpec` default.
|
|
7
|
+
*/
|
|
8
|
+
import { FileTree } from '../fs/file-tree.js';
|
|
9
|
+
import type { WorkspaceManifest } from '../workspace/manifest.js';
|
|
10
|
+
export type Schematic = 'resource' | 'module' | 'service' | 'controller' | 'entity' | 'dto' | 'migration' | 'seed';
|
|
11
|
+
export declare const SCHEMATICS: readonly Schematic[];
|
|
12
|
+
export interface GenerateOptions {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly manifest: WorkspaceManifest;
|
|
15
|
+
readonly schematic: Schematic;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly appName: string;
|
|
18
|
+
readonly fields?: string;
|
|
19
|
+
readonly route?: string;
|
|
20
|
+
readonly withMigration?: boolean;
|
|
21
|
+
readonly withSpec?: boolean;
|
|
22
|
+
readonly entityPackage?: string;
|
|
23
|
+
readonly timestamp?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface GenerateResult {
|
|
26
|
+
readonly tree: FileTree;
|
|
27
|
+
readonly notes: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Plan a generation.
|
|
31
|
+
*
|
|
32
|
+
* The full `resource` set is produced once and then filtered: a `service`
|
|
33
|
+
* generator emits exactly the file the `resource` generator would have emitted,
|
|
34
|
+
* so the two can never drift apart.
|
|
35
|
+
*/
|
|
36
|
+
export declare function planGenerate(options: GenerateOptions): Promise<GenerateResult>;
|
|
37
|
+
//# sourceMappingURL=generate.d.ts.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `nage generate <schematic> <name>` (PLAN.md §10.3).
|
|
4
|
+
*
|
|
5
|
+
* `resource` composes the sub-generators; the sub-generators exist so a module
|
|
6
|
+
* can be extended later without regenerating it. Every schematic emits a test —
|
|
7
|
+
* the reverse of the legacy CLI's `--noSpec` default.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.SCHEMATICS = void 0;
|
|
11
|
+
exports.planGenerate = planGenerate;
|
|
12
|
+
const promises_1 = require("node:fs/promises");
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
const core_1 = require("@nage-api/core");
|
|
16
|
+
const file_tree_js_1 = require("../fs/file-tree.js");
|
|
17
|
+
const naming_js_1 = require("../naming.js");
|
|
18
|
+
const resource_template_js_1 = require("../templates/resource.template.js");
|
|
19
|
+
exports.SCHEMATICS = [
|
|
20
|
+
'resource',
|
|
21
|
+
'module',
|
|
22
|
+
'service',
|
|
23
|
+
'controller',
|
|
24
|
+
'entity',
|
|
25
|
+
'dto',
|
|
26
|
+
'migration',
|
|
27
|
+
'seed',
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Plan a generation.
|
|
31
|
+
*
|
|
32
|
+
* The full `resource` set is produced once and then filtered: a `service`
|
|
33
|
+
* generator emits exactly the file the `resource` generator would have emitted,
|
|
34
|
+
* so the two can never drift apart.
|
|
35
|
+
*/
|
|
36
|
+
async function planGenerate(options) {
|
|
37
|
+
const names = (0, naming_js_1.deriveNames)(options.name);
|
|
38
|
+
const fields = (0, resource_template_js_1.parseFields)(options.fields);
|
|
39
|
+
if (options.entityPackage !== undefined) {
|
|
40
|
+
const known = options.manifest.packages.some((entry) => entry.name === options.entityPackage);
|
|
41
|
+
if (!known) {
|
|
42
|
+
throw new core_1.ConfigurationError({
|
|
43
|
+
detail: `This workspace has no package named "${options.entityPackage}"`,
|
|
44
|
+
meta: {
|
|
45
|
+
package: options.entityPackage,
|
|
46
|
+
available: options.manifest.packages.map((entry) => entry.name),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const all = (0, resource_template_js_1.resourceFiles)({
|
|
52
|
+
name: options.name,
|
|
53
|
+
appName: options.appName,
|
|
54
|
+
engine: options.manifest.engine,
|
|
55
|
+
fields,
|
|
56
|
+
...(options.route === undefined ? {} : { route: options.route }),
|
|
57
|
+
...(options.withMigration === undefined ? {} : { withMigration: options.withMigration }),
|
|
58
|
+
...(options.withSpec === undefined ? {} : { withSpec: options.withSpec }),
|
|
59
|
+
...(options.entityPackage === undefined ? {} : { entityPackage: options.entityPackage }),
|
|
60
|
+
...(options.timestamp === undefined ? {} : { timestamp: options.timestamp }),
|
|
61
|
+
});
|
|
62
|
+
const selected = selectFiles(all, options.schematic, names.kebab);
|
|
63
|
+
const tree = new file_tree_js_1.FileTree().addAll(
|
|
64
|
+
// Re-running a generator must not clobber edited code: existing files are
|
|
65
|
+
// left alone, and only what is missing is added.
|
|
66
|
+
selected.map((file) => ({ ...file, onConflict: 'skip' })));
|
|
67
|
+
const notes = [];
|
|
68
|
+
if (options.schematic === 'resource' || options.schematic === 'module') {
|
|
69
|
+
const wiring = await wireModuleIntoApp(options, names.pascal, names.kebab);
|
|
70
|
+
if (wiring === undefined) {
|
|
71
|
+
notes.push(`Register ${names.pascal}Module in apps/${options.appName}/src/app.module.ts to finish wiring it.`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
tree.add(wiring);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (options.withMigration !== false && options.schematic === 'resource') {
|
|
78
|
+
notes.push('Review the generated migration, then apply it with `nage db migrate`.');
|
|
79
|
+
}
|
|
80
|
+
return { tree, notes };
|
|
81
|
+
}
|
|
82
|
+
function selectFiles(files, schematic, kebab) {
|
|
83
|
+
const matches = (suffixes) => files.filter((file) => suffixes.some((suffix) => file.path.endsWith(suffix)));
|
|
84
|
+
switch (schematic) {
|
|
85
|
+
case 'resource':
|
|
86
|
+
return [...files];
|
|
87
|
+
case 'module':
|
|
88
|
+
return matches([`${kebab}.module.ts`]);
|
|
89
|
+
case 'service':
|
|
90
|
+
return matches([`${kebab}.service.ts`, `${kebab}.service.spec.ts`]);
|
|
91
|
+
case 'controller':
|
|
92
|
+
return matches([`${kebab}.controller.ts`, `${kebab}.e2e-spec.ts`]);
|
|
93
|
+
case 'entity':
|
|
94
|
+
return matches([`${kebab}.entity.ts`]);
|
|
95
|
+
case 'dto':
|
|
96
|
+
return files.filter((file) => file.path.includes('/dto/'));
|
|
97
|
+
case 'migration':
|
|
98
|
+
return files.filter((file) => file.path.startsWith('database/migrations/'));
|
|
99
|
+
case 'seed':
|
|
100
|
+
return [seedFile(kebab)];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function seedFile(kebab) {
|
|
104
|
+
const names = (0, naming_js_1.deriveNames)(kebab);
|
|
105
|
+
return {
|
|
106
|
+
path: `database/seeds/${kebab}.seed.ts`,
|
|
107
|
+
contents: [
|
|
108
|
+
"import type { Seed } from '@nage-api/data';",
|
|
109
|
+
'',
|
|
110
|
+
'/** Runs once ever; `mode: "always"` re-runs on every invocation. */',
|
|
111
|
+
'const seed: Seed = {',
|
|
112
|
+
` id: '${kebab}',`,
|
|
113
|
+
` name: '${names.title}',`,
|
|
114
|
+
" mode: 'once',",
|
|
115
|
+
' run: async () => {',
|
|
116
|
+
` // Insert ${names.title.toLowerCase()} reference data here.`,
|
|
117
|
+
' },',
|
|
118
|
+
'};',
|
|
119
|
+
'',
|
|
120
|
+
'export default seed;',
|
|
121
|
+
'',
|
|
122
|
+
].join('\n'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Add the module to the app's `app.module.ts` imports.
|
|
127
|
+
*
|
|
128
|
+
* Editing generated TypeScript textually is fragile, so it is deliberately
|
|
129
|
+
* conservative: it only acts on the shape this CLI emits, and when the file has
|
|
130
|
+
* been restructured it returns `undefined` and the caller prints an instruction
|
|
131
|
+
* instead of mangling the file.
|
|
132
|
+
*/
|
|
133
|
+
async function wireModuleIntoApp(options, pascal, kebab) {
|
|
134
|
+
const path = `apps/${options.appName}/src/app.module.ts`;
|
|
135
|
+
const absolute = (0, node_path_1.join)(options.root, path);
|
|
136
|
+
if (!(0, node_fs_1.existsSync)(absolute))
|
|
137
|
+
return undefined;
|
|
138
|
+
const contents = await (0, promises_1.readFile)(absolute, 'utf8');
|
|
139
|
+
if (contents.includes(`${pascal}Module`))
|
|
140
|
+
return undefined; // already wired
|
|
141
|
+
const importLine = `import { ${pascal}Module } from './modules/${kebab}/${kebab}.module.js';`;
|
|
142
|
+
const importsMatch = /imports:\s*\[([^\]]*)\]/.exec(contents);
|
|
143
|
+
if (importsMatch === null)
|
|
144
|
+
return undefined;
|
|
145
|
+
const existing = importsMatch[1] ?? '';
|
|
146
|
+
const updatedImports = `imports: [${existing.trim().length === 0 ? '' : `${existing.trim()}, `}${pascal}Module]`;
|
|
147
|
+
const withImport = contents.replace(/(\n)(@Module\(\{)/, `\n${importLine}\n$1$2`);
|
|
148
|
+
const wired = withImport.replace(/imports:\s*\[[^\]]*\]/, updatedImports);
|
|
149
|
+
return { path, contents: wired, onConflict: 'overwrite' };
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=generate.js.map
|