@lenne.tech/cli 1.26.1 → 1.28.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.
@@ -14,11 +14,11 @@ const path_1 = require("path");
14
14
  const caddy_1 = require("../../lib/caddy");
15
15
  const dev_env_1 = require("../../lib/dev-env");
16
16
  const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
17
- const dev_identity_1 = require("../../lib/dev-identity");
18
17
  const dev_patches_1 = require("../../lib/dev-patches");
19
18
  const dev_process_1 = require("../../lib/dev-process");
20
19
  const dev_project_1 = require("../../lib/dev-project");
21
20
  const dev_state_1 = require("../../lib/dev-state");
21
+ const dev_ticket_1 = require("../../lib/dev-ticket");
22
22
  /**
23
23
  * Start API + App behind Caddy with project-specific URLs.
24
24
  *
@@ -81,7 +81,7 @@ const UpCommand = {
81
81
  hidden: false,
82
82
  name: 'up',
83
83
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
84
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
84
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
85
85
  const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
86
86
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
87
87
  if (!layout.apiDir && !layout.appDir) {
@@ -103,21 +103,48 @@ const UpCommand = {
103
103
  process.exit(1);
104
104
  return 'dev up: caddy daemon down';
105
105
  }
106
- const identity = (0, dev_identity_1.buildIdentity)(layout.root);
107
- const dbName = (0, dev_project_1.deriveDbName)(layout.apiDir, identity.slug);
106
+ // Ticket-aware: in a `lt ticket` worktree (tagged by a `.lt-dev/ticket`
107
+ // marker) or with an explicit `--ticket <name>` — the slug / URLs / DB are
108
+ // suffixed so the stack is fully isolated from the base dev session and every
109
+ // other ticket. Without a ticket this is the plain project identity.
110
+ const { dbName, identity, ticket } = (0, dev_ticket_1.resolveDevIdentity)(layout, { ticket: parameters.options.ticket });
111
+ // Guard against two checkouts of the SAME project (same package.json "name"
112
+ // → same slug → shared URLs / ports / DB / Caddy block). If another checkout
113
+ // is already RUNNING under this slug, abort with a clear message — otherwise
114
+ // both fight over the same ports and one `lt dev down` unroutes the other.
115
+ {
116
+ const conflict = (0, dev_state_1.detectSlugConflict)(identity.slug, layout.root);
117
+ if (conflict === null || conflict === void 0 ? void 0 : conflict.otherSessionAlive) {
118
+ error(`Slug "${identity.slug}" is already in use by another RUNNING checkout:`);
119
+ info(colors.dim(` ${conflict.otherPath}`));
120
+ info('Two checkouts of the same project share URLs / ports / database and collide.');
121
+ info('Stop it there (`lt dev down`), or give THIS checkout a distinct package.json "name".');
122
+ if (!parameters.options.fromGluegunMenu)
123
+ process.exit(1);
124
+ return 'dev up: slug in use by another checkout';
125
+ }
126
+ }
108
127
  // Sanft auto-migrate sichere Operationen (ohne Code-Modifikation):
109
128
  // CLAUDE.md-URL-Block einfügen + .gitignore ergänzen.
110
129
  // Code-Patches (config.env.ts, nuxt.config.ts) bleiben explizit `lt dev init`.
111
130
  {
112
- const claudeCandidates = [
113
- (0, path_1.join)(layout.root, 'CLAUDE.md'),
114
- ...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
115
- ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
116
- ];
117
- const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
118
- if (patched.length > 0) {
119
- info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
131
+ // NEVER patch the git-tracked CLAUDE.md for a ticket worktree: it would
132
+ // differ per worktree and risk committing ticket-specific URLs. The lt-dev
133
+ // plugin hook surfaces the ticket context per prompt instead (from the
134
+ // gitignored `.lt-dev/ticket` marker). For the base project we keep the
135
+ // committed URL block up to date as before.
136
+ if (!ticket) {
137
+ const claudeCandidates = [
138
+ (0, path_1.join)(layout.root, 'CLAUDE.md'),
139
+ ...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
140
+ ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
141
+ ];
142
+ const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
143
+ if (patched.length > 0) {
144
+ info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
145
+ }
120
146
  }
147
+ // Always keep `.lt-dev/` (state, env bridge, ticket marker) out of git.
121
148
  if ((0, dev_patches_1.addToGitignore)(layout.root, '.lt-dev/')) {
122
149
  info(colors.dim('added `.lt-dev/` to .gitignore'));
123
150
  }
@@ -154,7 +181,7 @@ const UpCommand = {
154
181
  apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
155
182
  appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
156
183
  appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
157
- dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : (0, dev_project_1.deriveDbName)(layout.apiDir, identity.slug),
184
+ dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
158
185
  });
159
186
  info('Run `lt dev down` first.');
160
187
  if (!parameters.options.fromGluegunMenu)
@@ -162,25 +189,48 @@ const UpCommand = {
162
189
  return 'dev up: already running';
163
190
  }
164
191
  }
165
- // Allocate internal ports (reuse existing if registered).
166
- const reg = (0, dev_state_1.loadRegistry)();
167
- const entry = reg.projects[identity.slug];
168
- const taken = (0, dev_state_1.takenInternalPorts)(reg, identity.slug);
169
- const apiPort = (_f = entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) !== null && _f !== void 0 ? _f : (layout.apiDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
170
- if (apiPort)
171
- taken.add(apiPort);
172
- const appPort = (_g = entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) !== null && _g !== void 0 ? _g : (layout.appDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
173
- // Pre-flight: internal ports free?
174
- const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
175
- const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
176
- for (const p of portsToCheck) {
177
- const r = snap.get(p);
178
- if (r) {
179
- error(`Internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
180
- if (!parameters.options.fromGluegunMenu)
181
- process.exit(1);
182
- return 'dev up: port in use';
183
- }
192
+ // Allocate internal ports (reuse existing if registered), verify they are
193
+ // free, AND reserve them in the registry — all ATOMICALLY under a cross-
194
+ // process lock, so two simultaneous `lt ticket start` (each → `lt dev up`)
195
+ // can never grab the same ports.
196
+ let apiPort;
197
+ let appPort;
198
+ try {
199
+ yield (0, dev_state_1.withRegistryLock)(() => __awaiter(void 0, void 0, void 0, function* () {
200
+ var _a, _b;
201
+ const reg = (0, dev_state_1.loadRegistry)();
202
+ const entry = reg.projects[identity.slug];
203
+ const taken = (0, dev_state_1.takenInternalPorts)(reg, identity.slug);
204
+ apiPort = (_a = entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) !== null && _a !== void 0 ? _a : (layout.apiDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
205
+ if (apiPort)
206
+ taken.add(apiPort);
207
+ appPort = (_b = entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) !== null && _b !== void 0 ? _b : (layout.appDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
208
+ const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
209
+ const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
210
+ for (const p of portsToCheck) {
211
+ const r = snap.get(p);
212
+ if (r)
213
+ throw new Error(`Internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
214
+ }
215
+ // Reserve immediately so a concurrent `lt dev up` sees these as taken.
216
+ const subdomainMap = {};
217
+ for (const [k, v] of Object.entries(identity.subdomains))
218
+ subdomainMap[k] = v.hostname;
219
+ reg.projects[identity.slug] = {
220
+ dbName,
221
+ internalPorts: { api: apiPort, app: appPort },
222
+ lastUsedAt: new Date().toISOString(),
223
+ path: layout.root,
224
+ subdomains: subdomainMap,
225
+ };
226
+ (0, dev_state_1.saveRegistry)(reg);
227
+ }));
228
+ }
229
+ catch (e) {
230
+ error(e.message);
231
+ if (!parameters.options.fromGluegunMenu)
232
+ process.exit(1);
233
+ return 'dev up: port in use';
184
234
  }
185
235
  // Caddy block + reload.
186
236
  const routes = [];
@@ -203,7 +253,7 @@ const UpCommand = {
203
253
  return 'dev up: caddy reload failed';
204
254
  }
205
255
  info('');
206
- info(colors.bold(`Starting "${identity.slug}"`));
256
+ info(colors.bold(`Starting "${identity.slug}"`) + (ticket ? colors.dim(` (ticket ${ticket})`) : ''));
207
257
  if (identity.subdomains.app)
208
258
  info(` app: https://${identity.subdomains.app.hostname} → 127.0.0.1:${appPort}`);
209
259
  if (identity.subdomains.api)
@@ -230,7 +280,7 @@ const UpCommand = {
230
280
  if (apiResult) {
231
281
  pids.api = apiResult.pid;
232
282
  if (apiResult.rotated.rotated && apiResult.rotated.archivePath !== undefined) {
233
- rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_h = apiResult.rotated.previousSize) !== null && _h !== void 0 ? _h : 0));
283
+ rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_f = apiResult.rotated.previousSize) !== null && _f !== void 0 ? _f : 0));
234
284
  }
235
285
  }
236
286
  }
@@ -243,35 +293,25 @@ const UpCommand = {
243
293
  if (appResult) {
244
294
  pids.app = appResult.pid;
245
295
  if (appResult.rotated.rotated && appResult.rotated.archivePath !== undefined) {
246
- rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_j = appResult.rotated.previousSize) !== null && _j !== void 0 ? _j : 0));
296
+ rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_g = appResult.rotated.previousSize) !== null && _g !== void 0 ? _g : 0));
247
297
  }
248
298
  }
249
299
  }
250
- // Persist.
251
- const subdomainMap = {};
252
- for (const [k, v] of Object.entries(identity.subdomains))
253
- subdomainMap[k] = v.hostname;
254
- reg.projects[identity.slug] = {
255
- dbName,
256
- internalPorts: { api: apiPort, app: appPort },
257
- lastUsedAt: new Date().toISOString(),
258
- path: layout.root,
259
- subdomains: subdomainMap,
260
- };
261
- (0, dev_state_1.saveRegistry)(reg);
300
+ // Persist the session (PIDs). The registry entry (ports) was already reserved
301
+ // atomically before the spawn, above.
262
302
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() });
263
303
  // Write the ENV bridge so external tools (Playwright, IDE test runners,
264
304
  // custom shell scripts) can pick up the URLs without inheriting our shell.
265
305
  const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
266
306
  info(colors.dim(`ENV bridge: ${bridgePath}`));
267
- success(`Started: api pid=${(_k = pids.api) !== null && _k !== void 0 ? _k : '-'}, app pid=${(_l = pids.app) !== null && _l !== void 0 ? _l : '-'}`);
307
+ success(`Started: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
268
308
  // Echo the bound URLs next to the PIDs as well — the "Starting" block
269
309
  // prints them before the spawn, but on a long boot log they scroll out
270
310
  // of view, so repeating them here keeps PID + URL visually grouped.
271
311
  printProjectUrls(info, {
272
- apiHostname: (_m = identity.subdomains.api) === null || _m === void 0 ? void 0 : _m.hostname,
312
+ apiHostname: (_k = identity.subdomains.api) === null || _k === void 0 ? void 0 : _k.hostname,
273
313
  apiUpstreamPort: apiPort,
274
- appHostname: (_o = identity.subdomains.app) === null || _o === void 0 ? void 0 : _o.hostname,
314
+ appHostname: (_l = identity.subdomains.app) === null || _l === void 0 ? void 0 : _l.hostname,
275
315
  appUpstreamPort: appPort,
276
316
  dbName,
277
317
  });
@@ -0,0 +1,78 @@
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
+ const dev_identity_1 = require("../../lib/dev-identity");
13
+ const dev_project_1 = require("../../lib/dev-project");
14
+ const dev_state_1 = require("../../lib/dev-state");
15
+ const dev_ticket_1 = require("../../lib/dev-ticket");
16
+ /**
17
+ * `lt ticket list` — the dashboard of all active ticket worktrees: id, folder,
18
+ * branch, URLs, DB and running state. The one place to re-view every ticket's
19
+ * URLs at any time.
20
+ */
21
+ const ListCommand = {
22
+ alias: ['ls'],
23
+ description: 'List all ticket worktrees with their URLs + status',
24
+ name: 'list',
25
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
26
+ var _a;
27
+ const { filesystem, parameters, print: { colors, info, warning }, } = toolbox;
28
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
29
+ let mainRepoRoot;
30
+ try {
31
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
32
+ }
33
+ catch (_b) {
34
+ warning('Not inside a git repository.');
35
+ if (!parameters.options.fromGluegunMenu)
36
+ process.exit(1);
37
+ return 'ticket list: not a git repo';
38
+ }
39
+ const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
40
+ const tickets = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).filter((w) => w.ticket);
41
+ const reg = (0, dev_state_1.loadRegistry)();
42
+ info('');
43
+ info(colors.bold('Ticket environments'));
44
+ info(colors.dim('─'.repeat(64)));
45
+ if (tickets.length === 0) {
46
+ info(colors.dim(' none — start one with `lt ticket start <ticket-or-name>`'));
47
+ info('');
48
+ if (!parameters.options.fromGluegunMenu)
49
+ process.exit();
50
+ return 'ticket list: empty';
51
+ }
52
+ for (const wt of tickets) {
53
+ const session = (0, dev_state_1.loadSession)(wt.path);
54
+ const apiAlive = (session === null || session === void 0 ? void 0 : session.pids.api) ? (0, dev_state_1.isPidAlive)(session.pids.api) : false;
55
+ const appAlive = (session === null || session === void 0 ? void 0 : session.pids.app) ? (0, dev_state_1.isPidAlive)(session.pids.app) : false;
56
+ const running = apiAlive || appAlive;
57
+ const dot = running ? colors.green('●') : colors.dim('○');
58
+ // The dev stack for this ticket is registered under `<base>-<id>`.
59
+ const entry = reg.projects[`${base.slug}-${wt.ticket}`];
60
+ info(` ${dot} ${colors.bold(String(wt.ticket).padEnd(16))} ${colors.dim(`branch ${(_a = wt.branch) !== null && _a !== void 0 ? _a : '-'}`)}`);
61
+ if (entry) {
62
+ for (const [sub, host] of Object.entries(entry.subdomains))
63
+ info(` ${sub.padEnd(4)} https://${host}`);
64
+ if (entry.dbName)
65
+ info(` db mongodb://127.0.0.1/${entry.dbName}`);
66
+ }
67
+ info(colors.dim(` dir ${wt.path}`));
68
+ info(colors.dim(` ${running ? 'running' : 'stopped'}${entry ? '' : ' (not brought up yet)'}`));
69
+ }
70
+ info('');
71
+ info(colors.dim('Open: `code <dir>` / `lt ticket switch <id>` · Test: `lt ticket test <id>` · Stop: `lt ticket stop <id>`'));
72
+ info('');
73
+ if (!parameters.options.fromGluegunMenu)
74
+ process.exit();
75
+ return `ticket list: ${tickets.length}`;
76
+ }),
77
+ };
78
+ module.exports = ListCommand;
@@ -0,0 +1,141 @@
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
+ const fs_1 = require("fs");
13
+ const dev_identity_1 = require("../../lib/dev-identity");
14
+ const dev_process_1 = require("../../lib/dev-process");
15
+ const dev_project_1 = require("../../lib/dev-project");
16
+ const dev_state_1 = require("../../lib/dev-state");
17
+ const dev_ticket_1 = require("../../lib/dev-ticket");
18
+ /**
19
+ * `lt ticket start <name>` — spin up a fully-isolated parallel dev environment
20
+ * for a ticket or feature, in seconds:
21
+ *
22
+ * 1. `git fetch` + create a git WORKTREE on a fresh branch from `origin/dev`
23
+ * (so every ticket starts independent from the latest dev) at a sibling
24
+ * folder `<parent>/<slug>-<id>`,
25
+ * 2. tag it with a `.lt-dev/ticket` marker (makes every `lt dev *` in it
26
+ * ticket-aware),
27
+ * 3. `pnpm install` (hard-links from the shared store → fast),
28
+ * 4. `lt dev up` → own URLs (`<slug>-<id>.localhost` / `api.<slug>-<id>…`),
29
+ * own ports, own Caddy block, own EMPTY DB (`<base>-<id>`).
30
+ *
31
+ * lt ticket start DEV-2200 → svl-2200.localhost (branch feat/DEV-2200)
32
+ * lt ticket start checkout-refactor → svl-checkout-refactor.localhost
33
+ * lt ticket start DEV-2200 --as cof → svl-cof.localhost
34
+ */
35
+ const StartCommand = {
36
+ description: 'Start an isolated parallel dev env for a ticket/feature (worktree + lt dev up)',
37
+ name: 'start',
38
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
39
+ var _a;
40
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
41
+ // `String(...)` — a purely-numeric arg (e.g. a ticket "9991") is parsed as a
42
+ // JS number by the option parser, on which `.trim()` would throw.
43
+ const name = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
44
+ if (!name) {
45
+ error('Usage: lt ticket start <ticket-or-name> [--as <id>] [--branch <branch>] [--base <ref>]');
46
+ info(colors.dim(' e.g. lt ticket start DEV-2200 | lt ticket start checkout-refactor'));
47
+ if (!parameters.options.fromGluegunMenu)
48
+ process.exit(1);
49
+ return 'ticket start: missing name';
50
+ }
51
+ // Anchor to the MAIN repo (works even when invoked from inside a worktree).
52
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
53
+ let mainRepoRoot;
54
+ try {
55
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
56
+ }
57
+ catch (_b) {
58
+ error('Not inside a git repository — `lt ticket` needs a git project.');
59
+ if (!parameters.options.fromGluegunMenu)
60
+ process.exit(1);
61
+ return 'ticket start: not a git repo';
62
+ }
63
+ const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
64
+ const id = (0, dev_ticket_1.deriveTicketId)(name, parameters.options.as != null ? String(parameters.options.as) : undefined);
65
+ const branch = typeof parameters.options.branch === 'string' ? parameters.options.branch : (0, dev_ticket_1.defaultTicketBranch)(name);
66
+ const baseRef = typeof parameters.options.base === 'string' ? parameters.options.base : 'origin/dev';
67
+ const ticketIdentity = (0, dev_identity_1.buildTicketIdentity)(base, id);
68
+ const dbName = (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, base.slug), id);
69
+ const worktreePath = (0, dev_ticket_1.worktreePathFor)(mainRepoRoot, base.slug, id);
70
+ // Pre-flight: nothing already there + slug free.
71
+ if ((0, fs_1.existsSync)(worktreePath)) {
72
+ error(`Target folder already exists: ${worktreePath}`);
73
+ info(colors.dim(' Use a different id (`--as <id>`) or remove it with `lt ticket stop`.'));
74
+ if (!parameters.options.fromGluegunMenu)
75
+ process.exit(1);
76
+ return 'ticket start: path exists';
77
+ }
78
+ if ((0, dev_state_1.loadRegistry)().projects[ticketIdentity.slug]) {
79
+ warning(`An env named "${ticketIdentity.slug}" is already registered — choose another id with --as.`);
80
+ }
81
+ info('');
82
+ info(colors.bold(`Starting ticket "${id}" → ${ticketIdentity.slug}`));
83
+ info(colors.dim(` branch: ${branch} (from ${baseRef})`));
84
+ info(colors.dim(` worktree: ${worktreePath}`));
85
+ info('');
86
+ // 1. fetch + worktree (fresh branch from origin/dev → independent).
87
+ try {
88
+ info(colors.dim(`Fetching + creating worktree from ${baseRef} …`));
89
+ (0, dev_ticket_1.gitFetch)(mainRepoRoot);
90
+ (0, dev_ticket_1.worktreeAdd)(mainRepoRoot, worktreePath, branch, baseRef);
91
+ }
92
+ catch (e) {
93
+ error(`git worktree setup failed: ${e.message}`);
94
+ if (!parameters.options.fromGluegunMenu)
95
+ process.exit(1);
96
+ return 'ticket start: worktree failed';
97
+ }
98
+ // 2. tag the worktree with its ticket id (makes lt dev * ticket-aware).
99
+ (0, dev_ticket_1.writeTicketMarker)(worktreePath, id);
100
+ // 3. install deps (pnpm hard-links from the shared store → fast).
101
+ if (parameters.options.install !== false) {
102
+ info(colors.dim('Installing dependencies (pnpm) …'));
103
+ try {
104
+ (0, dev_ticket_1.pnpmInstall)(worktreePath);
105
+ }
106
+ catch (e) {
107
+ warning(`pnpm install failed (${e.message}) — continuing; run it manually in the worktree.`);
108
+ }
109
+ }
110
+ // 4. bring the isolated stack up (re-invokes THIS lt build so the marker is
111
+ // honoured even before a global recompile). `--no-up` just scaffolds.
112
+ if (parameters.options.up !== false) {
113
+ info('');
114
+ info(colors.dim('Bringing up the isolated stack (lt dev up) …'));
115
+ const code = yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'up'], {
116
+ cwd: worktreePath,
117
+ env: process.env,
118
+ });
119
+ if (code !== 0)
120
+ warning(`lt dev up exited ${code} — inspect with \`cd ${worktreePath} && lt dev status\`.`);
121
+ }
122
+ // Summary — the URLs the user works with (always re-viewable via `lt ticket list`).
123
+ info('');
124
+ success(`Ticket "${id}" ready.`);
125
+ if (ticketIdentity.subdomains.app)
126
+ info(` app: https://${ticketIdentity.subdomains.app.hostname}`);
127
+ if (ticketIdentity.subdomains.api)
128
+ info(` api: https://${ticketIdentity.subdomains.api.hostname}`);
129
+ info(` db: mongodb://127.0.0.1/${dbName} (empty)`);
130
+ info(` dir: ${worktreePath}`);
131
+ info('');
132
+ info(colors.dim(`Open it: code ${worktreePath} (or: lt ticket switch ${id})`));
133
+ info(colors.dim(`Test it: cd ${worktreePath} && lt dev test (or: lt ticket test ${id})`));
134
+ info(colors.dim('All envs: lt ticket list'));
135
+ info(colors.dim(`Stop it: lt ticket stop ${id}`));
136
+ if (!parameters.options.fromGluegunMenu)
137
+ process.exit();
138
+ return `ticket start: ${id}`;
139
+ }),
140
+ };
141
+ module.exports = StartCommand;
@@ -0,0 +1,166 @@
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
+ const fs_1 = require("fs");
13
+ const dev_identity_1 = require("../../lib/dev-identity");
14
+ const dev_process_1 = require("../../lib/dev-process");
15
+ const dev_project_1 = require("../../lib/dev-project");
16
+ const dev_state_1 = require("../../lib/dev-state");
17
+ const dev_ticket_1 = require("../../lib/dev-ticket");
18
+ /**
19
+ * `lt ticket stop [<id>]` — tear a ticket env down + remove its worktree.
20
+ *
21
+ * 1. `lt dev down` inside the worktree (stops the ticket stack + any test
22
+ * stacks, removes the Caddy block — residue-free),
23
+ * 2. `git worktree remove` (the BRANCH is kept, so nothing is lost),
24
+ * 3. `--drop-db` also drops the ticket's empty dev + test databases.
25
+ *
26
+ * Run with NO id from INSIDE a ticket worktree to clean up THIS environment
27
+ * (the current folder is removed; the process steps out to the main repo first).
28
+ */
29
+ const StopCommand = {
30
+ alias: ['rm'],
31
+ description: 'Stop a ticket env + remove its worktree (branch kept); no id = the current worktree',
32
+ name: 'stop',
33
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
34
+ var _a, _b, _c, _d, _e;
35
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
36
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
37
+ let mainRepoRoot;
38
+ try {
39
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
40
+ }
41
+ catch (_f) {
42
+ error('Not inside a git repository.');
43
+ if (!parameters.options.fromGluegunMenu)
44
+ process.exit(1);
45
+ return 'ticket stop: not a git repo';
46
+ }
47
+ // Id from the argument — or, when invoked with NO id from INSIDE a ticket
48
+ // worktree, the current worktree's own ticket (so a bare `lt ticket stop`
49
+ // cleans up "this" environment and removes this very folder).
50
+ const fromMarker = parameters.first == null ? (0, dev_ticket_1.readTicketMarker)(layout.root) : null;
51
+ const id = String((_b = (_a = parameters.first) !== null && _a !== void 0 ? _a : fromMarker) !== null && _b !== void 0 ? _b : '').trim();
52
+ if (!id) {
53
+ error('Usage: lt ticket stop <id> [--drop-db] [--force] — or run with no id from INSIDE a ticket worktree.');
54
+ if (!parameters.options.fromGluegunMenu)
55
+ process.exit(1);
56
+ return 'ticket stop: missing id';
57
+ }
58
+ const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
59
+ if (!wt) {
60
+ error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
61
+ if (!parameters.options.fromGluegunMenu)
62
+ process.exit(1);
63
+ return 'ticket stop: not found';
64
+ }
65
+ // SAFETY: never silently delete unsaved work. Warn + REFUSE (unless --force)
66
+ // when the worktree has uncommitted changes OR unpushed commits, so the user
67
+ // commits + pushes first. (`--force` removes anyway; the branch is kept, so
68
+ // committed history survives regardless.)
69
+ const safety = (0, dev_ticket_1.worktreeSafetyReport)(wt.path);
70
+ if ((safety.dirtySource.length > 0 || safety.unpushed > 0) && parameters.options.force !== true) {
71
+ warning('');
72
+ warning(`Refusing to remove ticket "${id}" — work is not fully committed + pushed:`);
73
+ if (safety.dirtySource.length > 0) {
74
+ warning(` • ${safety.dirtySource.length} uncommitted change(s) — would be LOST on removal:`);
75
+ safety.dirtySource.slice(0, 12).forEach((l) => info(colors.dim(` ${l}`)));
76
+ if (safety.dirtySource.length > 12)
77
+ info(colors.dim(` … and ${safety.dirtySource.length - 12} more`));
78
+ }
79
+ if (safety.unpushed > 0) {
80
+ warning(` • ${safety.unpushed} commit(s) on "${(_c = wt.branch) !== null && _c !== void 0 ? _c : '-'}" not pushed to any remote`);
81
+ }
82
+ info('');
83
+ info(colors.dim(' Commit + push first (the branch is kept), or re-run with --force to remove anyway.'));
84
+ if (!parameters.options.fromGluegunMenu)
85
+ process.exit(1);
86
+ return 'ticket stop: unsaved work (use --force)';
87
+ }
88
+ // If we are removing the worktree we are standing in, step the process out
89
+ // to the main repo first so git can remove the folder cleanly.
90
+ const removingCwd = fromMarker !== null || samePath(wt.path, layout.root);
91
+ if (removingCwd) {
92
+ try {
93
+ process.chdir(mainRepoRoot);
94
+ }
95
+ catch (_g) {
96
+ /* best-effort */
97
+ }
98
+ }
99
+ info('');
100
+ info(colors.bold(`Stopping ticket "${id}"`));
101
+ // 1. Tear the isolated stack down from inside the worktree (marker-aware).
102
+ info(colors.dim(' lt dev down …'));
103
+ yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'down'], { cwd: wt.path, env: process.env });
104
+ // 2. Optionally drop the ticket databases (they are otherwise just left empty).
105
+ if (parameters.options.dropDb === true || parameters.options['drop-db'] === true) {
106
+ const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
107
+ const entry = (0, dev_state_1.loadRegistry)().projects[`${base.slug}-${id}`];
108
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
109
+ const devDb = (_d = entry === null || entry === void 0 ? void 0 : entry.dbName) !== null && _d !== void 0 ? _d : (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(mainLayout.apiDir, base.slug), id);
110
+ const testDb = (0, dev_project_1.deriveTestDbName)(devDb);
111
+ for (const db of [devDb, testDb]) {
112
+ if ((0, dev_ticket_1.dropDatabase)(db))
113
+ info(colors.dim(` dropped db ${db}`));
114
+ else
115
+ warning(` could not drop db ${db} (mongosh missing or DB not reachable) — drop it manually if needed.`);
116
+ }
117
+ }
118
+ // 3. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
119
+ // files are framework-generated (e.g. `nuxt dev` rewrites the tracked
120
+ // `.nuxtrc` on boot), which would otherwise block the remove — but NEVER
121
+ // discard real source edits (those keep the non-forced remove, which
122
+ // errors with a hint so unsaved work is never lost).
123
+ const force = parameters.options.force === true || (0, dev_ticket_1.worktreeDirtyOnlyGenerated)(wt.path);
124
+ if (force && parameters.options.force !== true) {
125
+ info(colors.dim(' (worktree had only generated files dirty, e.g. .nuxtrc — removing)'));
126
+ }
127
+ try {
128
+ (0, dev_ticket_1.worktreeRemove)(mainRepoRoot, wt.path, force);
129
+ }
130
+ catch (e) {
131
+ error(`git worktree remove failed: ${e.message}`);
132
+ info(colors.dim(' The worktree has uncommitted SOURCE changes — commit/stash them, or pass --force to discard.'));
133
+ if (!parameters.options.fromGluegunMenu)
134
+ process.exit(1);
135
+ return 'ticket stop: worktree remove failed';
136
+ }
137
+ // The whole env is gone now — drop the ticket's registry entry so its slug +
138
+ // reserved ports are reclaimed (`lt dev down` only ends the session, keeping
139
+ // the entry for a restart; `lt ticket stop` removes the env entirely).
140
+ {
141
+ const reg = (0, dev_state_1.loadRegistry)();
142
+ const ticketSlug = `${(0, dev_identity_1.buildIdentity)(mainRepoRoot).slug}-${id}`;
143
+ if (reg.projects[ticketSlug]) {
144
+ delete reg.projects[ticketSlug];
145
+ (0, dev_state_1.saveRegistry)(reg);
146
+ }
147
+ }
148
+ info('');
149
+ success(`Ticket "${id}" stopped — worktree removed, branch "${(_e = wt.branch) !== null && _e !== void 0 ? _e : '-'}" kept.`);
150
+ if (removingCwd)
151
+ info(colors.dim(` This folder is gone — your shell is still in it. Run: cd ${mainRepoRoot}`));
152
+ if (!parameters.options.fromGluegunMenu)
153
+ process.exit();
154
+ return `ticket stop: ${id}`;
155
+ }),
156
+ };
157
+ /** True if two paths point at the same location (resolving symlinks, e.g. /tmp → /private/tmp). */
158
+ function samePath(a, b) {
159
+ try {
160
+ return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
161
+ }
162
+ catch (_a) {
163
+ return a === b;
164
+ }
165
+ }
166
+ module.exports = StopCommand;