@lenne.tech/cli 1.34.0 → 1.35.1

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 (42) hide show
  1. package/README.md +6 -5
  2. package/build/commands/config/validate.js +0 -4
  3. package/build/commands/deployment/create.js +218 -213
  4. package/build/commands/dev/init.js +2 -1
  5. package/build/commands/dev/status.js +2 -0
  6. package/build/commands/dev/test.js +2 -2
  7. package/build/commands/dev/up.js +8 -4
  8. package/build/commands/frontend/nuxt.js +6 -0
  9. package/build/commands/fullstack/add-api.js +6 -9
  10. package/build/commands/fullstack/add-app.js +5 -7
  11. package/build/commands/fullstack/init.js +8 -9
  12. package/build/commands/status.js +2 -0
  13. package/build/commands/ticket/list.js +2 -0
  14. package/build/extensions/server.js +140 -18
  15. package/build/lib/angular-environments.js +108 -0
  16. package/build/lib/dev-env.js +2 -2
  17. package/build/lib/dev-identity.js +38 -18
  18. package/build/lib/dev-project.js +20 -7
  19. package/build/lib/dev-state.js +17 -1
  20. package/build/lib/dev-test-session.js +31 -3
  21. package/build/lib/ensure-root-dockerignore.js +92 -0
  22. package/build/lib/fs-utils.js +19 -0
  23. package/build/lib/hoist-workspace-pnpm-config.js +2 -11
  24. package/build/lib/remove-nested-lockfiles.js +53 -0
  25. package/build/lib/turboops-config.js +93 -0
  26. package/build/lib/workspace-integration.js +96 -3
  27. package/build/templates/deployment/turboops.json.ejs +3 -0
  28. package/build/templates/vendor-scripts/migrate-store.js +25 -0
  29. package/docs/LT-ECOSYSTEM-GUIDE.md +1 -1
  30. package/docs/commands.md +30 -9
  31. package/docs/lt.config.md +9 -19
  32. package/package.json +1 -1
  33. package/build/templates/deployment/.github/workflows/pre-release.yml.ejs +0 -41
  34. package/build/templates/deployment/.github/workflows/release.yml.ejs +0 -41
  35. package/build/templates/deployment/.gitlab-ci.yml.ejs +0 -181
  36. package/build/templates/deployment/Dockerfile.app.ejs +0 -13
  37. package/build/templates/deployment/Dockerfile.ejs +0 -18
  38. package/build/templates/deployment/docker-compose.dev.yml.ejs +0 -99
  39. package/build/templates/deployment/docker-compose.prod.yml.ejs +0 -92
  40. package/build/templates/deployment/docker-compose.test.yml.ejs +0 -98
  41. package/build/templates/deployment/scripts/build-push.sh.ejs +0 -20
  42. package/build/templates/deployment/scripts/deploy.sh.ejs +0 -7
@@ -10,7 +10,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.help = void 0;
13
- const hoist_workspace_pnpm_config_1 = require("../../lib/hoist-workspace-pnpm-config");
14
13
  const workspace_integration_1 = require("../../lib/workspace-integration");
15
14
  /**
16
15
  * Add an API (`projects/api/`) to a fullstack workspace that currently
@@ -320,14 +319,12 @@ const NewCommand = {
320
319
  if (apiResult.method !== 'link') {
321
320
  (0, workspace_integration_1.writeApiConfig)({ apiDir: apiDest, apiMode, filesystem, frameworkMode });
322
321
  }
323
- // Hoist pnpm config `setupServerForFullstack` may have produced a
324
- // sub-project package.json with `pnpm.overrides` etc. that pnpm
325
- // ignores at non-root level.
326
- (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({
327
- filesystem,
328
- projectDir: workspaceDir,
329
- subProjects: ['projects/api', 'projects/app'],
330
- });
322
+ // Normalize the workspace root: hoist pnpm config out of the sub-projects
323
+ // (`setupServerForFullstack` may have left `pnpm.overrides` etc. at a
324
+ // non-root level pnpm ignores), drop nested lockfiles the root supersedes,
325
+ // and guarantee a root `.dockerignore` (Docker never reads a sub-project's
326
+ // own when building from the root context).
327
+ (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
331
328
  // Run install + format unless explicitly skipped (CI/agents may
332
329
  // want to chain multiple add-* calls before installing once).
333
330
  if (!skipInstall && !experimental) {
@@ -10,7 +10,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.help = void 0;
13
- const hoist_workspace_pnpm_config_1 = require("../../lib/hoist-workspace-pnpm-config");
14
13
  const workspace_integration_1 = require("../../lib/workspace-integration");
15
14
  /**
16
15
  * Add a frontend app (`projects/app/`) to a fullstack workspace that
@@ -249,12 +248,11 @@ const NewCommand = {
249
248
  warning('Continuing with npm mode for frontend.');
250
249
  }
251
250
  }
252
- // Hoist pnpm config (frontend templates may carry pnpm.overrides too).
253
- (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({
254
- filesystem,
255
- projectDir: workspaceDir,
256
- subProjects: ['projects/api', 'projects/app'],
257
- });
251
+ // Normalize the workspace root: hoist pnpm config (frontend templates may
252
+ // carry pnpm.overrides too), drop the frontend starter's standalone
253
+ // pnpm-lock.yaml the root supersedes, and guarantee a root `.dockerignore`
254
+ // (else the image inherits the host's node_modules, .output and .env).
255
+ (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
258
256
  if (!skipInstall) {
259
257
  const installSpinner = spin('Install workspace packages');
260
258
  try {
@@ -16,7 +16,6 @@ const gluegun_1 = require("gluegun");
16
16
  const caddy_1 = require("../../lib/caddy");
17
17
  const dev_migrate_helper_1 = require("../../lib/dev-migrate-helper");
18
18
  const dev_project_1 = require("../../lib/dev-project");
19
- const hoist_workspace_pnpm_config_1 = require("../../lib/hoist-workspace-pnpm-config");
20
19
  const package_name_1 = require("../../lib/package-name");
21
20
  const vendor_claude_md_1 = require("../../lib/vendor-claude-md");
22
21
  const workspace_integration_1 = require("../../lib/workspace-integration");
@@ -621,14 +620,14 @@ const NewCommand = {
621
620
  else {
622
621
  serverSpinner.warn('Nest Server Starter not integrated');
623
622
  }
624
- // Hoist workspace-scoped pnpm config out of sub-projects. pnpm only
625
- // honors `pnpm.overrides`, `pnpm.onlyBuiltDependencies`, and
626
- // `pnpm.ignoredOptionalDependencies` at the workspace root; leaving
627
- // them in projects/api/package.json or projects/app/package.json
628
- // causes `WARN The field was found in This will not take
629
- // effect. You should configure at the root of the workspace
630
- // instead.` and silently disables CVE overrides.
631
- (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects: ['projects/api', 'projects/app'] });
623
+ // Normalize the workspace root. Hoists workspace-scoped pnpm config out
624
+ // of the sub-projects (pnpm only honors `pnpm.overrides` etc. at the
625
+ // root otherwise CVE overrides are silently disabled), drops the
626
+ // starters' standalone pnpm-lock.yaml files the root supersedes, and
627
+ // guarantees a root `.dockerignore` (else the images carry the host's
628
+ // node_modules, a stale .output, and a local .env that `nuxt build`
629
+ // bakes in).
630
+ (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
632
631
  // Install all packages
633
632
  if (!experimental) {
634
633
  const installSpinner = spin('Install all packages');
@@ -251,6 +251,8 @@ const StatusCommand = {
251
251
  for (const [sub, host] of Object.entries(entry.subdomains)) {
252
252
  info(` ${sub.padEnd(6)} https://${host}`);
253
253
  }
254
+ // `loadRegistry` strips dbName from API-less entries, so its
255
+ // presence alone means there is a database to advertise.
254
256
  if (entry.dbName)
255
257
  info(` db mongodb://127.0.0.1/${entry.dbName}`);
256
258
  const session = (0, dev_state_1.loadSession)(registryRoot);
@@ -61,6 +61,8 @@ const ListCommand = {
61
61
  if (entry) {
62
62
  for (const [sub, host] of Object.entries(entry.subdomains))
63
63
  info(` ${sub.padEnd(4)} https://${host}`);
64
+ // `loadRegistry` strips dbName from API-less entries, so its presence
65
+ // alone means there is a database to show.
64
66
  if (entry.dbName)
65
67
  info(` db mongodb://127.0.0.1/${entry.dbName}`);
66
68
  }
@@ -1333,25 +1333,74 @@ class Server {
1333
1333
  // above works indirectly (via `bin/migrate.js` loading ts-node first),
1334
1334
  // but it's fragile: if somebody invokes migrate.js standalone, `require
1335
1335
  // ('../src/core')` would crash because Node cannot load TypeScript.
1336
- // Replace the file with the explicit, imo-pilot-proven variant that
1337
- // registers ts-node itself BEFORE requiring the vendored core and
1338
- // points at the specific migration.helper path (not the index hub).
1336
+ //
1337
+ // Two things this file must get right, both learned the hard way:
1338
+ // 1. ts-node is registered ONLY when the core next to it is still
1339
+ // TypeScript. In the production image everything is compiled and
1340
+ // ts-node is pruned, so an unconditional `require('./ts-compiler')`
1341
+ // would throw MODULE_NOT_FOUND and — under `set -e` in the
1342
+ // entrypoint — take the whole container down before the server starts.
1343
+ // 2. The Mongo URI comes from resolveMongoUri(), which falls back to
1344
+ // NSC__MONGOOSE__URI when config.env.ts is not loadable (Docker).
1345
+ // Reading `config.default.mongoose.uri` directly crashes there.
1339
1346
  const migrateJsPath = `${dest}/migrations-utils/migrate.js`;
1340
- filesystem.write(migrateJsPath, [
1341
- '// The vendored core is TypeScript-only (no prebuilt dist/). Register ts-node (via our',
1342
- '// custom bootstrap) before requiring any vendor module, so that .ts source files are',
1343
- '// transparently compiled on demand. Uses the same compiler config as the migrate CLI.',
1344
- "require('./ts-compiler');",
1345
- '',
1346
- "const { createMigrationStore } = require('../src/core/modules/migrate/helpers/migration.helper');",
1347
- "const config = require('../src/config.env');",
1348
- '',
1349
- 'module.exports = createMigrationStore(',
1350
- ' config.default.mongoose.uri,',
1351
- " 'migrations' // optional, default is 'migrations'",
1352
- ');',
1353
- '',
1354
- ].join('\n'));
1347
+ const migrateStoreTemplate = filesystem.read(path.join(__dirname, '..', 'templates', 'vendor-scripts', 'migrate-store.js'));
1348
+ if (!migrateStoreTemplate) {
1349
+ throw new Error('CLI build is missing templates/vendor-scripts/migrate-store.js cannot generate migrations-utils/migrate.js.');
1350
+ }
1351
+ filesystem.write(migrateJsPath, migrateStoreTemplate);
1352
+ // The generated migrate.js does `require('./mongo-uri')` (resolveMongoUri).
1353
+ // Unlike ts-compiler.js (written below), that file is NOT generated here — it
1354
+ // ships with nest-server-starter (the vendor clone base) and is copied into
1355
+ // dist/ by `copy:migrations`. Assert it so a non-standard/older starter that
1356
+ // lacks it fails loudly HERE instead of silently skipping every migration at
1357
+ // container start (the entrypoint's `migrate up` is best-effort under `set -e`).
1358
+ if (!filesystem.exists(`${dest}/migrations-utils/mongo-uri.js`)) {
1359
+ this.toolbox.print.warning(' ⚠ migrations-utils/mongo-uri.js is missing — the generated migrate.js requires it ' +
1360
+ '(resolveMongoUri). Migrations will not run. It normally ships with nest-server-starter; ' +
1361
+ 'add it (must export resolveMongoUri) so `migrate up` can resolve the Mongo URI.');
1362
+ }
1363
+ // Compile migrations to JavaScript. The production image prunes ts-node, so
1364
+ // `migrate up` can only load plain .js migrations there. Without this the
1365
+ // entrypoint's migration step never applies anything.
1366
+ const tsconfigBuildPath = `${dest}/tsconfig.build.json`;
1367
+ if (filesystem.exists(tsconfigBuildPath)) {
1368
+ const tsconfigBuildRaw = filesystem.read(tsconfigBuildPath) || '';
1369
+ if (!tsconfigBuildRaw.includes('migrations/**/*.ts')) {
1370
+ // 1. Extend `include` so tsc also emits the .ts migrations + helpers.
1371
+ const includePatched = tsconfigBuildRaw.replace(/"include":\s*\[\s*"src\/\*\*\/\*"\s*\]/, '"include": ["src/**/*", "migrations/**/*.ts", "migrations-utils/**/*.ts"]');
1372
+ if (includePatched === tsconfigBuildRaw) {
1373
+ this.toolbox.print.warning(' ⚠ tsconfig.build.json: could not extend "include" automatically — ' +
1374
+ 'add "migrations/**/*.ts" and "migrations-utils/**/*.ts" manually, ' +
1375
+ 'otherwise migrations are never compiled and the container skips them.');
1376
+ }
1377
+ else {
1378
+ // 2. Pin rootDir. Once `include` reaches beyond src/, tsc re-infers the
1379
+ // rootDir from the common parent of ALL included files, which can
1380
+ // shift the emit layout and relocate the entry point. An explicit
1381
+ // "rootDir": "./" keeps the output mirroring the project tree:
1382
+ // `dist/src/main.js` — which is exactly what the Docker entrypoint
1383
+ // runs (see nest-server-starter/docker-entrypoint.sh) — plus
1384
+ // `dist/migrations/` and `dist/migrations-utils/`. Without the pin the
1385
+ // entry can move and the container's `node dist/src/main.js` dies with
1386
+ // MODULE_NOT_FOUND. Newer starters ship the pin; older ones (pre-9.0) do not.
1387
+ let patched = includePatched;
1388
+ let rootDirPinned = /"rootDir"\s*:/.test(patched);
1389
+ if (!rootDirPinned) {
1390
+ const withRootDir = patched.replace(/("compilerOptions"\s*:\s*\{)/, '$1\n "rootDir": "./",');
1391
+ if (withRootDir !== patched) {
1392
+ patched = withRootDir;
1393
+ rootDirPinned = true;
1394
+ }
1395
+ }
1396
+ filesystem.write(tsconfigBuildPath, patched);
1397
+ if (!rootDirPinned) {
1398
+ this.toolbox.print.warning(' ⚠ tsconfig.build.json: extended "include" but could not confirm "rootDir": "./" — ' +
1399
+ 'add it manually, otherwise tsc may relocate the emit and `node dist/src/main.js` fails.');
1400
+ }
1401
+ }
1402
+ }
1403
+ }
1355
1404
  // ── 4b. (removed) ─────────────────────────────────────────────────────
1356
1405
  //
1357
1406
  // Previous versions copied three maintenance scripts into
@@ -1471,6 +1520,79 @@ class Server {
1471
1520
  scripts['migrate:test:up'] = `NODE_ENV=test node ./bin/migrate.js up ${migrateArgs}`;
1472
1521
  scripts['migrate:preview:up'] = `NODE_ENV=preview node ./bin/migrate.js up ${migrateArgs}`;
1473
1522
  scripts['migrate:prod:up'] = `NODE_ENV=production node ./bin/migrate.js up ${migrateArgs}`;
1523
+ // Make the production build carry a runnable migration setup.
1524
+ //
1525
+ // The image has no ts-node, and the docker entrypoint looks for
1526
+ // `dist/bin/migrate.js`. Without these three scripts the entrypoint
1527
+ // silently skips migrations forever — the failure mode is invisible
1528
+ // until the first real data migration quietly does not run.
1529
+ //
1530
+ // copy:bin ships bin/migrate.js into dist/
1531
+ // copy:migrations ships only the .js helpers (the .ts migrations
1532
+ // are compiled by tsc, see tsconfig.build.json)
1533
+ // prune:migrations drops the *.d.ts / *.js.map that tsc emits next
1534
+ // to each compiled migration — the runner would
1535
+ // otherwise pick the declaration file up as a
1536
+ // second migration and throw on `export declare`
1537
+ //
1538
+ // Define each only if the project doesn't already have it, so a
1539
+ // deliberate customization survives a re-run (older starters simply
1540
+ // lack them → still repaired).
1541
+ if (typeof scripts['copy:bin'] !== 'string') {
1542
+ scripts['copy:bin'] = 'cpy ./bin ./dist/ || true';
1543
+ }
1544
+ if (typeof scripts['copy:migrations'] !== 'string') {
1545
+ scripts['copy:migrations'] =
1546
+ 'cpy "./migrations-utils/*.js" ./dist/migrations-utils/ && cpy ./tsconfig.json ./dist/';
1547
+ }
1548
+ if (typeof scripts['prune:migrations'] !== 'string') {
1549
+ scripts['prune:migrations'] = 'rimraf --glob "./dist/migrations/*.d.ts" "./dist/migrations/*.js.map"';
1550
+ }
1551
+ // Wire the three steps into the build. Order among them is irrelevant:
1552
+ // all three only need to run AFTER tsc, which `build` does first
1553
+ // (copy:bin/copy:migrations copy source files; prune only cleans
1554
+ // tsc's dist/migrations output). A step is already covered when it is
1555
+ // named directly in `build` or reachable through a `copy` aggregator
1556
+ // that `build` runs (newer starters). Older starters have no such
1557
+ // aggregator, so append to `build` — the one script guaranteed to
1558
+ // exist and to run tsc first; without this the sub-scripts are defined
1559
+ // but never invoked and the image ships without dist/bin/migrate.js.
1560
+ //
1561
+ // Use the package-manager `run` the project's own scripts already use
1562
+ // (older starters: npm, newer: pnpm); a hard-coded `pnpm run` would
1563
+ // make `build` die with "pnpm: command not found" in an npm project.
1564
+ const scriptText = Object.values(scripts)
1565
+ .filter((v) => typeof v === 'string')
1566
+ .join('\n');
1567
+ const pmRun = /\bpnpm\s+run\b/.test(scriptText)
1568
+ ? 'pnpm run'
1569
+ : /\byarn\s+run\b/.test(scriptText)
1570
+ ? 'yarn run'
1571
+ : /\bnpm\s+run\b/.test(scriptText)
1572
+ ? 'npm run'
1573
+ : 'pnpm run';
1574
+ if (typeof scripts.build === 'string') {
1575
+ const copyAggregator = typeof scripts.copy === 'string' ? scripts.copy : '';
1576
+ const covered = (step) => scripts.build.includes(step) || copyAggregator.includes(step);
1577
+ const missingSteps = ['copy:bin', 'copy:migrations', 'prune:migrations'].filter((s) => !covered(s));
1578
+ if (missingSteps.length > 0) {
1579
+ scripts.build = `${scripts.build}${missingSteps.map((s) => ` && ${pmRun} ${s}`).join('')}`;
1580
+ }
1581
+ }
1582
+ else {
1583
+ this.toolbox.print.warning(' ⚠ package.json has no "build" script — copy:bin/copy:migrations/prune:migrations were defined but ' +
1584
+ 'not wired in; the production image may ship without dist/bin/migrate.js and skip migrations.');
1585
+ }
1586
+ // prune:migrations needs `rimraf`, copy:* need `cpy` (cpy-cli). Older
1587
+ // starters ship cpy-cli but not rimraf — add whatever is missing so
1588
+ // `build` doesn't die on an absent binary.
1589
+ const devDependencies = (pkg.devDependencies && typeof pkg.devDependencies === 'object'
1590
+ ? pkg.devDependencies
1591
+ : (pkg.devDependencies = {}));
1592
+ if (typeof devDependencies.rimraf !== 'string')
1593
+ devDependencies.rimraf = '^6.0.1';
1594
+ if (typeof devDependencies['cpy-cli'] !== 'string')
1595
+ devDependencies['cpy-cli'] = '^6.0.0';
1474
1596
  // Vendor freshness check: reads VENDOR.md baseline version and
1475
1597
  // compares against npm registry. Non-blocking (always exits 0).
1476
1598
  // Uses a short inline script — no external file needed.
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findAngularEnvironmentsDir = findAngularEnvironmentsDir;
4
+ exports.patchAngularEnvironments = patchAngularEnvironments;
5
+ exports.stageHosts = stageHosts;
6
+ /**
7
+ * Rewrite the hardcoded localhost URLs in an Angular app's environment files
8
+ * to the deployed TurboOps stage URLs.
9
+ *
10
+ * WHY: a Nuxt app reads its API URL from the stage env at runtime
11
+ * (`NUXT_PUBLIC_API_URL`, injected by TurboOps). Angular has no such runtime
12
+ * config — `ng build --configuration production` BAKES `environment.prod.ts`
13
+ * into the bundle. Without this patch a deployed Angular app ships
14
+ * `apiUrl: 'http://127.0.0.1:3000/graphql'` and calls the end user's own
15
+ * machine.
16
+ *
17
+ * Stage mapping (TurboOps knows exactly two stages, `dev` and `production`):
18
+ *
19
+ * environment.prod.ts → production → <domain> + api.<domain>
20
+ * environment.develop.ts → dev → dev.<domain> + api.dev.<domain>
21
+ * environment.test.ts → dev → dev.<domain> + api.dev.<domain>
22
+ *
23
+ * `environment.ts` is the local-dev config and is deliberately left alone.
24
+ * `environment.test.ts` and `environment.develop.ts` are identical in
25
+ * ng-base-starter (both `prefix: 'app-test'`); with no TurboOps "test" stage,
26
+ * the non-production stage is `dev` for both.
27
+ *
28
+ * Only the URL ORIGIN (scheme + host + optional port) is replaced — the path
29
+ * (`/graphql`, `/api`) is preserved, so a project that customised it keeps its
30
+ * routes. That also makes the patch idempotent AND re-runnable with a new
31
+ * domain: an already-deployed URL is rewritten to the new one instead of being
32
+ * skipped (the old `http://127.0.0.1:3000`-only replace silently did nothing
33
+ * on the second run).
34
+ */
35
+ const fs_1 = require("fs");
36
+ const path_1 = require("path");
37
+ /** Which environment file belongs to which TurboOps stage. */
38
+ const ENVIRONMENT_STAGES = {
39
+ 'environment.develop.ts': 'dev',
40
+ 'environment.prod.ts': 'production',
41
+ 'environment.test.ts': 'dev',
42
+ };
43
+ /**
44
+ * Locate the Angular `environments/` directory for a project root.
45
+ * Returns null for non-Angular apps (e.g. Nuxt), which makes the whole
46
+ * patch a no-op there.
47
+ */
48
+ function findAngularEnvironmentsDir(projectRoot) {
49
+ const candidates = [(0, path_1.join)('projects', 'app', 'src', 'environments'), (0, path_1.join)('src', 'environments')];
50
+ for (const rel of candidates) {
51
+ const dir = (0, path_1.join)(projectRoot, rel);
52
+ if ((0, fs_1.existsSync)(dir))
53
+ return dir;
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * Point every stage-specific Angular environment file at the deployed URLs.
59
+ * Returns one {@link PatchResult} per file that exists (empty array when the
60
+ * project is not an Angular app).
61
+ */
62
+ function patchAngularEnvironments(options) {
63
+ const dir = findAngularEnvironmentsDir(options.projectRoot);
64
+ if (!dir)
65
+ return [];
66
+ const results = [];
67
+ for (const [fileName, stage] of Object.entries(ENVIRONMENT_STAGES)) {
68
+ const file = (0, path_1.join)(dir, fileName);
69
+ if (!(0, fs_1.existsSync)(file))
70
+ continue;
71
+ results.push(patchEnvironmentFile(file, options.domain, stage));
72
+ }
73
+ return results;
74
+ }
75
+ /**
76
+ * Public hostnames of a stage. The API always lives one label in front of the
77
+ * app host, so `dev` nests as `api.dev.<domain>`.
78
+ */
79
+ function stageHosts(domain, stage) {
80
+ const app = stage === 'production' ? domain : `dev.${domain}`;
81
+ return { api: `api.${app}`, app };
82
+ }
83
+ /** Rewrite the four URL properties of a single environment file. */
84
+ function patchEnvironmentFile(file, domain, stage) {
85
+ const hosts = stageHosts(domain, stage);
86
+ const original = (0, fs_1.readFileSync)(file, 'utf8');
87
+ let content = original;
88
+ let replacements = 0;
89
+ let recognized = 0;
90
+ const rewriteOrigin = (property, origin) => {
91
+ // (property: )(quote)scheme://host[:port](path)(same quote)
92
+ const pattern = new RegExp(`(${property}:\\s*)(['"])[a-z]+://[^'"/]+([^'"]*)\\2`, 'g');
93
+ content = content.replace(pattern, (match, prefix, quote, path) => {
94
+ recognized++;
95
+ const next = `${prefix}${quote}${origin}${path}${quote}`;
96
+ if (next !== match)
97
+ replacements++;
98
+ return next;
99
+ });
100
+ };
101
+ rewriteOrigin('apiUrl', `https://${hosts.api}`);
102
+ rewriteOrigin('restUrl', `https://${hosts.api}`);
103
+ rewriteOrigin('wsUrl', `wss://${hosts.api}`);
104
+ rewriteOrigin('appUrl', `https://${hosts.app}`);
105
+ if (replacements > 0)
106
+ (0, fs_1.writeFileSync)(file, content, 'utf8');
107
+ return { file, patched: replacements > 0, recognized: recognized > 0, replacements };
108
+ }
@@ -52,14 +52,14 @@ function buildDevEnv(input) {
52
52
  internalPort: apiInternalPort,
53
53
  },
54
54
  app: {
55
- env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
55
+ env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
56
56
  // See API note above: pin the dev server to IPv4 so Caddy's
57
57
  // `127.0.0.1` upstream is unambiguous.
58
58
  HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
59
59
  // Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
60
60
  // both subdomains under HTTPS with shared cookie domain, so
61
61
  // same-origin trickery is no longer required.
62
- NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug, PORT: String(appInternalPort) }),
62
+ NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug, PORT: String(appInternalPort) }), (process.platform === 'darwin' ? { TMPDIR: '/tmp' } : {})),
63
63
  internalPort: appInternalPort,
64
64
  },
65
65
  };
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildIdentity = buildIdentity;
4
4
  exports.buildTestIdentity = buildTestIdentity;
5
5
  exports.buildTicketIdentity = buildTicketIdentity;
6
+ exports.detectStandaloneKind = detectStandaloneKind;
6
7
  exports.isUnmodifiedTemplateName = isUnmodifiedTemplateName;
7
8
  exports.projectSlug = projectSlug;
8
9
  exports.slugify = slugify;
@@ -31,43 +32,46 @@ const path_1 = require("path");
31
32
  * - `projects/app` → `<slug>.localhost` (primary)
32
33
  * - `projects/<other>` → `<other>.<slug>.localhost`
33
34
  *
34
- * For standalone projects (single repo, no `projects/` directory):
35
+ * For standalone projects (single repo, no `projects/api` or `projects/app`):
35
36
  * - API project (config.env.ts present) → `api.<slug>.localhost`
36
37
  * - App project (nuxt.config.ts present) → `<slug>.localhost`
38
+ *
39
+ * Every combination is valid: api+app, api-only, and app-only.
37
40
  */
38
41
  function buildIdentity(root) {
39
42
  const slug = projectSlug(root);
40
43
  const subdomains = {};
41
- const projectsDir = (0, path_1.join)(root, 'projects');
42
- if ((0, fs_1.existsSync)(projectsDir)) {
43
- // Monorepo: enumerate projects/* subdirectories.
44
- const apiDir = (0, path_1.join)(projectsDir, 'api');
45
- const appDir = (0, path_1.join)(projectsDir, 'app');
46
- if ((0, fs_1.existsSync)(apiDir)) {
44
+ const apiDir = (0, path_1.join)(root, 'projects', 'api');
45
+ const appDir = (0, path_1.join)(root, 'projects', 'app');
46
+ const hasApi = (0, fs_1.existsSync)(apiDir);
47
+ const hasApp = (0, fs_1.existsSync)(appDir);
48
+ // Monorepo only when a known subproject is actually present. A bare (or
49
+ // unrelated) `projects/` directory must not shadow the standalone probe —
50
+ // it would yield an identity with zero subdomains and no routable URLs.
51
+ if (hasApi || hasApp) {
52
+ if (hasApi) {
47
53
  subdomains.api = {
48
54
  hostname: `api.${slug}.localhost`,
49
55
  isPrimaryApp: false,
50
56
  subdir: 'projects/api',
51
57
  };
52
58
  }
53
- if ((0, fs_1.existsSync)(appDir)) {
59
+ if (hasApp) {
54
60
  subdomains.app = {
55
61
  hostname: `${slug}.localhost`,
56
62
  isPrimaryApp: true,
57
63
  subdir: 'projects/app',
58
64
  };
59
65
  }
66
+ return { root, slug, subdomains };
60
67
  }
61
- else {
62
- // Standalone derive from project shape.
63
- const isApi = (0, fs_1.existsSync)((0, path_1.join)(root, 'src', 'config.env.ts')) || (0, fs_1.existsSync)((0, path_1.join)(root, 'nest-cli.json'));
64
- const isApp = (0, fs_1.existsSync)((0, path_1.join)(root, 'nuxt.config.ts'));
65
- if (isApi) {
66
- subdomains.api = { hostname: `api.${slug}.localhost`, isPrimaryApp: false, subdir: null };
67
- }
68
- if (isApp) {
69
- subdomains.app = { hostname: `${slug}.localhost`, isPrimaryApp: true, subdir: null };
70
- }
68
+ // Standalone — derive from project shape.
69
+ const { isApi, isApp } = detectStandaloneKind(root);
70
+ if (isApi) {
71
+ subdomains.api = { hostname: `api.${slug}.localhost`, isPrimaryApp: false, subdir: null };
72
+ }
73
+ if (isApp) {
74
+ subdomains.app = { hostname: `${slug}.localhost`, isPrimaryApp: true, subdir: null };
71
75
  }
72
76
  return { root, slug, subdomains };
73
77
  }
@@ -105,6 +109,22 @@ function buildTestIdentity(base, suffix = '-test') {
105
109
  function buildTicketIdentity(base, id) {
106
110
  return buildTestIdentity(base, `-${id}`);
107
111
  }
112
+ /**
113
+ * Probe a single directory for the shape of a STANDALONE lt project.
114
+ *
115
+ * Single source of truth for "is this an API / an App?", shared by
116
+ * {@link buildIdentity} and `dev-project.ts#resolveLayout` so the identity
117
+ * (URLs) and the layout (which processes to spawn) can never disagree.
118
+ *
119
+ * Both flags can be true (a single repo holding both), and both can be false
120
+ * (not an lt-dev project).
121
+ */
122
+ function detectStandaloneKind(dir) {
123
+ return {
124
+ isApi: (0, fs_1.existsSync)((0, path_1.join)(dir, 'src', 'config.env.ts')) || (0, fs_1.existsSync)((0, path_1.join)(dir, 'nest-cli.json')),
125
+ isApp: (0, fs_1.existsSync)((0, path_1.join)(dir, 'nuxt.config.ts')),
126
+ };
127
+ }
108
128
  /**
109
129
  * package.json `name` values that are unchanged starter-template defaults.
110
130
  *
@@ -8,6 +8,7 @@ exports.deriveTicketDbName = deriveTicketDbName;
8
8
  exports.resolveLayout = resolveLayout;
9
9
  const fs_1 = require("fs");
10
10
  const path_1 = require("path");
11
+ const dev_identity_1 = require("./dev-identity");
11
12
  const workspace_integration_1 = require("./workspace-integration");
12
13
  /**
13
14
  * Detect whether the API project still has the legacy hardcoded `port: 3000`.
@@ -85,20 +86,32 @@ function deriveTicketDbName(devDbName, ticketId) {
85
86
  /**
86
87
  * Resolve layout starting from `cwd`. Walks up to find a workspace if
87
88
  * cwd is inside `projects/api/` or `projects/app/`.
89
+ *
90
+ * A workspace marker alone never wins: it only selects the monorepo layout
91
+ * when `projects/api` or `projects/app` actually exists. Otherwise we fall
92
+ * through to the standalone probe — a single-package repo may legitimately
93
+ * carry a settings-only `pnpm-workspace.yaml` (pnpm 10/11 keeps `overrides`
94
+ * and build allowlists there), and an npm workspace may use `packages/*`
95
+ * rather than the lt `projects/*` convention.
88
96
  */
89
97
  function resolveLayout(cwd, filesystem) {
90
98
  const subContext = (0, workspace_integration_1.detectSubProjectContext)(cwd, filesystem);
91
99
  if (subContext)
92
100
  return monorepoLayout(subContext.workspaceRoot);
93
101
  const layout = (0, workspace_integration_1.detectWorkspaceLayout)(cwd, filesystem);
94
- if (layout.hasWorkspace)
95
- return monorepoLayout(layout.workspaceDir);
102
+ if (layout.hasWorkspace) {
103
+ const mono = monorepoLayout(layout.workspaceDir);
104
+ if (mono.apiDir || mono.appDir)
105
+ return mono;
106
+ }
96
107
  const workspaceRoot = (0, workspace_integration_1.findWorkspaceRoot)(cwd, filesystem);
97
- if (workspaceRoot)
98
- return monorepoLayout(workspaceRoot);
99
- // Standalone project — figure out if it's API or App.
100
- const isApi = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'src', 'config.env.ts')) || (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nest-cli.json'));
101
- const isApp = (0, fs_1.existsSync)((0, path_1.join)(cwd, 'nuxt.config.ts'));
108
+ if (workspaceRoot) {
109
+ const mono = monorepoLayout(workspaceRoot);
110
+ if (mono.apiDir || mono.appDir)
111
+ return mono;
112
+ }
113
+ // Standalone project — API-only, App-only, or a single repo holding both.
114
+ const { isApi, isApp } = (0, dev_identity_1.detectStandaloneKind)(cwd);
102
115
  return {
103
116
  apiDir: isApi ? cwd : null,
104
117
  appDir: isApp ? cwd : null,
@@ -118,7 +118,7 @@ function loadRegistry() {
118
118
  try {
119
119
  const parsed = JSON.parse((0, fs_1.readFileSync)(REGISTRY_PATH, 'utf8'));
120
120
  if (parsed && typeof parsed === 'object' && parsed.version === 1 && typeof parsed.projects === 'object') {
121
- return parsed;
121
+ return normalizeRegistry(parsed);
122
122
  }
123
123
  }
124
124
  catch (_a) {
@@ -184,6 +184,22 @@ function takenInternalPorts(reg, excludeSlug) {
184
184
  }
185
185
  return ports;
186
186
  }
187
+ /**
188
+ * Drop a `dbName` from any entry that has no `api` subdomain. An App-only
189
+ * project has no database, yet older registries (written before `lt dev up`
190
+ * stopped persisting one for App-only stacks) still carry a derived name.
191
+ * Normalizing once on load lets every reader test `entry.dbName` alone,
192
+ * instead of repeating `entry.dbName && entry.subdomains.api` at each display.
193
+ */
194
+ function normalizeRegistry(reg) {
195
+ var _a;
196
+ for (const entry of Object.values(reg.projects)) {
197
+ if ((entry === null || entry === void 0 ? void 0 : entry.dbName) && !((_a = entry.subdomains) === null || _a === void 0 ? void 0 : _a.api)) {
198
+ delete entry.dbName;
199
+ }
200
+ }
201
+ return reg;
202
+ }
187
203
  /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
188
204
  function sameRealPath(a, b) {
189
205
  try {