diff --git a/docs/cli.mdx b/docs/cli.mdx
index ddc49d371..81dd024fb 100644
--- a/docs/cli.mdx
+++ b/docs/cli.mdx
@@ -39,6 +39,7 @@ description: "Build, deploy, and manage Server-Driven UI projects with the Stac
| `status` | Show authentication status | No |
| `init` | Initialize Stac in project | Yes |
| `build` | Convert Dart widgets to JSON | No |
+| `dev` | Run local widget development | No |
| `deploy` | Build and deploy to Stac Cloud | Yes |
| `project list` | List all cloud projects | Yes |
| `project create` | Create new cloud project | Yes |
@@ -128,7 +129,59 @@ stac build --verbose
| `--validate` | Validate generated JSON | `true` |
| `-v, --verbose` | Show detailed build output | `false` |
-The build command converts Stac widget definitions from the `stac/` folder into JSON format in the `build/` folder.
+The build command converts Stac widget definitions from the `stac/` folder into JSON format in `stac/.build/`.
+
+### Develop Locally
+
+Use `stac dev` to test `Stac(routeName: ...)` screens in a running app without deploying to Stac Cloud. It builds your local Stac DSL, serves the generated `stac/.build` JSON through cloud-compatible `/screens` and `/themes` endpoints, and rebuilds when files are saved.
+
+```bash
+stac dev
+```
+
+Serve a specific project:
+
+```bash
+stac dev --project /path/to/project
+```
+
+Use a custom host and port:
+
+```bash
+stac dev --host 0.0.0.0 --port 45700
+```
+
+Skip the initial build and serve existing build files:
+
+```bash
+stac dev --skip-build
+```
+
+Then point your debug app at the local server:
+
+```dart
+import 'package:flutter/foundation.dart';
+
+await Stac.initialize(
+ options: defaultStacOptions.copyWith(
+ apiBaseUrl: kDebugMode
+ ? 'http://127.0.0.1:45700'
+ : defaultStacOptions.apiBaseUrl,
+ ),
+);
+```
+
+`stac dev` prints ready-to-copy URLs for local/iOS Simulator/web, Android Emulator, and physical devices. For a physical device, start with `stac dev --host 0.0.0.0` and use the printed LAN URL as `StacOptions.apiBaseUrl`.
+
+#### Dev Options
+
+| Option | Description | Default |
+| --------------- | ---------------------------------------- | ----------------- |
+| `-p, --project` | Project directory path | Current directory |
+| `--host` | Host address for the local server | `127.0.0.1` |
+| `--port` | Port for the local server | `45700` |
+| `--skip-build` | Serve existing files without first build | `false` |
+| `--watch` | Rebuild local JSON when Stac files save | `true` |
### Deploy to Stac Cloud
@@ -237,4 +290,4 @@ stac deploy
| --------------- | ------------------------------ |
| `-v, --verbose` | Show additional command output |
| `--version` | Print tool version |
-| `--help` | Print usage information |
\ No newline at end of file
+| `--help` | Print usage information |
diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index 8e5c74967..4b6735f71 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -133,6 +133,30 @@ StacWidget helloWorld() {
Stac follows Flutter's conventions for building widgets. For example, to use Flutter's `Scaffold`, use the `StacScaffold` widget.
+## Test Locally
+
+During development, you can test Stac screens in your running app without deploying to Stac Cloud:
+
+```bash
+stac dev
+```
+
+Then point debug builds at the local server:
+
+```dart
+import 'package:flutter/foundation.dart';
+
+await Stac.initialize(
+ options: defaultStacOptions.copyWith(
+ apiBaseUrl: kDebugMode
+ ? 'http://127.0.0.1:45700'
+ : defaultStacOptions.apiBaseUrl,
+ ),
+);
+```
+
+`stac dev` rebuilds local JSON when files in `stac/` are saved and prints ready-to-copy URLs for local/iOS Simulator/web, Android Emulator, and physical devices. For a physical device, start with `stac dev --host 0.0.0.0` and use the printed LAN URL.
+
## Deploy Stac widgets
Now that we have our widget built, we can deploy it to Stac Cloud.
@@ -239,4 +263,4 @@ You're all set. Next, explore widgets and actions, or jump into the CLI guide:
- [Actions](/actions/navigate)
- [CLI](/cli)
-Need help? Join the community on [Discord](https://discord.com/invite/vTGsVRK86V) or open an issue on [GitHub](https://github.com/StacDev/stac/issues).
\ No newline at end of file
+Need help? Join the community on [Discord](https://discord.com/invite/vTGsVRK86V) or open an issue on [GitHub](https://github.com/StacDev/stac/issues).
diff --git a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist
index 1dc6cf765..391a902b2 100644
--- a/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist
+++ b/examples/movie_app/ios/Flutter/AppFrameworkInfo.plist
@@ -20,7 +20,5 @@
????
CFBundleVersion
1.0
- MinimumOSVersion
- 13.0
diff --git a/examples/movie_app/ios/Runner/AppDelegate.swift b/examples/movie_app/ios/Runner/AppDelegate.swift
index 626664468..c30b367ec 100644
--- a/examples/movie_app/ios/Runner/AppDelegate.swift
+++ b/examples/movie_app/ios/Runner/AppDelegate.swift
@@ -2,12 +2,15 @@ import Flutter
import UIKit
@main
-@objc class AppDelegate: FlutterAppDelegate {
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
- GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
}
diff --git a/examples/movie_app/ios/Runner/Info.plist b/examples/movie_app/ios/Runner/Info.plist
index a257724c8..d3235fe44 100644
--- a/examples/movie_app/ios/Runner/Info.plist
+++ b/examples/movie_app/ios/Runner/Info.plist
@@ -2,6 +2,8 @@
+ CADisableMinimumFrameDurationOnPhone
+
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
@@ -24,6 +26,29 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ FlutterSceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
@@ -41,9 +66,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- CADisableMinimumFrameDurationOnPhone
-
- UIApplicationSupportsIndirectInputEvents
-
diff --git a/examples/movie_app/lib/main.dart b/examples/movie_app/lib/main.dart
index df21e93a0..3f2d240fe 100644
--- a/examples/movie_app/lib/main.dart
+++ b/examples/movie_app/lib/main.dart
@@ -19,7 +19,7 @@ void main() async {
);
await Stac.initialize(
- options: defaultStacOptions,
+ options: defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700'),
dio: dio,
parsers: [MovieCarouselParser()],
);
diff --git a/examples/movie_app/stac/app_theme.dart b/examples/movie_app/stac/app_theme.dart
index 9889386c6..388abb393 100644
--- a/examples/movie_app/stac/app_theme.dart
+++ b/examples/movie_app/stac/app_theme.dart
@@ -5,7 +5,7 @@ StacTheme get darkTheme => _buildTheme(
brightness: StacBrightness.dark,
colorScheme: StacColorScheme(
brightness: StacBrightness.dark,
- primary: '#95E183',
+ primary: '#212121',
onPrimary: '#050608',
secondary: '#95E183',
onSecondary: '#FFFFFF',
diff --git a/packages/stac/lib/src/services/stac_cloud.dart b/packages/stac/lib/src/services/stac_cloud.dart
index 283516a2a..03b0bbf30 100644
--- a/packages/stac/lib/src/services/stac_cloud.dart
+++ b/packages/stac/lib/src/services/stac_cloud.dart
@@ -20,7 +20,15 @@ class StacCloud {
),
);
- static const String _baseUrl = 'https://api.stac.dev';
+ static const String _defaultBaseUrl = 'https://api.stac.dev';
+
+ static String get _baseUrl {
+ final configuredBaseUrl = StacService.options?.apiBaseUrl.trim();
+ if (configuredBaseUrl == null || configuredBaseUrl.isEmpty) {
+ return _defaultBaseUrl;
+ }
+ return configuredBaseUrl.replaceFirst(RegExp(r'/+$'), '');
+ }
/// Gets the fetch URL for a given artifact type.
static String _getFetchUrl(StacArtifactType artifactType) {
diff --git a/packages/stac_cli/README.md b/packages/stac_cli/README.md
index f0ef2a5d1..c36ea392c 100644
--- a/packages/stac_cli/README.md
+++ b/packages/stac_cli/README.md
@@ -23,9 +23,15 @@ stac --version
stac login
stac init
stac build
+stac dev
stac deploy
```
+Use `stac dev` during local development to build and serve Stac screens from
+`stac/.build` without deploying to Stac Cloud. Point debug builds at the local
+server with `defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700')`.
+The command also prints local/iOS, Android Emulator, and physical-device URLs.
+
## Environment
The CLI reads credentials from:
@@ -41,4 +47,3 @@ Required keys:
- `STAC_FIREBASE_API_KEY`
Set environment in code via `currentEnvironment` in `lib/src/config/env.dart`.
-
diff --git a/packages/stac_cli/bin/stac_cli.dart b/packages/stac_cli/bin/stac_cli.dart
index aabbf704c..df9df2374 100644
--- a/packages/stac_cli/bin/stac_cli.dart
+++ b/packages/stac_cli/bin/stac_cli.dart
@@ -7,6 +7,7 @@ import 'package:stac_cli/src/commands/auth/logout_command.dart';
import 'package:stac_cli/src/commands/auth/status_command.dart';
import 'package:stac_cli/src/commands/build_command.dart';
import 'package:stac_cli/src/commands/deploy_command.dart';
+import 'package:stac_cli/src/commands/dev_command.dart';
import 'package:stac_cli/src/commands/init_command.dart';
import 'package:stac_cli/src/commands/project_command.dart';
import 'package:stac_cli/src/commands/upgrade_command.dart';
@@ -66,6 +67,7 @@ void main(List arguments) async {
..addCommand(InitCommand())
..addCommand(ProjectCommand())
..addCommand(BuildCommand())
+ ..addCommand(DevCommand())
..addCommand(DeployCommand())
..addCommand(UpgradeCommand());
diff --git a/packages/stac_cli/lib/src/commands/deploy_command.dart b/packages/stac_cli/lib/src/commands/deploy_command.dart
index 5d294f164..75870dde9 100644
--- a/packages/stac_cli/lib/src/commands/deploy_command.dart
+++ b/packages/stac_cli/lib/src/commands/deploy_command.dart
@@ -6,7 +6,7 @@ import 'base_command.dart';
/// Command for deploying JSON files to the cloud
class DeployCommand extends BaseCommand {
final BuildService _buildService = BuildService();
- final DeployService _deployService = DeployService();
+ late final DeployService _deployService = DeployService();
@override
String get name => 'deploy';
diff --git a/packages/stac_cli/lib/src/commands/dev_command.dart b/packages/stac_cli/lib/src/commands/dev_command.dart
new file mode 100644
index 000000000..b1b5d5c2c
--- /dev/null
+++ b/packages/stac_cli/lib/src/commands/dev_command.dart
@@ -0,0 +1,76 @@
+import 'package:stac_cli/src/services/dev_service.dart';
+import 'package:stac_cli/src/utils/console_logger.dart';
+
+import 'base_command.dart';
+
+class DevCommand extends BaseCommand {
+ DevCommand({DevService? devService})
+ : _devService = devService ?? DevService() {
+ argParser.addOption(
+ 'project',
+ abbr: 'p',
+ help: 'Project directory (defaults to current directory)',
+ );
+ argParser.addOption(
+ 'host',
+ help: 'Host address for the local development server',
+ defaultsTo: '127.0.0.1',
+ );
+ argParser.addOption(
+ 'port',
+ help: 'Port for the local development server',
+ defaultsTo: '45700',
+ );
+ argParser.addFlag(
+ 'skip-build',
+ help: 'Use existing build files without running the initial build',
+ negatable: false,
+ );
+ argParser.addFlag(
+ 'watch',
+ help: 'Watch Stac source files and rebuild on save',
+ defaultsTo: true,
+ );
+ }
+
+ final DevService _devService;
+
+ @override
+ String get name => 'dev';
+
+ @override
+ String get description =>
+ 'Run a local Stac development server for screens and themes';
+
+ @override
+ bool get requiresProject => true;
+
+ @override
+ Future execute() async {
+ final projectPath = argResults?['project'] as String?;
+ final host = argResults?['host'] as String? ?? '127.0.0.1';
+ final portValue = argResults?['port'] as String? ?? '45700';
+ final port = int.tryParse(portValue);
+ final skipBuild = argResults?['skip-build'] as bool? ?? false;
+ final watch = argResults?['watch'] as bool? ?? true;
+
+ if (port == null || port < 0 || port > 65535) {
+ ConsoleLogger.error('Invalid port: $portValue');
+ return 1;
+ }
+
+ try {
+ await _devService.serve(
+ projectPath: projectPath,
+ host: host,
+ port: port,
+ skipBuild: skipBuild,
+ watch: watch,
+ );
+ return 0;
+ } catch (e) {
+ ConsoleLogger.error('Dev failed: $e');
+ return 1;
+ }
+ }
+}
diff --git a/packages/stac_cli/lib/src/commands/init_command.dart b/packages/stac_cli/lib/src/commands/init_command.dart
index 3b40a19e6..9a0f86098 100644
--- a/packages/stac_cli/lib/src/commands/init_command.dart
+++ b/packages/stac_cli/lib/src/commands/init_command.dart
@@ -11,7 +11,7 @@ import 'base_command.dart';
/// Command for initializing a Stac project from cloud projects
class InitCommand extends BaseCommand {
- final ProjectService _projectService = ProjectService();
+ late final ProjectService _projectService = ProjectService();
@override
String get name => 'init';
diff --git a/packages/stac_cli/lib/src/commands/project/create_command.dart b/packages/stac_cli/lib/src/commands/project/create_command.dart
index 62724ee91..9ca8e6140 100644
--- a/packages/stac_cli/lib/src/commands/project/create_command.dart
+++ b/packages/stac_cli/lib/src/commands/project/create_command.dart
@@ -4,7 +4,7 @@ import '../../utils/console_logger.dart';
/// Command for creating a new project on the cloud
class CreateCommand extends BaseCommand {
- final ProjectService _projectService = ProjectService();
+ late final ProjectService _projectService = ProjectService();
@override
String get name => 'create';
diff --git a/packages/stac_cli/lib/src/commands/project/list_command.dart b/packages/stac_cli/lib/src/commands/project/list_command.dart
index c2372cdea..fe97a448e 100644
--- a/packages/stac_cli/lib/src/commands/project/list_command.dart
+++ b/packages/stac_cli/lib/src/commands/project/list_command.dart
@@ -6,7 +6,7 @@ import '../base_command.dart';
/// Command for listing all cloud projects
class ListCommand extends BaseCommand {
- final ProjectService _projectService = ProjectService();
+ late final ProjectService _projectService = ProjectService();
@override
String get name => 'list';
diff --git a/packages/stac_cli/lib/src/services/build_service.dart b/packages/stac_cli/lib/src/services/build_service.dart
index c9bd20b66..92ac1a156 100644
--- a/packages/stac_cli/lib/src/services/build_service.dart
+++ b/packages/stac_cli/lib/src/services/build_service.dart
@@ -15,11 +15,10 @@ class BuildService {
/// Build the project from Dart to JSON using analyzer + isolate execution
Future build({String? projectPath}) async {
// Determine project root (directory containing pubspec.yaml)
- final projectDir =
- projectPath ?? _findProjectRoot() ?? Directory.current.path;
+ final projectDir = resolveProjectDir(projectPath: projectPath);
// Load build configuration from lib/default_stac_options.dart (with defaults)
- final options = await _loadBuildConfigFromOptions(projectDir);
+ final options = await loadBuildConfigFromOptions(projectDir);
ConsoleLogger.info('Building Stac project...');
ConsoleLogger.debug('Project directory: $projectDir');
@@ -163,8 +162,14 @@ class BuildService {
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File && entity.path.endsWith('.dart')) {
- // Skip hidden directories and build directories
- if (!entity.path.contains('/.') && !entity.path.contains('.build')) {
+ final relativePath = path.relative(entity.path, from: sourceDir);
+ final pathSegments = path.split(relativePath);
+ final isHiddenOrBuildFile = pathSegments.any(
+ (segment) => segment.startsWith('.') || segment == 'build',
+ );
+
+ // Skip hidden/build directories inside the Stac source directory.
+ if (!isHiddenOrBuildFile) {
dartFiles.add(entity.path);
}
}
@@ -174,7 +179,11 @@ class BuildService {
}
/// Load build configuration from lib/default_stac_options.dart with sensible defaults
- Future _loadBuildConfigFromOptions(String projectDir) async {
+ String resolveProjectDir({String? projectPath}) {
+ return projectPath ?? _findProjectRoot() ?? Directory.current.path;
+ }
+
+ Future loadBuildConfigFromOptions(String projectDir) async {
final optionsPath = path.join(
projectDir,
'lib',
diff --git a/packages/stac_cli/lib/src/services/dev_service.dart b/packages/stac_cli/lib/src/services/dev_service.dart
new file mode 100644
index 000000000..b2443b6b4
--- /dev/null
+++ b/packages/stac_cli/lib/src/services/dev_service.dart
@@ -0,0 +1,360 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+import 'package:stac_cli/src/services/build_service.dart';
+import 'package:stac_cli/src/utils/console_logger.dart';
+
+class DevService {
+ DevService({BuildService? buildService})
+ : _buildService = buildService ?? BuildService();
+
+ final BuildService _buildService;
+
+ Future serve({
+ String? projectPath,
+ String host = '127.0.0.1',
+ int port = 45700,
+ bool skipBuild = false,
+ bool watch = true,
+ }) async {
+ final projectDir = _buildService.resolveProjectDir(
+ projectPath: projectPath,
+ );
+ final options = await _buildService.loadBuildConfigFromOptions(projectDir);
+ final outputDir = path.join(projectDir, options.outputDir);
+ final sourceDir = path.join(projectDir, options.sourceDir);
+
+ var buildRunning = false;
+ var buildQueued = false;
+
+ Future runBuild(String reason) async {
+ if (buildRunning) {
+ buildQueued = true;
+ return;
+ }
+
+ buildRunning = true;
+ do {
+ buildQueued = false;
+ try {
+ ConsoleLogger.info('Building Stac project ($reason)...');
+ await _buildService.build(projectPath: projectDir);
+ ConsoleLogger.success('Local Stac build updated.');
+ } catch (e) {
+ ConsoleLogger.error('Local Stac build failed: $e');
+ }
+ } while (buildQueued);
+ buildRunning = false;
+ }
+
+ if (!skipBuild) {
+ await runBuild('initial');
+ }
+
+ final server = await HttpServer.bind(host, port);
+ final serverUrl = _localBaseUrl(server.address.host, server.port);
+ ConsoleLogger.success('Stac local server running at $serverUrl');
+ ConsoleLogger.info('Screens: $serverUrl/screens?screenName=');
+ ConsoleLogger.info('Themes: $serverUrl/themes?themeName=');
+ await _printDeviceUrls(
+ requestedHost: host,
+ boundHost: server.address.host,
+ port: server.port,
+ );
+
+ StreamSubscription? watcher;
+ Timer? rebuildDebounce;
+
+ void scheduleBuild(FileSystemEvent event) {
+ if (!event.path.endsWith('.dart')) {
+ return;
+ }
+ rebuildDebounce?.cancel();
+ rebuildDebounce = Timer(const Duration(milliseconds: 350), () {
+ unawaited(runBuild(path.relative(event.path, from: projectDir)));
+ });
+ }
+
+ if (watch) {
+ final directory = Directory(sourceDir);
+ if (directory.existsSync()) {
+ try {
+ watcher = directory.watch(recursive: true).listen(scheduleBuild);
+ ConsoleLogger.info(
+ 'Watching ${path.relative(sourceDir, from: projectDir)} for changes.',
+ );
+ } catch (e) {
+ ConsoleLogger.warning('File watching is not available: $e');
+ }
+ } else {
+ ConsoleLogger.warning('Source directory not found: $sourceDir');
+ }
+ }
+
+ final done = Completer();
+ final signalSubscriptions = >[];
+
+ Future shutdown() async {
+ if (done.isCompleted) {
+ return;
+ }
+ rebuildDebounce?.cancel();
+ await watcher?.cancel();
+ for (final subscription in signalSubscriptions) {
+ await subscription.cancel();
+ }
+ await server.close(force: true);
+ ConsoleLogger.info('Stopped Stac local server.');
+ done.complete();
+ }
+
+ if (!Platform.isWindows) {
+ signalSubscriptions.add(
+ ProcessSignal.sigint.watch().listen((_) => unawaited(shutdown())),
+ );
+ signalSubscriptions.add(
+ ProcessSignal.sigterm.watch().listen((_) => unawaited(shutdown())),
+ );
+ }
+
+ server.listen(
+ (request) =>
+ unawaited(_handleRequest(request: request, outputDir: outputDir)),
+ onError: (Object error, StackTrace stackTrace) {
+ ConsoleLogger.error('Server error: $error');
+ },
+ );
+
+ await done.future;
+ }
+
+ Future _handleRequest({
+ required HttpRequest request,
+ required String outputDir,
+ }) async {
+ final response = request.response;
+ _setCorsHeaders(response);
+
+ if (request.method == 'OPTIONS') {
+ response.statusCode = HttpStatus.noContent;
+ await response.close();
+ return;
+ }
+
+ if (request.method != 'GET') {
+ await _writeJson(response, HttpStatus.methodNotAllowed, {
+ 'error': 'Only GET is supported by stac dev.',
+ });
+ return;
+ }
+
+ switch (request.uri.path) {
+ case '/':
+ case '/health':
+ await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
+ return;
+ case '/screens':
+ await _serveArtifact(
+ response: response,
+ outputDir: outputDir,
+ artifactDirName: 'screens',
+ artifactName: request.uri.queryParameters['screenName'],
+ missingNameMessage: 'Missing screenName query parameter.',
+ );
+ return;
+ case '/themes':
+ await _serveArtifact(
+ response: response,
+ outputDir: outputDir,
+ artifactDirName: 'themes',
+ artifactName: request.uri.queryParameters['themeName'],
+ missingNameMessage: 'Missing themeName query parameter.',
+ );
+ return;
+ default:
+ await _writeJson(response, HttpStatus.notFound, {
+ 'error': 'Unknown stac dev endpoint: ${request.uri.path}',
+ });
+ }
+ }
+
+ Future _serveArtifact({
+ required HttpResponse response,
+ required String outputDir,
+ required String artifactDirName,
+ required String? artifactName,
+ required String missingNameMessage,
+ }) async {
+ if (artifactName == null || artifactName.trim().isEmpty) {
+ await _writeJson(response, HttpStatus.badRequest, {
+ 'error': missingNameMessage,
+ });
+ return;
+ }
+
+ final artifactFile = _resolveArtifactFile(
+ outputDir: outputDir,
+ artifactDirName: artifactDirName,
+ artifactName: artifactName,
+ );
+
+ if (artifactFile == null) {
+ await _writeJson(response, HttpStatus.badRequest, {
+ 'error': 'Artifact names cannot be absolute or contain "..".',
+ });
+ return;
+ }
+
+ if (!await artifactFile.exists()) {
+ await _writeJson(response, HttpStatus.notFound, {
+ 'error': 'Artifact not found: $artifactName',
+ });
+ return;
+ }
+
+ final stacJson = await artifactFile.readAsString();
+ final stat = await artifactFile.stat();
+ await _writeJson(response, HttpStatus.ok, {
+ 'name': artifactName,
+ 'stacJson': stacJson,
+ 'version': stat.modified.millisecondsSinceEpoch,
+ });
+ }
+
+ File? _resolveArtifactFile({
+ required String outputDir,
+ required String artifactDirName,
+ required String artifactName,
+ }) {
+ final fileName = '$artifactName.json';
+ if (path.isAbsolute(fileName) || path.split(fileName).contains('..')) {
+ return null;
+ }
+ return File(path.join(outputDir, artifactDirName, fileName));
+ }
+
+ Future _writeJson(
+ HttpResponse response,
+ int statusCode,
+ Map payload,
+ ) async {
+ response.statusCode = statusCode;
+ response.headers.contentType = ContentType.json;
+ response.write(jsonEncode(payload));
+ await response.close();
+ }
+
+ void _setCorsHeaders(HttpResponse response) {
+ response.headers
+ ..set(HttpHeaders.accessControlAllowOriginHeader, '*')
+ ..set(HttpHeaders.accessControlAllowMethodsHeader, 'GET, OPTIONS')
+ ..set(HttpHeaders.accessControlAllowHeadersHeader, 'content-type');
+ }
+
+ Future _printDeviceUrls({
+ required String requestedHost,
+ required String boundHost,
+ required int port,
+ }) async {
+ final localUrl = _localBaseUrl(boundHost, port);
+ final androidEmulatorUrl = _baseUrl('10.0.2.2', port);
+ final lanUrls = await _lanBaseUrls(port);
+ final acceptsPhysicalDevices =
+ _acceptsRemoteConnections(requestedHost) ||
+ _acceptsRemoteConnections(boundHost);
+
+ ConsoleLogger.info('Device URLs:');
+ ConsoleLogger.info(' Local / iOS Simulator / Web: $localUrl');
+ ConsoleLogger.info(' Android Emulator: $androidEmulatorUrl');
+
+ if (lanUrls.isEmpty) {
+ ConsoleLogger.warning(' Physical device: no LAN IPv4 address found.');
+ _printApiBaseUrlHint();
+ return;
+ }
+
+ if (!acceptsPhysicalDevices) {
+ ConsoleLogger.warning(
+ ' Physical device: run "stac dev --host 0.0.0.0", then use ${lanUrls.first}',
+ );
+ _printApiBaseUrlHint();
+ return;
+ }
+
+ ConsoleLogger.info(' Physical device: ${lanUrls.first}');
+ for (final url in lanUrls.skip(1)) {
+ ConsoleLogger.info(' $url');
+ }
+ _printApiBaseUrlHint();
+ }
+
+ void _printApiBaseUrlHint() {
+ ConsoleLogger.info(
+ 'Use the matching URL as StacOptions.apiBaseUrl in debug builds.',
+ );
+ }
+
+ Future> _lanBaseUrls(int port) async {
+ try {
+ final interfaces = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ includeLoopback: false,
+ includeLinkLocal: false,
+ );
+ final urls = {};
+ for (final interface in interfaces) {
+ for (final address in interface.addresses) {
+ if (_isAnyHost(address.address) || _isLoopbackHost(address.address)) {
+ continue;
+ }
+ urls.add(_baseUrl(address.address, port));
+ }
+ }
+ return urls.toList()..sort();
+ } catch (e) {
+ ConsoleLogger.debug('Could not discover LAN URLs: $e');
+ return const [];
+ }
+ }
+
+ String _localBaseUrl(String host, int port) {
+ if (_isAnyHost(host)) {
+ return _baseUrl('127.0.0.1', port);
+ }
+ return _baseUrl(host, port);
+ }
+
+ String _baseUrl(String host, int port) {
+ final normalizedHost = _normalizeHost(host);
+ if (normalizedHost.contains(':')) {
+ return 'http://[$normalizedHost]:$port';
+ }
+ return 'http://$normalizedHost:$port';
+ }
+
+ bool _acceptsRemoteConnections(String host) {
+ return _isAnyHost(host) || !_isLoopbackHost(host);
+ }
+
+ bool _isAnyHost(String host) {
+ final normalizedHost = _normalizeHost(host);
+ return normalizedHost == '0.0.0.0' || normalizedHost == '::';
+ }
+
+ bool _isLoopbackHost(String host) {
+ final normalizedHost = _normalizeHost(host);
+ return normalizedHost == 'localhost' ||
+ normalizedHost == '::1' ||
+ normalizedHost.startsWith('127.');
+ }
+
+ String _normalizeHost(String host) {
+ final trimmedHost = host.trim().toLowerCase();
+ if (trimmedHost.startsWith('[') && trimmedHost.endsWith(']')) {
+ return trimmedHost.substring(1, trimmedHost.length - 1);
+ }
+ return trimmedHost;
+ }
+}
diff --git a/packages/stac_core/lib/core/stac_options.dart b/packages/stac_core/lib/core/stac_options.dart
index cfb37bb2f..33f2f12b3 100644
--- a/packages/stac_core/lib/core/stac_options.dart
+++ b/packages/stac_core/lib/core/stac_options.dart
@@ -8,6 +8,8 @@
/// const options = StacOptions(
/// name: 'MyProject',
/// projectId: 'my_project_id',
+/// // Override the runtime API endpoint for local debugging:
+/// // apiBaseUrl: 'http://127.0.0.1:45700',
/// // apiKey: '...optional...',
/// // Override paths if needed (absolute or relative to your project root):
/// // sourceDir: '/stac/',
@@ -20,6 +22,7 @@ class StacOptions {
required this.name,
this.description,
required this.projectId,
+ this.apiBaseUrl = 'https://api.stac.dev',
this.sourceDir = '/stac/',
this.outputDir = '/stac/.build',
});
@@ -33,6 +36,12 @@ class StacOptions {
/// Unique identifier for the project, used by tooling and integrations.
final String projectId;
+ /// Base URL used by the runtime when fetching screens and themes.
+ ///
+ /// Defaults to Stac Cloud. Point this at `stac dev` in debug builds to
+ /// preview local screens without deploying them.
+ final String apiBaseUrl;
+
/// Directory path where Stac source files are located.
///
/// Can be absolute or relative to your project root.
@@ -42,4 +51,23 @@ class StacOptions {
///
/// Can be absolute or relative to your project root.
final String outputDir;
+
+ /// Creates a new [StacOptions] with selected fields replaced.
+ StacOptions copyWith({
+ String? name,
+ String? description,
+ String? projectId,
+ String? apiBaseUrl,
+ String? sourceDir,
+ String? outputDir,
+ }) {
+ return StacOptions(
+ name: name ?? this.name,
+ description: description ?? this.description,
+ projectId: projectId ?? this.projectId,
+ apiBaseUrl: apiBaseUrl ?? this.apiBaseUrl,
+ sourceDir: sourceDir ?? this.sourceDir,
+ outputDir: outputDir ?? this.outputDir,
+ );
+ }
}