@tlbx-ai/midterm 0.1.0 → 8.2.3

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 +4 -1
  2. package/bin/midterm.js +415 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Launch MidTerm through `npx`.
6
6
  npx @tlbx-ai/midterm
7
7
  ```
8
8
 
9
- The launcher downloads the native MidTerm release for your platform, caches it in your user profile, and runs it locally.
9
+ The launcher downloads the native MidTerm release for your platform, caches it in your user profile, runs it locally, and opens MidTerm in your default browser.
10
10
 
11
11
  Supported platforms:
12
12
 
@@ -24,10 +24,13 @@ npx @tlbx-ai/midterm -- --port 2001 --bind 127.0.0.1
24
24
  Launcher-only options:
25
25
 
26
26
  - `--channel stable|dev`
27
+ - `--no-browser`
27
28
  - `--help-launcher`
28
29
 
29
30
  Notes:
30
31
 
31
32
  - Default channel is `stable`
32
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`
33
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
@@ -4,11 +4,15 @@
4
4
 
5
5
  const fs = require('node:fs');
6
6
  const fsp = require('node:fs/promises');
7
+ const https = require('node:https');
7
8
  const os = require('node:os');
8
9
  const path = require('node:path');
9
10
  const { spawn, spawnSync } = require('node:child_process');
10
11
 
11
- const PACKAGE_VERSION = '0.1.0';
12
+ const { version: PACKAGE_VERSION } = require('../package.json');
13
+ const DEFAULT_PORT = 2000;
14
+ const SERVER_READY_TIMEOUT_MS = 15000;
15
+ const SERVER_READY_INTERVAL_MS = 500;
12
16
  const REPO_OWNER = 'tlbx-ai';
13
17
  const REPO_NAME = 'MidTerm';
14
18
  const GITHUB_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}`;
@@ -21,33 +25,41 @@ async function main() {
21
25
  return;
22
26
  }
23
27
 
24
- const target = getPlatformTarget();
28
+ const runtime = await detectRuntime();
29
+ const target = await getPlatformTarget(runtime);
25
30
  const release = await resolveRelease(launcher.channel);
26
- const installDir = await ensureInstalledRelease(release, target);
27
- const mtPath = path.join(installDir, target.binaryName);
28
- const mthostPath = path.join(installDir, target.hostBinaryName);
29
-
30
- if (!fs.existsSync(mtPath) || !fs.existsSync(mthostPath)) {
31
- throw new Error(`Downloaded release is incomplete: expected ${target.binaryName} and ${target.hostBinaryName}`);
32
- }
31
+ const install = runtime.kind === 'wsl-interop'
32
+ ? await ensureInstalledReleaseInWsl(release, target, runtime)
33
+ : await ensureInstalledRelease(release, target);
33
34
 
34
35
  const childArgs = passthrough.slice();
35
- if (!hasArg(childArgs, '--bind')) {
36
+ const explicitBind = getArgValue(childArgs, '--bind');
37
+ const explicitPort = parsePortArg(getArgValue(childArgs, '--port'));
38
+
39
+ if (!explicitBind) {
36
40
  childArgs.push('--bind', '127.0.0.1');
37
41
  }
38
42
 
43
+ const browserUrl = buildBrowserUrl(explicitBind ?? '127.0.0.1', explicitPort ?? DEFAULT_PORT);
39
44
  const childEnv = {
40
45
  ...process.env,
41
46
  MIDTERM_LAUNCH_MODE: 'npx',
42
47
  MIDTERM_NPX: '1',
43
48
  MIDTERM_NPX_CHANNEL: launcher.channel,
44
- MIDTERM_NPX_PACKAGE_VERSION: PACKAGE_VERSION
49
+ MIDTERM_NPX_PACKAGE_VERSION: PACKAGE_VERSION,
50
+ MIDTERM_NPX_RUNTIME: runtime.kind
45
51
  };
46
52
 
47
- const child = spawn(mtPath, childArgs, {
48
- stdio: 'inherit',
49
- env: childEnv
50
- });
53
+ const child = runtime.kind === 'wsl-interop'
54
+ ? spawnMidTermInWsl(runtime, install.mtPath, childArgs, childEnv)
55
+ : spawn(install.mtPath, childArgs, {
56
+ stdio: 'inherit',
57
+ env: childEnv
58
+ });
59
+
60
+ if (launcher.openBrowser) {
61
+ void openBrowserWhenReady(browserUrl);
62
+ }
51
63
 
52
64
  forwardSignal(child, 'SIGINT');
53
65
  forwardSignal(child, 'SIGTERM');
@@ -66,7 +78,8 @@ async function main() {
66
78
  function parseArgs(args) {
67
79
  const launcher = {
68
80
  help: false,
69
- channel: 'stable'
81
+ channel: 'stable',
82
+ openBrowser: true
70
83
  };
71
84
  const passthrough = [];
72
85
 
@@ -83,6 +96,11 @@ function parseArgs(args) {
83
96
  continue;
84
97
  }
85
98
 
99
+ if (arg === '--no-browser') {
100
+ launcher.openBrowser = false;
101
+ continue;
102
+ }
103
+
86
104
  if (arg === '--channel') {
87
105
  const value = args[i + 1];
88
106
  if (value !== 'stable' && value !== 'dev') {
@@ -106,12 +124,85 @@ function printHelp() {
106
124
  console.log('');
107
125
  console.log('Launcher options:');
108
126
  console.log(' --channel stable|dev Choose the release channel (default: stable)');
127
+ console.log(' --no-browser Do not auto-open MidTerm in the default browser');
109
128
  console.log(' --help-launcher Show launcher help');
110
129
  console.log('');
111
130
  console.log('All other arguments are passed to mt.');
112
131
  }
113
132
 
114
- function getPlatformTarget() {
133
+ async function detectRuntime() {
134
+ const wslContext = await detectWslInteropContext();
135
+ if (wslContext) {
136
+ return wslContext;
137
+ }
138
+
139
+ return {
140
+ kind: 'native',
141
+ platform: process.platform,
142
+ arch: process.arch
143
+ };
144
+ }
145
+
146
+ async function detectWslInteropContext() {
147
+ if (process.platform !== 'win32') {
148
+ return null;
149
+ }
150
+
151
+ const parsed = parseWslUncPath(process.cwd());
152
+ if (!parsed) {
153
+ return null;
154
+ }
155
+
156
+ const linuxHome = getWslCommandOutput(parsed.distroName, ['pwd'], '~');
157
+ if (!linuxHome.startsWith('/')) {
158
+ throw new Error(`Failed to determine WSL home directory for ${parsed.distroName}`);
159
+ }
160
+
161
+ const archRaw = getWslCommandOutput(parsed.distroName, ['uname', '-m'], '/');
162
+
163
+ return {
164
+ kind: 'wsl-interop',
165
+ distroName: parsed.distroName,
166
+ linuxCwd: parsed.linuxPath,
167
+ linuxHome,
168
+ uncRoot: parsed.uncRoot,
169
+ arch: normalizeWslArchitecture(archRaw)
170
+ };
171
+ }
172
+
173
+ function parseWslUncPath(value) {
174
+ const normalized = String(value || '');
175
+ const match = normalized.match(/^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(\\.*)?$/i);
176
+ if (!match) {
177
+ return null;
178
+ }
179
+
180
+ const distroName = match[1];
181
+ const suffix = match[2] || '';
182
+ const linuxPath = suffix
183
+ ? suffix.replace(/\\/g, '/')
184
+ : '/';
185
+
186
+ return {
187
+ distroName,
188
+ linuxPath,
189
+ uncRoot: `\\\\wsl.localhost\\${distroName}`
190
+ };
191
+ }
192
+
193
+ async function getPlatformTarget(runtime) {
194
+ if (runtime.kind === 'wsl-interop') {
195
+ if (runtime.arch === 'x64') {
196
+ return {
197
+ assetName: 'mt-linux-x64.tar.gz',
198
+ binaryName: 'mt',
199
+ hostBinaryName: 'mthost'
200
+ };
201
+ }
202
+
203
+ throw new Error(`Unsupported WSL platform: linux ${runtime.arch}`);
204
+ }
205
+
115
206
  if (process.platform === 'win32' && process.arch === 'x64') {
116
207
  return {
117
208
  assetName: 'mt-win-x64.zip',
@@ -189,8 +280,12 @@ async function ensureInstalledRelease(release, target) {
189
280
  throw new Error(`Release ${release.tag} does not contain ${target.assetName}`);
190
281
  }
191
282
 
283
+ const mtPath = path.join(versionDir, target.binaryName);
284
+ const mthostPath = path.join(versionDir, target.hostBinaryName);
285
+
192
286
  if (fs.existsSync(completeMarker)) {
193
- return versionDir;
287
+ ensureInstalledFilesExist(mtPath, mthostPath, target);
288
+ return { mtPath, mthostPath };
194
289
  }
195
290
 
196
291
  await fsp.mkdir(cacheRoot, { recursive: true });
@@ -209,7 +304,8 @@ async function ensureInstalledRelease(release, target) {
209
304
  await fsp.rm(versionDir, { recursive: true, force: true });
210
305
  await fsp.rename(extractDir, versionDir);
211
306
  await fsp.writeFile(completeMarker, `${release.tag}\n`, 'utf8');
212
- return versionDir;
307
+ ensureInstalledFilesExist(mtPath, mthostPath, target);
308
+ return { mtPath, mthostPath };
213
309
  } catch (error) {
214
310
  await fsp.rm(versionDir, { recursive: true, force: true }).catch(() => {});
215
311
  throw error;
@@ -218,6 +314,58 @@ async function ensureInstalledRelease(release, target) {
218
314
  }
219
315
  }
220
316
 
317
+ async function ensureInstalledReleaseInWsl(release, target, runtime) {
318
+ const cacheRootLinux = path.posix.join(runtime.linuxHome, '.cache', 'midterm', 'npx-cache');
319
+ const cacheRootUnc = toWslUncPath(runtime, cacheRootLinux);
320
+ const versionDirLinux = path.posix.join(cacheRootLinux, sanitizeTag(release.tag));
321
+ const versionDirUnc = toWslUncPath(runtime, versionDirLinux);
322
+ const completeMarkerUnc = toWslUncPath(runtime, path.posix.join(versionDirLinux, '.complete'));
323
+ const targetAsset = release.assets.find((asset) => asset.name === target.assetName);
324
+
325
+ if (!targetAsset || !targetAsset.browser_download_url) {
326
+ throw new Error(`Release ${release.tag} does not contain ${target.assetName}`);
327
+ }
328
+
329
+ const mtPath = path.posix.join(versionDirLinux, target.binaryName);
330
+ const mthostPath = path.posix.join(versionDirLinux, target.hostBinaryName);
331
+ const mtPathUnc = toWslUncPath(runtime, mtPath);
332
+ const mthostPathUnc = toWslUncPath(runtime, mthostPath);
333
+
334
+ if (fs.existsSync(completeMarkerUnc)) {
335
+ ensureInstalledFilesExist(mtPathUnc, mthostPathUnc, target);
336
+ return { mtPath, mthostPath };
337
+ }
338
+
339
+ await fsp.mkdir(cacheRootUnc, { recursive: true });
340
+
341
+ const tempName = `staging-${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
342
+ const tempRootLinux = path.posix.join(cacheRootLinux, tempName);
343
+ const tempRootUnc = toWslUncPath(runtime, tempRootLinux);
344
+ const archiveLinux = path.posix.join(tempRootLinux, target.assetName);
345
+ const archiveUnc = toWslUncPath(runtime, archiveLinux);
346
+ const extractLinux = path.posix.join(tempRootLinux, 'extract');
347
+ const extractUnc = toWslUncPath(runtime, extractLinux);
348
+
349
+ try {
350
+ await fsp.mkdir(extractUnc, { recursive: true });
351
+ console.error(`MidTerm ${release.tag}: downloading ${target.assetName}`);
352
+ await downloadFile(targetAsset.browser_download_url, archiveUnc);
353
+ console.error(`MidTerm ${release.tag}: extracting`);
354
+ runWslCommand(runtime, ['tar', '-xzf', archiveLinux, '-C', extractLinux], '/');
355
+ runWslCommand(runtime, ['chmod', '755', path.posix.join(extractLinux, target.binaryName), path.posix.join(extractLinux, target.hostBinaryName)], '/');
356
+ await fsp.rm(versionDirUnc, { recursive: true, force: true });
357
+ await fsp.rename(extractUnc, versionDirUnc);
358
+ await fsp.writeFile(completeMarkerUnc, `${release.tag}\n`, 'utf8');
359
+ ensureInstalledFilesExist(mtPathUnc, mthostPathUnc, target);
360
+ return { mtPath, mthostPath };
361
+ } catch (error) {
362
+ await fsp.rm(versionDirUnc, { recursive: true, force: true }).catch(() => {});
363
+ throw error;
364
+ } finally {
365
+ await fsp.rm(tempRootUnc, { recursive: true, force: true }).catch(() => {});
366
+ }
367
+ }
368
+
221
369
  function getCacheRoot() {
222
370
  if (process.platform === 'win32') {
223
371
  const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
@@ -267,14 +415,20 @@ function extractArchive(archivePath, destinationPath) {
267
415
  '-Command',
268
416
  `Expand-Archive -LiteralPath '${escapePowerShell(archivePath)}' -DestinationPath '${escapePowerShell(destinationPath)}' -Force`
269
417
  ];
270
- const result = spawnSync('powershell', command, { stdio: 'inherit' });
418
+ const result = spawnSync('powershell', command, {
419
+ stdio: 'inherit',
420
+ cwd: getWindowsSubprocessCwd()
421
+ });
271
422
  if (result.status !== 0) {
272
423
  throw new Error(`Failed to extract ${path.basename(archivePath)} with PowerShell`);
273
424
  }
274
425
  return;
275
426
  }
276
427
 
277
- const result = spawnSync('tar', ['-xzf', archivePath, '-C', destinationPath], { stdio: 'inherit' });
428
+ const result = spawnSync('tar', ['-xzf', archivePath, '-C', destinationPath], {
429
+ stdio: 'inherit',
430
+ cwd: getWindowsSubprocessCwd()
431
+ });
278
432
  if (result.status !== 0) {
279
433
  throw new Error(`Failed to extract ${path.basename(archivePath)} with tar`);
280
434
  }
@@ -295,6 +449,159 @@ function hasArg(args, name) {
295
449
  return args.some((arg) => arg === name || arg.startsWith(`${name}=`));
296
450
  }
297
451
 
452
+ function getArgValue(args, name) {
453
+ for (let i = 0; i < args.length; i++) {
454
+ const arg = args[i];
455
+ if (arg === name) {
456
+ return args[i + 1];
457
+ }
458
+ if (arg.startsWith(`${name}=`)) {
459
+ return arg.slice(name.length + 1);
460
+ }
461
+ }
462
+
463
+ return undefined;
464
+ }
465
+
466
+ function parsePortArg(value) {
467
+ if (!value) {
468
+ return undefined;
469
+ }
470
+
471
+ const parsed = Number.parseInt(value, 10);
472
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
473
+ return undefined;
474
+ }
475
+
476
+ return parsed;
477
+ }
478
+
479
+ function buildBrowserUrl(bindAddress, port) {
480
+ const normalized = normalizeHostForBrowser(bindAddress);
481
+ return `https://${normalized}:${port}`;
482
+ }
483
+
484
+ function normalizeHostForBrowser(bindAddress) {
485
+ const raw = String(bindAddress || '').trim();
486
+ if (!raw || raw === '0.0.0.0' || raw === '::' || raw === '[::]') {
487
+ return '127.0.0.1';
488
+ }
489
+
490
+ const host = raw.replace(/^\[(.*)\]$/, '$1');
491
+ if (host.includes(':')) {
492
+ return `[${host}]`;
493
+ }
494
+
495
+ return host;
496
+ }
497
+
498
+ async function openBrowserWhenReady(url) {
499
+ const ready = await waitForServer(url, SERVER_READY_TIMEOUT_MS);
500
+ if (!ready) {
501
+ console.error(`@tlbx-ai/midterm: server did not become ready within ${SERVER_READY_TIMEOUT_MS}ms, opening browser anyway`);
502
+ }
503
+
504
+ openUrl(url);
505
+ }
506
+
507
+ async function waitForServer(url, timeoutMs) {
508
+ const deadline = Date.now() + timeoutMs;
509
+
510
+ while (Date.now() < deadline) {
511
+ if (await probeUrl(url)) {
512
+ return true;
513
+ }
514
+
515
+ await sleep(SERVER_READY_INTERVAL_MS);
516
+ }
517
+
518
+ return false;
519
+ }
520
+
521
+ function probeUrl(url) {
522
+ return new Promise((resolve) => {
523
+ const request = https.request(url, {
524
+ method: 'GET',
525
+ rejectUnauthorized: false,
526
+ timeout: SERVER_READY_INTERVAL_MS
527
+ }, (response) => {
528
+ response.resume();
529
+ resolve(true);
530
+ });
531
+
532
+ request.on('error', () => resolve(false));
533
+ request.on('timeout', () => {
534
+ request.destroy();
535
+ resolve(false);
536
+ });
537
+ request.end();
538
+ });
539
+ }
540
+
541
+ function sleep(ms) {
542
+ return new Promise((resolve) => {
543
+ setTimeout(resolve, ms);
544
+ });
545
+ }
546
+
547
+ function openUrl(url) {
548
+ let command;
549
+ let args;
550
+
551
+ if (process.platform === 'win32') {
552
+ command = 'cmd';
553
+ args = ['/c', 'start', '', url];
554
+ } else if (process.platform === 'darwin') {
555
+ command = 'open';
556
+ args = [url];
557
+ } else {
558
+ command = 'xdg-open';
559
+ args = [url];
560
+ }
561
+
562
+ const result = spawn(command, args, {
563
+ detached: true,
564
+ stdio: 'ignore',
565
+ windowsHide: true,
566
+ cwd: getWindowsSubprocessCwd()
567
+ });
568
+ result.on('error', (error) => {
569
+ console.error(`@tlbx-ai/midterm: failed to open browser automatically: ${error.message}`);
570
+ });
571
+ result.unref();
572
+ }
573
+
574
+ function spawnMidTermInWsl(runtime, mtPath, childArgs, childEnv) {
575
+ const envArgs = ['env'];
576
+ const passthroughEnv = [
577
+ 'MIDTERM_LAUNCH_MODE',
578
+ 'MIDTERM_NPX',
579
+ 'MIDTERM_NPX_CHANNEL',
580
+ 'MIDTERM_NPX_PACKAGE_VERSION',
581
+ 'MIDTERM_NPX_RUNTIME'
582
+ ];
583
+
584
+ for (const key of passthroughEnv) {
585
+ if (childEnv[key]) {
586
+ envArgs.push(`${key}=${childEnv[key]}`);
587
+ }
588
+ }
589
+
590
+ envArgs.push(mtPath, ...childArgs);
591
+
592
+ return spawn('wsl.exe', [
593
+ '--distribution',
594
+ runtime.distroName,
595
+ '--cd',
596
+ runtime.linuxCwd,
597
+ '--exec',
598
+ ...envArgs
599
+ ], {
600
+ stdio: 'inherit',
601
+ cwd: getWindowsSubprocessCwd()
602
+ });
603
+ }
604
+
298
605
  function forwardSignal(child, signal) {
299
606
  process.on(signal, () => {
300
607
  if (!child.killed) {
@@ -307,6 +614,93 @@ function escapePowerShell(value) {
307
614
  return value.replace(/'/g, "''");
308
615
  }
309
616
 
617
+ function ensureInstalledFilesExist(mtPath, mthostPath, target) {
618
+ if (!fs.existsSync(mtPath) || !fs.existsSync(mthostPath)) {
619
+ throw new Error(`Downloaded release is incomplete: expected ${target.binaryName} and ${target.hostBinaryName}`);
620
+ }
621
+ }
622
+
623
+ function toWslUncPath(runtime, linuxPath) {
624
+ const normalized = String(linuxPath || '/');
625
+ const suffix = normalized === '/'
626
+ ? ''
627
+ : normalized.replace(/\//g, '\\');
628
+ return `${runtime.uncRoot}${suffix}`;
629
+ }
630
+
631
+ function getWslCommandOutput(distroName, commandArgs, cwd) {
632
+ const result = spawnSync('wsl.exe', [
633
+ '--distribution',
634
+ distroName,
635
+ '--cd',
636
+ cwd,
637
+ '--exec',
638
+ ...commandArgs
639
+ ], {
640
+ encoding: 'utf8',
641
+ cwd: getWindowsSubprocessCwd()
642
+ });
643
+
644
+ if (result.error) {
645
+ throw result.error;
646
+ }
647
+
648
+ if (result.status !== 0) {
649
+ const stderr = String(result.stderr || '').trim();
650
+ throw new Error(`WSL command failed: ${stderr || commandArgs.join(' ')}`);
651
+ }
652
+
653
+ return String(result.stdout || '').trim();
654
+ }
655
+
656
+ function runWslCommand(runtime, commandArgs, cwd) {
657
+ const result = spawnSync('wsl.exe', [
658
+ '--distribution',
659
+ runtime.distroName,
660
+ '--cd',
661
+ cwd,
662
+ '--exec',
663
+ ...commandArgs
664
+ ], {
665
+ stdio: 'inherit',
666
+ cwd: getWindowsSubprocessCwd()
667
+ });
668
+
669
+ if (result.error) {
670
+ throw result.error;
671
+ }
672
+
673
+ if (result.status !== 0) {
674
+ throw new Error(`WSL command failed: ${commandArgs.join(' ')}`);
675
+ }
676
+ }
677
+
678
+ function normalizeWslArchitecture(value) {
679
+ const normalized = String(value || '').trim().toLowerCase();
680
+ if (normalized === 'x86_64' || normalized === 'amd64') {
681
+ return 'x64';
682
+ }
683
+
684
+ if (normalized === 'aarch64' || normalized === 'arm64') {
685
+ return 'arm64';
686
+ }
687
+
688
+ return normalized || process.arch;
689
+ }
690
+
691
+ function getWindowsSubprocessCwd() {
692
+ if (process.platform !== 'win32') {
693
+ return undefined;
694
+ }
695
+
696
+ const cwd = process.cwd();
697
+ if (/^\\\\/.test(cwd)) {
698
+ return process.env.SystemRoot || 'C:\\Windows';
699
+ }
700
+
701
+ return undefined;
702
+ }
703
+
310
704
  function compareVersions(leftTag, rightTag) {
311
705
  const left = parseVersion(leftTag);
312
706
  const right = parseVersion(rightTag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlbx-ai/midterm",
3
- "version": "0.1.0",
3
+ "version": "8.2.3",
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": {