@lenne.tech/cli 1.24.0 → 1.25.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.
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.runInstall = runInstall;
13
+ const caddy_1 = require("./caddy");
14
+ const dev_service_1 = require("./dev-service");
15
+ /**
16
+ * Run the one-time per-machine `lt dev` setup. Idempotent — safe to
17
+ * re-run. When `opts.auto` is set the heading reflects that it was
18
+ * triggered by another command (e.g. `lt dev init`).
19
+ */
20
+ function runInstall(toolbox_1) {
21
+ return __awaiter(this, arguments, void 0, function* (toolbox, opts = {}) {
22
+ const { print: { colors, error, info, success, warning }, } = toolbox;
23
+ info('');
24
+ info(colors.bold(opts.auto ? 'Preparing this machine for lt dev (lt dev install)' : 'lt dev install — one-time per-machine setup'));
25
+ info(colors.dim('─'.repeat(60)));
26
+ const plat = (0, dev_service_1.platformSupported)();
27
+ if (plat === 'unsupported') {
28
+ error(`Service management is not supported on ${process.platform}. Only macOS and Linux are covered.`);
29
+ info(` Workaround: run \`${colors.cyan(`caddy run --config ${caddy_1.paths.caddyfile}`)}\` manually.`);
30
+ return { blocked: true, caddyMissing: false, ok: false, unsupported: true };
31
+ }
32
+ let blocked = false;
33
+ // 1. caddy on PATH
34
+ const hasCaddy = yield (0, caddy_1.caddyAvailable)();
35
+ if (hasCaddy) {
36
+ success('caddy is on PATH');
37
+ }
38
+ else {
39
+ warning('caddy is not installed.');
40
+ info(` → macOS: ${colors.cyan('brew install caddy')}`);
41
+ info(` → Linux: ${colors.cyan('https://caddyserver.com/docs/install')}`);
42
+ info(' (Do NOT start it via `brew services` — `lt dev install` runs its own service.)');
43
+ blocked = true;
44
+ }
45
+ // 2. Caddyfile stub
46
+ (0, caddy_1.writeCaddyfile)('# lt dev — managed Caddyfile\n# Add per-project blocks via `lt dev up`.\n');
47
+ success(`Caddyfile present at ${caddy_1.paths.caddyfile}`);
48
+ if (!hasCaddy) {
49
+ info('');
50
+ error('Cannot continue setup until Caddy is installed. Re-run `lt dev install` afterwards.');
51
+ return { blocked: true, caddyMissing: true, ok: false, unsupported: false };
52
+ }
53
+ // 3. brew services conflict warning
54
+ const brewConflict = yield detectBrewCaddyConflict();
55
+ if (brewConflict) {
56
+ warning('A `brew services caddy` instance is registered.');
57
+ info(` Stop it (it crash-loops against our Caddyfile): ${colors.cyan('brew services stop caddy')}`);
58
+ info(' `lt dev install` runs its own service — the brew one is no longer needed.');
59
+ }
60
+ // 4. Install our LaunchAgent / systemd unit
61
+ const paths = (0, dev_service_1.getServicePaths)();
62
+ info('');
63
+ info(`Installing ${plat === 'darwin' ? 'LaunchAgent' : 'systemd-user unit'} at:`);
64
+ info(colors.dim(` ${paths.unitFile}`));
65
+ const installResult = yield (0, dev_service_1.installService)();
66
+ if (!installResult.ok) {
67
+ error(installResult.message);
68
+ blocked = true;
69
+ }
70
+ else if (installResult.created) {
71
+ success(installResult.message);
72
+ }
73
+ else {
74
+ info(colors.dim(installResult.message));
75
+ }
76
+ // 5. Wait for admin endpoint
77
+ if (installResult.ok) {
78
+ info(colors.dim('Waiting for Caddy admin endpoint (:2019) ...'));
79
+ const ready = yield (0, dev_service_1.waitForServiceReady)(8000);
80
+ const status = yield (0, dev_service_1.getServiceStatus)();
81
+ if (ready && status.daemonReachable) {
82
+ success(`Caddy daemon ready${status.pid ? ` (pid ${status.pid})` : ''}.`);
83
+ }
84
+ else if (status.loaded && !status.daemonReachable) {
85
+ warning('Service is loaded but admin endpoint did not respond within 8s.');
86
+ info(colors.dim(` Logs: ${paths.logFile} / ${paths.errFile}`));
87
+ blocked = true;
88
+ }
89
+ else {
90
+ warning('Caddy daemon did not start. See logs:');
91
+ info(colors.dim(` ${paths.logFile}`));
92
+ info(colors.dim(` ${paths.errFile}`));
93
+ blocked = true;
94
+ }
95
+ }
96
+ // 6. Validate Caddyfile
97
+ if (installResult.ok) {
98
+ const validation = yield (0, caddy_1.validateCaddyfile)();
99
+ if (validation.ok)
100
+ success('Caddyfile validates');
101
+ else
102
+ warning(`Caddyfile validation: ${validation.stderr.split('\n').slice(0, 2).join(' / ')}`);
103
+ }
104
+ // 7. CA trust
105
+ info('');
106
+ info(colors.bold('Local CA trust'));
107
+ info(' Caddy creates its local CA on first run. To trust it system-wide,');
108
+ info(' run this once (HOME must be preserved so sudo keeps the user-scoped');
109
+ info(' CA, otherwise caddy looks in /var/root and fails):');
110
+ info(` ${colors.cyan('sudo -E HOME="$HOME" caddy trust')}`);
111
+ info(` Browsers will then accept ${colors.cyan('https://*.localhost')} without warnings.`);
112
+ return { blocked, caddyMissing: false, ok: installResult.ok, unsupported: false };
113
+ });
114
+ }
115
+ /**
116
+ * Quick `brew services list` scan for a registered caddy service.
117
+ * Returns true on macOS if any entry contains "caddy" — error/started
118
+ * alike, both are conflicts. Always returns false on non-darwin or
119
+ * when `brew` is unavailable (no false positives).
120
+ */
121
+ function detectBrewCaddyConflict() {
122
+ if (process.platform !== 'darwin')
123
+ return Promise.resolve(false);
124
+ return new Promise((resolve) => {
125
+ var _a;
126
+ const { spawn } = require('child_process');
127
+ const child = spawn('brew', ['services', 'list'], { stdio: ['ignore', 'pipe', 'ignore'] });
128
+ let out = '';
129
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (out += String(b)));
130
+ child.on('error', () => resolve(false));
131
+ child.on('close', () => {
132
+ const conflict = /\bcaddy\b/.test(out) && !/^caddy\s+none\b/m.test(out);
133
+ resolve(conflict);
134
+ });
135
+ });
136
+ }
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.printMigrateResult = printMigrateResult;
3
4
  exports.runMigrate = runMigrate;
4
5
  /**
5
- * Reusable migrate logic — used by `commands/dev/migrate.ts` (interactive
6
- * + verbose) and `commands/fullstack/init.ts` (silent best-effort after
7
- * project creation).
6
+ * Reusable init/migrate logic — used by `commands/dev/init.ts` (interactive
7
+ * + verbose, also chained from `commands/dev/install.ts`) and
8
+ * `commands/fullstack/init.ts` (silent best-effort after project creation).
8
9
  *
9
10
  * Returns a structured result so callers can decide what to print.
10
11
  * Idempotent — safe to run multiple times.
@@ -15,6 +16,43 @@ const dev_identity_1 = require("./dev-identity");
15
16
  const dev_patches_1 = require("./dev-patches");
16
17
  const dev_project_1 = require("./dev-project");
17
18
  const dev_state_1 = require("./dev-state");
19
+ /**
20
+ * Print a `runMigrate` result via the toolbox. Shared by `lt dev init`
21
+ * and the auto-init step of `lt dev install` so both render identically.
22
+ * Does NOT print Caddy-install hints — chaining handles that separately.
23
+ */
24
+ function printMigrateResult(toolbox, result) {
25
+ const { print: { colors, info, success }, } = toolbox;
26
+ info('');
27
+ info(colors.bold(`Initializing "${result.identity.slug}" for lt dev`));
28
+ info(colors.dim('─'.repeat(60)));
29
+ if (result.identity.subdomains.app)
30
+ info(` App URL: https://${result.identity.subdomains.app.hostname}`);
31
+ if (result.identity.subdomains.api)
32
+ info(` API URL: https://${result.identity.subdomains.api.hostname}`);
33
+ info(` DB: mongodb://127.0.0.1/${result.dbName}`);
34
+ info('');
35
+ if (result.codePatches.length > 0) {
36
+ for (const r of result.codePatches) {
37
+ if (r.patched)
38
+ success(`patched ${r.replacements}× in ${r.file}`);
39
+ else
40
+ info(colors.dim(`already patched: ${r.file}`));
41
+ }
42
+ }
43
+ else {
44
+ info(colors.dim(' patches: not needed (already env-aware)'));
45
+ }
46
+ result.claudePatches.filter((r) => r.patched).forEach((r) => success(`updated CLAUDE.md URL block: ${r.file}`));
47
+ if (result.registryUpdated) {
48
+ success(`registered in ${process.env.LT_DEV_REGISTRY_PATH || '~/.lenneTech/projects.json'}`);
49
+ }
50
+ if (result.addedGitignoreEntry)
51
+ success('added `.lt-dev/` to .gitignore');
52
+ if (result.alreadyMigrated) {
53
+ info(colors.dim(' Project was already initialized — nothing changed.'));
54
+ }
55
+ }
18
56
  /**
19
57
  * Run all migration steps for a resolved project.
20
58
  *
@@ -7,7 +7,7 @@ exports.patchClaudeMd = patchClaudeMd;
7
7
  exports.patchNuxtConfig = patchNuxtConfig;
8
8
  exports.patchPlaywrightConfig = patchPlaywrightConfig;
9
9
  /**
10
- * Idempotent patches applied by `lt dev migrate`.
10
+ * Idempotent patches applied by `lt dev init`.
11
11
  *
12
12
  * Goal: take a project that still has hardcoded `localhost:3000`
13
13
  * defaults and make it env-aware so it can be served behind Caddy
@@ -40,16 +40,32 @@ function autoPatch(file) {
40
40
  return patchPlaywrightConfig(file);
41
41
  return { file, patched: false, replacements: 0 };
42
42
  }
43
- /** API: `port: 3000,` → `port: Number(process.env.PORT) || 3000,`. */
43
+ /**
44
+ * API: make the server listen port honour `process.env.PORT` (injected by
45
+ * `lt dev up` for its Caddy upstream). Handles two patterns found in
46
+ * nest-server `config.env.ts` files:
47
+ *
48
+ * - the legacy literal `port: 3000,` (e.g. in `deployedConfig()`)
49
+ * - the offers-pattern `port: process.env.NSC__PORT ? parseInt(process.env.NSC__PORT, 10) : 3000`
50
+ * found in `localConfig()` — `lt dev` runs the API in local mode, so this
51
+ * line MUST be patched too or the API ignores the assigned port.
52
+ *
53
+ * Idempotent — lines that already read `process.env.PORT` are left untouched.
54
+ */
44
55
  function patchApiConfig(file) {
45
56
  if (!(0, fs_1.existsSync)(file))
46
57
  return { file, patched: false, replacements: 0 };
47
58
  const before = (0, fs_1.readFileSync)(file, 'utf8');
48
59
  let count = 0;
49
- const after = before.replace(/^(\s*)port:\s*3000\s*,$/gm, (_m, indent) => {
60
+ let after = before.replace(/^(\s*)port:\s*3000\s*,$/gm, (_m, indent) => {
50
61
  count++;
51
62
  return `${indent}port: Number(process.env.PORT) || 3000,`;
52
63
  });
64
+ // localConfig() keeps the NSC__PORT operator override; PORT (lt dev) wins.
65
+ after = after.replace(/^(\s*)port:\s*process\.env\.NSC__PORT\s*\?[^\n]*:\s*3000\s*,$/gm, (_m, indent) => {
66
+ count++;
67
+ return `${indent}port: Number(process.env.PORT) || (process.env.NSC__PORT ? parseInt(process.env.NSC__PORT, 10) : 3000),`;
68
+ });
53
69
  if (count === 0)
54
70
  return { file, patched: false, replacements: 0 };
55
71
  (0, fs_1.writeFileSync)(file, after, 'utf8');
@@ -69,6 +85,7 @@ function patchClaudeMd(file, options) {
69
85
  const appSub = identity.subdomains.app;
70
86
  const lines = [
71
87
  startMarker,
88
+ '',
72
89
  '## Local Development (lt dev)',
73
90
  '',
74
91
  `This project is registered with \`lt dev\` (slug: \`${identity.slug}\`). Use these commands to run alongside other lt projects without cross-wiring or port collisions:`,
@@ -77,9 +94,12 @@ function patchClaudeMd(file, options) {
77
94
  'lt dev up # Start API + App behind Caddy with project-specific URLs',
78
95
  'lt dev down # Stop the detached processes + remove Caddy block',
79
96
  'lt dev status # Show running PIDs + bound URLs',
97
+ 'lt dev test # Ensure up + run the E2E suite with project URLs injected',
80
98
  'lt dev doctor # Diagnose Caddy/CA/DNS/port issues',
81
99
  '```',
82
100
  '',
101
+ '**Start and test local apps via `lt dev`** — never `pnpm dev` / `pnpm start` / a bare `playwright test` directly; those bind the framework default ports (3000/3001) and collide with parallel projects.',
102
+ '',
83
103
  '**Active URLs for THIS project:**',
84
104
  '',
85
105
  ];
@@ -88,7 +108,7 @@ function patchClaudeMd(file, options) {
88
108
  if (apiSub)
89
109
  lines.push(`- API: \`https://${apiSub.hostname}\``);
90
110
  if (dbName)
91
- lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
111
+ lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
92
112
  lines.push('');
93
113
  lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
94
114
  lines.push('');
@@ -159,30 +179,52 @@ function patchPlaywrightConfig(file) {
159
179
  return `${key}: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`;
160
180
  });
161
181
  }
162
- // 2. Top-of-file dotenv bridge — only inject if not already present.
182
+ // 2. Top-of-file dotenv bridge — inject it, or replace an outdated block.
183
+ // The loader walks UP from cwd to find `.lt-dev/.env`: that file lives
184
+ // at the repo root, while playwright.config.ts (and the process cwd of
185
+ // a direct `playwright test` run) usually sit in `projects/app`. The
186
+ // original cwd-only resolve missed it, so direct runs fell back to
187
+ // `localhost:3001` and could collide with a parallel project.
163
188
  const bridgeStart = '// >>> lt-dev:bridge >>>';
164
189
  const bridgeEnd = '// <<< lt-dev:bridge <<<';
165
- if (!after.includes(bridgeStart)) {
166
- const bridgeBlock = [
167
- bridgeStart,
168
- '// Auto-load <root>/.lt-dev/.env when `lt dev up` is active so',
169
- '// external test runners (CLI, IDE, VS Code Playwright Extension)',
170
- '// pick up project URLs + Caddy CA without inheriting the parent shell.',
171
- "import { existsSync as __ltDevExists, readFileSync as __ltDevRead } from 'node:fs';",
172
- "import { resolve as __ltDevResolve } from 'node:path';",
173
- "const __ltDevEnvFile = __ltDevResolve(process.cwd(), '.lt-dev/.env');",
174
- 'if (__ltDevExists(__ltDevEnvFile)) {',
175
- ' for (const __ln of __ltDevRead(__ltDevEnvFile, "utf8").split(/\\r?\\n/)) {',
176
- ' const __m = __ln.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);',
177
- ' if (__m && process.env[__m[1]] === undefined) process.env[__m[1]] = __m[2];',
178
- ' }',
179
- '}',
180
- bridgeEnd,
181
- '',
182
- ].join('\n');
183
- after = bridgeBlock + after;
190
+ const bridgeBlock = [
191
+ bridgeStart,
192
+ '// Auto-load <root>/.lt-dev/.env when `lt dev up` is active so',
193
+ '// external test runners (CLI, IDE, VS Code Playwright Extension)',
194
+ '// pick up project URLs + Caddy CA without inheriting the parent shell.',
195
+ '// Searches upward from cwd because `.lt-dev/` sits at the repo root',
196
+ '// while playwright.config.ts (and cwd) usually sit in projects/app.',
197
+ "import { existsSync as __ltDevExists, readFileSync as __ltDevRead } from 'node:fs';",
198
+ "import { dirname as __ltDevDirname, resolve as __ltDevResolve } from 'node:path';",
199
+ "let __ltDevEnvFile = '';",
200
+ 'for (let __ltDevDir = process.cwd(), __i = 0; __i < 6; __i++) {',
201
+ " const __candidate = __ltDevResolve(__ltDevDir, '.lt-dev/.env');",
202
+ ' if (__ltDevExists(__candidate)) { __ltDevEnvFile = __candidate; break; }',
203
+ ' const __parent = __ltDevDirname(__ltDevDir);',
204
+ ' if (__parent === __ltDevDir) break;',
205
+ ' __ltDevDir = __parent;',
206
+ '}',
207
+ 'if (__ltDevEnvFile) {',
208
+ ' for (const __ln of __ltDevRead(__ltDevEnvFile, "utf8").split(/\\r?\\n/)) {',
209
+ ' const __m = __ln.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);',
210
+ ' if (__m && process.env[__m[1]] === undefined) process.env[__m[1]] = __m[2];',
211
+ ' }',
212
+ '}',
213
+ bridgeEnd,
214
+ ].join('\n');
215
+ const bridgeStartIdx = after.indexOf(bridgeStart);
216
+ const bridgeEndIdx = after.indexOf(bridgeEnd);
217
+ if (bridgeStartIdx === -1) {
218
+ after = `${bridgeBlock}\n${after}`;
184
219
  count++;
185
220
  }
221
+ else if (bridgeEndIdx !== -1) {
222
+ const rebuilt = after.slice(0, bridgeStartIdx) + bridgeBlock + after.slice(bridgeEndIdx + bridgeEnd.length);
223
+ if (rebuilt !== after) {
224
+ after = rebuilt;
225
+ count++;
226
+ }
227
+ }
186
228
  if (count === 0)
187
229
  return { file, patched: false, replacements: 0 };
188
230
  (0, fs_1.writeFileSync)(file, after, 'utf8');
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
4
+ const fs_1 = require("fs");
5
+ const js_yaml_1 = require("js-yaml");
4
6
  /**
5
7
  * pnpm workspace-scoped fields that must live at the workspace root.
6
8
  * When present in sub-project package.json files, pnpm emits:
@@ -15,6 +17,16 @@ exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
15
17
  * and the actual dependency-resolution behavior.
16
18
  */
17
19
  const WORKSPACE_SCOPED_PNPM_FIELDS = ['overrides', 'onlyBuiltDependencies', 'ignoredOptionalDependencies'];
20
+ /**
21
+ * pnpm 11 renamed `onlyBuiltDependencies` (string array) to `allowBuilds`
22
+ * (a `{ pkg: boolean }` map). A migrated sub-project pnpm-workspace.yaml
23
+ * usually carries BOTH for cross-version compatibility, but we must not
24
+ * rely on the array twin always being present: we normalise `allowBuilds`
25
+ * back into `onlyBuiltDependencies` before hoisting (see
26
+ * `normalizeAllowBuilds`) so the build-allowlist survives into the pnpm-10
27
+ * monorepo root even when the file only carries the pnpm-11 object form.
28
+ */
29
+ const PNPM11_BUILD_KEY = 'allowBuilds';
18
30
  /**
19
31
  * Hoist workspace-scoped pnpm config from sub-projects into the root
20
32
  * package.json. After this runs, sub-project package.json files no
@@ -22,6 +34,27 @@ const WORKSPACE_SCOPED_PNPM_FIELDS = ['overrides', 'onlyBuiltDependencies', 'ign
22
34
  * `ignoredOptionalDependencies`, and the root package.json contains
23
35
  * the merged union.
24
36
  *
37
+ * Two sources are read per sub-project, because the two starters store
38
+ * their pnpm config differently:
39
+ *
40
+ * 1. `<sub>/package.json` `pnpm` block — nest-server-starter, and
41
+ * nuxt-base-template before its pnpm-11 migration.
42
+ * 2. `<sub>/pnpm-workspace.yaml` — nuxt-base-template after the
43
+ * migration. pnpm 11 silently ignores the `pnpm` block in
44
+ * package.json, so the template moved overrides into
45
+ * pnpm-workspace.yaml. Inside a monorepo that nested file would
46
+ * (a) not be hoisted by the old package.json-only logic, regressing
47
+ * the CVE overrides, and (b) declare a nested workspace root that
48
+ * conflicts with the monorepo's own pnpm-workspace.yaml. We hoist
49
+ * its fields into the root package.json (the lt-monorepo root pins
50
+ * pnpm@10 via `packageManager`, where package.json#pnpm IS honored)
51
+ * and remove the now-redundant nested file.
52
+ *
53
+ * Symlinked sub-projects are skipped entirely: in `--frontend-link` /
54
+ * `--api-link` mode `projects/app` (or `projects/api`) points at the
55
+ * user's local framework checkout, and stripping its config or deleting
56
+ * its pnpm-workspace.yaml would corrupt that source repo.
57
+ *
25
58
  * Idempotent: running twice has the same effect as running once.
26
59
  *
27
60
  * @param options.filesystem Gluegun filesystem tool
@@ -38,36 +71,106 @@ function hoistWorkspacePnpmConfig(options) {
38
71
  if (!rootPkg)
39
72
  return;
40
73
  (_a = rootPkg.pnpm) !== null && _a !== void 0 ? _a : (rootPkg.pnpm = {});
74
+ const rootPnpm = rootPkg.pnpm;
41
75
  let rootChanged = false;
42
76
  for (const subDir of subProjects) {
43
- const subPkgPath = `${projectDir}/${subDir}/package.json`;
44
- if (!filesystem.exists(subPkgPath))
77
+ const subPath = `${projectDir}/${subDir}`;
78
+ if (!filesystem.exists(subPath))
45
79
  continue;
46
- const subPkg = filesystem.read(subPkgPath, 'json');
47
- if (!(subPkg === null || subPkg === void 0 ? void 0 : subPkg.pnpm))
80
+ // Never mutate a symlinked sub-project — it points at the user's own
81
+ // checkout in link mode.
82
+ if (isSymlink(subPath))
48
83
  continue;
49
- let subChanged = false;
50
- for (const field of WORKSPACE_SCOPED_PNPM_FIELDS) {
51
- const subValue = subPkg.pnpm[field];
52
- if (subValue === undefined)
53
- continue;
54
- rootPkg.pnpm[field] = mergePnpmFieldValue(field, rootPkg.pnpm[field], subValue);
84
+ if (hoistFromSubPackageJson({ filesystem, rootPnpm, subPath })) {
55
85
  rootChanged = true;
56
- delete subPkg.pnpm[field];
57
- subChanged = true;
58
86
  }
59
- if (subChanged) {
60
- // If the sub-project's pnpm section is now empty, drop it entirely.
61
- if (subPkg.pnpm && Object.keys(subPkg.pnpm).length === 0) {
62
- delete subPkg.pnpm;
63
- }
64
- filesystem.write(subPkgPath, `${JSON.stringify(subPkg, null, 2)}\n`);
87
+ if (hoistFromSubWorkspaceYaml({ filesystem, rootPnpm, subPath })) {
88
+ rootChanged = true;
65
89
  }
66
90
  }
67
91
  if (rootChanged) {
68
92
  filesystem.write(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
69
93
  }
70
94
  }
95
+ /**
96
+ * Move the workspace-scoped pnpm fields from `source` into `rootPnpm`,
97
+ * deleting each moved field from `source`. Returns true if anything moved.
98
+ */
99
+ function hoistFields(rootPnpm, source) {
100
+ let changed = false;
101
+ for (const field of WORKSPACE_SCOPED_PNPM_FIELDS) {
102
+ if (source[field] === undefined)
103
+ continue;
104
+ rootPnpm[field] = mergePnpmFieldValue(field, rootPnpm[field], source[field]);
105
+ delete source[field];
106
+ changed = true;
107
+ }
108
+ return changed;
109
+ }
110
+ /** Source 1: the sub-project's package.json `pnpm` block. */
111
+ function hoistFromSubPackageJson(options) {
112
+ const { filesystem, rootPnpm, subPath } = options;
113
+ const subPkgPath = `${subPath}/package.json`;
114
+ if (!filesystem.exists(subPkgPath))
115
+ return false;
116
+ const subPkg = filesystem.read(subPkgPath, 'json');
117
+ if (!(subPkg === null || subPkg === void 0 ? void 0 : subPkg.pnpm))
118
+ return false;
119
+ if (!hoistFields(rootPnpm, subPkg.pnpm))
120
+ return false;
121
+ // If the sub-project's pnpm section is now empty, drop it entirely.
122
+ if (Object.keys(subPkg.pnpm).length === 0) {
123
+ delete subPkg.pnpm;
124
+ }
125
+ filesystem.write(subPkgPath, `${JSON.stringify(subPkg, null, 2)}\n`);
126
+ return true;
127
+ }
128
+ /** Source 2: the sub-project's pnpm-workspace.yaml (pnpm-11 layout). */
129
+ function hoistFromSubWorkspaceYaml(options) {
130
+ const { filesystem, rootPnpm, subPath } = options;
131
+ const subWsPath = `${subPath}/pnpm-workspace.yaml`;
132
+ if (!filesystem.exists(subWsPath))
133
+ return false;
134
+ const raw = filesystem.read(subWsPath);
135
+ if (!raw)
136
+ return false;
137
+ let parsed;
138
+ try {
139
+ parsed = (0, js_yaml_1.load)(raw);
140
+ }
141
+ catch (_a) {
142
+ // Malformed YAML — leave it untouched rather than risk data loss.
143
+ return false;
144
+ }
145
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
146
+ return false;
147
+ const ws = parsed;
148
+ // Fold the pnpm-11 `allowBuilds` map into `onlyBuiltDependencies` so it is
149
+ // hoisted rather than discarded — even when the array twin is absent.
150
+ normalizeAllowBuilds(ws);
151
+ if (!hoistFields(rootPnpm, ws))
152
+ return false;
153
+ // A settings-only file (no `packages:`) exists solely to carry these
154
+ // hoisted keys — once emptied it would only declare a nested workspace
155
+ // root, so remove it. A file that declares `packages:` is a real (rare)
156
+ // nested workspace; keep it minus the hoisted keys.
157
+ if (Array.isArray(ws.packages) && ws.packages.length > 0) {
158
+ filesystem.write(subWsPath, (0, js_yaml_1.dump)(ws));
159
+ }
160
+ else {
161
+ filesystem.remove(subWsPath);
162
+ }
163
+ return true;
164
+ }
165
+ /** Whether `path` is a symbolic link (false on any stat error). */
166
+ function isSymlink(path) {
167
+ try {
168
+ return (0, fs_1.lstatSync)(path).isSymbolicLink();
169
+ }
170
+ catch (_a) {
171
+ return false;
172
+ }
173
+ }
71
174
  /**
72
175
  * Merge two values for a pnpm workspace-scoped field.
73
176
  *
@@ -95,3 +198,26 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
95
198
  const merged = Object.assign(Object.assign({}, rootObj), subObj);
96
199
  return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
97
200
  }
201
+ /**
202
+ * Fold a pnpm-11 `allowBuilds: { pkg: boolean }` map into the pnpm-10
203
+ * `onlyBuiltDependencies: string[]` form (packages whose value is `true`),
204
+ * unioned with any existing array, then remove the `allowBuilds` key so the
205
+ * redundant object form does not linger. Mutates `ws` in place.
206
+ *
207
+ * No-op when `allowBuilds` is absent or not an object map — an unexpected
208
+ * shape is left untouched rather than risk silent data loss.
209
+ */
210
+ function normalizeAllowBuilds(ws) {
211
+ const raw = ws[PNPM11_BUILD_KEY];
212
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
213
+ return;
214
+ const allowed = Object.entries(raw)
215
+ .filter(([, enabled]) => enabled === true)
216
+ .map(([pkg]) => pkg);
217
+ if (allowed.length > 0) {
218
+ const existing = Array.isArray(ws.onlyBuiltDependencies) ? ws.onlyBuiltDependencies : [];
219
+ // Order here is irrelevant — mergePnpmFieldValue sorts the union on hoist.
220
+ ws.onlyBuiltDependencies = Array.from(new Set([...allowed, ...existing]));
221
+ }
222
+ delete ws[PNPM11_BUILD_KEY];
223
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setPackageName = setPackageName;
4
+ /**
5
+ * Set the `name` field of a package.json on disk.
6
+ *
7
+ * Used by `lt fullstack init` to rename the cloned monorepo's root package
8
+ * so each project gets a unique `lt dev` slug (the slug is derived from
9
+ * package.json `name`; without a rename every lt-monorepo-based project would
10
+ * register as `lt-monorepo` and collide on `https://lt-monorepo.localhost`).
11
+ *
12
+ * IMPORTANT: this reads/writes the file as parsed JSON rather than running a
13
+ * string regex through `patching.update`. Gluegun's `patching.update` hands
14
+ * the callback a *parsed object* for any `.json` file, so a String-based
15
+ * `content.replace(...)` callback throws `content.replace is not a function`
16
+ * at runtime. Going through parsed JSON here is both correct and robust: it
17
+ * adds a `name` field if one is missing instead of silently no-op'ing.
18
+ *
19
+ * Idempotent: if the name already equals `name`, the file is left untouched
20
+ * and the function returns false.
21
+ *
22
+ * @param options.filesystem Gluegun filesystem tool
23
+ * @param options.name New value for the `name` field
24
+ * @param options.packageJsonPath Absolute path to the package.json
25
+ * @returns true if the file was written, false otherwise (missing/unreadable/unchanged)
26
+ */
27
+ function setPackageName(options) {
28
+ const { filesystem, name, packageJsonPath } = options;
29
+ if (!filesystem.exists(packageJsonPath))
30
+ return false;
31
+ const pkg = filesystem.read(packageJsonPath, 'json');
32
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
33
+ return false;
34
+ if (pkg.name === name)
35
+ return false;
36
+ pkg.name = name;
37
+ filesystem.write(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
38
+ return true;
39
+ }
@@ -40,6 +40,7 @@ exports.detectSubProjectContext = detectSubProjectContext;
40
40
  exports.detectWorkspaceLayout = detectWorkspaceLayout;
41
41
  exports.findWorkspaceRoot = findWorkspaceRoot;
42
42
  exports.isNonInteractive = isNonInteractive;
43
+ exports.reconfigureUpstreamForDownstream = reconfigureUpstreamForDownstream;
43
44
  exports.runExperimentalNestBaseRename = runExperimentalNestBaseRename;
44
45
  exports.runStandaloneWorkspaceGate = runStandaloneWorkspaceGate;
45
46
  exports.shouldProceedAsStandalone = shouldProceedAsStandalone;
@@ -140,6 +141,60 @@ function isNonInteractive(noConfirmFlag) {
140
141
  // process.stdin may be undefined in some test runners — guard.
141
142
  return Boolean(process.stdin && process.stdin.isTTY === false);
142
143
  }
144
+ /**
145
+ * Reconfigure the cloned nest-base template's `.claude/upstream.json`
146
+ * into its downstream shape.
147
+ *
148
+ * WHY: nest-base ships `.claude/upstream.json` with `isTemplate: true`
149
+ * and `upstream: null` — the template-self default. The `/upstream-pr`
150
+ * slash command and the `contributing-upstream` skill key off
151
+ * `isTemplate` and refuse to open upstream PRs when it is still `true`
152
+ * ("this repo IS the template"). The template's own notes document a
153
+ * manual post-fork step (flip `isTemplate` to false, fill `upstream`)
154
+ * that humans and agents both forget. The `bun run rename` step only
155
+ * rewrites four files and never touches this one, so without this
156
+ * helper every scaffolded project keeps `isTemplate: true` and can
157
+ * never contribute core fixes back to nest-base.
158
+ *
159
+ * Behaviour:
160
+ * - Missing file (or unparseable JSON) → `{ updated: false }`, no
161
+ * throw. Older templates may not ship the file at all; that is not
162
+ * an error, just nothing to do.
163
+ * - Otherwise sets `isTemplate = false` and
164
+ * `upstream = { repo, branch }`, preserving `$schema` and
165
+ * `syncedPaths` exactly as the template defined them.
166
+ *
167
+ * Idempotent: running twice yields identical output.
168
+ */
169
+ function reconfigureUpstreamForDownstream(options) {
170
+ const { apiDir, filesystem, upstreamBranch, upstreamRepo } = options;
171
+ const upstreamPath = filesystem.path(apiDir, '.claude', 'upstream.json');
172
+ // Non-fatal when absent — older templates may not ship the file.
173
+ if (filesystem.exists(upstreamPath) !== 'file') {
174
+ return { updated: false };
175
+ }
176
+ const current = filesystem.read(upstreamPath, 'json');
177
+ if (!current) {
178
+ // Unparseable / empty JSON — leave it untouched rather than risk
179
+ // clobbering hand-edited content with a guessed shape.
180
+ return { updated: false };
181
+ }
182
+ const next = Object.assign(Object.assign({}, current), {
183
+ // The whole point of the fix: this is a fork, not the template.
184
+ isTemplate: false,
185
+ // Replace the template-self notes (no longer applicable) with a
186
+ // short downstream-appropriate marker. Kept as a fixed array so the
187
+ // operation is idempotent.
188
+ notes: [
189
+ 'Downstream project forked from the nest-base template.',
190
+ 'Core fixes flow back upstream via the /upstream-pr command.',
191
+ ], upstream: {
192
+ branch: upstreamBranch !== null && upstreamBranch !== void 0 ? upstreamBranch : 'main',
193
+ repo: upstreamRepo !== null && upstreamRepo !== void 0 ? upstreamRepo : 'lenneTech/nest-base',
194
+ } });
195
+ filesystem.write(upstreamPath, next, { jsonIndent: 2 });
196
+ return { updated: true };
197
+ }
143
198
  /**
144
199
  * Run the experimental `bun run rename <projectDir>` step. Only relevant
145
200
  * for the `--next` nest-base template (it ships hard-coded `nest-base`