@lenne.tech/cli 1.9.6 → 1.10.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/README.md +83 -0
- package/build/commands/fullstack/init.js +108 -4
- package/build/commands/fullstack/update.js +129 -0
- package/build/commands/server/add-property.js +29 -2
- package/build/commands/server/create.js +41 -3
- package/build/commands/server/module.js +58 -25
- package/build/commands/server/object.js +26 -5
- package/build/commands/server/permissions.js +20 -6
- package/build/commands/server/test.js +7 -1
- package/build/commands/status.js +13 -1
- package/build/config/vendor-runtime-deps.json +9 -0
- package/build/extensions/api-mode.js +19 -3
- package/build/extensions/server.js +1028 -3
- package/build/lib/framework-detection.js +167 -0
- package/build/templates/nest-server-module/inputs/template-create.input.ts.ejs +1 -1
- package/build/templates/nest-server-module/inputs/template.input.ts.ejs +1 -1
- package/build/templates/nest-server-module/outputs/template-fac-result.output.ts.ejs +1 -1
- package/build/templates/nest-server-module/template.controller.ts.ejs +1 -1
- package/build/templates/nest-server-module/template.model.ts.ejs +1 -1
- package/build/templates/nest-server-module/template.module.ts.ejs +1 -1
- package/build/templates/nest-server-module/template.resolver.ts.ejs +1 -1
- package/build/templates/nest-server-module/template.service.ts.ejs +1 -1
- package/build/templates/nest-server-object/template-create.input.ts.ejs +1 -1
- package/build/templates/nest-server-object/template.input.ts.ejs +1 -1
- package/build/templates/nest-server-object/template.object.ts.ejs +1 -1
- package/build/templates/nest-server-tests/tests.e2e-spec.ts.ejs +1 -1
- package/build/templates/vendor-scripts/check-vendor-freshness.mjs +131 -0
- package/build/templates/vendor-scripts/propose-upstream-pr.ts +269 -0
- package/build/templates/vendor-scripts/sync-from-upstream.ts +250 -0
- package/package.json +16 -8
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Framework-detection helpers for @lenne.tech/nest-server consumer projects.
|
|
4
|
+
*
|
|
5
|
+
* lenne.tech API projects can consume the framework in two modes:
|
|
6
|
+
*
|
|
7
|
+
* - **npm mode** (classic): `@lenne.tech/nest-server` is installed as an npm
|
|
8
|
+
* dependency. Framework source lives in
|
|
9
|
+
* `node_modules/@lenne.tech/nest-server/`. Generated code uses bare
|
|
10
|
+
* specifiers (`from '@lenne.tech/nest-server'`).
|
|
11
|
+
*
|
|
12
|
+
* - **vendored mode**: The framework's `core/` directory is copied directly
|
|
13
|
+
* into the project at `<api-root>/src/core/` as first-class project code.
|
|
14
|
+
* There is **no** `@lenne.tech/nest-server` dependency in `package.json`.
|
|
15
|
+
* Generated code uses relative imports (`from '../../../core'`, depth
|
|
16
|
+
* varies by file location).
|
|
17
|
+
*
|
|
18
|
+
* The detection is driven by the presence of `<api-root>/src/core/VENDOR.md`
|
|
19
|
+
* (a baseline + patch-log file written by the vendoring pilot).
|
|
20
|
+
*
|
|
21
|
+
* This module centralizes the detection logic so that every CLI command which
|
|
22
|
+
* emits or patches nest-server-aware code can branch consistently.
|
|
23
|
+
*/
|
|
24
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
25
|
+
if (k2 === undefined) k2 = k;
|
|
26
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
27
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
28
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
29
|
+
}
|
|
30
|
+
Object.defineProperty(o, k2, desc);
|
|
31
|
+
}) : (function(o, m, k, k2) {
|
|
32
|
+
if (k2 === undefined) k2 = k;
|
|
33
|
+
o[k2] = m[k];
|
|
34
|
+
}));
|
|
35
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
36
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
37
|
+
}) : function(o, v) {
|
|
38
|
+
o["default"] = v;
|
|
39
|
+
});
|
|
40
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
41
|
+
var ownKeys = function(o) {
|
|
42
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
43
|
+
var ar = [];
|
|
44
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
45
|
+
return ar;
|
|
46
|
+
};
|
|
47
|
+
return ownKeys(o);
|
|
48
|
+
};
|
|
49
|
+
return function (mod) {
|
|
50
|
+
if (mod && mod.__esModule) return mod;
|
|
51
|
+
var result = {};
|
|
52
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
53
|
+
__setModuleDefault(result, mod);
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
})();
|
|
57
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
58
|
+
exports.detectFrameworkMode = detectFrameworkMode;
|
|
59
|
+
exports.findProjectDir = findProjectDir;
|
|
60
|
+
exports.getFrameworkImportSpecifier = getFrameworkImportSpecifier;
|
|
61
|
+
exports.getFrameworkRootPath = getFrameworkRootPath;
|
|
62
|
+
exports.isVendoredProject = isVendoredProject;
|
|
63
|
+
const node_fs_1 = require("node:fs");
|
|
64
|
+
const path = __importStar(require("node:path"));
|
|
65
|
+
/**
|
|
66
|
+
* Determines the current framework consumption mode of the given project.
|
|
67
|
+
*
|
|
68
|
+
* Returns `'vendor'` if `VENDOR.md` indicates vendored mode. Otherwise
|
|
69
|
+
* returns `'npm'` (the classic mode where `@lenne.tech/nest-server` is an
|
|
70
|
+
* npm dependency).
|
|
71
|
+
*/
|
|
72
|
+
function detectFrameworkMode(projectDir) {
|
|
73
|
+
return isVendoredProject(projectDir) ? 'vendor' : 'npm';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Walks up from `startDir` looking for the nearest `package.json`, returning
|
|
77
|
+
* the directory that contains it. Used by commands that are invoked from a
|
|
78
|
+
* sub-directory of an API project and need to find the project root.
|
|
79
|
+
*
|
|
80
|
+
* Returns `undefined` if no `package.json` is found up to the filesystem root.
|
|
81
|
+
*/
|
|
82
|
+
function findProjectDir(startDir) {
|
|
83
|
+
let current = path.resolve(startDir);
|
|
84
|
+
const root = path.parse(current).root;
|
|
85
|
+
while (current !== root) {
|
|
86
|
+
if ((0, node_fs_1.existsSync)(path.join(current, 'package.json'))) {
|
|
87
|
+
return current;
|
|
88
|
+
}
|
|
89
|
+
current = path.dirname(current);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Returns the import specifier to use for `from '<framework>'` in generated
|
|
95
|
+
* source code living at `sourceFilePath`.
|
|
96
|
+
*
|
|
97
|
+
* - npm mode: always `'@lenne.tech/nest-server'` (bare specifier).
|
|
98
|
+
* - vendor mode: a relative path from `sourceFilePath`'s directory to
|
|
99
|
+
* `<projectDir>/src/core`, normalized to forward slashes and
|
|
100
|
+
* prefixed with `./` if needed. Example: for
|
|
101
|
+
* `projectDir/src/server/modules/foo/foo.service.ts`, returns
|
|
102
|
+
* `'../../../core'`.
|
|
103
|
+
*
|
|
104
|
+
* `sourceFilePath` is the absolute path of the file that WILL CONTAIN the
|
|
105
|
+
* import — NOT the file being imported. Depth is calculated from this path.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* // Project at /abs/api, file at src/server/modules/foo/foo.service.ts
|
|
109
|
+
* getFrameworkImportSpecifier('/abs/api',
|
|
110
|
+
* '/abs/api/src/server/modules/foo/foo.service.ts');
|
|
111
|
+
* // → '../../../core' (vendor mode)
|
|
112
|
+
* // → '@lenne.tech/nest-server' (npm mode)
|
|
113
|
+
*/
|
|
114
|
+
function getFrameworkImportSpecifier(projectDir, sourceFilePath) {
|
|
115
|
+
if (!isVendoredProject(projectDir)) {
|
|
116
|
+
return '@lenne.tech/nest-server';
|
|
117
|
+
}
|
|
118
|
+
const corePath = path.join(projectDir, 'src', 'core');
|
|
119
|
+
const fromDir = path.dirname(sourceFilePath);
|
|
120
|
+
let rel = path.relative(fromDir, corePath);
|
|
121
|
+
// Normalize to POSIX separators (import specifiers are always forward-slash)
|
|
122
|
+
rel = rel.split(path.sep).join('/');
|
|
123
|
+
// Guarantee a relative prefix; sibling or descendant paths otherwise miss `./`
|
|
124
|
+
if (!rel.startsWith('.')) {
|
|
125
|
+
rel = `./${rel}`;
|
|
126
|
+
}
|
|
127
|
+
return rel;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Returns the filesystem root of the framework source for the project.
|
|
131
|
+
*
|
|
132
|
+
* - npm mode: `<projectDir>/node_modules/@lenne.tech/nest-server`
|
|
133
|
+
* - vendor mode: `<projectDir>/src/core`
|
|
134
|
+
*
|
|
135
|
+
* Consumers that need to introspect framework source files (e.g. permissions
|
|
136
|
+
* scanner, CrudService lookup) should use this instead of hard-coding either
|
|
137
|
+
* path.
|
|
138
|
+
*/
|
|
139
|
+
function getFrameworkRootPath(projectDir) {
|
|
140
|
+
return isVendoredProject(projectDir)
|
|
141
|
+
? path.join(projectDir, 'src', 'core')
|
|
142
|
+
: path.join(projectDir, 'node_modules', '@lenne.tech', 'nest-server');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Detects whether the given API project directory runs in vendored mode.
|
|
146
|
+
*
|
|
147
|
+
* A project is considered vendored when:
|
|
148
|
+
* 1. `<projectDir>/src/core/VENDOR.md` exists, AND
|
|
149
|
+
* 2. The VENDOR.md content references `@lenne.tech/nest-server` (guards
|
|
150
|
+
* against coincidental unrelated `VENDOR.md` files).
|
|
151
|
+
*
|
|
152
|
+
* @param projectDir Absolute path to the api project (the directory that
|
|
153
|
+
* contains `package.json` and `src/`).
|
|
154
|
+
*/
|
|
155
|
+
function isVendoredProject(projectDir) {
|
|
156
|
+
const vendorMd = path.join(projectDir, 'src', 'core', 'VENDOR.md');
|
|
157
|
+
if (!(0, node_fs_1.existsSync)(vendorMd)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const content = (0, node_fs_1.readFileSync)(vendorMd, 'utf-8');
|
|
162
|
+
return content.includes('@lenne.tech/nest-server');
|
|
163
|
+
}
|
|
164
|
+
catch (_a) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Restricted, RoleEnum, UnifiedField } from '
|
|
1
|
+
import { Restricted, RoleEnum, UnifiedField } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { InputType } from '@nestjs/graphql';
|
|
3
3
|
import { <%= props.namePascal %>Input } from './<%= props.nameKebab %>.input';<%- props.imports %>
|
|
4
4
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiCommonErrorResponses, FilterArgs, RoleEnum, Roles } from '
|
|
1
|
+
import { ApiCommonErrorResponses, FilterArgs, RoleEnum, Roles } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
|
|
3
3
|
import { ApiOkResponse } from '@nestjs/swagger';
|
|
4
4
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Restricted, RoleEnum, equalIds<% if (props.mappings.includes('mapClasses')) { %>, mapClasses<% } %>, UnifiedField} from '
|
|
1
|
+
import { Restricted, RoleEnum, equalIds<% if (props.mappings.includes('mapClasses')) { %>, mapClasses<% } %>, UnifiedField} from '<%= props.frameworkImport %>';
|
|
2
2
|
<% if (props.isGql) { %>
|
|
3
3
|
import { ObjectType } from '@nestjs/graphql';
|
|
4
4
|
<% } %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigService } from '
|
|
1
|
+
import { ConfigService } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { Module, forwardRef } from '@nestjs/common';
|
|
3
3
|
import { MongooseModule } from '@nestjs/mongoose';
|
|
4
4
|
<% if ((props.controller === 'GraphQL') || (props.controller === 'Both')) { -%>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FilterArgs, GraphQLServiceOptions, RoleEnum, Roles, ServiceOptions } from '
|
|
1
|
+
import { FilterArgs, GraphQLServiceOptions, RoleEnum, Roles, ServiceOptions } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { Inject } from '@nestjs/common';
|
|
3
3
|
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
|
|
4
4
|
import { PubSub } from 'graphql-subscriptions';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigService, CrudService, ServiceOptions, assignPlain } from '
|
|
1
|
+
import { ConfigService, CrudService, ServiceOptions, assignPlain } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
|
3
3
|
import { InjectModel } from '@nestjs/mongoose';
|
|
4
4
|
<% if (props.isGql) { %>import { PubSub } from 'graphql-subscriptions';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Restricted, RoleEnum, UnifiedField } from '
|
|
1
|
+
import { Restricted, RoleEnum, UnifiedField } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { InputType } from '@nestjs/graphql';
|
|
3
3
|
import { <%= props.namePascal %>Input } from './<%= props.nameKebab %>.input';<%- props.imports %>
|
|
4
4
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CoreModel, Restricted, RoleEnum<% if (props.mappings.includes('mapClasses')) { %>, mapClasses<% } %>, UnifiedField } from '
|
|
1
|
+
import { CoreModel, Restricted, RoleEnum<% if (props.mappings.includes('mapClasses')) { %>, mapClasses<% } %>, UnifiedField } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { ObjectType } from '@nestjs/graphql';
|
|
3
3
|
import { Schema as MongooseSchema, SchemaFactory } from '@nestjs/mongoose';
|
|
4
4
|
import { Document } from 'mongoose';<%- props.imports %>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RoleEnum, TestGraphQLType, TestHelper } from '
|
|
1
|
+
import { RoleEnum, TestGraphQLType, TestHelper } from '<%= props.frameworkImport %>';
|
|
2
2
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
3
3
|
import { PubSub } from 'graphql-subscriptions';
|
|
4
4
|
import envConfig from '../src/config.env';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Check whether the vendored @lenne.tech/nest-server core is up-to-date with the
|
|
3
|
+
// latest upstream release. Non-blocking: prints a warning when outdated, but always
|
|
4
|
+
// exits 0 so that `check` / `check:fix` pipelines continue.
|
|
5
|
+
//
|
|
6
|
+
// Reads:
|
|
7
|
+
// projects/api/src/core/VENDOR.md → baseline version (e.g. "11.24.1")
|
|
8
|
+
//
|
|
9
|
+
// Fetches:
|
|
10
|
+
// https://registry.npmjs.org/@lenne.tech/nest-server/latest → latest published version
|
|
11
|
+
//
|
|
12
|
+
// Outputs:
|
|
13
|
+
// - Up-to-date → stdout: "✓ vendored nest-server core is up-to-date (vX.Y.Z)"
|
|
14
|
+
// - Outdated → stderr: "⚠ vendored nest-server core is X.Y.Z, latest is A.B.C"
|
|
15
|
+
// - Offline/err → stderr: warn + exit 0 (never fail)
|
|
16
|
+
|
|
17
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import https from 'node:https';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const VENDOR_MD = join(__dirname, '..', '..', 'src', 'core', 'VENDOR.md');
|
|
24
|
+
|
|
25
|
+
// ANSI color codes (no external deps)
|
|
26
|
+
const C = {
|
|
27
|
+
reset: '\x1b[0m',
|
|
28
|
+
yellow: '\x1b[33m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
dim: '\x1b[2m',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function warnAndExit(msg) {
|
|
34
|
+
process.stderr.write(`${C.yellow}⚠ ${msg}${C.reset}\n`);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ok(msg) {
|
|
39
|
+
process.stdout.write(`${C.green}✓ ${msg}${C.reset}\n`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 1. Locate VENDOR.md
|
|
44
|
+
if (!existsSync(VENDOR_MD)) {
|
|
45
|
+
warnAndExit(
|
|
46
|
+
`vendor-freshness: VENDOR.md not found at ${VENDOR_MD}. ` +
|
|
47
|
+
`Is this project vendored? Skipping check.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Parse baseline version from VENDOR.md
|
|
52
|
+
let baselineVersion;
|
|
53
|
+
try {
|
|
54
|
+
const content = readFileSync(VENDOR_MD, 'utf-8');
|
|
55
|
+
// Match: "**Baseline-Version:** 11.24.1" or "Baseline-Version: 11.24.1"
|
|
56
|
+
const match = content.match(/Baseline-Version[:*\s]+([\d.]+[\w.-]*)/);
|
|
57
|
+
if (!match) {
|
|
58
|
+
warnAndExit(`vendor-freshness: could not parse Baseline-Version from ${VENDOR_MD}`);
|
|
59
|
+
}
|
|
60
|
+
baselineVersion = match[1];
|
|
61
|
+
} catch (err) {
|
|
62
|
+
warnAndExit(`vendor-freshness: failed to read ${VENDOR_MD}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 3. Fetch latest from npm registry (offline-tolerant)
|
|
66
|
+
function fetchLatest() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const req = https.get(
|
|
69
|
+
'https://registry.npmjs.org/@lenne.tech/nest-server/latest',
|
|
70
|
+
{ timeout: 5000 },
|
|
71
|
+
(res) => {
|
|
72
|
+
if (res.statusCode !== 200) {
|
|
73
|
+
resolve(null);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let body = '';
|
|
77
|
+
res.on('data', (chunk) => (body += chunk));
|
|
78
|
+
res.on('end', () => {
|
|
79
|
+
try {
|
|
80
|
+
const json = JSON.parse(body);
|
|
81
|
+
resolve(json.version || null);
|
|
82
|
+
} catch {
|
|
83
|
+
resolve(null);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
req.on('error', () => resolve(null));
|
|
89
|
+
req.on('timeout', () => {
|
|
90
|
+
req.destroy();
|
|
91
|
+
resolve(null);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const latestVersion = await fetchLatest();
|
|
97
|
+
|
|
98
|
+
if (!latestVersion) {
|
|
99
|
+
warnAndExit(
|
|
100
|
+
`vendor-freshness: could not reach npm registry. ` +
|
|
101
|
+
`Current baseline: ${baselineVersion}. Check skipped.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4. semver compare (simple lexical sort works for X.Y.Z)
|
|
106
|
+
function parseSemver(v) {
|
|
107
|
+
const parts = v.split('.').map((p) => parseInt(p, 10));
|
|
108
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const [bMaj, bMin, bPatch] = parseSemver(baselineVersion);
|
|
112
|
+
const [lMaj, lMin, lPatch] = parseSemver(latestVersion);
|
|
113
|
+
|
|
114
|
+
const baselineNum = bMaj * 1e6 + bMin * 1e3 + bPatch;
|
|
115
|
+
const latestNum = lMaj * 1e6 + lMin * 1e3 + lPatch;
|
|
116
|
+
|
|
117
|
+
if (baselineNum === latestNum) {
|
|
118
|
+
ok(`vendored nest-server core is up-to-date (v${baselineVersion})`);
|
|
119
|
+
} else if (baselineNum < latestNum) {
|
|
120
|
+
const msg =
|
|
121
|
+
`vendored nest-server core is v${baselineVersion}, ` +
|
|
122
|
+
`latest upstream is v${latestVersion}\n` +
|
|
123
|
+
`${C.dim} Run /lt-dev:backend:update-nest-server-core to sync${C.reset}`;
|
|
124
|
+
warnAndExit(msg);
|
|
125
|
+
} else {
|
|
126
|
+
// baseline > latest: weird but not fatal
|
|
127
|
+
warnAndExit(
|
|
128
|
+
`vendored nest-server core is v${baselineVersion} (ahead of npm latest v${latestVersion}). ` +
|
|
129
|
+
`Possibly tracking an unreleased branch.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff-generator for the `lt-dev:nest-server-core-contributor` agent.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes local git commits that touched projects/api/src/core/ since the
|
|
5
|
+
* vendoring baseline, emits per-commit patch files and a human-readable
|
|
6
|
+
* candidate list. Filters out cosmetic commits (format, style, lint:fix).
|
|
7
|
+
* Does NOT cherry-pick or open any PR — that's the contributor agent's job.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* pnpm run vendor:propose-upstream
|
|
11
|
+
* (or: ts-node scripts/vendor/propose-upstream-pr.ts [--since <sha>])
|
|
12
|
+
*
|
|
13
|
+
* Output directory: scripts/vendor/upstream-candidates/<timestamp>/
|
|
14
|
+
* - local-commits.json: structured metadata for every commit
|
|
15
|
+
* - local-diffs/<commit-sha>.patch: per-commit patch file
|
|
16
|
+
* - summary.md: human-readable candidate list
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const PROJECT_ROOT = join(__dirname, '..', '..');
|
|
24
|
+
const VENDOR_DIR = join(PROJECT_ROOT, 'src', 'core');
|
|
25
|
+
const VENDOR_MD = join(VENDOR_DIR, 'VENDOR.md');
|
|
26
|
+
const OUTPUT_BASE = join(PROJECT_ROOT, 'scripts', 'vendor', 'upstream-candidates');
|
|
27
|
+
|
|
28
|
+
const MONOREPO_ROOT = join(PROJECT_ROOT, '..', '..');
|
|
29
|
+
|
|
30
|
+
function die(msg: string): never {
|
|
31
|
+
process.stderr.write(`ERROR: ${msg}\n`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sh(cmd: string, opts: { cwd?: string; allowFailure?: boolean } = {}): string {
|
|
36
|
+
try {
|
|
37
|
+
return execSync(cmd, {
|
|
38
|
+
cwd: opts.cwd ?? MONOREPO_ROOT,
|
|
39
|
+
encoding: 'utf-8',
|
|
40
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
41
|
+
});
|
|
42
|
+
} catch (err: unknown) {
|
|
43
|
+
if (opts.allowFailure) {
|
|
44
|
+
const e = err as { stdout?: string };
|
|
45
|
+
return e.stdout ?? '';
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cosmetic message patterns (case-insensitive)
|
|
52
|
+
const COSMETIC_PATTERNS = [
|
|
53
|
+
/^chore.*format/i,
|
|
54
|
+
/^style:/i,
|
|
55
|
+
/^chore.*oxfmt/i,
|
|
56
|
+
/^chore.*prettier/i,
|
|
57
|
+
/^chore.*lint:fix/i,
|
|
58
|
+
/^chore.*linting/i,
|
|
59
|
+
/^chore.*apply project formatting/i,
|
|
60
|
+
/^chore.*re-?format/i,
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
interface CommitInfo {
|
|
64
|
+
sha: string;
|
|
65
|
+
shortSha: string;
|
|
66
|
+
subject: string;
|
|
67
|
+
author: string;
|
|
68
|
+
date: string;
|
|
69
|
+
files: string[];
|
|
70
|
+
isCosmetic: boolean;
|
|
71
|
+
cosmeticReason: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 1. Parse arguments
|
|
75
|
+
const args = process.argv.slice(2);
|
|
76
|
+
let sinceRef: string | null = null;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < args.length; i++) {
|
|
79
|
+
if (args[i] === '--since' && i + 1 < args.length) {
|
|
80
|
+
sinceRef = args[++i];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Verify vendored state
|
|
85
|
+
if (!existsSync(VENDOR_MD)) {
|
|
86
|
+
die(`VENDOR.md not found at ${VENDOR_MD}. Not a vendored project.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const vendorContent = readFileSync(VENDOR_MD, 'utf-8');
|
|
90
|
+
const baselineVersionMatch = vendorContent.match(/Baseline-Version[:*\s]+([\d.]+[\w.-]*)/);
|
|
91
|
+
const baselineVersion = baselineVersionMatch?.[1] ?? 'unknown';
|
|
92
|
+
|
|
93
|
+
// 3. Determine starting point for git log
|
|
94
|
+
if (!sinceRef) {
|
|
95
|
+
// Find the commit that added VENDOR.md — that's the vendoring commit
|
|
96
|
+
sinceRef = sh(
|
|
97
|
+
`git log --diff-filter=A --format="%H" -- projects/api/src/core/VENDOR.md | tail -1`,
|
|
98
|
+
).trim();
|
|
99
|
+
if (!sinceRef) {
|
|
100
|
+
die(
|
|
101
|
+
'Could not find the commit that added VENDOR.md. Pass --since <sha> manually.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. Collect all commits since that ref that touched src/core/
|
|
107
|
+
const gitLog = sh(
|
|
108
|
+
`git log --format="%H%x09%s%x09%an%x09%aI" ${sinceRef}..HEAD -- projects/api/src/core/`,
|
|
109
|
+
).trim();
|
|
110
|
+
|
|
111
|
+
if (!gitLog) {
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
`No local commits found since ${sinceRef.substring(0, 8)} touching src/core/. Nothing to propose.\n`,
|
|
114
|
+
);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const commits: CommitInfo[] = gitLog
|
|
119
|
+
.split('\n')
|
|
120
|
+
.filter((line) => line.trim())
|
|
121
|
+
.map((line) => {
|
|
122
|
+
const [sha, subject, author, date] = line.split('\t');
|
|
123
|
+
const filesOutput = sh(
|
|
124
|
+
`git show --pretty="" --name-only ${sha} -- projects/api/src/core/`,
|
|
125
|
+
).trim();
|
|
126
|
+
const files = filesOutput ? filesOutput.split('\n') : [];
|
|
127
|
+
|
|
128
|
+
// Cosmetic check by message pattern
|
|
129
|
+
let isCosmetic = false;
|
|
130
|
+
let cosmeticReason: string | null = null;
|
|
131
|
+
for (const pat of COSMETIC_PATTERNS) {
|
|
132
|
+
if (pat.test(subject)) {
|
|
133
|
+
isCosmetic = true;
|
|
134
|
+
cosmeticReason = `commit-message matches ${pat.source}`;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Additional cosmetic check: if diff has only whitespace/formatting changes
|
|
140
|
+
if (!isCosmetic) {
|
|
141
|
+
const diff = sh(`git show --format="" ${sha} -- projects/api/src/core/`);
|
|
142
|
+
// Normalize: drop all whitespace, quote style, trailing commas
|
|
143
|
+
const normalized = diff
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter((l) => l.startsWith('+') || l.startsWith('-'))
|
|
146
|
+
.filter((l) => !l.startsWith('+++') && !l.startsWith('---'))
|
|
147
|
+
.map((l) => l.slice(1).replace(/\s+/g, '').replace(/['"`]/g, '').replace(/,$/, ''))
|
|
148
|
+
.filter((l) => l.length > 0);
|
|
149
|
+
|
|
150
|
+
// Count +/- with the same normalized content — if they cancel out, it's cosmetic
|
|
151
|
+
const plus = normalized.filter((_, i) => diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++'))[i]);
|
|
152
|
+
// Simpler heuristic: if normalized plus == normalized minus, it's cosmetic
|
|
153
|
+
const plusLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1).replace(/\s+/g, ''));
|
|
154
|
+
const minusLines = diff.split('\n').filter((l) => l.startsWith('-') && !l.startsWith('---')).map((l) => l.slice(1).replace(/\s+/g, ''));
|
|
155
|
+
const plusSet = new Set(plusLines);
|
|
156
|
+
const minusSet = new Set(minusLines);
|
|
157
|
+
const plusOnlyCount = [...plusSet].filter((l) => !minusSet.has(l) && l.length > 0).length;
|
|
158
|
+
const minusOnlyCount = [...minusSet].filter((l) => !plusSet.has(l) && l.length > 0).length;
|
|
159
|
+
if (plusOnlyCount === 0 && minusOnlyCount === 0 && plusLines.length > 0) {
|
|
160
|
+
isCosmetic = true;
|
|
161
|
+
cosmeticReason = 'normalized diff is empty (whitespace/quotes only)';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
sha,
|
|
167
|
+
shortSha: sha.substring(0, 8),
|
|
168
|
+
subject,
|
|
169
|
+
author,
|
|
170
|
+
date,
|
|
171
|
+
files,
|
|
172
|
+
isCosmetic,
|
|
173
|
+
cosmeticReason,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 5. Write output
|
|
178
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
179
|
+
const outputDir = join(OUTPUT_BASE, timestamp);
|
|
180
|
+
const diffsDir = join(outputDir, 'local-diffs');
|
|
181
|
+
mkdirSync(diffsDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
// Save JSON
|
|
184
|
+
writeFileSync(
|
|
185
|
+
join(outputDir, 'local-commits.json'),
|
|
186
|
+
JSON.stringify(
|
|
187
|
+
{
|
|
188
|
+
baselineVersion,
|
|
189
|
+
sinceRef,
|
|
190
|
+
generated: new Date().toISOString(),
|
|
191
|
+
total: commits.length,
|
|
192
|
+
cosmetic: commits.filter((c) => c.isCosmetic).length,
|
|
193
|
+
substantial: commits.filter((c) => !c.isCosmetic).length,
|
|
194
|
+
commits,
|
|
195
|
+
},
|
|
196
|
+
null,
|
|
197
|
+
2,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Save per-commit patch files (only substantial ones)
|
|
202
|
+
for (const commit of commits.filter((c) => !c.isCosmetic)) {
|
|
203
|
+
const patch = sh(`git show ${commit.sha} -- projects/api/src/core/`);
|
|
204
|
+
writeFileSync(join(diffsDir, `${commit.shortSha}.patch`), patch);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Save summary
|
|
208
|
+
const substantialCommits = commits.filter((c) => !c.isCosmetic);
|
|
209
|
+
const cosmeticCommits = commits.filter((c) => c.isCosmetic);
|
|
210
|
+
|
|
211
|
+
const summary = `# Upstream-PR Candidates
|
|
212
|
+
|
|
213
|
+
**Baseline version:** ${baselineVersion}
|
|
214
|
+
**Since commit:** ${sinceRef.substring(0, 8)}
|
|
215
|
+
**Generated:** ${new Date().toISOString()}
|
|
216
|
+
|
|
217
|
+
## Statistics
|
|
218
|
+
|
|
219
|
+
- Total commits touching \`src/core/\`: ${commits.length}
|
|
220
|
+
- Filtered as cosmetic: ${cosmeticCommits.length}
|
|
221
|
+
- **Substantial (candidate pool):** ${substantialCommits.length}
|
|
222
|
+
|
|
223
|
+
## Substantial Commits (need manual categorization by the contributor agent)
|
|
224
|
+
|
|
225
|
+
${
|
|
226
|
+
substantialCommits.length === 0
|
|
227
|
+
? '_No substantial local changes. Nothing to contribute._'
|
|
228
|
+
: substantialCommits
|
|
229
|
+
.map(
|
|
230
|
+
(c) =>
|
|
231
|
+
`### \`${c.shortSha}\` — ${c.subject}\n\n` +
|
|
232
|
+
`- **Author:** ${c.author}\n` +
|
|
233
|
+
`- **Date:** ${c.date}\n` +
|
|
234
|
+
`- **Files:** ${c.files.length}\n` +
|
|
235
|
+
c.files.map((f) => ` - ${f}`).join('\n') +
|
|
236
|
+
`\n- **Patch:** \`local-diffs/${c.shortSha}.patch\`\n`,
|
|
237
|
+
)
|
|
238
|
+
.join('\n')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
## Filtered Cosmetic Commits
|
|
242
|
+
|
|
243
|
+
${
|
|
244
|
+
cosmeticCommits.length === 0
|
|
245
|
+
? '_(none)_'
|
|
246
|
+
: cosmeticCommits
|
|
247
|
+
.map((c) => `- \`${c.shortSha}\` — ${c.subject} _(${c.cosmeticReason})_`)
|
|
248
|
+
.join('\n')
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
## Next Steps
|
|
252
|
+
|
|
253
|
+
Run the contributor agent:
|
|
254
|
+
\`\`\`
|
|
255
|
+
/lt-dev:backend:contribute-nest-server-core
|
|
256
|
+
\`\`\`
|
|
257
|
+
|
|
258
|
+
It will:
|
|
259
|
+
1. Categorize each substantial commit (upstream-candidate / project-specific / unclear)
|
|
260
|
+
2. Check upstream HEAD for duplicates
|
|
261
|
+
3. Prepare candidate branches in a local upstream clone with reverse flatten-fix
|
|
262
|
+
4. Generate PR-body drafts for human review
|
|
263
|
+
5. Present a final list with \`gh pr create\` commands ready to run
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
writeFileSync(join(outputDir, 'summary.md'), summary);
|
|
267
|
+
|
|
268
|
+
process.stdout.write(`\nDone. Review:\n`);
|
|
269
|
+
process.stdout.write(` cat ${outputDir}/summary.md\n`);
|