jac-client 0.2.12__py3-none-any.whl → 0.2.14__py3-none-any.whl

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 (51) hide show
  1. jac_client/examples/all-in-one/components/Header.jac +1 -1
  2. jac_client/examples/all-in-one/components/ProfitOverview.jac +1 -1
  3. jac_client/examples/all-in-one/components/Summary.jac +1 -1
  4. jac_client/examples/all-in-one/components/TransactionList.jac +2 -2
  5. jac_client/examples/all-in-one/components/navigation.jac +3 -9
  6. jac_client/examples/all-in-one/context/BudgetContext.jac +1 -1
  7. jac_client/examples/all-in-one/main.jac +5 -386
  8. jac_client/examples/all-in-one/pages/(auth)/index.jac +299 -0
  9. jac_client/examples/all-in-one/pages/{nestedDemo.jac → (auth)/nested.jac} +3 -13
  10. jac_client/examples/all-in-one/pages/{loginPage.jac → (public)/login.jac} +1 -1
  11. jac_client/examples/all-in-one/pages/{signupPage.jac → (public)/signup.jac} +1 -1
  12. jac_client/examples/all-in-one/pages/{notFound.jac → [...notFound].jac} +2 -1
  13. jac_client/examples/all-in-one/pages/budget.jac +11 -0
  14. jac_client/examples/all-in-one/pages/budget_planner_ui.cl.jac +1 -1
  15. jac_client/examples/all-in-one/pages/features.jac +8 -0
  16. jac_client/examples/all-in-one/pages/features_test_ui.cl.jac +7 -7
  17. jac_client/examples/all-in-one/pages/{LandingPage.jac → landing.jac} +4 -9
  18. jac_client/examples/all-in-one/pages/layout.jac +20 -0
  19. jac_client/examples/nested-folders/nested-advance/src/ButtonRoot.jac +1 -1
  20. jac_client/examples/nested-folders/nested-advance/src/level1/ButtonSecondL.jac +1 -1
  21. jac_client/examples/nested-folders/nested-advance/src/level1/level2/ButtonThirdL.jac +1 -1
  22. jac_client/plugin/cli.jac +3 -3
  23. jac_client/plugin/client_runtime.cl.jac +7 -4
  24. jac_client/plugin/impl/client_runtime.impl.jac +29 -7
  25. jac_client/plugin/plugin_config.jac +4 -11
  26. jac_client/plugin/src/compiler.jac +19 -1
  27. jac_client/plugin/src/config_loader.jac +1 -0
  28. jac_client/plugin/src/impl/compiler.impl.jac +232 -62
  29. jac_client/plugin/src/impl/config_loader.impl.jac +8 -0
  30. jac_client/plugin/src/impl/package_installer.impl.jac +3 -2
  31. jac_client/plugin/src/impl/route_scanner.impl.jac +201 -0
  32. jac_client/plugin/src/impl/vite_bundler.impl.jac +54 -15
  33. jac_client/plugin/src/route_scanner.jac +44 -0
  34. jac_client/plugin/src/targets/desktop/sidecar/main.py +42 -23
  35. jac_client/plugin/src/targets/desktop_target.jac +4 -2
  36. jac_client/plugin/src/targets/impl/desktop_target.impl.jac +324 -112
  37. jac_client/plugin/src/vite_bundler.jac +18 -3
  38. jac_client/plugin/utils/impl/bun_installer.impl.jac +16 -19
  39. jac_client/plugin/utils/impl/client_deps.impl.jac +12 -16
  40. jac_client/templates/fullstack.jacpack +3 -2
  41. jac_client/tests/test_cli.py +74 -0
  42. jac_client/tests/test_desktop_api_url.py +854 -0
  43. jac_client/tests/test_e2e.py +31 -40
  44. jac_client/tests/test_it.py +209 -11
  45. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/METADATA +2 -2
  46. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/RECORD +49 -44
  47. jac_client/examples/all-in-one/pages/BudgetPlanner.jac +0 -140
  48. jac_client/examples/all-in-one/pages/FeaturesTest.jac +0 -157
  49. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/WHEEL +0 -0
  50. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/entry_points.txt +0 -0
  51. {jac_client-0.2.12.dist-info → jac_client-0.2.14.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,201 @@
1
+ import re;
2
+ import from pathlib { Path }
3
+ import from operator { attrgetter }
4
+ import from jaclang.runtimelib.client_bundle { ClientBundleError }
5
+
6
+ impl RouteEntry.init(
7
+ self: RouteEntry,
8
+ path: str,
9
+ component_import: str,
10
+ file_path: Path,
11
+ auth_required: bool = False,
12
+ is_layout: bool = False,
13
+ is_catch_all: bool = False,
14
+ children: (list[RouteEntry] | None) = None
15
+ ) {
16
+ self.path = path;
17
+ self.component_import = component_import;
18
+ self.file_path = file_path;
19
+ self.auth_required = auth_required;
20
+ self.is_layout = is_layout;
21
+ self.is_catch_all = is_catch_all;
22
+ self.children = children if children is not None else [];
23
+ }
24
+
25
+ impl RouteScanner.init(self: RouteScanner, project_root: Path) {
26
+ self.project_root = project_root;
27
+ self.pages_dir = project_root / self.PAGES_DIR_NAME;
28
+ self._routes: list[RouteEntry] = [];
29
+ self._layouts: dict[(str, RouteEntry)] = {};
30
+ self._page_files: list[Path] = [];
31
+ }
32
+
33
+ impl RouteScanner.has_pages_dir(self: RouteScanner) -> bool {
34
+ return self.pages_dir.exists() and self.pages_dir.is_dir();
35
+ }
36
+
37
+ impl RouteScanner.scan(self: RouteScanner) -> list[RouteEntry] {
38
+ if not self.has_pages_dir() {
39
+ return [];
40
+ }
41
+ self._routes = [];
42
+ self._layouts = {};
43
+ self._page_files = [];
44
+ self._routes = self._scan_directory(self.pages_dir);
45
+ self._detect_collisions(self._routes);
46
+ return self._routes;
47
+ }
48
+
49
+ """Return layouts discovered during the last scan, keyed by URL prefix."""
50
+ impl RouteScanner.get_layouts(self: RouteScanner) -> dict[(str, RouteEntry)] {
51
+ return self._layouts;
52
+ }
53
+
54
+ impl RouteScanner.get_page_files(self: RouteScanner) -> list[Path] {
55
+ return self._page_files;
56
+ }
57
+
58
+ impl RouteScanner._scan_directory(
59
+ self: RouteScanner,
60
+ dir_path: Path,
61
+ url_prefix: str = "",
62
+ auth_required: bool = False
63
+ ) -> list[RouteEntry] {
64
+ routes: list[RouteEntry] = [];
65
+ # Collect and sort entries for deterministic route ordering
66
+ entries = sorted(dir_path.iterdir(), key=attrgetter('name'));
67
+ for entry in entries {
68
+ if entry.is_dir() {
69
+ dir_name = entry.name;
70
+ # Route groups: (public), (auth), etc.
71
+ group_match = re.match(r'^\((\w+)\)$', dir_name);
72
+ if group_match {
73
+ group_name = group_match.group(1);
74
+ group_auth = auth_required or (group_name == self.AUTH_GROUP_NAME);
75
+ # Route groups don't add to the URL path
76
+ child_routes = self._scan_directory(entry, url_prefix, group_auth);
77
+ routes.extend(child_routes);
78
+ } else {
79
+ # Regular subdirectory — adds to URL path
80
+ child_prefix = f"{url_prefix}/{dir_name}";
81
+ child_routes = self._scan_directory(entry, child_prefix, auth_required);
82
+ routes.extend(child_routes);
83
+ }
84
+ } elif entry.is_file() and entry.suffix == '.jac' {
85
+ if any(suffix in entry.name for suffix in self.SKIP_SUFFIXES) {
86
+ continue;
87
+ }
88
+ stem = entry.stem;
89
+ # layout.jac — not a route, it's a layout wrapper
90
+ if stem == self.LAYOUT_FILENAME {
91
+ layout_key = url_prefix or "/";
92
+ if layout_key in self._layouts {
93
+ raise ClientBundleError(
94
+ f"Layout collision: '{layout_key}' has layouts from both "
95
+ f"'{self._layouts[layout_key].file_path}' and '{entry}'. "
96
+ f"Remove one of them."
97
+ ) ;
98
+ }
99
+ component_name = self._file_to_component_name(entry);
100
+ layout_entry = RouteEntry(
101
+ path=layout_key,
102
+ component_import=component_name,
103
+ file_path=entry,
104
+ is_layout=True
105
+ );
106
+ self._layouts[layout_key] = layout_entry;
107
+ self._page_files.append(entry);
108
+ continue;
109
+ }
110
+ route_segment = self._file_to_route_path(stem);
111
+ is_catch_all = stem.startswith('[...');
112
+ if stem == self.INDEX_FILENAME {
113
+ # index.jac maps to the directory path
114
+ route_path = url_prefix or "/";
115
+ } else {
116
+ route_path = f"{url_prefix}/{route_segment}";
117
+ }
118
+ component_name = self._file_to_component_name(entry);
119
+ route = RouteEntry(
120
+ path=route_path,
121
+ component_import=component_name,
122
+ file_path=entry,
123
+ auth_required=auth_required,
124
+ is_catch_all=is_catch_all
125
+ );
126
+ routes.append(route);
127
+ self._page_files.append(entry);
128
+ }
129
+ }
130
+ return routes;
131
+ }
132
+
133
+ """Convert a filename stem to a React Router route path segment.
134
+
135
+ index → (empty, handled by caller)
136
+ about → about
137
+ [id] → :id
138
+ [...slug] → *
139
+ """
140
+ impl RouteScanner._file_to_route_path(self: RouteScanner, filename: str) -> str {
141
+ # Catch-all: [...slug] → *
142
+ catch_all_match = re.match(r'^\[\.\.\.(\w+)\]$', filename);
143
+ if catch_all_match {
144
+ return "*";
145
+ }
146
+ # Dynamic segment: [id] → :id
147
+ dynamic_match = re.match(r'^\[(\w+)\]$', filename);
148
+ if dynamic_match {
149
+ return f":{dynamic_match.group(1)}";
150
+ }
151
+ # Static segment
152
+ return filename;
153
+ }
154
+
155
+ """Generate a unique component import name from a page file path.
156
+
157
+ pages/index.jac → PagesIndex
158
+ pages/about.jac → PagesAbout
159
+ pages/(auth)/dashboard.jac → PagesDashboard
160
+ pages/users/[id].jac → PagesUsersId
161
+ pages/[...slug].jac → PagesSlug
162
+ """
163
+ impl RouteScanner._file_to_component_name(self: RouteScanner, file_path: Path) -> str {
164
+ try {
165
+ relative = file_path.relative_to(self.pages_dir);
166
+ } except ValueError {
167
+ relative = Path(file_path.name);
168
+ }
169
+ parts: list[str] = [];
170
+ for part in relative.with_suffix('').parts {
171
+ # Skip route group directories like (auth), (public)
172
+ if re.match(r'^\(\w+\)$', part) {
173
+ continue;
174
+ }
175
+ # Strip catch-all prefix: [...slug] → slug
176
+ clean = re.sub(r'^\[\.\.\.(\w+)\]$', r'\1', part);
177
+ # Strip dynamic brackets: [id] → id
178
+ clean = re.sub(r'^\[(\w+)\]$', r'\1', clean);
179
+ parts.append(clean[0].upper() + clean[1:] if len(clean) > 0 else clean);
180
+ }
181
+ return self.COMPONENT_PREFIX + "".join(parts);
182
+ }
183
+
184
+ impl RouteScanner._detect_collisions(
185
+ self: RouteScanner, routes: list[RouteEntry]
186
+ ) -> None {
187
+ seen: dict[(str, Path)] = {};
188
+ for route in routes {
189
+ if route.is_layout {
190
+ continue;
191
+ }
192
+ if route.path in seen {
193
+ raise ClientBundleError(
194
+ f"Route collision: '{route.path}' is defined by both "
195
+ f"'{seen[route.path]}' and '{route.file_path}'. "
196
+ f"Remove one of them."
197
+ ) ;
198
+ }
199
+ seen[route.path] = route.file_path;
200
+ }
201
+ }
@@ -1,11 +1,17 @@
1
+ import from jaclang.cli.console { console }
2
+
1
3
  """Get the client build directory from project config."""
2
4
  impl ViteBundler._get_client_dir(self: ViteBundler) -> Path {
3
- # Try to get from project config
5
+ # Try to get from project config, but only if it matches our project dir
4
6
  try {
5
7
  import from jaclang.project.config { get_config }
6
8
  config = get_config();
7
9
  if config is not None {
8
- return config.get_client_dir();
10
+ config_root = config.project_root;
11
+ if config_root is not None
12
+ and config_root.resolve() == self.project_dir.resolve() {
13
+ return config.get_client_dir();
14
+ }
9
15
  }
10
16
  } except ImportError { }
11
17
  # Fallback to default
@@ -216,6 +222,19 @@ impl ViteBundler._format_plugin_options(self: ViteBundler, options: dict) -> str
216
222
  return '{ ' + ', '.join(items) + ' }';
217
223
  }
218
224
 
225
+ """Resolve the API base URL using a consistent priority chain.
226
+
227
+ Priority: jac.toml base_url > direct override > env var (desktop start) > "" (same-origin)
228
+ """
229
+ impl ViteBundler._resolve_api_base_url(
230
+ self: ViteBundler, api_base_url_override: str = ""
231
+ ) -> str {
232
+ api_config_data = self.config_loader.get_api_config();
233
+ toml_base_url = api_config_data.get('base_url', '');
234
+ env_override = os.environ.get(API_BASE_URL_ENV_VAR, '');
235
+ return toml_base_url or api_base_url_override or env_override;
236
+ }
237
+
219
238
  """Get a valid JavaScript variable name from plugin module name."""
220
239
  impl ViteBundler._get_plugin_var_name(self: ViteBundler, plugin_name: str) -> str {
221
240
  name = plugin_name.split('/')[-1];
@@ -224,13 +243,20 @@ impl ViteBundler._get_plugin_var_name(self: ViteBundler, plugin_name: str) -> st
224
243
  return name;
225
244
  }
226
245
 
227
- """Create vite.config.js from config.json during bundling."""
228
- impl ViteBundler.create_vite_config(self: ViteBundler, entry_file: Path) -> Path {
246
+ """Create vite.config.js from config.json during bundling.
247
+
248
+ api_base_url_override: overrides jac.toml base_url for desktop targets
249
+ where the CLI knows the backend port at startup time.
250
+ """
251
+ impl ViteBundler.create_vite_config(
252
+ self: ViteBundler, entry_file: Path, api_base_url_override: str = ""
253
+ ) -> Path {
229
254
  build_dir = self._get_client_dir();
230
255
  build_dir.mkdir(parents=True, exist_ok=True);
231
256
  configs_dir = build_dir / 'configs';
232
257
  configs_dir.mkdir(exist_ok=True);
233
258
  vite_config_data = self.config_loader.get_vite_config();
259
+ api_base_url = self._resolve_api_base_url(api_base_url_override);
234
260
  config_path = configs_dir / 'vite.config.js';
235
261
  # TypeScript is always enabled by default
236
262
  try {
@@ -355,6 +381,9 @@ function jacSourceMapper() {{
355
381
  */
356
382
 
357
383
  export default defineConfig({{
384
+ define: {{
385
+ 'globalThis.__JAC_API_BASE_URL__': '"{api_base_url}"',
386
+ }},
358
387
  plugins: [
359
388
  jacSourceMapper(),{(newline + plugins_str + newline + ' ') if plugins_str else ''}],
360
389
  root: buildDir, // base folder (.jac/client/) so vite can find node_modules
@@ -459,7 +488,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
459
488
  }
460
489
  try {
461
490
  # Install to .jac/client/node_modules with progress feedback
462
- print("\n ⏳ Installing dependencies...\n", flush=True);
491
+ console.print("\n ⏳ Installing dependencies...\n");
463
492
  start_time = time.time();
464
493
  result = subprocess.run(
465
494
  ['bun', 'install'],
@@ -474,7 +503,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
474
503
  error_msg = f"Dependency installation failed after {elapsed:.1f}s\n\n{error_output}\nCommand: bun install";
475
504
  raise ClientBundleError(error_msg) from None ;
476
505
  }
477
- print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)", flush=True);
506
+ console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
478
507
  } except FileNotFoundError {
479
508
  # This shouldn't happen since we check for bun at the start
480
509
  raise ClientBundleError(
@@ -507,7 +536,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
507
536
  command = ['bun', 'run', 'build'];
508
537
  }
509
538
  # Run vite from client build directory so it can find node_modules
510
- print("\n ⏳ Building client bundle...\n", flush=True);
539
+ console.print("\n ⏳ Building client bundle...\n");
511
540
  start_time = time.time();
512
541
  result = subprocess.run(
513
542
  command, cwd=build_dir, check=False, text=True, capture_output=True
@@ -520,7 +549,7 @@ impl ViteBundler.build(self: ViteBundler, entry_file: Optional[Path] = None) ->
520
549
  )}";
521
550
  raise ClientBundleError(error_msg) from None ;
522
551
  }
523
- print(f"\n ✔ Client bundle built ({elapsed:.1f}s)", flush=True);
552
+ console.print(f"\n ✔ Client bundle built ({elapsed:.1f}s)");
524
553
  } finally {
525
554
  # Clean up temporary package.json in client build dir
526
555
  build_package_json = build_dir / 'package.json';
@@ -555,15 +584,23 @@ impl ViteBundler.init(
555
584
  self.output_dir = output_dir or (self._get_client_dir() / 'dist');
556
585
  }
557
586
 
558
- """Create a dev-mode vite config with API proxy for HMR."""
587
+ """Create a dev-mode vite config with API proxy for HMR.
588
+
589
+ api_base_url_override: when set, API calls go directly to this URL
590
+ instead of via proxy (used by desktop targets).
591
+ """
559
592
  impl ViteBundler.create_dev_vite_config(
560
- self: ViteBundler, entry_file: Path, api_port: int = 8000
593
+ self: ViteBundler,
594
+ entry_file: Path,
595
+ api_port: int = 8000,
596
+ api_base_url_override: str = ""
561
597
  ) -> Path {
562
598
  build_dir = self._get_client_dir();
563
599
  build_dir.mkdir(parents=True, exist_ok=True);
564
600
  configs_dir = build_dir / 'configs';
565
601
  configs_dir.mkdir(exist_ok=True);
566
602
  config_path = configs_dir / 'vite.dev.config.js';
603
+ api_base_url = self._resolve_api_base_url(api_base_url_override);
567
604
  # Get entry file relative path
568
605
  try {
569
606
  entry_relative = entry_file.relative_to(build_dir).as_posix();
@@ -599,9 +636,13 @@ const projectRoot = path.resolve(__dirname, "../../..");
599
636
  * Proxies API routes to Python server at localhost:{api_port}
600
637
  */
601
638
  export default defineConfig({{
639
+ define: {{
640
+ 'globalThis.__JAC_API_BASE_URL__': '"{api_base_url}"',
641
+ }},
602
642
  plugins: [react()],
603
643
  root: buildDir,
604
644
  publicDir: false,
645
+ appType: 'spa',
605
646
  build: {{
606
647
  sourcemap: true, // Enable source maps for better error messages
607
648
  }},
@@ -731,19 +772,17 @@ impl ViteBundler.start_dev_server(self: ViteBundler, port: int = 3000) -> Any {
731
772
  shutil.copy2(generated_package_json, build_package_json);
732
773
  }
733
774
  try {
734
- print("\n ⏳ Installing dependencies...\n", flush=True);
775
+ console.print("\n ⏳ Installing dependencies...\n");
735
776
  start_time = time.time();
736
777
  result = subprocess.run(
737
778
  ['bun', 'install'], cwd=build_dir, check=False, text=True
738
779
  );
739
780
  elapsed = time.time() - start_time;
740
781
  if result.returncode != 0 {
741
- print(
742
- f"\n ✖ bun install failed after {elapsed:.1f}s", file=sys.stderr
743
- );
782
+ console.error(f"\n ✖ bun install failed after {elapsed:.1f}s");
744
783
  raise ClientBundleError("Failed to install dependencies") ;
745
784
  }
746
- print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)", flush=True);
785
+ console.print(f"\n ✔ Dependencies installed ({elapsed:.1f}s)");
747
786
  } finally {
748
787
  # Clean up temp package.json
749
788
  if build_package_json.exists() {
@@ -0,0 +1,44 @@
1
+ """File-based route scanner for pages/ directory convention."""
2
+ import from pathlib { Path }
3
+
4
+ """A single route entry derived from a file in pages/."""
5
+ class RouteEntry {
6
+ def init(
7
+ self: RouteEntry,
8
+ path: str,
9
+ component_import: str,
10
+ file_path: Path,
11
+ auth_required: bool = False,
12
+ is_layout: bool = False,
13
+ is_catch_all: bool = False,
14
+ children: (list[RouteEntry] | None) = None
15
+ );
16
+ }
17
+
18
+ """Scans a pages/ directory and produces a route tree."""
19
+ class RouteScanner {
20
+ with entry {
21
+ PAGES_DIR_NAME = 'pages';
22
+ LAYOUT_FILENAME = 'layout';
23
+ INDEX_FILENAME = 'index';
24
+ AUTH_GROUP_NAME = 'auth';
25
+ COMPONENT_PREFIX = 'Pages';
26
+ SKIP_SUFFIXES = ['.cl.', '.impl.', '.test.'];
27
+ }
28
+
29
+ def init(self: RouteScanner, project_root: Path);
30
+ def has_pages_dir(self: RouteScanner) -> bool;
31
+ def scan(self: RouteScanner) -> list[RouteEntry];
32
+ def get_layouts(self: RouteScanner) -> dict[(str, RouteEntry)];
33
+ def get_page_files(self: RouteScanner) -> list[Path];
34
+ def _scan_directory(
35
+ self: RouteScanner,
36
+ dir_path: Path,
37
+ url_prefix: str = "",
38
+ auth_required: bool = False
39
+ ) -> list[RouteEntry];
40
+
41
+ def _file_to_route_path(self: RouteScanner, filename: str) -> str;
42
+ def _file_to_component_name(self: RouteScanner, file_path: Path) -> str;
43
+ def _detect_collisions(self: RouteScanner, routes: list[RouteEntry]) -> None;
44
+ }
@@ -11,7 +11,7 @@ Usage:
11
11
 
12
12
  Options:
13
13
  --module-path PATH Path to the .jac module file (default: main.jac)
14
- --port PORT Port to bind the API server (default: 8000)
14
+ --port PORT Port to bind the API server (default: 8000, 0 = auto)
15
15
  --base-path PATH Base path for the project (default: current directory)
16
16
  --host HOST Host to bind to (default: 127.0.0.1)
17
17
  --help Show this help message
@@ -20,10 +20,16 @@ Options:
20
20
  from __future__ import annotations
21
21
 
22
22
  import argparse
23
+ import socket
23
24
  import sys
24
25
  from pathlib import Path
25
26
 
26
- from jaclang.cli.console import console
27
+
28
+ def _find_free_port(host: str = "127.0.0.1") -> int:
29
+ """Find and return a free port on the given host."""
30
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
31
+ s.bind((host, 0))
32
+ return s.getsockname()[1]
27
33
 
28
34
 
29
35
  def main():
@@ -41,7 +47,7 @@ def main():
41
47
  "--port",
42
48
  type=int,
43
49
  default=8000,
44
- help="Port to bind the API server (default: 8000)",
50
+ help="Port to bind the API server (default: 8000, 0 = auto-assign free port)",
45
51
  )
46
52
  parser.add_argument(
47
53
  "--base-path",
@@ -58,6 +64,10 @@ def main():
58
64
 
59
65
  args = parser.parse_args()
60
66
 
67
+ port = args.port
68
+ if port == 0:
69
+ port = _find_free_port(args.host)
70
+
61
71
  # Determine base path
62
72
  if args.base_path:
63
73
  base_path = Path(args.base_path).resolve()
@@ -75,8 +85,9 @@ def main():
75
85
  module_path = base_path / module_path
76
86
 
77
87
  if not module_path.exists():
78
- console.print(f"Error: Module file not found: {module_path}", file=sys.stderr)
79
- console.print(f" Base path: {base_path}", file=sys.stderr)
88
+ # Console not yet available (jaclang not imported)
89
+ sys.stderr.write(f"Error: Module file not found: {module_path}\n")
90
+ sys.stderr.write(f" Base path: {base_path}\n")
80
91
  sys.exit(1)
81
92
 
82
93
  # Extract module name (without .jac extension)
@@ -88,25 +99,25 @@ def main():
88
99
  # Import jaclang (must be installed via pip)
89
100
  from jaclang.pycore.runtime import JacRuntime as Jac
90
101
  except ImportError as e:
91
- console.print(f"Error: Failed to import Jac runtime: {e}", file=sys.stderr)
92
- console.print(
93
- " Make sure jaclang is installed: pip install jaclang", file=sys.stderr
94
- )
102
+ # Console not available (jaclang import failed)
103
+ sys.stderr.write(f"Error: Failed to import Jac runtime: {e}\n")
104
+ sys.stderr.write(" Make sure jaclang is installed: pip install jaclang\n")
95
105
  sys.exit(1)
96
106
 
107
+ # Get the console now that jaclang is available
108
+ from jaclang.cli.console import console
109
+
97
110
  # Initialize Jac runtime
98
111
  try:
99
112
  # Import the module
100
113
  Jac.jac_import(target=module_name, base_path=str(module_base), lng="jac")
101
114
  if Jac.program.errors_had:
102
- console.print("Error: Failed to compile module:", file=sys.stderr)
115
+ console.error("Failed to compile module:")
103
116
  for error in Jac.program.errors_had:
104
- console.print(f" {error}", file=sys.stderr)
117
+ console.print(f" {error}", style="error")
105
118
  sys.exit(1)
106
119
  except Exception as e:
107
- console.print(
108
- f"Error: Failed to load module '{module_name}': {e}", file=sys.stderr
109
- )
120
+ console.error(f"Failed to load module '{module_name}': {e}")
110
121
  import traceback
111
122
 
112
123
  traceback.print_exc()
@@ -117,23 +128,31 @@ def main():
117
128
  # Get server class (allows plugins like jac-scale to provide enhanced server)
118
129
  server_class = Jac.get_api_server_class()
119
130
  server = server_class(
120
- module_name=module_name, port=args.port, base_path=str(base_path)
131
+ module_name=module_name, port=port, base_path=str(base_path)
121
132
  )
122
133
 
123
- console.print("Jac Sidecar starting...")
124
- console.print(f" Module: {module_name}")
125
- console.print(f" Base path: {base_path}")
126
- console.print(f" Server: http://{args.host}:{args.port}")
127
- console.print("\nPress Ctrl+C to stop the server\n")
134
+ # MUST be raw stdout — Tauri host reads this line to discover the port.
135
+ # Cannot use console here; Tauri parses this exact format.
136
+ sys.stdout.write(f"JAC_SIDECAR_PORT={port}\n")
137
+ sys.stdout.flush()
138
+
139
+ # stderr: Tauri drops the stdout pipe after reading the port marker,
140
+ # so any further stdout writes raise BrokenPipeError.
141
+ console.print("Jac Sidecar starting...", style="bold")
142
+ console.print(f" Module: {module_name}", style="muted")
143
+ console.print(f" Base path: {base_path}", style="muted")
144
+ console.print(f" Server: http://{args.host}:{port}", style="muted")
145
+ console.print("")
128
146
 
129
147
  # Start the server (blocks until interrupted)
130
- server.start(dev=False)
148
+ # no_client=True: client bundle is already embedded in the Tauri webview
149
+ server.start(dev=False, no_client=True)
131
150
 
132
151
  except KeyboardInterrupt:
133
- console.print("\nShutting down sidecar...")
152
+ console.print("\nShutting down sidecar...", style="muted")
134
153
  sys.exit(0)
135
154
  except Exception as e:
136
- console.print(f"Error: Server failed to start: {e}", file=sys.stderr)
155
+ console.error(f"Server failed to start: {e}")
137
156
  import traceback
138
157
 
139
158
  traceback.print_exc()
@@ -28,10 +28,12 @@ class DesktopTarget(ClientTarget) {
28
28
  ) -> Path;
29
29
 
30
30
  """Start desktop dev server - start web dev server and launch tauri dev."""
31
- override def dev(self: DesktopTarget, entry_file: Path, project_dir: Path) -> None;
31
+ override def dev(
32
+ self: DesktopTarget, entry_file: Path, project_dir: Path, api_port: int = 8000
33
+ ) -> None;
32
34
 
33
35
  """Start desktop app - build web bundle and launch Tauri with built bundle."""
34
36
  override def start(
35
- self: DesktopTarget, entry_file: Path, project_dir: Path
37
+ self: DesktopTarget, entry_file: Path, project_dir: Path, api_port: int = 8000
36
38
  ) -> None;
37
39
  }