@tlbx-ai/midterm 8.2.1 → 8.2.4

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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/bin/midterm.js +381 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,5 +31,6 @@ Notes:
31
31
 
32
32
  - Default channel is `stable`
33
33
  - If you do not pass `--bind`, the launcher forces `127.0.0.1`
34
- - If you do not pass `--port`, the launcher opens `https://127.0.0.1:2000`
34
+ - If you do not pass `--port`, the launcher starts at `https://127.0.0.1:2000` and automatically moves to the next free port if `2000` is unavailable
35
35
  - The launcher sets `MIDTERM_LAUNCH_MODE=npx` for the child process
36
+ - If you invoke `npx` from WSL but it resolves to Windows `node/npm`, the launcher detects the WSL working directory and runs the Linux MidTerm build inside that distro
package/bin/midterm.js CHANGED
@@ -5,12 +5,14 @@
5
5
  const fs = require('node:fs');
6
6
  const fsp = require('node:fs/promises');
7
7
  const https = require('node:https');
8
+ const net = require('node:net');
8
9
  const os = require('node:os');
9
10
  const path = require('node:path');
10
11
  const { spawn, spawnSync } = require('node:child_process');
11
12
 
12
13
  const { version: PACKAGE_VERSION } = require('../package.json');
13
14
  const DEFAULT_PORT = 2000;
15
+ const MAX_PORT_SCAN_ATTEMPTS = 100;
14
16
  const SERVER_READY_TIMEOUT_MS = 15000;
15
17
  const SERVER_READY_INTERVAL_MS = 500;
16
18
  const REPO_OWNER = 'tlbx-ai';
@@ -25,39 +27,55 @@ async function main() {
25
27
  return;
26
28
  }
27
29
 
28
- const target = getPlatformTarget();
30
+ const runtime = await detectRuntime();
31
+ const target = await getPlatformTarget(runtime);
29
32
  const release = await resolveRelease(launcher.channel);
30
- const installDir = await ensureInstalledRelease(release, target);
31
- const mtPath = path.join(installDir, target.binaryName);
32
- const mthostPath = path.join(installDir, target.hostBinaryName);
33
-
34
- if (!fs.existsSync(mtPath) || !fs.existsSync(mthostPath)) {
35
- throw new Error(`Downloaded release is incomplete: expected ${target.binaryName} and ${target.hostBinaryName}`);
36
- }
33
+ const install = runtime.kind === 'wsl-interop'
34
+ ? await ensureInstalledReleaseInWsl(release, target, runtime)
35
+ : await ensureInstalledRelease(release, target);
37
36
 
38
37
  const childArgs = passthrough.slice();
38
+ const hasExplicitPort = hasArg(childArgs, '--port');
39
39
  const explicitBind = getArgValue(childArgs, '--bind');
40
40
  const explicitPort = parsePortArg(getArgValue(childArgs, '--port'));
41
+ const effectiveBind = explicitBind ?? '127.0.0.1';
42
+ const startsServer = shouldStartServer(childArgs);
43
+
44
+ let effectivePort = explicitPort ?? DEFAULT_PORT;
41
45
 
42
46
  if (!explicitBind) {
43
47
  childArgs.push('--bind', '127.0.0.1');
44
48
  }
45
49
 
46
- const browserUrl = buildBrowserUrl(explicitBind ?? '127.0.0.1', explicitPort ?? DEFAULT_PORT);
50
+ if (startsServer && !hasExplicitPort) {
51
+ effectivePort = await findAvailablePort(effectiveBind, DEFAULT_PORT);
52
+ childArgs.push('--port', String(effectivePort));
53
+
54
+ if (effectivePort !== DEFAULT_PORT) {
55
+ console.error(`@tlbx-ai/midterm: port ${DEFAULT_PORT} is unavailable, using ${effectivePort}`);
56
+ }
57
+ }
58
+
59
+ const browserUrl = startsServer
60
+ ? buildBrowserUrl(effectiveBind, effectivePort)
61
+ : null;
47
62
  const childEnv = {
48
63
  ...process.env,
49
64
  MIDTERM_LAUNCH_MODE: 'npx',
50
65
  MIDTERM_NPX: '1',
51
66
  MIDTERM_NPX_CHANNEL: launcher.channel,
52
- MIDTERM_NPX_PACKAGE_VERSION: PACKAGE_VERSION
67
+ MIDTERM_NPX_PACKAGE_VERSION: PACKAGE_VERSION,
68
+ MIDTERM_NPX_RUNTIME: runtime.kind
53
69
  };
54
70
 
55
- const child = spawn(mtPath, childArgs, {
56
- stdio: 'inherit',
57
- env: childEnv
58
- });
71
+ const child = runtime.kind === 'wsl-interop'
72
+ ? spawnMidTermInWsl(runtime, install.mtPath, childArgs, childEnv)
73
+ : spawn(install.mtPath, childArgs, {
74
+ stdio: 'inherit',
75
+ env: childEnv
76
+ });
59
77
 
60
- if (launcher.openBrowser) {
78
+ if (launcher.openBrowser && browserUrl) {
61
79
  void openBrowserWhenReady(browserUrl);
62
80
  }
63
81
 
@@ -130,7 +148,105 @@ function printHelp() {
130
148
  console.log('All other arguments are passed to mt.');
131
149
  }
132
150
 
133
- function getPlatformTarget() {
151
+ async function detectRuntime() {
152
+ const wslContext = await detectWslInteropContext();
153
+ if (wslContext) {
154
+ return wslContext;
155
+ }
156
+
157
+ return {
158
+ kind: 'native',
159
+ platform: process.platform,
160
+ arch: process.arch
161
+ };
162
+ }
163
+
164
+ async function detectWslInteropContext() {
165
+ if (process.platform !== 'win32') {
166
+ return null;
167
+ }
168
+
169
+ const parsed = findWslInteropPath();
170
+ if (!parsed) {
171
+ return null;
172
+ }
173
+
174
+ const linuxHome = getWslCommandOutput(parsed.distroName, ['pwd'], '~');
175
+ if (!linuxHome.startsWith('/')) {
176
+ throw new Error(`Failed to determine WSL home directory for ${parsed.distroName}`);
177
+ }
178
+
179
+ const archRaw = getWslCommandOutput(parsed.distroName, ['uname', '-m'], '/');
180
+
181
+ return {
182
+ kind: 'wsl-interop',
183
+ distroName: parsed.distroName,
184
+ linuxCwd: parsed.linuxPath,
185
+ linuxHome,
186
+ uncRoot: parsed.uncRoot,
187
+ arch: normalizeWslArchitecture(archRaw)
188
+ };
189
+ }
190
+
191
+ function findWslInteropPath() {
192
+ const candidates = [
193
+ process.cwd(),
194
+ process.env.INIT_CWD,
195
+ process.env.npm_config_local_prefix,
196
+ getPackageDirectory(process.env.npm_package_json)
197
+ ];
198
+
199
+ for (const candidate of candidates) {
200
+ const parsed = parseWslUncPath(candidate);
201
+ if (parsed) {
202
+ return parsed;
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ function getPackageDirectory(packageJsonPath) {
210
+ if (!packageJsonPath) {
211
+ return '';
212
+ }
213
+
214
+ return path.win32.dirname(packageJsonPath);
215
+ }
216
+
217
+ function parseWslUncPath(value) {
218
+ const normalized = String(value || '');
219
+ const match = normalized.match(/^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(\\.*)?$/i);
220
+ if (!match) {
221
+ return null;
222
+ }
223
+
224
+ const distroName = match[1];
225
+ const suffix = match[2] || '';
226
+ const linuxPath = suffix
227
+ ? suffix.replace(/\\/g, '/')
228
+ : '/';
229
+
230
+ return {
231
+ distroName,
232
+ linuxPath,
233
+ uncRoot: `\\\\wsl.localhost\\${distroName}`
234
+ };
235
+ }
236
+
237
+ async function getPlatformTarget(runtime) {
238
+ if (runtime.kind === 'wsl-interop') {
239
+ if (runtime.arch === 'x64') {
240
+ return {
241
+ assetName: 'mt-linux-x64.tar.gz',
242
+ binaryName: 'mt',
243
+ hostBinaryName: 'mthost'
244
+ };
245
+ }
246
+
247
+ throw new Error(`Unsupported WSL platform: linux ${runtime.arch}`);
248
+ }
249
+
134
250
  if (process.platform === 'win32' && process.arch === 'x64') {
135
251
  return {
136
252
  assetName: 'mt-win-x64.zip',
@@ -208,8 +324,12 @@ async function ensureInstalledRelease(release, target) {
208
324
  throw new Error(`Release ${release.tag} does not contain ${target.assetName}`);
209
325
  }
210
326
 
327
+ const mtPath = path.join(versionDir, target.binaryName);
328
+ const mthostPath = path.join(versionDir, target.hostBinaryName);
329
+
211
330
  if (fs.existsSync(completeMarker)) {
212
- return versionDir;
331
+ ensureInstalledFilesExist(mtPath, mthostPath, target);
332
+ return { mtPath, mthostPath };
213
333
  }
214
334
 
215
335
  await fsp.mkdir(cacheRoot, { recursive: true });
@@ -228,7 +348,8 @@ async function ensureInstalledRelease(release, target) {
228
348
  await fsp.rm(versionDir, { recursive: true, force: true });
229
349
  await fsp.rename(extractDir, versionDir);
230
350
  await fsp.writeFile(completeMarker, `${release.tag}\n`, 'utf8');
231
- return versionDir;
351
+ ensureInstalledFilesExist(mtPath, mthostPath, target);
352
+ return { mtPath, mthostPath };
232
353
  } catch (error) {
233
354
  await fsp.rm(versionDir, { recursive: true, force: true }).catch(() => {});
234
355
  throw error;
@@ -237,6 +358,58 @@ async function ensureInstalledRelease(release, target) {
237
358
  }
238
359
  }
239
360
 
361
+ async function ensureInstalledReleaseInWsl(release, target, runtime) {
362
+ const cacheRootLinux = path.posix.join(runtime.linuxHome, '.cache', 'midterm', 'npx-cache');
363
+ const cacheRootUnc = toWslUncPath(runtime, cacheRootLinux);
364
+ const versionDirLinux = path.posix.join(cacheRootLinux, sanitizeTag(release.tag));
365
+ const versionDirUnc = toWslUncPath(runtime, versionDirLinux);
366
+ const completeMarkerUnc = toWslUncPath(runtime, path.posix.join(versionDirLinux, '.complete'));
367
+ const targetAsset = release.assets.find((asset) => asset.name === target.assetName);
368
+
369
+ if (!targetAsset || !targetAsset.browser_download_url) {
370
+ throw new Error(`Release ${release.tag} does not contain ${target.assetName}`);
371
+ }
372
+
373
+ const mtPath = path.posix.join(versionDirLinux, target.binaryName);
374
+ const mthostPath = path.posix.join(versionDirLinux, target.hostBinaryName);
375
+ const mtPathUnc = toWslUncPath(runtime, mtPath);
376
+ const mthostPathUnc = toWslUncPath(runtime, mthostPath);
377
+
378
+ if (fs.existsSync(completeMarkerUnc)) {
379
+ ensureInstalledFilesExist(mtPathUnc, mthostPathUnc, target);
380
+ return { mtPath, mthostPath };
381
+ }
382
+
383
+ await fsp.mkdir(cacheRootUnc, { recursive: true });
384
+
385
+ const tempName = `staging-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
386
+ const tempRootLinux = path.posix.join(cacheRootLinux, tempName);
387
+ const tempRootUnc = toWslUncPath(runtime, tempRootLinux);
388
+ const archiveLinux = path.posix.join(tempRootLinux, target.assetName);
389
+ const archiveUnc = toWslUncPath(runtime, archiveLinux);
390
+ const extractLinux = path.posix.join(tempRootLinux, 'extract');
391
+ const extractUnc = toWslUncPath(runtime, extractLinux);
392
+
393
+ try {
394
+ await fsp.mkdir(extractUnc, { recursive: true });
395
+ console.error(`MidTerm ${release.tag}: downloading ${target.assetName}`);
396
+ await downloadFile(targetAsset.browser_download_url, archiveUnc);
397
+ console.error(`MidTerm ${release.tag}: extracting`);
398
+ runWslCommand(runtime, ['tar', '-xzf', archiveLinux, '-C', extractLinux], '/');
399
+ runWslCommand(runtime, ['chmod', '755', path.posix.join(extractLinux, target.binaryName), path.posix.join(extractLinux, target.hostBinaryName)], '/');
400
+ await fsp.rm(versionDirUnc, { recursive: true, force: true });
401
+ await fsp.rename(extractUnc, versionDirUnc);
402
+ await fsp.writeFile(completeMarkerUnc, `${release.tag}\n`, 'utf8');
403
+ ensureInstalledFilesExist(mtPathUnc, mthostPathUnc, target);
404
+ return { mtPath, mthostPath };
405
+ } catch (error) {
406
+ await fsp.rm(versionDirUnc, { recursive: true, force: true }).catch(() => {});
407
+ throw error;
408
+ } finally {
409
+ await fsp.rm(tempRootUnc, { recursive: true, force: true }).catch(() => {});
410
+ }
411
+ }
412
+
240
413
  function getCacheRoot() {
241
414
  if (process.platform === 'win32') {
242
415
  const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
@@ -286,14 +459,20 @@ function extractArchive(archivePath, destinationPath) {
286
459
  '-Command',
287
460
  `Expand-Archive -LiteralPath '${escapePowerShell(archivePath)}' -DestinationPath '${escapePowerShell(destinationPath)}' -Force`
288
461
  ];
289
- const result = spawnSync('powershell', command, { stdio: 'inherit' });
462
+ const result = spawnSync('powershell', command, {
463
+ stdio: 'inherit',
464
+ cwd: getWindowsSubprocessCwd()
465
+ });
290
466
  if (result.status !== 0) {
291
467
  throw new Error(`Failed to extract ${path.basename(archivePath)} with PowerShell`);
292
468
  }
293
469
  return;
294
470
  }
295
471
 
296
- const result = spawnSync('tar', ['-xzf', archivePath, '-C', destinationPath], { stdio: 'inherit' });
472
+ const result = spawnSync('tar', ['-xzf', archivePath, '-C', destinationPath], {
473
+ stdio: 'inherit',
474
+ cwd: getWindowsSubprocessCwd()
475
+ });
297
476
  if (result.status !== 0) {
298
477
  throw new Error(`Failed to extract ${path.basename(archivePath)} with tar`);
299
478
  }
@@ -341,6 +520,68 @@ function parsePortArg(value) {
341
520
  return parsed;
342
521
  }
343
522
 
523
+ function shouldStartServer(args) {
524
+ const nonServerFlags = [
525
+ '--check-update',
526
+ '--update',
527
+ '--apply-update',
528
+ '--version',
529
+ '-v',
530
+ '--help',
531
+ '-h',
532
+ '--hash-password',
533
+ '--write-secret',
534
+ '--generate-cert'
535
+ ];
536
+
537
+ return !nonServerFlags.some((flag) => hasArg(args, flag));
538
+ }
539
+
540
+ async function findAvailablePort(bindAddress, preferredPort) {
541
+ for (let offset = 0; offset < MAX_PORT_SCAN_ATTEMPTS; offset++) {
542
+ const port = preferredPort + offset;
543
+ if (port > 65535) {
544
+ break;
545
+ }
546
+
547
+ if (await isPortAvailable(bindAddress, port)) {
548
+ return port;
549
+ }
550
+ }
551
+
552
+ throw new Error(`Could not find a free port starting at ${preferredPort}`);
553
+ }
554
+
555
+ function isPortAvailable(bindAddress, port) {
556
+ const host = normalizeBindForNetProbe(bindAddress);
557
+
558
+ return new Promise((resolve) => {
559
+ const server = net.createServer();
560
+
561
+ server.once('error', (error) => {
562
+ if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) {
563
+ resolve(false);
564
+ return;
565
+ }
566
+
567
+ resolve(false);
568
+ });
569
+
570
+ server.listen(port, host, () => {
571
+ server.close(() => resolve(true));
572
+ });
573
+ });
574
+ }
575
+
576
+ function normalizeBindForNetProbe(bindAddress) {
577
+ const raw = String(bindAddress || '').trim();
578
+ if (!raw || raw === 'localhost') {
579
+ return '127.0.0.1';
580
+ }
581
+
582
+ return raw.replace(/^\[(.*)\]$/, '$1');
583
+ }
584
+
344
585
  function buildBrowserUrl(bindAddress, port) {
345
586
  const normalized = normalizeHostForBrowser(bindAddress);
346
587
  return `https://${normalized}:${port}`;
@@ -427,7 +668,8 @@ function openUrl(url) {
427
668
  const result = spawn(command, args, {
428
669
  detached: true,
429
670
  stdio: 'ignore',
430
- windowsHide: true
671
+ windowsHide: true,
672
+ cwd: getWindowsSubprocessCwd()
431
673
  });
432
674
  result.on('error', (error) => {
433
675
  console.error(`@tlbx-ai/midterm: failed to open browser automatically: ${error.message}`);
@@ -435,6 +677,37 @@ function openUrl(url) {
435
677
  result.unref();
436
678
  }
437
679
 
680
+ function spawnMidTermInWsl(runtime, mtPath, childArgs, childEnv) {
681
+ const envArgs = ['env'];
682
+ const passthroughEnv = [
683
+ 'MIDTERM_LAUNCH_MODE',
684
+ 'MIDTERM_NPX',
685
+ 'MIDTERM_NPX_CHANNEL',
686
+ 'MIDTERM_NPX_PACKAGE_VERSION',
687
+ 'MIDTERM_NPX_RUNTIME'
688
+ ];
689
+
690
+ for (const key of passthroughEnv) {
691
+ if (childEnv[key]) {
692
+ envArgs.push(`${key}=${childEnv[key]}`);
693
+ }
694
+ }
695
+
696
+ envArgs.push(mtPath, ...childArgs);
697
+
698
+ return spawn('wsl.exe', [
699
+ '--distribution',
700
+ runtime.distroName,
701
+ '--cd',
702
+ runtime.linuxCwd,
703
+ '--exec',
704
+ ...envArgs
705
+ ], {
706
+ stdio: 'inherit',
707
+ cwd: getWindowsSubprocessCwd()
708
+ });
709
+ }
710
+
438
711
  function forwardSignal(child, signal) {
439
712
  process.on(signal, () => {
440
713
  if (!child.killed) {
@@ -447,6 +720,93 @@ function escapePowerShell(value) {
447
720
  return value.replace(/'/g, "''");
448
721
  }
449
722
 
723
+ function ensureInstalledFilesExist(mtPath, mthostPath, target) {
724
+ if (!fs.existsSync(mtPath) || !fs.existsSync(mthostPath)) {
725
+ throw new Error(`Downloaded release is incomplete: expected ${target.binaryName} and ${target.hostBinaryName}`);
726
+ }
727
+ }
728
+
729
+ function toWslUncPath(runtime, linuxPath) {
730
+ const normalized = String(linuxPath || '/');
731
+ const suffix = normalized === '/'
732
+ ? ''
733
+ : normalized.replace(/\//g, '\\');
734
+ return `${runtime.uncRoot}${suffix}`;
735
+ }
736
+
737
+ function getWslCommandOutput(distroName, commandArgs, cwd) {
738
+ const result = spawnSync('wsl.exe', [
739
+ '--distribution',
740
+ distroName,
741
+ '--cd',
742
+ cwd,
743
+ '--exec',
744
+ ...commandArgs
745
+ ], {
746
+ encoding: 'utf8',
747
+ cwd: getWindowsSubprocessCwd()
748
+ });
749
+
750
+ if (result.error) {
751
+ throw result.error;
752
+ }
753
+
754
+ if (result.status !== 0) {
755
+ const stderr = String(result.stderr || '').trim();
756
+ throw new Error(`WSL command failed: ${stderr || commandArgs.join(' ')}`);
757
+ }
758
+
759
+ return String(result.stdout || '').trim();
760
+ }
761
+
762
+ function runWslCommand(runtime, commandArgs, cwd) {
763
+ const result = spawnSync('wsl.exe', [
764
+ '--distribution',
765
+ runtime.distroName,
766
+ '--cd',
767
+ cwd,
768
+ '--exec',
769
+ ...commandArgs
770
+ ], {
771
+ stdio: 'inherit',
772
+ cwd: getWindowsSubprocessCwd()
773
+ });
774
+
775
+ if (result.error) {
776
+ throw result.error;
777
+ }
778
+
779
+ if (result.status !== 0) {
780
+ throw new Error(`WSL command failed: ${commandArgs.join(' ')}`);
781
+ }
782
+ }
783
+
784
+ function normalizeWslArchitecture(value) {
785
+ const normalized = String(value || '').trim().toLowerCase();
786
+ if (normalized === 'x86_64' || normalized === 'amd64') {
787
+ return 'x64';
788
+ }
789
+
790
+ if (normalized === 'aarch64' || normalized === 'arm64') {
791
+ return 'arm64';
792
+ }
793
+
794
+ return normalized || process.arch;
795
+ }
796
+
797
+ function getWindowsSubprocessCwd() {
798
+ if (process.platform !== 'win32') {
799
+ return undefined;
800
+ }
801
+
802
+ const cwd = process.cwd();
803
+ if (/^\\\\/.test(cwd)) {
804
+ return process.env.SystemRoot || 'C:\\Windows';
805
+ }
806
+
807
+ return undefined;
808
+ }
809
+
450
810
  function compareVersions(leftTag, rightTag) {
451
811
  const left = parseVersion(leftTag);
452
812
  const right = parseVersion(rightTag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlbx-ai/midterm",
3
- "version": "8.2.1",
3
+ "version": "8.2.4",
4
4
  "description": "Launch MidTerm via npx by downloading the native binary for your platform",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {