@lenne.tech/cli 1.34.0 → 1.35.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 (40) 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/fullstack/add-api.js +6 -9
  9. package/build/commands/fullstack/add-app.js +5 -7
  10. package/build/commands/fullstack/init.js +8 -9
  11. package/build/commands/status.js +2 -0
  12. package/build/commands/ticket/list.js +2 -0
  13. package/build/extensions/server.js +140 -18
  14. package/build/lib/angular-environments.js +108 -0
  15. package/build/lib/dev-identity.js +38 -18
  16. package/build/lib/dev-project.js +20 -7
  17. package/build/lib/dev-state.js +17 -1
  18. package/build/lib/dev-test-session.js +31 -3
  19. package/build/lib/ensure-root-dockerignore.js +92 -0
  20. package/build/lib/fs-utils.js +19 -0
  21. package/build/lib/hoist-workspace-pnpm-config.js +2 -11
  22. package/build/lib/remove-nested-lockfiles.js +53 -0
  23. package/build/lib/turboops-config.js +93 -0
  24. package/build/lib/workspace-integration.js +96 -3
  25. package/build/templates/deployment/turboops.json.ejs +3 -0
  26. package/build/templates/vendor-scripts/migrate-store.js +25 -0
  27. package/docs/LT-ECOSYSTEM-GUIDE.md +1 -1
  28. package/docs/commands.md +30 -9
  29. package/docs/lt.config.md +9 -19
  30. package/package.json +1 -1
  31. package/build/templates/deployment/.github/workflows/pre-release.yml.ejs +0 -41
  32. package/build/templates/deployment/.github/workflows/release.yml.ejs +0 -41
  33. package/build/templates/deployment/.gitlab-ci.yml.ejs +0 -181
  34. package/build/templates/deployment/Dockerfile.app.ejs +0 -13
  35. package/build/templates/deployment/Dockerfile.ejs +0 -18
  36. package/build/templates/deployment/docker-compose.dev.yml.ejs +0 -99
  37. package/build/templates/deployment/docker-compose.prod.yml.ejs +0 -92
  38. package/build/templates/deployment/docker-compose.test.yml.ejs +0 -98
  39. package/build/templates/deployment/scripts/build-push.sh.ejs +0 -20
  40. package/build/templates/deployment/scripts/deploy.sh.ejs +0 -7
package/README.md CHANGED
@@ -170,13 +170,14 @@ $ lt fullstack update
170
170
 
171
171
  ### Vendor-mode housekeeping
172
172
 
173
- Vendor-mode projects ship three maintenance scripts under `scripts/vendor/`:
173
+ Vendor-mode projects carry no separate maintenance scripts under
174
+ `scripts/vendor/` anymore. The vendor-specific surface is now:
174
175
 
175
- | Script | Purpose | Invocation |
176
+ | Concern | Where | Invocation |
176
177
  |---|---|---|
177
- | `check-vendor-freshness.mjs` | Non-blocking warning when upstream has a newer release than the current baseline | `pnpm run check:vendor-freshness` (auto-invoked by `pnpm run check` / `check:fix` / `check:naf`) |
178
- | `sync-from-upstream.ts` | Diff generator consumed by the `nest-server-core-updater` Claude Code agent | `pnpm run vendor:sync` |
179
- | `propose-upstream-pr.ts` | Patch-list generator consumed by the `nest-server-core-contributor` agent | `pnpm run vendor:propose-upstream` |
178
+ | Upstream freshness | Inline `check:vendor-freshness` script in `package.json` reads the `src/core/VENDOR.md` baseline, compares against the npm registry, non-blocking | auto-invoked by `pnpm run check` / `check:fix` / `check:naf`, or `pnpm run check:vendor-freshness` |
179
+ | Sync from upstream | `nest-server-core-updater` Claude Code agent | `/lt-dev:backend:update-nest-server-core` |
180
+ | Propose local changes upstream | `nest-server-core-contributor` Claude Code agent | `/lt-dev:backend:contribute-nest-server-core` |
180
181
 
181
182
  The vendor-mode baseline (upstream version + commit SHA) is recorded in
182
183
  `src/core/VENDOR.md`. Log any substantial local patch there so the updater
@@ -30,11 +30,7 @@ const KNOWN_KEYS = {
30
30
  },
31
31
  deployment: {
32
32
  domain: 'string',
33
- gitHub: 'boolean',
34
- gitLab: 'boolean',
35
33
  noConfirm: 'boolean',
36
- prodRunner: 'string',
37
- testRunner: 'string',
38
34
  },
39
35
  directus: {
40
36
  dockerSetup: {
@@ -9,276 +9,281 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.help = void 0;
12
13
  const path_1 = require("path");
14
+ const angular_environments_1 = require("../../lib/angular-environments");
15
+ const turboops_config_1 = require("../../lib/turboops-config");
16
+ const workspace_integration_1 = require("../../lib/workspace-integration");
13
17
  /**
14
- * Create a new deployment for mono repository
18
+ * Create the TurboOps deployment config for a fullstack monorepo.
19
+ *
20
+ * The generic deployment files already ship with the monorepo template:
21
+ * - projects/api/Dockerfile + projects/app/Dockerfile (from the starters)
22
+ * - docker-compose.yml + .gitlab-ci.yml (TurboOps build + `turbo deploy`)
23
+ * so this command only writes the project-specific `.turboops.json` and prints the
24
+ * one-time TurboOps setup checklist (project, stages, CI/CD vars, DNS, stage env).
25
+ *
26
+ * The file is MERGED, never clobbered: any keys a user added by hand survive a
27
+ * re-run, and an unchanged file is left untouched.
28
+ *
29
+ * Angular apps additionally get their `environment.{prod,develop,test}.ts`
30
+ * rewritten to the deployed stage URLs (see lib/angular-environments.ts) — a
31
+ * Nuxt app reads those from the stage env at runtime, an Angular bundle bakes
32
+ * them in at build time. No-op for Nuxt projects.
33
+ *
34
+ * Legacy note: this used to generate a Docker-Swarm deployment (root
35
+ * Dockerfile/Dockerfile.app, docker-compose.{dev,test,prod}.yml, manual Traefik
36
+ * labels). That is obsolete on the TurboOps stack and has been removed. GitLab
37
+ * is the only supported pipeline — the lt-monorepo template ships
38
+ * `.gitlab-ci.yml`, not a GitHub deploy workflow.
15
39
  */
40
+ exports.help = {
41
+ configuration: 'commands.deployment.domain, commands.deployment.noConfirm, defaults.domain, defaults.noConfirm',
42
+ description: 'Create the TurboOps deployment config (.turboops.json) for a fullstack monorepo',
43
+ examples: [
44
+ 'deployment create',
45
+ 'deployment create MyProject --domain myproject.lenne.tech --noConfirm',
46
+ 'deployment create MyProject --domain myproject.lenne.tech --project myproject',
47
+ ],
48
+ features: [
49
+ 'Writes `.turboops.json` to the workspace root (merges into an existing file)',
50
+ 'Prints the one-time TurboOps setup checklist (project, stages, CI/CD vars, DNS, stage env)',
51
+ ],
52
+ name: 'create',
53
+ options: [
54
+ {
55
+ description: 'Main domain of the project (e.g. myproject.lenne.tech)',
56
+ flag: '--domain',
57
+ required: false,
58
+ type: 'string',
59
+ },
60
+ {
61
+ description: 'TurboOps project slug (registry namespace + login user). Default: kebab-case project name',
62
+ flag: '--project',
63
+ required: false,
64
+ type: 'string',
65
+ },
66
+ {
67
+ default: false,
68
+ description: 'Skip confirmation prompts; resolve every value from CLI flags, lt.config, or defaults',
69
+ flag: '--noConfirm',
70
+ required: false,
71
+ type: 'boolean',
72
+ },
73
+ ],
74
+ };
16
75
  const NewCommand = {
17
76
  alias: ['dc'],
18
- description: 'Create deployment config',
77
+ description: 'Create TurboOps config',
19
78
  hidden: false,
20
79
  name: 'create',
21
80
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
22
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
23
- // Retrieve the tools we need
24
- const { config, filesystem, helper, parameters, patching, print: { info, spin, success }, prompt: { confirm }, strings: { camelCase, kebabCase, pascalCase }, system, template, } = toolbox;
81
+ var _a, _b, _c, _d, _e, _f, _g;
82
+ const { config, filesystem, helper, parameters, print: { error, info, spin, success, warning }, prompt: { confirm }, strings: { kebabCase, pascalCase }, system, template, } = toolbox;
83
+ const fail = (message, result) => {
84
+ error(message);
85
+ if (!parameters.options.fromGluegunMenu)
86
+ process.exit(1);
87
+ return result;
88
+ };
25
89
  // Load configuration
26
90
  const ltConfig = config.loadConfig();
27
91
  const configDomain = (_b = (_a = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _a === void 0 ? void 0 : _a.deployment) === null || _b === void 0 ? void 0 : _b.domain;
28
- const configGitHub = (_d = (_c = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _c === void 0 ? void 0 : _c.deployment) === null || _d === void 0 ? void 0 : _d.gitHub;
29
- const configGitLab = (_f = (_e = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _e === void 0 ? void 0 : _e.deployment) === null || _f === void 0 ? void 0 : _f.gitLab;
30
- const configTestRunner = (_h = (_g = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _g === void 0 ? void 0 : _g.deployment) === null || _h === void 0 ? void 0 : _h.testRunner;
31
- const configProdRunner = (_k = (_j = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _j === void 0 ? void 0 : _j.deployment) === null || _k === void 0 ? void 0 : _k.prodRunner;
32
- // Load global defaults
33
92
  const globalDomain = config.getGlobalDefault(ltConfig, 'domain');
34
- // Parse CLI arguments
35
- const cliDomain = parameters.options.domain;
36
- const cliGitHub = parameters.options.gitHub;
37
- const cliGitLab = parameters.options.gitLab;
38
- const cliTestRunner = parameters.options.testRunner;
39
- const cliProdRunner = parameters.options.prodRunner;
40
- const cliNoConfirm = parameters.options.noConfirm;
41
- // Determine noConfirm with priority: CLI > command > parent > global > default
93
+ const cliDomain = (0, turboops_config_1.optionString)(parameters.options.domain);
94
+ const cliProject = (0, turboops_config_1.optionString)(parameters.options.project);
95
+ // Priority: CLI > command config > global default > false. When set, every
96
+ // value must resolve from a flag, lt.config, or a default — no prompts, so
97
+ // AI agents and CI never block on an interactive question.
42
98
  const noConfirm = config.getNoConfirm({
43
- cliValue: cliNoConfirm,
44
- commandConfig: (_l = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _l === void 0 ? void 0 : _l.deployment,
99
+ cliValue: parameters.options.noConfirm,
100
+ commandConfig: (_c = ltConfig === null || ltConfig === void 0 ? void 0 : ltConfig.commands) === null || _c === void 0 ? void 0 : _c.deployment,
45
101
  config: ltConfig,
46
102
  });
47
103
  // Start timer
48
104
  const timer = system.startTimer();
49
- // Info
50
- info('Create a new deployment');
51
- // Get default project name
105
+ info('Create TurboOps deployment config');
106
+ // `.turboops.json` must sit in the repo root — `.gitlab-ci.yml` reads it
107
+ // from there. Resolve the workspace root so running this from inside
108
+ // `projects/api/` still writes to the right place.
109
+ const cwd = filesystem.cwd();
110
+ const subProject = (0, workspace_integration_1.detectSubProjectContext)(cwd, filesystem);
111
+ const projectRoot = (_e = (_d = subProject === null || subProject === void 0 ? void 0 : subProject.workspaceRoot) !== null && _d !== void 0 ? _d : (0, workspace_integration_1.findWorkspaceRoot)(cwd, filesystem)) !== null && _e !== void 0 ? _e : cwd;
112
+ if (projectRoot !== cwd) {
113
+ info(`Using workspace root: ${projectRoot}`);
114
+ }
115
+ // Default project name from lt.json / package.json. Read-only: `patching.update`
116
+ // would re-serialize and rewrite the file just to look at one field. A
117
+ // malformed file is tolerated (best-effort default), never fatal.
52
118
  let projectName = '';
53
- const hasLtJson = yield filesystem.exists('lt.json');
54
- if (hasLtJson) {
55
- yield patching.update('lt.json', (data) => {
56
- projectName = data.name;
57
- return data;
58
- });
119
+ const ltName = (_f = (0, turboops_config_1.readJsonObject)(filesystem, (0, path_1.join)(projectRoot, 'lt.json')).value) === null || _f === void 0 ? void 0 : _f.name;
120
+ if (typeof ltName === 'string' && ltName) {
121
+ projectName = ltName;
59
122
  }
60
123
  if (!projectName) {
61
- yield patching.update('package.json', (data) => {
62
- projectName = pascalCase(data.name);
63
- return data;
64
- });
124
+ const pkgName = (_g = (0, turboops_config_1.readJsonObject)(filesystem, (0, path_1.join)(projectRoot, 'package.json')).value) === null || _g === void 0 ? void 0 : _g.name;
125
+ if (typeof pkgName === 'string' && pkgName) {
126
+ projectName = pascalCase(pkgName);
127
+ }
65
128
  }
66
- // Get name
67
- const name = yield helper.getInput(parameters.first, {
68
- initial: projectName,
69
- name: `project name (e.g. ${projectName ? projectName : 'My new project'})`,
70
- });
129
+ // Project name (CLI arg > detected name > interactive)
130
+ const cliName = (0, turboops_config_1.optionString)(parameters.first);
131
+ const name = cliName !== null && cliName !== void 0 ? cliName : (noConfirm
132
+ ? projectName
133
+ : yield helper.getInput('', {
134
+ initial: projectName,
135
+ name: `project name (e.g. ${projectName || 'My new project'})`,
136
+ }));
71
137
  if (!name) {
72
- return;
138
+ return fail(noConfirm
139
+ ? 'No project name: pass it as the first argument (no lt.json / package.json "name" found).'
140
+ : 'No project name given.', 'deployment create: no name');
73
141
  }
74
- // Determine domain with priority: CLI > config > global > interactive
142
+ const slug = kebabCase(name);
143
+ // Main domain (priority: CLI > config > global > interactive/default)
75
144
  let domain;
76
145
  if (cliDomain) {
77
146
  domain = cliDomain;
78
147
  }
79
148
  else if (configDomain) {
80
- domain = configDomain.replace('{name}', kebabCase(name));
149
+ domain = configDomain.replace('{name}', slug);
81
150
  info(`Using domain from lt.config commands.deployment: ${domain}`);
82
151
  }
83
152
  else if (globalDomain) {
84
- domain = globalDomain.replace('{name}', kebabCase(name));
153
+ domain = globalDomain.replace('{name}', slug);
85
154
  info(`Using domain from lt.config defaults: ${domain}`);
86
155
  }
156
+ else if (noConfirm) {
157
+ // Honour the documented `[domain]` positional; only then fall back to the
158
+ // default — otherwise a `--noConfirm` caller's explicit domain is dropped.
159
+ const argDomain = (0, turboops_config_1.optionString)(parameters.second);
160
+ domain = argDomain !== null && argDomain !== void 0 ? argDomain : `${slug}.lenne.tech`;
161
+ info(argDomain ? `Using domain: ${domain}` : `Using default domain: ${domain}`);
162
+ }
87
163
  else {
88
164
  domain = yield helper.getInput(parameters.second, {
89
- initial: `${kebabCase(name)}.lenne.tech`,
90
- name: `main domain of the project (e.g. ${kebabCase(name)}.lenne.tech)`,
165
+ initial: `${slug}.lenne.tech`,
166
+ name: `main domain of the project (e.g. ${slug}.lenne.tech)`,
91
167
  });
92
168
  }
93
169
  if (!domain) {
94
- return;
170
+ return fail('No domain given.', 'deployment create: no domain');
95
171
  }
96
- // Determine gitHub with priority: CLI > config > noConfirm > interactive
97
- let gitHub;
98
- if (cliGitHub !== undefined) {
99
- gitHub = cliGitHub === true || cliGitHub === 'true';
172
+ // `domain` is written verbatim into generated Angular environment.*.ts (baked
173
+ // into the browser bundle) and into copy-paste checklist lines, so validate it
174
+ // as a hostname — a stray quote / `$` / whitespace would inject into the
175
+ // generated source or emit a non-compiling config. Mirrors the slug check below.
176
+ if (!(0, turboops_config_1.isValidDomain)(domain)) {
177
+ return fail(`Invalid domain "${domain}" — expected a hostname like ${slug}.lenne.tech (letters, digits, dashes, dots).`, 'deployment create: invalid domain');
100
178
  }
101
- else if (configGitHub !== undefined) {
102
- gitHub = configGitHub;
103
- if (gitHub) {
104
- info('Using GitHub pipeline setting from lt.config: enabled');
105
- }
179
+ // TurboOps project slug (registry namespace + login user). Defaults to the kebab name.
180
+ const rawProject = cliProject !== null && cliProject !== void 0 ? cliProject : (noConfirm ? slug : yield helper.getInput('', { initial: slug, name: `TurboOps project slug (e.g. ${slug})` }));
181
+ if (!rawProject) {
182
+ return fail('No TurboOps project slug given.', 'deployment create: no project');
106
183
  }
107
- else if (noConfirm) {
108
- gitHub = false; // Default to false when noConfirm
109
- }
110
- else {
111
- gitHub = yield confirm('Add GitHub pipeline?');
184
+ // `docker login -u "$TURBOOPS_PROJECT"` and `registry.turbo-ops.de/<project>`
185
+ // only accept a lowercase dash-separated slug.
186
+ const project = kebabCase(rawProject);
187
+ if (!(0, turboops_config_1.isValidProjectSlug)(project)) {
188
+ return fail(`Invalid TurboOps project slug "${rawProject}" — expected lowercase letters, digits and dashes.`, 'deployment create: invalid project slug');
112
189
  }
113
- // Determine gitLab with priority: CLI > config > noConfirm > interactive
114
- let gitLab;
115
- if (cliGitLab !== undefined) {
116
- gitLab = cliGitLab === true || cliGitLab === 'true';
190
+ if (project !== rawProject) {
191
+ warning(`Normalized TurboOps project slug "${rawProject}" → "${project}".`);
117
192
  }
118
- else if (configGitLab !== undefined) {
119
- gitLab = configGitLab;
120
- if (gitLab) {
121
- info('Using GitLab pipeline setting from lt.config: enabled');
193
+ // Write `.turboops.json`. Merge instead of overwrite: the file is committed
194
+ // and a user may have added keys by hand; a re-run must not silently drop them.
195
+ const target = (0, path_1.join)(projectRoot, '.turboops.json');
196
+ const generateSpinner = spin('Generate .turboops.json');
197
+ const current = (0, turboops_config_1.readJsonObject)(filesystem, target);
198
+ const existing = current.value;
199
+ // File exists but did not parse into a usable object (malformed JSON, or a
200
+ // bare null/number/array). Don't silently clobber it.
201
+ if (current.found && !existing) {
202
+ generateSpinner.fail('.turboops.json exists but is not a usable JSON object');
203
+ if (noConfirm || !(yield confirm(`Replace the unusable ${target}?`, false))) {
204
+ return fail(`Refusing to overwrite unusable ${target}.`, 'deployment create: unusable config');
122
205
  }
123
206
  }
124
- else if (noConfirm) {
125
- gitLab = false; // Default to false when noConfirm
126
- }
127
- else {
128
- gitLab = yield confirm('Add GitLab pipeline?');
129
- }
130
- // GitLab test runner
131
- let testRunner;
132
- let prodRunner;
133
- if (gitLab) {
134
- // Determine testRunner with priority: CLI > config > interactive
135
- if (cliTestRunner) {
136
- testRunner = cliTestRunner;
137
- }
138
- else if (configTestRunner) {
139
- testRunner = configTestRunner;
140
- info(`Using test runner from lt.config: ${testRunner}`);
141
- }
142
- else {
143
- testRunner = yield helper.getInput('', {
144
- initial: 'docker-swarm',
145
- name: 'runner for test (tag in .gitlab-ci.yml, e.g. docker-swarm)',
146
- });
147
- }
148
- if (!testRunner) {
149
- return;
150
- }
151
- // Determine prodRunner with priority: CLI > config > interactive
152
- if (cliProdRunner) {
153
- prodRunner = cliProdRunner;
154
- }
155
- else if (configProdRunner) {
156
- prodRunner = configProdRunner;
157
- info(`Using prod runner from lt.config: ${prodRunner}`);
207
+ if (existing) {
208
+ const merged = (0, turboops_config_1.mergeTurboOpsConfig)(existing, project);
209
+ if (!merged.changed) {
210
+ generateSpinner.succeed('.turboops.json already up to date');
158
211
  }
159
212
  else {
160
- prodRunner = yield helper.getInput('', {
161
- initial: 'docker-landing',
162
- name: 'runner for production (tag in .gitlab-ci.yml, e.g. docker-landing)',
163
- });
164
- }
165
- if (!prodRunner) {
166
- return;
213
+ if (merged.previousProject && merged.previousProject !== project) {
214
+ warning(`Changing TurboOps project "${merged.previousProject}" → "${project}".`);
215
+ }
216
+ // Trailing newline matches the template-generated form, so re-runs stay
217
+ // byte-stable and never produce a whitespace-only diff.
218
+ filesystem.write(target, `${JSON.stringify(merged.config, null, 2)}\n`);
219
+ generateSpinner.succeed('.turboops.json updated (existing keys preserved)');
167
220
  }
168
221
  }
169
- // Set up initial props (to pass into templates)
170
- const nameCamel = camelCase(name);
171
- const nameKebab = kebabCase(name);
172
- const namePascal = pascalCase(name);
173
- // Check if directory
174
- const cwd = filesystem.cwd();
175
- const generateSpinner = spin('Generate files');
176
- yield template.generate({
177
- props: { nameCamel, nameKebab, namePascal },
178
- target: (0, path_1.join)(cwd, 'scripts', 'build-push.sh'),
179
- template: 'deployment/scripts/build-push.sh.ejs',
180
- });
181
- yield template.generate({
182
- props: { nameCamel, nameKebab, namePascal },
183
- target: (0, path_1.join)(cwd, 'scripts', 'deploy.sh'),
184
- template: 'deployment/scripts/deploy.sh.ejs',
185
- });
186
- yield template.generate({
187
- props: { nameCamel, nameKebab, namePascal },
188
- target: (0, path_1.join)(cwd, 'Dockerfile'),
189
- template: 'deployment/Dockerfile.ejs',
190
- });
191
- yield template.generate({
192
- props: { nameCamel, nameKebab, namePascal },
193
- target: (0, path_1.join)(cwd, 'Dockerfile.app'),
194
- template: 'deployment/Dockerfile.app.ejs',
195
- });
196
- yield template.generate({
197
- props: { nameCamel, nameKebab, namePascal },
198
- target: (0, path_1.join)(cwd, 'docker-compose.dev.yml'),
199
- template: 'deployment/docker-compose.dev.yml.ejs',
200
- });
201
- yield template.generate({
202
- props: { nameCamel, nameKebab, namePascal },
203
- target: (0, path_1.join)(cwd, 'docker-compose.test.yml'),
204
- template: 'deployment/docker-compose.test.yml.ejs',
205
- });
206
- yield template.generate({
207
- props: { nameCamel, nameKebab, namePascal },
208
- target: (0, path_1.join)(cwd, 'docker-compose.prod.yml'),
209
- template: 'deployment/docker-compose.prod.yml.ejs',
210
- });
211
- if (gitHub) {
212
- yield template.generate({
213
- props: { nameCamel, nameKebab, namePascal, url: domain },
214
- target: (0, path_1.join)(cwd, '.github', 'workflows', 'pre-release.yml'),
215
- template: 'deployment/.github/workflows/pre-release.yml.ejs',
216
- });
217
- yield template.generate({
218
- props: { nameCamel, nameKebab, namePascal, url: domain },
219
- target: (0, path_1.join)(cwd, '.github', 'workflows', 'release.yml'),
220
- template: 'deployment/.github/workflows/release.yml.ejs',
221
- });
222
- }
223
- if (gitLab) {
224
- yield template.generate({
225
- props: { nameCamel, nameKebab, namePascal, prodRunner, testRunner, url: domain },
226
- target: (0, path_1.join)(cwd, '.gitlab-ci.yml'),
227
- template: 'deployment/.gitlab-ci.yml.ejs',
228
- });
229
- }
230
- generateSpinner.succeed('Files generated');
231
- const environmentsSpinner = spin('Update app environment files');
232
- const prodEnv = yield filesystem.exists('projects/app/src/environments/environment.prod.ts');
233
- if (prodEnv) {
234
- yield patching.patch('projects/app/src/environments/environment.prod.ts', {
235
- insert: `https://api.${domain}`,
236
- replace: new RegExp('http://127.0.0.1:3000', 'g'),
237
- });
238
- yield patching.patch('projects/app/src/environments/environment.prod.ts', {
239
- insert: `wss://api.${domain}`,
240
- replace: new RegExp('ws://127.0.0.1:3000', 'g'),
241
- });
242
- yield patching.patch('projects/app/src/environments/environment.prod.ts', {
243
- insert: `https://${domain}`,
244
- replace: new RegExp('http://127.0.0.1:4200', 'g'),
245
- });
246
- }
247
222
  else {
248
- info('Missing projects/app/src/environments/environment.prod.ts');
249
- }
250
- const testEnv = yield filesystem.exists('projects/app/src/environments/environment.test.ts');
251
- if (testEnv) {
252
- yield patching.patch('projects/app/src/environments/environment.test.ts', {
253
- insert: `https://api.test.${domain}`,
254
- replace: new RegExp('http://127.0.0.1:3000', 'g'),
255
- });
256
- yield patching.patch('projects/app/src/environments/environment.test.ts', {
257
- insert: `wss://api.test.${domain}`,
258
- replace: new RegExp('ws://127.0.0.1:3000', 'g'),
259
- });
260
- yield patching.patch('projects/app/src/environments/environment.test.ts', {
261
- insert: `https://test.${domain}`,
262
- replace: new RegExp('http://127.0.0.1:4200', 'g'),
223
+ yield template.generate({
224
+ props: { project },
225
+ target,
226
+ template: 'deployment/turboops.json.ejs',
263
227
  });
228
+ generateSpinner.succeed('.turboops.json generated');
264
229
  }
265
- else {
266
- info('Missing projects/app/src/environments/environment.test.ts');
230
+ // Angular bakes its API URL into the bundle at build time, so the deployed
231
+ // stage URLs must be written into `environment.*.ts`. No-op for Nuxt, which
232
+ // reads them from the TurboOps stage env at runtime. Detect Angular from the
233
+ // presence of the environments dir, NOT from the patch count — a project
234
+ // using non-standard env filenames would otherwise be misread as Nuxt and
235
+ // ship a bundle that still calls localhost.
236
+ const isAngular = (0, angular_environments_1.findAngularEnvironmentsDir)(projectRoot) !== null;
237
+ if (isAngular) {
238
+ const envPatches = (0, angular_environments_1.patchAngularEnvironments)({ domain, projectRoot });
239
+ const changed = envPatches.filter((r) => r.patched);
240
+ if (changed.length > 0) {
241
+ for (const r of changed) {
242
+ success(`patched ${r.replacements}× in ${(0, path_1.relative)(projectRoot, r.file)} (deployed URLs)`);
243
+ }
244
+ }
245
+ else if (envPatches.some((r) => r.recognized)) {
246
+ info('Angular environment files already point at the deployed URLs.');
247
+ }
248
+ else {
249
+ warning('Angular project detected but no environment.{prod,develop,test}.ts with a rewritable URL was found — ' +
250
+ 'set the deployed API/app URLs manually so the bundle does not call localhost.');
251
+ }
267
252
  }
268
- environmentsSpinner.succeed('App environment files updated');
269
- // We're done, so show what to do next
253
+ // One-time setup checklist
270
254
  info('');
271
- success(`Generated deployment for ${namePascal} in ${helper.msToMinutesAndSeconds(timer())}m.`);
255
+ success(`TurboOps deployment wired for "${name}" in ${helper.msToMinutesAndSeconds(timer())}m.`);
272
256
  info('');
273
- // Hint for CI/CD
274
- const subDomains = ['www', 'api', 'test', 'www.test', 'api.test'];
275
- let urlStr = `\n- ${domain}`;
276
- for (const sub of subDomains) {
277
- urlStr += `\n- ${sub}.${domain}`;
257
+ info('One-time setup:');
258
+ info(` 1. TurboOps: create project "${project}" with stages "dev" and "production" on your server.`);
259
+ info(` - production domains: ${domain} (app) + api.${domain} (api)`);
260
+ info(` - dev domains: dev.${domain} (app) + api.dev.${domain} (api)`);
261
+ info(` 2. CI/CD variables: TURBOOPS_PROJECT=${project} and TURBOOPS_TOKEN=<token> (masked).`);
262
+ info(' GitLab: Settings > CI/CD > Variables. GitHub: a repo variable + a repo secret.');
263
+ info(' 3. DNS A/AAAA records -> your server IP. A wildcard matches exactly ONE label, so:');
264
+ info(` - ${domain} (apex — NOT covered by a wildcard)`);
265
+ info(` - *.${domain} (covers api.${domain} and dev.${domain})`);
266
+ info(` - *.dev.${domain} (covers api.dev.${domain})`);
267
+ info(' 4. Set the stage env vars in TurboOps (per stage), e.g. for production:');
268
+ info(` NODE_ENV=production, NSC__BASE_URL=https://api.${domain},`);
269
+ info(' NSC__MONGOOSE__URI, NSC__BETTER_AUTH__SECRET, NSC__AI__ENCRYPTION_SECRET,');
270
+ if (isAngular) {
271
+ info(' NSC__EMAIL__SMTP__*, NSC__EMAIL__DEFAULT_SENDER__EMAIL');
272
+ info(' The Angular app needs no URL env vars — they are baked into');
273
+ info(' environment.prod.ts / environment.develop.ts (patched above).');
274
+ }
275
+ else {
276
+ info(' NSC__EMAIL__SMTP__*, NSC__EMAIL__DEFAULT_SENDER__EMAIL,');
277
+ info(` NUXT_PUBLIC_API_URL=https://api.${domain}, NUXT_API_URL=https://api.${domain},`);
278
+ info(` NUXT_PUBLIC_SITE_URL=https://${domain}`);
278
279
  }
279
- success(`HINT: please initialize following Domains before running the CI/CD pipeline:${urlStr}`);
280
+ info(isAngular
281
+ ? ' 5. Commit `.turboops.json` (read by `turbo deploy`) AND the patched environment.*.ts (baked into the build).'
282
+ : ' 5. Commit `.turboops.json` — `turbo deploy` reads it from the repo root in CI.');
280
283
  info('');
281
- if (!toolbox.parameters.options.fromGluegunMenu) {
284
+ info('Then: push `dev` -> the dev stage, push `main` -> the production stage');
285
+ info('(.gitlab-ci.yml, or .github/workflows/deploy.yml on GitHub). Tests gate the deploy.');
286
+ if (!parameters.options.fromGluegunMenu) {
282
287
  process.exit();
283
288
  }
284
289
  // For tests
@@ -47,7 +47,8 @@ const InitCommand = {
47
47
  const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
48
48
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
49
49
  if (!layout.apiDir && !layout.appDir) {
50
- error('No API (src/config.env.ts) or App (nuxt.config.ts) project detected at this path.');
50
+ error('No lt project detected at this path. Expected an API (src/config.env.ts or nest-cli.json), ' +
51
+ 'an App (nuxt.config.ts), or a monorepo with projects/api and/or projects/app.');
51
52
  if (!parameters.options.fromGluegunMenu)
52
53
  process.exit(1);
53
54
  return 'dev init: not a project';
@@ -128,6 +128,8 @@ const StatusCommand = {
128
128
  const port = sub === 'api' ? entry.internalPorts.api : entry.internalPorts.app;
129
129
  info(` ${sub.padEnd(6)} https://${host}${port ? colors.dim(` → 127.0.0.1:${port}`) : ''}`);
130
130
  }
131
+ // `loadRegistry` strips dbName from API-less (App-only) entries, so its
132
+ // presence alone means there is a database to advertise.
131
133
  if (entry.dbName)
132
134
  info(` db mongodb://127.0.0.1/${entry.dbName}`);
133
135
  // Patch status — quick view whether legacy ports are still in source.
@@ -224,9 +224,9 @@ const TestCommand = {
224
224
  let exitCode = 1;
225
225
  try {
226
226
  const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log, { devDbName });
227
- const env = Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
227
+ const env = Object.assign(Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
228
228
  // Playwright global-setup resets THIS db (allow-listed) before the suite.
229
- MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
229
+ MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {}));
230
230
  if (debug) {
231
231
  env.PWDEBUG = '1';
232
232
  env.HEADED = '1';
@@ -86,7 +86,8 @@ const UpCommand = {
86
86
  const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
87
87
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
88
88
  if (!layout.apiDir && !layout.appDir) {
89
- error('No API or App project detected at this path. Run `lt dev init` first.');
89
+ error('No lt project detected at this path. Expected an API (src/config.env.ts or nest-cli.json), ' +
90
+ 'an App (nuxt.config.ts), or a monorepo with projects/api and/or projects/app.');
90
91
  if (!parameters.options.fromGluegunMenu)
91
92
  process.exit(1);
92
93
  return 'dev up: not a project';
@@ -271,7 +272,9 @@ const UpCommand = {
271
272
  apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
272
273
  appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
273
274
  appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
274
- dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
275
+ // App-only projects have no database don't advertise a Mongo URL
276
+ // that nothing listens on.
277
+ dbName: identity.subdomains.api ? ((_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName) : undefined,
275
278
  });
276
279
  info('Run `lt dev down` first to force a full restart.');
277
280
  if (!parameters.options.fromGluegunMenu)
@@ -317,7 +320,8 @@ const UpCommand = {
317
320
  info(` app: https://${identity.subdomains.app.hostname} → 127.0.0.1:${appPort}`);
318
321
  if (identity.subdomains.api)
319
322
  info(` api: https://${identity.subdomains.api.hostname} → 127.0.0.1:${apiPort}`);
320
- info(` db: mongodb://127.0.0.1/${dbName}`);
323
+ if (identity.subdomains.api)
324
+ info(` db: mongodb://127.0.0.1/${dbName}`);
321
325
  info('');
322
326
  // Build env per process.
323
327
  const devEnv = (0, dev_env_1.buildDevEnv)({
@@ -414,7 +418,7 @@ const UpCommand = {
414
418
  apiUpstreamPort: apiPort,
415
419
  appHostname: (_l = identity.subdomains.app) === null || _l === void 0 ? void 0 : _l.hostname,
416
420
  appUpstreamPort: appPort,
417
- dbName,
421
+ dbName: identity.subdomains.api ? dbName : undefined,
418
422
  });
419
423
  info(colors.dim('Logs: <root>/.lt-dev/api.log, <root>/.lt-dev/app.log'));
420
424
  for (const note of rotationNotes)