@lenne.tech/cli 1.27.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/dev/doctor.js +27 -1
- package/build/commands/dev/down.js +22 -10
- package/build/commands/dev/status.js +4 -3
- package/build/commands/dev/test.js +12 -4
- package/build/commands/dev/up.js +90 -50
- package/build/commands/ticket/list.js +78 -0
- package/build/commands/ticket/start.js +141 -0
- package/build/commands/ticket/stop.js +166 -0
- package/build/commands/ticket/switch.js +70 -0
- package/build/commands/ticket/test.js +80 -0
- package/build/commands/ticket/ticket.js +36 -0
- package/build/lib/dev-identity.js +18 -0
- package/build/lib/dev-patches.js +1 -1
- package/build/lib/dev-project.js +14 -0
- package/build/lib/dev-state.js +96 -0
- package/build/lib/dev-test-session.js +55 -35
- package/build/lib/dev-ticket.js +343 -0
- package/docs/lt-dev-ticket-workflow.html +603 -0
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +32 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const dev_identity_1 = require("../../lib/dev-identity");
|
|
14
|
+
const dev_process_1 = require("../../lib/dev-process");
|
|
15
|
+
const dev_project_1 = require("../../lib/dev-project");
|
|
16
|
+
const dev_state_1 = require("../../lib/dev-state");
|
|
17
|
+
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
18
|
+
/**
|
|
19
|
+
* `lt ticket stop [<id>]` — tear a ticket env down + remove its worktree.
|
|
20
|
+
*
|
|
21
|
+
* 1. `lt dev down` inside the worktree (stops the ticket stack + any test
|
|
22
|
+
* stacks, removes the Caddy block — residue-free),
|
|
23
|
+
* 2. `git worktree remove` (the BRANCH is kept, so nothing is lost),
|
|
24
|
+
* 3. `--drop-db` also drops the ticket's empty dev + test databases.
|
|
25
|
+
*
|
|
26
|
+
* Run with NO id from INSIDE a ticket worktree to clean up THIS environment
|
|
27
|
+
* (the current folder is removed; the process steps out to the main repo first).
|
|
28
|
+
*/
|
|
29
|
+
const StopCommand = {
|
|
30
|
+
alias: ['rm'],
|
|
31
|
+
description: 'Stop a ticket env + remove its worktree (branch kept); no id = the current worktree',
|
|
32
|
+
name: 'stop',
|
|
33
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
34
|
+
var _a, _b, _c, _d, _e;
|
|
35
|
+
const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
|
|
36
|
+
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
37
|
+
let mainRepoRoot;
|
|
38
|
+
try {
|
|
39
|
+
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
|
|
40
|
+
}
|
|
41
|
+
catch (_f) {
|
|
42
|
+
error('Not inside a git repository.');
|
|
43
|
+
if (!parameters.options.fromGluegunMenu)
|
|
44
|
+
process.exit(1);
|
|
45
|
+
return 'ticket stop: not a git repo';
|
|
46
|
+
}
|
|
47
|
+
// Id from the argument — or, when invoked with NO id from INSIDE a ticket
|
|
48
|
+
// worktree, the current worktree's own ticket (so a bare `lt ticket stop`
|
|
49
|
+
// cleans up "this" environment and removes this very folder).
|
|
50
|
+
const fromMarker = parameters.first == null ? (0, dev_ticket_1.readTicketMarker)(layout.root) : null;
|
|
51
|
+
const id = String((_b = (_a = parameters.first) !== null && _a !== void 0 ? _a : fromMarker) !== null && _b !== void 0 ? _b : '').trim();
|
|
52
|
+
if (!id) {
|
|
53
|
+
error('Usage: lt ticket stop <id> [--drop-db] [--force] — or run with no id from INSIDE a ticket worktree.');
|
|
54
|
+
if (!parameters.options.fromGluegunMenu)
|
|
55
|
+
process.exit(1);
|
|
56
|
+
return 'ticket stop: missing id';
|
|
57
|
+
}
|
|
58
|
+
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
|
|
59
|
+
if (!wt) {
|
|
60
|
+
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
|
|
61
|
+
if (!parameters.options.fromGluegunMenu)
|
|
62
|
+
process.exit(1);
|
|
63
|
+
return 'ticket stop: not found';
|
|
64
|
+
}
|
|
65
|
+
// SAFETY: never silently delete unsaved work. Warn + REFUSE (unless --force)
|
|
66
|
+
// when the worktree has uncommitted changes OR unpushed commits, so the user
|
|
67
|
+
// commits + pushes first. (`--force` removes anyway; the branch is kept, so
|
|
68
|
+
// committed history survives regardless.)
|
|
69
|
+
const safety = (0, dev_ticket_1.worktreeSafetyReport)(wt.path);
|
|
70
|
+
if ((safety.dirtySource.length > 0 || safety.unpushed > 0) && parameters.options.force !== true) {
|
|
71
|
+
warning('');
|
|
72
|
+
warning(`Refusing to remove ticket "${id}" — work is not fully committed + pushed:`);
|
|
73
|
+
if (safety.dirtySource.length > 0) {
|
|
74
|
+
warning(` • ${safety.dirtySource.length} uncommitted change(s) — would be LOST on removal:`);
|
|
75
|
+
safety.dirtySource.slice(0, 12).forEach((l) => info(colors.dim(` ${l}`)));
|
|
76
|
+
if (safety.dirtySource.length > 12)
|
|
77
|
+
info(colors.dim(` … and ${safety.dirtySource.length - 12} more`));
|
|
78
|
+
}
|
|
79
|
+
if (safety.unpushed > 0) {
|
|
80
|
+
warning(` • ${safety.unpushed} commit(s) on "${(_c = wt.branch) !== null && _c !== void 0 ? _c : '-'}" not pushed to any remote`);
|
|
81
|
+
}
|
|
82
|
+
info('');
|
|
83
|
+
info(colors.dim(' Commit + push first (the branch is kept), or re-run with --force to remove anyway.'));
|
|
84
|
+
if (!parameters.options.fromGluegunMenu)
|
|
85
|
+
process.exit(1);
|
|
86
|
+
return 'ticket stop: unsaved work (use --force)';
|
|
87
|
+
}
|
|
88
|
+
// If we are removing the worktree we are standing in, step the process out
|
|
89
|
+
// to the main repo first so git can remove the folder cleanly.
|
|
90
|
+
const removingCwd = fromMarker !== null || samePath(wt.path, layout.root);
|
|
91
|
+
if (removingCwd) {
|
|
92
|
+
try {
|
|
93
|
+
process.chdir(mainRepoRoot);
|
|
94
|
+
}
|
|
95
|
+
catch (_g) {
|
|
96
|
+
/* best-effort */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
info('');
|
|
100
|
+
info(colors.bold(`Stopping ticket "${id}"`));
|
|
101
|
+
// 1. Tear the isolated stack down from inside the worktree (marker-aware).
|
|
102
|
+
info(colors.dim(' lt dev down …'));
|
|
103
|
+
yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'down'], { cwd: wt.path, env: process.env });
|
|
104
|
+
// 2. Optionally drop the ticket databases (they are otherwise just left empty).
|
|
105
|
+
if (parameters.options.dropDb === true || parameters.options['drop-db'] === true) {
|
|
106
|
+
const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
|
|
107
|
+
const entry = (0, dev_state_1.loadRegistry)().projects[`${base.slug}-${id}`];
|
|
108
|
+
const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
|
|
109
|
+
const devDb = (_d = entry === null || entry === void 0 ? void 0 : entry.dbName) !== null && _d !== void 0 ? _d : (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(mainLayout.apiDir, base.slug), id);
|
|
110
|
+
const testDb = (0, dev_project_1.deriveTestDbName)(devDb);
|
|
111
|
+
for (const db of [devDb, testDb]) {
|
|
112
|
+
if ((0, dev_ticket_1.dropDatabase)(db))
|
|
113
|
+
info(colors.dim(` dropped db ${db}`));
|
|
114
|
+
else
|
|
115
|
+
warning(` could not drop db ${db} (mongosh missing or DB not reachable) — drop it manually if needed.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 3. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
|
|
119
|
+
// files are framework-generated (e.g. `nuxt dev` rewrites the tracked
|
|
120
|
+
// `.nuxtrc` on boot), which would otherwise block the remove — but NEVER
|
|
121
|
+
// discard real source edits (those keep the non-forced remove, which
|
|
122
|
+
// errors with a hint so unsaved work is never lost).
|
|
123
|
+
const force = parameters.options.force === true || (0, dev_ticket_1.worktreeDirtyOnlyGenerated)(wt.path);
|
|
124
|
+
if (force && parameters.options.force !== true) {
|
|
125
|
+
info(colors.dim(' (worktree had only generated files dirty, e.g. .nuxtrc — removing)'));
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
(0, dev_ticket_1.worktreeRemove)(mainRepoRoot, wt.path, force);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
error(`git worktree remove failed: ${e.message}`);
|
|
132
|
+
info(colors.dim(' The worktree has uncommitted SOURCE changes — commit/stash them, or pass --force to discard.'));
|
|
133
|
+
if (!parameters.options.fromGluegunMenu)
|
|
134
|
+
process.exit(1);
|
|
135
|
+
return 'ticket stop: worktree remove failed';
|
|
136
|
+
}
|
|
137
|
+
// The whole env is gone now — drop the ticket's registry entry so its slug +
|
|
138
|
+
// reserved ports are reclaimed (`lt dev down` only ends the session, keeping
|
|
139
|
+
// the entry for a restart; `lt ticket stop` removes the env entirely).
|
|
140
|
+
{
|
|
141
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
142
|
+
const ticketSlug = `${(0, dev_identity_1.buildIdentity)(mainRepoRoot).slug}-${id}`;
|
|
143
|
+
if (reg.projects[ticketSlug]) {
|
|
144
|
+
delete reg.projects[ticketSlug];
|
|
145
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
info('');
|
|
149
|
+
success(`Ticket "${id}" stopped — worktree removed, branch "${(_e = wt.branch) !== null && _e !== void 0 ? _e : '-'}" kept.`);
|
|
150
|
+
if (removingCwd)
|
|
151
|
+
info(colors.dim(` This folder is gone — your shell is still in it. Run: cd ${mainRepoRoot}`));
|
|
152
|
+
if (!parameters.options.fromGluegunMenu)
|
|
153
|
+
process.exit();
|
|
154
|
+
return `ticket stop: ${id}`;
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
/** True if two paths point at the same location (resolving symlinks, e.g. /tmp → /private/tmp). */
|
|
158
|
+
function samePath(a, b) {
|
|
159
|
+
try {
|
|
160
|
+
return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
|
|
161
|
+
}
|
|
162
|
+
catch (_a) {
|
|
163
|
+
return a === b;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
module.exports = StopCommand;
|
|
@@ -0,0 +1,70 @@
|
|
|
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 child_process_1 = require("child_process");
|
|
13
|
+
const dev_project_1 = require("../../lib/dev-project");
|
|
14
|
+
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
15
|
+
/**
|
|
16
|
+
* `lt ticket switch <id>` — show a ticket worktree's path and open it in your
|
|
17
|
+
* editor (best-effort). A CLI cannot change the parent shell's directory, so it
|
|
18
|
+
* also prints the `cd` line to copy. `--no-open` only prints.
|
|
19
|
+
*/
|
|
20
|
+
const SwitchCommand = {
|
|
21
|
+
alias: ['sw', 'open'],
|
|
22
|
+
description: 'Show a ticket worktree path + open it in your editor',
|
|
23
|
+
name: 'switch',
|
|
24
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
var _a;
|
|
26
|
+
const { filesystem, parameters, print: { colors, error, info, success }, } = toolbox;
|
|
27
|
+
const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
|
|
28
|
+
if (!id) {
|
|
29
|
+
error('Usage: lt ticket switch <id>');
|
|
30
|
+
if (!parameters.options.fromGluegunMenu)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
return 'ticket switch: missing id';
|
|
33
|
+
}
|
|
34
|
+
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
35
|
+
let mainRepoRoot;
|
|
36
|
+
try {
|
|
37
|
+
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
|
|
38
|
+
}
|
|
39
|
+
catch (_b) {
|
|
40
|
+
error('Not inside a git repository.');
|
|
41
|
+
if (!parameters.options.fromGluegunMenu)
|
|
42
|
+
process.exit(1);
|
|
43
|
+
return 'ticket switch: not a git repo';
|
|
44
|
+
}
|
|
45
|
+
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
|
|
46
|
+
if (!wt) {
|
|
47
|
+
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
|
|
48
|
+
if (!parameters.options.fromGluegunMenu)
|
|
49
|
+
process.exit(1);
|
|
50
|
+
return 'ticket switch: not found';
|
|
51
|
+
}
|
|
52
|
+
info('');
|
|
53
|
+
info(`Ticket "${id}" → ${wt.path}`);
|
|
54
|
+
info(colors.dim(` cd ${wt.path}`));
|
|
55
|
+
if (parameters.options.open !== false) {
|
|
56
|
+
const editor = process.env.LT_EDITOR || 'code';
|
|
57
|
+
try {
|
|
58
|
+
(0, child_process_1.execFileSync)(editor, [wt.path], { stdio: 'ignore' });
|
|
59
|
+
success(`Opened in ${editor}.`);
|
|
60
|
+
}
|
|
61
|
+
catch (_c) {
|
|
62
|
+
info(colors.dim(` (could not run \`${editor}\` — open the folder manually, or set LT_EDITOR)`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!parameters.options.fromGluegunMenu)
|
|
66
|
+
process.exit();
|
|
67
|
+
return `ticket switch: ${id}`;
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
module.exports = SwitchCommand;
|
|
@@ -0,0 +1,80 @@
|
|
|
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_process_1 = require("../../lib/dev-process");
|
|
13
|
+
const dev_project_1 = require("../../lib/dev-project");
|
|
14
|
+
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
15
|
+
/**
|
|
16
|
+
* `lt ticket test <id> [--shard N] [-- <args>]` — run the E2E suite for a ticket
|
|
17
|
+
* in ITS isolated stack + DB, by delegating to `lt dev test` inside the ticket
|
|
18
|
+
* worktree (which is ticket-aware via the marker → test DB `<base>-<id>-test`).
|
|
19
|
+
*/
|
|
20
|
+
const TestCommand = {
|
|
21
|
+
alias: ['t'],
|
|
22
|
+
description: 'Run the E2E suite for a ticket in its isolated stack',
|
|
23
|
+
name: 'test',
|
|
24
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
25
|
+
var _a;
|
|
26
|
+
const { filesystem, parameters, print: { colors, error, info }, } = toolbox;
|
|
27
|
+
const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
|
|
28
|
+
if (!id) {
|
|
29
|
+
error('Usage: lt ticket test <id> [--shard N] [--keep] [-- <playwright args>]');
|
|
30
|
+
if (!parameters.options.fromGluegunMenu)
|
|
31
|
+
process.exit(1);
|
|
32
|
+
return 'ticket test: missing id';
|
|
33
|
+
}
|
|
34
|
+
const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
|
|
35
|
+
let mainRepoRoot;
|
|
36
|
+
try {
|
|
37
|
+
mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
|
|
38
|
+
}
|
|
39
|
+
catch (_b) {
|
|
40
|
+
error('Not inside a git repository.');
|
|
41
|
+
if (!parameters.options.fromGluegunMenu)
|
|
42
|
+
process.exit(1);
|
|
43
|
+
return 'ticket test: not a git repo';
|
|
44
|
+
}
|
|
45
|
+
const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
|
|
46
|
+
if (!wt) {
|
|
47
|
+
error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
|
|
48
|
+
if (!parameters.options.fromGluegunMenu)
|
|
49
|
+
process.exit(1);
|
|
50
|
+
return 'ticket test: not found';
|
|
51
|
+
}
|
|
52
|
+
// Reconstruct `lt dev test` args: forward the common flags + the `--` array.
|
|
53
|
+
const devArgs = ['dev', 'test'];
|
|
54
|
+
if (parameters.options.shard !== undefined) {
|
|
55
|
+
const s = parameters.options.shard;
|
|
56
|
+
devArgs.push(s === true ? '--shard' : `--shard=${s}`);
|
|
57
|
+
}
|
|
58
|
+
if (parameters.options.keep === true)
|
|
59
|
+
devArgs.push('--keep');
|
|
60
|
+
if (parameters.options.debug === true)
|
|
61
|
+
devArgs.push('--debug');
|
|
62
|
+
// Playwright args after `--` (e.g. a spec path / `--grep`): read them from
|
|
63
|
+
// the RAW argv. gluegun's parsed `parameters.array` does not reliably carry
|
|
64
|
+
// post-`--` tokens through the `ticket <id>` positional, which silently ran
|
|
65
|
+
// the WHOLE suite instead of the requested spec.
|
|
66
|
+
const dashIdx = process.argv.indexOf('--');
|
|
67
|
+
const forwarded = dashIdx >= 0 ? process.argv.slice(dashIdx + 1) : [];
|
|
68
|
+
if (forwarded.length > 0)
|
|
69
|
+
devArgs.push('--', ...forwarded);
|
|
70
|
+
info(colors.dim(`(cd ${wt.path} && lt ${devArgs.join(' ')})`));
|
|
71
|
+
const code = yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], ...devArgs], {
|
|
72
|
+
cwd: wt.path,
|
|
73
|
+
env: process.env,
|
|
74
|
+
});
|
|
75
|
+
if (!parameters.options.fromGluegunMenu)
|
|
76
|
+
process.exit(code !== null && code !== void 0 ? code : 1);
|
|
77
|
+
return `ticket test: ${id} exit=${code}`;
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
module.exports = TestCommand;
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* Parallel ticket dev environments (`lt ticket <subcommand>`).
|
|
14
|
+
*
|
|
15
|
+
* Each ticket runs in its OWN git worktree (a fresh branch from `origin/dev`)
|
|
16
|
+
* with its OWN isolated `lt dev` stack — own URLs, ports, Caddy block and empty
|
|
17
|
+
* database — so several tickets can be developed, browser-tested and E2E-tested
|
|
18
|
+
* fully in parallel without ever influencing each other.
|
|
19
|
+
*
|
|
20
|
+
* Subcommands:
|
|
21
|
+
* - `start <name>` — create the worktree + bring the isolated stack up
|
|
22
|
+
* - `list` — dashboard of all ticket envs (URLs, branch, status, DB)
|
|
23
|
+
* - `switch <id>` — show + open a ticket worktree in your editor
|
|
24
|
+
* - `test <id>` — run the E2E suite in the ticket's isolated stack
|
|
25
|
+
* - `stop <id>` — tear the env down + remove the worktree (branch kept)
|
|
26
|
+
*/
|
|
27
|
+
module.exports = {
|
|
28
|
+
alias: ['tk'],
|
|
29
|
+
description: 'Parallel ticket dev environments (worktree + isolated lt dev stack)',
|
|
30
|
+
hidden: false,
|
|
31
|
+
name: 'ticket',
|
|
32
|
+
run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
|
|
33
|
+
yield toolbox.helper.showMenu('ticket');
|
|
34
|
+
return 'ticket';
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildIdentity = buildIdentity;
|
|
4
4
|
exports.buildTestIdentity = buildTestIdentity;
|
|
5
|
+
exports.buildTicketIdentity = buildTicketIdentity;
|
|
5
6
|
exports.projectSlug = projectSlug;
|
|
6
7
|
exports.slugify = slugify;
|
|
7
8
|
/**
|
|
@@ -85,6 +86,23 @@ function buildTestIdentity(base, suffix = '-test') {
|
|
|
85
86
|
}
|
|
86
87
|
return { root: base.root, slug, subdomains };
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive a per-TICKET identity from a base identity (used by `lt ticket` /
|
|
91
|
+
* `lt dev up --ticket`). Suffixes the slug + every subdomain hostname with the
|
|
92
|
+
* ticket id, so each ticket worktree runs on its OWN URLs / ports / Caddy block
|
|
93
|
+
* / DB — fully parallel to and isolated from every other ticket and the base
|
|
94
|
+
* dev session.
|
|
95
|
+
*
|
|
96
|
+
* svl.localhost → svl-2200.localhost
|
|
97
|
+
* api.svl.localhost → api.svl-2200.localhost
|
|
98
|
+
*
|
|
99
|
+
* Mechanically identical to {@link buildTestIdentity} (a named wrapper for
|
|
100
|
+
* readability + intent at the call sites). `id` is already a clean slug (see
|
|
101
|
+
* `deriveTicketId` in dev-ticket.ts).
|
|
102
|
+
*/
|
|
103
|
+
function buildTicketIdentity(base, id) {
|
|
104
|
+
return buildTestIdentity(base, `-${id}`);
|
|
105
|
+
}
|
|
88
106
|
/**
|
|
89
107
|
* Read the bare project name from package.json (scope stripped).
|
|
90
108
|
* Falls back to directory basename if no package.json or no `name`.
|
package/build/lib/dev-patches.js
CHANGED
|
@@ -264,7 +264,7 @@ function patchPlaywrightConfig(file) {
|
|
|
264
264
|
if (!/const SHARDED\b/.test(after) && /export default defineConfig/.test(after)) {
|
|
265
265
|
const shardConst = '// `lt dev test --shard N` saturates the CPU (N built SSR servers + N Chromium),\n' +
|
|
266
266
|
'// slowing every navigation. Relax timeouts ONLY under that load — the CLI sets\n' +
|
|
267
|
-
|
|
267
|
+
'// LT_DEV_TEST_SHARDS — so serial + CI keep their tight, fast-failing defaults.\n' +
|
|
268
268
|
"const SHARDED = Number(process.env.LT_DEV_TEST_SHARDS || '0') > 1;\n\n";
|
|
269
269
|
after = after.replace(/(export default defineConfig)/, `${shardConst}$1`);
|
|
270
270
|
count++;
|
package/build/lib/dev-project.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.apiNeedsPortPatch = apiNeedsPortPatch;
|
|
|
4
4
|
exports.appNeedsPortPatch = appNeedsPortPatch;
|
|
5
5
|
exports.deriveDbName = deriveDbName;
|
|
6
6
|
exports.deriveTestDbName = deriveTestDbName;
|
|
7
|
+
exports.deriveTicketDbName = deriveTicketDbName;
|
|
7
8
|
exports.resolveLayout = resolveLayout;
|
|
8
9
|
const fs_1 = require("fs");
|
|
9
10
|
const path_1 = require("path");
|
|
@@ -68,6 +69,19 @@ function deriveTestDbName(devDbName) {
|
|
|
68
69
|
const base = devDbName.replace(/-(local|dev)$/i, '');
|
|
69
70
|
return `${base}-test`;
|
|
70
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Derive the per-TICKET database name from the project's dev DB name, so each
|
|
74
|
+
* ticket worktree reads/writes its OWN database and tickets never collide.
|
|
75
|
+
*
|
|
76
|
+
* svl-sports-system-local + "2200" → svl-sports-system-2200
|
|
77
|
+
*
|
|
78
|
+
* The ticket's isolated `lt dev test` stack then derives its test DB from this
|
|
79
|
+
* via {@link deriveTestDbName} → `svl-sports-system-2200-test`.
|
|
80
|
+
*/
|
|
81
|
+
function deriveTicketDbName(devDbName, ticketId) {
|
|
82
|
+
const base = devDbName.replace(/-(local|dev)$/i, '');
|
|
83
|
+
return `${base}-${ticketId}`;
|
|
84
|
+
}
|
|
71
85
|
/**
|
|
72
86
|
* Resolve layout starting from `cwd`. Walks up to find a workspace if
|
|
73
87
|
* cwd is inside `projects/api/` or `projects/app/`.
|
package/build/lib/dev-state.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
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
|
+
};
|
|
2
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
12
|
exports.paths = exports.TEST_SESSION_FILE = void 0;
|
|
4
13
|
exports.allocateInternalPort = allocateInternalPort;
|
|
5
14
|
exports.clearSession = clearSession;
|
|
15
|
+
exports.detectSlugConflict = detectSlugConflict;
|
|
6
16
|
exports.isPidAlive = isPidAlive;
|
|
7
17
|
exports.isValidPid = isValidPid;
|
|
8
18
|
exports.loadRegistry = loadRegistry;
|
|
@@ -10,6 +20,7 @@ exports.loadSession = loadSession;
|
|
|
10
20
|
exports.saveRegistry = saveRegistry;
|
|
11
21
|
exports.saveSession = saveSession;
|
|
12
22
|
exports.takenInternalPorts = takenInternalPorts;
|
|
23
|
+
exports.withRegistryLock = withRegistryLock;
|
|
13
24
|
/**
|
|
14
25
|
* State persistence for `lt dev`.
|
|
15
26
|
*
|
|
@@ -57,6 +68,21 @@ function clearSession(root, sessionFile = SESSION_FILE) {
|
|
|
57
68
|
}
|
|
58
69
|
}
|
|
59
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Detect when `slug` is registered to a DIFFERENT checkout than `root`. Two
|
|
73
|
+
* clones of the same project share a package.json "name" → the same slug → the
|
|
74
|
+
* same Caddy block / internal ports / database, so running both via `lt dev`
|
|
75
|
+
* collides (and one's `lt dev down` can unroute the other). Returns null when
|
|
76
|
+
* there is no conflict (no registry entry, or the entry belongs to THIS checkout).
|
|
77
|
+
*/
|
|
78
|
+
function detectSlugConflict(slug, root) {
|
|
79
|
+
const entry = loadRegistry().projects[slug];
|
|
80
|
+
if (!(entry === null || entry === void 0 ? void 0 : entry.path) || sameRealPath(entry.path, root))
|
|
81
|
+
return null;
|
|
82
|
+
const session = loadSession(entry.path);
|
|
83
|
+
const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p));
|
|
84
|
+
return { otherPath: entry.path, otherSessionAlive };
|
|
85
|
+
}
|
|
60
86
|
/** Check whether a process with the given PID is currently alive. */
|
|
61
87
|
function isPidAlive(pid) {
|
|
62
88
|
if (!isValidPid(pid))
|
|
@@ -146,6 +172,76 @@ function takenInternalPorts(reg, excludeSlug) {
|
|
|
146
172
|
}
|
|
147
173
|
return ports;
|
|
148
174
|
}
|
|
175
|
+
/** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
|
|
176
|
+
function sameRealPath(a, b) {
|
|
177
|
+
try {
|
|
178
|
+
return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
|
|
179
|
+
}
|
|
180
|
+
catch (_a) {
|
|
181
|
+
return a === b;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const LOCK_PATH = `${REGISTRY_PATH}.lock`;
|
|
185
|
+
/**
|
|
186
|
+
* Run `fn` while holding an EXCLUSIVE lock on the registry, so concurrent
|
|
187
|
+
* `lt dev` invocations — e.g. two parallel `lt dev test` in different ticket
|
|
188
|
+
* worktrees — cannot read-modify-write the registry, or allocate the SAME
|
|
189
|
+
* internal ports, at the same time. (Without this, two simultaneous test runs
|
|
190
|
+
* both read the registry before either saves, both pick the same free ports,
|
|
191
|
+
* and the second server fails to bind — the port-allocation race.)
|
|
192
|
+
*
|
|
193
|
+
* The lock is a single atomically-created lock file (`openSync(..,'wx')`); a
|
|
194
|
+
* stale lock left by a crashed process (older than `staleMs`) is reclaimed.
|
|
195
|
+
* Keep `fn` SHORT — allocation + reservation only, NEVER across a build/spawn.
|
|
196
|
+
*/
|
|
197
|
+
function withRegistryLock(fn_1) {
|
|
198
|
+
return __awaiter(this, arguments, void 0, function* (fn, opts = {}) {
|
|
199
|
+
var _a, _b;
|
|
200
|
+
const staleMs = (_a = opts.staleMs) !== null && _a !== void 0 ? _a : 30000;
|
|
201
|
+
const timeoutMs = (_b = opts.timeoutMs) !== null && _b !== void 0 ? _b : 20000;
|
|
202
|
+
const start = Date.now();
|
|
203
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(LOCK_PATH), { recursive: true });
|
|
204
|
+
let fd = null;
|
|
205
|
+
while (fd === null) {
|
|
206
|
+
try {
|
|
207
|
+
fd = (0, fs_1.openSync)(LOCK_PATH, 'wx'); // atomic exclusive create — throws if held
|
|
208
|
+
}
|
|
209
|
+
catch (_c) {
|
|
210
|
+
try {
|
|
211
|
+
if (Date.now() - (0, fs_1.statSync)(LOCK_PATH).mtimeMs > staleMs)
|
|
212
|
+
(0, fs_1.unlinkSync)(LOCK_PATH); // reclaim a crashed holder
|
|
213
|
+
}
|
|
214
|
+
catch (_d) {
|
|
215
|
+
/* lock vanished between calls — just retry */
|
|
216
|
+
}
|
|
217
|
+
if (Date.now() - start > timeoutMs)
|
|
218
|
+
throw new Error(`registry lock busy for >${timeoutMs}ms (${LOCK_PATH})`);
|
|
219
|
+
yield delay(40 + Math.floor(Math.random() * 60)); // jittered backoff
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return yield fn();
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
try {
|
|
227
|
+
(0, fs_1.closeSync)(fd);
|
|
228
|
+
}
|
|
229
|
+
catch (_e) {
|
|
230
|
+
/* ignore */
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
(0, fs_1.unlinkSync)(LOCK_PATH);
|
|
234
|
+
}
|
|
235
|
+
catch (_f) {
|
|
236
|
+
/* ignore */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
/** Promise-based delay for the lock retry loop. */
|
|
242
|
+
function delay(ms) {
|
|
243
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
244
|
+
}
|
|
149
245
|
/** Path constants exported for tests + status displays. */
|
|
150
246
|
exports.paths = {
|
|
151
247
|
registry: REGISTRY_PATH,
|