@lenne.tech/cli 1.35.1 → 1.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
36
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
37
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -121,6 +154,28 @@ const DoctorCommand = {
121
154
  else if (gs.file && gs.hasDbReset) {
122
155
  line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
123
156
  }
157
+ // 7.5 check.mjs drift: the root wrapper is canonical (bundled with the
158
+ // CLI, synced by `lt fullstack update`). A diverged copy silently
159
+ // misses fixes (idle-watchdog, install hoisting, summed test
160
+ // metrics) — surface it instead of letting copies drift apart.
161
+ try {
162
+ const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
163
+ const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
164
+ const projectCheck = j(layout.root, 'scripts', 'check.mjs');
165
+ const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
166
+ if (filesystem.exists(projectCheck) && filesystem.exists(bundledCheck)) {
167
+ if (read(projectCheck, 'utf8') === read(bundledCheck, 'utf8')) {
168
+ line('OK', colors.green, 'scripts/check.mjs matches the canonical CLI version');
169
+ }
170
+ else {
171
+ line('WARN', colors.yellow, 'scripts/check.mjs differs from the canonical CLI version');
172
+ line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
173
+ }
174
+ }
175
+ }
176
+ catch (_a) {
177
+ /* best-effort diagnostics */
178
+ }
124
179
  // 8. Slug ↔ path: is this project's slug registered to a DIFFERENT checkout?
125
180
  // Two clones of the same project (same package.json "name") share the
126
181
  // slug → Caddy block / ports / DB and collide. Surface it proactively.
@@ -0,0 +1,152 @@
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_prune_1 = require("../../lib/dev-prune");
15
+ const dev_state_1 = require("../../lib/dev-state");
16
+ const dev_ticket_1 = require("../../lib/dev-ticket");
17
+ const workspace_integration_1 = require("../../lib/workspace-integration");
18
+ /**
19
+ * `lt dev prune [--dry-run] [--noConfirm]` — collect the leftovers that accumulate
20
+ * around parallel ticket work:
21
+ *
22
+ * 1. ORPHANED TICKET DATABASES of this project: `<base>-<id>`(+`-test`/`-test-<n>`)
23
+ * whose ticket has neither a live worktree nor a live registry entry. The id
24
+ * set comes from this repo's own `feat/*` branches — never from name shapes,
25
+ * so sibling projects sharing a name prefix are safe. DBs recorded via
26
+ * `lt ticket stop --keep-db` are never touched.
27
+ * 2. STALE SHARD TEST DATABASES (`<base>-test-<n>` from `lt dev test --shard`)
28
+ * when no test session is running — they are reset on every run anyway.
29
+ * 3. DEAD REGISTRY ENTRIES (all projects): path no longer exists → the entry and
30
+ * its reserved ports are reclaimed. Databases of dead MAIN projects are NEVER
31
+ * dropped (a deleted folder is not consent to destroy data).
32
+ *
33
+ * `lt dev up` runs the same collection automatically (opt out with --no-prune), so
34
+ * restarting an environment always cleans up after its predecessors.
35
+ */
36
+ const PruneCommand = {
37
+ description: 'Remove orphaned ticket databases, stale shard test DBs and dead registry entries',
38
+ hidden: false,
39
+ name: 'prune',
40
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
41
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, prompt: { confirm }, } = toolbox;
42
+ const dryRun = parameters.options.dryRun === true || parameters.options['dry-run'] === true;
43
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
44
+ let mainRepoRoot = layout.root;
45
+ try {
46
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
47
+ }
48
+ catch (_a) {
49
+ /* not a git repo — registry prune still works, ticket sweep will be empty */
50
+ }
51
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
52
+ const slug = (0, dev_identity_1.buildIdentity)(mainRepoRoot).slug;
53
+ const projectDevDb = (0, dev_project_1.deriveDbName)(mainLayout.apiDir, slug);
54
+ const observed = mainLayout.apiDir ? (0, dev_ticket_1.listDatabaseNames)(undefined, [mainLayout.apiDir, mainRepoRoot]) : null;
55
+ if (mainLayout.apiDir && observed === null) {
56
+ warning('Could not list databases (mongosh missing or MongoDB unreachable) — only the registry is pruned.');
57
+ }
58
+ const plan = (0, dev_prune_1.collectDevPrunePlan)({
59
+ loadRegistry: dev_state_1.loadRegistry,
60
+ mainRepoRoot,
61
+ observedDbNames: observed,
62
+ projectDevDb,
63
+ slug,
64
+ });
65
+ const dbTargets = [...new Set([...plan.orphan.targets, ...plan.shardTargets])];
66
+ if (dbTargets.length === 0 && plan.registryPrune.length === 0) {
67
+ success('Nothing to prune — no orphaned ticket DBs, no stale shard DBs, no dead registry entries.');
68
+ if (plan.orphan.protected.length > 0) {
69
+ info(colors.dim(` kept (registry keptDbs / referenced): ${plan.orphan.protected.join(', ')}`));
70
+ }
71
+ if (!parameters.options.fromGluegunMenu)
72
+ process.exit();
73
+ return 'dev prune: nothing to do';
74
+ }
75
+ if (plan.orphan.targets.length > 0) {
76
+ info(colors.bold(`Orphaned ticket databases (tickets: ${plan.orphan.orphanIds.join(', ')}):`));
77
+ plan.orphan.targets.forEach((db) => info(colors.dim(` • ${db}`)));
78
+ }
79
+ if (plan.shardTargets.length > 0) {
80
+ info(colors.bold('Stale shard test databases (no test session running):'));
81
+ plan.shardTargets.forEach((db) => info(colors.dim(` • ${db}`)));
82
+ }
83
+ if (plan.orphan.protected.length > 0) {
84
+ info(colors.dim(`Kept (recorded via --keep-db or still referenced): ${plan.orphan.protected.join(', ')}`));
85
+ }
86
+ if (plan.registryPrune.length > 0) {
87
+ info(colors.bold('Dead registry entries (path gone — entry + ports reclaimed, databases untouched):'));
88
+ plan.registryPrune.forEach((key) => info(colors.dim(` • ${key}`)));
89
+ }
90
+ if (dryRun) {
91
+ info('');
92
+ info(colors.dim('--dry-run → nothing was changed.'));
93
+ if (!parameters.options.fromGluegunMenu)
94
+ process.exit();
95
+ return 'dev prune: dry run';
96
+ }
97
+ // Dropping databases is irreversible — confirm interactively (documented default
98
+ // for non-interactive callers, mirroring `lt ticket stop`).
99
+ let confirmedDbTargets = dbTargets;
100
+ if (dbTargets.length > 0 && !(0, workspace_integration_1.isNonInteractive)(parameters.options.noConfirm === true)) {
101
+ info('');
102
+ warning(' These databases will be DROPPED (irreversible):');
103
+ dbTargets.forEach((db) => info(colors.dim(` • ${db}`)));
104
+ if (!(yield confirm('Drop them?', true))) {
105
+ info(colors.dim(' keeping the databases.'));
106
+ confirmedDbTargets = [];
107
+ }
108
+ }
109
+ if (confirmedDbTargets.length > 0) {
110
+ const { dropped, reason } = (0, dev_ticket_1.dropDatabases)(confirmedDbTargets, undefined, [mainLayout.apiDir, mainRepoRoot]);
111
+ dropped.forEach((db) => info(colors.dim(` dropped db ${db}`)));
112
+ if (reason === 'no-mongosh') {
113
+ warning(' mongosh is not installed — the databases were NOT dropped.');
114
+ }
115
+ else if (reason === 'unreachable') {
116
+ warning(' MongoDB is not reachable — the databases were NOT dropped.');
117
+ }
118
+ }
119
+ if (plan.registryPrune.length > 0) {
120
+ try {
121
+ // Locked read-modify-write: a parallel `lt dev up` may be registering a
122
+ // project right now — pruning without the lock clobbers its fresh entry.
123
+ const removed = yield (0, dev_state_1.withRegistryLock)(() => {
124
+ const reg = (0, dev_state_1.loadRegistry)();
125
+ const gone = [];
126
+ for (const key of plan.registryPrune) {
127
+ if (reg.projects[key] && !filesystem.exists(reg.projects[key].path)) {
128
+ delete reg.projects[key];
129
+ gone.push(key);
130
+ }
131
+ }
132
+ if (gone.length > 0) {
133
+ (0, dev_state_1.saveRegistry)(reg);
134
+ }
135
+ return gone;
136
+ });
137
+ if (removed.length > 0) {
138
+ info(colors.dim(` removed ${removed.length} dead registry entr${removed.length === 1 ? 'y' : 'ies'}: ${removed.join(', ')}`));
139
+ }
140
+ }
141
+ catch (e) {
142
+ error(` registry update failed: ${e.message}`);
143
+ }
144
+ }
145
+ info('');
146
+ success('Prune complete.');
147
+ if (!parameters.options.fromGluegunMenu)
148
+ process.exit();
149
+ return 'dev prune: done';
150
+ }),
151
+ };
152
+ module.exports = PruneCommand;
@@ -236,6 +236,9 @@ const TestCommand = {
236
236
  info(colors.dim(` app: ${ctx.appUrl} db: ${ctx.dbName}`));
237
237
  info('');
238
238
  const appPm = (0, dev_package_manager_1.pickPackageManager)(layout.appDir);
239
+ // Self-heal: install the app's Playwright browser build if missing —
240
+ // idempotent (~1s when cached); a fresh project otherwise fails every spec.
241
+ (0, dev_test_session_1.ensurePlaywrightBrowsers)(layout.appDir, appPm, (m) => info(colors.dim(m)));
239
242
  exitCode = yield (0, dev_process_1.runChildInherit)(appPm.bin, appPm.runScript('test:e2e', forwarded), {
240
243
  cwd: layout.appDir,
241
244
  env,
@@ -14,10 +14,12 @@ 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");
17
18
  const dev_package_manager_1 = require("../../lib/dev-package-manager");
18
19
  const dev_patches_1 = require("../../lib/dev-patches");
19
20
  const dev_process_1 = require("../../lib/dev-process");
20
21
  const dev_project_1 = require("../../lib/dev-project");
22
+ const dev_prune_1 = require("../../lib/dev-prune");
21
23
  const dev_state_1 = require("../../lib/dev-state");
22
24
  const dev_ticket_1 = require("../../lib/dev-ticket");
23
25
  /**
@@ -432,6 +434,67 @@ const UpCommand = {
432
434
  if (pids.app)
433
435
  (0, dev_process_1.killProcessGroup)(pids.app);
434
436
  }
437
+ // AUTO-PRUNE — restarting an environment is the moment to clean up after its
438
+ // predecessors: orphaned ticket DBs (ids from this repo's own feat/* branches,
439
+ // no live worktree/registry entry, `--keep-db` records excluded), stale shard
440
+ // test DBs, and dead-path registry entries. Same guards as `lt dev prune`;
441
+ // best-effort and non-blocking — a failure here must never affect `up` itself.
442
+ // Opt out with `--no-prune`.
443
+ if (parameters.options.prune !== false) {
444
+ try {
445
+ let mainRepoRoot = layout.root;
446
+ try {
447
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
448
+ }
449
+ catch (_m) {
450
+ /* not a git repo — registry prune still applies */
451
+ }
452
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
453
+ const baseSlug = (0, dev_identity_1.buildIdentity)(mainRepoRoot).slug;
454
+ const plan = (0, dev_prune_1.collectDevPrunePlan)({
455
+ loadRegistry: dev_state_1.loadRegistry,
456
+ mainRepoRoot,
457
+ observedDbNames: mainLayout.apiDir ? (0, dev_ticket_1.listDatabaseNames)(undefined, [mainLayout.apiDir, mainRepoRoot]) : null,
458
+ projectDevDb: (0, dev_project_1.deriveDbName)(mainLayout.apiDir, baseSlug),
459
+ slug: baseSlug,
460
+ });
461
+ const dbTargets = [...new Set([...plan.orphan.targets, ...plan.shardTargets])];
462
+ if (dbTargets.length > 0) {
463
+ const { dropped, reason } = (0, dev_ticket_1.dropDatabases)(dbTargets, undefined, [mainLayout.apiDir, mainRepoRoot]);
464
+ if (dropped.length > 0) {
465
+ info(colors.dim(`Pruned ${dropped.length} orphaned test/ticket database(s): ${dropped.join(', ')}`));
466
+ }
467
+ if (reason) {
468
+ info(colors.dim(`(prune incomplete — ${reason === 'no-mongosh' ? 'mongosh missing' : 'MongoDB unreachable'}; run \`lt dev prune\` later)`));
469
+ }
470
+ }
471
+ if (plan.registryPrune.length > 0) {
472
+ // Under the registry lock: a parallel `lt dev up` (another project) may be
473
+ // REGISTERING right now — an unlocked read-modify-write here would clobber
474
+ // its fresh entry (classic lost update; observed with two simultaneous ups).
475
+ const removed = yield (0, dev_state_1.withRegistryLock)(() => {
476
+ const reg = (0, dev_state_1.loadRegistry)();
477
+ const gone = [];
478
+ for (const key of plan.registryPrune) {
479
+ if (reg.projects[key] && !(0, fs_1.existsSync)(reg.projects[key].path)) {
480
+ delete reg.projects[key];
481
+ gone.push(key);
482
+ }
483
+ }
484
+ if (gone.length > 0) {
485
+ (0, dev_state_1.saveRegistry)(reg);
486
+ }
487
+ return gone;
488
+ });
489
+ if (removed.length > 0) {
490
+ info(colors.dim(`Pruned ${removed.length} dead registry entr${removed.length === 1 ? 'y' : 'ies'}: ${removed.join(', ')} (ports reclaimed)`));
491
+ }
492
+ }
493
+ }
494
+ catch (_o) {
495
+ /* never block `up` on cleanup */
496
+ }
497
+ }
435
498
  if (!parameters.options.fromGluegunMenu)
436
499
  process.exit();
437
500
  return `dev up: api=${pids.api}, app=${pids.app}`;
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  const path_1 = require("path");
13
+ const dev_patches_1 = require("../../lib/dev-patches");
13
14
  const framework_detection_1 = require("../../lib/framework-detection");
14
15
  const frontend_framework_detection_1 = require("../../lib/frontend-framework-detection");
15
16
  const heal_check_wrapper_1 = require("../../lib/heal-check-wrapper");
@@ -198,6 +199,15 @@ const NewCommand = {
198
199
  info('');
199
200
  success(` Installed/updated the check wrapper: ${changedCheck.join(', ')}`);
200
201
  }
202
+ // ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
203
+ //
204
+ // Newer templates ship the entry, but pre-existing projects predate it —
205
+ // `lt dev up` then dirties `.gitignore` in every ticket worktree, which
206
+ // used to block every `lt ticket stop` at its safety gate. Idempotent.
207
+ if ((0, dev_patches_1.addToGitignore)(cwd, '.lt-dev/')) {
208
+ info('');
209
+ success(' Added `.lt-dev/` to .gitignore');
210
+ }
201
211
  info('');
202
212
  info(colors.bold('For a comprehensive update of everything, use:'));
203
213
  info('');
@@ -15,13 +15,16 @@ const dev_process_1 = require("../../lib/dev-process");
15
15
  const dev_project_1 = require("../../lib/dev-project");
16
16
  const dev_state_1 = require("../../lib/dev-state");
17
17
  const dev_ticket_1 = require("../../lib/dev-ticket");
18
+ const workspace_integration_1 = require("../../lib/workspace-integration");
18
19
  /**
19
20
  * `lt ticket start <name>` — spin up a fully-isolated parallel dev environment
20
21
  * for a ticket or feature, in seconds:
21
22
  *
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>`,
23
+ * 1. `git fetch` + create a git WORKTREE on a fresh branch from the repo's base
24
+ * branch (`origin/dev` `origin/develop` remote HEAD …, or `--base`;
25
+ * the user is asked when none of them exists) so every ticket starts
26
+ * independent from the latest integration state, at a sibling folder
27
+ * `<parent>/<slug>-<id>`,
25
28
  * 2. tag it with a `.lt-dev/ticket` marker (makes every `lt dev *` in it
26
29
  * ticket-aware),
27
30
  * 3. `pnpm install` (hard-links from the shared store → fast),
@@ -62,8 +65,18 @@ const StartCommand = {
62
65
  }
63
66
  const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
64
67
  const id = (0, dev_ticket_1.deriveTicketId)(name, parameters.options.as != null ? String(parameters.options.as) : undefined);
68
+ // `local`/`dev`/`test`/… would derive a ticket DB that collides with the PROJECT's own
69
+ // dev or test database (both derivations strip a trailing `-(local|dev)` before adding
70
+ // their suffix, so the name round-trips). `lt ticket stop` would then be aimed at the
71
+ // developer's own data. Refuse the id instead of creating that trap.
72
+ if ((0, dev_ticket_1.isReservedTicketId)(id)) {
73
+ error(`Ticket id "${id}" is reserved — its database would collide with the project's own.`);
74
+ info(colors.dim(` Pick a different name, or map it to a distinct id: lt ticket start ${name} --as ${id}-fix`));
75
+ if (!parameters.options.fromGluegunMenu)
76
+ process.exit(1);
77
+ return 'ticket start: reserved id';
78
+ }
65
79
  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
80
  const ticketIdentity = (0, dev_identity_1.buildTicketIdentity)(base, id);
68
81
  const dbName = (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, base.slug), id);
69
82
  const worktreePath = (0, dev_ticket_1.worktreePathFor)(mainRepoRoot, base.slug, id);
@@ -80,14 +93,55 @@ const StartCommand = {
80
93
  }
81
94
  info('');
82
95
  info(colors.bold(`Starting ticket "${id}" → ${ticketIdentity.slug}`));
83
- info(colors.dim(` branch: ${branch} (from ${baseRef})`));
96
+ info(colors.dim(` branch: ${branch}`));
84
97
  info(colors.dim(` worktree: ${worktreePath}`));
85
98
  info('');
86
- // 1. fetch + worktree (fresh branch from origin/dev independent).
99
+ // 1. fetch, so the base ref below is resolved against the freshest remote
100
+ // state. Best-effort: offline / auth-less repos still get their worktree
101
+ // from whatever refs exist locally.
102
+ info(colors.dim('Fetching …'));
87
103
  try {
88
- info(colors.dim(`Fetching + creating worktree from ${baseRef} …`));
89
104
  (0, dev_ticket_1.gitFetch)(mainRepoRoot);
90
- (0, dev_ticket_1.worktreeAdd)(mainRepoRoot, worktreePath, branch, baseRef);
105
+ }
106
+ catch (e) {
107
+ warning(`git fetch failed (${e.message.split('\n')[0]}) — continuing with the local refs.`);
108
+ }
109
+ // 2. resolve the ref the fresh branch starts from. Only needed for a NEW
110
+ // branch — an existing one is simply checked out into the worktree.
111
+ let baseRef = null;
112
+ if (!(0, dev_ticket_1.gitBranchExists)(mainRepoRoot, branch)) {
113
+ const explicit = typeof parameters.options.base === 'string' ? parameters.options.base : undefined;
114
+ const resolution = (0, dev_ticket_1.resolveBaseRef)(mainRepoRoot, explicit);
115
+ baseRef = resolution.ref;
116
+ if (!baseRef) {
117
+ // No base branch found (repos differ: `dev`, `develop`, `main`, …) — ask
118
+ // the user rather than failing on a guess. Non-interactive callers (CI /
119
+ // AI agents) must pass `--base` instead.
120
+ if (resolution.explicit) {
121
+ error(`Base ref "${explicit}" does not exist in this repository.`);
122
+ }
123
+ else {
124
+ error(`No base branch found — tried: ${resolution.candidates.join(', ')}`);
125
+ }
126
+ if ((0, workspace_integration_1.isNonInteractive)(Boolean(parameters.options.noConfirm))) {
127
+ info(colors.dim(` Pass one explicitly: lt ticket start ${name} --base <ref>`));
128
+ if (!parameters.options.fromGluegunMenu)
129
+ process.exit(1);
130
+ return 'ticket start: no base ref';
131
+ }
132
+ baseRef = yield askForBaseRef(toolbox, mainRepoRoot);
133
+ if (!baseRef) {
134
+ if (!parameters.options.fromGluegunMenu)
135
+ process.exit(1);
136
+ return 'ticket start: no base ref';
137
+ }
138
+ }
139
+ info(colors.dim(` base: ${baseRef}`));
140
+ }
141
+ // 3. worktree (fresh branch from the base ref → independent).
142
+ try {
143
+ info(colors.dim(`Creating worktree${baseRef ? ` from ${baseRef}` : ` on existing branch ${branch}`} …`));
144
+ (0, dev_ticket_1.worktreeAdd)(mainRepoRoot, worktreePath, branch, baseRef !== null && baseRef !== void 0 ? baseRef : '');
91
145
  }
92
146
  catch (e) {
93
147
  error(`git worktree setup failed: ${e.message}`);
@@ -95,9 +149,9 @@ const StartCommand = {
95
149
  process.exit(1);
96
150
  return 'ticket start: worktree failed';
97
151
  }
98
- // 2. tag the worktree with its ticket id (makes lt dev * ticket-aware).
152
+ // 4. tag the worktree with its ticket id (makes lt dev * ticket-aware).
99
153
  (0, dev_ticket_1.writeTicketMarker)(worktreePath, id);
100
- // 3. install deps. Auto-detects pnpm/yarn/npm from the worktree's
154
+ // 5. install deps. Auto-detects pnpm/yarn/npm from the worktree's
101
155
  // lockfile so an npm-only repo doesn't get a foreign pnpm-lock
102
156
  // injected on every ticket start.
103
157
  if (parameters.options.install !== false) {
@@ -109,7 +163,7 @@ const StartCommand = {
109
163
  warning(`install failed (${e.message}) — continuing; run it manually in the worktree.`);
110
164
  }
111
165
  }
112
- // 4. bring the isolated stack up (re-invokes THIS lt build so the marker is
166
+ // 6. bring the isolated stack up (re-invokes THIS lt build so the marker is
113
167
  // honoured even before a global recompile). `--no-up` just scaffolds.
114
168
  if (parameters.options.up !== false) {
115
169
  info('');
@@ -140,4 +194,47 @@ const StartCommand = {
140
194
  return `ticket start: ${id}`;
141
195
  }),
142
196
  };
197
+ /**
198
+ * Ask which ref the ticket branch should start from — the last resort when no
199
+ * base branch was found (repos name theirs differently: `dev`, `develop`,
200
+ * `main`, …). Offers the repo's branches (most recently committed first) plus a
201
+ * free-text escape hatch, and re-asks until the ref actually resolves, so a typo
202
+ * fails here instead of inside `git worktree add`.
203
+ */
204
+ function askForBaseRef(toolbox, repoDir) {
205
+ return __awaiter(this, void 0, void 0, function* () {
206
+ var _a;
207
+ const { print: { colors, info, warning }, prompt, } = toolbox;
208
+ const MANUAL = 'Other — type a ref …';
209
+ const choices = (0, dev_ticket_1.listBaseRefChoices)(repoDir);
210
+ for (let attempt = 0; attempt < 3; attempt++) {
211
+ let ref;
212
+ if (choices.length) {
213
+ const answer = yield prompt.ask({
214
+ choices: [...choices, MANUAL],
215
+ message: 'Which branch should this ticket start from?',
216
+ name: 'ref',
217
+ type: 'select',
218
+ });
219
+ ref = answer.ref;
220
+ }
221
+ // No branches at all (fresh repo), or the user picked the escape hatch.
222
+ if (!choices.length || ref === MANUAL) {
223
+ const answer = yield prompt.ask({
224
+ message: 'Base ref (branch, tag or commit):',
225
+ name: 'ref',
226
+ type: 'input',
227
+ });
228
+ ref = (_a = answer.ref) === null || _a === void 0 ? void 0 : _a.trim();
229
+ }
230
+ if (!ref)
231
+ return null; // aborted / empty
232
+ if ((0, dev_ticket_1.gitRefExists)(repoDir, ref))
233
+ return ref;
234
+ warning(`"${ref}" does not resolve to a commit — pick another one.`);
235
+ }
236
+ info(colors.dim(' No usable base ref — aborting.'));
237
+ return null;
238
+ });
239
+ }
143
240
  module.exports = StartCommand;