@lenne.tech/cli 1.9.5 → 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.
Files changed (127) hide show
  1. package/README.md +83 -0
  2. package/build/cli.js +0 -1
  3. package/build/commands/blocks/add.js +0 -1
  4. package/build/commands/blocks/blocks.js +0 -1
  5. package/build/commands/claude/claude.js +0 -1
  6. package/build/commands/claude/plugins.js +0 -1
  7. package/build/commands/claude/shortcuts.js +0 -1
  8. package/build/commands/cli/cli.js +0 -1
  9. package/build/commands/cli/create.js +0 -1
  10. package/build/commands/cli/rename.js +0 -1
  11. package/build/commands/completion.js +34 -2
  12. package/build/commands/components/add.js +0 -1
  13. package/build/commands/components/components.js +0 -1
  14. package/build/commands/config/config.js +0 -1
  15. package/build/commands/config/help.js +0 -1
  16. package/build/commands/config/init.js +0 -1
  17. package/build/commands/config/show.js +0 -1
  18. package/build/commands/config/validate.js +0 -1
  19. package/build/commands/deployment/create.js +0 -1
  20. package/build/commands/deployment/deployment.js +0 -1
  21. package/build/commands/directus/directus.js +0 -1
  22. package/build/commands/directus/docker-setup.js +35 -3
  23. package/build/commands/directus/remove.js +0 -1
  24. package/build/commands/directus/typegen.js +0 -1
  25. package/build/commands/docs/docs.js +0 -1
  26. package/build/commands/docs/open.js +34 -2
  27. package/build/commands/doctor.js +0 -1
  28. package/build/commands/frontend/angular.js +0 -1
  29. package/build/commands/frontend/frontend.js +0 -1
  30. package/build/commands/frontend/nuxt.js +0 -1
  31. package/build/commands/fullstack/fullstack.js +0 -1
  32. package/build/commands/fullstack/init.js +119 -6
  33. package/build/commands/fullstack/update.js +129 -0
  34. package/build/commands/git/clean.js +0 -1
  35. package/build/commands/git/clear.js +0 -1
  36. package/build/commands/git/create.js +0 -1
  37. package/build/commands/git/force-pull.js +0 -1
  38. package/build/commands/git/get.js +0 -1
  39. package/build/commands/git/git.js +0 -1
  40. package/build/commands/git/install-scripts.js +0 -1
  41. package/build/commands/git/rebase.js +0 -1
  42. package/build/commands/git/rename.js +0 -1
  43. package/build/commands/git/reset.js +0 -1
  44. package/build/commands/git/squash.js +0 -1
  45. package/build/commands/git/undo.js +0 -1
  46. package/build/commands/git/update.js +0 -1
  47. package/build/commands/history.js +0 -1
  48. package/build/commands/lt.js +0 -1
  49. package/build/commands/mongodb/collection-export.js +35 -3
  50. package/build/commands/mongodb/mongodb.js +0 -1
  51. package/build/commands/mongodb/s3-restore.js +35 -3
  52. package/build/commands/npm/npm.js +0 -1
  53. package/build/commands/npm/reinit.js +0 -1
  54. package/build/commands/npm/update.js +0 -1
  55. package/build/commands/qdrant/delete.js +0 -1
  56. package/build/commands/qdrant/qdrant.js +0 -1
  57. package/build/commands/qdrant/stats.js +0 -1
  58. package/build/commands/redis/redis.js +0 -1
  59. package/build/commands/server/add-property.js +34 -5
  60. package/build/commands/server/create-secret.js +34 -2
  61. package/build/commands/server/create.js +41 -4
  62. package/build/commands/server/module.js +62 -27
  63. package/build/commands/server/object.js +30 -7
  64. package/build/commands/server/permissions.js +20 -7
  65. package/build/commands/server/server.js +0 -1
  66. package/build/commands/server/set-secrets.js +0 -1
  67. package/build/commands/server/test.js +7 -2
  68. package/build/commands/starter/chrome-extension.js +0 -1
  69. package/build/commands/starter/starter.js +0 -1
  70. package/build/commands/status.js +13 -2
  71. package/build/commands/templates/list.js +0 -1
  72. package/build/commands/templates/llm.js +0 -1
  73. package/build/commands/templates/templates.js +0 -1
  74. package/build/commands/tools/crypt.js +0 -1
  75. package/build/commands/tools/install-scripts.js +0 -1
  76. package/build/commands/tools/jwt-read.js +0 -1
  77. package/build/commands/tools/regex.js +34 -2
  78. package/build/commands/tools/sha256.js +0 -1
  79. package/build/commands/tools/tools.js +0 -1
  80. package/build/commands/typescript/create.js +0 -1
  81. package/build/commands/typescript/playground.js +0 -1
  82. package/build/commands/typescript/typescript.js +0 -1
  83. package/build/commands/update.js +0 -1
  84. package/build/config/vendor-runtime-deps.json +9 -0
  85. package/build/extensions/api-mode.js +19 -4
  86. package/build/extensions/config.js +35 -3
  87. package/build/extensions/frontend-helper.js +0 -1
  88. package/build/extensions/git.js +0 -1
  89. package/build/extensions/history.js +0 -1
  90. package/build/extensions/logger.js +0 -1
  91. package/build/extensions/package-manager.js +0 -1
  92. package/build/extensions/parse-properties.js +0 -1
  93. package/build/extensions/server.js +1095 -6
  94. package/build/extensions/template.js +0 -1
  95. package/build/extensions/tools.js +0 -1
  96. package/build/extensions/typescript.js +35 -3
  97. package/build/interfaces/ServerProps.interface.js +0 -1
  98. package/build/interfaces/extended-gluegun-command.js +0 -1
  99. package/build/interfaces/extended-gluegun-toolbox.js +0 -1
  100. package/build/interfaces/lt-config.interface.js +0 -1
  101. package/build/lib/claude-cli.js +0 -1
  102. package/build/lib/fallback-scanner.js +0 -1
  103. package/build/lib/framework-detection.js +167 -0
  104. package/build/lib/json-utils.js +0 -1
  105. package/build/lib/marketplace.js +0 -1
  106. package/build/lib/nuxt-base-components.js +40 -5
  107. package/build/lib/plugin-utils.js +0 -1
  108. package/build/lib/shell-config.js +0 -1
  109. package/build/lib/validation.js +0 -1
  110. package/build/templates/nest-server-module/inputs/template-create.input.ts.ejs +1 -1
  111. package/build/templates/nest-server-module/inputs/template.input.ts.ejs +1 -1
  112. package/build/templates/nest-server-module/outputs/template-fac-result.output.ts.ejs +1 -1
  113. package/build/templates/nest-server-module/template.controller.ts.ejs +1 -1
  114. package/build/templates/nest-server-module/template.model.ts.ejs +1 -1
  115. package/build/templates/nest-server-module/template.module.ts.ejs +1 -1
  116. package/build/templates/nest-server-module/template.resolver.ts.ejs +1 -1
  117. package/build/templates/nest-server-module/template.service.ts.ejs +1 -1
  118. package/build/templates/nest-server-object/template-create.input.ts.ejs +1 -1
  119. package/build/templates/nest-server-object/template.input.ts.ejs +1 -1
  120. package/build/templates/nest-server-object/template.object.ts.ejs +1 -1
  121. package/build/templates/nest-server-tests/tests.e2e-spec.ts.ejs +1 -1
  122. package/build/templates/vendor-scripts/check-vendor-freshness.mjs +131 -0
  123. package/build/templates/vendor-scripts/propose-upstream-pr.ts +269 -0
  124. package/build/templates/vendor-scripts/sync-from-upstream.ts +250 -0
  125. package/docs/commands.md +13 -0
  126. package/package.json +22 -13
  127. package/tsconfig.json +4 -2
@@ -1,4 +1,4 @@
1
- import { CoreInput, Restricted, RoleEnum, UnifiedField } from '@lenne.tech/nest-server';
1
+ import { CoreInput, Restricted, RoleEnum, UnifiedField } from '<%= props.frameworkImport %>';
2
2
  import { InputType } from '@nestjs/graphql';<%- props.imports %>
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { UnifiedField } from '@lenne.tech/nest-server';
1
+ import { UnifiedField } from '<%= props.frameworkImport %>';
2
2
  <% if (props.isGql) { %>
3
3
  import { ObjectType } from '@nestjs/graphql';
4
4
  <% } %>
@@ -1,4 +1,4 @@
1
- import { ApiCommonErrorResponses, FilterArgs, RoleEnum, Roles } from '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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 { CoreInput, Restricted, RoleEnum, UnifiedField } from '@lenne.tech/nest-server';
1
+ import { CoreInput, Restricted, RoleEnum, UnifiedField } from '<%= props.frameworkImport %>';
2
2
  import { InputType } from '@nestjs/graphql';<%- props.imports %>
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { CoreModel, Restricted, RoleEnum<% if (props.mappings.includes('mapClasses')) { %>, mapClasses<% } %>, UnifiedField } from '@lenne.tech/nest-server';
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 '@lenne.tech/nest-server';
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`);