@lenne.tech/cli 1.26.0 → 1.27.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.
@@ -19,20 +19,22 @@ const shell_config_1 = require("../../lib/shell-config");
19
19
  function installPlugin(plugin, cli, toolbox) {
20
20
  return __awaiter(this, void 0, void 0, function* () {
21
21
  const { print: { error, info, spin }, } = toolbox;
22
- // Step 1: Install or update plugin
22
+ // Step 1: Install or update plugin.
23
+ //
24
+ // `claude plugin install` is a no-op for an already-installed plugin: it reports
25
+ // "already installed" and leaves the active version pinned to whatever was installed
26
+ // before. To actually pull a newer release from the (freshly updated) marketplace
27
+ // cache, an existing install must be bumped with `claude plugin update`.
23
28
  const fullPluginName = `${plugin.pluginName}@${plugin.marketplaceName}`;
24
29
  const pluginSpinner = spin(`Installing/updating ${plugin.pluginName}`);
25
- const installResult = (0, claude_cli_1.runClaudeCommand)(cli, `plugin install ${fullPluginName}`);
30
+ let installResult = (0, claude_cli_1.runClaudeCommand)(cli, `plugin install ${fullPluginName}`);
26
31
  let pluginAction = 'installed';
27
- if (installResult.output.includes('already') || installResult.output.includes('up to date')) {
28
- pluginAction = 'up to date';
32
+ if (installResult.output.includes('already installed')) {
33
+ // Plugin was already present — follow up with an update to reach the latest version.
34
+ installResult = (0, claude_cli_1.runClaudeCommand)(cli, `plugin update ${fullPluginName}`);
35
+ pluginAction = installResult.output.includes('already') ? 'up to date' : 'updated';
29
36
  }
30
- else if (installResult.output.includes('update') || installResult.output.includes('upgrade')) {
31
- pluginAction = 'updated';
32
- }
33
- if (installResult.success ||
34
- installResult.output.includes('already') ||
35
- installResult.output.includes('up to date')) {
37
+ if (installResult.success || installResult.output.includes('already')) {
36
38
  pluginSpinner.succeed(`${plugin.pluginName} ${pluginAction}`);
37
39
  }
38
40
  else {
@@ -42,6 +44,7 @@ function installPlugin(plugin, cli, toolbox) {
42
44
  info('Manual installation:');
43
45
  info(` /plugin marketplace add ${plugin.marketplaceRepo}`);
44
46
  info(` /plugin install ${fullPluginName}`);
47
+ info(` /plugin update ${fullPluginName}`);
45
48
  return { action: 'failed', contents: plugin_utils_1.EMPTY_PLUGIN_CONTENTS, postInstall: null, success: false };
46
49
  }
47
50
  // Step 2: Read plugin contents
@@ -15,6 +15,7 @@ const dev_identity_1 = require("../../lib/dev-identity");
15
15
  const dev_process_1 = require("../../lib/dev-process");
16
16
  const dev_project_1 = require("../../lib/dev-project");
17
17
  const dev_state_1 = require("../../lib/dev-state");
18
+ const dev_test_session_1 = require("../../lib/dev-test-session");
18
19
  /**
19
20
  * Stop the processes started by `lt dev up` and remove the project's
20
21
  * Caddy block.
@@ -66,6 +67,17 @@ const DownCommand = {
66
67
  // do not pick up stale URLs.
67
68
  if ((0, dev_env_bridge_1.clearEnvBridge)(layout.root))
68
69
  info(colors.dim('Removed .lt-dev/.env bridge.'));
70
+ // Also tear down any isolated test stack (`lt dev test`) for this project,
71
+ // so `lt dev down` always leaves a clean slate.
72
+ if ((0, dev_test_session_1.hasTestSession)(layout.root)) {
73
+ const { stopped: testStopped } = yield (0, dev_test_session_1.tearDownTestSession)(layout, identity, {
74
+ dim: colors.dim,
75
+ info,
76
+ warn: warning,
77
+ });
78
+ if (testStopped.length > 0)
79
+ success(`Stopped test stack: ${testStopped.join(', ')}`);
80
+ }
69
81
  if (stopped.length > 0)
70
82
  success(`Stopped: ${stopped.join(', ')}`);
71
83
  if (!parameters.options.fromGluegunMenu)
@@ -26,7 +26,7 @@ const StatusCommand = {
26
26
  hidden: false,
27
27
  name: 'status',
28
28
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
29
- var _a, _b;
29
+ var _a, _b, _c, _d;
30
30
  const { filesystem, parameters, print: { colors, info, warning }, } = toolbox;
31
31
  const all = Boolean(parameters.options.all);
32
32
  const reg = (0, dev_state_1.loadRegistry)();
@@ -144,6 +144,24 @@ const StatusCommand = {
144
144
  }
145
145
  }
146
146
  }
147
+ // Isolated test stack (`lt dev test`), if one is up / left with --keep.
148
+ const testSession = (0, dev_state_1.loadSession)(layout.root, dev_state_1.TEST_SESSION_FILE);
149
+ if (testSession) {
150
+ const testEntry = reg.projects[`${identity.slug}-test`];
151
+ info('');
152
+ info(colors.bold(' Isolated test stack (lt dev test)'));
153
+ if (testEntry) {
154
+ for (const [sub, host] of Object.entries(testEntry.subdomains))
155
+ info(` ${sub.padEnd(6)} https://${host}`);
156
+ if (testEntry.dbName)
157
+ info(` db mongodb://127.0.0.1/${testEntry.dbName}`);
158
+ }
159
+ const apiAlive = testSession.pids.api ? (0, dev_state_1.isPidAlive)(testSession.pids.api) : false;
160
+ const appAlive = testSession.pids.app ? (0, dev_state_1.isPidAlive)(testSession.pids.app) : false;
161
+ info(` api: ${apiAlive ? colors.green('running') : colors.red('dead')} (pid ${(_c = testSession.pids.api) !== null && _c !== void 0 ? _c : '-'})`);
162
+ info(` app: ${appAlive ? colors.green('running') : colors.red('dead')} (pid ${(_d = testSession.pids.app) !== null && _d !== void 0 ? _d : '-'})`);
163
+ info(colors.dim(' stop: lt dev test down'));
164
+ }
147
165
  info('');
148
166
  if (!parameters.options.fromGluegunMenu)
149
167
  process.exit();
@@ -9,45 +9,44 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- const child_process_1 = require("child_process");
13
12
  const fs_1 = require("fs");
14
13
  const caddy_1 = require("../../lib/caddy");
15
14
  const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
16
15
  const dev_identity_1 = require("../../lib/dev-identity");
16
+ const dev_process_1 = require("../../lib/dev-process");
17
17
  const dev_project_1 = require("../../lib/dev-project");
18
- const dev_state_1 = require("../../lib/dev-state");
18
+ const dev_test_session_1 = require("../../lib/dev-test-session");
19
19
  /**
20
20
  * One-shot E2E convenience wrapper.
21
21
  *
22
- * Ensures the project is up under `lt dev`, then runs the test command
23
- * with the ENV bridge loaded, and optionally tears the session down at
24
- * the end. Useful for TDD loops, CI reproduction, and "just test it"
25
- * workflows where the developer doesn't want to remember three commands.
22
+ * App mode (default) runs the Playwright suite against a fully ISOLATED test
23
+ * stack (own URLs / ports / Caddy block / dedicated `<…>-test` database) that
24
+ * runs parallel to and never touches the developer's `lt dev up` session.
25
+ * Playwright's global-setup resets that dedicated DB once before the first test.
26
+ * The stack is torn down automatically when the run finishes (residue-free).
26
27
  *
27
- * Behaviour:
28
- * 1. If no `lt dev up` session is alive: run `lt dev up` first.
29
- * 2. Wait for the App URL to respond (best-effort, ~30s timeout).
30
- * 3. Inherit the env from `<root>/.lt-dev/.env` (so VS Code IDE-style
31
- * runners get the same setup) and spawn the test command in the
32
- * App project.
33
- * 4. With `--teardown` (default false): run `lt dev down` after.
28
+ * API mode (`--api`) runs the standalone API test suite (`pnpm test:e2e` in the
29
+ * API), which already isolates itself on its own DB no stack is brought up.
34
30
  *
35
31
  * Usage:
36
- * lt dev test # ensure up + run pnpm test:e2e in app
37
- * lt dev test --api # run API tests instead (pnpm test in api)
38
- * lt dev test --teardown # plus stop session after
39
- * lt dev test --debug # PWDEBUG=1 + headed mode
40
- * lt dev test -- <args> # forward args to the test runner
41
- * lt dev test -- --ui crm-login.spec.ts
32
+ * lt dev test # isolated Playwright E2E (auto teardown)
33
+ * lt dev test --keep # leave the test stack up afterwards (debug)
34
+ * lt dev test down # tear the test stack down (residue-free)
35
+ * lt dev test --api # run API tests instead (already isolated)
36
+ * lt dev test --debug # PWDEBUG=1 + headed
37
+ * lt dev test --shard # shard across 2 isolated stacks (default — stable)
38
+ * lt dev test --shard N # shard across N isolated stacks (parallel)
39
+ * lt dev test --shard auto # size N from this machine's CPU + RAM
40
+ * lt dev test -- <args> # forward args to Playwright
42
41
  */
43
42
  const TestCommand = {
44
43
  alias: ['t'],
45
- description: 'Ensure up + run E2E tests with lt dev env',
44
+ description: 'Run E2E tests in an isolated, parallel test stack (auto teardown)',
46
45
  hidden: false,
47
46
  name: 'test',
48
47
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
49
48
  var _a;
50
- const { filesystem, parameters, print: { colors, error, info, success }, } = toolbox;
49
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
51
50
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
52
51
  if (!layout.apiDir && !layout.appDir) {
53
52
  error('No API or App project detected at this path.');
@@ -55,74 +54,193 @@ const TestCommand = {
55
54
  process.exit(1);
56
55
  return 'dev test: not a project';
57
56
  }
57
+ const identity = (0, dev_identity_1.buildIdentity)(layout.root);
58
+ const log = { dim: colors.dim, info, warn: warning };
59
+ // Sub-command: `lt dev test down` — tear the test stack(s) down + exit.
60
+ // Reclaims both the unsharded stack and any leftover sharded stacks.
61
+ if (parameters.first === 'down') {
62
+ const { stopped } = yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log);
63
+ if (stopped.length > 0)
64
+ success(`Test stack down: ${stopped.join(', ')}`);
65
+ else
66
+ info(colors.dim('No test stack was running.'));
67
+ if (!parameters.options.fromGluegunMenu)
68
+ process.exit();
69
+ return 'dev test: down';
70
+ }
58
71
  const apiMode = Boolean(parameters.options.api);
59
- const teardown = Boolean(parameters.options.teardown);
72
+ const keep = Boolean(parameters.options.keep) || parameters.options.teardown === false;
60
73
  const debug = Boolean(parameters.options.debug);
61
- // Forwarded args after `--` (gluegun puts them in parameters.array).
62
74
  const forwarded = parameters.array || [];
63
- const targetDir = apiMode ? layout.apiDir : layout.appDir;
64
- if (!targetDir) {
65
- error(`Cannot run ${apiMode ? 'API' : 'App'} testsno ${apiMode ? 'apiDir' : 'appDir'} in layout.`);
66
- if (!parameters.options.fromGluegunMenu)
67
- process.exit(1);
68
- return 'dev test: target missing';
75
+ const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
76
+ // `--shard N` → run the suite split across N fully-isolated stacks in
77
+ // parallel. A bare `--shard` defaults to 2the stable sweet spot for a
78
+ // heavy built-SSR suite (N>=3 over-subscribes the perf cores → flaky; see
79
+ // autoShardCount). `--shard auto` instead sizes N from this machine's CPU+RAM.
80
+ const SHARD_DEFAULT = 2;
81
+ const shardRaw = (_a = parameters.options.shard) !== null && _a !== void 0 ? _a : parameters.options.shards;
82
+ let shardTotal;
83
+ if (shardRaw !== undefined) {
84
+ if (shardRaw === true)
85
+ shardTotal = SHARD_DEFAULT;
86
+ else if (String(shardRaw).toLowerCase() === 'auto')
87
+ shardTotal = (0, dev_test_session_1.autoShardCount)();
88
+ else
89
+ shardTotal = Math.floor(Number(shardRaw));
69
90
  }
70
- const identity = (0, dev_identity_1.buildIdentity)(layout.root);
71
- // Pre-flight: Caddy + ensure up.
72
- if (!apiMode) {
73
- if (!(yield (0, caddy_1.caddyAvailable)())) {
74
- error('caddy is not installed. Run `lt dev install` first.');
91
+ // -------------------------------------------------------------------------
92
+ // API mode standalone, already isolated on its own DB. No stack needed.
93
+ // -------------------------------------------------------------------------
94
+ if (apiMode) {
95
+ if (!layout.apiDir) {
96
+ error('No API project in this layout.');
75
97
  if (!parameters.options.fromGluegunMenu)
76
98
  process.exit(1);
77
- return 'dev test: caddy missing';
99
+ return 'dev test: no api';
78
100
  }
79
- if (!(yield (0, caddy_1.caddyDaemonRunning)())) {
80
- error('caddy daemon not running. Run `lt dev install` first.');
81
- if (!parameters.options.fromGluegunMenu)
82
- process.exit(1);
83
- return 'dev test: caddy daemon down';
101
+ info(colors.bold(`Running API tests for "${identity.slug}" (isolated DB)`));
102
+ const code = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], {
103
+ cwd: layout.apiDir,
104
+ env: process.env,
105
+ });
106
+ if (code === 0)
107
+ success('API tests passed.');
108
+ else
109
+ error(`API tests failed (exit ${code}).`);
110
+ if (!parameters.options.fromGluegunMenu)
111
+ process.exit(code !== null && code !== void 0 ? code : 1);
112
+ return `dev test: api exit=${code}`;
113
+ }
114
+ // -------------------------------------------------------------------------
115
+ // App mode — isolated Playwright stack.
116
+ // -------------------------------------------------------------------------
117
+ if (!layout.appDir) {
118
+ error('No App project in this layout.');
119
+ if (!parameters.options.fromGluegunMenu)
120
+ process.exit(1);
121
+ return 'dev test: no app';
122
+ }
123
+ if (!(yield (0, caddy_1.caddyAvailable)())) {
124
+ error('caddy is not installed. Run `lt dev install` first.');
125
+ if (!parameters.options.fromGluegunMenu)
126
+ process.exit(1);
127
+ return 'dev test: caddy missing';
128
+ }
129
+ if (!(yield (0, caddy_1.caddyDaemonRunning)())) {
130
+ error('caddy daemon not running. Run `lt dev install` first.');
131
+ if (!parameters.options.fromGluegunMenu)
132
+ process.exit(1);
133
+ return 'dev test: caddy daemon down';
134
+ }
135
+ // Pre-flight (#3): if the project's playwright.config is not env-aware
136
+ // (hardcoded baseURL or an unguarded `webServer`), the suite would IGNORE
137
+ // the isolated stack we are about to build and hit localhost:3001 instead —
138
+ // wasting a full build on a stack nothing uses. Abort early with the fix,
139
+ // unless `--force`.
140
+ const unpatched = (0, dev_project_1.appNeedsPortPatch)(layout.appDir).some((f) => f.endsWith('playwright.config.ts'));
141
+ if (unpatched && !parameters.options.force) {
142
+ error("This project's playwright.config.ts is not env-aware (hardcoded baseURL or unguarded webServer).");
143
+ error('`lt dev test` would build an isolated stack the suite then IGNORES (tests would hit localhost:3001).');
144
+ info(colors.dim('Fix: run `lt dev init` to make baseURL env-aware + guard the webServer — or pass --force.'));
145
+ if (!parameters.options.fromGluegunMenu)
146
+ process.exit(1);
147
+ return 'dev test: playwright.config not env-aware (run lt dev init or --force)';
148
+ }
149
+ // -------------------------------------------------------------------------
150
+ // Sharded mode (`--shard N`) — N fully-isolated stacks + parallel
151
+ // `--shard=i/N`, the local CI-parity matrix. Each shard has its own DB so
152
+ // there is no cross-shard contention. Auto-teardown of ALL shards (or
153
+ // `--keep` them for `lt dev test down`).
154
+ // -------------------------------------------------------------------------
155
+ if (shardTotal !== undefined && shardTotal > 1) {
156
+ let allTornDown = false;
157
+ const teardownAll = () => __awaiter(void 0, void 0, void 0, function* () {
158
+ if (allTornDown || keep)
159
+ return;
160
+ allTornDown = true;
161
+ yield (0, dev_test_session_1.tearDownAllTestSessions)(layout, identity, log, { silent: true });
162
+ });
163
+ const onShardSignal = () => {
164
+ teardownAll()
165
+ .catch(() => undefined)
166
+ .finally(() => process.exit(130));
167
+ };
168
+ process.on('SIGINT', onShardSignal);
169
+ process.on('SIGTERM', onShardSignal);
170
+ let shardExit = 1;
171
+ try {
172
+ info('');
173
+ info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}" sharded across ${shardTotal} stacks`));
174
+ shardExit = yield (0, dev_test_session_1.runShardedTestSession)(layout, identity, log, { forwarded, pnpmBin, total: shardTotal });
175
+ }
176
+ catch (e) {
177
+ error(`Failed to run sharded E2E: ${e.message}`);
178
+ shardExit = 1;
84
179
  }
85
- const reg = (0, dev_state_1.loadRegistry)();
86
- const entry = reg.projects[identity.slug];
87
- const session = (0, dev_state_1.loadSession)(layout.root);
88
- const sessionAlive = session !== null &&
89
- ((session.pids.api && (0, dev_state_1.isPidAlive)(session.pids.api)) || (session.pids.app && (0, dev_state_1.isPidAlive)(session.pids.app)));
90
- if (!entry || !sessionAlive) {
91
- info(colors.dim('Project not running — invoking `lt dev up` first ...'));
92
- const upResult = yield runChild('lt', ['dev', 'up'], { cwd: layout.root, env: process.env, inherit: true });
93
- if (upResult !== 0) {
94
- error(`lt dev up failed (exit ${upResult}).`);
95
- if (!parameters.options.fromGluegunMenu)
96
- process.exit(upResult !== null && upResult !== void 0 ? upResult : 1);
97
- return 'dev test: up failed';
180
+ finally {
181
+ process.off('SIGINT', onShardSignal);
182
+ process.off('SIGTERM', onShardSignal);
183
+ if (keep) {
184
+ info('');
185
+ info(colors.dim('Test stacks left running (--keep). Stop them with: `lt dev test down`.'));
98
186
  }
99
- // Wait for App URL to respond (best-effort).
100
- const appHost = (_a = identity.subdomains.app) === null || _a === void 0 ? void 0 : _a.hostname;
101
- if (appHost) {
102
- info(colors.dim(`Waiting for https://${appHost} to respond ...`));
103
- yield waitForUrl(`https://${appHost}`, 30000);
187
+ else {
188
+ yield teardownAll();
104
189
  }
105
190
  }
191
+ if (shardExit === 0)
192
+ success('Tests passed (all shards).');
193
+ else
194
+ error(`Tests failed (exit ${shardExit}).`);
195
+ if (!parameters.options.fromGluegunMenu)
196
+ process.exit(shardExit !== null && shardExit !== void 0 ? shardExit : 1);
197
+ return `dev test: sharded exit=${shardExit}`;
106
198
  }
107
- // Build env: process.env + bridge file (bridge wins for keys it defines).
108
- const env = Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root));
109
- if (debug) {
110
- env.PWDEBUG = '1';
111
- env.HEADED = '1';
112
- }
113
- // Pick the runner.
114
- const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
115
- const args = apiMode ? ['run', 'test:e2e', ...forwarded] : ['run', 'test:e2e', ...forwarded];
116
- info('');
117
- info(colors.bold(`Running ${apiMode ? 'API' : 'App'} E2E tests for "${identity.slug}"`));
118
- info(colors.dim(` ${pnpmBin} ${args.join(' ')}`));
119
- info(colors.dim(` cwd: ${targetDir}`));
120
- info('');
121
- const exitCode = yield runChild(pnpmBin, args, { cwd: targetDir, env, inherit: true });
122
- if (teardown) {
199
+ let tornDown = false;
200
+ const teardown = () => __awaiter(void 0, void 0, void 0, function* () {
201
+ if (tornDown || keep)
202
+ return;
203
+ tornDown = true;
204
+ yield (0, dev_test_session_1.tearDownTestSession)(layout, identity, log);
205
+ });
206
+ // Residue-free teardown even on Ctrl-C / kill.
207
+ const onSignal = () => {
208
+ teardown()
209
+ .catch(() => undefined)
210
+ .finally(() => process.exit(130));
211
+ };
212
+ process.on('SIGINT', onSignal);
213
+ process.on('SIGTERM', onSignal);
214
+ let exitCode = 1;
215
+ try {
216
+ const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log);
217
+ const env = Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
218
+ // Playwright global-setup resets THIS db (allow-listed) before the suite.
219
+ MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` });
220
+ if (debug) {
221
+ env.PWDEBUG = '1';
222
+ env.HEADED = '1';
223
+ }
123
224
  info('');
124
- info(colors.dim('Tearing down lt dev session ...'));
125
- yield runChild('lt', ['dev', 'down'], { cwd: layout.root, env: process.env, inherit: true });
225
+ info(colors.bold(`Running isolated Playwright E2E for "${identity.slug}"`));
226
+ info(colors.dim(` app: ${ctx.appUrl} db: ${ctx.dbName}`));
227
+ info('');
228
+ exitCode = yield (0, dev_process_1.runChildInherit)(pnpmBin, ['run', 'test:e2e', ...forwarded], { cwd: layout.appDir, env });
229
+ }
230
+ catch (e) {
231
+ error(`Failed to run isolated E2E: ${e.message}`);
232
+ exitCode = 1;
233
+ }
234
+ finally {
235
+ process.off('SIGINT', onSignal);
236
+ process.off('SIGTERM', onSignal);
237
+ if (keep) {
238
+ info('');
239
+ info(colors.dim('Test stack left running (--keep). Stop it with: `lt dev test down`.'));
240
+ }
241
+ else {
242
+ yield teardown();
243
+ }
126
244
  }
127
245
  if (exitCode === 0)
128
246
  success('Tests passed.');
@@ -134,11 +252,11 @@ const TestCommand = {
134
252
  }),
135
253
  };
136
254
  /**
137
- * Read the .lt-dev/.env bridge file as a key/value map.
138
- * Returns an empty object if the file is missing.
255
+ * Read the test ENV bridge (`.lt-dev/.env.test`) as a key/value map.
256
+ * Returns an empty object if missing.
139
257
  */
140
258
  function readBridgeEnv(root) {
141
- const file = (0, dev_env_bridge_1.envBridgePath)(root);
259
+ const file = (0, dev_env_bridge_1.envBridgePath)(root, '.env.test');
142
260
  if (!(0, fs_1.existsSync)(file))
143
261
  return {};
144
262
  const out = {};
@@ -149,43 +267,4 @@ function readBridgeEnv(root) {
149
267
  }
150
268
  return out;
151
269
  }
152
- /** Spawn a child synchronously (waits for exit), inheriting stdio when requested. */
153
- function runChild(cmd, args, opts) {
154
- return new Promise((resolve) => {
155
- const child = (0, child_process_1.spawn)(cmd, args, {
156
- cwd: opts.cwd,
157
- env: opts.env,
158
- stdio: opts.inherit ? 'inherit' : 'pipe',
159
- });
160
- child.on('error', () => resolve(1));
161
- child.on('close', (code) => resolve(code));
162
- });
163
- }
164
- /** Poll a URL until it responds or timeout elapses. */
165
- function waitForUrl(url, timeoutMs) {
166
- const start = Date.now();
167
- return new Promise((resolve) => {
168
- const tick = () => {
169
- var _a;
170
- const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
171
- stdio: ['ignore', 'pipe', 'ignore'],
172
- });
173
- let status = '';
174
- (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
175
- child.on('close', () => {
176
- if (/^[1-5]\d\d$/.test(status.trim()))
177
- return resolve(true);
178
- if (Date.now() - start > timeoutMs)
179
- return resolve(false);
180
- setTimeout(tick, 500);
181
- });
182
- child.on('error', () => {
183
- if (Date.now() - start > timeoutMs)
184
- return resolve(false);
185
- setTimeout(tick, 500);
186
- });
187
- };
188
- tick();
189
- });
190
- }
191
270
  module.exports = TestCommand;
@@ -24,8 +24,8 @@ const os_1 = require("os");
24
24
  const path_1 = require("path");
25
25
  const HEADER = `# Managed by \`lt dev up\` — do NOT edit, will be overwritten.\n# Removed by \`lt dev down\`. Loaded by Playwright + other tools.\n`;
26
26
  /** Remove the ENV bridge file. No-op if missing. */
27
- function clearEnvBridge(projectRoot) {
28
- const file = envBridgePath(projectRoot);
27
+ function clearEnvBridge(projectRoot, fileName = '.env') {
28
+ const file = envBridgePath(projectRoot, fileName);
29
29
  if (!(0, fs_1.existsSync)(file))
30
30
  return false;
31
31
  try {
@@ -61,16 +61,16 @@ function detectCaddyRootCa() {
61
61
  return null;
62
62
  }
63
63
  /** Resolve the path to the ENV bridge file for a project. */
64
- function envBridgePath(projectRoot) {
65
- return (0, path_1.join)(projectRoot, '.lt-dev', '.env');
64
+ function envBridgePath(projectRoot, fileName = '.env') {
65
+ return (0, path_1.join)(projectRoot, '.lt-dev', fileName);
66
66
  }
67
67
  /**
68
68
  * Write the ENV bridge file. Idempotent — same content = no rewrite.
69
69
  *
70
70
  * Returns the absolute path that was written.
71
71
  */
72
- function writeEnvBridge(projectRoot, devEnv, dbName) {
73
- const file = envBridgePath(projectRoot);
72
+ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
73
+ const file = envBridgePath(projectRoot, fileName);
74
74
  const lines = [];
75
75
  // The App-side env is the more "external" one (Playwright, browser tools).
76
76
  // We expose every URL/storage/api key the App env carries.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildIdentity = buildIdentity;
4
+ exports.buildTestIdentity = buildTestIdentity;
4
5
  exports.projectSlug = projectSlug;
5
6
  exports.slugify = slugify;
6
7
  /**
@@ -67,6 +68,23 @@ function buildIdentity(root) {
67
68
  }
68
69
  return { root, slug, subdomains };
69
70
  }
71
+ /**
72
+ * Derive an ephemeral "test" identity from a base identity (used by
73
+ * `lt dev test`). Suffixes the slug and every subdomain hostname with
74
+ * `-test`, so the test stack runs on its own URLs / ports / Caddy block,
75
+ * fully parallel to (and isolated from) the dev session.
76
+ *
77
+ * svl.localhost → svl-test.localhost
78
+ * api.svl.localhost → api.svl-test.localhost
79
+ */
80
+ function buildTestIdentity(base, suffix = '-test') {
81
+ const slug = `${base.slug}${suffix}`;
82
+ const subdomains = {};
83
+ for (const [sub, value] of Object.entries(base.subdomains)) {
84
+ subdomains[sub] = Object.assign(Object.assign({}, value), { hostname: value.isPrimaryApp ? `${slug}.localhost` : `${sub}.${slug}.localhost` });
85
+ }
86
+ return { root: base.root, slug, subdomains };
87
+ }
70
88
  /**
71
89
  * Read the bare project name from package.json (scope stripped).
72
90
  * Falls back to directory basename if no package.json or no `name`.
@@ -78,15 +78,25 @@ function runMigrate(input) {
78
78
  });
79
79
  const identity = (0, dev_identity_1.buildIdentity)(layout.root);
80
80
  const dbName = (0, dev_project_1.deriveDbName)(layout.apiDir, identity.slug);
81
- // 1. Code patches (config.env.ts, nuxt.config.ts, playwright.config.ts).
81
+ // 1. Code patches. Run `autoPatch` over EVERY existing config file (not just
82
+ // the ones a port-detector flags): the patches are idempotent, and some —
83
+ // e.g. the playwright.config `ignoreHTTPSErrors` + shard-aware
84
+ // `LT_DEV_TEST_SHARDS` timeout block — apply to configs that are already
85
+ // env-aware. So `lt dev init` makes any project fully `lt dev test --shard`
86
+ // ready in one command; an up-to-date config is a no-op (`patched: false`).
82
87
  const filesToPatch = [];
83
88
  if (layout.apiDir) {
84
- const f = (0, dev_project_1.apiNeedsPortPatch)(layout.apiDir);
85
- if (f)
86
- filesToPatch.push(f);
89
+ const apiCfg = (0, path_1.join)(layout.apiDir, 'src', 'config.env.ts');
90
+ if ((0, fs_1.existsSync)(apiCfg))
91
+ filesToPatch.push(apiCfg);
92
+ }
93
+ if (layout.appDir) {
94
+ for (const rel of ['nuxt.config.ts', 'playwright.config.ts']) {
95
+ const f = (0, path_1.join)(layout.appDir, rel);
96
+ if ((0, fs_1.existsSync)(f))
97
+ filesToPatch.push(f);
98
+ }
87
99
  }
88
- if (layout.appDir)
89
- filesToPatch.push(...(0, dev_project_1.appNeedsPortPatch)(layout.appDir));
90
100
  const codePatches = filesToPatch.map((f) => (0, dev_patches_1.autoPatch)(f));
91
101
  // 2. CLAUDE.md URL block (root + each subproject — only patches existing files).
92
102
  const claudeCandidates = [