@reventlessdev/reventless-local 3.0.0-alpha.216 → 3.0.0-alpha.218

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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +10 -10
  3. package/src/BakedManifest.res +63 -51
  4. package/src/BakedManifest.res.mjs +44 -28
  5. package/src/HostShellDist.res +29 -0
  6. package/src/HostShellDist.res.mjs +20 -0
  7. package/src/Platform.res +30 -11
  8. package/src/Platform.res.mjs +6 -0
  9. package/src/ShellConfig.res +178 -0
  10. package/src/ShellConfig.res.mjs +114 -0
  11. package/src/UiHints.res +107 -0
  12. package/src/UiHints.res.mjs +61 -0
  13. package/src/adapter/Auth/Auth_GraphqlContext.res +4 -6
  14. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +5 -0
  15. package/src/adapter/Auth/LocalAuth.res +136 -6
  16. package/src/adapter/Auth/LocalAuth.res.mjs +82 -3
  17. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +23 -2
  18. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +19 -2
  19. package/src/adapter/DomainGraphQL_Server.res +67 -4
  20. package/src/adapter/DomainGraphQL_Server.res.mjs +49 -2
  21. package/src/adapter/GraphQL_CallerError.res +19 -0
  22. package/src/adapter/GraphQL_CallerError.res.mjs +16 -0
  23. package/src/adapter/GraphQL_Server.res.mjs +3 -0
  24. package/tests/BakedManifestFilesTest.res +68 -0
  25. package/tests/BakedManifestFilesTest.res.mjs +115 -0
  26. package/tests/ShellConfigTest.res +221 -0
  27. package/tests/ShellConfigTest.res.mjs +203 -0
  28. package/tests/UiHintsTest.res +94 -0
  29. package/tests/UiHintsTest.res.mjs +99 -0
  30. package/tests/adapter/CommandAuthorizationTest.res +86 -0
  31. package/tests/adapter/CommandAuthorizationTest.res.mjs +62 -0
  32. package/tests/adapter/LocalAuthLoginTest.res +214 -0
  33. package/tests/adapter/LocalAuthLoginTest.res.mjs +191 -8
  34. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +2 -2
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.218 (2026-08-13)
7
+
8
+ ### Features
9
+
10
+ * **aws:** bake the component manifest after every plugin stack is up ([2fff09a](https://github.com/ReventlessDev/reventless-core/commit/2fff09aa32b77cc8e33a65414c58c12026ca009e))
11
+ * **local:** bake one curated surface per audience ([3be6f63](https://github.com/ReventlessDev/reventless-core/commit/3be6f63e579cef8cd213a84b6ccded773b1abc9e))
12
+ * **local:** let a login act as one of the roles it holds ([61141ac](https://github.com/ReventlessDev/reventless-core/commit/61141ac783a71dd7f043e84e6e7732c67cfb921b))
13
+ * **local:** point the dev shell at the manifest it bakes ([e4d0f1c](https://github.com/ReventlessDev/reventless-core/commit/e4d0f1cef70f9a9ca6d13266d72db3f38d0cc2d9))
14
+ * **local:** serve the ui-hints file a deployment declares ([8258c85](https://github.com/ReventlessDev/reventless-core/commit/8258c858155fec760a772483f9bde208ffa57f61))
15
+ * **local:** switch role without logging in again ([4f82348](https://github.com/ReventlessDev/reventless-core/commit/4f82348f01b9117256fc1d7208355c3654875909))
16
+
17
+
18
+ # 3.0.0-alpha.217 (2026-08-12)
19
+
20
+ ### Bug Fixes
21
+
22
+ * **command:** drop null arguments, and let a caller read why a payload was refused ([bfee0dc](https://github.com/ReventlessDev/reventless-core/commit/bfee0dc37755596ea8bcce1fe2cccdace7759f10))
23
+
24
+
6
25
  # 3.0.0-alpha.216 (2026-08-12)
7
26
 
8
27
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.216",
3
+ "version": "3.0.0-alpha.218",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -35,18 +35,18 @@
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
39
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
40
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
- "@reventlessdev/rescript-node": "2.0.0-alpha.4",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.5",
42
+ "@reventlessdev/reventless-core": "3.0.0-alpha.230",
42
43
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.76",
44
- "@reventlessdev/reventless-core": "3.0.0-alpha.228",
45
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.92",
46
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.175",
47
- "@reventlessdev/reventless-infra": "3.0.0-alpha.137",
48
- "@reventlessdev/reventless-seed": "1.0.0-alpha.11",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.111"
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.78",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.177",
46
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.12",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.94",
48
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.112",
49
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.138"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -1,72 +1,84 @@
1
1
  // Writes the curated component manifest where the local host shell serves its
2
- // static assets from, so `manifestUrl` discovery works in dev exactly as it does
3
- // in a deployment.
4
- //
5
- // Locally there is no deploy and no bucket: `reventless-host-shell` serves its
6
- // own `dist/` (that is where the shipped `config.json` and `ui-hints.json` come
7
- // from), so that is where the file goes. Rewritten on every boot, which also
8
- // makes it self-healing — a `pnpm install` that replaces the package directory
9
- // costs one restart, not a debugging session.
2
+ // static assets from (`HostShellDist`), so `manifestUrl` discovery works in dev
3
+ // exactly as it does in a deployment. Rewritten on every boot, which also makes
4
+ // it self-healing — a `pnpm install` that replaces the package directory costs
5
+ // one restart, not a debugging session.
10
6
  //
11
7
  // The curation itself lives in `ReventlessCore.Platform_BakedManifest`, shared
12
8
  // with any other platform that bakes: only the destination is local.
13
9
 
14
- let hostShellPackage = "@reventlessdev/reventless-host-shell"
15
-
16
10
  let log = ReventlessCore.Logger.fromEnv()
17
11
 
18
- // Resolved from the running project rather than from this framework module: the
19
- // bundle is a deploy input the project names in its own package.json, and a
20
- // framework-rooted lookup would skip that pin (the same distinction
21
- // `Util_Bundle.resolvePackageRoot(~fromPulumiProject)` draws on AWS).
22
- let hostShellDistDir = (): option<string> =>
23
- try {
24
- Some(
25
- NodePath.dirname(
26
- NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(
27
- hostShellPackage ++ "/package.json",
28
- ),
29
- ) ++ "/dist",
30
- )
31
- } catch {
32
- | _ => None
33
- }
34
-
35
- let defaultKey = "component-manifest.json"
36
-
37
12
  /**
38
13
  A declared bake writes the file or fails loudly. Both failure modes it can hit
39
14
  are the deployment's own mistake — a name that matches no component, or a shell
40
15
  package that is not installed — and both produce the same symptom if swallowed:
41
16
  a shop that renders nothing, with no line in the log saying why.
42
17
  */
18
+ let toSelections = (
19
+ components: array<ReventlessInfra.Platform.bakedManifestSelection>,
20
+ ): array<ReventlessCore.Platform_BakedManifest.selection> =>
21
+ components->Array.map((s): ReventlessCore.Platform_BakedManifest.selection => {
22
+ plugin: s.plugin,
23
+ views: s.views,
24
+ commands: s.commands,
25
+ })
26
+
27
+ /**
28
+ Every file this declaration produces, as (key, selections) pairs.
29
+
30
+ The default journey first, then one per declared journey. Separate from the
31
+ write so the whole set is reachable from a test — the same reason
32
+ `ShellConfig.overlay` exists apart from its write.
33
+ */
34
+ let files = (
35
+ ~config: ReventlessInfra.Platform.bakedManifest,
36
+ ): array<(string, array<ReventlessCore.Platform_BakedManifest.selection>)> => {
37
+ let base = [
38
+ (
39
+ config.key->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey),
40
+ toSelections(config.components),
41
+ ),
42
+ ]
43
+ switch config.journeys {
44
+ | None => base
45
+ | Some(journeys) =>
46
+ base->Array.concat(
47
+ journeys->Array.map(j => (
48
+ j.key->Option.getOr(ReventlessCore.Platform_BakedManifest.journeyKey(~group=j.group)),
49
+ toSelections(j.components),
50
+ )),
51
+ )
52
+ }
53
+ }
54
+
43
55
  let emit = (
44
56
  ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
45
57
  ~config: ReventlessInfra.Platform.bakedManifest,
46
58
  ) => {
47
- let selections =
48
- config.components->Array.map((
49
- s
50
- ): ReventlessCore.Platform_BakedManifest.selection => {
51
- plugin: s.plugin,
52
- views: s.views,
53
- commands: s.commands,
54
- })
55
- switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
56
- | Error(e) => JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
57
- | Ok(manifest) =>
58
- switch hostShellDistDir() {
59
- | None =>
60
- JsError.throwWithMessage(
61
- `baked manifest: cannot resolve ${hostShellPackage} from ${NodeProcess.cwd()} — ` ++
62
- `the local shell serves the file from that package's dist/, so declaring a bake ` ++
63
- `without the package installed would write nothing and render an empty shell.`,
64
- )
65
- | Some(dir) =>
66
- let key = config.key->Option.getOr(defaultKey)
59
+ let outputs = files(~config)
60
+ // Every file is curated before any is written. A declaration naming a
61
+ // component that does not exist fails the boot, and failing it halfway would
62
+ // leave a shell serving one audience's file and a stale copy of another's.
63
+ let curated = outputs->Array.map(((key, selections)) => (
64
+ key,
65
+ switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
66
+ | Error(e) => JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
67
+ | Ok(manifest) => manifest
68
+ },
69
+ ))
70
+ switch HostShellDist.dir() {
71
+ | None =>
72
+ JsError.throwWithMessage(
73
+ `baked manifest: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
74
+ `the local shell serves the file from that package's dist/, so declaring a bake ` ++
75
+ `without the package installed would write nothing and render an empty shell.`,
76
+ )
77
+ | Some(dir) =>
78
+ curated->Array.forEach(((key, manifest)) => {
67
79
  let path = NodePath.join([dir, key])
68
80
  NodeFs.writeFileSync(path, JSON.stringify(manifest, ~space=2))
69
- log.info(~comp="BakedManifest", `wrote ${key} for ${selections->Array.length->Int.toString} plugin(s): ${path}`)
70
- }
81
+ log.info(~comp="BakedManifest", `wrote ${key}: ${path}`)
82
+ })
71
83
  }
72
84
  }
@@ -2,51 +2,67 @@
2
2
 
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
- import * as Nodemodule from "node:module";
6
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
7
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
+ import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
9
9
  import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
10
10
 
11
- let hostShellPackage = "@reventlessdev/reventless-host-shell";
12
-
13
11
  let log = Logger$ReventlessCore.fromEnv();
14
12
 
15
- function hostShellDistDir() {
16
- try {
17
- return Nodepath.dirname(Nodemodule.createRequire(process.cwd() + "/index.js").resolve(hostShellPackage + "/package.json")) + "/dist";
18
- } catch (exn) {
19
- return;
20
- }
21
- }
22
-
23
- let defaultKey = "component-manifest.json";
24
-
25
- function emit(structures, config) {
26
- let selections = config.components.map(s => ({
13
+ function toSelections(components) {
14
+ return components.map(s => ({
27
15
  plugin: s.plugin,
28
16
  views: s.views,
29
17
  commands: s.commands
30
18
  }));
31
- let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
32
- if (e.TAG !== "Ok") {
33
- return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
19
+ }
20
+
21
+ function files(config) {
22
+ let base = [[
23
+ Stdlib_Option.getOr(config.key, Platform_BakedManifest$ReventlessCore.defaultKey),
24
+ toSelections(config.components)
25
+ ]];
26
+ let journeys = config.journeys;
27
+ if (journeys !== undefined) {
28
+ return base.concat(journeys.map(j => [
29
+ Stdlib_Option.getOr(j.key, Platform_BakedManifest$ReventlessCore.journeyKey(j.group)),
30
+ toSelections(j.components)
31
+ ]));
32
+ } else {
33
+ return base;
34
34
  }
35
- let dir = hostShellDistDir();
36
- if (dir === undefined) {
37
- return Stdlib_JsError.throwWithMessage(`baked manifest: cannot resolve ` + hostShellPackage + ` from ` + process.cwd() + ` — the local shell serves the file from that package's dist/, so declaring a bake without the package installed would write nothing and render an empty shell.`);
35
+ }
36
+
37
+ function emit(structures, config) {
38
+ let outputs = files(config);
39
+ let curated = outputs.map(param => {
40
+ let e = Platform_BakedManifest$ReventlessCore.curate(structures, param[1]);
41
+ let tmp;
42
+ tmp = e.TAG === "Ok" ? e._0 : Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
43
+ return [
44
+ param[0],
45
+ tmp
46
+ ];
47
+ });
48
+ let dir = HostShellDist$ReventlessLocal.dir();
49
+ if (dir !== undefined) {
50
+ curated.forEach(param => {
51
+ let key = param[0];
52
+ let path = Nodepath.join(dir, key);
53
+ Nodefs.writeFileSync(path, JSON.stringify(param[1], undefined, 2), "utf8");
54
+ log.info("BakedManifest", undefined, `wrote ` + key + `: ` + path);
55
+ });
56
+ return;
57
+ } else {
58
+ return Stdlib_JsError.throwWithMessage(`baked manifest: cannot resolve ` + HostShellDist$ReventlessLocal.$$package + ` from ` + process.cwd() + ` — the local shell serves the file from that package's dist/, so declaring a bake without the package installed would write nothing and render an empty shell.`);
38
59
  }
39
- let key = Stdlib_Option.getOr(config.key, defaultKey);
40
- let path = Nodepath.join(dir, key);
41
- Nodefs.writeFileSync(path, JSON.stringify(e._0, undefined, 2), "utf8");
42
- log.info("BakedManifest", undefined, `wrote ` + key + ` for ` + selections.length.toString() + ` plugin(s): ` + path);
43
60
  }
44
61
 
45
62
  export {
46
- hostShellPackage,
47
63
  log,
48
- hostShellDistDir,
49
- defaultKey,
64
+ toSelections,
65
+ files,
50
66
  emit,
51
67
  }
52
68
  /* log Not a pure module */
@@ -0,0 +1,29 @@
1
+ // Where the local host shell serves its static assets from.
2
+ //
3
+ // Locally there is no deploy and no bucket: `reventless-host-shell` serves its
4
+ // own `dist/` (that is where the shipped `config.json` and `ui-hints.json` a dev
5
+ // sees come from), so that is where every generated asset the browser fetches
6
+ // has to land — the baked component manifest and the `config.json` overlay
7
+ // alike. Resolved in one place because those writers have to agree: a second
8
+ // opinion about the directory puts a manifest where nothing reads it, and the
9
+ // symptom is an empty shell rather than a missing file.
10
+ //
11
+ // Resolved from the running project rather than from this framework module: the
12
+ // bundle is a deploy input the project names in its own package.json, and a
13
+ // framework-rooted lookup would skip that pin (the same distinction
14
+ // `Util_Bundle.resolvePackageRoot(~fromPulumiProject)` draws on AWS).
15
+
16
+ let package = "@reventlessdev/reventless-host-shell"
17
+
18
+ let dir = (): option<string> =>
19
+ try {
20
+ Some(
21
+ NodePath.dirname(
22
+ NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(
23
+ package ++ "/package.json",
24
+ ),
25
+ ) ++ "/dist",
26
+ )
27
+ } catch {
28
+ | _ => None
29
+ }
@@ -0,0 +1,20 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodepath from "node:path";
4
+ import * as Nodemodule from "node:module";
5
+
6
+ let $$package = "@reventlessdev/reventless-host-shell";
7
+
8
+ function dir() {
9
+ try {
10
+ return Nodepath.dirname(Nodemodule.createRequire(process.cwd() + "/index.js").resolve($$package + "/package.json")) + "/dist";
11
+ } catch (exn) {
12
+ return;
13
+ }
14
+ }
15
+
16
+ export {
17
+ $$package,
18
+ dir,
19
+ }
20
+ /* node:path Not a pure module */
package/src/Platform.res CHANGED
@@ -1428,28 +1428,33 @@ module MakeWithConfig = (
1428
1428
  }
1429
1429
  })
1430
1430
 
1431
- // In-memory ignores most of `~hostUiBundle` — the host shell is served by
1432
- // `vite dev` against the running in-process GraphQL server, not from a CDN,
1433
- // including `uiHintsFile` (the AWS deploy writes it as a BucketObject; local
1434
- // dev serves `public/ui-hints.json` directly). `bakedManifest` is the
1435
- // exception: it is generated content, so there is no hand-authored file for
1436
- // local dev to serve instead.
1431
+ // In-memory ignores the infrastructure half of `~hostUiBundle` — the host shell
1432
+ // is served from the installed package's own `dist/`, not from a CDN. What a
1433
+ // deployment *says* is honoured, in the same three files the AWS deploy writes
1434
+ // beside each other: `bakedManifest` through `BakedManifest`, `shellConfig`
1435
+ // through `ShellConfig`, which overlays the served `config.json` the way the
1436
+ // AWS deploy composes the one it writes, and `uiHintsFile` through `UiHints`,
1437
+ // which replaces the served hints file the way the deploy uploads it.
1437
1438
  type hostUiBundleConfig = {
1438
1439
  assetsDir?: string,
1439
1440
  bundleVersion?: string,
1441
+ // `makePlatform` serves the declared file in place of the host-shell
1442
+ // package's own dev-mode fallback, which an undeclared platform keeps.
1440
1443
  uiHintsFile?: string,
1441
- // Honoured here, unlike the rest: `makePlatform` writes the curated manifest
1442
- // where the local host-shell serves its static assets from.
1444
+ // `makePlatform` writes the curated manifest where the local host-shell
1445
+ // serves its static assets from, and points `config.json` at it.
1443
1446
  bakedManifest?: ReventlessInfra.Platform.bakedManifest,
1444
1447
  // AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
1445
1448
  // signature; the in-memory platform provisions no infrastructure and
1446
1449
  // ignores them.
1447
1450
  geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1448
1451
  uploadBucket?: ReventlessInfra.Platform.objectStore,
1449
- // Same terms: the AWS deploy writes these into the config.json it hosts, and
1450
- // local dev has no config.json of its own to write — the host-shell package
1451
- // serves `public/config.json`, which is where local turns a view mode on.
1452
+ // Still ignored: a mode is a shell *build* input locally, turned on in the
1453
+ // host-shell package's own `public/config.json`, where the AWS deploy has to
1454
+ // write it into the config.json it hosts.
1452
1455
  viewModes?: array<ReventlessInfra.Platform.viewMode>,
1456
+ // Merged into the served `config.json` verbatim, as on AWS — the keys the
1457
+ // shell owns and neither platform computes (`appName`, `elevatedGroups`, …).
1453
1458
  shellConfig?: dict<JSON.t>,
1454
1459
  }
1455
1460
  let makePlatform = (
@@ -1610,6 +1615,20 @@ module MakeWithConfig = (
1610
1615
  // offers, which is settled at composition and owes nothing to projection
1611
1616
  // catch-up. Every plugin passed to this call is connected by construction
1612
1617
  // locally, so there is no status or version dedup to apply.
1618
+ //
1619
+ // The config.json overlay is written first and synchronously: it depends on
1620
+ // the *declaration* only, where the bake has to wait for every plugin's
1621
+ // structure Output to land. Unconditional, unlike the bake, because it is
1622
+ // also what restores the shipped file for a platform that has stopped
1623
+ // declaring anything.
1624
+ ShellConfig.emit(
1625
+ ~bakedManifest=hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest),
1626
+ ~shellConfig=hostUiBundle->Option.flatMap(cfg => cfg.shellConfig),
1627
+ )
1628
+ // Unconditional for the same reason, and one file over: it is also what
1629
+ // restores the shell package's own hints for a platform that has stopped
1630
+ // declaring its own.
1631
+ UiHints.emit(~uiHintsFile=hostUiBundle->Option.flatMap(cfg => cfg.uiHintsFile))
1613
1632
  switch hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest) {
1614
1633
  | None => ()
1615
1634
  | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg)
@@ -18,6 +18,7 @@ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/
18
18
  import * as Plugin$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin.res.mjs";
19
19
  import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
20
20
  import * as Backend$ReventlessLocal from "./adapter/Backend.res.mjs";
21
+ import * as UiHints$ReventlessLocal from "./UiHints.res.mjs";
21
22
  import * as Component$ReventlessCore from "@reventlessdev/reventless-core/src/components/Component.res.mjs";
22
23
  import * as LocalBus$ReventlessLocal from "./adapter/LocalBus.res.mjs";
23
24
  import * as Api_Naming$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Naming.res.mjs";
@@ -27,6 +28,7 @@ import * as TestRunner$ReventlessLocal from "./test/TestRunner.res.mjs";
27
28
  import * as UiFragments$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs";
28
29
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
29
30
  import * as EffectLogger$ReventlessCore from "@reventlessdev/reventless-core/src/util/EffectLogger.res.mjs";
31
+ import * as ShellConfig$ReventlessLocal from "./ShellConfig.res.mjs";
30
32
  import * as BackendState$ReventlessLocal from "./adapter/BackendState.res.mjs";
31
33
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
32
34
  import * as SqliteDriver$ReventlessLocal from "./adapter/SqliteDriver.res.mjs";
@@ -1285,6 +1287,8 @@ function MakeWithConfig(Config) {
1285
1287
  adminResources: []
1286
1288
  });
1287
1289
  subscribeToPluginEvents();
1290
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
1291
+ UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
1288
1292
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
1289
1293
  if (cfg !== undefined) {
1290
1294
  bakeManifest(plugins$1, cfg);
@@ -3003,6 +3007,8 @@ function Make($star) {
3003
3007
  adminResources: []
3004
3008
  });
3005
3009
  subscribeToPluginEvents();
3010
+ ShellConfig$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest), Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.shellConfig), undefined);
3011
+ UiHints$ReventlessLocal.emit(Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.uiHintsFile), undefined);
3006
3012
  let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
3007
3013
  if (cfg !== undefined) {
3008
3014
  bakeManifest(plugins$1, cfg);
@@ -0,0 +1,178 @@
1
+ // Delivers a deployment's host-shell keys to local dev, so `config.json` says
2
+ // the same things it says on AWS.
3
+ //
4
+ // On AWS the deploy owns that file outright: `Util_ShellConfig.fields` joins the
5
+ // keys the deploy computes with the ones the deployment chose, and the result is
6
+ // written as a `BucketObject`. Locally there is no deploy and no bucket — the
7
+ // `config.json` a dev's browser fetches is the one `reventless-host-shell`
8
+ // shipped in its own `dist/`. That file is therefore the *baseline* here, not
9
+ // the output.
10
+ //
11
+ // Hence the copy. The first write puts the shipped file aside as
12
+ // `config.base.json` and every write since is baseline + overlay. Overlaying the
13
+ // previous output instead would make removal impossible: drop `bakedManifest`
14
+ // from the platform root and yesterday's `manifestUrl` stays, pointing the shell
15
+ // at a file nothing writes any more — an empty shop with nothing in the diff to
16
+ // explain it. Reading a baseline makes both directions self-healing, and a
17
+ // `pnpm install` that replaces the package directory costs one restart, because
18
+ // the baseline is re-seeded from whatever the new package ships.
19
+ //
20
+ // One key is computed (`manifestUrl`, from the bake declaration — local knows
21
+ // where it put the file); the rest are the deployment's own `shellConfig`,
22
+ // passed through verbatim. A `shellConfig` that names a computed key fails the
23
+ // same way it does on AWS: a passthrough cannot redirect a key the platform
24
+ // decides, and silently resolving it either way points the shell somewhere
25
+ // unintended.
26
+
27
+ let log = ReventlessCore.Logger.fromEnv()
28
+
29
+ let fileName = "config.json"
30
+
31
+ // Not `config.json.base` — the shell's dist is served as a static directory, and
32
+ // a name that keeps the `.json` extension is the one a dev can open in a browser
33
+ // to see what the overlay started from.
34
+ let baselineFileName = "config.base.json"
35
+
36
+ let manifestUrlOf = (config: ReventlessInfra.Platform.bakedManifest): string =>
37
+ ReventlessCore.Platform_BakedManifest.urlForKey(config.key)
38
+
39
+ // Where a caller acting as a given role discovers from. Beside `manifestUrl`
40
+ // rather than replacing it: that key stays the default journey, which is what a
41
+ // caller matching no declared group gets and what every existing deployment has.
42
+ let journeyManifestsKey = "journeyManifestUrls"
43
+
44
+ let computedKeys = ["manifestUrl", journeyManifestsKey]
45
+
46
+ /**
47
+ The overlay this platform puts on top of the shipped `config.json`.
48
+
49
+ Separate from the write so the whole key set is reachable from a test, the same
50
+ reason `Util_ShellConfig.fields` exists apart from `deployPlatform`'s
51
+ `Pulumi.Output.apply` on AWS.
52
+ */
53
+ let overlay = (
54
+ ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
55
+ ~shellConfig: option<dict<JSON.t>>,
56
+ ): dict<JSON.t> => {
57
+ let out = Dict.make()
58
+
59
+ bakedManifest->Option.forEach(config => {
60
+ out->Dict.set("manifestUrl", JSON.Encode.string(manifestUrlOf(config)))
61
+ // Omitted entirely when nothing is declared, so a single-audience deployment
62
+ // writes the same key set it always did and a shell that has never heard of
63
+ // journeys sees no new keys.
64
+ switch config.journeys {
65
+ | None => ()
66
+ | Some([]) => ()
67
+ | Some(journeys) =>
68
+ let map = Dict.make()
69
+ journeys->Array.forEach(j =>
70
+ map->Dict.set(
71
+ j.group,
72
+ JSON.Encode.string(
73
+ ReventlessCore.Platform_BakedManifest.urlForKey(
74
+ Some(
75
+ j.key->Option.getOr(
76
+ ReventlessCore.Platform_BakedManifest.journeyKey(~group=j.group),
77
+ ),
78
+ ),
79
+ ),
80
+ ),
81
+ )
82
+ )
83
+ out->Dict.set(journeyManifestsKey, map->JSON.Encode.object)
84
+ }
85
+ })
86
+
87
+ shellConfig->Option.forEach(extra => {
88
+ let collisions = extra->Dict.keysToArray->Array.filter(k => computedKeys->Array.includes(k))
89
+ if collisions->Array.length > 0 {
90
+ JsError.throwWithMessage(
91
+ "host UI config.json: shellConfig sets key(s) the platform already computes — " ++
92
+ collisions->Array.join(", ") ++
93
+ ". Remove them from shellConfig; a passthrough cannot redirect a computed key.",
94
+ )
95
+ }
96
+ extra->Dict.forEachWithKey((v, k) => out->Dict.set(k, v))
97
+ })
98
+
99
+ out
100
+ }
101
+
102
+ let readObject = (~path: string, ~label: string): dict<JSON.t> =>
103
+ switch NodeFs.readFileSync(path)->JSON.parseOrThrow->JSON.Decode.object {
104
+ | Some(obj) => obj
105
+ | None => JsError.throwWithMessage(`host UI ${label}: ${path} is not a JSON object`)
106
+ | exception _ => JsError.throwWithMessage(`host UI ${label}: cannot read ${path} as JSON`)
107
+ }
108
+
109
+ /**
110
+ Merge the overlay onto the shipped `config.json` in the host shell's `dist/`.
111
+
112
+ A no-op when there is nothing to say and nothing was said before — a platform
113
+ that declares neither a bake nor any `shellConfig` never touches the file, so
114
+ its dev shell is byte-identical to one built before this module existed. Once
115
+ either is declared the write happens or fails loudly, for the reason
116
+ `BakedManifest.emit` gives: both failure modes it can hit are the deployment's
117
+ own mistake, and both produce the same silent symptom if swallowed.
118
+ */
119
+ let emit = (
120
+ ~bakedManifest: option<ReventlessInfra.Platform.bakedManifest>,
121
+ ~shellConfig: option<dict<JSON.t>>,
122
+ // Test seam. The baseline dance is the part of this module with state behind
123
+ // it — "boot twice and the second write still starts from the shipped file" is
124
+ // not a property a pure function can carry.
125
+ ~dir: option<string>=?,
126
+ ) => {
127
+ let overlay = overlay(~bakedManifest, ~shellConfig)
128
+
129
+ switch (
130
+ switch dir {
131
+ | Some(_) as given => given
132
+ | None => HostShellDist.dir()
133
+ }
134
+ ) {
135
+ | None =>
136
+ // Nothing declared and no shell installed is the ordinary case for a
137
+ // platform nobody points a browser at; only a declaration makes the missing
138
+ // package an error, and then it is the same error the bake gives.
139
+ if overlay->Dict.keysToArray->Array.length > 0 {
140
+ JsError.throwWithMessage(
141
+ `host UI config.json: cannot resolve ${HostShellDist.package} from ${NodeProcess.cwd()} — ` ++
142
+ `the local shell reads its config from that package's dist/, so declaring shell ` ++
143
+ `config without the package installed would write nothing and leave the shell ` ++
144
+ `configured as it shipped.`,
145
+ )
146
+ }
147
+ | Some(dir) =>
148
+ let path = NodePath.join([dir, fileName])
149
+ let baselinePath = NodePath.join([dir, baselineFileName])
150
+
151
+ // Seeded only when there is an overlay to apply. A platform that declares
152
+ // nothing must not leave a `config.base.json` behind for the next one to
153
+ // read as authoritative.
154
+ if !NodeFs.existsSync(baselinePath) && overlay->Dict.keysToArray->Array.length > 0 {
155
+ if !NodeFs.existsSync(path) {
156
+ JsError.throwWithMessage(
157
+ `host UI config.json: ${HostShellDist.package} ships no ${fileName} at ${dir} — ` ++
158
+ `there is no baseline to overlay, so the shell would boot with only the keys ` ++
159
+ `declared here and none of the ones it expects.`,
160
+ )
161
+ }
162
+ NodeFs.writeFileSync(baselinePath, NodeFs.readFileSync(path))
163
+ }
164
+
165
+ if NodeFs.existsSync(baselinePath) {
166
+ let merged = readObject(~path=baselinePath, ~label="config baseline")
167
+ overlay->Dict.forEachWithKey((v, k) => merged->Dict.set(k, v))
168
+ NodeFs.writeFileSync(path, JSON.stringify(merged->JSON.Encode.object, ~space=2))
169
+ let keys = overlay->Dict.keysToArray
170
+ log.info(
171
+ ~comp="ShellConfig",
172
+ keys->Array.length == 0
173
+ ? `restored ${fileName} from ${baselineFileName}: ${path}`
174
+ : `wrote ${fileName} with ${keys->Array.join(", ")}: ${path}`,
175
+ )
176
+ }
177
+ }
178
+ }