@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.
- package/build/commands/dev/doctor.js +55 -0
- package/build/commands/dev/prune.js +152 -0
- package/build/commands/dev/test.js +3 -0
- package/build/commands/dev/up.js +63 -0
- package/build/commands/fullstack/update.js +10 -0
- package/build/commands/ticket/start.js +108 -11
- package/build/commands/ticket/stop.js +177 -39
- package/build/lib/dev-prune.js +211 -0
- package/build/lib/dev-state.js +10 -9
- package/build/lib/dev-test-session.js +24 -0
- package/build/lib/dev-ticket.js +422 -14
- package/build/lib/heal-check-wrapper.js +33 -3
- package/build/lib/hoist-workspace-pnpm-config.js +110 -2
- package/build/lib/workspace-integration.js +15 -5
- package/build/templates/check/check.mjs +208 -23
- package/docs/commands.md +130 -0
- package/docs/lt-dev-ticket-workflow.html +8 -8
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +1 -1
|
@@ -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,
|
package/build/commands/dev/up.js
CHANGED
|
@@ -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
|
|
23
|
-
* (
|
|
24
|
-
*
|
|
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}
|
|
96
|
+
info(colors.dim(` branch: ${branch}`));
|
|
84
97
|
info(colors.dim(` worktree: ${worktreePath}`));
|
|
85
98
|
info('');
|
|
86
|
-
// 1. fetch
|
|
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
|
-
|
|
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
|
-
//
|
|
152
|
+
// 4. tag the worktree with its ticket id (makes lt dev * ticket-aware).
|
|
99
153
|
(0, dev_ticket_1.writeTicketMarker)(worktreePath, id);
|
|
100
|
-
//
|
|
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
|
-
//
|
|
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;
|