@lenne.tech/cli 1.30.0 → 1.32.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/status.js +83 -11
- package/build/commands/dev/up.js +160 -65
- package/build/commands/ticket/stop.js +6 -4
- package/build/lib/dev-identity.js +59 -4
- package/build/lib/dev-state.js +12 -0
- package/build/lib/dev-test-session.js +26 -0
- package/build/lib/dev-ticket.js +109 -21
- package/build/lib/package-name.js +9 -22
- package/docs/commands.md +30 -3
- package/package.json +11 -4
|
@@ -39,13 +39,51 @@ const StatusCommand = {
|
|
|
39
39
|
warning('No projects registered. Run `lt dev init` in a project.');
|
|
40
40
|
}
|
|
41
41
|
else {
|
|
42
|
+
// One lsof over EVERY registered internal port, so liveness reflects
|
|
43
|
+
// what is actually bound — not just whether a supervisor PID survives a
|
|
44
|
+
// crashed ts-node. (See classifyComponentHealth.)
|
|
45
|
+
const allPorts = [];
|
|
46
|
+
for (const slug of slugs) {
|
|
47
|
+
const e = reg.projects[slug];
|
|
48
|
+
if (e.internalPorts.api)
|
|
49
|
+
allPorts.push(e.internalPorts.api);
|
|
50
|
+
if (e.internalPorts.app)
|
|
51
|
+
allPorts.push(e.internalPorts.app);
|
|
52
|
+
}
|
|
53
|
+
const snap = yield (0, dev_process_1.listenSnapshot)(allPorts);
|
|
42
54
|
for (const slug of slugs) {
|
|
43
55
|
const e = reg.projects[slug];
|
|
44
56
|
const session = (0, dev_state_1.loadSession)(e.path);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
57
|
+
// Only components the project actually has (a registered internal
|
|
58
|
+
// port) count toward the health summary.
|
|
59
|
+
const comps = [];
|
|
60
|
+
if (e.internalPorts.api) {
|
|
61
|
+
comps.push((0, dev_state_1.classifyComponentHealth)({ pid: session === null || session === void 0 ? void 0 : session.pids.api, portBound: snap.has(e.internalPorts.api) }));
|
|
62
|
+
}
|
|
63
|
+
if (e.internalPorts.app) {
|
|
64
|
+
comps.push((0, dev_state_1.classifyComponentHealth)({ pid: session === null || session === void 0 ? void 0 : session.pids.app, portBound: snap.has(e.internalPorts.app) }));
|
|
65
|
+
}
|
|
66
|
+
const allRunning = comps.length > 0 && comps.every((h) => h === 'running');
|
|
67
|
+
const anyRunning = comps.some((h) => h === 'running');
|
|
68
|
+
const anyCrashed = comps.some((h) => h === 'crashed');
|
|
69
|
+
let status;
|
|
70
|
+
let note = '';
|
|
71
|
+
if (allRunning) {
|
|
72
|
+
status = colors.green('●');
|
|
73
|
+
}
|
|
74
|
+
else if (anyRunning) {
|
|
75
|
+
// Some up, some down — honest "partially up" rather than green.
|
|
76
|
+
status = colors.yellow('◐');
|
|
77
|
+
note = colors.yellow(' degraded — `lt dev up` to restart the down half');
|
|
78
|
+
}
|
|
79
|
+
else if (anyCrashed) {
|
|
80
|
+
status = colors.yellow('◐');
|
|
81
|
+
note = colors.yellow(' crashed — `lt dev up` to restart');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
status = colors.dim('○');
|
|
85
|
+
}
|
|
86
|
+
info(` ${status} ${slug.padEnd(30)} ${colors.dim(e.path)}${note}`);
|
|
49
87
|
for (const [sub, host] of Object.entries(e.subdomains))
|
|
50
88
|
info(` ${sub.padEnd(6)} https://${host}`);
|
|
51
89
|
}
|
|
@@ -128,22 +166,56 @@ const StatusCommand = {
|
|
|
128
166
|
info(colors.dim(' no `lt dev up` session active'));
|
|
129
167
|
}
|
|
130
168
|
else {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
info(colors.dim(` started: ${session.startedAt}`));
|
|
136
|
-
// Live port snapshot
|
|
169
|
+
// Probe the actual ports FIRST: a component is only truly "running" when
|
|
170
|
+
// its supervisor PID is alive AND its internal port is bound. A crashed
|
|
171
|
+
// ts-node leaves nodemon alive ("waiting for file changes"), so the
|
|
172
|
+
// wrapper PID alone reads as "running" while nothing serves the port.
|
|
137
173
|
const ports = [entry.internalPorts.api, entry.internalPorts.app].filter((p) => typeof p === 'number');
|
|
174
|
+
const snap = yield (0, dev_process_1.listenSnapshot)(ports);
|
|
175
|
+
const apiHealth = (0, dev_state_1.classifyComponentHealth)({
|
|
176
|
+
pid: session.pids.api,
|
|
177
|
+
portBound: entry.internalPorts.api ? snap.has(entry.internalPorts.api) : false,
|
|
178
|
+
});
|
|
179
|
+
const appHealth = (0, dev_state_1.classifyComponentHealth)({
|
|
180
|
+
pid: session.pids.app,
|
|
181
|
+
portBound: entry.internalPorts.app ? snap.has(entry.internalPorts.app) : false,
|
|
182
|
+
});
|
|
183
|
+
const label = (health) => health === 'running'
|
|
184
|
+
? colors.green('running')
|
|
185
|
+
: health === 'crashed'
|
|
186
|
+
? colors.yellow('crashed (supervisor up, port not listening)')
|
|
187
|
+
: colors.red('dead');
|
|
188
|
+
if (session.pids.api !== undefined || entry.internalPorts.api) {
|
|
189
|
+
info(` api: ${label(apiHealth)} (pid ${(_a = session.pids.api) !== null && _a !== void 0 ? _a : '-'})`);
|
|
190
|
+
}
|
|
191
|
+
if (session.pids.app !== undefined || entry.internalPorts.app) {
|
|
192
|
+
info(` app: ${label(appHealth)} (pid ${(_b = session.pids.app) !== null && _b !== void 0 ? _b : '-'})`);
|
|
193
|
+
}
|
|
194
|
+
info(colors.dim(` started: ${session.startedAt}`));
|
|
195
|
+
// Live port snapshot — the authoritative "what is actually bound" view.
|
|
138
196
|
if (ports.length > 0) {
|
|
139
197
|
info('');
|
|
140
198
|
info(colors.bold(' Live upstream state'));
|
|
141
|
-
const snap = yield (0, dev_process_1.listenSnapshot)(ports);
|
|
142
199
|
for (const p of ports) {
|
|
143
200
|
const r = snap.get(p);
|
|
144
201
|
info(` ${p}: ${r ? colors.green(`bound to ${r.command} (pid ${r.pid})`) : colors.dim('free')}`);
|
|
145
202
|
}
|
|
146
203
|
}
|
|
204
|
+
// Surface any present-but-not-serving component (crashed OR dead) and
|
|
205
|
+
// point at the now-selective `lt dev up`, which restarts just the down
|
|
206
|
+
// half and leaves the healthy one running.
|
|
207
|
+
const apiPresent = session.pids.api !== undefined || !!entry.internalPorts.api;
|
|
208
|
+
const appPresent = session.pids.app !== undefined || !!entry.internalPorts.app;
|
|
209
|
+
const down = [
|
|
210
|
+
apiPresent && apiHealth !== 'running' ? 'api' : null,
|
|
211
|
+
appPresent && appHealth !== 'running' ? 'app' : null,
|
|
212
|
+
].filter((c) => c !== null);
|
|
213
|
+
if (down.length > 0) {
|
|
214
|
+
const crashed = (apiPresent && apiHealth === 'crashed') || (appPresent && appHealth === 'crashed');
|
|
215
|
+
info('');
|
|
216
|
+
warning(` ${down.join(' + ')} not serving${crashed ? ' (supervisor still up — crashed)' : ''}. ` +
|
|
217
|
+
`Run \`lt dev up\` to restart ${down.length === 1 ? 'it' : 'them'}.`);
|
|
218
|
+
}
|
|
147
219
|
}
|
|
148
220
|
// Isolated test stack (`lt dev test`), if one is up / left with --keep.
|
|
149
221
|
const testSession = (0, dev_state_1.loadSession)(layout.root, dev_state_1.TEST_SESSION_FILE);
|
package/build/commands/dev/up.js
CHANGED
|
@@ -124,9 +124,10 @@ const UpCommand = {
|
|
|
124
124
|
return 'dev up: slug in use by another checkout';
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
//
|
|
128
|
-
// CLAUDE.md
|
|
129
|
-
//
|
|
127
|
+
// Auto-establish every prerequisite so the user never has to run `lt dev
|
|
128
|
+
// init` first: CLAUDE.md URL block (base only) + `.gitignore` + the code
|
|
129
|
+
// patches that make config.env.ts / nuxt.config.ts / playwright.config.ts
|
|
130
|
+
// honour the env `lt dev` injects (PORT, URLs).
|
|
130
131
|
{
|
|
131
132
|
// NEVER patch the git-tracked CLAUDE.md for a ticket worktree: it would
|
|
132
133
|
// differ per worktree and risk committing ticket-specific URLs. The lt-dev
|
|
@@ -149,46 +150,41 @@ const UpCommand = {
|
|
|
149
150
|
info(colors.dim('added `.lt-dev/` to .gitignore'));
|
|
150
151
|
}
|
|
151
152
|
}
|
|
152
|
-
//
|
|
153
|
+
// Self-heal legacy hardcoded ports instead of just warning. An unmigrated
|
|
154
|
+
// project hardcodes `port: 3000`/`3001` and ignores the injected `PORT`, so
|
|
155
|
+
// it binds the framework defaults and misses Caddy → the (ticket) URLs don't
|
|
156
|
+
// route and collide with parallel stacks. `autoPatch` is idempotent (no-op on
|
|
157
|
+
// an already-env-aware config) and only ever touches config.env.ts /
|
|
158
|
+
// nuxt.config.ts / playwright.config.ts — never CLAUDE.md — so it is safe in a
|
|
159
|
+
// ticket worktree. In a worktree these are uncommitted patches; `lt ticket
|
|
160
|
+
// stop` recognises a pristine lt-dev patch and tears down without `--force`.
|
|
153
161
|
{
|
|
154
|
-
const
|
|
162
|
+
const filesToPatch = [];
|
|
155
163
|
if (layout.apiDir) {
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
164
|
+
const apiCfg = (0, path_1.join)(layout.apiDir, 'src', 'config.env.ts');
|
|
165
|
+
if ((0, fs_1.existsSync)(apiCfg))
|
|
166
|
+
filesToPatch.push(apiCfg);
|
|
159
167
|
}
|
|
160
|
-
if (layout.appDir)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
if (layout.appDir) {
|
|
169
|
+
for (const rel of ['nuxt.config.ts', 'playwright.config.ts']) {
|
|
170
|
+
const f = (0, path_1.join)(layout.appDir, rel);
|
|
171
|
+
if ((0, fs_1.existsSync)(f))
|
|
172
|
+
filesToPatch.push(f);
|
|
173
|
+
}
|
|
166
174
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (apiUp || appUp) {
|
|
174
|
-
warning(`Already running (api pid ${(_a = existingSession.pids.api) !== null && _a !== void 0 ? _a : '-'}, app pid ${(_b = existingSession.pids.app) !== null && _b !== void 0 ? _b : '-'}).`);
|
|
175
|
-
// Surface the bound URLs so the user can copy them out without having
|
|
176
|
-
// to look up `lt dev status` separately. Falls back to the in-process
|
|
177
|
-
// identity/registry data — both sources stay in sync via saveRegistry.
|
|
178
|
-
const existingEntry = (0, dev_state_1.loadRegistry)().projects[identity.slug];
|
|
179
|
-
printProjectUrls(info, {
|
|
180
|
-
apiHostname: (_c = identity.subdomains.api) === null || _c === void 0 ? void 0 : _c.hostname,
|
|
181
|
-
apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
|
|
182
|
-
appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
|
|
183
|
-
appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
|
|
184
|
-
dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
|
|
185
|
-
});
|
|
186
|
-
info('Run `lt dev down` first.');
|
|
187
|
-
if (!parameters.options.fromGluegunMenu)
|
|
188
|
-
process.exit(1);
|
|
189
|
-
return 'dev up: already running';
|
|
175
|
+
const patched = filesToPatch.map((f) => (0, dev_patches_1.autoPatch)(f)).filter((r) => r.patched);
|
|
176
|
+
if (patched.length > 0) {
|
|
177
|
+
patched.forEach((r) => success(`patched ${r.replacements}× in ${r.file} (env-aware ports for lt dev)`));
|
|
178
|
+
if (ticket) {
|
|
179
|
+
info(colors.dim(' (worktree config self-healed — auto-discarded on `lt ticket stop`, or commit it to migrate the project)'));
|
|
180
|
+
}
|
|
190
181
|
}
|
|
191
182
|
}
|
|
183
|
+
// Load any existing session up-front. The "is it already running?" decision
|
|
184
|
+
// is deliberately deferred until AFTER port allocation: a recorded wrapper
|
|
185
|
+
// PID being alive is NOT proof a component serves (a crashed ts-node leaves
|
|
186
|
+
// nodemon alive), so we probe the actual internal ports once we know them.
|
|
187
|
+
const existingSession = (0, dev_state_1.loadSession)(layout.root);
|
|
192
188
|
// Allocate internal ports (reuse existing if registered), verify they are
|
|
193
189
|
// free, AND reserve them in the registry — all ATOMICALLY under a cross-
|
|
194
190
|
// process lock, so two simultaneous `lt ticket start` (each → `lt dev up`)
|
|
@@ -197,15 +193,34 @@ const UpCommand = {
|
|
|
197
193
|
let appPort;
|
|
198
194
|
try {
|
|
199
195
|
yield (0, dev_state_1.withRegistryLock)(() => __awaiter(void 0, void 0, void 0, function* () {
|
|
200
|
-
var _a, _b;
|
|
201
196
|
const reg = (0, dev_state_1.loadRegistry)();
|
|
202
197
|
const entry = reg.projects[identity.slug];
|
|
203
198
|
const taken = (0, dev_state_1.takenInternalPorts)(reg, identity.slug);
|
|
204
|
-
|
|
199
|
+
let apiPortReused = false;
|
|
200
|
+
let appPortReused = false;
|
|
201
|
+
if (entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) {
|
|
202
|
+
apiPort = entry.internalPorts.api;
|
|
203
|
+
apiPortReused = true;
|
|
204
|
+
}
|
|
205
|
+
else if (layout.apiDir) {
|
|
206
|
+
apiPort = (0, dev_state_1.allocateInternalPort)(4000, taken);
|
|
207
|
+
}
|
|
205
208
|
if (apiPort)
|
|
206
209
|
taken.add(apiPort);
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
if (entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) {
|
|
211
|
+
appPort = entry.internalPorts.app;
|
|
212
|
+
appPortReused = true;
|
|
213
|
+
}
|
|
214
|
+
else if (layout.appDir) {
|
|
215
|
+
appPort = (0, dev_state_1.allocateInternalPort)(4000, taken);
|
|
216
|
+
}
|
|
217
|
+
// Only FRESHLY allocated ports must be verified free here. A reused
|
|
218
|
+
// (registered) port may legitimately be bound by our own still-healthy
|
|
219
|
+
// component; the spawn phase below decides per-component whether to keep
|
|
220
|
+
// or restart it (and reclaims a reused port held by a crashed/orphaned
|
|
221
|
+
// process). Free-checking a reused port would falsely abort a partial
|
|
222
|
+
// restart of just the crashed half.
|
|
223
|
+
const portsToCheck = [apiPortReused ? undefined : apiPort, appPortReused ? undefined : appPort].filter((p) => typeof p === 'number');
|
|
209
224
|
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
|
|
210
225
|
for (const p of portsToCheck) {
|
|
211
226
|
const r = snap.get(p);
|
|
@@ -232,6 +247,49 @@ const UpCommand = {
|
|
|
232
247
|
process.exit(1);
|
|
233
248
|
return 'dev up: port in use';
|
|
234
249
|
}
|
|
250
|
+
// ── Health-aware (re)start decision ──────────────────────────────────────
|
|
251
|
+
// Probe the just-resolved ports so we can tell a still-serving component
|
|
252
|
+
// from a crashed one (supervisor PID alive, port free). Only dead/crashed
|
|
253
|
+
// components get (re)started; a healthy one keeps running untouched.
|
|
254
|
+
const hasApi = Boolean(layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort);
|
|
255
|
+
const hasApp = Boolean(layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort);
|
|
256
|
+
const healthSnap = yield (0, dev_process_1.listenSnapshot)([apiPort, appPort].filter((p) => typeof p === 'number'));
|
|
257
|
+
const apiHealth = hasApi
|
|
258
|
+
? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, portBound: !!apiPort && healthSnap.has(apiPort) })
|
|
259
|
+
: undefined;
|
|
260
|
+
const appHealth = hasApp
|
|
261
|
+
? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, portBound: !!appPort && healthSnap.has(appPort) })
|
|
262
|
+
: undefined;
|
|
263
|
+
// All present components already serving → nothing to do.
|
|
264
|
+
const presentHealth = [apiHealth, appHealth].filter((h) => h !== undefined);
|
|
265
|
+
if (presentHealth.length > 0 && presentHealth.every((h) => h === 'running')) {
|
|
266
|
+
warning(`Already running (api pid ${(_a = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api) !== null && _a !== void 0 ? _a : '-'}, app pid ${(_b = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app) !== null && _b !== void 0 ? _b : '-'}) — all components healthy.`);
|
|
267
|
+
const existingEntry = (0, dev_state_1.loadRegistry)().projects[identity.slug];
|
|
268
|
+
printProjectUrls(info, {
|
|
269
|
+
apiHostname: (_c = identity.subdomains.api) === null || _c === void 0 ? void 0 : _c.hostname,
|
|
270
|
+
apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
|
|
271
|
+
appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
|
|
272
|
+
appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
|
|
273
|
+
dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
|
|
274
|
+
});
|
|
275
|
+
info('Run `lt dev down` first to force a full restart.');
|
|
276
|
+
if (!parameters.options.fromGluegunMenu)
|
|
277
|
+
process.exit();
|
|
278
|
+
return 'dev up: already running';
|
|
279
|
+
}
|
|
280
|
+
// Partial restart — at least one component is healthy and at least one is
|
|
281
|
+
// down. Announce what we keep vs. restart so the user sees the honest state.
|
|
282
|
+
const partialRestart = presentHealth.some((h) => h === 'running');
|
|
283
|
+
if (partialRestart) {
|
|
284
|
+
if (apiHealth === 'running')
|
|
285
|
+
info(colors.dim(`api healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api}) → keeping`));
|
|
286
|
+
else if (apiHealth)
|
|
287
|
+
warning(`api ${apiHealth} (port ${apiPort} not serving) → restarting`);
|
|
288
|
+
if (appHealth === 'running')
|
|
289
|
+
info(colors.dim(`app healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app}) → keeping`));
|
|
290
|
+
else if (appHealth)
|
|
291
|
+
warning(`app ${appHealth} (port ${appPort} not serving) → restarting`);
|
|
292
|
+
}
|
|
235
293
|
// Caddy block + reload.
|
|
236
294
|
const routes = [];
|
|
237
295
|
if (identity.subdomains.api && apiPort)
|
|
@@ -271,40 +329,77 @@ const UpCommand = {
|
|
|
271
329
|
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
|
|
272
330
|
const pids = {};
|
|
273
331
|
const rotationNotes = [];
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
332
|
+
const started = [];
|
|
333
|
+
const kept = [];
|
|
334
|
+
// Free a reused internal port before respawning: stop a CRASHED component's
|
|
335
|
+
// still-alive supervisor group (otherwise its idle nodemon leaks and a
|
|
336
|
+
// second one stacks on top), and kill any orphaned listener squatting the
|
|
337
|
+
// port (e.g. a ts-node whose supervisor already died). No-op on a clean
|
|
338
|
+
// first start (no prev PID, port free).
|
|
339
|
+
const reclaimPort = (prevPid, port, health) => __awaiter(void 0, void 0, void 0, function* () {
|
|
340
|
+
if (health === 'crashed' && prevPid)
|
|
341
|
+
yield (0, dev_process_1.terminateProcessGroup)(prevPid);
|
|
342
|
+
const bound = port ? healthSnap.get(port) : undefined;
|
|
343
|
+
if (bound === null || bound === void 0 ? void 0 : bound.pid)
|
|
344
|
+
yield (0, dev_process_1.terminateProcessGroup)(bound.pid);
|
|
345
|
+
});
|
|
346
|
+
if (hasApi && layout.apiDir && apiPort) {
|
|
347
|
+
if (apiHealth === 'running') {
|
|
348
|
+
pids.api = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api;
|
|
349
|
+
kept.push('api');
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
yield reclaimPort(existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, apiPort, apiHealth !== null && apiHealth !== void 0 ? apiHealth : 'dead');
|
|
353
|
+
const apiResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
|
|
354
|
+
cwd: layout.apiDir,
|
|
355
|
+
env: devEnv.api.env,
|
|
356
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', 'api.log'),
|
|
357
|
+
});
|
|
358
|
+
if (apiResult) {
|
|
359
|
+
pids.api = apiResult.pid;
|
|
360
|
+
started.push('api');
|
|
361
|
+
if (apiResult.rotated.rotated && apiResult.rotated.archivePath !== undefined) {
|
|
362
|
+
rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_f = apiResult.rotated.previousSize) !== null && _f !== void 0 ? _f : 0));
|
|
363
|
+
}
|
|
284
364
|
}
|
|
285
365
|
}
|
|
286
366
|
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
367
|
+
if (hasApp && layout.appDir && appPort) {
|
|
368
|
+
if (appHealth === 'running') {
|
|
369
|
+
pids.app = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app;
|
|
370
|
+
kept.push('app');
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
yield reclaimPort(existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, appPort, appHealth !== null && appHealth !== void 0 ? appHealth : 'dead');
|
|
374
|
+
const appResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
|
|
375
|
+
cwd: layout.appDir,
|
|
376
|
+
env: devEnv.app.env,
|
|
377
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', 'app.log'),
|
|
378
|
+
});
|
|
379
|
+
if (appResult) {
|
|
380
|
+
pids.app = appResult.pid;
|
|
381
|
+
started.push('app');
|
|
382
|
+
if (appResult.rotated.rotated && appResult.rotated.archivePath !== undefined) {
|
|
383
|
+
rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_g = appResult.rotated.previousSize) !== null && _g !== void 0 ? _g : 0));
|
|
384
|
+
}
|
|
297
385
|
}
|
|
298
386
|
}
|
|
299
387
|
}
|
|
300
|
-
// Persist the session (PIDs)
|
|
301
|
-
//
|
|
302
|
-
|
|
388
|
+
// Persist the session (PIDs) — merging kept (healthy) + freshly started PIDs.
|
|
389
|
+
// The registry entry (ports) was already reserved atomically above. On a
|
|
390
|
+
// partial restart we preserve the original session start time.
|
|
391
|
+
const startedAt = existingSession && kept.length > 0 ? existingSession.startedAt : new Date().toISOString();
|
|
392
|
+
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt });
|
|
303
393
|
// Write the ENV bridge so external tools (Playwright, IDE test runners,
|
|
304
394
|
// custom shell scripts) can pick up the URLs without inheriting our shell.
|
|
305
395
|
const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
|
|
306
396
|
info(colors.dim(`ENV bridge: ${bridgePath}`));
|
|
307
|
-
|
|
397
|
+
const summary = started.length === 0
|
|
398
|
+
? 'Nothing restarted'
|
|
399
|
+
: kept.length > 0
|
|
400
|
+
? `Restarted ${started.join('+')} (kept ${kept.join('+')})`
|
|
401
|
+
: `Started ${started.join('+')}`;
|
|
402
|
+
success(`${summary}: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
|
|
308
403
|
// Echo the bound URLs next to the PIDs as well — the "Starting" block
|
|
309
404
|
// prints them before the spawn, but on a long boot log they scroll out
|
|
310
405
|
// of view, so repeating them here keeps PID + URL visually grouped.
|
|
@@ -116,13 +116,15 @@ const StopCommand = {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
// 3. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
|
|
119
|
-
// files are framework-generated (e.g. `nuxt dev`
|
|
120
|
-
// `.nuxtrc` on boot)
|
|
119
|
+
// files are auto-discardable — framework-generated (e.g. `nuxt dev`
|
|
120
|
+
// rewrites the tracked `.nuxtrc` on boot) OR pristine lt-dev self-heal
|
|
121
|
+
// patches (config.env.ts/nuxt.config.ts/playwright.config.ts that
|
|
122
|
+
// `lt dev up` env-aware'd) — which would otherwise block the remove. NEVER
|
|
121
123
|
// discard real source edits (those keep the non-forced remove, which
|
|
122
124
|
// errors with a hint so unsaved work is never lost).
|
|
123
|
-
const force = parameters.options.force === true || (0, dev_ticket_1.
|
|
125
|
+
const force = parameters.options.force === true || (0, dev_ticket_1.worktreeDirtyOnlyAutoDiscardable)(wt.path);
|
|
124
126
|
if (force && parameters.options.force !== true) {
|
|
125
|
-
info(colors.dim(' (worktree had only generated files dirty
|
|
127
|
+
info(colors.dim(' (worktree had only generated / lt-dev-patched files dirty — removing)'));
|
|
126
128
|
}
|
|
127
129
|
try {
|
|
128
130
|
(0, dev_ticket_1.worktreeRemove)(mainRepoRoot, wt.path, force);
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildIdentity = buildIdentity;
|
|
4
4
|
exports.buildTestIdentity = buildTestIdentity;
|
|
5
5
|
exports.buildTicketIdentity = buildTicketIdentity;
|
|
6
|
+
exports.isUnmodifiedTemplateName = isUnmodifiedTemplateName;
|
|
6
7
|
exports.projectSlug = projectSlug;
|
|
7
8
|
exports.slugify = slugify;
|
|
8
9
|
/**
|
|
@@ -19,6 +20,7 @@ exports.slugify = slugify;
|
|
|
19
20
|
* The internal port behind each subdomain is opaque — Caddy proxies
|
|
20
21
|
* arbitrary local ports. Developers and Claude only ever see the URL.
|
|
21
22
|
*/
|
|
23
|
+
const child_process_1 = require("child_process");
|
|
22
24
|
const fs_1 = require("fs");
|
|
23
25
|
const path_1 = require("path");
|
|
24
26
|
/**
|
|
@@ -104,13 +106,44 @@ function buildTicketIdentity(base, id) {
|
|
|
104
106
|
return buildTestIdentity(base, `-${id}`);
|
|
105
107
|
}
|
|
106
108
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
+
* package.json `name` values that are unchanged starter-template defaults.
|
|
110
|
+
*
|
|
111
|
+
* A project scaffolded by cloning a template (`git clone lenneTech/lt-monorepo
|
|
112
|
+
* my-project`) instead of running `lt fullstack init`, or one that predates the
|
|
113
|
+
* rename-on-init logic, keeps the template's default `name`. That value is not
|
|
114
|
+
* project-identifying, so {@link projectSlug} ignores it and falls back to the
|
|
115
|
+
* project directory name; `renameUnmodifiedTemplatePackage` (in package-name.ts)
|
|
116
|
+
* rewrites the field on the next `lt dev init`.
|
|
117
|
+
*/
|
|
118
|
+
const UNMODIFIED_TEMPLATE_NAMES = new Set(['lt-monorepo']);
|
|
119
|
+
/**
|
|
120
|
+
* True when `name` matches a known unmodified starter-template default.
|
|
121
|
+
*
|
|
122
|
+
* Such names (e.g. `lt-monorepo`) are NOT project-identifying — see
|
|
123
|
+
* {@link UNMODIFIED_TEMPLATE_NAMES} — so {@link projectSlug} ignores them and
|
|
124
|
+
* derives the slug from the project directory instead.
|
|
125
|
+
*/
|
|
126
|
+
function isUnmodifiedTemplateName(name) {
|
|
127
|
+
return typeof name === 'string' && UNMODIFIED_TEMPLATE_NAMES.has(name);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read the bare project name from package.json (scope stripped) and slugify it.
|
|
131
|
+
*
|
|
132
|
+
* Falls back to the PROJECT directory name when there is no usable name — i.e.
|
|
133
|
+
* no package.json, no `name`, or an unmodified starter-template default (e.g.
|
|
134
|
+
* `lt-monorepo`, left over from a project scaffolded before rename-on-init
|
|
135
|
+
* existed). The fallback is anchored on the MAIN git worktree, so a linked
|
|
136
|
+
* `lt ticket` worktree (`imo-2314/`) inherits the base project name (`imo`)
|
|
137
|
+
* rather than slugging to its own folder — otherwise {@link buildTicketIdentity}
|
|
138
|
+
* would double-suffix it to `imo-2314-2314`.
|
|
109
139
|
*/
|
|
110
140
|
function projectSlug(root) {
|
|
141
|
+
var _a;
|
|
111
142
|
const fromPkg = readPackageName(root);
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
if (fromPkg && !isUnmodifiedTemplateName(fromPkg)) {
|
|
144
|
+
return slugify(fromPkg);
|
|
145
|
+
}
|
|
146
|
+
return slugify((0, path_1.basename)((_a = mainWorktreeDir(root)) !== null && _a !== void 0 ? _a : root));
|
|
114
147
|
}
|
|
115
148
|
/** Lowercase, alphanumerics + dashes only, trimmed dashes. */
|
|
116
149
|
function slugify(input) {
|
|
@@ -119,6 +152,28 @@ function slugify(input) {
|
|
|
119
152
|
.replace(/[^a-z0-9]+/g, '-')
|
|
120
153
|
.replace(/^-+|-+$/g, '');
|
|
121
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Best-effort directory of the MAIN git worktree containing `root`.
|
|
157
|
+
*
|
|
158
|
+
* For a linked worktree (e.g. an `lt ticket` worktree `imo-2314/`) this resolves
|
|
159
|
+
* to the primary checkout (`imo/`), NOT the worktree folder: `--git-common-dir`
|
|
160
|
+
* is the shared `.git`, identical for every worktree, and its parent is the main
|
|
161
|
+
* repo root. Used only by {@link projectSlug}'s fallback so every worktree
|
|
162
|
+
* inherits the same base project name. Returns null when `root` is outside a git
|
|
163
|
+
* repo or git is unavailable.
|
|
164
|
+
*/
|
|
165
|
+
function mainWorktreeDir(root) {
|
|
166
|
+
try {
|
|
167
|
+
const commonDir = (0, child_process_1.execFileSync)('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
170
|
+
}).trim();
|
|
171
|
+
return commonDir ? (0, path_1.dirname)(commonDir) : null;
|
|
172
|
+
}
|
|
173
|
+
catch (_a) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
122
177
|
/** Read `name` from package.json, scope-stripped (e.g. `@lenne.tech/foo` → `foo`). */
|
|
123
178
|
function readPackageName(dir) {
|
|
124
179
|
const pkgPath = (0, path_1.join)(dir, 'package.json');
|
package/build/lib/dev-state.js
CHANGED
|
@@ -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
|
exports.paths = exports.TEST_SESSION_FILE = void 0;
|
|
13
|
+
exports.classifyComponentHealth = classifyComponentHealth;
|
|
13
14
|
exports.allocateInternalPort = allocateInternalPort;
|
|
14
15
|
exports.clearSession = clearSession;
|
|
15
16
|
exports.detectSlugConflict = detectSlugConflict;
|
|
@@ -36,6 +37,17 @@ exports.withRegistryLock = withRegistryLock;
|
|
|
36
37
|
const fs_1 = require("fs");
|
|
37
38
|
const os_1 = require("os");
|
|
38
39
|
const path_1 = require("path");
|
|
40
|
+
/**
|
|
41
|
+
* Classify a component's true health from its recorded wrapper PID plus whether
|
|
42
|
+
* its internal port is actually bound (caller provides the port probe result,
|
|
43
|
+
* typically from a single {@link import('./dev-process').listenSnapshot} call).
|
|
44
|
+
*/
|
|
45
|
+
function classifyComponentHealth(opts) {
|
|
46
|
+
const wrapperAlive = typeof opts.pid === 'number' && isPidAlive(opts.pid);
|
|
47
|
+
if (!wrapperAlive)
|
|
48
|
+
return 'dead';
|
|
49
|
+
return opts.portBound ? 'running' : 'crashed';
|
|
50
|
+
}
|
|
39
51
|
const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
|
|
40
52
|
const SESSION_DIR = '.lt-dev';
|
|
41
53
|
const SESSION_FILE = 'state.json';
|
|
@@ -47,6 +47,7 @@ const caddy_1 = require("./caddy");
|
|
|
47
47
|
const dev_env_1 = require("./dev-env");
|
|
48
48
|
const dev_env_bridge_1 = require("./dev-env-bridge");
|
|
49
49
|
const dev_identity_1 = require("./dev-identity");
|
|
50
|
+
const dev_patches_1 = require("./dev-patches");
|
|
50
51
|
const dev_process_1 = require("./dev-process");
|
|
51
52
|
const dev_project_1 = require("./dev-project");
|
|
52
53
|
const dev_state_1 = require("./dev-state");
|
|
@@ -96,6 +97,31 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
96
97
|
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex, devDbName);
|
|
97
98
|
// Always start from a clean slate — reclaim a stale/crashed test session.
|
|
98
99
|
yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
|
|
100
|
+
// Self-heal legacy hardcoded ports BEFORE the build below — the test API runs
|
|
101
|
+
// COMPILED (`node dist/...`), so config.env.ts must already honour the injected
|
|
102
|
+
// `PORT` or the compiled bundle binds 3000 and misses Caddy. Belt-and-suspenders
|
|
103
|
+
// for a project that never ran `lt dev up` first; idempotent and only ever
|
|
104
|
+
// touches the three configs (never CLAUDE.md), so it is ticket-safe. Mirrors the
|
|
105
|
+
// self-heal in `lt dev up` (see commands/dev/up.ts).
|
|
106
|
+
{
|
|
107
|
+
const filesToPatch = [];
|
|
108
|
+
if (layout.apiDir) {
|
|
109
|
+
const apiCfg = (0, path_1.join)(layout.apiDir, 'src', 'config.env.ts');
|
|
110
|
+
if ((0, fs_1.existsSync)(apiCfg))
|
|
111
|
+
filesToPatch.push(apiCfg);
|
|
112
|
+
}
|
|
113
|
+
if (layout.appDir) {
|
|
114
|
+
for (const rel of ['nuxt.config.ts', 'playwright.config.ts']) {
|
|
115
|
+
const f = (0, path_1.join)(layout.appDir, rel);
|
|
116
|
+
if ((0, fs_1.existsSync)(f))
|
|
117
|
+
filesToPatch.push(f);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const r of filesToPatch.map((f) => (0, dev_patches_1.autoPatch)(f))) {
|
|
121
|
+
if (r.patched)
|
|
122
|
+
log.info(`patched ${r.replacements}× in ${r.file} (env-aware ports for lt dev test)`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
99
125
|
// Allocate internal ports AND reserve them in the registry ATOMICALLY, under a
|
|
100
126
|
// cross-process lock — so two parallel `lt dev test` runs (different ticket
|
|
101
127
|
// worktrees) can never read the registry, pick the same free ports, and both
|
package/build/lib/dev-ticket.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.pnpmInstall = pnpmInstall;
|
|
|
13
13
|
exports.readTicketMarker = readTicketMarker;
|
|
14
14
|
exports.resolveDevIdentity = resolveDevIdentity;
|
|
15
15
|
exports.worktreeAdd = worktreeAdd;
|
|
16
|
+
exports.worktreeDirtyOnlyAutoDiscardable = worktreeDirtyOnlyAutoDiscardable;
|
|
16
17
|
exports.worktreeDirtyOnlyGenerated = worktreeDirtyOnlyGenerated;
|
|
17
18
|
exports.worktreePathFor = worktreePathFor;
|
|
18
19
|
exports.worktreeRemove = worktreeRemove;
|
|
@@ -39,8 +40,10 @@ exports.writeTicketMarker = writeTicketMarker;
|
|
|
39
40
|
*/
|
|
40
41
|
const child_process_1 = require("child_process");
|
|
41
42
|
const fs_1 = require("fs");
|
|
43
|
+
const os_1 = require("os");
|
|
42
44
|
const path_1 = require("path");
|
|
43
45
|
const dev_identity_1 = require("./dev-identity");
|
|
46
|
+
const dev_patches_1 = require("./dev-patches");
|
|
44
47
|
const dev_project_1 = require("./dev-project");
|
|
45
48
|
const dev_state_1 = require("./dev-state");
|
|
46
49
|
/** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
|
|
@@ -261,6 +264,19 @@ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
|
|
|
261
264
|
}
|
|
262
265
|
/** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
|
|
263
266
|
const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
|
|
267
|
+
/** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
|
|
268
|
+
const LT_DEV_MANAGED_CONFIG = /(?:^|\/)(?:config\.env\.ts|nuxt\.config\.ts|playwright\.config\.ts)$/;
|
|
269
|
+
/**
|
|
270
|
+
* True when a worktree has uncommitted changes AND every one is auto-discardable
|
|
271
|
+
* (framework-generated OR a pristine lt-dev self-heal patch). Lets `lt ticket
|
|
272
|
+
* stop` force-remove a provisioning-only worktree — e.g. an unmigrated project
|
|
273
|
+
* whose configs `lt dev up` env-aware'd — without `--force` and without ever
|
|
274
|
+
* discarding real developer work.
|
|
275
|
+
*/
|
|
276
|
+
function worktreeDirtyOnlyAutoDiscardable(worktreePath) {
|
|
277
|
+
const { autoDiscardable, realDirty } = classifyWorktreeDirt(worktreePath);
|
|
278
|
+
return realDirty.length === 0 && autoDiscardable.length > 0;
|
|
279
|
+
}
|
|
264
280
|
/**
|
|
265
281
|
* True ONLY when a worktree has uncommitted changes AND every one is a
|
|
266
282
|
* framework-generated / ephemeral file (`.nuxtrc`, `.nuxt`, `.output`, …).
|
|
@@ -268,14 +284,7 @@ const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.
|
|
|
268
284
|
* `git worktree remove`; this lets `lt ticket stop` auto-clean those safely.
|
|
269
285
|
*/
|
|
270
286
|
function worktreeDirtyOnlyGenerated(worktreePath) {
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
out = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']);
|
|
274
|
-
}
|
|
275
|
-
catch (_a) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
|
-
const lines = out.split(/\r?\n/).filter((l) => l.trim());
|
|
287
|
+
const lines = gitStatusPorcelain(worktreePath);
|
|
279
288
|
if (lines.length === 0)
|
|
280
289
|
return false; // clean → no force needed
|
|
281
290
|
return lines.every((line) => GENERATED_PATHS.test(porcelainPath(line)));
|
|
@@ -301,25 +310,19 @@ function worktreeRemove(repoDir, worktreePath, force = false) {
|
|
|
301
310
|
* the branch not on any remote (the branch is kept on stop, but local-only).
|
|
302
311
|
*/
|
|
303
312
|
function worktreeSafetyReport(worktreePath) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
return { dirtySource: [], unpushed: 0 };
|
|
310
|
-
}
|
|
311
|
-
const dirtySource = status
|
|
312
|
-
.split(/\r?\n/)
|
|
313
|
-
.filter((l) => l.trim())
|
|
314
|
-
.filter((l) => !GENERATED_PATHS.test(porcelainPath(l)));
|
|
313
|
+
// Only REAL developer work counts as dirtySource — framework-generated files
|
|
314
|
+
// AND pristine lt-dev self-heal patches (config.env.ts/nuxt.config.ts/
|
|
315
|
+
// playwright.config.ts that `lt dev up` env-aware'd) are auto-discardable, so
|
|
316
|
+
// `lt ticket stop` never refuses over them.
|
|
317
|
+
const { realDirty } = classifyWorktreeDirt(worktreePath);
|
|
315
318
|
let unpushed = 0;
|
|
316
319
|
try {
|
|
317
320
|
unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
|
|
318
321
|
}
|
|
319
|
-
catch (
|
|
322
|
+
catch (_a) {
|
|
320
323
|
/* no remotes / detached HEAD → cannot determine; treat as 0 */
|
|
321
324
|
}
|
|
322
|
-
return { dirtySource, unpushed };
|
|
325
|
+
return { dirtySource: realDirty, unpushed };
|
|
323
326
|
}
|
|
324
327
|
/** Write the ticket marker that tags a worktree (created by `lt ticket start`). */
|
|
325
328
|
function writeTicketMarker(root, id) {
|
|
@@ -327,6 +330,24 @@ function writeTicketMarker(root, id) {
|
|
|
327
330
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
328
331
|
(0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
|
|
329
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Split a worktree's uncommitted changes into "auto-discardable" (framework-
|
|
335
|
+
* generated files + pristine lt-dev self-heal patches) and "real" developer work.
|
|
336
|
+
* `lt ticket stop` may force-remove a worktree whose changes are ALL
|
|
337
|
+
* auto-discardable; anything in `realDirty` blocks removal (work could be lost).
|
|
338
|
+
*/
|
|
339
|
+
function classifyWorktreeDirt(worktreePath) {
|
|
340
|
+
const autoDiscardable = [];
|
|
341
|
+
const realDirty = [];
|
|
342
|
+
for (const line of gitStatusPorcelain(worktreePath)) {
|
|
343
|
+
const p = porcelainPath(line);
|
|
344
|
+
if (GENERATED_PATHS.test(p) || isPristineLtDevPatch(worktreePath, p))
|
|
345
|
+
autoDiscardable.push(p);
|
|
346
|
+
else
|
|
347
|
+
realDirty.push(p);
|
|
348
|
+
}
|
|
349
|
+
return { autoDiscardable, realDirty };
|
|
350
|
+
}
|
|
330
351
|
function finalizeWorktree(partial) {
|
|
331
352
|
var _a, _b;
|
|
332
353
|
const path = (_a = partial.path) !== null && _a !== void 0 ? _a : '';
|
|
@@ -336,6 +357,73 @@ function finalizeWorktree(partial) {
|
|
|
336
357
|
function git(cwd, args) {
|
|
337
358
|
return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
338
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* `git status --porcelain` lines, each kept VERBATIM (not trimmed).
|
|
362
|
+
*
|
|
363
|
+
* Crucial: a tracked-but-modified file's porcelain prefix begins with a space
|
|
364
|
+
* (` M path`), so trimming the blob (as the generic `git()` helper does) would
|
|
365
|
+
* eat that leading space and shift `porcelainPath`'s `slice(3)` by one,
|
|
366
|
+
* corrupting the path (`projects/…` → `rojects/…`). Returns [] on error.
|
|
367
|
+
*/
|
|
368
|
+
function gitStatusPorcelain(cwd) {
|
|
369
|
+
let out = '';
|
|
370
|
+
try {
|
|
371
|
+
out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' });
|
|
372
|
+
}
|
|
373
|
+
catch (_a) {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
return out.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* True when the dirty tracked file at `relPath` (relative to the worktree root)
|
|
380
|
+
* differs from its committed (HEAD) version by EXACTLY the lt-dev self-heal
|
|
381
|
+
* patch — i.e. `lt dev up` env-aware'd a legacy config and the developer made no
|
|
382
|
+
* other edit. Verified by re-deriving: apply the same `autoPatch` to the HEAD
|
|
383
|
+
* blob and compare to the working-tree content. Any extra developer edit makes
|
|
384
|
+
* the two differ → treated as real work (never auto-discarded).
|
|
385
|
+
*
|
|
386
|
+
* Only the three lt-dev-managed configs qualify; everything else returns false.
|
|
387
|
+
*/
|
|
388
|
+
function isPristineLtDevPatch(worktreePath, relPath) {
|
|
389
|
+
if (!LT_DEV_MANAGED_CONFIG.test(relPath))
|
|
390
|
+
return false;
|
|
391
|
+
let head;
|
|
392
|
+
try {
|
|
393
|
+
// NOT the trimming `git()` helper — the trailing newline must survive so the
|
|
394
|
+
// comparison against the (untrimmed) working-tree content is exact.
|
|
395
|
+
head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], { encoding: 'utf8' });
|
|
396
|
+
}
|
|
397
|
+
catch (_a) {
|
|
398
|
+
return false; // not tracked at HEAD (e.g. a brand-new file) → never auto-discard
|
|
399
|
+
}
|
|
400
|
+
let current;
|
|
401
|
+
try {
|
|
402
|
+
current = (0, fs_1.readFileSync)((0, path_1.join)(worktreePath, relPath), 'utf8');
|
|
403
|
+
}
|
|
404
|
+
catch (_b) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
// Re-derive what `lt dev up`'s autoPatch produced from the HEAD blob. autoPatch
|
|
408
|
+
// dispatches by filename suffix, so the temp file must keep the basename.
|
|
409
|
+
const tmp = (0, path_1.join)((0, os_1.tmpdir)(), `lt-dev-verify-${process.pid}-${(0, path_1.basename)(relPath)}`);
|
|
410
|
+
try {
|
|
411
|
+
(0, fs_1.writeFileSync)(tmp, head, 'utf8');
|
|
412
|
+
(0, dev_patches_1.autoPatch)(tmp);
|
|
413
|
+
return (0, fs_1.readFileSync)(tmp, 'utf8') === current;
|
|
414
|
+
}
|
|
415
|
+
catch (_c) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
try {
|
|
420
|
+
(0, fs_1.unlinkSync)(tmp);
|
|
421
|
+
}
|
|
422
|
+
catch (_d) {
|
|
423
|
+
/* best-effort cleanup */
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
339
427
|
/** Path from a `git status --porcelain` line ("XY <path>" / "XY <old> -> <new>"). */
|
|
340
428
|
function porcelainPath(line) {
|
|
341
429
|
var _a;
|
|
@@ -1,29 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isUnmodifiedTemplateName =
|
|
3
|
+
exports.isUnmodifiedTemplateName = void 0;
|
|
4
4
|
exports.renameUnmodifiedTemplatePackage = renameUnmodifiedTemplatePackage;
|
|
5
5
|
exports.setPackageName = setPackageName;
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const dev_identity_1 = require("./dev-identity");
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* `dev-identity#projectSlug` reads to derive `<slug>.localhost`, so every
|
|
15
|
-
* cloned project would collide on `https://lt-monorepo.localhost`.
|
|
16
|
-
*
|
|
17
|
-
* `lt fullstack init` rewrites this field already (see `setPackageName`);
|
|
18
|
-
* the detection here is the safety net for projects that bypassed init.
|
|
19
|
-
*/
|
|
20
|
-
const UNMODIFIED_TEMPLATE_NAMES = new Set(['lt-monorepo']);
|
|
21
|
-
/**
|
|
22
|
-
* True when `name` matches a known unmodified starter template default.
|
|
23
|
-
*/
|
|
24
|
-
function isUnmodifiedTemplateName(name) {
|
|
25
|
-
return typeof name === 'string' && UNMODIFIED_TEMPLATE_NAMES.has(name);
|
|
26
|
-
}
|
|
8
|
+
// `isUnmodifiedTemplateName` and the template-name detection now live in
|
|
9
|
+
// `dev-identity.ts` (the slug owner — `projectSlug` needs the same check to
|
|
10
|
+
// ignore an unmodified `lt-monorepo` name). Re-exported here so existing
|
|
11
|
+
// importers / tests of the package-name surface keep working unchanged.
|
|
12
|
+
var dev_identity_2 = require("./dev-identity");
|
|
13
|
+
Object.defineProperty(exports, "isUnmodifiedTemplateName", { enumerable: true, get: function () { return dev_identity_2.isUnmodifiedTemplateName; } });
|
|
27
14
|
/**
|
|
28
15
|
* If the package.json at `<projectRoot>/package.json` still carries an
|
|
29
16
|
* unmodified starter-template `name` (e.g. `lt-monorepo` from a raw
|
|
@@ -47,7 +34,7 @@ function renameUnmodifiedTemplatePackage(options) {
|
|
|
47
34
|
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
|
|
48
35
|
return null;
|
|
49
36
|
const currentName = typeof pkg.name === 'string' ? pkg.name : null;
|
|
50
|
-
if (!isUnmodifiedTemplateName(currentName))
|
|
37
|
+
if (!(0, dev_identity_1.isUnmodifiedTemplateName)(currentName))
|
|
51
38
|
return null;
|
|
52
39
|
// Slugify the directory basename: npm names must be lowercase and
|
|
53
40
|
// URL-safe, and this keeps the rewritten value consistent with what
|
|
@@ -56,7 +43,7 @@ function renameUnmodifiedTemplatePackage(options) {
|
|
|
56
43
|
// back). Anything else would produce a slug mismatch between
|
|
57
44
|
// package.json and `<slug>.localhost`.
|
|
58
45
|
const derived = (0, dev_identity_1.slugify)((0, path_1.basename)(projectRoot));
|
|
59
|
-
if (!derived || isUnmodifiedTemplateName(derived))
|
|
46
|
+
if (!derived || (0, dev_identity_1.isUnmodifiedTemplateName)(derived))
|
|
60
47
|
return null;
|
|
61
48
|
const written = setPackageName({ filesystem, name: derived, packageJsonPath });
|
|
62
49
|
return written ? derived : null;
|
package/docs/commands.md
CHANGED
|
@@ -451,8 +451,23 @@ lt dev up
|
|
|
451
451
|
**Pre-flight guards (exit code 1 each):**
|
|
452
452
|
- Caddy not installed (`lt dev install` first)
|
|
453
453
|
- Caddy daemon not running (run `lt dev install` — it bootstraps the lt-dev service)
|
|
454
|
-
-
|
|
455
|
-
|
|
454
|
+
- A FRESHLY allocated internal port is already in use by a foreign process
|
|
455
|
+
|
|
456
|
+
**Health-aware (re)start (idempotent):** `lt dev up` is safe to re-run. It probes
|
|
457
|
+
the actual internal ports — not just the recorded supervisor PID — so it can tell
|
|
458
|
+
a still-serving component from a *crashed* one (the supervisor / nodemon survives a
|
|
459
|
+
ts-node crash and the recorded PID stays alive while nothing listens on the port).
|
|
460
|
+
Behaviour:
|
|
461
|
+
- **All present components truly serving** → no-op, exits 0 with "already running".
|
|
462
|
+
- **Some serving, some down** → restarts ONLY the down component(s); a healthy one
|
|
463
|
+
keeps running untouched and its PID is preserved in the session.
|
|
464
|
+
- Before respawning a crashed component it terminates that supervisor's whole
|
|
465
|
+
process group (so its idle `nodemon` doesn't leak / stack a second one) and
|
|
466
|
+
reclaims any orphaned listener still squatting the reused port.
|
|
467
|
+
|
|
468
|
+
This is the fix for the "`status` says api running but no data loads" case: a
|
|
469
|
+
crashed ts-node dev API is healed by simply re-running `lt dev up` (it does not
|
|
470
|
+
fall back to compiled `node dist` — ts-node is kept so code edits hot-reload).
|
|
456
471
|
|
|
457
472
|
**Logs:** `<root>/.lt-dev/api.log`, `<root>/.lt-dev/app.log` (append-mode).
|
|
458
473
|
|
|
@@ -485,7 +500,19 @@ lt dev status --all # every project in the registry
|
|
|
485
500
|
|
|
486
501
|
**Alias:** `lt d s`
|
|
487
502
|
|
|
488
|
-
The current-project view shows subdomains → upstream ports, db URI,
|
|
503
|
+
The current-project view shows subdomains → upstream ports, db URI, per-component
|
|
504
|
+
health, and live `lsof` state. **Health is honest:** a component is reported
|
|
505
|
+
`running` only when its supervisor PID is alive AND its internal port is actually
|
|
506
|
+
bound. A supervisor that survived a ts-node crash (PID alive, port free) is shown
|
|
507
|
+
as `crashed (supervisor up, port not listening)` instead of the old misleading
|
|
508
|
+
`running`, with a hint to run `lt dev up` to restart just that one.
|
|
509
|
+
|
|
510
|
+
The `--all` view lists every project with a single indicator:
|
|
511
|
+
- `●` (green) — all present components serving
|
|
512
|
+
- `◐` (yellow) — `degraded` (some up, some down) or `crashed` (supervisor up, port free)
|
|
513
|
+
- `○` (dim) — stopped
|
|
514
|
+
|
|
515
|
+
A single `lsof` snapshot covers every registered port, so `--all` stays fast.
|
|
489
516
|
|
|
490
517
|
---
|
|
491
518
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "lenne.Tech CLI: lt",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lenne.Tech",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"c": "npm run check",
|
|
22
22
|
"cf": "npm run check:fix",
|
|
23
|
-
"check": "
|
|
23
|
+
"check": "bash scripts/check.sh",
|
|
24
|
+
"check:audit": "bash scripts/check.sh --audit-only",
|
|
24
25
|
"check:fix": "npm install && npm audit fix && npm run format && npm run lint:fix && npm run build && npm run check:start",
|
|
25
26
|
"check:start": "bash scripts/check-cli-start.sh",
|
|
26
27
|
"postinstall": "node bin/postinstall.js 2>/dev/null || true",
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"glob": "13.0.6",
|
|
69
70
|
"gluegun": "5.2.2",
|
|
70
71
|
"js-sha256": "0.11.1",
|
|
71
|
-
"js-yaml": "4.
|
|
72
|
+
"js-yaml": "4.2.0",
|
|
72
73
|
"jsdom": "29.1.1",
|
|
73
74
|
"lodash": "4.18.1",
|
|
74
75
|
"open": "11.0.0",
|
|
@@ -100,10 +101,16 @@
|
|
|
100
101
|
},
|
|
101
102
|
"//overrides": {
|
|
102
103
|
"brace-expansion@5.0.2 - 5.0.5": "Security fix: GHSA-jxxr-4gwj-5jf2 (large numeric range defeats max DoS protection) in brace-expansion 5.0.2-5.0.5 - transitive via minimatch under glob, @ts-morph/common, @typescript-eslint/typescript-estree. Remove once those parents resolve minimatch to a brace-expansion >=5.0.6.",
|
|
103
|
-
"semver@*": "Force latest semver 7.x across all sub-deps; gluegun@5.2.2 pins semver@7.7.0 which is stale - remove once gluegun updates its dep."
|
|
104
|
+
"semver@*": "Force latest semver 7.x across all sub-deps; gluegun@5.2.2 pins semver@7.7.0 which is stale - remove once gluegun updates its dep.",
|
|
105
|
+
"js-yaml": "Security fix: GHSA-h67p-54hq-rp68 (quadratic-complexity DoS in merge-key handling) in js-yaml <=4.1.1. Forces 4.2.0 everywhere, incl. the 3.14.2 pulled transitively by @istanbuljs/load-nyc-config under babel-plugin-istanbul (jest coverage); that loader only runs js-yaml when reading a YAML nyc config, which this project does not use. Remove once the jest toolchain resolves js-yaml >=4.2.0 itself.",
|
|
106
|
+
"form-data": "Security fix: GHSA-hmw2-7cc7-3qxx (CRLF injection via unescaped multipart field names) in form-data 4.0.0-4.0.5 - transitive via axios. Remove once axios pins form-data >=4.0.6.",
|
|
107
|
+
"@babel/core": "Security fix: GHSA-4x5r-pxfx-6jf8 (arbitrary file read via sourceMappingURL) in @babel/core <=7.29.0 - transitive via the jest toolchain. Remove once jest resolves @babel/core >=7.29.6."
|
|
104
108
|
},
|
|
105
109
|
"overrides": {
|
|
110
|
+
"@babel/core": "7.29.7",
|
|
106
111
|
"brace-expansion@5.0.2 - 5.0.5": "5.0.6",
|
|
112
|
+
"form-data": "4.0.6",
|
|
113
|
+
"js-yaml": "4.2.0",
|
|
107
114
|
"semver@*": "7.8.1"
|
|
108
115
|
},
|
|
109
116
|
"jest": {
|