@lenne.tech/cli 1.24.1 → 1.26.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.
@@ -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,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUnmodifiedTemplateName = isUnmodifiedTemplateName;
4
+ exports.renameUnmodifiedTemplatePackage = renameUnmodifiedTemplatePackage;
5
+ exports.setPackageName = setPackageName;
6
+ const path_1 = require("path");
7
+ const dev_identity_1 = require("./dev-identity");
8
+ /**
9
+ * package.json `name` values that are unchanged starter-template defaults.
10
+ *
11
+ * When a user clones a template manually (`git clone lenneTech/lt-monorepo
12
+ * my-project`) instead of running `lt fullstack init`, the `name` field
13
+ * stays at the template's default. That field is what
14
+ * `dev-identity#projectSlug` reads to derive `<slug>.localhost`, so every
15
+ * cloned project would collide on `https://lt-monorepo.localhost`.
16
+ *
17
+ * `lt fullstack init` rewrites this field already (see `setPackageName`);
18
+ * the detection here is the safety net for projects that bypassed init.
19
+ */
20
+ const UNMODIFIED_TEMPLATE_NAMES = new Set(['lt-monorepo']);
21
+ /**
22
+ * True when `name` matches a known unmodified starter template default.
23
+ */
24
+ function isUnmodifiedTemplateName(name) {
25
+ return typeof name === 'string' && UNMODIFIED_TEMPLATE_NAMES.has(name);
26
+ }
27
+ /**
28
+ * If the package.json at `<projectRoot>/package.json` still carries an
29
+ * unmodified starter-template `name` (e.g. `lt-monorepo` from a raw
30
+ * `git clone`), rewrite it to the directory basename — which is what the
31
+ * user actually called their project when they cloned the folder.
32
+ *
33
+ * Returns the new name if a rewrite happened, `null` otherwise. Reasons
34
+ * for `null`: missing/unreadable package.json, name already custom, or the
35
+ * directory basename itself is in the deny list (pathological case of a
36
+ * fresh clone into a literal `lt-monorepo` folder — leaving the file
37
+ * untouched is correct behaviour there).
38
+ *
39
+ * Idempotent — safe to call from every `lt dev init` invocation.
40
+ */
41
+ function renameUnmodifiedTemplatePackage(options) {
42
+ const { filesystem, projectRoot } = options;
43
+ const packageJsonPath = filesystem.path(projectRoot, 'package.json');
44
+ if (!filesystem.exists(packageJsonPath))
45
+ return null;
46
+ const pkg = filesystem.read(packageJsonPath, 'json');
47
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
48
+ return null;
49
+ const currentName = typeof pkg.name === 'string' ? pkg.name : null;
50
+ if (!isUnmodifiedTemplateName(currentName))
51
+ return null;
52
+ // Slugify the directory basename: npm names must be lowercase and
53
+ // URL-safe, and this keeps the rewritten value consistent with what
54
+ // `lt fullstack init` writes (which kebab-cases its --name arg) and
55
+ // with `dev-identity#projectSlug` (which slugifies whatever it reads
56
+ // back). Anything else would produce a slug mismatch between
57
+ // package.json and `<slug>.localhost`.
58
+ const derived = (0, dev_identity_1.slugify)((0, path_1.basename)(projectRoot));
59
+ if (!derived || isUnmodifiedTemplateName(derived))
60
+ return null;
61
+ const written = setPackageName({ filesystem, name: derived, packageJsonPath });
62
+ return written ? derived : null;
63
+ }
64
+ /**
65
+ * Set the `name` field of a package.json on disk.
66
+ *
67
+ * Used by `lt fullstack init` to rename the cloned monorepo's root package
68
+ * so each project gets a unique `lt dev` slug (the slug is derived from
69
+ * package.json `name`; without a rename every lt-monorepo-based project would
70
+ * register as `lt-monorepo` and collide on `https://lt-monorepo.localhost`).
71
+ *
72
+ * IMPORTANT: this reads/writes the file as parsed JSON rather than running a
73
+ * string regex through `patching.update`. Gluegun's `patching.update` hands
74
+ * the callback a *parsed object* for any `.json` file, so a String-based
75
+ * `content.replace(...)` callback throws `content.replace is not a function`
76
+ * at runtime. Going through parsed JSON here is both correct and robust: it
77
+ * adds a `name` field if one is missing instead of silently no-op'ing.
78
+ *
79
+ * Idempotent: if the name already equals `name`, the file is left untouched
80
+ * and the function returns false.
81
+ *
82
+ * @param options.filesystem Gluegun filesystem tool
83
+ * @param options.name New value for the `name` field
84
+ * @param options.packageJsonPath Absolute path to the package.json
85
+ * @returns true if the file was written, false otherwise (missing/unreadable/unchanged)
86
+ */
87
+ function setPackageName(options) {
88
+ const { filesystem, name, packageJsonPath } = options;
89
+ if (!filesystem.exists(packageJsonPath))
90
+ return false;
91
+ const pkg = filesystem.read(packageJsonPath, 'json');
92
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
93
+ return false;
94
+ if (pkg.name === name)
95
+ return false;
96
+ pkg.name = name;
97
+ filesystem.write(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
98
+ return true;
99
+ }
package/docs/commands.md CHANGED
@@ -312,10 +312,13 @@ One-time per-machine setup. Idempotent — re-run anytime to diagnose what's mis
312
312
  **Usage:**
313
313
  ```bash
314
314
  lt dev install
315
+ lt dev install --skip-init # do NOT auto-run `lt dev init` afterwards
315
316
  ```
316
317
 
317
318
  **Alias:** `lt d i`
318
319
 
320
+ **Auto-chaining:** when run from inside an lt-dev-capable project that is not yet initialized, `lt dev install` runs `lt dev init` for that project **afterwards**. Pass `--skip-init` to opt out. This is one hop deep and never recurses — `install` calls the init *helper*, not the init command.
321
+
319
322
  **What it does:**
320
323
  1. Verifies `caddy` is on PATH (suggests `brew install caddy` if missing).
321
324
  2. Creates `~/.lenneTech/Caddyfile` stub if absent.
@@ -355,16 +358,19 @@ lt dev uninstall --noConfirm # skip the purge prompt (keep files)
355
358
 
356
359
  ---
357
360
 
358
- ### `lt dev migrate`
361
+ ### `lt dev init`
359
362
 
360
- Register an existing project with `lt dev` and apply idempotent env-aware patches. Safe to run multiple times; safe to run after `lt fullstack init`.
363
+ Initialize an existing project for `lt dev` and apply idempotent env-aware patches. Safe to run multiple times; safe to run after `lt fullstack init`.
361
364
 
362
365
  **Usage:**
363
366
  ```bash
364
- lt dev migrate
367
+ lt dev init
368
+ lt dev init --skip-install # do NOT auto-run `lt dev install` first
365
369
  ```
366
370
 
367
- **Alias:** `lt d m`
371
+ **Alias:** `lt d init`, `lt d migrate`, `lt d m` (`migrate` is the former name, kept for backwards compatibility)
372
+
373
+ **Auto-chaining:** if the machine has not been prepared yet (no `lt dev install` has run), `lt dev init` runs the install step **first**, then initializes the project. Pass `--skip-install` to opt out. This is one hop deep and never recurses — `init` calls the install *helper*, not the install command.
368
374
 
369
375
  **What it does:**
370
376
  1. Detects the workspace layout (monorepo `projects/api`+`projects/app`, or standalone).
@@ -538,7 +544,7 @@ lt dev test -- --ui spec.ts # everything after `--` is forwarded to playwri
538
544
  | `LT_DEV_ACTIVE`, `LT_DEV_DB_NAME` | Marker keys for consumers |
539
545
  | `NODE_EXTRA_CA_CERTS` | Path to Caddy's root CA cert (auto-detected) |
540
546
 
541
- `lt dev migrate` injects a tiny `// >>> lt-dev:bridge >>>` block at the top of `playwright.config.ts` that loads this file at config-load time — making Playwright (CLI, IDE, VS Code extension) automatically use the `lt dev` URLs and trust the local CA, without inheriting the parent shell.
547
+ `lt dev init` injects a tiny `// >>> lt-dev:bridge >>>` block at the top of `playwright.config.ts` that loads this file at config-load time — making Playwright (CLI, IDE, VS Code extension) automatically use the `lt dev` URLs and trust the local CA, without inheriting the parent shell.
542
548
 
543
549
  `lt dev down` removes the bridge file so subsequent runs without `lt dev up` fall back cleanly to the classic `localhost:3000`/`localhost:3001` defaults.
544
550
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.24.1",
3
+ "version": "1.26.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",
@@ -32,6 +32,8 @@
32
32
  "test:vendor-init": "bash scripts/test-vendor-init.sh",
33
33
  "test:frontend-vendor-init": "bash scripts/test-frontend-vendor-init.sh",
34
34
  "test:incremental-fullstack": "bash scripts/test-incremental-fullstack.sh",
35
+ "test:manual": "jest --testMatch '<rootDir>/*.manual.ts' --testTimeout=60000",
36
+ "test:e2e:service": "jest --testMatch '<rootDir>/dev-service-e2e.manual.ts' --testTimeout=60000",
35
37
  "format": "prettier --write 'src/**/*.{js,ts,tsx,json}' '!src/templates/**/*'",
36
38
  "lint": "eslint './src/**/*.{ts,js,vue}'",
37
39
  "lint:fix": "eslint './src/**/*.{ts,js,vue}' --fix",
@@ -58,9 +60,9 @@
58
60
  "bin"
59
61
  ],
60
62
  "dependencies": {
61
- "@aws-sdk/client-s3": "3.1045.0",
63
+ "@aws-sdk/client-s3": "3.1053.0",
62
64
  "@lenne.tech/cli-plugin-helper": "0.0.14",
63
- "axios": "1.16.0",
65
+ "axios": "1.16.1",
64
66
  "bcrypt": "6.0.0",
65
67
  "defuddle": "0.18.1",
66
68
  "glob": "13.0.6",
@@ -70,7 +72,7 @@
70
72
  "jsdom": "29.1.1",
71
73
  "lodash": "4.18.1",
72
74
  "open": "11.0.0",
73
- "playwright-core": "1.59.1",
75
+ "playwright-core": "1.60.0",
74
76
  "ts-morph": "28.0.0",
75
77
  "ts-node": "10.9.2",
76
78
  "turndown": "7.2.4",
@@ -85,7 +87,7 @@
85
87
  "@types/js-yaml": "4.0.9",
86
88
  "@types/jsdom": "28.0.1",
87
89
  "@types/lodash": "4.17.24",
88
- "@types/node": "25.6.2",
90
+ "@types/node": "25.9.1",
89
91
  "@types/turndown": "5.0.6",
90
92
  "ejs": "5.0.2",
91
93
  "eslint": "9.39.4",
@@ -94,18 +96,23 @@
94
96
  "prettier": "3.8.3",
95
97
  "rimraf": "6.1.3",
96
98
  "standard-version": "9.5.0",
97
- "ts-jest": "29.4.9"
99
+ "ts-jest": "29.4.11"
98
100
  },
99
101
  "//overrides": {
100
- "semver@*": "Force latest semver across all sub-deps; gluegun@5.2.2 pins semver@7.7.0 which is stale - remove once gluegun updates its dep."
102
+ "brace-expansion@5.0.2 - 5.0.5": "Security fix: GHSA-jxxr-4gwj-5jf2 (large numeric range defeats max DoS protection) in brace-expansion 5.0.2-5.0.5 - transitive via minimatch under glob, @ts-morph/common, @typescript-eslint/typescript-estree. Remove once those parents resolve minimatch to a brace-expansion >=5.0.6.",
103
+ "semver@*": "Force latest semver 7.x across all sub-deps; gluegun@5.2.2 pins semver@7.7.0 which is stale - remove once gluegun updates its dep."
101
104
  },
102
105
  "overrides": {
103
- "semver@*": "7.7.4"
106
+ "brace-expansion@5.0.2 - 5.0.5": "5.0.6",
107
+ "semver@*": "7.8.1"
104
108
  },
105
109
  "jest": {
106
110
  "testEnvironment": "node",
107
111
  "rootDir": "__tests__",
108
112
  "testTimeout": 60000,
113
+ "testMatch": [
114
+ "<rootDir>/*.test.ts"
115
+ ],
109
116
  "transform": {
110
117
  "^.+\\.tsx?$": [
111
118
  "ts-jest",