diff --git a/cli/beamable.templates/templates/PortalExtensionReactApp/package.json b/cli/beamable.templates/templates/PortalExtensionReactApp/package.json
index 8b2bf3d4a2..5bacab3d76 100644
--- a/cli/beamable.templates/templates/PortalExtensionReactApp/package.json
+++ b/cli/beamable.templates/templates/PortalExtensionReactApp/package.json
@@ -6,6 +6,7 @@
"beamable": {
"version": "1.0.0",
"portalExtension": true,
+ "serviceScope": "realm",
"microserviceDependencies": [],
"mounts": [
{
diff --git a/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs b/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs
index 95ae8d9f6c..79445f22ee 100644
--- a/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs
+++ b/cli/cli/Commands/LocalStack/LocalStackUpCommand.cs
@@ -4,6 +4,7 @@
using System.CommandLine;
using System.Diagnostics;
using System.Reflection;
+using System.Text;
using System.Text.RegularExpressions;
using ServiceLogs = Beamable.Common.Constants.Features.Services.Logs;
@@ -281,6 +282,15 @@ public override async Task Handle(LocalStackUpCommandArgs args)
// runs `mvn install -U` and refills ~/.m2, escaping the trap.
EnsureScalaCoreInLocalMavenRepo(config, autoBuild);
+ // Second half of the offline-classpath trap. EnsureScalaCoreInLocalMavenRepo above guarantees `core` is in
+ // ~/.m2, but the per-service launcher's `mvn -o dependency:build-classpath` ALSO needs Maven to resolve the
+ // `dependency` plugin PREFIX offline — which requires the maven-dependency-plugin's plugin-group metadata
+ // (org/apache/maven/plugins/maven-metadata-*.xml) to be cached. On a machine where that was never cached (or
+ // was cached as a miss), the offline resolve dies with "No plugin found for prefix 'dependency'" even with
+ // core present — and `--build` does NOT fix it, because the reactor resolves every plugin by explicit
+ // version and never touches the prefix metadata. Probe it, and prime it online once if it is missing.
+ EnsureMavenDependencyPluginResolvableOffline(config, args.build);
+
// Say WHY a build is being run that was not asked for. "Building the Scala reactor" out of nowhere on a
// plain `up` is confusing unless it names the module that has never been compiled.
foreach (var step in autoBuild)
@@ -1233,6 +1243,264 @@ public static LocalStackStep ShouldForceBuildScalaForMissingCore(LocalStackConfi
return buildScala;
}
+ ///
+ /// Inputs for the offline maven-dependency-plugin probe, resolved from the manifest by
+ /// . Public so the decision (should we probe at all, and against which
+ /// module) can be unit-tested without launching Maven.
+ ///
+ public class MavenDependencyProbePlan
+ {
+ /// True when there is a Scala launch step and enough resolved inputs to run the probe.
+ public bool shouldProbe;
+
+ /// The mvn command the probe runs — the toolchain's pinned path, or bare mvn.
+ public string mvnCommand;
+
+ /// The BeamableBackend working directory the probe runs in.
+ public string scalaDir;
+
+ /// The module the probe targets (e.g. tools/account), mirroring the launcher's -pl.
+ public string probeModule;
+
+ /// When is false, the reason — for tests and tracing.
+ public string skipReason;
+ }
+
+ ///
+ /// Pure decision for : reads the manifest and decides
+ /// whether the offline maven-dependency-plugin probe should run, and against which module. No filesystem check
+ /// (beyond string validity) and no process launch, so it is unit-testable. The probe is only relevant when a
+ /// scala: * service will launch — those are the steps whose launcher runs mvn -o
+ /// dependency:build-classpath. The module is derived from the launch step's name (scala: account →
+ /// tools/account), exactly the -pl tools/$SVC the launcher uses.
+ ///
+ public static MavenDependencyProbePlan PlanDependencyPluginProbe(LocalStackConfig config)
+ {
+ var plan = new MavenDependencyProbePlan();
+
+ if (config?.steps == null)
+ {
+ plan.skipReason = "no manifest steps";
+ return plan;
+ }
+
+ var scalaLaunch = config.steps.FirstOrDefault(s =>
+ s != null && s.enabled && !string.IsNullOrEmpty(s.name)
+ && s.name.StartsWith("scala: ", StringComparison.OrdinalIgnoreCase));
+ if (scalaLaunch == null)
+ {
+ plan.skipReason = "no scala launch step";
+ return plan;
+ }
+
+ var svc = scalaLaunch.name.Substring("scala: ".Length).Trim();
+ if (string.IsNullOrEmpty(svc))
+ {
+ plan.skipReason = "scala launch step has no service name";
+ return plan;
+ }
+ plan.probeModule = "tools/" + svc;
+
+ var scalaDir = config.repos?.scalaDir;
+ if (string.IsNullOrWhiteSpace(scalaDir)
+ || scalaDir.Contains(LocalStackConfigIO.EditPlaceholder, StringComparison.Ordinal))
+ {
+ plan.skipReason = "no BeamableBackend path on the manifest";
+ return plan;
+ }
+ plan.scalaDir = scalaDir;
+
+ var mvn = LocalStackConfigIO.Substitute("${maven}", config);
+ if (string.IsNullOrWhiteSpace(mvn))
+ {
+ plan.skipReason = "no maven command resolved";
+ return plan;
+ }
+ plan.mvnCommand = mvn;
+
+ plan.shouldProbe = true;
+ return plan;
+ }
+
+ ///
+ /// Ensures the per-service launcher's offline mvn -o dependency:build-classpath can resolve the
+ /// dependency plugin prefix. Probes it offline; if that fails, primes the plugin once ONLINE (which
+ /// downloads the plugin-group metadata and clears any cached "not found" miss), re-probes, and — if it still
+ /// cannot be made to work (no network, a blocked Maven repo) — throws with the exact manual fix. See the call
+ /// site in for why --build alone does not cover this.
+ ///
+ private static void EnsureMavenDependencyPluginResolvableOffline(LocalStackConfig config, bool forceBuild)
+ {
+ var plan = PlanDependencyPluginProbe(config);
+ if (!plan.shouldProbe) return;
+
+ // A manifest can name a BeamableBackend path that isn't checked out on this machine — nothing to run mvn in.
+ if (!Directory.Exists(plan.scalaDir)) return;
+
+ // Fast path: a previous run already wrote a non-empty classpath cache, which means the offline resolve
+ // demonstrably works here — skip the maven probe so a healthy `up` pays nothing. `--build` wipes that cache
+ // later in Handle, so always probe on a build: it is exactly the run where priming matters.
+ if (!forceBuild && HasNonEmptyScalaClasspathCache()) return;
+
+ var offline = RunMavenDependencyGoalProbe(config, plan, online: false);
+ if (!offline.started) return; // couldn't even launch mvn — let the normal launch path surface that
+ if (offline.exitCode == 0) return; // resolves offline — healthy, nothing to do
+
+ Log.Information(
+ "[maven] the Scala services resolve their classpath with an offline 'mvn dependency:build-classpath', " +
+ "but Maven can't resolve the 'dependency' plugin prefix offline yet (its plugin metadata isn't cached). " +
+ $"Priming it once online (mvn -U dependency:help in {plan.scalaDir}) so the services can launch — this " +
+ "does not rebuild anything.");
+
+ var online = RunMavenDependencyGoalProbe(config, plan, online: true);
+ if (online.started && online.exitCode == 0)
+ {
+ var reprobe = RunMavenDependencyGoalProbe(config, plan, online: false);
+ if (reprobe.started && reprobe.exitCode == 0)
+ {
+ Log.Information("[maven] maven-dependency-plugin is now resolvable offline — the Scala classpath " +
+ "caches will regenerate on launch.");
+ return;
+ }
+ }
+
+ throw new CliException(BuildDependencyPluginRemediation(plan));
+ }
+
+ /// Outcome of one dependency:help probe: whether mvn started at all, its exit code, and its
+ /// combined output (for the remediation message).
+ private readonly struct MvnProbeOutcome
+ {
+ public MvnProbeOutcome(bool started, int exitCode, string output)
+ {
+ this.started = started;
+ this.exitCode = exitCode;
+ this.output = output;
+ }
+
+ public readonly bool started;
+ public readonly int exitCode;
+ public readonly string output;
+ }
+
+ ///
+ /// Runs dependency:help — the cheapest goal that still forces prefix resolution of
+ /// maven-dependency-plugin, i.e. the exact resolution that fails as "No plugin found for prefix 'dependency'".
+ /// Offline (-o) reproduces the launcher's own resolve; online adds -U to invalidate a cached
+ /// "not found" miss and fetch the plugin-group metadata that makes subsequent offline resolves succeed. Runs
+ /// under the same PATH/JAVA_HOME as the reactor so it hits the same ~/.m2 and JDK.
+ ///
+ private static MvnProbeOutcome RunMavenDependencyGoalProbe(LocalStackConfig config, MavenDependencyProbePlan plan, bool online)
+ {
+ var mvnArgs = new List();
+ if (!online) mvnArgs.Add("-o");
+ mvnArgs.Add("-q");
+ if (online) mvnArgs.Add("-U");
+ mvnArgs.Add("-pl");
+ mvnArgs.Add(plan.probeModule);
+ mvnArgs.Add("dependency:help");
+
+ var psi = new ProcessStartInfo
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ WorkingDirectory = plan.scalaDir,
+ };
+
+ if (OperatingSystem.IsWindows())
+ {
+ // mvn is a .cmd batch file — Process.Start can't run it directly, so go through cmd.exe. The doubled
+ // quotes are cmd's rule for "everything after /s /c is one command that carries its own quotes".
+ psi.FileName = "cmd.exe";
+ psi.Arguments = $"/s /c \"\"{plan.mvnCommand}\" {string.Join(" ", mvnArgs)}\"";
+ }
+ else
+ {
+ psi.FileName = plan.mvnCommand;
+ foreach (var a in mvnArgs) psi.ArgumentList.Add(a);
+ }
+
+ ApplyToolchainEnvironment(psi, config);
+
+ var output = new StringBuilder();
+ try
+ {
+ var proc = Process.Start(psi);
+ if (proc == null) return new MvnProbeOutcome(false, -1, string.Empty);
+
+ proc.OutputDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data != null) lock (output) output.AppendLine(e.Data); };
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+
+ // The offline resolve is fast; the online prime may download plugin-group metadata. Bound both so a
+ // wedged mvn can't hang `up` forever.
+ var timeoutMs = (int)(online ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(2)).TotalMilliseconds;
+ if (!proc.WaitForExit(timeoutMs))
+ {
+ try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ }
+ return new MvnProbeOutcome(false, -1, output.ToString());
+ }
+
+ proc.WaitForExit(); // let the async output readers flush
+ return new MvnProbeOutcome(true, proc.ExitCode, output.ToString());
+ }
+ catch (Exception)
+ {
+ // mvn not found / not runnable — "couldn't probe", not the trap this method detects.
+ return new MvnProbeOutcome(false, -1, output.ToString());
+ }
+ }
+
+ ///
+ /// True when a previous run left a non-empty cp-*.txt in the shared Scala classpath cache
+ /// (<temp>/beam-scala-cp) — proof the offline resolve works on this machine, so the probe can be
+ /// skipped. Mirrors the temp location the launcher scripts write to and that up wipes on --build.
+ ///
+ private static bool HasNonEmptyScalaClasspathCache()
+ {
+ try
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "beam-scala-cp");
+ if (!Directory.Exists(dir)) return false;
+ foreach (var f in Directory.EnumerateFiles(dir, "cp-*.txt"))
+ {
+ try { if (new FileInfo(f).Length > 0) return true; } catch { /* unreadable — ignore */ }
+ }
+ }
+ catch { /* ignore */ }
+ return false;
+ }
+
+ ///
+ /// The fail-fast message when the offline resolve is broken and the automatic online prime could not fix it.
+ /// Names the real cause, says plainly that --build does not fix it, and gives the exact online command
+ /// plus the cached-miss cleanup — the same guidance the per-service launcher's empty-cache guard prints.
+ ///
+ private static string BuildDependencyPluginRemediation(MavenDependencyProbePlan plan)
+ {
+ var m2Plugin = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? "~",
+ ".m2", "repository", "org", "apache", "maven", "plugins", "maven-dependency-plugin");
+ var mvnDisplay = plan.mvnCommand?.Contains(' ') == true ? $"\"{plan.mvnCommand}\"" : plan.mvnCommand;
+
+ return
+ "The Scala services can't start: Maven can't resolve the 'dependency' plugin prefix offline, so each " +
+ "service's classpath step ('mvn -o dependency:build-classpath') fails with \"No plugin found for prefix " +
+ "'dependency'\" — even though com.kickstand:core is present in ~/.m2.\n\n" +
+ "'beam local up --build' does NOT fix this: the reactor resolves plugins by version, never by prefix, so " +
+ "it never caches the plugin-prefix metadata this needs. Beam tried to prime it online automatically and " +
+ "could not (usually no network, or a blocked Maven repo).\n\n" +
+ "Fix it by priming the plugin once, online, then re-run 'beam local up':\n" +
+ $" cd \"{plan.scalaDir}\"\n" +
+ $" {mvnDisplay} -U dependency:build-classpath -pl {plan.probeModule}\n\n" +
+ "If it still fails, delete the cached-miss markers and retry:\n" +
+ $" (Windows) del /s \"{m2Plugin}\\*.lastUpdated\"\n" +
+ $" (macOS/Linux) rm {m2Plugin.Replace('\\', '/')}/*/*.lastUpdated";
+ }
+
///
/// if a non-Docker process is squatting 127.0.0.1:27015 it shadows the docker-published mongo_master, so
/// every host process' localhost:27015 Mongo connection times out and the gateway + scala services
diff --git a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs
index 2d50197b69..2509c8625c 100644
--- a/cli/cli/Commands/Project/NewPortalExtensionCommand.cs
+++ b/cli/cli/Commands/Project/NewPortalExtensionCommand.cs
@@ -60,7 +60,7 @@ public override void Configure()
AddOption(new Option(
aliases: new string[] { "--mount-page" },
- description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. Run 'portal extension list-extension-options' to see all valid values"),
+ description: "The portal page to mount on. For page extensions use routePrefix + your custom route; for component extensions use the page path. A --zone extension is declared with a zone-relative page path and renders inside the portal's zone view (/zones/:zid/) — the portal owns the prefix. Run 'portal extension list-extension-options' to see all valid values"),
binder: (args, i) => args.mountPage = i);
AddOption(new Option(
@@ -195,6 +195,10 @@ public override async Task Handle(NewPortalExtensionCommandArgs args)
if (resolvedSelector.type == "page")
{
+ // A zone extension's mount page is stored zone-relative and verbatim — the portal
+ // auto-assumes the full `:cid/zones/:zid/` prefix when rendering it in the zone view,
+ // so the CLI must not prepend anything here.
+
// Full-page (hub) extensions need a nav group and a display label. The hub hierarchy comes
// from the page path itself (e.g. "cars" vs "cars/ferrari"); the nav group is a separate way
// to organize extensions within a hub, and is required for page extensions.
diff --git a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban
index dedf086ea7..33b05a4b34 100644
--- a/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban
+++ b/cli/cli/Docs/SkillTemplates/beam-create-portal-extension.md.scriban
@@ -70,6 +70,18 @@ substitute a real hub name:
replaced by your hub path, not literally prepended.) For full-page extensions the
`--mount-selector` is always auto-assigned from the page slot, so you never pass it.
+#### Zone (`--zone`) extensions render in the zone view
+
+A `--zone` extension is scoped to `cid.zid` and lives **above realms**, so its hub and
+pages render in the Portal's **zone view** under `/zones/:zid/` rather than inside a realm.
+
+**Declare the mount page zone-relative** — exactly as for a realm extension
+(`--mount-page "dashboards"` or `"dashboards/overview"`). The Portal owns the routing
+prefix: it auto-assumes the full `:cid/zones/:zid/` prefix when rendering a zone
+extension, so the stored `page` in `package.json` is the zone-relative path with **no**
+`:cid/` segment. Do not add a `:cid/` prefix yourself, whether via `--mount-page` or by
+editing the manifest's `page` by hand.
+
### 2. Create the extension
For a **page extension** (new navigation page):
@@ -147,6 +159,7 @@ Read the generated TypeScript files in the extension's `node_modules` to discove
- **Always pass `-q` (quiet mode)** when executing from MCP to avoid interactive prompts that will hang.
- **Page extensions need `--mount-group` and `--mount-label`** for the sidebar entry. Component extensions do not use these.
- **Mount page format differs by type**: page extensions use `routePrefix + custom-route`, component extensions use the exact `path` value.
+- **Zone extensions render in the Portal's zone view** under `/zones/:zid/`. Declare their mount `page` **zone-relative** (e.g. `dashboards` or `dashboards/overview`) — the Portal owns the `:cid/zones/:zid/` prefix and auto-assumes it. Do not add a `:cid/` prefix, via `--mount-page` or by hand-editing the manifest.
- **In React, import components from `@beamable/portal-toolkit/react`** — do NOT use `` web component tags directly in TSX. Use the typed React forwarders instead.
- **Use Beamable components, not raw HTML.** Portal extensions render inside the Portal UI. Read the generated files in `node_modules/@beamable/portal-toolkit/src/generated/` for available components.
- **The `--service-directory` for portal extensions is `extensions/`**, not `services/`.
diff --git a/cli/cli/Services/DiscoveryService.cs b/cli/cli/Services/DiscoveryService.cs
index b0cbc1a7ab..1dc1f65cb7 100644
--- a/cli/cli/Services/DiscoveryService.cs
+++ b/cli/cli/Services/DiscoveryService.cs
@@ -557,16 +557,57 @@ await WebsocketUtil.RunServerNotificationListenLoop(handle, message =>
}
+ ///
+ /// Resolves the local manifest definition and beamoId for a service discovered on the network by the
+ /// name it broadcasts. Every service broadcasts under its beamoId except a portal extension, whose
+ /// backing server broadcasts a synthetic runtime name (BeamPortalExtension_<beamoId>_<guid>);
+ /// this reconciles that back to the extension definition so zone detection, metadata, and the surfaced
+ /// name behave the same as they do for a microservice. Without it a zone-scoped portal extension is
+ /// dropped by host discovery — its broadcast pid is the zone id (never the realm pid) and the unresolved
+ /// name defeats the IsZoneScoped escape hatch — so it never appears in beam project ps nor
+ /// gets stopped by beam project stop.
+ ///
+ /// true if a local definition was found for the broadcast name.
+ public static bool TryResolveBroadcastDefinition(BeamoLocalManifest manifest, string broadcastServiceName, out BeamoServiceDefinition definition, out string beamoId)
+ {
+ if (manifest.TryGetDefinition(broadcastServiceName, out definition))
+ {
+ beamoId = broadcastServiceName;
+ return true;
+ }
+
+ foreach (var candidate in manifest.ServiceDefinitions)
+ {
+ if (candidate.Protocol != BeamoProtocolType.PortalExtension)
+ {
+ continue;
+ }
+
+ if (BeamoLocalSystem.IsMatchingPortalExtensionService(broadcastServiceName, candidate.BeamoId))
+ {
+ definition = candidate;
+ beamoId = candidate.BeamoId;
+ return true;
+ }
+ }
+
+ definition = null;
+ beamoId = broadcastServiceName;
+ return false;
+ }
+
public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue evtQueue)
{
return Task.Run(async () =>
{
try
{
- // Keyed by (processId, serviceName) so multiple BeamServer instances hosted inside
+ // Keyed by (processId, beamoId) so multiple BeamServer instances hosted inside
// the same OS process — e.g. several portal extensions started by one `beam project run` —
// each register independently. Keying by processId alone hid every extension after the first.
- var processIdToEntry = new Dictionary<(int processId, string serviceName), HostServiceDescriptor>();
+ // The beamoId is resolved from the broadcast serviceName below (a portal extension broadcasts a
+ // synthetic runtime name, not its beamoId).
+ var processIdToEntry = new Dictionary<(int processId, string beamoId), HostServiceDescriptor>();
var socketListener = new Socket(SocketType.Dgram, ProtocolType.Udp);
var ed = new IPEndPoint(System.Net.IPAddress.Any, Beamable.Common.Constants.Features.Services.DISCOVERY_PORT);
@@ -672,27 +713,31 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue) in the pid slot, which never equals the realm pid — so surface it by its
- // local manifest definition instead (the workspace only defines its own zone projects),
- // keyed off the serviceName the broadcast carries.
+ // (the raw zid) in the pid slot, which never equals the realm pid — so surface it by its
+ // local manifest definition instead (the workspace only defines its own zone projects).
if (service.cid != _appContext.Cid)
continue;
- var isLocalZoneService =
- _localSystem.BeamoManifest.TryGetDefinition(service.serviceName, out var discoveredDef) &&
- discoveredDef.IsZoneScoped;
+ // A service broadcasts under its MicroserviceName. For a portal extension that is a
+ // synthetic runtime name (BeamPortalExtension__), not the beamoId the local
+ // manifest is keyed by — so resolve it back to the manifest definition before it drives
+ // zone detection, metadata, and the surfaced service name. For every other service the
+ // broadcast name already is the beamoId, so this resolves to itself.
+ TryResolveBroadcastDefinition(_localSystem.BeamoManifest, service.serviceName, out var discoveredDef, out var resolvedBeamoId);
+
+ var isLocalZoneService = discoveredDef != null && discoveredDef.IsZoneScoped;
if (!isLocalZoneService && service.pid != _appContext.Pid)
continue;
- var entryKey = (service.processId, service.serviceName);
+ var entryKey = (service.processId, resolvedBeamoId);
if (!processIdToEntry.ContainsKey(entryKey))
{
var groups = Array.Empty();
var fedConfig = default(FederationsConfig);
- if (_localSystem.BeamoManifest.TryGetDefinition(service.serviceName, out var definition))
+ if (discoveredDef != null)
{
- groups = definition.ServiceGroupTags;
- fedConfig = definition.FederationsConfig.Federations;
+ groups = discoveredDef.ServiceGroupTags;
+ fedConfig = discoveredDef.FederationsConfig.Federations;
}
var feds = fedConfig?.Select(kvp =>
@@ -703,7 +748,7 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue
{
var fedKey = FederationUtils.BuildLocalSettingKey(v.Interface, kvp.Key);
- if (_localSystem.BeamoManifest.HttpMicroserviceLocalProtocols[service.serviceName].Settings.TryGetSetting(fedKey, out var settingsJsonVal))
+ if (_localSystem.BeamoManifest.HttpMicroserviceLocalProtocols[resolvedBeamoId].Settings.TryGetSetting(fedKey, out var settingsJsonVal))
return settingsJsonVal;
return "{}";
}).ToArray()
@@ -712,7 +757,7 @@ public Task StartHostDiscoveryTask(CancellationToken token, ConcurrentQueue&2; exit 1; }; " +
+ "[ -s \"$CPF\" ] || { echo \"beam: classpath cache $CPF is empty — offline 'mvn dependency:build-classpath' failed for tools/$SVC. The usual cause is that com.kickstand:core is not in your local Maven repository (~/.m2/repository/com/kickstand/core/1.0-SNAPSHOT/). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete ~/.m2/repository/com/kickstand/ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'. Second cause: Maven cannot resolve the 'dependency' plugin prefix OFFLINE (its plugin metadata was never cached) — '--build' does NOT fix that. Fix: run once online from this repo: mvn -U dependency:build-classpath -pl tools/$SVC ; then re-run 'beam local up'. If it still fails, delete ~/.m2/repository/org/apache/maven/plugins/maven-dependency-plugin/*/*.lastUpdated and retry.\" >&2; exit 1; }; " +
"CP=\"tools/$SVC/target/classes:core/target/classes:$JAR:$(cat \"$CPF\")\"; " +
// $JVM_ARGS unquoted on purpose: it must word-split into separate flags.
"exec \"$JHOME/bin/java\" $JVM_ARGS -cp \"$CP\" \"$MAIN\"";
@@ -1004,7 +1004,7 @@ private static string ScalaLaunchPowerShell(string svc, string mainClass, string
"$deps = ''",
"if (Test-Path $cpf) { $raw = Get-Content $cpf -Raw; if ($raw) { $deps = $raw.Trim() } }",
"if (-not $deps) {",
- " Write-Host \"beam: classpath cache $cpf is empty - offline 'mvn dependency:build-classpath' failed for tools/$svc. The usual cause is that com.kickstand:core is not in your local Maven repository ($env:USERPROFILE\\.m2\\repository\\com\\kickstand\\core\\1.0-SNAPSHOT\\). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete $env:USERPROFILE\\.m2\\repository\\com\\kickstand\\ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'.\"",
+ " Write-Host \"beam: classpath cache $cpf is empty - offline 'mvn dependency:build-classpath' failed for tools/$svc. The usual cause is that com.kickstand:core is not in your local Maven repository ($env:USERPROFILE\\.m2\\repository\\com\\kickstand\\core\\1.0-SNAPSHOT\\). Fix: (1) run 'beam local up --build' (the reactor uses -U and 'mvn install', which invalidates any cached miss and writes core to ~/.m2). If that ALSO fails, (2) delete $env:USERPROFILE\\.m2\\repository\\com\\kickstand\\ (locally-built artifacts only, safe to remove) and re-run 'beam local up --build'. Second cause: Maven cannot resolve the 'dependency' plugin prefix OFFLINE (its plugin metadata was never cached) - '--build' does NOT fix that. Fix: run once online from this repo: mvn -U dependency:build-classpath -pl tools/$svc ; then re-run 'beam local up'. If it still fails, delete $env:USERPROFILE\\.m2\\repository\\org\\apache\\maven\\plugins\\maven-dependency-plugin\\*\\*.lastUpdated and retry.\"",
" exit 1 }",
"$cp = \"tools/$svc/target/classes;core/target/classes;$jar;\" + $deps",
"& \"$jhome\\bin\\java.exe\" @jvmArgs -cp $cp $main",
diff --git a/cli/cli/Services/ProjectService.cs b/cli/cli/Services/ProjectService.cs
index 9451430980..5fc32f12de 100644
--- a/cli/cli/Services/ProjectService.cs
+++ b/cli/cli/Services/ProjectService.cs
@@ -293,11 +293,13 @@ public async Task CreateNewPortalExtension(NewPortalExtensionCom
root[Beamable.Common.Constants.Features.PortalExtension.EXTENSION_NAME_PROPERTY_NAME] =
JToken.FromObject(args.ProjectName.Value);
- // A zone extension marks its backing service as zone-scoped via beamable.serviceScope; the run
- // path (BeamoLocalSystem_PortalExtension) reads this and boots the service as a ZoneMicroservice.
- if (args.IsZone && root["beamable"] is JObject beamable)
+ // Stamp the extension's scope on beamable.serviceScope. A zone extension marks its backing
+ // service as zone-scoped (the run path BeamoLocalSystem_PortalExtension reads this and boots
+ // the service as a ZoneMicroservice); a realm extension records "realm" explicitly. The Portal
+ // reads the same field to place the extension's hub at org (zone) vs realm level.
+ if (root["beamable"] is JObject beamable)
{
- beamable["serviceScope"] = "zone";
+ beamable["serviceScope"] = args.IsZone ? "zone" : "realm";
}
File.WriteAllText(packagePath, root.ToString(Newtonsoft.Json.Formatting.Indented));
diff --git a/cli/cli/Services/Web/WebLocalRegistryService.cs b/cli/cli/Services/Web/WebLocalRegistryService.cs
index 7f3032026d..f6f6c567be 100644
--- a/cli/cli/Services/Web/WebLocalRegistryService.cs
+++ b/cli/cli/Services/Web/WebLocalRegistryService.cs
@@ -36,6 +36,9 @@ public class WebLocalRegistryService
public const string DefaultRegistry = "http://localhost:4873";
public const string DefaultCdn = "http://localhost:4874";
+ /// The public npm registry a released @beamable pin must always resolve against.
+ public const string PublicRegistry = "https://registry.npmjs.org/";
+
/// Directory inside the product repo holding the registry's docker-compose file.
public const string LocaldevDirName = "portal-localdev";
@@ -251,23 +254,31 @@ public static string ReadPinnedVersion(string packageJsonPath, string package)
}
///
- /// The npm arguments needed to install a project whose @beamable/portal-toolkit pin is a local
- /// developer build — i.e. --registry <local> plus its auth token. Returns an empty string
- /// for every other project, so a normal install is completely untouched.
- ///
- ///
- /// Required, not an optimisation: a local-dev version exists only on the local registry, so a plain
- /// npm install resolves it against npmjs, 404s, and fails the build. Routing the *whole* install
- /// at the local registry is correct because it proxies everything else to npmjs (see
+ /// The npm arguments needed to install a project's @beamable/portal-toolkit pin, chosen from the
+ /// pinned version:
+ ///
+ /// -
+ /// A local developer build (0.0.123-*) exists only on the local registry, so the whole install is
+ /// routed there with --registry <local> plus its auth token. Required, not an optimisation:
+ /// a plain npm install would resolve it against npmjs, 404, and fail the build. Routing everything
+ /// at the local registry is fine because it proxies the rest to npmjs (see
/// portal-localdev/verdaccio/config.yml).
- ///
+ ///
+ /// -
+ /// A released build pins the @beamable scope at the public npm registry with
+ /// --@beamable:registry=<public>. A user's machine may have a corporate proxy or a private
+ /// registry configured for @beamable in their npmrc that has never heard of the package, so pinning
+ /// the scope to npmjs keeps a normal install from failing there.
+ ///
+ ///
///
public static string InstallArgsFor(string projectDir, string registryUrl = DefaultRegistry)
{
var pinned = ReadPinnedVersion(Path.Combine(projectDir, "package.json"), ToolkitPackage);
if (!IsLocalDevVersion(pinned))
{
- return string.Empty;
+ Log.Verbose($"[{projectDir}] pins the released {ToolkitPackage}@{pinned}; forcing the @beamable scope at [{PublicRegistry}]");
+ return $" --@beamable:registry={PublicRegistry}";
}
Log.Verbose($"[{projectDir}] pins the local build {ToolkitPackage}@{pinned}; installing from [{registryUrl}]");
diff --git a/cli/tests/DiscoveryTests/DiscoveryServiceTests.cs b/cli/tests/DiscoveryTests/DiscoveryServiceTests.cs
new file mode 100644
index 0000000000..d12be010c9
--- /dev/null
+++ b/cli/tests/DiscoveryTests/DiscoveryServiceTests.cs
@@ -0,0 +1,115 @@
+using cli.Services;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+
+namespace tests.DiscoveryTests;
+
+///
+/// Unit tests for , the reconciliation that
+/// lets host discovery map the name a service broadcasts back to its local manifest definition. This is
+/// what makes a zone-scoped portal extension visible to beam project ps / stoppable by
+/// beam project stop: its backing server broadcasts a synthetic runtime name, and without this
+/// resolution the IsZoneScoped escape hatch in host discovery never engages.
+///
+public class DiscoveryServiceTests
+{
+ private static BeamoLocalManifest ManifestWith(params BeamoServiceDefinition[] definitions)
+ {
+ return new BeamoLocalManifest
+ {
+ ServiceDefinitions = new List(definitions),
+ HttpMicroserviceLocalProtocols = new BeamoLocalProtocolMap(),
+ EmbeddedMongoDbLocalProtocols = new BeamoLocalProtocolMap(),
+ };
+ }
+
+ private static string BroadcastName(string beamoId) =>
+ $"BeamPortalExtension_{beamoId}_{Guid.NewGuid()}";
+
+ [Test]
+ public void ResolvesMicroservice_ByExactName()
+ {
+ var manifest = ManifestWith(new BeamoServiceDefinition
+ {
+ BeamoId = "MyService",
+ Protocol = BeamoProtocolType.HttpMicroservice,
+ });
+
+ var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, "MyService", out var def, out var beamoId);
+
+ Assert.That(found, Is.True, "a microservice broadcasts under its beamoId and must resolve directly");
+ Assert.That(beamoId, Is.EqualTo("MyService"));
+ Assert.That(def, Is.Not.Null);
+ }
+
+ [Test]
+ public void ResolvesRealmPortalExtension_FromMangledBroadcastName()
+ {
+ var manifest = ManifestWith(new BeamoServiceDefinition
+ {
+ BeamoId = "MyExt",
+ Protocol = BeamoProtocolType.PortalExtension,
+ ServiceScope = null,
+ });
+
+ var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("MyExt"), out var def, out var beamoId);
+
+ Assert.That(found, Is.True, "a portal extension's synthetic runtime name must resolve back to its beamoId");
+ Assert.That(beamoId, Is.EqualTo("MyExt"), "the surfaced name must be the beamoId, not the mangled runtime name");
+ Assert.That(def, Is.Not.Null);
+ Assert.That(def.IsZoneScoped, Is.False, "a realm extension is not zone-scoped");
+ }
+
+ [Test]
+ public void ResolvesZonePortalExtension_AndReportsZoneScoped()
+ {
+ // This is the regression case: a zone-scoped portal extension broadcasts a mangled runtime name and a
+ // zone id in its pid slot. Host discovery only keeps it (past the realm-pid filter) if this resolution
+ // finds the definition and surfaces IsZoneScoped.
+ var manifest = ManifestWith(new BeamoServiceDefinition
+ {
+ BeamoId = "MyZoneExt",
+ Protocol = BeamoProtocolType.PortalExtension,
+ ServiceScope = "zone",
+ });
+
+ var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("MyZoneExt"), out var def, out var beamoId);
+
+ Assert.That(found, Is.True, "a zone portal extension must resolve so host discovery can keep it");
+ Assert.That(beamoId, Is.EqualTo("MyZoneExt"));
+ Assert.That(def, Is.Not.Null);
+ Assert.That(def.IsZoneScoped, Is.True, "the resolved definition must report zone scope so the realm-pid filter is bypassed");
+ }
+
+ [Test]
+ public void PicksCorrectExtension_WhenMultipleExist()
+ {
+ var manifest = ManifestWith(
+ new BeamoServiceDefinition { BeamoId = "ExtA", Protocol = BeamoProtocolType.PortalExtension, ServiceScope = "zone" },
+ new BeamoServiceDefinition { BeamoId = "ExtB", Protocol = BeamoProtocolType.PortalExtension, ServiceScope = null });
+
+ var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("ExtB"), out var def, out var beamoId);
+
+ Assert.That(found, Is.True);
+ Assert.That(beamoId, Is.EqualTo("ExtB"), "resolution must match the extension embedded in the broadcast name");
+ Assert.That(def.IsZoneScoped, Is.False);
+ }
+
+ [Test]
+ public void ReturnsFalse_ForUnknownName()
+ {
+ var manifest = ManifestWith(new BeamoServiceDefinition
+ {
+ BeamoId = "Known",
+ Protocol = BeamoProtocolType.HttpMicroservice,
+ });
+
+ var found = DiscoveryService.TryResolveBroadcastDefinition(manifest, BroadcastName("Unknown"), out var def, out var beamoId);
+
+ Assert.That(found, Is.False, "a broadcast that matches no local definition must not resolve");
+ Assert.That(def, Is.Null);
+ Assert.That(beamoId, Does.StartWith("BeamPortalExtension_Unknown_"),
+ "when unresolved, the raw broadcast name is passed through unchanged");
+ }
+}
diff --git a/cli/tests/LocalStackDiscoveryTests.cs b/cli/tests/LocalStackDiscoveryTests.cs
index f1ee96c9e6..5c072fc7a5 100644
--- a/cli/tests/LocalStackDiscoveryTests.cs
+++ b/cli/tests/LocalStackDiscoveryTests.cs
@@ -713,6 +713,90 @@ public void MissingCoreInM2_LeftAlone_WhenNoScalaLaunchStep()
Assert.That(result, Is.Null);
}
+ // ----------------------------------------------------------------------------------
+ // Offline maven-dependency-plugin probe (the second half of the negative-cache trap:
+ // core is present, but `mvn -o dependency:build-classpath` can't resolve the `dependency`
+ // plugin prefix offline, so every scala service fails and --build never fixes it).
+ // ----------------------------------------------------------------------------------
+
+ private static LocalStackConfig ProbeConfig(string scalaDir, string mavenHome, params (string name, bool enabled)[] steps) =>
+ new LocalStackConfig
+ {
+ repos = new LocalStackRepos { scalaDir = scalaDir },
+ toolchain = mavenHome == null ? null : new LocalStackToolchain { maven = mavenHome },
+ steps = steps.Select(s => new LocalStackStep { name = s.name, enabled = s.enabled }).ToList()
+ };
+
+ [Test]
+ public void PlanDependencyPluginProbe_DerivesModuleFromScalaLaunchStep()
+ {
+ // A scala service will launch, so the launcher's offline classpath resolve will run — the probe is relevant.
+ // The module it targets mirrors the launcher's `-pl tools/$SVC`, derived from the step name after "scala: ".
+ var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"),
+ ("build: scala", true), ("scala: account", true), ("scala: dbflake", true));
+
+ var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config);
+
+ Assert.That(plan.shouldProbe, Is.True, plan.skipReason);
+ Assert.That(plan.probeModule, Is.EqualTo("tools/account"), "first scala launch step wins");
+ Assert.That(plan.scalaDir, Is.EqualTo(Path.GetTempPath()));
+ Assert.That(plan.mvnCommand, Does.Contain("mvn"), "resolves the toolchain's mvn from ${maven}");
+ }
+
+ [Test]
+ public void PlanDependencyPluginProbe_SkipsWhenNoScalaLaunchStep()
+ {
+ // No scala service means no launcher, so the offline classpath resolve never runs — don't pay for a probe.
+ var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"),
+ ("build: scala", true), ("docker: api deps + caddy", true));
+
+ var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config);
+
+ Assert.That(plan.shouldProbe, Is.False);
+ Assert.That(plan.skipReason, Is.EqualTo("no scala launch step"));
+ }
+
+ [Test]
+ public void PlanDependencyPluginProbe_SkipsWhenScalaStepIsDisabled()
+ {
+ // A disabled scala step won't launch, so it must not drag the probe in.
+ var config = ProbeConfig(Path.GetTempPath(), Path.Combine(Path.GetTempPath(), "mvn-home"),
+ ("scala: account", false));
+
+ var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config);
+
+ Assert.That(plan.shouldProbe, Is.False);
+ Assert.That(plan.skipReason, Is.EqualTo("no scala launch step"));
+ }
+
+ [Test]
+ public void PlanDependencyPluginProbe_SkipsWhenScalaDirIsAPlaceholder()
+ {
+ // An un-edited manifest carries the EditPlaceholder for repo paths; there's no real dir to run mvn in.
+ var config = ProbeConfig("<" + "EDIT-ME" + ">", Path.Combine(Path.GetTempPath(), "mvn-home"),
+ ("scala: account", true));
+ // Force the actual placeholder token in case the constant differs from the guess above.
+ config.repos.scalaDir = LocalStackConfigIO.EditPlaceholder;
+
+ var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config);
+
+ Assert.That(plan.shouldProbe, Is.False);
+ Assert.That(plan.skipReason, Is.EqualTo("no BeamableBackend path on the manifest"));
+ }
+
+ [Test]
+ public void PlanDependencyPluginProbe_ResolvesBareMvn_WithoutAToolchain()
+ {
+ // No toolchain (never ran `beam local setup`): ${maven} falls back to the bare command, which is still a
+ // valid thing to probe with — it resolves via PATH just like the reactor step would.
+ var config = ProbeConfig(Path.GetTempPath(), mavenHome: null, ("scala: account", true));
+
+ var plan = LocalStackUpCommand.PlanDependencyPluginProbe(config);
+
+ Assert.That(plan.shouldProbe, Is.True, plan.skipReason);
+ Assert.That(plan.mvnCommand, Does.Contain("mvn"));
+ }
+
// ----------------------------------------------------------------------------------
// In-memory manifest migration for pre-Maven-cache-fix arguments
// ----------------------------------------------------------------------------------
diff --git a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs
index fac240e925..9dfc6c0a9f 100644
--- a/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs
+++ b/cli/tests/PortalExtensionTests/PortalExtensionCommandTests.cs
@@ -266,6 +266,48 @@ public void NewPortalExtension_ZoneExtension_ScaffoldsZoneTemplate()
"the zone template must register via the zone-scoped API");
}
+ [Test]
+ public void NewPortalExtension_ZoneExtension_StoresPageZoneRelative()
+ {
+ InitWorkspace();
+ SetupBeamoServiceMock();
+ MockRemotePortalConfig();
+
+ Run("project", "new", "portal-extension", "TestZonePage", "--quiet",
+ "--mount-page", "my-zone-page",
+ "--mount-group", "TestGroup",
+ "--mount-label", "TestLabel",
+ "--template", "react",
+ "--zone");
+
+ var packageJson = BFile.ReadAllText("extensions/TestZonePage/package.json");
+ Assert.That(packageJson, Does.Contain("\"my-zone-page\""),
+ "a zone extension's page is declared zone-relative and stored verbatim");
+ Assert.That(packageJson, Does.Not.Contain(":cid/"),
+ "the portal owns the :cid/zones/:zid/ prefix, so the CLI must not prepend :cid/");
+ }
+
+ [Test]
+ public void NewPortalExtension_ZoneTemplate_DefaultPageIsZoneRelative()
+ {
+ InitWorkspace();
+ SetupBeamoServiceMock();
+ MockRemotePortalConfig();
+
+ // Scaffold from the zone template without overriding the mount page, then inspect the
+ // template's seeded default. The zone template must ship a zone-relative default page.
+ Run("project", "new", "portal-extension", "TestZoneDefault", "--quiet",
+ "--mount-page", "zone-default",
+ "--mount-group", "TestGroup",
+ "--mount-label", "TestLabel",
+ "--template", "react",
+ "--zone");
+
+ var packageJson = BFile.ReadAllText("extensions/TestZoneDefault/package.json");
+ Assert.That(packageJson, Does.Not.Contain(":cid/"),
+ "the zone template default page must be zone-relative, without a :cid/ prefix");
+ }
+
[Test]
public void NewPortalExtension_PageExtension_PassesThroughHubPath()
{