@bedrock-rbx/core 0.1.0-beta.10 → 0.1.0-beta.12

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.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { C as ConfigError, S as validateConfig, _ as StateConfig, a as ConfigEnvironmentUniverseId, b as UniverseOverlayWithoutId, c as DisplayNamePrefixConfig, d as GistStateConfig, f as PlaceEntry, g as ResourceEntryByKind, h as ResolvedUniverseEntry, i as Config, l as EnvironmentEntry, m as ResolvedPlaceEntry, n as ConfigInput, o as ConfigRootUniverseId, p as ResolvedConfig, r as defineConfig, s as DeveloperProductEntry, t as ConfigContext, u as GamePassEntry, v as UniverseEntry, w as ConfigValidationIssue, x as isGistStateConfig, y as UniverseOverlayWithId } from "./define-config-87u2jqjM.mjs";
2
- import { Type as Type$1 } from "arktype";
3
2
  import { OpenCloudError, OpenCloudError as OpenCloudError$1, Result, Result as Result$1 } from "@bedrock-rbx/ocale";
3
+ import { Type as Type$1 } from "arktype";
4
4
  import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
5
5
  import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
6
6
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
@@ -708,17 +708,16 @@ type Prettify<T> = { readonly [K in keyof T]: T[K] };
708
708
  */
709
709
  declare const UNIVERSE_SINGLETON_KEY: ResourceKey;
710
710
  //#endregion
711
- //#region src/ports/resource-driver.d.ts
711
+ //#region src/core/state.d.ts
712
712
  /**
713
- * Plugin contract for a resource adapter: the interface a third-party author
714
- * implements to teach Bedrock how to reconcile one {@link ResourceKind} against
715
- * its upstream API.
713
+ * In-memory state snapshot for one environment.
716
714
  *
717
- * `ResourceDriver<K>` is a *driven* (secondary) port in hexagonal terms; the
718
- * name "driver" follows Terraform, Pulumi, and Mantle IaC convention for a
719
- * component that talks to a specific resource API.
715
+ * The on-disk JSON wraps this shape with a `$bedrock: { version: N }` envelope.
716
+ * Adapters flatten the envelope on read and re-wrap it on write; nothing
717
+ * outside an adapter sees the `$bedrock` key.
720
718
  *
721
- * @template K - The {@link ResourceKind} discriminator this driver handles.
719
+ * `version` is a literal so a breaking schema change is a compile-time type
720
+ * shift rather than a silently accepted runtime value.
722
721
  *
723
722
  * @example
724
723
  *
@@ -727,820 +726,708 @@ declare const UNIVERSE_SINGLETON_KEY: ResourceKey;
727
726
  * asResourceKey,
728
727
  * asRobloxAssetId,
729
728
  * asSha256Hex,
730
- * type ResourceDriver,
729
+ * type BedrockState,
731
730
  * } from "@bedrock-rbx/core";
732
731
  *
733
- * const gamePassDriver: ResourceDriver<"gamePass"> = {
734
- * async create(desired) {
735
- * return {
736
- * data: {
737
- * ...desired,
738
- * outputs: {
739
- * assetId: asRobloxAssetId("9876543210"),
740
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
741
- * },
732
+ * const state: BedrockState = {
733
+ * environment: "production",
734
+ * resources: [
735
+ * {
736
+ * description: "Grants VIP perks.",
737
+ * icon: { "en-us": "assets/vip-icon.png" },
738
+ * iconFileHashes: {
739
+ * "en-us": asSha256Hex(
740
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
741
+ * ),
742
742
  * },
743
- * success: true,
744
- * };
745
- * },
743
+ * key: asResourceKey("vip-pass"),
744
+ * kind: "gamePass",
745
+ * name: "VIP Pass",
746
+ * outputs: {
747
+ * assetId: asRobloxAssetId("9876543210"),
748
+ * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
749
+ * },
750
+ * price: 500,
751
+ * },
752
+ * ],
753
+ * version: 1,
746
754
  * };
747
755
  *
748
- * return gamePassDriver
749
- * .create({
750
- * description: "Grants VIP perks.",
751
- * icon: { "en-us": "assets/vip-icon.png" },
752
- * iconFileHashes: {
753
- * "en-us": asSha256Hex(
754
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
755
- * ),
756
- * },
757
- * key: asResourceKey("vip-pass"),
758
- * kind: "gamePass",
759
- * name: "VIP Pass",
760
- * price: undefined,
761
- * })
762
- * .then((result) => {
763
- * expect(result.success).toBeTrue();
764
- * if (result.success) {
765
- * expect(result.data.outputs.assetId).toBe("9876543210");
766
- * }
767
- * });
756
+ * expect(state.version).toBe(1);
757
+ * expect(state.resources).toHaveLength(1);
768
758
  * ```
769
759
  */
770
- interface ResourceDriver<K extends ResourceKind> {
771
- /**
772
- * Create the resource upstream from its desired state and return the
773
- * resulting current state (desired fields + Roblox-assigned outputs).
774
- */
775
- create(desired: Extract<ResourceDesiredState, {
776
- kind: K;
777
- }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
778
- /**
779
- * Reconcile an upstream resource whose managed content has drifted from its
780
- * desired state. Receives the last-known current state so the driver can
781
- * compute a minimal patch (or no-op upstream, for file-backed kinds where
782
- * republishing is unconditional).
783
- *
784
- * Optional. Drivers whose upstream API has no update operation omit this
785
- * method; `applyOps` surfaces an `updateUnsupported` error at dispatch time
786
- * instead.
787
- */
788
- update?(current: ResourceCurrentState<K>, desired: Extract<ResourceDesiredState, {
789
- kind: K;
790
- }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
760
+ interface BedrockState {
761
+ /** Environment name this snapshot belongs to (e.g. `"production"`, `"staging"`). */
762
+ readonly environment: string;
763
+ /** Current state of every resource Bedrock manages in this environment. */
764
+ readonly resources: ReadonlyArray<ResourceCurrentState>;
765
+ /** Schema-version literal; bumped only for breaking changes to the on-disk format. */
766
+ readonly version: 1;
791
767
  }
792
768
  /**
793
- * Polymorphic dispatch table keyed by {@link ResourceKind}, mapping each kind
794
- * to the {@link ResourceDriver} that handles it. `applyOps` indexes the
795
- * registry by `op.desired.kind` to reach the matching driver with full type
796
- * safety: adding a new kind to `ResourceDesiredState` is a compile error until
797
- * a matching registry entry is supplied.
769
+ * Failure surfaced by a `StatePort` when a state file exists but cannot be
770
+ * trusted: corrupt JSON, schema failure, or an unknown `$bedrock.version`.
771
+ *
772
+ * Narrow on `kind` rather than using `instanceof`: `StateError` is plain data,
773
+ * not a thrown error subclass.
798
774
  *
799
775
  * @example
800
776
  *
801
777
  * ```ts
802
- * import { OpenCloudError, type DriverRegistry } from "@bedrock-rbx/core";
778
+ * import type { StateError } from "@bedrock-rbx/core";
803
779
  *
804
- * const registry: DriverRegistry = {
805
- * gamePass: {
806
- * async create() {
807
- * return { err: new OpenCloudError("not implemented"), success: false };
808
- * },
809
- * },
810
- * place: {
811
- * async create() {
812
- * return { err: new OpenCloudError("not implemented"), success: false };
813
- * },
814
- * },
815
- * universe: {
816
- * async create() {
817
- * return { err: new OpenCloudError("not implemented"), success: false };
818
- * },
819
- * },
820
- * developerProduct: {
821
- * async create() {
822
- * return { err: new OpenCloudError("not implemented"), success: false };
823
- * },
824
- * },
780
+ * const err: StateError = {
781
+ * file: ".bedrock/state/production.json",
782
+ * kind: "stateError",
783
+ * reason: "Corrupt JSON: unexpected token at line 1 column 5",
825
784
  * };
826
785
  *
827
- * expect(registry.gamePass).toBeObject();
786
+ * expect(err.kind).toBe("stateError");
828
787
  * ```
829
788
  */
830
- type DriverRegistry = { [K in ResourceKind]: ResourceDriver<K> };
789
+ interface StateError {
790
+ /** Adapter-specific path or identifier of the file that failed to parse. */
791
+ readonly file: string;
792
+ /** Literal discriminator for narrowing. */
793
+ readonly kind: "stateError";
794
+ /** Human-readable explanation of why the file could not be trusted. */
795
+ readonly reason: string;
796
+ }
831
797
  //#endregion
832
- //#region src/adapters/developer-product-driver.d.ts
798
+ //#region src/core/migrate/migration-report.d.ts
833
799
  /**
834
- * Dependencies of `createDeveloperProductDriver`. `universeId` is captured
835
- * at construction time (matching `GamePassDriverDeps`) so each driver
836
- * instance is bound to a single universe; multi-universe deploys construct
837
- * one driver per universe. `readFile` exists on the driver (not upstream
838
- * in shell) because icon hashes flow through `diff` but bytes do not.
839
- *
840
- * @example
841
- *
842
- * ```ts
843
- * import type { HttpClient } from "@bedrock-rbx/ocale";
844
- * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
845
- * import { asRobloxAssetId, type DeveloperProductDriverDeps } from "@bedrock-rbx/core";
846
- *
847
- * const httpClient: HttpClient = {
848
- * async request() {
849
- * return { data: { body: {}, headers: {}, status: 200 }, success: true };
850
- * },
851
- * };
852
- *
853
- * const deps: DeveloperProductDriverDeps = {
854
- * client: new DeveloperProductsClient({
855
- * apiKey: "rbx-your-key",
856
- * httpClient,
857
- * sleep: async () => {},
858
- * }),
859
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
860
- * universeId: asRobloxAssetId("1234567890"),
861
- * };
800
+ * Per-environment in-memory state snapshot map keyed by environment name.
862
801
  *
863
- * expect(deps.universeId).toBe("1234567890");
864
- * ```
802
+ * `Record` rather than the PRD-suggested `Map` so the field survives
803
+ * `JSON.stringify` for downstream logging and parallel-iterates cleanly
804
+ * with `Config.environments` (which is itself a `Record`).
865
805
  */
866
- interface DeveloperProductDriverDeps {
867
- /** Configured developer-products client from `@bedrock-rbx/ocale/developer-products`. */
868
- readonly client: DeveloperProductsClient;
869
- /** Reads icon bytes for upload; rejections propagate out of `create` and `update`. */
870
- readonly readFile: (path: string) => Promise<Uint8Array>;
871
- /** Universe that owns every developer product this driver creates. */
872
- readonly universeId: RobloxAssetId;
806
+ type StatesByEnvironment = Readonly<Record<string, BedrockState>>;
807
+ /**
808
+ * Aggregate counts for the four `MigrationWarning` kinds. Computed by
809
+ * folding `MigrationReport.warnings`; lets a CI gate skim totals without
810
+ * iterating every entry. All fields are zero on a clean migration.
811
+ */
812
+ interface MigrationSummary {
813
+ /** Number of `ambiguous` warnings emitted. */
814
+ readonly ambiguousCount: number;
815
+ /** Number of `blocked` warnings emitted. */
816
+ readonly blockedCount: number;
817
+ /** Number of `deferred` warnings emitted. */
818
+ readonly deferredCount: number;
819
+ /** Number of `interpretive` warnings emitted. */
820
+ readonly interpretiveCount: number;
873
821
  }
874
822
  /**
875
- * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
876
- * that maps a desired-state entry to an ocale create or update call and the
877
- * response back to a `ResourceCurrentState<"developerProduct">`. The
878
- * `update` path consumes the upstream `204 No Content` response and
879
- * synthesizes the post-update `ResourceCurrentState` from `desired` plus
880
- * the existing `current.outputs`, carrying `iconImageAssetId` forward when
881
- * present.
823
+ * Discriminated union describing one observation the migrator made about a
824
+ * Mantle field that did not flow straight into bedrock config or state.
882
825
  *
883
- * Upstream `OpenCloudError` results pass through as `Result` failures.
826
+ * - `deferred` - bedrock plans to support the field once the matching
827
+ * resource kind ships; the migration is non-destructive.
828
+ * - `blocked` - no Open Cloud writable endpoint exists; Mantle was using a
829
+ * cookie or legacy API that bedrock cannot call.
830
+ * - `interpretive` - the migrator applied a documented mapping rule
831
+ * (cross-field fold, list-to-flag rewrite, URL-domain dispatch). Each
832
+ * rule names the bedrock-side path it produced and the rule it followed
833
+ * so the user can audit.
834
+ * - `ambiguous` - the field is mappable but unsafe to act on without
835
+ * user input; the migrator carries the hint forward instead of guessing.
884
836
  *
885
- * @param deps - Injected ocale client and owning universe.
886
- * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
837
+ * Every variant carries `mantlePath` rooted at the environment so the
838
+ * report is searchable (for example
839
+ * `production.experienceConfiguration_singleton.genre`).
840
+ */
841
+ type MigrationWarning = {
842
+ readonly bedrockPath: string;
843
+ readonly kind: "interpretive";
844
+ readonly mantlePath: string;
845
+ readonly rule: string;
846
+ } | {
847
+ readonly hint: string;
848
+ readonly kind: "ambiguous";
849
+ readonly mantlePath: string;
850
+ } | {
851
+ readonly kind: "blocked";
852
+ readonly mantlePath: string;
853
+ readonly reason: string;
854
+ } | {
855
+ readonly kind: "deferred";
856
+ readonly mantlePath: string;
857
+ readonly reason: string;
858
+ };
859
+ /**
860
+ * Failure surfaced by `migrateMantleState`. Plain-data discriminated
861
+ * union; narrow on `kind` rather than using `instanceof`.
887
862
  *
888
- * @example
863
+ * - `stateFileNotFound` - `deps.readFile` threw with `code: "ENOENT"`;
864
+ * the file does not exist at the supplied path. Permission failures
865
+ * (`EACCES`, `EPERM`) and other I/O errors are re-thrown rather than
866
+ * wrapped here, so callers see the original code on the rejection.
867
+ * - `stateParseFailed` - the YAML parser refused the file's contents.
868
+ * - `unsupportedMantleStateVersion` - the parsed file's `version` field is
869
+ * not one of the values in `supported`. V0.1 supports `"6"` only; older
870
+ * versions need to be upgraded with any recent Mantle release first.
871
+ * - `primaryEnvironmentRequired` - the input has more than one environment
872
+ * and `deps.primaryEnvironment` was not supplied. The migrator refuses
873
+ * to silently pick a winner.
874
+ * - `primaryEnvironmentNotFound` - `deps.primaryEnvironment` does not match
875
+ * any environment in the input.
876
+ * - `internalError` - the migrator's own emitted config failed
877
+ * `validateConfig`; `cause` carries the `ConfigError` so callers can
878
+ * inspect each `validationFailed` issue. Defensive bug catcher that
879
+ * callers should never see in practice.
880
+ */
881
+ type MigrateError = {
882
+ readonly available: ReadonlyArray<string>;
883
+ readonly kind: "primaryEnvironmentNotFound";
884
+ readonly primary: string;
885
+ } | {
886
+ readonly available: ReadonlyArray<string>;
887
+ readonly kind: "primaryEnvironmentRequired";
888
+ } | {
889
+ readonly cause: ConfigError;
890
+ readonly kind: "internalError";
891
+ readonly reason: string;
892
+ } | {
893
+ readonly found: string;
894
+ readonly kind: "unsupportedMantleStateVersion";
895
+ readonly supported: ReadonlyArray<string>;
896
+ } | {
897
+ readonly kind: "stateFileNotFound";
898
+ readonly path: string;
899
+ } | {
900
+ readonly kind: "stateParseFailed";
901
+ readonly path: string;
902
+ readonly reason: string;
903
+ };
904
+ /**
905
+ * Result returned by a successful `migrateMantleState` call.
889
906
  *
890
- * ```ts
891
- * import type { HttpClient } from "@bedrock-rbx/ocale";
892
- * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
893
- * import {
894
- * asResourceKey,
895
- * asRobloxAssetId,
896
- * createDeveloperProductDriver,
897
- * } from "@bedrock-rbx/core";
907
+ * `config` is the bedrock-shape projection of the Mantle state file,
908
+ * already validated against the runtime schema (a failure to validate
909
+ * surfaces as `MigrateError.internalError`, not as a returned report).
898
910
  *
899
- * const httpClient: HttpClient = {
900
- * async request() {
901
- * return {
902
- * data: {
903
- * body: {
904
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
905
- * description: "Stocks the player up with 1,000 premium gems.",
906
- * iconImageAssetId: null,
907
- * isForSale: false,
908
- * isImmutable: false,
909
- * name: "Gem Pack",
910
- * priceInformation: null,
911
- * productId: 9_876_543_210,
912
- * storePageEnabled: false,
913
- * universeId: 1_234_567_890,
914
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
915
- * },
916
- * headers: {},
917
- * status: 200,
918
- * },
919
- * success: true,
920
- * };
921
- * },
922
- * };
911
+ * `configFileContent` is the same data rendered as TypeScript source
912
+ * (`defineConfig({...})`) so the caller can write it straight to disk
913
+ * without re-serializing. `loadConfig` round-trips it cleanly.
923
914
  *
924
- * const driver = createDeveloperProductDriver({
925
- * client: new DeveloperProductsClient({
926
- * apiKey: "rbx-your-key",
927
- * httpClient,
928
- * sleep: async () => {},
929
- * }),
930
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
931
- * universeId: asRobloxAssetId("1234567890"),
932
- * });
915
+ * `statesByEnvironment` carries one in-memory `BedrockState` per
916
+ * environment from the input. Truthful per environment (no factorization)
917
+ * so `bedrock deploy --env=<env>` produces zero ops on first run.
933
918
  *
934
- * return driver
935
- * .create({
936
- * description: "Stocks the player up with 1,000 premium gems.",
937
- * isRegionalPricingEnabled: undefined,
938
- * key: asResourceKey("gem-pack"),
939
- * kind: "developerProduct",
940
- * name: "Gem Pack",
941
- * price: undefined,
942
- * storePageEnabled: undefined,
943
- * })
944
- * .then((result) => {
945
- * expect(result.success).toBeTrue();
946
- * if (result.success) {
947
- * expect(result.data.outputs.productId).toBe("9876543210");
948
- * }
949
- * });
950
- * ```
919
+ * `warnings` and `summary` describe what the migrator did *not* migrate
920
+ * verbatim, classified for triage. The skeleton emits no warnings.
951
921
  */
952
- declare function createDeveloperProductDriver(deps: DeveloperProductDriverDeps): ResourceDriver<"developerProduct">;
922
+ interface MigrationReport {
923
+ /** Validated bedrock config built from the Mantle state file. */
924
+ readonly config: Config;
925
+ /** Same `config` rendered as TypeScript source the caller can write to disk. */
926
+ readonly configFileContent: string;
927
+ /** One `BedrockState` per environment in the input, keyed by environment name. */
928
+ readonly statesByEnvironment: StatesByEnvironment;
929
+ /** Aggregate counts of `warnings` by kind. */
930
+ readonly summary: MigrationSummary;
931
+ /** One entry per non-trivial mapping or skipped Mantle field. */
932
+ readonly warnings: ReadonlyArray<MigrationWarning>;
933
+ }
953
934
  //#endregion
954
- //#region src/adapters/game-pass-driver.d.ts
935
+ //#region src/ports/state-port.d.ts
955
936
  /**
956
- * `universeId` is captured at construction time rather than on
957
- * `GamePassDesiredState` so state files round-trip with Mantle's `PassInputs`
958
- * shape. `readFile` exists on the driver (not upstream in shell) because icon
959
- * hashes flow through `diff` but bytes do not.
937
+ * Plugin contract for persisting deployment state: the interface an adapter
938
+ * (Gist, local filesystem, cloud object store) implements to let Bedrock load
939
+ * and save its per-environment {@link BedrockState} snapshot.
940
+ *
941
+ * `StatePort` is a *driven* (secondary) port in hexagonal terms, following the
942
+ * same naming convention as {@link "./resource-driver".ResourceDriver}.
960
943
  *
961
944
  * @example
962
945
  *
963
946
  * ```ts
964
- * import type { HttpClient } from "@bedrock-rbx/ocale";
965
- * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
966
- * import { asRobloxAssetId, type GamePassDriverDeps } from "@bedrock-rbx/core";
947
+ * import type { BedrockState, StatePort } from "@bedrock-rbx/core";
967
948
  *
968
- * const httpClient: HttpClient = {
969
- * async request() {
970
- * return { data: { body: {}, headers: {}, status: 200 }, success: true };
971
- * },
972
- * };
949
+ * const store = new Map<string, BedrockState>();
973
950
  *
974
- * const deps: GamePassDriverDeps = {
975
- * client: new GamePassesClient({
976
- * apiKey: "rbx-your-key",
977
- * httpClient,
978
- * sleep: async () => {},
979
- * }),
980
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
981
- * universeId: asRobloxAssetId("1234567890"),
951
+ * const statePort: StatePort = {
952
+ * async read(environment) {
953
+ * return { data: store.get(environment), success: true };
954
+ * },
955
+ * async write(state) {
956
+ * store.set(state.environment, state);
957
+ * return { data: undefined, success: true };
958
+ * },
982
959
  * };
983
960
  *
984
- * expect(deps.universeId).toBe("1234567890");
961
+ * return statePort
962
+ * .read("production")
963
+ * .then((firstRead) => {
964
+ * expect(firstRead.success).toBeTrue();
965
+ * if (firstRead.success) {
966
+ * expect(firstRead.data).toBeUndefined();
967
+ * }
968
+ * return statePort.write({
969
+ * environment: "production",
970
+ * resources: [],
971
+ * version: 1,
972
+ * });
973
+ * })
974
+ * .then((writeResult) => {
975
+ * expect(writeResult.success).toBeTrue();
976
+ * return statePort.read("production");
977
+ * })
978
+ * .then((secondRead) => {
979
+ * expect(secondRead.success).toBeTrue();
980
+ * if (secondRead.success && secondRead.data !== undefined) {
981
+ * expect(secondRead.data.environment).toBe("production");
982
+ * expect(secondRead.data.resources).toBeEmpty();
983
+ * }
984
+ * });
985
985
  * ```
986
986
  */
987
- interface GamePassDriverDeps {
988
- /** Configured game-passes client from `@bedrock-rbx/ocale/game-passes`. */
989
- readonly client: GamePassesClient;
990
- /** Reads icon bytes for upload; rejections propagate out of `create`. */
991
- readonly readFile: (path: string) => Promise<Uint8Array>;
992
- /** Universe that owns every game pass this driver creates. */
993
- readonly universeId: RobloxAssetId;
987
+ interface StatePort {
988
+ /**
989
+ * Reads state for the given environment.
990
+ *
991
+ * - Returns `Ok(undefined)` when no state file exists (legitimate first deploy).
992
+ * - Returns `Err(StateError)` when a file exists but cannot be parsed
993
+ * (corrupt JSON, schema failure, unknown `$bedrock.version`).
994
+ *
995
+ * Never silently falls back to empty state: a malformed file that collapsed
996
+ * to `{ resources: [] }` would cause the next apply to re-create every
997
+ * resource on Roblox.
998
+ */
999
+ read(environment: string): Promise<Result$1<BedrockState | undefined, StateError>>;
1000
+ /** Writes state for the given environment, overwriting any existing file. */
1001
+ write(state: BedrockState): Promise<Result$1<void, StateError>>;
994
1002
  }
1003
+ //#endregion
1004
+ //#region src/adapters/gist-state-adapter.d.ts
995
1005
  /**
996
- * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
997
- * a desired-state entry to an ocale create call and the response back to a
998
- * `ResourceCurrentState<"gamePass">`.
999
- *
1000
- * Upstream `OpenCloudError` results pass through as `Result` failures.
1001
- * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
1002
- * shape and propagate as promise rejections; shell callers are expected to
1003
- * translate them if a unified error surface is required.
1006
+ * Minimal `fetch`-compatible signature the adapter needs, narrower than
1007
+ * `typeof globalThis.fetch` so test fakes do not have to stub runtime
1008
+ * extensions such as `fetch.preconnect`.
1009
+ */
1010
+ type GistFetch = (input: globalThis.Request | string | URL, init?: RequestInit) => Promise<Response>;
1011
+ /**
1012
+ * Configuration for {@link createGistStateAdapter}.
1013
+ */
1014
+ interface GistStateAdapterDeps {
1015
+ /** Injection seam for tests; defaults to `globalThis.fetch`. */
1016
+ readonly fetch?: GistFetch | undefined;
1017
+ /** ID of an existing GitHub Gist that holds this project's state files. */
1018
+ readonly gistId: string;
1019
+ /**
1020
+ * Injection seam for retry backoff timing; defaults to a `setTimeout`-based
1021
+ * promise. Tests pass a fake to keep retry assertions deterministic.
1022
+ */
1023
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
1024
+ /** GitHub token (fine-grained PAT or classic PAT) with gist read/write scope. */
1025
+ readonly token: string;
1026
+ }
1027
+ /**
1028
+ * Build a `StatePort` that persists Bedrock state in a GitHub Gist.
1004
1029
  *
1005
- * @param deps - Injected ocale client, file reader, and owning universe.
1006
- * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
1007
- * @throws Whatever `deps.readFile` rejects with.
1030
+ * One gist holds one file per environment, named `state.<env>.json`. The
1031
+ * adapter authenticates with a user-supplied token and speaks the GitHub
1032
+ * REST API directly; no SDK dependency.
1008
1033
  *
1009
1034
  * @example
1010
1035
  *
1011
1036
  * ```ts
1012
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1013
- * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
1014
- * import {
1015
- * asResourceKey,
1016
- * asRobloxAssetId,
1017
- * asSha256Hex,
1018
- * createGamePassDriver,
1019
- * } from "@bedrock-rbx/core";
1020
- *
1021
- * const httpClient: HttpClient = {
1022
- * async request() {
1023
- * return {
1024
- * data: {
1025
- * body: {
1026
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
1027
- * description: "Grants VIP perks.",
1028
- * gamePassId: 9_876_543_210,
1029
- * iconAssetId: 1_122_334_455,
1030
- * isForSale: true,
1031
- * name: "VIP Pass",
1032
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1033
- * },
1034
- * headers: {},
1035
- * status: 200,
1036
- * },
1037
- * success: true,
1038
- * };
1039
- * },
1040
- * };
1037
+ * import { createGistStateAdapter } from "@bedrock-rbx/core";
1041
1038
  *
1042
- * const driver = createGamePassDriver({
1043
- * client: new GamePassesClient({
1044
- * apiKey: "rbx-your-key",
1045
- * httpClient,
1046
- * sleep: async () => {},
1047
- * }),
1048
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1049
- * universeId: asRobloxAssetId("1234567890"),
1039
+ * const port = createGistStateAdapter({
1040
+ * fetch: async () =>
1041
+ * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1042
+ * gistId: "abc123def456",
1043
+ * token: "ghp_example",
1050
1044
  * });
1051
1045
  *
1052
- * return driver
1053
- * .create({
1054
- * description: "Grants VIP perks.",
1055
- * icon: { "en-us": "assets/vip-icon.png" },
1056
- * iconFileHashes: {
1057
- * "en-us": asSha256Hex(
1058
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1059
- * ),
1060
- * },
1061
- * key: asResourceKey("vip-pass"),
1062
- * kind: "gamePass",
1063
- * name: "VIP Pass",
1064
- * price: 500,
1065
- * })
1066
- * .then((result) => {
1067
- * expect(result.success).toBeTrue();
1068
- * if (result.success) {
1069
- * expect(result.data.outputs.assetId).toBe("9876543210");
1070
- * }
1071
- * });
1046
+ * return port.read("production").then((result) => {
1047
+ * expect(result.success).toBeTrue();
1048
+ * if (result.success) {
1049
+ * expect(result.data).toBeUndefined();
1050
+ * }
1051
+ * });
1072
1052
  * ```
1053
+ *
1054
+ * @param deps - Gist ID, GitHub token, and optional fetch override.
1055
+ * @returns A `StatePort` ready to be passed to `deploy()`.
1073
1056
  */
1074
- declare function createGamePassDriver(deps: GamePassDriverDeps): ResourceDriver<"gamePass">;
1057
+ declare function createGistStateAdapter(deps: GistStateAdapterDeps): StatePort;
1075
1058
  //#endregion
1076
- //#region src/core/state.d.ts
1059
+ //#region src/shell/build-state-port.d.ts
1077
1060
  /**
1078
- * In-memory state snapshot for one environment.
1079
- *
1080
- * The on-disk JSON wraps this shape with a `$bedrock: { version: N }` envelope.
1081
- * Adapters flatten the envelope on read and re-wrap it on write; nothing
1082
- * outside an adapter sees the `$bedrock` key.
1083
- *
1084
- * `version` is a literal so a breaking schema change is a compile-time type
1085
- * shift rather than a silently accepted runtime value.
1061
+ * Failure surfaced when a default-constructed adapter cannot find a
1062
+ * required environment variable. The deploy boundary wraps this in a
1063
+ * `DeployError` so the caller sees a typed Result instead of an
1064
+ * exception or a confusing downstream HTTP error.
1065
+ */
1066
+ interface MissingCredentialError {
1067
+ /** Literal discriminator for narrowing. */
1068
+ readonly kind: "missingCredential";
1069
+ /** Whether the credential was needed for the state backend or the driver registry. */
1070
+ readonly purpose: "registry" | "stateBackend";
1071
+ /** Environment variable name the default-construction path tried to read. */
1072
+ readonly variable: string;
1073
+ }
1074
+ /**
1075
+ * Failure surfaced when the dispatch helper sees a `state.backend` value
1076
+ * it does not recognize. The hint points at `opts.statePort` so the
1077
+ * caller can pass a custom adapter as an escape hatch.
1078
+ */
1079
+ interface UnsupportedBackendError {
1080
+ /** Backend name read from `state.backend`. */
1081
+ readonly backend: string;
1082
+ /** Suggested escape hatch routed back to the caller. */
1083
+ readonly hint: string;
1084
+ /** Literal discriminator for narrowing. */
1085
+ readonly kind: "unsupportedBackend";
1086
+ }
1087
+ /** Inputs for {@link buildStatePort}. */
1088
+ interface BuildStatePortDeps {
1089
+ /** Optional `fetch` seam plumbed through to the gist adapter for tests. */
1090
+ readonly fetch?: GistFetch | undefined;
1091
+ /** Reads an environment variable; injected so tests stay free of `process.env`. */
1092
+ readonly getEnv: (name: string) => string | undefined;
1093
+ /** Resolved state configuration for the target environment. */
1094
+ readonly stateConfig: StateConfig;
1095
+ }
1096
+ /**
1097
+ * Construct a `StatePort` from a resolved `StateConfig`. Dispatches on
1098
+ * `stateConfig.backend` to the matching builtin adapter; reads the
1099
+ * required credential from `getEnv` and surfaces `missingCredential` or
1100
+ * `unsupportedBackend` as typed Results.
1086
1101
  *
1087
1102
  * @example
1088
1103
  *
1089
1104
  * ```ts
1090
- * import {
1091
- * asResourceKey,
1092
- * asRobloxAssetId,
1093
- * asSha256Hex,
1094
- * type BedrockState,
1095
- * } from "@bedrock-rbx/core";
1105
+ * import { buildStatePort } from "@bedrock-rbx/core";
1096
1106
  *
1097
- * const state: BedrockState = {
1098
- * environment: "production",
1099
- * resources: [
1100
- * {
1101
- * description: "Grants VIP perks.",
1102
- * icon: { "en-us": "assets/vip-icon.png" },
1103
- * iconFileHashes: {
1104
- * "en-us": asSha256Hex(
1105
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1106
- * ),
1107
- * },
1108
- * key: asResourceKey("vip-pass"),
1109
- * kind: "gamePass",
1110
- * name: "VIP Pass",
1111
- * outputs: {
1112
- * assetId: asRobloxAssetId("9876543210"),
1113
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
1114
- * },
1115
- * price: 500,
1116
- * },
1117
- * ],
1118
- * version: 1,
1119
- * };
1107
+ * const port = buildStatePort({
1108
+ * fetch: async () =>
1109
+ * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1110
+ * getEnv: (name) => (name === "GITHUB_TOKEN" ? "ghp_example" : undefined),
1111
+ * stateConfig: { backend: "gist", gistId: "abc123" },
1112
+ * });
1120
1113
  *
1121
- * expect(state.version).toBe(1);
1122
- * expect(state.resources).toHaveLength(1);
1114
+ * expect(port.success).toBeTrue();
1123
1115
  * ```
1116
+ *
1117
+ * @param deps - Resolved state config plus credential-injection seams.
1118
+ * @returns A `StatePort` on success, or a typed Err describing the
1119
+ * missing credential or the unsupported backend.
1124
1120
  */
1125
- interface BedrockState {
1126
- /** Environment name this snapshot belongs to (e.g. `"production"`, `"staging"`). */
1121
+ declare function buildStatePort(deps: BuildStatePortDeps): Result$1<StatePort, MissingCredentialError | UnsupportedBackendError>;
1122
+ //#endregion
1123
+ //#region src/core/resolve-state-config.d.ts
1124
+ /**
1125
+ * Failure surfaced when no `StateConfig` is configured for the requested
1126
+ * environment. The shell layer wraps this in a `DeployError` when default
1127
+ * state-port construction is requested but the project has not declared
1128
+ * where state should live.
1129
+ */
1130
+ interface StateNotConfiguredError {
1131
+ /** Environment that the resolver was called against. */
1127
1132
  readonly environment: string;
1128
- /** Current state of every resource Bedrock manages in this environment. */
1129
- readonly resources: ReadonlyArray<ResourceCurrentState>;
1130
- /** Schema-version literal; bumped only for breaking changes to the on-disk format. */
1131
- readonly version: 1;
1133
+ /** Literal discriminator for narrowing. */
1134
+ readonly kind: "stateNotConfigured";
1132
1135
  }
1133
1136
  /**
1134
- * Failure surfaced by a `StatePort` when a state file exists but cannot be
1135
- * trusted: corrupt JSON, schema failure, or an unknown `$bedrock.version`.
1136
- *
1137
- * Narrow on `kind` rather than using `instanceof`: `StateError` is plain data,
1138
- * not a thrown error subclass.
1137
+ * Minimal structural input the state resolver needs. Both `Config`
1138
+ * (pre-merge, discriminated XOR union) and `ResolvedConfig` (post-merge)
1139
+ * satisfy this shape, so callers can route either in without coupling
1140
+ * the resolver to the discriminated-union arms.
1141
+ */
1142
+ interface StateResolutionInputs {
1143
+ readonly environments: Record<string, undefined | {
1144
+ readonly state?: StateConfig;
1145
+ }>;
1146
+ readonly state?: StateConfig;
1147
+ }
1148
+ /**
1149
+ * Pick the `StateConfig` that applies to `environment`. Per-environment
1150
+ * overrides win over the root block; if neither is present, returns
1151
+ * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
1152
+ * error instead of silently falling back.
1139
1153
  *
1154
+ * @param config - Validated project config.
1155
+ * @param environment - Target environment name.
1156
+ * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
1157
+ * neither the environment override nor the root block is set.
1140
1158
  * @example
1141
1159
  *
1142
1160
  * ```ts
1143
- * import type { StateError } from "@bedrock-rbx/core";
1161
+ * import { resolveStateConfig } from "@bedrock-rbx/core";
1144
1162
  *
1145
- * const err: StateError = {
1146
- * file: ".bedrock/state/production.json",
1147
- * kind: "stateError",
1148
- * reason: "Corrupt JSON: unexpected token at line 1 column 5",
1149
- * };
1163
+ * const result = resolveStateConfig(
1164
+ * {
1165
+ * state: { backend: "gist", gistId: "root-gist" },
1166
+ * environments: {
1167
+ * production: { state: { backend: "gist", gistId: "prod-gist" } },
1168
+ * },
1169
+ * },
1170
+ * "production",
1171
+ * );
1150
1172
  *
1151
- * expect(err.kind).toBe("stateError");
1173
+ * expect(result.success).toBeTrue();
1174
+ * if (result.success) {
1175
+ * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
1176
+ * }
1152
1177
  * ```
1153
1178
  */
1154
- interface StateError {
1155
- /** Adapter-specific path or identifier of the file that failed to parse. */
1156
- readonly file: string;
1179
+ declare function resolveStateConfig(config: StateResolutionInputs, environment: string): Result$1<StateConfig, StateNotConfiguredError>;
1180
+ //#endregion
1181
+ //#region src/core/select-environment.d.ts
1182
+ /**
1183
+ * Failure surfaced when `selectEnvironment` is asked for an environment
1184
+ * name that is not a key of `config.environments`. Carries the list of
1185
+ * declared names so callers can render a "did you mean?" hint or a
1186
+ * close-match suggestion.
1187
+ */
1188
+ interface UnknownEnvironmentError {
1189
+ /** Environment names that the config actually declared. */
1190
+ readonly declared: ReadonlyArray<string>;
1191
+ /** Environment name the caller asked for. */
1192
+ readonly environment: string;
1157
1193
  /** Literal discriminator for narrowing. */
1158
- readonly kind: "stateError";
1159
- /** Human-readable explanation of why the file could not be trusted. */
1160
- readonly reason: string;
1194
+ readonly kind: "unknownEnvironment";
1161
1195
  }
1162
- //#endregion
1163
- //#region src/ports/state-port.d.ts
1164
1196
  /**
1165
- * Plugin contract for persisting deployment state: the interface an adapter
1166
- * (Gist, local filesystem, cloud object store) implements to let Bedrock load
1167
- * and save its per-environment {@link BedrockState} snapshot.
1168
- *
1169
- * `StatePort` is a *driven* (secondary) port in hexagonal terms, following the
1170
- * same naming convention as {@link "./resource-driver".ResourceDriver}.
1171
- *
1172
- * @example
1173
- *
1174
- * ```ts
1175
- * import type { BedrockState, StatePort } from "@bedrock-rbx/core";
1176
- *
1177
- * const store = new Map<string, BedrockState>();
1178
- *
1179
- * const statePort: StatePort = {
1180
- * async read(environment) {
1181
- * return { data: store.get(environment), success: true };
1182
- * },
1183
- * async write(state) {
1184
- * store.set(state.environment, state);
1185
- * return { data: undefined, success: true };
1186
- * },
1187
- * };
1188
- *
1189
- * return statePort
1190
- * .read("production")
1191
- * .then((firstRead) => {
1192
- * expect(firstRead.success).toBeTrue();
1193
- * if (firstRead.success) {
1194
- * expect(firstRead.data).toBeUndefined();
1195
- * }
1196
- * return statePort.write({
1197
- * environment: "production",
1198
- * resources: [],
1199
- * version: 1,
1200
- * });
1201
- * })
1202
- * .then((writeResult) => {
1203
- * expect(writeResult.success).toBeTrue();
1204
- * return statePort.read("production");
1205
- * })
1206
- * .then((secondRead) => {
1207
- * expect(secondRead.success).toBeTrue();
1208
- * if (secondRead.success && secondRead.data !== undefined) {
1209
- * expect(secondRead.data.environment).toBe("production");
1210
- * expect(secondRead.data.resources).toBeEmpty();
1211
- * }
1212
- * });
1213
- * ```
1197
+ * Failure surfaced when a merged place entry is missing a required field.
1198
+ * Two paths reach this error: a root place declared without a matching
1199
+ * per-environment overlay supplying `placeId`, and an overlay-only place
1200
+ * declared under `environments.X.places` with no matching root entry to
1201
+ * supply `filePath`. Surfacing both at the resolution boundary attributes
1202
+ * the missing field to the offending entry's key instead of letting
1203
+ * `buildDesired` crash with a generic `fileReadFailed` later on.
1214
1204
  */
1215
- interface StatePort {
1216
- /**
1217
- * Reads state for the given environment.
1218
- *
1219
- * - Returns `Ok(undefined)` when no state file exists (legitimate first deploy).
1220
- * - Returns `Err(StateError)` when a file exists but cannot be parsed
1221
- * (corrupt JSON, schema failure, unknown `$bedrock.version`).
1222
- *
1223
- * Never silently falls back to empty state: a malformed file that collapsed
1224
- * to `{ resources: [] }` would cause the next apply to re-create every
1225
- * resource on Roblox.
1226
- */
1227
- read(environment: string): Promise<Result$1<BedrockState | undefined, StateError>>;
1228
- /** Writes state for the given environment, overwriting any existing file. */
1229
- write(state: BedrockState): Promise<Result$1<void, StateError>>;
1205
+ interface IncompletePlaceEntryError {
1206
+ /** ResourceKey of the place entry that is missing a required field. */
1207
+ readonly key: string;
1208
+ /** Environment whose overlay was projected onto the config. */
1209
+ readonly environment: string;
1210
+ /** Literal discriminator for narrowing. */
1211
+ readonly kind: "incompletePlaceEntry";
1212
+ /** Field that the merged entry lacks. */
1213
+ readonly missingField: "filePath" | "placeId";
1230
1214
  }
1231
- //#endregion
1232
- //#region src/adapters/gist-state-adapter.d.ts
1233
- /**
1234
- * Minimal `fetch`-compatible signature the adapter needs, narrower than
1235
- * `typeof globalThis.fetch` so test fakes do not have to stub runtime
1236
- * extensions such as `fetch.preconnect`.
1237
- */
1238
- type GistFetch = (input: globalThis.Request | string | URL, init?: RequestInit) => Promise<Response>;
1239
1215
  /**
1240
- * Configuration for {@link createGistStateAdapter}.
1216
+ * Failure surfaced when a merged `universe` block lacks `universeId`.
1217
+ * The schema-level XOR rule normally prevents this by requiring
1218
+ * `universeId` either at the root or on every per-environment overlay;
1219
+ * this error remains as a typed safety net for callers that bypass
1220
+ * `validateConfig` and hand a `Config` to `selectEnvironment` directly.
1241
1221
  */
1242
- interface GistStateAdapterDeps {
1243
- /** Injection seam for tests; defaults to `globalThis.fetch`. */
1244
- readonly fetch?: GistFetch | undefined;
1245
- /** ID of an existing GitHub Gist that holds this project's state files. */
1246
- readonly gistId: string;
1247
- /**
1248
- * Injection seam for retry backoff timing; defaults to a `setTimeout`-based
1249
- * promise. Tests pass a fake to keep retry assertions deterministic.
1250
- */
1251
- readonly sleep?: ((ms: number) => Promise<void>) | undefined;
1252
- /** GitHub token (fine-grained PAT or classic PAT) with gist read/write scope. */
1253
- readonly token: string;
1222
+ interface IncompleteUniverseEntryError {
1223
+ /** Environment whose overlay was projected onto the config. */
1224
+ readonly environment: string;
1225
+ /** Literal discriminator for narrowing. */
1226
+ readonly kind: "incompleteUniverseEntry";
1227
+ /** Field that the merged entry lacks. V1 only surfaces `"universeId"`. */
1228
+ readonly missingField: "universeId";
1254
1229
  }
1230
+ /** Failure modes returned by {@link selectEnvironment}. */
1231
+ type SelectEnvironmentError = IncompletePlaceEntryError | IncompleteUniverseEntryError | UnknownEnvironmentError;
1255
1232
  /**
1256
- * Build a `StatePort` that persists Bedrock state in a GitHub Gist.
1257
- *
1258
- * One gist holds one file per environment, named `state.<env>.json`. The
1259
- * adapter authenticates with a user-supplied token and speaks the GitHub
1260
- * REST API directly; no SDK dependency.
1233
+ * Project a validated `Config` onto a single environment. Looks up the
1234
+ * matching `environments[environment]` entry, deep-merges its resource
1235
+ * overlay (`passes`, `places`, `universe`) over the root config via defu,
1236
+ * and applies the env-level state override when present (the env entry's
1237
+ * `state` field wins; otherwise the root `state` flows through).
1261
1238
  *
1262
- * @example
1239
+ * Pure: no I/O. Returns a `ResolvedConfig` ready to feed into downstream
1240
+ * functions (`flattenConfig`, `buildDefaultRegistry`, `resolveStateConfig`).
1241
+ * The post-merge view promotes `places` from `Record<string, PlaceEntry>`
1242
+ * (root: file-paths only) to `Record<string, ResolvedPlaceEntry>` (root +
1243
+ * overlay merged). `environments` and `extends` are passed through
1244
+ * unchanged because they preserve the shape relationship to `Config`;
1245
+ * downstream consumers do not read them post-merge.
1263
1246
  *
1264
- * ```ts
1265
- * import { createGistStateAdapter } from "@bedrock-rbx/core";
1247
+ * Defu's merge semantics are deliberate: keyed-map collections merge by
1248
+ * key (so a place declared in both root and overlay produces a single
1249
+ * entry whose overlay-supplied fields win), and `null` / `undefined` in
1250
+ * the overlay are skipped (so the overlay never deletes a root field).
1251
+ * State has its own resolution path (a single replacement, not a
1252
+ * deep-merge) because it is a tagged union: a deep-merge of
1253
+ * `{ backend: "s3" }` over `{ backend: "gist", gistId }` would produce
1254
+ * a malformed `{ backend: "s3", gistId }`.
1266
1255
  *
1267
- * const port = createGistStateAdapter({
1268
- * fetch: async () =>
1269
- * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1270
- * gistId: "abc123def456",
1271
- * token: "ghp_example",
1272
- * });
1256
+ * State is left absent when neither the env override nor the root block
1257
+ * provides one. Callers that require a resolved `StateConfig` should
1258
+ * route through `resolveStateConfig` or `buildStatePort`; the absent
1259
+ * case surfaces as a typed `stateNotConfigured` there.
1273
1260
  *
1274
- * return port.read("production").then((result) => {
1275
- * expect(result.success).toBeTrue();
1276
- * if (result.success) {
1277
- * expect(result.data).toBeUndefined();
1278
- * }
1279
- * });
1280
- * ```
1261
+ * Limitation in v1: a per-environment universe overlay that introduces a
1262
+ * brand-new universe block may still have optional fields missing, since
1263
+ * the overlay type only requires the identity-bearing key. The resolver
1264
+ * surfaces the entry as-is; the universe driver reports the missing
1265
+ * field when it tries to consume the entry. Universe is a singleton with
1266
+ * 20+ optional fields, so the same `incompletePlaceEntry`-style validation
1267
+ * is deferred to a separate follow-up.
1281
1268
  *
1282
- * @param deps - Gist ID, GitHub token, and optional fetch override.
1283
- * @returns A `StatePort` ready to be passed to `deploy()`.
1284
- */
1285
- declare function createGistStateAdapter(deps: GistStateAdapterDeps): StatePort;
1286
- //#endregion
1287
- //#region src/adapters/place-driver.d.ts
1288
- /**
1289
- * Dependencies of `createPlaceDriver`. `universeId` is captured at
1290
- * construction time (matching `GamePassDriverDeps`) so each driver instance
1291
- * is bound to a single universe; multi-universe deploys construct one driver
1292
- * per universe. `readFile` is injected because `diff` operates on file hashes
1293
- * while the driver is the only place that needs the raw bytes.
1269
+ * When the project sets a `displayNamePrefix` (or omits it, in which case
1270
+ * prefixing defaults to enabled) and the chosen environment declares a
1271
+ * non-empty `label`, the resolver renders the configured template via
1272
+ * `renderDisplayNamePrefix` and prepends the result to `universe.displayName`
1273
+ * and every declared place `displayName`. An undeclared `displayName`, an
1274
+ * empty/absent label, or an explicit `displayNamePrefix.enabled: false` all
1275
+ * skip prefixing for the affected fields.
1294
1276
  *
1295
1277
  * @example
1296
1278
  *
1297
1279
  * ```ts
1298
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1299
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1300
- * import { asRobloxAssetId, type PlaceDriverDeps } from "@bedrock-rbx/core";
1280
+ * import { selectEnvironment } from "@bedrock-rbx/core";
1281
+ * import type { Config } from "@bedrock-rbx/core/config";
1301
1282
  *
1302
- * const httpClient: HttpClient = {
1303
- * async request() {
1304
- * return { data: { body: {}, headers: {}, status: 200 }, success: true };
1283
+ * const config: Config = {
1284
+ * environments: {
1285
+ * production: { universe: { universeId: "999" } },
1305
1286
  * },
1287
+ * state: { backend: "gist", gistId: "abc123" },
1288
+ * universe: { voiceChatEnabled: true },
1306
1289
  * };
1307
1290
  *
1308
- * const deps: PlaceDriverDeps = {
1309
- * client: new PlacesClient({
1310
- * apiKey: "rbx-your-key",
1311
- * httpClient,
1312
- * sleep: async () => {},
1313
- * }),
1314
- * readFile: async () => new Uint8Array(),
1315
- * universeId: asRobloxAssetId("1234567890"),
1316
- * };
1291
+ * const result = selectEnvironment(config, "production");
1317
1292
  *
1318
- * expect(deps.universeId).toBe("1234567890");
1293
+ * expect(result.success).toBeTrue();
1294
+ * if (result.success) {
1295
+ * expect(result.data.universe?.universeId).toBe("999");
1296
+ * expect(result.data.universe?.voiceChatEnabled).toBeTrue();
1297
+ * expect(result.data.state?.backend).toBe("gist");
1298
+ * }
1319
1299
  * ```
1300
+ *
1301
+ * @param config - Validated project config carrying at least one
1302
+ * environment under `environments`.
1303
+ * @param environment - Environment name to project onto. Must be a key
1304
+ * of `config.environments`.
1305
+ * @returns `Ok(ResolvedConfig)` with the merged resource fields and the
1306
+ * resolved state, or `Err(SelectEnvironmentError)` describing why the
1307
+ * projection failed.
1320
1308
  */
1321
- interface PlaceDriverDeps {
1322
- /** Configured places client from `@bedrock-rbx/ocale/places`. */
1323
- readonly client: PlacesClient;
1324
- /** Reads place-file bytes for upload; rejections propagate out of the driver. */
1325
- readonly readFile: (path: string) => Promise<Uint8Array>;
1326
- /** Universe that owns every place this driver publishes. */
1327
- readonly universeId: RobloxAssetId;
1328
- }
1309
+ declare function selectEnvironment(config: Config, environment: string): Result$1<ResolvedConfig, SelectEnvironmentError>;
1310
+ //#endregion
1311
+ //#region src/ports/resource-driver.d.ts
1329
1312
  /**
1330
- * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
1331
- * `update` are both thin wrappers over a shared publish helper because the
1332
- * upstream Open Cloud call is identical either way: there is no "create
1333
- * place" endpoint (the place is user-supplied input), only "publish version".
1313
+ * Plugin contract for a resource adapter: the interface a third-party author
1314
+ * implements to teach Bedrock how to reconcile one {@link ResourceKind} against
1315
+ * its upstream API.
1334
1316
  *
1335
- * Format is detected from the file extension (`.rbxl` binary,
1336
- * `.rbxlx` XML); any other extension returns an `ApiError`-backed failure
1337
- * without hitting the network.
1317
+ * `ResourceDriver<K>` is a *driven* (secondary) port in hexagonal terms; the
1318
+ * name "driver" follows Terraform, Pulumi, and Mantle IaC convention for a
1319
+ * component that talks to a specific resource API.
1338
1320
  *
1339
- * @param deps - Injected ocale client, file reader, and owning universe.
1340
- * @returns A driver indexable by `"place"` in a `DriverRegistry`.
1341
- * @throws Whatever `deps.readFile` rejects with.
1321
+ * @template K - The {@link ResourceKind} discriminator this driver handles.
1342
1322
  *
1343
1323
  * @example
1344
1324
  *
1345
1325
  * ```ts
1346
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1347
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1348
1326
  * import {
1349
1327
  * asResourceKey,
1350
1328
  * asRobloxAssetId,
1351
1329
  * asSha256Hex,
1352
- * createPlaceDriver,
1330
+ * type ResourceDriver,
1353
1331
  * } from "@bedrock-rbx/core";
1354
1332
  *
1355
- * const httpClient: HttpClient = {
1356
- * async request() {
1357
- * return {
1358
- * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1359
- * success: true,
1360
- * };
1361
- * },
1362
- * };
1363
- *
1364
- * const driver = createPlaceDriver({
1365
- * client: new PlacesClient({
1366
- * apiKey: "rbx-your-key",
1367
- * httpClient,
1368
- * sleep: async () => {},
1369
- * }),
1370
- * readFile: async () =>
1371
- * new Uint8Array([
1372
- * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
1373
- * 0x0a,
1374
- * ]),
1375
- * universeId: asRobloxAssetId("1234567890"),
1376
- * });
1377
- *
1378
- * return driver
1379
- * .create({
1380
- * description: undefined,
1381
- * displayName: undefined,
1382
- * fileHash: asSha256Hex(
1383
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1384
- * ),
1385
- * filePath: "places/start.rbxl",
1386
- * key: asResourceKey("start-place"),
1387
- * kind: "place",
1388
- * placeId: asRobloxAssetId("4711"),
1389
- * serverSize: undefined,
1390
- * })
1391
- * .then((result) => {
1392
- * expect(result.success).toBeTrue();
1393
- * if (result.success) {
1394
- * expect(result.data.outputs.versionNumber).toBe(1);
1395
- * }
1396
- * });
1397
- * ```
1398
- */
1399
- declare function createPlaceDriver(deps: PlaceDriverDeps): ResourceDriver<"place">;
1400
- //#endregion
1401
- //#region src/adapters/universe-driver.d.ts
1402
- /**
1403
- * Dependencies of `createUniverseDriver`. The driver reconciles the
1404
- * universe singleton against both the universes endpoint and the root
1405
- * place (for fields Roblox marks read-only on the universe, like
1406
- * `displayName`). There is no `universeId` at construction time because
1407
- * the universe *is* the resource the driver reconciles, so the ID rides
1408
- * along on each `UniverseDesiredState`.
1409
- */
1410
- interface UniverseDriverDeps {
1411
- /** Configured places client from `@bedrock-rbx/ocale/places`. */
1412
- readonly places: PlacesClient;
1413
- /** Configured universes client from `@bedrock-rbx/ocale/universes`. */
1414
- readonly universes: UniversesClient;
1415
- }
1416
- /**
1417
- * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
1418
- * and `update` both delegate to a shared reconcile helper because Open
1419
- * Cloud cannot mint universes; the user supplies an existing `universeId`
1420
- * and bedrock adopts the universe on first apply.
1421
- *
1422
- * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
1423
- * as an adoption-error `ApiError` whose message names the config key and
1424
- * the `universeId`, so operators can tell adoption failure apart from
1425
- * transient upstream errors. A successful response whose `rootPlaceId` is
1426
- * absent surfaces as an `ApiError` with status 200, mirroring the
1427
- * malformed-response guard in `GamePassDriver`.
1428
- *
1429
- * When `displayName` is declared, the driver routes that field through
1430
- * `PlacesClient.update` on the root place after the universe PATCH
1431
- * succeeds. A subsequent places failure surfaces to the caller as the
1432
- * driver's error result without rolling back the prior universe patch,
1433
- * so callers observing a partial failure should reconcile by
1434
- * reapplying rather than assuming the universe-level fields are
1435
- * unchanged.
1436
- *
1437
- * @param deps - Injected ocale clients (universes plus places for the
1438
- * read-only universe fields Roblox derives from the root place).
1439
- * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
1440
- *
1441
- * @example
1442
- *
1443
- * ```ts
1444
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1445
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1446
- * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
1447
- * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
1448
- * import {
1449
- * asRobloxAssetId,
1450
- * createUniverseDriver,
1451
- * UNIVERSE_SINGLETON_KEY,
1452
- * } from "@bedrock-rbx/core";
1453
- *
1454
- * const universeBodyHttpClient: HttpClient = {
1455
- * async request() {
1333
+ * const gamePassDriver: ResourceDriver<"gamePass"> = {
1334
+ * async create(desired) {
1456
1335
  * return {
1457
1336
  * data: {
1458
- * body: validUniverseBody({
1459
- * path: "universes/1234567890",
1460
- * rootPlace: "universes/1234567890/places/4711",
1461
- * }),
1462
- * headers: {},
1463
- * status: 200,
1337
+ * ...desired,
1338
+ * outputs: {
1339
+ * assetId: asRobloxAssetId("9876543210"),
1340
+ * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
1341
+ * },
1464
1342
  * },
1465
1343
  * success: true,
1466
1344
  * };
1467
1345
  * },
1468
1346
  * };
1469
1347
  *
1470
- * const driver = createUniverseDriver({
1471
- * places: new PlacesClient({
1472
- * apiKey: "rbx-your-key",
1473
- * httpClient: universeBodyHttpClient,
1474
- * sleep: async () => {},
1475
- * }),
1476
- * universes: new UniversesClient({
1477
- * apiKey: "rbx-your-key",
1478
- * httpClient: universeBodyHttpClient,
1479
- * sleep: async () => {},
1480
- * }),
1481
- * });
1482
- *
1483
- * return driver
1348
+ * return gamePassDriver
1484
1349
  * .create({
1485
- * consoleEnabled: undefined,
1486
- * desktopEnabled: true,
1487
- * displayName: undefined,
1488
- * key: UNIVERSE_SINGLETON_KEY,
1489
- * kind: "universe",
1490
- * mobileEnabled: undefined,
1491
- * privateServerPriceRobux: undefined,
1492
- * tabletEnabled: undefined,
1493
- * universeId: asRobloxAssetId("1234567890"),
1494
- * voiceChatEnabled: true,
1495
- * vrEnabled: undefined,
1350
+ * description: "Grants VIP perks.",
1351
+ * icon: { "en-us": "assets/vip-icon.png" },
1352
+ * iconFileHashes: {
1353
+ * "en-us": asSha256Hex(
1354
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1355
+ * ),
1356
+ * },
1357
+ * key: asResourceKey("vip-pass"),
1358
+ * kind: "gamePass",
1359
+ * name: "VIP Pass",
1360
+ * price: undefined,
1496
1361
  * })
1497
1362
  * .then((result) => {
1498
1363
  * expect(result.success).toBeTrue();
1499
1364
  * if (result.success) {
1500
- * expect(result.data.outputs.rootPlaceId).toBe("4711");
1365
+ * expect(result.data.outputs.assetId).toBe("9876543210");
1501
1366
  * }
1502
1367
  * });
1503
1368
  * ```
1504
1369
  */
1505
- declare function createUniverseDriver(deps: UniverseDriverDeps): ResourceDriver<"universe">;
1506
- //#endregion
1507
- //#region src/core/derive-price-fields.d.ts
1508
- /**
1509
- * Wire-shape pricing fragment produced by {@link derivePriceFields}: the
1510
- * `isForSale` flag and an optional numeric `price`. Mirrors the multipart
1511
- * fields the Open Cloud `developer-products` create and update endpoints
1512
- * accept for setting Robux pricing.
1513
- */
1514
- interface PriceFields {
1515
- /** Whether the developer product should be purchasable. */
1516
- readonly isForSale: boolean;
1517
- /** Default price in Robux; absent when the product is off-sale. */
1518
- readonly price?: number;
1370
+ interface ResourceDriver<K extends ResourceKind> {
1371
+ /**
1372
+ * Create the resource upstream from its desired state and return the
1373
+ * resulting current state (desired fields + Roblox-assigned outputs).
1374
+ */
1375
+ create(desired: Extract<ResourceDesiredState, {
1376
+ kind: K;
1377
+ }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
1378
+ /**
1379
+ * Reconcile an upstream resource whose managed content has drifted from its
1380
+ * desired state. Receives the last-known current state so the driver can
1381
+ * compute a minimal patch (or no-op upstream, for file-backed kinds where
1382
+ * republishing is unconditional).
1383
+ *
1384
+ * Optional. Drivers whose upstream API has no update operation omit this
1385
+ * method; `applyOps` surfaces an `updateUnsupported` error at dispatch time
1386
+ * instead.
1387
+ */
1388
+ update?(current: ResourceCurrentState<K>, desired: Extract<ResourceDesiredState, {
1389
+ kind: K;
1390
+ }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
1519
1391
  }
1520
1392
  /**
1521
- * Translate a Mantle-style optional price into the Open Cloud wire shape.
1522
- *
1523
- * `desired.price === undefined` (no price declared) becomes
1524
- * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
1525
- * price (including `0`) becomes `{ isForSale: true, price }`. Both
1526
- * `developerProduct` create and update paths share this helper so the
1527
- * "absent ⇒ off-sale" semantics live in exactly one place.
1528
- *
1529
- * @param desired - Object carrying the user-declared `price`.
1530
- * @returns The wire-shape `{ isForSale, price? }` fragment.
1393
+ * Polymorphic dispatch table keyed by {@link ResourceKind}, mapping each kind
1394
+ * to the {@link ResourceDriver} that handles it. `applyOps` indexes the
1395
+ * registry by `op.desired.kind` to reach the matching driver with full type
1396
+ * safety: adding a new kind to `ResourceDesiredState` is a compile error until
1397
+ * a matching registry entry is supplied.
1531
1398
  *
1532
1399
  * @example
1533
1400
  *
1534
1401
  * ```ts
1535
- * import { derivePriceFields } from "@bedrock-rbx/core";
1402
+ * import { OpenCloudError, type DriverRegistry } from "@bedrock-rbx/core";
1536
1403
  *
1537
- * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
1538
- * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
1404
+ * const registry: DriverRegistry = {
1405
+ * gamePass: {
1406
+ * async create() {
1407
+ * return { err: new OpenCloudError("not implemented"), success: false };
1408
+ * },
1409
+ * },
1410
+ * place: {
1411
+ * async create() {
1412
+ * return { err: new OpenCloudError("not implemented"), success: false };
1413
+ * },
1414
+ * },
1415
+ * universe: {
1416
+ * async create() {
1417
+ * return { err: new OpenCloudError("not implemented"), success: false };
1418
+ * },
1419
+ * },
1420
+ * developerProduct: {
1421
+ * async create() {
1422
+ * return { err: new OpenCloudError("not implemented"), success: false };
1423
+ * },
1424
+ * },
1425
+ * };
1426
+ *
1427
+ * expect(registry.gamePass).toBeObject();
1539
1428
  * ```
1540
1429
  */
1541
- declare function derivePriceFields(desired: {
1542
- readonly price: number | undefined;
1543
- }): PriceFields;
1430
+ type DriverRegistry = { [K in ResourceKind]: ResourceDriver<K> };
1544
1431
  //#endregion
1545
1432
  //#region src/core/operations.d.ts
1546
1433
  /**
@@ -1738,162 +1625,228 @@ interface NoopOperation extends BaseOperation {
1738
1625
  */
1739
1626
  type Operation = CreateOperation | NoopOperation | UpdateOperation;
1740
1627
  //#endregion
1741
- //#region src/core/diff.d.ts
1628
+ //#region src/shell/apply-ops.d.ts
1742
1629
  /**
1743
- * Computes the operations required to reconcile `current` state with `desired`
1744
- * state. Pure and synchronous: no I/O, no side effects, no `Result` wrapper.
1745
- *
1746
- * Each entry in `desired` is matched to `current` by `(kind, key)`: resources
1747
- * are uniquely identified by that pair, so a `place` and a `universe` keyed
1748
- * `"main"` are independent slots. A `(kind, key)` pair present only in
1749
- * `desired` produces a `create` op; a pair present in both produces an
1750
- * `update` op if any declared field differs or a `noop` op if every field
1751
- * matches.
1752
- *
1753
- * Ops appear in the order their desired entries appear in the input array so
1754
- * callers can rely on declaration order when logging or applying ops.
1630
+ * Failure surfaced by `applyOps` when an operation cannot be applied.
1631
+ * Plain-data discriminated union; narrow on `kind`, do not `instanceof` it.
1755
1632
  *
1756
- * @param desired - Declared desired state from user config, already normalized
1757
- * (file hashes computed, nullable wire values mapped to `undefined`).
1758
- * @param current - Last-known live state from the state file.
1759
- * @returns Operations to reconcile the two snapshots.
1633
+ * `appliedSoFar` carries the driver outputs from operations that succeeded
1634
+ * before the failing one, in dispatched order. Callers persist this so a
1635
+ * follow-up reconcile does not duplicate Roblox-side resources that have
1636
+ * already been created or updated.
1760
1637
  *
1761
1638
  * @example
1762
1639
  *
1763
1640
  * ```ts
1764
- * import {
1765
- * asResourceKey,
1766
- * asRobloxAssetId,
1767
- * asSha256Hex,
1768
- * diff,
1769
- * type GamePassDesiredState,
1770
- * type ResourceCurrentState,
1771
- * } from "@bedrock-rbx/core";
1641
+ * import { asResourceKey, type ApplyError } from "@bedrock-rbx/core";
1772
1642
  *
1773
- * const hash = asSha256Hex(
1774
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1775
- * );
1643
+ * function describe(err: ApplyError): string {
1644
+ * switch (err.kind) {
1645
+ * case "driverFailure": {
1646
+ * return `driver failed for ${err.key}: ${err.cause.message}`;
1647
+ * }
1648
+ * case "updateUnsupported": {
1649
+ * return `update not supported for ${err.key}`;
1650
+ * }
1651
+ * }
1652
+ * }
1776
1653
  *
1777
- * const unchanged: GamePassDesiredState = {
1778
- * description: "Grants VIP perks.",
1779
- * icon: { "en-us": "assets/vip-icon.png" },
1780
- * iconFileHashes: { "en-us": hash },
1654
+ * const err: ApplyError = {
1781
1655
  * key: asResourceKey("vip-pass"),
1782
- * kind: "gamePass",
1783
- * name: "VIP Pass",
1784
- * price: 500,
1785
- * };
1786
- * const drifted: GamePassDesiredState = {
1787
- * ...unchanged,
1788
- * key: asResourceKey("legend-pass"),
1789
- * name: "Legend Pass (renamed)",
1790
- * };
1791
- * const fresh: GamePassDesiredState = {
1792
- * ...unchanged,
1793
- * key: asResourceKey("rookie-pass"),
1794
- * name: "Rookie Pass",
1656
+ * appliedSoFar: [],
1657
+ * kind: "updateUnsupported",
1795
1658
  * };
1796
1659
  *
1797
- * const current: ReadonlyArray<ResourceCurrentState> = [
1798
- * {
1799
- * ...unchanged,
1800
- * outputs: {
1801
- * assetId: asRobloxAssetId("111"),
1802
- * iconAssetIds: { "en-us": asRobloxAssetId("222") },
1660
+ * expect(describe(err)).toBe("update not supported for vip-pass");
1661
+ * ```
1662
+ */
1663
+ type ApplyError = {
1664
+ readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
1665
+ readonly cause: OpenCloudError$1;
1666
+ readonly key: ResourceKey;
1667
+ readonly kind: "driverFailure";
1668
+ } | {
1669
+ readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
1670
+ readonly key: ResourceKey;
1671
+ readonly kind: "updateUnsupported";
1672
+ };
1673
+ /**
1674
+ * Dispatch each reconciliation operation to the matching resource driver
1675
+ * with first-fail semantics: on the first `Err` (driver failure or
1676
+ * `updateUnsupported`), the remaining operations are skipped and the error
1677
+ * is returned verbatim.
1678
+ *
1679
+ * Behaviour:
1680
+ * - `create` operations are routed to `registry[op.desired.kind].create`.
1681
+ * - `update` operations are routed to `registry[op.desired.kind].update`
1682
+ * when the driver exposes it; otherwise they short-circuit to an
1683
+ * `updateUnsupported` Err without invoking the driver.
1684
+ * - `noop` operations are skipped entirely (no I/O, no dispatch).
1685
+ *
1686
+ * On success the returned array carries the driver outputs for every
1687
+ * non-noop op, in dispatched order. Noops are not represented; callers
1688
+ * needing a full post-apply snapshot merge with the pre-apply current
1689
+ * state keyed by `ResourceKey`.
1690
+ *
1691
+ * @param ops - Reconciliation operations produced by `diff`, applied in order.
1692
+ * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
1693
+ * @returns `Ok(state)` when every operation succeeds, where `state` holds
1694
+ * driver outputs for each non-noop op in dispatched order; or the first
1695
+ * failure encountered.
1696
+ * @throws Whatever the dispatched driver rejects with outside its `Result`
1697
+ * return. A driver whose injected I/O (file reads, network calls, etc.)
1698
+ * throws will surface that rejection here rather than translating it into
1699
+ * a `Result` failure; wrap the call site in a try/catch when drivers are
1700
+ * not trusted to contain their own rejections.
1701
+ * @example
1702
+ *
1703
+ * ```ts
1704
+ * import {
1705
+ * applyOps,
1706
+ * asResourceKey,
1707
+ * asRobloxAssetId,
1708
+ * asSha256Hex,
1709
+ * type DriverRegistry,
1710
+ * type Operation,
1711
+ * } from "@bedrock-rbx/core";
1712
+ *
1713
+ * const registry: DriverRegistry = {
1714
+ * gamePass: {
1715
+ * async create(desired) {
1716
+ * return {
1717
+ * data: {
1718
+ * ...desired,
1719
+ * outputs: {
1720
+ * assetId: asRobloxAssetId("9876543210"),
1721
+ * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
1722
+ * },
1723
+ * },
1724
+ * success: true,
1725
+ * };
1726
+ * },
1727
+ * },
1728
+ * place: {
1729
+ * async create(desired) {
1730
+ * return {
1731
+ * data: { ...desired, outputs: { versionNumber: 1 } },
1732
+ * success: true,
1733
+ * };
1734
+ * },
1735
+ * },
1736
+ * universe: {
1737
+ * async create(desired) {
1738
+ * return {
1739
+ * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
1740
+ * success: true,
1741
+ * };
1742
+ * },
1743
+ * },
1744
+ * developerProduct: {
1745
+ * async create(desired) {
1746
+ * return {
1747
+ * data: {
1748
+ * ...desired,
1749
+ * outputs: { productId: asRobloxAssetId("8172635495") },
1750
+ * },
1751
+ * success: true,
1752
+ * };
1803
1753
  * },
1804
1754
  * },
1755
+ * };
1756
+ *
1757
+ * const ops: ReadonlyArray<Operation> = [
1805
1758
  * {
1806
- * ...drifted,
1807
- * name: "Legend Pass",
1808
- * outputs: {
1809
- * assetId: asRobloxAssetId("333"),
1810
- * iconAssetIds: { "en-us": asRobloxAssetId("444") },
1759
+ * key: asResourceKey("vip-pass"),
1760
+ * type: "create",
1761
+ * desired: {
1762
+ * key: asResourceKey("vip-pass"),
1763
+ * name: "VIP Pass",
1764
+ * description: "Grants VIP perks.",
1765
+ * icon: { "en-us": "assets/vip-icon.png" },
1766
+ * iconFileHashes: {
1767
+ * "en-us": asSha256Hex(
1768
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1769
+ * ),
1770
+ * },
1771
+ * kind: "gamePass",
1772
+ * price: 500,
1811
1773
  * },
1812
1774
  * },
1813
1775
  * ];
1814
1776
  *
1815
- * const ops = diff([unchanged, drifted, fresh], current);
1816
- *
1817
- * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
1777
+ * return applyOps(ops, registry).then((result) => {
1778
+ * expect(result.success).toBe(true);
1779
+ * expect(result.success && result.data).toHaveLength(1);
1780
+ * });
1818
1781
  * ```
1819
1782
  */
1820
- declare function diff(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): ReadonlyArray<Operation>;
1783
+ declare function applyOps(ops: ReadonlyArray<Operation>, registry: DriverRegistry): Promise<Result$1<ReadonlyArray<ResourceCurrentState>, ApplyError>>;
1821
1784
  //#endregion
1822
- //#region src/core/display-name-prefix.d.ts
1785
+ //#region src/shell/build-default-registry.d.ts
1823
1786
  /**
1824
- * Default template applied when a project enables display-name prefixing
1825
- * without supplying its own `displayNamePrefix.format`. Yields outputs
1826
- * like `[STAGING] ` for an environment whose `label` is `"staging"`.
1787
+ * Failure surfaced when default-constructing a registry needs a config
1788
+ * field that is not present. The deploy boundary wraps this in a
1789
+ * `DeployError` so the caller sees a typed Result instead of a downstream
1790
+ * driver error.
1827
1791
  */
1828
- declare const DEFAULT_PREFIX_FORMAT = "[{LABEL}] ";
1792
+ interface RegistryConfigError {
1793
+ /** Suggested fix routed back to the caller. */
1794
+ readonly hint: string;
1795
+ /** Literal discriminator for narrowing. */
1796
+ readonly kind: "registryConfigMissing";
1797
+ /** Which config field was missing. */
1798
+ readonly missing: "universeId";
1799
+ }
1800
+ /** Inputs for {@link buildDefaultRegistry}. */
1801
+ interface BuildDefaultRegistryDeps {
1802
+ /** Resolved project config; supplies `universe.universeId` and is read for nothing else. */
1803
+ readonly config: ResolvedConfig;
1804
+ /** Reads an environment variable; injected so tests stay free of `process.env`. */
1805
+ readonly getEnv: (name: string) => string | undefined;
1806
+ /** Reader plumbed into kind-specific drivers that ingest file bytes. */
1807
+ readonly readFile: (path: string) => Promise<Uint8Array>;
1808
+ }
1829
1809
  /**
1830
- * Render the prefix that selectEnvironment prepends to declared display
1831
- * names when a project enables `displayNamePrefix`. The template
1832
- * recognizes three placeholders:
1833
- *
1834
- * - `{label}`: label as written.
1835
- * - `{LABEL}`: upper-cased label.
1836
- * - `{Label}`: capitalized label (first character upper, rest as written).
1837
- *
1838
- * Other characters in the template flow through verbatim.
1839
- *
1840
- * @param label - Environment label declared on `EnvironmentEntry.label`.
1841
- * @param format - Template string. Falls back to
1842
- * {@link DEFAULT_PREFIX_FORMAT} when omitted.
1843
- * @returns The rendered prefix string.
1810
+ * Construct the default `DriverRegistry` from `config.universe.universeId`
1811
+ * and `BEDROCK_API_KEY`. Reads the API key via the injected `getEnv` seam
1812
+ * and surfaces `missingCredential` or `registryConfigMissing` as typed
1813
+ * Results instead of throwing.
1844
1814
  *
1845
1815
  * @example
1846
1816
  *
1847
1817
  * ```ts
1848
- * import { renderDisplayNamePrefix } from "@bedrock-rbx/core";
1818
+ * import { buildDefaultRegistry } from "@bedrock-rbx/core";
1849
1819
  *
1850
- * expect(renderDisplayNamePrefix("staging")).toBe("[STAGING] ");
1851
- * expect(renderDisplayNamePrefix("staging", "{Label}: ")).toBe("Staging: ");
1852
- * expect(renderDisplayNamePrefix("dev", "{LABEL}-{label}")).toBe("DEV-dev");
1820
+ * const registry = buildDefaultRegistry({
1821
+ * config: {
1822
+ * environments: { production: {} },
1823
+ * state: { backend: "gist", gistId: "abc" },
1824
+ * universe: { universeId: "1234567890" },
1825
+ * },
1826
+ * getEnv: () => "rbx-test",
1827
+ * readFile: async () => new Uint8Array(),
1828
+ * });
1829
+ *
1830
+ * expect(registry.success).toBeTrue();
1853
1831
  * ```
1832
+ *
1833
+ * @param deps - Validated config plus credential and file-reader seams.
1834
+ * @returns A `DriverRegistry` on success, or a typed Err describing the
1835
+ * missing API key or the missing universe declaration.
1854
1836
  */
1855
- declare function renderDisplayNamePrefix(label: string, format?: string): string;
1837
+ declare function buildDefaultRegistry(deps: BuildDefaultRegistryDeps): Result$1<DriverRegistry, MissingCredentialError | RegistryConfigError>;
1856
1838
  //#endregion
1857
- //#region src/core/environment.d.ts
1839
+ //#region src/core/flatten.d.ts
1858
1840
  /**
1859
- * Validate an environment name at a state-adapter boundary.
1860
- *
1861
- * Adapters that map environment names onto filesystem-like identifiers
1862
- * (gist filenames, S3 keys) must reject names that could collide or escape
1863
- * their storage layout. This helper accepts letters, digits, `-`, and `_`
1864
- * only, with length between 1 and 64, and returns a `StateError` for
1865
- * anything outside that set so the adapter can fail loudly instead of
1866
- * silently stripping characters.
1841
+ * Pre-I/O game-pass input the flattener emits. Extends the authored
1842
+ * `GamePassEntry` with the tag discriminator and the `ResourceKey`-branded
1843
+ * key so `buildDesired` can consume a flat tagged list and layer on the
1844
+ * SHA-256 icon digest.
1867
1845
  *
1868
1846
  * @example
1869
1847
  *
1870
1848
  * ```ts
1871
- * import { validateEnvironmentName } from "@bedrock-rbx/core";
1872
- *
1873
- * const ok = validateEnvironmentName("production");
1874
- * expect(ok.success).toBeTrue();
1875
- *
1876
- * const bad = validateEnvironmentName("prod/staging");
1877
- * expect(bad.success).toBeFalse();
1878
- * ```
1879
- *
1880
- * @param environment - Raw environment name supplied by a caller.
1881
- * @returns `Ok(environment)` when the name is safe to use, or
1882
- * `Err(StateError)` with a descriptive reason when it is not.
1883
- */
1884
- declare function validateEnvironmentName(environment: string): Result$1<string, StateError>;
1885
- //#endregion
1886
- //#region src/core/flatten.d.ts
1887
- /**
1888
- * Pre-I/O game-pass input the flattener emits. Extends the authored
1889
- * `GamePassEntry` with the tag discriminator and the `ResourceKey`-branded
1890
- * key so `buildDesired` can consume a flat tagged list and layer on the
1891
- * SHA-256 icon digest.
1892
- *
1893
- * @example
1894
- *
1895
- * ```ts
1896
- * import { asResourceKey, type GamePassDesiredInput } from "@bedrock-rbx/core";
1849
+ * import { asResourceKey, type GamePassDesiredInput } from "@bedrock-rbx/core";
1897
1850
  *
1898
1851
  * const input: GamePassDesiredInput = {
1899
1852
  * description: "Grants VIP perks.",
@@ -2110,47 +2063,6 @@ type ResourceDesiredInput = DeveloperProductDesiredInput | GamePassDesiredInput
2110
2063
  */
2111
2064
  declare function flattenConfig(config: ResolvedConfig): ReadonlyArray<ResourceDesiredInput>;
2112
2065
  //#endregion
2113
- //#region src/core/get-environment.d.ts
2114
- /**
2115
- * Failure modes returned by {@link getEnvironment}.
2116
- */
2117
- type GetEnvironmentError = {
2118
- readonly kind: "missingEnvironment";
2119
- } | {
2120
- readonly kind: "multipleEnvironments";
2121
- readonly values: ReadonlyArray<string>;
2122
- };
2123
- /**
2124
- * Resolve the deploy environment for an override script invocation.
2125
- *
2126
- * Reads `--env <name>` from the supplied argv first, falls back to
2127
- * `BEDROCK_ENVIRONMENT` from the supplied env reader. Returns
2128
- * `missingEnvironment` when neither is present and `multipleEnvironments`
2129
- * (with every offending value) when argv contains more than one `--env`
2130
- * flag. Both inputs default to the running process so override scripts
2131
- * under `.bedrock/` can call `getEnvironment()` with no arguments.
2132
- *
2133
- * @param argv - Argument list to scan for `--env <name>` flags. Defaults to
2134
- * `process.argv.slice(2)` when omitted.
2135
- * @param readEnvironment - Reads an environment variable; consulted as a
2136
- * fallback when no `--env` flag is present. Defaults to a `process.env`
2137
- * reader when omitted.
2138
- * @returns `Ok(environment)` on success, `Err(GetEnvironmentError)` otherwise.
2139
- * @example
2140
- *
2141
- * ```ts
2142
- * import { getEnvironment } from "@bedrock-rbx/core";
2143
- *
2144
- * const result = getEnvironment(["--env", "production"], () => undefined);
2145
- *
2146
- * expect(result.success).toBeTrue();
2147
- * if (result.success) {
2148
- * expect(result.data).toBe("production");
2149
- * }
2150
- * ```
2151
- */
2152
- declare function getEnvironment(argv?: ReadonlyArray<string>, readEnvironment?: (name: string) => string | undefined): Result$1<string, GetEnvironmentError>;
2153
- //#endregion
2154
2066
  //#region src/core/kinds/module.d.ts
2155
2067
  /**
2156
2068
  * Failure surfaced during desired-state preparation. Two variants today:
@@ -2325,1007 +2237,1284 @@ type InputFor<K extends ResourceKind> = Extract<ResourceDesiredInput, {
2325
2237
  readonly kind: K;
2326
2238
  }>;
2327
2239
  //#endregion
2328
- //#region src/core/icons.d.ts
2240
+ //#region src/shell/build-desired.d.ts
2329
2241
  /**
2330
- * Cost-gate for icon re-uploads. Returns `true` when the locally-hashed
2331
- * desired icon differs from the hash recorded on the prior current-state
2332
- * entry, signalling that the driver must re-upload before reconciling.
2333
- * Returns `false` when the hashes match (no re-upload needed) and when
2334
- * both sides report no icon.
2335
- *
2336
- * The signature takes hash maps directly (not whole-state) so the helper
2337
- * is independent of any specific resource-kind shape; every icon-bearing
2338
- * driver projects its own `iconFileHashes` and `outputs.iconFileHashes`
2339
- * fields before calling.
2242
+ * Layer file I/O onto a flat tagged list of resource inputs to produce
2243
+ * `ResourceDesiredState`.
2340
2244
  *
2341
- * @param currentHashes - Hashes recorded on the prior current-state entry.
2342
- * @param desiredHashes - Hashes layered onto the desired-state entry by
2343
- * `normalize` from the local icon file's bytes.
2344
- * @returns `true` when the driver should re-upload the icon.
2245
+ * For each input, reads the file bytes via the injected `readFile`, computes
2246
+ * the SHA-256 hex digest, and assembles the branded desired-state record
2247
+ * that `diff` consumes. Entries are processed sequentially so the first
2248
+ * failure's attribution is deterministic.
2345
2249
  *
2250
+ * @param inputs - Flat tagged resource inputs from `flattenConfig`.
2251
+ * @param readFile - Reads file bytes for a given path; rejection becomes a
2252
+ * `fileReadFailed` Err.
2253
+ * @returns `Ok` with the desired-state array (same length and order as
2254
+ * `inputs`), or `Err` with the first I/O failure.
2346
2255
  * @example
2347
2256
  *
2348
2257
  * ```ts
2349
- * import { asSha256Hex, shouldReuploadIcon } from "@bedrock-rbx/core";
2258
+ * import { asResourceKey, buildDesired } from "@bedrock-rbx/core";
2350
2259
  *
2351
- * const previous = asSha256Hex(
2352
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2353
- * );
2354
- * const fresh = asSha256Hex(
2355
- * "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
2356
- * );
2260
+ * async function readFile(): Promise<Uint8Array> {
2261
+ * return new Uint8Array([1, 2, 3]);
2262
+ * }
2357
2263
  *
2358
- * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": previous })).toBe(false);
2359
- * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": fresh })).toBe(true);
2264
+ * return buildDesired(
2265
+ * [
2266
+ * {
2267
+ * description: "Grants VIP perks.",
2268
+ * icon: { "en-us": "assets/vip-icon.png" },
2269
+ * key: asResourceKey("vip-pass"),
2270
+ * kind: "gamePass",
2271
+ * name: "VIP Pass",
2272
+ * price: 500,
2273
+ * },
2274
+ * ],
2275
+ * readFile,
2276
+ * ).then((result) => {
2277
+ * expect(result.success).toBeTrue();
2278
+ * if (result.success) {
2279
+ * expect(result.data).toHaveLength(1);
2280
+ * expect(result.data[0]!.kind).toBe("gamePass");
2281
+ * }
2282
+ * });
2360
2283
  * ```
2361
2284
  */
2362
- declare function shouldReuploadIcon(currentHashes: Record<"en-us", Sha256Hex> | undefined, desiredHashes: Record<"en-us", Sha256Hex> | undefined): boolean;
2285
+ declare function buildDesired(inputs: ReadonlyArray<ResourceDesiredInput>, readFile: (path: string) => Promise<Uint8Array>): Promise<Result$1<ReadonlyArray<ResourceDesiredState>, BuildDesiredError>>;
2363
2286
  //#endregion
2364
- //#region src/core/kinds/index.d.ts
2287
+ //#region src/shell/load-config.d.ts
2365
2288
  /**
2366
- * Default {@link KindRegistry} composing every resource kind bedrock ships
2367
- * out of the box. Iteration order (`gamePass`, `place`, `universe`,
2368
- * `developerProduct`) matches the order `flattenConfig` emits entries
2369
- * today, preserving the observable order of generated operations.
2289
+ * Options for {@link loadConfig}. Matches a subset of c12's loader options;
2290
+ * additional fields land with the issues that introduce each flow.
2291
+ */
2292
+ interface LoadConfigOptions {
2293
+ /**
2294
+ * Path to a specific config file to load, including its extension.
2295
+ * Resolved relative to `cwd` when not absolute. Loaded as-is with no
2296
+ * extension search; if the file does not exist at the given path,
2297
+ * `loadConfig` returns `fileNotFound`. When omitted, `loadConfig`
2298
+ * discovers `bedrock.config.{ts,js,...}` from `cwd`.
2299
+ */
2300
+ readonly configFile?: string;
2301
+ /**
2302
+ * Directory to search from. Defaults to `process.cwd()` at call time, so
2303
+ * each invocation sees the current working directory.
2304
+ */
2305
+ readonly cwd?: string;
2306
+ }
2307
+ /**
2308
+ * Discover, parse, and validate the project config.
2309
+ *
2310
+ * Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json}`, `.bedrockrc*`,
2311
+ * and `package.json#bedrock` starting at `options.cwd` (or the current
2312
+ * working directory). Returns a fresh, mutable `Config` on every call so
2313
+ * long-running scripts see up-to-date values.
2314
+ *
2315
+ * When the exported default is a function (sync or async), `loadConfig`
2316
+ * invokes it with an empty `ConfigContext` and awaits the result before
2317
+ * validating.
2318
+ *
2319
+ * Errors return via `Result`:
2320
+ * - `fileNotFound` - no config file was discovered under the search path.
2321
+ * - `parseFailed` - a config file was found but could not be parsed (for
2322
+ * example, malformed YAML or JSON).
2323
+ * - `validationFailed` - a file was found and parsed, but its content did
2324
+ * not satisfy the runtime schema.
2325
+ * - `configFunctionFailed` - a function-form config threw or its returned
2326
+ * promise rejected while being invoked.
2370
2327
  *
2328
+ * @param options - Loader options.
2329
+ * @returns `Ok` with the validated `Config`, or `Err` with a `ConfigError`.
2371
2330
  * @example
2372
2331
  *
2373
2332
  * ```ts
2374
- * import { defaultKindRegistry } from "@bedrock-rbx/core";
2333
+ * import { loadConfig } from "@bedrock-rbx/core";
2375
2334
  *
2376
- * expect(defaultKindRegistry.gamePass.kind).toBe("gamePass");
2377
- * expect(defaultKindRegistry.place.kind).toBe("place");
2378
- * expect(defaultKindRegistry.universe.kind).toBe("universe");
2379
- * expect(defaultKindRegistry.developerProduct.kind).toBe("developerProduct");
2335
+ * return loadConfig({
2336
+ * configFile: "bedrock.staging.config.yaml",
2337
+ * cwd: "/path/that/does/not/have/a/config",
2338
+ * }).then((result) => {
2339
+ * expect(result.success).toBeFalse();
2340
+ * if (!result.success) {
2341
+ * expect(result.err.kind).toBe("fileNotFound");
2342
+ * }
2343
+ * });
2380
2344
  * ```
2381
2345
  */
2382
- declare const defaultKindRegistry: KindRegistry;
2346
+ declare function loadConfig(options?: LoadConfigOptions): Promise<Result$1<Config, ConfigError>>;
2383
2347
  //#endregion
2384
- //#region src/core/migrate/migration-report.d.ts
2348
+ //#region src/shell/deploy.d.ts
2385
2349
  /**
2386
- * Per-environment in-memory state snapshot map keyed by environment name.
2387
- *
2388
- * `Record` rather than the PRD-suggested `Map` so the field survives
2389
- * `JSON.stringify` for downstream logging and parallel-iterates cleanly
2390
- * with `Config.environments` (which is itself a `Record`).
2350
+ * Inputs for `deploy`. Every field except `environment` is optional;
2351
+ * omitted dependencies are default-constructed from the project config
2352
+ * and the environment variables `GITHUB_TOKEN` and `BEDROCK_API_KEY`.
2391
2353
  */
2392
- type StatesByEnvironment = Readonly<Record<string, BedrockState>>;
2354
+ interface DeployOptions {
2355
+ /** Pre-loaded, optionally-mutated project config. Omit to call `loadConfig()` automatically. */
2356
+ readonly config?: Config;
2357
+ /** Environment name; threaded into `StatePort.read` and the persisted snapshot. */
2358
+ readonly environment: string;
2359
+ /** `fetch` override plumbed into the default-constructed gist adapter when `statePort` is omitted. */
2360
+ readonly fetch?: GistFetch;
2361
+ /** Reads an environment variable; defaults to `(name) => process.env[name]`. */
2362
+ readonly getEnv?: (name: string) => string | undefined;
2363
+ /** Loader invoked when `config` is omitted; defaults to `loadConfig` from this package. */
2364
+ readonly loadConfig?: (options?: LoadConfigOptions) => Promise<Result$1<Config, ConfigError>>;
2365
+ /** Reads file bytes for resources that have file-backed inputs. Defaults to `node:fs/promises.readFile`. */
2366
+ readonly readFile?: (path: string) => Promise<Uint8Array>;
2367
+ /** Per-kind driver table consulted for create / update dispatch. Default-constructed from `BEDROCK_API_KEY` when omitted. */
2368
+ readonly registry?: DriverRegistry;
2369
+ /** Backend used to read the prior snapshot and persist the new one. Default-constructed from `config.state` and `GITHUB_TOKEN` when omitted. */
2370
+ readonly statePort?: StatePort;
2371
+ }
2393
2372
  /**
2394
- * Aggregate counts for the four `MigrationWarning` kinds. Computed by
2395
- * folding `MigrationReport.warnings`; lets a CI gate skim totals without
2396
- * iterating every entry. All fields are zero on a clean migration.
2373
+ * Failure surfaced by `deploy`. Stage-tagged so callers can branch on
2374
+ * `kind` to distinguish reconciliation failures (`stateReadFailed`,
2375
+ * `applyFailed`, ...) from default-construction failures
2376
+ * (`configLoadFailed`, `stateNotConfigured`, `unknownEnvironment`,
2377
+ * `incompletePlaceEntry`, `incompleteUniverseEntry`, `missingCredential`,
2378
+ * `unsupportedBackend`, `registryConfigMissing`).
2397
2379
  */
2398
- interface MigrationSummary {
2399
- /** Number of `ambiguous` warnings emitted. */
2400
- readonly ambiguousCount: number;
2401
- /** Number of `blocked` warnings emitted. */
2402
- readonly blockedCount: number;
2403
- /** Number of `deferred` warnings emitted. */
2404
- readonly deferredCount: number;
2405
- /** Number of `interpretive` warnings emitted. */
2406
- readonly interpretiveCount: number;
2407
- }
2380
+ type DeployError = IncompletePlaceEntryError | IncompleteUniverseEntryError | MissingCredentialError | RegistryConfigError | StateNotConfiguredError | UnknownEnvironmentError | UnsupportedBackendError | {
2381
+ readonly cause: ApplyError;
2382
+ readonly kind: "applyFailed";
2383
+ } | {
2384
+ readonly cause: BuildDesiredError;
2385
+ readonly kind: "buildDesiredFailed";
2386
+ } | {
2387
+ readonly cause: ConfigError;
2388
+ readonly kind: "configLoadFailed";
2389
+ } | {
2390
+ readonly cause: StateError;
2391
+ readonly kind: "stateReadFailed";
2392
+ } | {
2393
+ readonly cause: StateError;
2394
+ readonly kind: "stateWriteFailed";
2395
+ readonly unsavedState: BedrockState;
2396
+ };
2408
2397
  /**
2409
- * Discriminated union describing one observation the migrator made about a
2410
- * Mantle field that did not flow straight into bedrock config or state.
2398
+ * Run a full reconcile end-to-end. Default-constructs missing deps from
2399
+ * the project config and the environment variables `GITHUB_TOKEN` and
2400
+ * `BEDROCK_API_KEY`; never reads `process.env` when `statePort`,
2401
+ * `registry`, and `config` are all supplied explicitly.
2411
2402
  *
2412
- * - `deferred` - bedrock plans to support the field once the matching
2413
- * resource kind ships; the migration is non-destructive.
2414
- * - `blocked` - no Open Cloud writable endpoint exists; Mantle was using a
2415
- * cookie or legacy API that bedrock cannot call.
2416
- * - `interpretive` - the migrator applied a documented mapping rule
2417
- * (cross-field fold, list-to-flag rewrite, URL-domain dispatch). Each
2418
- * rule names the bedrock-side path it produced and the rule it followed
2419
- * so the user can audit.
2420
- * - `ambiguous` - the field is mappable but unsafe to act on without
2421
- * user input; the migrator carries the hint forward instead of guessing.
2403
+ * @param options - Target environment plus optional overrides.
2404
+ * @returns The persisted `BedrockState` on success, or a stage-tagged
2405
+ * `DeployError` on failure.
2406
+ * @example
2422
2407
  *
2423
- * Every variant carries `mantlePath` rooted at the environment so the
2424
- * report is searchable (for example
2425
- * `production.experienceConfiguration_singleton.genre`).
2426
- */
2427
- type MigrationWarning = {
2428
- readonly bedrockPath: string;
2429
- readonly kind: "interpretive";
2430
- readonly mantlePath: string;
2431
- readonly rule: string;
2432
- } | {
2433
- readonly hint: string;
2434
- readonly kind: "ambiguous";
2435
- readonly mantlePath: string;
2436
- } | {
2437
- readonly kind: "blocked";
2438
- readonly mantlePath: string;
2439
- readonly reason: string;
2440
- } | {
2441
- readonly kind: "deferred";
2442
- readonly mantlePath: string;
2443
- readonly reason: string;
2444
- };
2445
- /**
2446
- * Failure surfaced by `migrateMantleState`. Plain-data discriminated
2447
- * union; narrow on `kind` rather than using `instanceof`.
2408
+ * ```ts
2409
+ * import { deploy } from "@bedrock-rbx/core";
2448
2410
  *
2449
- * - `stateFileNotFound` - `deps.readFile` threw with `code: "ENOENT"`;
2450
- * the file does not exist at the supplied path. Permission failures
2451
- * (`EACCES`, `EPERM`) and other I/O errors are re-thrown rather than
2452
- * wrapped here, so callers see the original code on the rejection.
2453
- * - `stateParseFailed` - the YAML parser refused the file's contents.
2454
- * - `unsupportedMantleStateVersion` - the parsed file's `version` field is
2455
- * not one of the values in `supported`. V0.1 supports `"6"` only; older
2456
- * versions need to be upgraded with any recent Mantle release first.
2457
- * - `primaryEnvironmentRequired` - the input has more than one environment
2458
- * and `deps.primaryEnvironment` was not supplied. The migrator refuses
2459
- * to silently pick a winner.
2460
- * - `primaryEnvironmentNotFound` - `deps.primaryEnvironment` does not match
2461
- * any environment in the input.
2462
- * - `internalError` - the migrator's own emitted config failed
2463
- * `validateConfig`; `cause` carries the `ConfigError` so callers can
2464
- * inspect each `validationFailed` issue. Defensive bug catcher that
2465
- * callers should never see in practice.
2411
+ * return deploy({ environment: "production" }).then((result) => {
2412
+ * expect(result.success).toBeFalse();
2413
+ * if (!result.success) {
2414
+ * expect(["configLoadFailed", "stateNotConfigured"]).toContain(result.err.kind);
2415
+ * }
2416
+ * });
2417
+ * ```
2418
+ *
2419
+ * @example
2420
+ *
2421
+ * ```ts
2422
+ * import { deploy, type BedrockState, type DriverRegistry, type StatePort } from "@bedrock-rbx/core";
2423
+ *
2424
+ * const store = new Map<string, BedrockState>();
2425
+ * const statePort: StatePort = {
2426
+ * async read(environment) {
2427
+ * return { data: store.get(environment), success: true };
2428
+ * },
2429
+ * async write(state) {
2430
+ * store.set(state.environment, state);
2431
+ * return { data: undefined, success: true };
2432
+ * },
2433
+ * };
2434
+ * const registry: DriverRegistry = {
2435
+ * developerProduct: {
2436
+ * create: async () => { throw new Error("unreachable: empty config"); },
2437
+ * },
2438
+ * gamePass: { create: async () => { throw new Error("unreachable: empty config"); } },
2439
+ * place: { create: async () => { throw new Error("unreachable: empty config"); } },
2440
+ * universe: { create: async () => { throw new Error("unreachable: empty config"); } },
2441
+ * };
2442
+ *
2443
+ * return deploy({
2444
+ * config: {
2445
+ * environments: { production: {} },
2446
+ * state: { backend: "gist", gistId: "abc" },
2447
+ * passes: {},
2448
+ * },
2449
+ * environment: "production",
2450
+ * registry,
2451
+ * statePort,
2452
+ * }).then((result) => {
2453
+ * expect(result.success).toBeTrue();
2454
+ * if (result.success) {
2455
+ * expect(result.data.environment).toBe("production");
2456
+ * expect(result.data.resources).toBeEmpty();
2457
+ * }
2458
+ * });
2459
+ * ```
2466
2460
  */
2467
- type MigrateError = {
2468
- readonly available: ReadonlyArray<string>;
2469
- readonly kind: "primaryEnvironmentNotFound";
2470
- readonly primary: string;
2471
- } | {
2472
- readonly available: ReadonlyArray<string>;
2473
- readonly kind: "primaryEnvironmentRequired";
2474
- } | {
2475
- readonly cause: ConfigError;
2476
- readonly kind: "internalError";
2477
- readonly reason: string;
2478
- } | {
2479
- readonly found: string;
2480
- readonly kind: "unsupportedMantleStateVersion";
2481
- readonly supported: ReadonlyArray<string>;
2482
- } | {
2483
- readonly kind: "stateFileNotFound";
2484
- readonly path: string;
2485
- } | {
2486
- readonly kind: "stateParseFailed";
2487
- readonly path: string;
2488
- readonly reason: string;
2489
- };
2461
+ declare function deploy(options: DeployOptions): Promise<Result$1<BedrockState, DeployError>>;
2462
+ //#endregion
2463
+ //#region src/cli/render.d.ts
2490
2464
  /**
2491
- * Result returned by a successful `migrateMantleState` call.
2465
+ * Output port the CLI renders through. Mirrors the subset of `@clack/prompts`
2466
+ * the bedrock CLI uses today; tests inject a fake to assert what was rendered.
2492
2467
  *
2493
- * `config` is the bedrock-shape projection of the Mantle state file,
2494
- * already validated against the runtime schema (a failure to validate
2495
- * surfaces as `MigrateError.internalError`, not as a returned report).
2468
+ * @example
2496
2469
  *
2497
- * `configFileContent` is the same data rendered as TypeScript source
2498
- * (`defineConfig({...})`) so the caller can write it straight to disk
2499
- * without re-serializing. `loadConfig` round-trips it cleanly.
2470
+ * ```ts
2471
+ * import type { ClackPort } from "@bedrock-rbx/core";
2472
+ *
2473
+ * const lines: Array<string> = [];
2474
+ * const port: ClackPort = {
2475
+ * cancel: (message) => lines.push(`cancel: ${message}`),
2476
+ * intro: (message) => lines.push(`intro: ${message}`),
2477
+ * logError: (message) => lines.push(`error: ${message}`),
2478
+ * logMessage: (message) => lines.push(`log: ${message}`),
2479
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
2480
+ * outro: (message) => lines.push(`outro: ${message}`),
2481
+ * };
2500
2482
  *
2501
- * `statesByEnvironment` carries one in-memory `BedrockState` per
2502
- * environment from the input. Truthful per environment (no factorization)
2503
- * so `bedrock deploy --env=<env>` produces zero ops on first run.
2483
+ * port.logSuccess("done");
2504
2484
  *
2505
- * `warnings` and `summary` describe what the migrator did *not* migrate
2506
- * verbatim, classified for triage. The skeleton emits no warnings.
2485
+ * expect(lines).toEqual(["ok: done"]);
2486
+ * ```
2507
2487
  */
2508
- interface MigrationReport {
2509
- /** Validated bedrock config built from the Mantle state file. */
2510
- readonly config: Config;
2511
- /** Same `config` rendered as TypeScript source the caller can write to disk. */
2512
- readonly configFileContent: string;
2513
- /** One `BedrockState` per environment in the input, keyed by environment name. */
2514
- readonly statesByEnvironment: StatesByEnvironment;
2515
- /** Aggregate counts of `warnings` by kind. */
2516
- readonly summary: MigrationSummary;
2517
- /** One entry per non-trivial mapping or skipped Mantle field. */
2518
- readonly warnings: ReadonlyArray<MigrationWarning>;
2488
+ interface ClackPort {
2489
+ /** End an interactive flow with a cancellation marker. */
2490
+ cancel(message: string): void;
2491
+ /** Open a framed section with a title (used for command intros). */
2492
+ intro(message: string): void;
2493
+ /** Render a single error line inside an open frame. */
2494
+ logError(message: string): void;
2495
+ /** Render a single neutral line inside an open frame. */
2496
+ logMessage(message: string): void;
2497
+ /** Render a single success line inside an open frame. */
2498
+ logSuccess(message: string): void;
2499
+ /** Close the current framed section with a final message. */
2500
+ outro(message: string): void;
2519
2501
  }
2520
2502
  //#endregion
2521
- //#region src/core/resolve-state-config.d.ts
2503
+ //#region src/ports/progress-port.d.ts
2522
2504
  /**
2523
- * Failure surfaced when no `StateConfig` is configured for the requested
2524
- * environment. The shell layer wraps this in a `DeployError` when default
2525
- * state-port construction is requested but the project has not declared
2526
- * where state should live.
2505
+ * Per-environment outcome event emitted after a deploy completes
2506
+ * successfully. Carries the environment name and the count of resources
2507
+ * present in the persisted state snapshot.
2527
2508
  */
2528
- interface StateNotConfiguredError {
2529
- /** Environment that the resolver was called against. */
2509
+ interface DeploySuccessEvent {
2510
+ /** The environment that finished reconciling. */
2530
2511
  readonly environment: string;
2531
- /** Literal discriminator for narrowing. */
2532
- readonly kind: "stateNotConfigured";
2512
+ /** Discriminator tag. */
2513
+ readonly kind: "deploySuccess";
2514
+ /** Number of resources in the post-deploy state snapshot. */
2515
+ readonly resourceCount: number;
2533
2516
  }
2534
2517
  /**
2535
- * Minimal structural input the state resolver needs. Both `Config`
2536
- * (pre-merge, discriminated XOR union) and `ResolvedConfig` (post-merge)
2537
- * satisfy this shape, so callers can route either in without coupling
2538
- * the resolver to the discriminated-union arms.
2518
+ * Per-environment outcome event emitted when a deploy fails. Carries the
2519
+ * environment name and the full {@link DeployError} so a renderer can
2520
+ * delegate to the existing diagnostic helpers.
2539
2521
  */
2540
- interface StateResolutionInputs {
2541
- readonly environments: Record<string, undefined | {
2542
- readonly state?: StateConfig;
2543
- }>;
2544
- readonly state?: StateConfig;
2522
+ interface DeployFailureEvent {
2523
+ /** The environment whose deploy failed. */
2524
+ readonly environment: string;
2525
+ /** Stage-tagged failure reason returned by the shell `deploy` function. */
2526
+ readonly error: DeployError;
2527
+ /** Discriminator tag. */
2528
+ readonly kind: "deployFailure";
2545
2529
  }
2546
2530
  /**
2547
- * Pick the `StateConfig` that applies to `environment`. Per-environment
2548
- * overrides win over the root block; if neither is present, returns
2549
- * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
2550
- * error instead of silently falling back.
2531
+ * Discriminated union of progress events the CLI emits while a deploy
2532
+ * runs. The variant set is additive: future per-stage and per-resource
2533
+ * events land as new `kind` values without breaking existing adapters.
2534
+ */
2535
+ type ProgressEvent = DeployFailureEvent | DeploySuccessEvent;
2536
+ /**
2537
+ * Plugin contract for receiving deploy outcomes: the interface an adapter
2538
+ * (clack renderer, JSON logger, custom UI) implements to let the CLI hand
2539
+ * off events without re-implementing rendering logic.
2540
+ *
2541
+ * `ProgressPort` is a *driven* (secondary) port in hexagonal terms.
2551
2542
  *
2552
- * @param config - Validated project config.
2553
- * @param environment - Target environment name.
2554
- * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
2555
- * neither the environment override nor the root block is set.
2556
2543
  * @example
2557
2544
  *
2558
2545
  * ```ts
2559
- * import { resolveStateConfig } from "@bedrock-rbx/core";
2546
+ * import type { ProgressEvent, ProgressPort } from "@bedrock-rbx/core";
2560
2547
  *
2561
- * const result = resolveStateConfig(
2562
- * {
2563
- * state: { backend: "gist", gistId: "root-gist" },
2564
- * environments: {
2565
- * production: { state: { backend: "gist", gistId: "prod-gist" } },
2566
- * },
2548
+ * let received: ReadonlyArray<ProgressEvent> = [];
2549
+ * const port: ProgressPort = {
2550
+ * emit(event) {
2551
+ * received = [...received, event];
2567
2552
  * },
2568
- * "production",
2569
- * );
2553
+ * };
2570
2554
  *
2571
- * expect(result.success).toBeTrue();
2572
- * if (result.success) {
2573
- * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
2574
- * }
2555
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
2556
+ *
2557
+ * expect(received).toEqual([
2558
+ * { environment: "production", kind: "deploySuccess", resourceCount: 3 },
2559
+ * ]);
2575
2560
  * ```
2576
2561
  */
2577
- declare function resolveStateConfig(config: StateResolutionInputs, environment: string): Result$1<StateConfig, StateNotConfiguredError>;
2562
+ interface ProgressPort {
2563
+ /** Hand a single progress event to the adapter for rendering or logging. */
2564
+ emit(event: ProgressEvent): void;
2565
+ }
2578
2566
  //#endregion
2579
- //#region src/core/select-environment.d.ts
2567
+ //#region src/adapters/clack-progress-adapter.d.ts
2580
2568
  /**
2581
- * Failure surfaced when `selectEnvironment` is asked for an environment
2582
- * name that is not a key of `config.environments`. Carries the list of
2583
- * declared names so callers can render a "did you mean?" hint or a
2584
- * close-match suggestion.
2569
+ * Configuration for {@link createClackProgressAdapter}.
2585
2570
  */
2586
- interface UnknownEnvironmentError {
2587
- /** Environment names that the config actually declared. */
2588
- readonly declared: ReadonlyArray<string>;
2589
- /** Environment name the caller asked for. */
2590
- readonly environment: string;
2591
- /** Literal discriminator for narrowing. */
2592
- readonly kind: "unknownEnvironment";
2571
+ interface ClackProgressAdapterDeps {
2572
+ /** Output port the events are rendered through. */
2573
+ readonly clack: ClackPort;
2593
2574
  }
2594
2575
  /**
2595
- * Failure surfaced when a merged place entry is missing a required field.
2596
- * Two paths reach this error: a root place declared without a matching
2597
- * per-environment overlay supplying `placeId`, and an overlay-only place
2598
- * declared under `environments.X.places` with no matching root entry to
2599
- * supply `filePath`. Surfacing both at the resolution boundary attributes
2600
- * the missing field to the offending entry's key instead of letting
2601
- * `buildDesired` crash with a generic `fileReadFailed` later on.
2576
+ * Build a {@link ProgressPort} that renders events through a `ClackPort`.
2577
+ * Pattern-matches on the event `kind`: `deploySuccess` becomes a single
2578
+ * success line and `deployFailure` delegates to the package's deploy-error
2579
+ * rendering helper.
2580
+ *
2581
+ * @example
2582
+ *
2583
+ * ```ts
2584
+ * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
2585
+ *
2586
+ * const lines: Array<string> = [];
2587
+ * const clack: ClackPort = {
2588
+ * cancel: (message) => lines.push(`cancel: ${message}`),
2589
+ * intro: (message) => lines.push(`intro: ${message}`),
2590
+ * logError: (message) => lines.push(`error: ${message}`),
2591
+ * logMessage: (message) => lines.push(`log: ${message}`),
2592
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
2593
+ * outro: (message) => lines.push(`outro: ${message}`),
2594
+ * };
2595
+ *
2596
+ * const port = createClackProgressAdapter({ clack });
2597
+ *
2598
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
2599
+ *
2600
+ * expect(lines).toEqual(["ok: production: 3 resources reconciled"]);
2601
+ * ```
2602
+ *
2603
+ * @param deps - The clack port the adapter renders through.
2604
+ * @returns A `ProgressPort` that renders via clack.
2602
2605
  */
2603
- interface IncompletePlaceEntryError {
2604
- /** ResourceKey of the place entry that is missing a required field. */
2605
- readonly key: string;
2606
- /** Environment whose overlay was projected onto the config. */
2607
- readonly environment: string;
2608
- /** Literal discriminator for narrowing. */
2609
- readonly kind: "incompletePlaceEntry";
2610
- /** Field that the merged entry lacks. */
2611
- readonly missingField: "filePath" | "placeId";
2612
- }
2606
+ declare function createClackProgressAdapter(deps: ClackProgressAdapterDeps): ProgressPort;
2607
+ //#endregion
2608
+ //#region src/adapters/developer-product-driver.d.ts
2613
2609
  /**
2614
- * Failure surfaced when a merged `universe` block lacks `universeId`.
2615
- * The schema-level XOR rule normally prevents this by requiring
2616
- * `universeId` either at the root or on every per-environment overlay;
2617
- * this error remains as a typed safety net for callers that bypass
2618
- * `validateConfig` and hand a `Config` to `selectEnvironment` directly.
2619
- */
2620
- interface IncompleteUniverseEntryError {
2621
- /** Environment whose overlay was projected onto the config. */
2622
- readonly environment: string;
2623
- /** Literal discriminator for narrowing. */
2624
- readonly kind: "incompleteUniverseEntry";
2625
- /** Field that the merged entry lacks. V1 only surfaces `"universeId"`. */
2626
- readonly missingField: "universeId";
2610
+ * Dependencies of `createDeveloperProductDriver`. `universeId` is captured
2611
+ * at construction time (matching `GamePassDriverDeps`) so each driver
2612
+ * instance is bound to a single universe; multi-universe deploys construct
2613
+ * one driver per universe. `readFile` exists on the driver (not upstream
2614
+ * in shell) because icon hashes flow through `diff` but bytes do not.
2615
+ *
2616
+ * @example
2617
+ *
2618
+ * ```ts
2619
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2620
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
2621
+ * import { asRobloxAssetId, type DeveloperProductDriverDeps } from "@bedrock-rbx/core";
2622
+ *
2623
+ * const httpClient: HttpClient = {
2624
+ * async request() {
2625
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2626
+ * },
2627
+ * };
2628
+ *
2629
+ * const deps: DeveloperProductDriverDeps = {
2630
+ * client: new DeveloperProductsClient({
2631
+ * apiKey: "rbx-your-key",
2632
+ * httpClient,
2633
+ * sleep: async () => {},
2634
+ * }),
2635
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2636
+ * universeId: asRobloxAssetId("1234567890"),
2637
+ * };
2638
+ *
2639
+ * expect(deps.universeId).toBe("1234567890");
2640
+ * ```
2641
+ */
2642
+ interface DeveloperProductDriverDeps {
2643
+ /** Configured developer-products client from `@bedrock-rbx/ocale/developer-products`. */
2644
+ readonly client: DeveloperProductsClient;
2645
+ /** Reads icon bytes for upload; rejections propagate out of `create` and `update`. */
2646
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2647
+ /** Universe that owns every developer product this driver creates. */
2648
+ readonly universeId: RobloxAssetId;
2627
2649
  }
2628
- /** Failure modes returned by {@link selectEnvironment}. */
2629
- type SelectEnvironmentError = IncompletePlaceEntryError | IncompleteUniverseEntryError | UnknownEnvironmentError;
2630
2650
  /**
2631
- * Project a validated `Config` onto a single environment. Looks up the
2632
- * matching `environments[environment]` entry, deep-merges its resource
2633
- * overlay (`passes`, `places`, `universe`) over the root config via defu,
2634
- * and applies the env-level state override when present (the env entry's
2635
- * `state` field wins; otherwise the root `state` flows through).
2651
+ * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
2652
+ * that maps a desired-state entry to an ocale create or update call and the
2653
+ * response back to a `ResourceCurrentState<"developerProduct">`. The
2654
+ * `update` path consumes the upstream `204 No Content` response and
2655
+ * synthesizes the post-update `ResourceCurrentState` from `desired` plus
2656
+ * the existing `current.outputs`, carrying `iconImageAssetId` forward when
2657
+ * present.
2636
2658
  *
2637
- * Pure: no I/O. Returns a `ResolvedConfig` ready to feed into downstream
2638
- * functions (`flattenConfig`, `buildDefaultRegistry`, `resolveStateConfig`).
2639
- * The post-merge view promotes `places` from `Record<string, PlaceEntry>`
2640
- * (root: file-paths only) to `Record<string, ResolvedPlaceEntry>` (root +
2641
- * overlay merged). `environments` and `extends` are passed through
2642
- * unchanged because they preserve the shape relationship to `Config`;
2643
- * downstream consumers do not read them post-merge.
2659
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
2644
2660
  *
2645
- * Defu's merge semantics are deliberate: keyed-map collections merge by
2646
- * key (so a place declared in both root and overlay produces a single
2647
- * entry whose overlay-supplied fields win), and `null` / `undefined` in
2648
- * the overlay are skipped (so the overlay never deletes a root field).
2649
- * State has its own resolution path (a single replacement, not a
2650
- * deep-merge) because it is a tagged union: a deep-merge of
2651
- * `{ backend: "s3" }` over `{ backend: "gist", gistId }` would produce
2652
- * a malformed `{ backend: "s3", gistId }`.
2661
+ * @param deps - Injected ocale client and owning universe.
2662
+ * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
2653
2663
  *
2654
- * State is left absent when neither the env override nor the root block
2655
- * provides one. Callers that require a resolved `StateConfig` should
2656
- * route through `resolveStateConfig` or `buildStatePort`; the absent
2657
- * case surfaces as a typed `stateNotConfigured` there.
2664
+ * @example
2658
2665
  *
2659
- * Limitation in v1: a per-environment universe overlay that introduces a
2660
- * brand-new universe block may still have optional fields missing, since
2661
- * the overlay type only requires the identity-bearing key. The resolver
2662
- * surfaces the entry as-is; the universe driver reports the missing
2663
- * field when it tries to consume the entry. Universe is a singleton with
2664
- * 20+ optional fields, so the same `incompletePlaceEntry`-style validation
2665
- * is deferred to a separate follow-up.
2666
+ * ```ts
2667
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2668
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
2669
+ * import {
2670
+ * asResourceKey,
2671
+ * asRobloxAssetId,
2672
+ * createDeveloperProductDriver,
2673
+ * } from "@bedrock-rbx/core";
2674
+ *
2675
+ * const httpClient: HttpClient = {
2676
+ * async request() {
2677
+ * return {
2678
+ * data: {
2679
+ * body: {
2680
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
2681
+ * description: "Stocks the player up with 1,000 premium gems.",
2682
+ * iconImageAssetId: null,
2683
+ * isForSale: false,
2684
+ * isImmutable: false,
2685
+ * name: "Gem Pack",
2686
+ * priceInformation: null,
2687
+ * productId: 9_876_543_210,
2688
+ * storePageEnabled: false,
2689
+ * universeId: 1_234_567_890,
2690
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
2691
+ * },
2692
+ * headers: {},
2693
+ * status: 200,
2694
+ * },
2695
+ * success: true,
2696
+ * };
2697
+ * },
2698
+ * };
2699
+ *
2700
+ * const driver = createDeveloperProductDriver({
2701
+ * client: new DeveloperProductsClient({
2702
+ * apiKey: "rbx-your-key",
2703
+ * httpClient,
2704
+ * sleep: async () => {},
2705
+ * }),
2706
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2707
+ * universeId: asRobloxAssetId("1234567890"),
2708
+ * });
2709
+ *
2710
+ * return driver
2711
+ * .create({
2712
+ * description: "Stocks the player up with 1,000 premium gems.",
2713
+ * isRegionalPricingEnabled: undefined,
2714
+ * key: asResourceKey("gem-pack"),
2715
+ * kind: "developerProduct",
2716
+ * name: "Gem Pack",
2717
+ * price: undefined,
2718
+ * storePageEnabled: undefined,
2719
+ * })
2720
+ * .then((result) => {
2721
+ * expect(result.success).toBeTrue();
2722
+ * if (result.success) {
2723
+ * expect(result.data.outputs.productId).toBe("9876543210");
2724
+ * }
2725
+ * });
2726
+ * ```
2727
+ */
2728
+ declare function createDeveloperProductDriver(deps: DeveloperProductDriverDeps): ResourceDriver<"developerProduct">;
2729
+ //#endregion
2730
+ //#region src/adapters/game-pass-driver.d.ts
2731
+ /**
2732
+ * `universeId` is captured at construction time rather than on
2733
+ * `GamePassDesiredState` so state files round-trip with Mantle's `PassInputs`
2734
+ * shape. `readFile` exists on the driver (not upstream in shell) because icon
2735
+ * hashes flow through `diff` but bytes do not.
2736
+ *
2737
+ * @example
2738
+ *
2739
+ * ```ts
2740
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2741
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
2742
+ * import { asRobloxAssetId, type GamePassDriverDeps } from "@bedrock-rbx/core";
2743
+ *
2744
+ * const httpClient: HttpClient = {
2745
+ * async request() {
2746
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2747
+ * },
2748
+ * };
2749
+ *
2750
+ * const deps: GamePassDriverDeps = {
2751
+ * client: new GamePassesClient({
2752
+ * apiKey: "rbx-your-key",
2753
+ * httpClient,
2754
+ * sleep: async () => {},
2755
+ * }),
2756
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2757
+ * universeId: asRobloxAssetId("1234567890"),
2758
+ * };
2759
+ *
2760
+ * expect(deps.universeId).toBe("1234567890");
2761
+ * ```
2762
+ */
2763
+ interface GamePassDriverDeps {
2764
+ /** Configured game-passes client from `@bedrock-rbx/ocale/game-passes`. */
2765
+ readonly client: GamePassesClient;
2766
+ /** Reads icon bytes for upload; rejections propagate out of `create`. */
2767
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2768
+ /** Universe that owns every game pass this driver creates. */
2769
+ readonly universeId: RobloxAssetId;
2770
+ }
2771
+ /**
2772
+ * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
2773
+ * a desired-state entry to an ocale create call and the response back to a
2774
+ * `ResourceCurrentState<"gamePass">`.
2775
+ *
2776
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
2777
+ * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
2778
+ * shape and propagate as promise rejections; shell callers are expected to
2779
+ * translate them if a unified error surface is required.
2780
+ *
2781
+ * @param deps - Injected ocale client, file reader, and owning universe.
2782
+ * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
2783
+ * @throws Whatever `deps.readFile` rejects with.
2784
+ *
2785
+ * @example
2786
+ *
2787
+ * ```ts
2788
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2789
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
2790
+ * import {
2791
+ * asResourceKey,
2792
+ * asRobloxAssetId,
2793
+ * asSha256Hex,
2794
+ * createGamePassDriver,
2795
+ * } from "@bedrock-rbx/core";
2796
+ *
2797
+ * const httpClient: HttpClient = {
2798
+ * async request() {
2799
+ * return {
2800
+ * data: {
2801
+ * body: {
2802
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
2803
+ * description: "Grants VIP perks.",
2804
+ * gamePassId: 9_876_543_210,
2805
+ * iconAssetId: 1_122_334_455,
2806
+ * isForSale: true,
2807
+ * name: "VIP Pass",
2808
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
2809
+ * },
2810
+ * headers: {},
2811
+ * status: 200,
2812
+ * },
2813
+ * success: true,
2814
+ * };
2815
+ * },
2816
+ * };
2817
+ *
2818
+ * const driver = createGamePassDriver({
2819
+ * client: new GamePassesClient({
2820
+ * apiKey: "rbx-your-key",
2821
+ * httpClient,
2822
+ * sleep: async () => {},
2823
+ * }),
2824
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2825
+ * universeId: asRobloxAssetId("1234567890"),
2826
+ * });
2827
+ *
2828
+ * return driver
2829
+ * .create({
2830
+ * description: "Grants VIP perks.",
2831
+ * icon: { "en-us": "assets/vip-icon.png" },
2832
+ * iconFileHashes: {
2833
+ * "en-us": asSha256Hex(
2834
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2835
+ * ),
2836
+ * },
2837
+ * key: asResourceKey("vip-pass"),
2838
+ * kind: "gamePass",
2839
+ * name: "VIP Pass",
2840
+ * price: 500,
2841
+ * })
2842
+ * .then((result) => {
2843
+ * expect(result.success).toBeTrue();
2844
+ * if (result.success) {
2845
+ * expect(result.data.outputs.assetId).toBe("9876543210");
2846
+ * }
2847
+ * });
2848
+ * ```
2849
+ */
2850
+ declare function createGamePassDriver(deps: GamePassDriverDeps): ResourceDriver<"gamePass">;
2851
+ //#endregion
2852
+ //#region src/adapters/no-op-progress-adapter.d.ts
2853
+ /**
2854
+ * Build a {@link ProgressPort} that silently drops every event. Useful for
2855
+ * tests and programmatic callers who want to invoke deploy logic without
2856
+ * any rendering.
2857
+ *
2858
+ * @example
2859
+ *
2860
+ * ```ts
2861
+ * import { createNoOpProgressAdapter } from "@bedrock-rbx/core";
2666
2862
  *
2667
- * When the project sets a `displayNamePrefix` (or omits it, in which case
2668
- * prefixing defaults to enabled) and the chosen environment declares a
2669
- * non-empty `label`, the resolver renders the configured template via
2670
- * `renderDisplayNamePrefix` and prepends the result to `universe.displayName`
2671
- * and every declared place `displayName`. An undeclared `displayName`, an
2672
- * empty/absent label, or an explicit `displayNamePrefix.enabled: false` all
2673
- * skip prefixing for the affected fields.
2863
+ * const port = createNoOpProgressAdapter();
2864
+ *
2865
+ * expect(() =>
2866
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 }),
2867
+ * ).not.toThrow();
2868
+ * ```
2869
+ *
2870
+ * @returns A `ProgressPort` whose `emit` method is a no-op.
2871
+ */
2872
+ declare function createNoOpProgressAdapter(): ProgressPort;
2873
+ //#endregion
2874
+ //#region src/adapters/place-driver.d.ts
2875
+ /**
2876
+ * Dependencies of `createPlaceDriver`. `universeId` is captured at
2877
+ * construction time (matching `GamePassDriverDeps`) so each driver instance
2878
+ * is bound to a single universe; multi-universe deploys construct one driver
2879
+ * per universe. `readFile` is injected because `diff` operates on file hashes
2880
+ * while the driver is the only place that needs the raw bytes.
2674
2881
  *
2675
2882
  * @example
2676
2883
  *
2677
2884
  * ```ts
2678
- * import { selectEnvironment } from "@bedrock-rbx/core";
2679
- * import type { Config } from "@bedrock-rbx/core/config";
2885
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2886
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2887
+ * import { asRobloxAssetId, type PlaceDriverDeps } from "@bedrock-rbx/core";
2680
2888
  *
2681
- * const config: Config = {
2682
- * environments: {
2683
- * production: { universe: { universeId: "999" } },
2889
+ * const httpClient: HttpClient = {
2890
+ * async request() {
2891
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2684
2892
  * },
2685
- * state: { backend: "gist", gistId: "abc123" },
2686
- * universe: { voiceChatEnabled: true },
2687
2893
  * };
2688
2894
  *
2689
- * const result = selectEnvironment(config, "production");
2895
+ * const deps: PlaceDriverDeps = {
2896
+ * client: new PlacesClient({
2897
+ * apiKey: "rbx-your-key",
2898
+ * httpClient,
2899
+ * sleep: async () => {},
2900
+ * }),
2901
+ * readFile: async () => new Uint8Array(),
2902
+ * universeId: asRobloxAssetId("1234567890"),
2903
+ * };
2690
2904
  *
2691
- * expect(result.success).toBeTrue();
2692
- * if (result.success) {
2693
- * expect(result.data.universe?.universeId).toBe("999");
2694
- * expect(result.data.universe?.voiceChatEnabled).toBeTrue();
2695
- * expect(result.data.state?.backend).toBe("gist");
2696
- * }
2905
+ * expect(deps.universeId).toBe("1234567890");
2697
2906
  * ```
2698
- *
2699
- * @param config - Validated project config carrying at least one
2700
- * environment under `environments`.
2701
- * @param environment - Environment name to project onto. Must be a key
2702
- * of `config.environments`.
2703
- * @returns `Ok(ResolvedConfig)` with the merged resource fields and the
2704
- * resolved state, or `Err(SelectEnvironmentError)` describing why the
2705
- * projection failed.
2706
2907
  */
2707
- declare function selectEnvironment(config: Config, environment: string): Result$1<ResolvedConfig, SelectEnvironmentError>;
2708
- //#endregion
2709
- //#region src/core/state-file.d.ts
2908
+ interface PlaceDriverDeps {
2909
+ /** Configured places client from `@bedrock-rbx/ocale/places`. */
2910
+ readonly client: PlacesClient;
2911
+ /** Reads place-file bytes for upload; rejections propagate out of the driver. */
2912
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2913
+ /** Universe that owns every place this driver publishes. */
2914
+ readonly universeId: RobloxAssetId;
2915
+ }
2710
2916
  /**
2711
- * Serialize a {@link BedrockState} to the on-disk JSON representation used by
2712
- * state-port adapters.
2917
+ * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
2918
+ * `update` are both thin wrappers over a shared publish helper because the
2919
+ * upstream Open Cloud call is identical either way: there is no "create
2920
+ * place" endpoint (the place is user-supplied input), only "publish version".
2713
2921
  *
2714
- * The on-disk shape wraps the in-memory state with a
2715
- * `$bedrock: { version: N }` envelope so that a future breaking change to the
2716
- * schema can be detected and rejected at parse time rather than silently
2717
- * accepted. The top-level `version` field is not duplicated on disk.
2922
+ * Format is detected from the file extension (`.rbxl` → binary,
2923
+ * `.rbxlx` XML); any other extension returns an `ApiError`-backed failure
2924
+ * without hitting the network.
2925
+ *
2926
+ * @param deps - Injected ocale client, file reader, and owning universe.
2927
+ * @returns A driver indexable by `"place"` in a `DriverRegistry`.
2928
+ * @throws Whatever `deps.readFile` rejects with.
2718
2929
  *
2719
2930
  * @example
2720
2931
  *
2721
2932
  * ```ts
2722
- * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
2933
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2934
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2935
+ * import {
2936
+ * asResourceKey,
2937
+ * asRobloxAssetId,
2938
+ * asSha256Hex,
2939
+ * createPlaceDriver,
2940
+ * } from "@bedrock-rbx/core";
2723
2941
  *
2724
- * const state: BedrockState = {
2725
- * environment: "production",
2726
- * resources: [],
2727
- * version: 1,
2942
+ * const httpClient: HttpClient = {
2943
+ * async request() {
2944
+ * return {
2945
+ * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
2946
+ * success: true,
2947
+ * };
2948
+ * },
2728
2949
  * };
2729
2950
  *
2730
- * const wire = serializeStateFile(state);
2731
- * expect(JSON.parse(wire)).toStrictEqual({
2732
- * $bedrock: { version: 1 },
2733
- * environment: "production",
2734
- * resources: [],
2951
+ * const driver = createPlaceDriver({
2952
+ * client: new PlacesClient({
2953
+ * apiKey: "rbx-your-key",
2954
+ * httpClient,
2955
+ * sleep: async () => {},
2956
+ * }),
2957
+ * readFile: async () =>
2958
+ * new Uint8Array([
2959
+ * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
2960
+ * 0x0a,
2961
+ * ]),
2962
+ * universeId: asRobloxAssetId("1234567890"),
2735
2963
  * });
2736
- * ```
2737
2964
  *
2738
- * @param state - The in-memory state snapshot to serialize.
2739
- * @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
2965
+ * return driver
2966
+ * .create({
2967
+ * description: undefined,
2968
+ * displayName: undefined,
2969
+ * fileHash: asSha256Hex(
2970
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2971
+ * ),
2972
+ * filePath: "places/start.rbxl",
2973
+ * key: asResourceKey("start-place"),
2974
+ * kind: "place",
2975
+ * placeId: asRobloxAssetId("4711"),
2976
+ * serverSize: undefined,
2977
+ * })
2978
+ * .then((result) => {
2979
+ * expect(result.success).toBeTrue();
2980
+ * if (result.success) {
2981
+ * expect(result.data.outputs.versionNumber).toBe(1);
2982
+ * }
2983
+ * });
2984
+ * ```
2740
2985
  */
2741
- declare function serializeStateFile(state: BedrockState): string;
2986
+ declare function createPlaceDriver(deps: PlaceDriverDeps): ResourceDriver<"place">;
2987
+ //#endregion
2988
+ //#region src/adapters/universe-driver.d.ts
2742
2989
  /**
2743
- * Parse a raw on-disk state file into a {@link BedrockState}.
2990
+ * Dependencies of `createUniverseDriver`. The driver reconciles the
2991
+ * universe singleton against both the universes endpoint and the root
2992
+ * place (for fields Roblox marks read-only on the universe, like
2993
+ * `displayName`). There is no `universeId` at construction time because
2994
+ * the universe *is* the resource the driver reconciles, so the ID rides
2995
+ * along on each `UniverseDesiredState`.
2996
+ */
2997
+ interface UniverseDriverDeps {
2998
+ /** Configured places client from `@bedrock-rbx/ocale/places`. */
2999
+ readonly places: PlacesClient;
3000
+ /** Configured universes client from `@bedrock-rbx/ocale/universes`. */
3001
+ readonly universes: UniversesClient;
3002
+ }
3003
+ /**
3004
+ * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
3005
+ * and `update` both delegate to a shared reconcile helper because Open
3006
+ * Cloud cannot mint universes; the user supplies an existing `universeId`
3007
+ * and bedrock adopts the universe on first apply.
2744
3008
  *
2745
- * A backend that reports "no state file for this environment yet" must pass
2746
- * `undefined`: that distinguishes a legitimate first deploy from a file that
2747
- * exists but cannot be trusted.
3009
+ * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
3010
+ * as an adoption-error `ApiError` whose message names the config key and
3011
+ * the `universeId`, so operators can tell adoption failure apart from
3012
+ * transient upstream errors. A successful response whose `rootPlaceId` is
3013
+ * absent surfaces as an `ApiError` with status 200, mirroring the
3014
+ * malformed-response guard in `GamePassDriver`.
3015
+ *
3016
+ * When `displayName` is declared, the driver routes that field through
3017
+ * `PlacesClient.update` on the root place after the universe PATCH
3018
+ * succeeds. A subsequent places failure surfaces to the caller as the
3019
+ * driver's error result without rolling back the prior universe patch,
3020
+ * so callers observing a partial failure should reconcile by
3021
+ * reapplying rather than assuming the universe-level fields are
3022
+ * unchanged.
3023
+ *
3024
+ * @param deps - Injected ocale clients (universes plus places for the
3025
+ * read-only universe fields Roblox derives from the root place).
3026
+ * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2748
3027
  *
2749
3028
  * @example
2750
3029
  *
2751
3030
  * ```ts
2752
- * import { parseStateFile } from "@bedrock-rbx/core";
3031
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
3032
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
3033
+ * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
3034
+ * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
3035
+ * import {
3036
+ * asRobloxAssetId,
3037
+ * createUniverseDriver,
3038
+ * UNIVERSE_SINGLETON_KEY,
3039
+ * } from "@bedrock-rbx/core";
2753
3040
  *
2754
- * const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
2755
- * expect(freshStart.success).toBeTrue();
2756
- * if (freshStart.success) {
2757
- * expect(freshStart.data).toBeUndefined();
2758
- * }
2759
- * ```
3041
+ * const universeBodyHttpClient: HttpClient = {
3042
+ * async request() {
3043
+ * return {
3044
+ * data: {
3045
+ * body: validUniverseBody({
3046
+ * path: "universes/1234567890",
3047
+ * rootPlace: "universes/1234567890/places/4711",
3048
+ * }),
3049
+ * headers: {},
3050
+ * status: 200,
3051
+ * },
3052
+ * success: true,
3053
+ * };
3054
+ * },
3055
+ * };
2760
3056
  *
2761
- * @param raw - Raw file contents as a string, or `undefined` when the
2762
- * backend reports no file exists yet.
2763
- * @param file - Adapter-specific identifier included in any `StateError`
2764
- * surfaced during parsing.
2765
- * @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
2766
- * file, or `Err(StateError)` for anything that cannot be trusted.
3057
+ * const driver = createUniverseDriver({
3058
+ * places: new PlacesClient({
3059
+ * apiKey: "rbx-your-key",
3060
+ * httpClient: universeBodyHttpClient,
3061
+ * sleep: async () => {},
3062
+ * }),
3063
+ * universes: new UniversesClient({
3064
+ * apiKey: "rbx-your-key",
3065
+ * httpClient: universeBodyHttpClient,
3066
+ * sleep: async () => {},
3067
+ * }),
3068
+ * });
3069
+ *
3070
+ * return driver
3071
+ * .create({
3072
+ * consoleEnabled: undefined,
3073
+ * desktopEnabled: true,
3074
+ * displayName: undefined,
3075
+ * key: UNIVERSE_SINGLETON_KEY,
3076
+ * kind: "universe",
3077
+ * mobileEnabled: undefined,
3078
+ * privateServerPriceRobux: undefined,
3079
+ * tabletEnabled: undefined,
3080
+ * universeId: asRobloxAssetId("1234567890"),
3081
+ * voiceChatEnabled: true,
3082
+ * vrEnabled: undefined,
3083
+ * })
3084
+ * .then((result) => {
3085
+ * expect(result.success).toBeTrue();
3086
+ * if (result.success) {
3087
+ * expect(result.data.outputs.rootPlaceId).toBe("4711");
3088
+ * }
3089
+ * });
3090
+ * ```
2767
3091
  */
2768
- declare function parseStateFile(raw: string | undefined, file: string): Result$1<BedrockState | undefined, StateError>;
3092
+ declare function createUniverseDriver(deps: UniverseDriverDeps): ResourceDriver<"universe">;
2769
3093
  //#endregion
2770
- //#region src/core/validate-plan.d.ts
3094
+ //#region src/cli/clack-port.d.ts
2771
3095
  /**
2772
- * Plan-time invariant check that runs after `buildDesired` and before
2773
- * `diff`. Walks paired `(kind, key)` entries and dispatches to each
2774
- * kind module's optional `assertReconcilable` hook so kind-specific
2775
- * rejections (e.g. Removing a developer-product icon, which the upstream
2776
- * API has no documented unset path for) surface as typed errors before
2777
- * `diff` runs and before any apply-side driver I/O is attempted.
2778
- *
2779
- * Pure and synchronous. Current-only entries (no matching desired) are
2780
- * ignored: their reconciliation is `diff`'s concern, not this seam's.
2781
- *
2782
- * @param desired - Desired state from `buildDesired`.
2783
- * @param current - Prior current state from the state port.
2784
- * @returns `Ok(undefined)` when every paired entry passes its kind-level
2785
- * reconcilability check, or the first `Err` returned by a hook.
3096
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
3097
+ * resulting port writes to `process.stdout` via clack's defaults. Kept in
3098
+ * its own module so consumers that never need the clack-backed rendering
3099
+ * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
3100
+ * into their bundle.
2786
3101
  *
2787
3102
  * @example
2788
3103
  *
2789
3104
  * ```ts
2790
- * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3105
+ * import { createClackPort } from "@bedrock-rbx/core";
2791
3106
  *
2792
- * const result = validatePlan(
2793
- * [
2794
- * {
2795
- * description: "Stocks the player up with 1,000 premium gems.",
2796
- * isRegionalPricingEnabled: undefined,
2797
- * key: asResourceKey("gem-pack"),
2798
- * kind: "developerProduct",
2799
- * name: "Gem Pack",
2800
- * price: undefined,
2801
- * storePageEnabled: undefined,
2802
- * },
2803
- * ],
2804
- * [
2805
- * {
2806
- * description: "Stocks the player up with 1,000 premium gems.",
2807
- * icon: { "en-us": "assets/gem-pack.png" },
2808
- * iconFileHashes: {
2809
- * "en-us": asSha256Hex(
2810
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2811
- * ),
2812
- * },
2813
- * isRegionalPricingEnabled: undefined,
2814
- * key: asResourceKey("gem-pack"),
2815
- * kind: "developerProduct",
2816
- * name: "Gem Pack",
2817
- * outputs: { productId: asRobloxAssetId("9876543210") },
2818
- * price: undefined,
2819
- * storePageEnabled: undefined,
2820
- * },
2821
- * ],
2822
- * );
3107
+ * const port = createClackPort();
2823
3108
  *
2824
- * expect(result.success).toBeFalse();
2825
- * if (!result.success) {
2826
- * expect(result.err.kind).toBe("iconRemovalRejected");
2827
- * }
3109
+ * expect(typeof port.logSuccess).toBe("function");
2828
3110
  * ```
3111
+ *
3112
+ * @returns A port whose six methods each invoke the matching clack helper.
2829
3113
  */
2830
- declare function validatePlan(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): Result$1<undefined, BuildDesiredError>;
3114
+ declare function createClackPort(): ClackPort;
2831
3115
  //#endregion
2832
- //#region src/shell/apply-ops.d.ts
3116
+ //#region src/core/derive-price-fields.d.ts
2833
3117
  /**
2834
- * Failure surfaced by `applyOps` when an operation cannot be applied.
2835
- * Plain-data discriminated union; narrow on `kind`, do not `instanceof` it.
3118
+ * Wire-shape pricing fragment produced by {@link derivePriceFields}: the
3119
+ * `isForSale` flag and an optional numeric `price`. Mirrors the multipart
3120
+ * fields the Open Cloud `developer-products` create and update endpoints
3121
+ * accept for setting Robux pricing.
3122
+ */
3123
+ interface PriceFields {
3124
+ /** Whether the developer product should be purchasable. */
3125
+ readonly isForSale: boolean;
3126
+ /** Default price in Robux; absent when the product is off-sale. */
3127
+ readonly price?: number;
3128
+ }
3129
+ /**
3130
+ * Translate a Mantle-style optional price into the Open Cloud wire shape.
2836
3131
  *
2837
- * `appliedSoFar` carries the driver outputs from operations that succeeded
2838
- * before the failing one, in dispatched order. Callers persist this so a
2839
- * follow-up reconcile does not duplicate Roblox-side resources that have
2840
- * already been created or updated.
3132
+ * `desired.price === undefined` (no price declared) becomes
3133
+ * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
3134
+ * price (including `0`) becomes `{ isForSale: true, price }`. Both
3135
+ * `developerProduct` create and update paths share this helper so the
3136
+ * "absent ⇒ off-sale" semantics live in exactly one place.
3137
+ *
3138
+ * @param desired - Object carrying the user-declared `price`.
3139
+ * @returns The wire-shape `{ isForSale, price? }` fragment.
2841
3140
  *
2842
3141
  * @example
2843
3142
  *
2844
3143
  * ```ts
2845
- * import { asResourceKey, type ApplyError } from "@bedrock-rbx/core";
2846
- *
2847
- * function describe(err: ApplyError): string {
2848
- * switch (err.kind) {
2849
- * case "driverFailure": {
2850
- * return `driver failed for ${err.key}: ${err.cause.message}`;
2851
- * }
2852
- * case "updateUnsupported": {
2853
- * return `update not supported for ${err.key}`;
2854
- * }
2855
- * }
2856
- * }
2857
- *
2858
- * const err: ApplyError = {
2859
- * key: asResourceKey("vip-pass"),
2860
- * appliedSoFar: [],
2861
- * kind: "updateUnsupported",
2862
- * };
3144
+ * import { derivePriceFields } from "@bedrock-rbx/core";
2863
3145
  *
2864
- * expect(describe(err)).toBe("update not supported for vip-pass");
3146
+ * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
3147
+ * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
2865
3148
  * ```
2866
3149
  */
2867
- type ApplyError = {
2868
- readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
2869
- readonly cause: OpenCloudError$1;
2870
- readonly key: ResourceKey;
2871
- readonly kind: "driverFailure";
2872
- } | {
2873
- readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
2874
- readonly key: ResourceKey;
2875
- readonly kind: "updateUnsupported";
2876
- };
3150
+ declare function derivePriceFields(desired: {
3151
+ readonly price: number | undefined;
3152
+ }): PriceFields;
3153
+ //#endregion
3154
+ //#region src/core/diff.d.ts
2877
3155
  /**
2878
- * Dispatch each reconciliation operation to the matching resource driver
2879
- * with first-fail semantics: on the first `Err` (driver failure or
2880
- * `updateUnsupported`), the remaining operations are skipped and the error
2881
- * is returned verbatim.
3156
+ * Computes the operations required to reconcile `current` state with `desired`
3157
+ * state. Pure and synchronous: no I/O, no side effects, no `Result` wrapper.
2882
3158
  *
2883
- * Behaviour:
2884
- * - `create` operations are routed to `registry[op.desired.kind].create`.
2885
- * - `update` operations are routed to `registry[op.desired.kind].update`
2886
- * when the driver exposes it; otherwise they short-circuit to an
2887
- * `updateUnsupported` Err without invoking the driver.
2888
- * - `noop` operations are skipped entirely (no I/O, no dispatch).
3159
+ * Each entry in `desired` is matched to `current` by `(kind, key)`: resources
3160
+ * are uniquely identified by that pair, so a `place` and a `universe` keyed
3161
+ * `"main"` are independent slots. A `(kind, key)` pair present only in
3162
+ * `desired` produces a `create` op; a pair present in both produces an
3163
+ * `update` op if any declared field differs or a `noop` op if every field
3164
+ * matches.
2889
3165
  *
2890
- * On success the returned array carries the driver outputs for every
2891
- * non-noop op, in dispatched order. Noops are not represented; callers
2892
- * needing a full post-apply snapshot merge with the pre-apply current
2893
- * state keyed by `ResourceKey`.
3166
+ * Ops appear in the order their desired entries appear in the input array so
3167
+ * callers can rely on declaration order when logging or applying ops.
3168
+ *
3169
+ * @param desired - Declared desired state from user config, already normalized
3170
+ * (file hashes computed, nullable wire values mapped to `undefined`).
3171
+ * @param current - Last-known live state from the state file.
3172
+ * @returns Operations to reconcile the two snapshots.
2894
3173
  *
2895
- * @param ops - Reconciliation operations produced by `diff`, applied in order.
2896
- * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
2897
- * @returns `Ok(state)` when every operation succeeds, where `state` holds
2898
- * driver outputs for each non-noop op in dispatched order; or the first
2899
- * failure encountered.
2900
- * @throws Whatever the dispatched driver rejects with outside its `Result`
2901
- * return. A driver whose injected I/O (file reads, network calls, etc.)
2902
- * throws will surface that rejection here rather than translating it into
2903
- * a `Result` failure; wrap the call site in a try/catch when drivers are
2904
- * not trusted to contain their own rejections.
2905
3174
  * @example
2906
3175
  *
2907
3176
  * ```ts
2908
3177
  * import {
2909
- * applyOps,
2910
3178
  * asResourceKey,
2911
3179
  * asRobloxAssetId,
2912
3180
  * asSha256Hex,
2913
- * type DriverRegistry,
2914
- * type Operation,
3181
+ * diff,
3182
+ * type GamePassDesiredState,
3183
+ * type ResourceCurrentState,
2915
3184
  * } from "@bedrock-rbx/core";
2916
3185
  *
2917
- * const registry: DriverRegistry = {
2918
- * gamePass: {
2919
- * async create(desired) {
2920
- * return {
2921
- * data: {
2922
- * ...desired,
2923
- * outputs: {
2924
- * assetId: asRobloxAssetId("9876543210"),
2925
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
2926
- * },
2927
- * },
2928
- * success: true,
2929
- * };
2930
- * },
2931
- * },
2932
- * place: {
2933
- * async create(desired) {
2934
- * return {
2935
- * data: { ...desired, outputs: { versionNumber: 1 } },
2936
- * success: true,
2937
- * };
2938
- * },
2939
- * },
2940
- * universe: {
2941
- * async create(desired) {
2942
- * return {
2943
- * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
2944
- * success: true,
2945
- * };
2946
- * },
2947
- * },
2948
- * developerProduct: {
2949
- * async create(desired) {
2950
- * return {
2951
- * data: {
2952
- * ...desired,
2953
- * outputs: { productId: asRobloxAssetId("8172635495") },
2954
- * },
2955
- * success: true,
2956
- * };
2957
- * },
2958
- * },
3186
+ * const hash = asSha256Hex(
3187
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3188
+ * );
3189
+ *
3190
+ * const unchanged: GamePassDesiredState = {
3191
+ * description: "Grants VIP perks.",
3192
+ * icon: { "en-us": "assets/vip-icon.png" },
3193
+ * iconFileHashes: { "en-us": hash },
3194
+ * key: asResourceKey("vip-pass"),
3195
+ * kind: "gamePass",
3196
+ * name: "VIP Pass",
3197
+ * price: 500,
3198
+ * };
3199
+ * const drifted: GamePassDesiredState = {
3200
+ * ...unchanged,
3201
+ * key: asResourceKey("legend-pass"),
3202
+ * name: "Legend Pass (renamed)",
3203
+ * };
3204
+ * const fresh: GamePassDesiredState = {
3205
+ * ...unchanged,
3206
+ * key: asResourceKey("rookie-pass"),
3207
+ * name: "Rookie Pass",
2959
3208
  * };
2960
3209
  *
2961
- * const ops: ReadonlyArray<Operation> = [
3210
+ * const current: ReadonlyArray<ResourceCurrentState> = [
2962
3211
  * {
2963
- * key: asResourceKey("vip-pass"),
2964
- * type: "create",
2965
- * desired: {
2966
- * key: asResourceKey("vip-pass"),
2967
- * name: "VIP Pass",
2968
- * description: "Grants VIP perks.",
2969
- * icon: { "en-us": "assets/vip-icon.png" },
2970
- * iconFileHashes: {
2971
- * "en-us": asSha256Hex(
2972
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2973
- * ),
2974
- * },
2975
- * kind: "gamePass",
2976
- * price: 500,
3212
+ * ...unchanged,
3213
+ * outputs: {
3214
+ * assetId: asRobloxAssetId("111"),
3215
+ * iconAssetIds: { "en-us": asRobloxAssetId("222") },
3216
+ * },
3217
+ * },
3218
+ * {
3219
+ * ...drifted,
3220
+ * name: "Legend Pass",
3221
+ * outputs: {
3222
+ * assetId: asRobloxAssetId("333"),
3223
+ * iconAssetIds: { "en-us": asRobloxAssetId("444") },
2977
3224
  * },
2978
3225
  * },
2979
3226
  * ];
2980
3227
  *
2981
- * return applyOps(ops, registry).then((result) => {
2982
- * expect(result.success).toBe(true);
2983
- * expect(result.success && result.data).toHaveLength(1);
2984
- * });
3228
+ * const ops = diff([unchanged, drifted, fresh], current);
3229
+ *
3230
+ * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
2985
3231
  * ```
2986
3232
  */
2987
- declare function applyOps(ops: ReadonlyArray<Operation>, registry: DriverRegistry): Promise<Result$1<ReadonlyArray<ResourceCurrentState>, ApplyError>>;
3233
+ declare function diff(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): ReadonlyArray<Operation>;
2988
3234
  //#endregion
2989
- //#region src/shell/build-state-port.d.ts
2990
- /**
2991
- * Failure surfaced when a default-constructed adapter cannot find a
2992
- * required environment variable. The deploy boundary wraps this in a
2993
- * `DeployError` so the caller sees a typed Result instead of an
2994
- * exception or a confusing downstream HTTP error.
2995
- */
2996
- interface MissingCredentialError {
2997
- /** Literal discriminator for narrowing. */
2998
- readonly kind: "missingCredential";
2999
- /** Whether the credential was needed for the state backend or the driver registry. */
3000
- readonly purpose: "registry" | "stateBackend";
3001
- /** Environment variable name the default-construction path tried to read. */
3002
- readonly variable: string;
3003
- }
3235
+ //#region src/core/display-name-prefix.d.ts
3004
3236
  /**
3005
- * Failure surfaced when the dispatch helper sees a `state.backend` value
3006
- * it does not recognize. The hint points at `opts.statePort` so the
3007
- * caller can pass a custom adapter as an escape hatch.
3237
+ * Default template applied when a project enables display-name prefixing
3238
+ * without supplying its own `displayNamePrefix.format`. Yields outputs
3239
+ * like `[STAGING] ` for an environment whose `label` is `"staging"`.
3008
3240
  */
3009
- interface UnsupportedBackendError {
3010
- /** Backend name read from `state.backend`. */
3011
- readonly backend: string;
3012
- /** Suggested escape hatch routed back to the caller. */
3013
- readonly hint: string;
3014
- /** Literal discriminator for narrowing. */
3015
- readonly kind: "unsupportedBackend";
3016
- }
3017
- /** Inputs for {@link buildStatePort}. */
3018
- interface BuildStatePortDeps {
3019
- /** Optional `fetch` seam plumbed through to the gist adapter for tests. */
3020
- readonly fetch?: GistFetch | undefined;
3021
- /** Reads an environment variable; injected so tests stay free of `process.env`. */
3022
- readonly getEnv: (name: string) => string | undefined;
3023
- /** Resolved state configuration for the target environment. */
3024
- readonly stateConfig: StateConfig;
3025
- }
3241
+ declare const DEFAULT_PREFIX_FORMAT = "[{LABEL}] ";
3026
3242
  /**
3027
- * Construct a `StatePort` from a resolved `StateConfig`. Dispatches on
3028
- * `stateConfig.backend` to the matching builtin adapter; reads the
3029
- * required credential from `getEnv` and surfaces `missingCredential` or
3030
- * `unsupportedBackend` as typed Results.
3243
+ * Render the prefix that selectEnvironment prepends to declared display
3244
+ * names when a project enables `displayNamePrefix`. The template
3245
+ * recognizes three placeholders:
3246
+ *
3247
+ * - `{label}`: label as written.
3248
+ * - `{LABEL}`: upper-cased label.
3249
+ * - `{Label}`: capitalized label (first character upper, rest as written).
3250
+ *
3251
+ * Other characters in the template flow through verbatim.
3252
+ *
3253
+ * @param label - Environment label declared on `EnvironmentEntry.label`.
3254
+ * @param format - Template string. Falls back to
3255
+ * {@link DEFAULT_PREFIX_FORMAT} when omitted.
3256
+ * @returns The rendered prefix string.
3031
3257
  *
3032
3258
  * @example
3033
3259
  *
3034
3260
  * ```ts
3035
- * import { buildStatePort } from "@bedrock-rbx/core";
3036
- *
3037
- * const port = buildStatePort({
3038
- * fetch: async () =>
3039
- * new Response(JSON.stringify({ files: {} }), { status: 200 }),
3040
- * getEnv: (name) => (name === "GITHUB_TOKEN" ? "ghp_example" : undefined),
3041
- * stateConfig: { backend: "gist", gistId: "abc123" },
3042
- * });
3261
+ * import { renderDisplayNamePrefix } from "@bedrock-rbx/core";
3043
3262
  *
3044
- * expect(port.success).toBeTrue();
3263
+ * expect(renderDisplayNamePrefix("staging")).toBe("[STAGING] ");
3264
+ * expect(renderDisplayNamePrefix("staging", "{Label}: ")).toBe("Staging: ");
3265
+ * expect(renderDisplayNamePrefix("dev", "{LABEL}-{label}")).toBe("DEV-dev");
3045
3266
  * ```
3046
- *
3047
- * @param deps - Resolved state config plus credential-injection seams.
3048
- * @returns A `StatePort` on success, or a typed Err describing the
3049
- * missing credential or the unsupported backend.
3050
3267
  */
3051
- declare function buildStatePort(deps: BuildStatePortDeps): Result$1<StatePort, MissingCredentialError | UnsupportedBackendError>;
3268
+ declare function renderDisplayNamePrefix(label: string, format?: string): string;
3052
3269
  //#endregion
3053
- //#region src/shell/build-default-registry.d.ts
3054
- /**
3055
- * Failure surfaced when default-constructing a registry needs a config
3056
- * field that is not present. The deploy boundary wraps this in a
3057
- * `DeployError` so the caller sees a typed Result instead of a downstream
3058
- * driver error.
3059
- */
3060
- interface RegistryConfigError {
3061
- /** Suggested fix routed back to the caller. */
3062
- readonly hint: string;
3063
- /** Literal discriminator for narrowing. */
3064
- readonly kind: "registryConfigMissing";
3065
- /** Which config field was missing. */
3066
- readonly missing: "universeId";
3067
- }
3068
- /** Inputs for {@link buildDefaultRegistry}. */
3069
- interface BuildDefaultRegistryDeps {
3070
- /** Resolved project config; supplies `universe.universeId` and is read for nothing else. */
3071
- readonly config: ResolvedConfig;
3072
- /** Reads an environment variable; injected so tests stay free of `process.env`. */
3073
- readonly getEnv: (name: string) => string | undefined;
3074
- /** Reader plumbed into kind-specific drivers that ingest file bytes. */
3075
- readonly readFile: (path: string) => Promise<Uint8Array>;
3076
- }
3270
+ //#region src/core/environment.d.ts
3077
3271
  /**
3078
- * Construct the default `DriverRegistry` from `config.universe.universeId`
3079
- * and `BEDROCK_API_KEY`. Reads the API key via the injected `getEnv` seam
3080
- * and surfaces `missingCredential` or `registryConfigMissing` as typed
3081
- * Results instead of throwing.
3272
+ * Validate an environment name at a state-adapter boundary.
3273
+ *
3274
+ * Adapters that map environment names onto filesystem-like identifiers
3275
+ * (gist filenames, S3 keys) must reject names that could collide or escape
3276
+ * their storage layout. This helper accepts letters, digits, `-`, and `_`
3277
+ * only, with length between 1 and 64, and returns a `StateError` for
3278
+ * anything outside that set so the adapter can fail loudly instead of
3279
+ * silently stripping characters.
3082
3280
  *
3083
3281
  * @example
3084
3282
  *
3085
3283
  * ```ts
3086
- * import { buildDefaultRegistry } from "@bedrock-rbx/core";
3284
+ * import { validateEnvironmentName } from "@bedrock-rbx/core";
3087
3285
  *
3088
- * const registry = buildDefaultRegistry({
3089
- * config: {
3090
- * environments: { production: {} },
3091
- * state: { backend: "gist", gistId: "abc" },
3092
- * universe: { universeId: "1234567890" },
3093
- * },
3094
- * getEnv: () => "rbx-test",
3095
- * readFile: async () => new Uint8Array(),
3096
- * });
3286
+ * const ok = validateEnvironmentName("production");
3287
+ * expect(ok.success).toBeTrue();
3097
3288
  *
3098
- * expect(registry.success).toBeTrue();
3289
+ * const bad = validateEnvironmentName("prod/staging");
3290
+ * expect(bad.success).toBeFalse();
3099
3291
  * ```
3100
3292
  *
3101
- * @param deps - Validated config plus credential and file-reader seams.
3102
- * @returns A `DriverRegistry` on success, or a typed Err describing the
3103
- * missing API key or the missing universe declaration.
3293
+ * @param environment - Raw environment name supplied by a caller.
3294
+ * @returns `Ok(environment)` when the name is safe to use, or
3295
+ * `Err(StateError)` with a descriptive reason when it is not.
3104
3296
  */
3105
- declare function buildDefaultRegistry(deps: BuildDefaultRegistryDeps): Result$1<DriverRegistry, MissingCredentialError | RegistryConfigError>;
3297
+ declare function validateEnvironmentName(environment: string): Result$1<string, StateError>;
3106
3298
  //#endregion
3107
- //#region src/shell/build-desired.d.ts
3299
+ //#region src/core/get-environment.d.ts
3108
3300
  /**
3109
- * Layer file I/O onto a flat tagged list of resource inputs to produce
3110
- * `ResourceDesiredState`.
3301
+ * Failure modes returned by {@link getEnvironment}.
3302
+ */
3303
+ type GetEnvironmentError = {
3304
+ readonly kind: "missingEnvironment";
3305
+ } | {
3306
+ readonly kind: "multipleEnvironments";
3307
+ readonly values: ReadonlyArray<string>;
3308
+ };
3309
+ /**
3310
+ * Resolve the deploy environment for an override script invocation.
3111
3311
  *
3112
- * For each input, reads the file bytes via the injected `readFile`, computes
3113
- * the SHA-256 hex digest, and assembles the branded desired-state record
3114
- * that `diff` consumes. Entries are processed sequentially so the first
3115
- * failure's attribution is deterministic.
3312
+ * Reads `--env <name>` from the supplied argv first, falls back to
3313
+ * `BEDROCK_ENVIRONMENT` from the supplied env reader. Returns
3314
+ * `missingEnvironment` when neither is present and `multipleEnvironments`
3315
+ * (with every offending value) when argv contains more than one `--env`
3316
+ * flag. Both inputs default to the running process so override scripts
3317
+ * under `.bedrock/` can call `getEnvironment()` with no arguments.
3116
3318
  *
3117
- * @param inputs - Flat tagged resource inputs from `flattenConfig`.
3118
- * @param readFile - Reads file bytes for a given path; rejection becomes a
3119
- * `fileReadFailed` Err.
3120
- * @returns `Ok` with the desired-state array (same length and order as
3121
- * `inputs`), or `Err` with the first I/O failure.
3319
+ * @param argv - Argument list to scan for `--env <name>` flags. Defaults to
3320
+ * `process.argv.slice(2)` when omitted.
3321
+ * @param readEnvironment - Reads an environment variable; consulted as a
3322
+ * fallback when no `--env` flag is present. Defaults to a `process.env`
3323
+ * reader when omitted.
3324
+ * @returns `Ok(environment)` on success, `Err(GetEnvironmentError)` otherwise.
3122
3325
  * @example
3123
3326
  *
3124
3327
  * ```ts
3125
- * import { asResourceKey, buildDesired } from "@bedrock-rbx/core";
3328
+ * import { getEnvironment } from "@bedrock-rbx/core";
3126
3329
  *
3127
- * async function readFile(): Promise<Uint8Array> {
3128
- * return new Uint8Array([1, 2, 3]);
3129
- * }
3330
+ * const result = getEnvironment(["--env", "production"], () => undefined);
3130
3331
  *
3131
- * return buildDesired(
3132
- * [
3133
- * {
3134
- * description: "Grants VIP perks.",
3135
- * icon: { "en-us": "assets/vip-icon.png" },
3136
- * key: asResourceKey("vip-pass"),
3137
- * kind: "gamePass",
3138
- * name: "VIP Pass",
3139
- * price: 500,
3140
- * },
3141
- * ],
3142
- * readFile,
3143
- * ).then((result) => {
3144
- * expect(result.success).toBeTrue();
3145
- * if (result.success) {
3146
- * expect(result.data).toHaveLength(1);
3147
- * expect(result.data[0]!.kind).toBe("gamePass");
3148
- * }
3149
- * });
3332
+ * expect(result.success).toBeTrue();
3333
+ * if (result.success) {
3334
+ * expect(result.data).toBe("production");
3335
+ * }
3150
3336
  * ```
3151
3337
  */
3152
- declare function buildDesired(inputs: ReadonlyArray<ResourceDesiredInput>, readFile: (path: string) => Promise<Uint8Array>): Promise<Result$1<ReadonlyArray<ResourceDesiredState>, BuildDesiredError>>;
3338
+ declare function getEnvironment(argv?: ReadonlyArray<string>, readEnvironment?: (name: string) => string | undefined): Result$1<string, GetEnvironmentError>;
3153
3339
  //#endregion
3154
- //#region src/shell/load-config.d.ts
3155
- /**
3156
- * Options for {@link loadConfig}. Matches a subset of c12's loader options;
3157
- * additional fields land with the issues that introduce each flow.
3158
- */
3159
- interface LoadConfigOptions {
3160
- /**
3161
- * Path to a specific config file to load, including its extension.
3162
- * Resolved relative to `cwd` when not absolute. Loaded as-is with no
3163
- * extension search; if the file does not exist at the given path,
3164
- * `loadConfig` returns `fileNotFound`. When omitted, `loadConfig`
3165
- * discovers `bedrock.config.{ts,js,...}` from `cwd`.
3166
- */
3167
- readonly configFile?: string;
3168
- /**
3169
- * Directory to search from. Defaults to `process.cwd()` at call time, so
3170
- * each invocation sees the current working directory.
3171
- */
3172
- readonly cwd?: string;
3173
- }
3340
+ //#region src/core/icons.d.ts
3174
3341
  /**
3175
- * Discover, parse, and validate the project config.
3176
- *
3177
- * Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json}`, `.bedrockrc*`,
3178
- * and `package.json#bedrock` starting at `options.cwd` (or the current
3179
- * working directory). Returns a fresh, mutable `Config` on every call so
3180
- * long-running scripts see up-to-date values.
3342
+ * Cost-gate for icon re-uploads. Returns `true` when the locally-hashed
3343
+ * desired icon differs from the hash recorded on the prior current-state
3344
+ * entry, signalling that the driver must re-upload before reconciling.
3345
+ * Returns `false` when the hashes match (no re-upload needed) and when
3346
+ * both sides report no icon.
3181
3347
  *
3182
- * When the exported default is a function (sync or async), `loadConfig`
3183
- * invokes it with an empty `ConfigContext` and awaits the result before
3184
- * validating.
3348
+ * The signature takes hash maps directly (not whole-state) so the helper
3349
+ * is independent of any specific resource-kind shape; every icon-bearing
3350
+ * driver projects its own `iconFileHashes` and `outputs.iconFileHashes`
3351
+ * fields before calling.
3185
3352
  *
3186
- * Errors return via `Result`:
3187
- * - `fileNotFound` - no config file was discovered under the search path.
3188
- * - `parseFailed` - a config file was found but could not be parsed (for
3189
- * example, malformed YAML or JSON).
3190
- * - `validationFailed` - a file was found and parsed, but its content did
3191
- * not satisfy the runtime schema.
3192
- * - `configFunctionFailed` - a function-form config threw or its returned
3193
- * promise rejected while being invoked.
3353
+ * @param currentHashes - Hashes recorded on the prior current-state entry.
3354
+ * @param desiredHashes - Hashes layered onto the desired-state entry by
3355
+ * `normalize` from the local icon file's bytes.
3356
+ * @returns `true` when the driver should re-upload the icon.
3194
3357
  *
3195
- * @param options - Loader options.
3196
- * @returns `Ok` with the validated `Config`, or `Err` with a `ConfigError`.
3197
3358
  * @example
3198
3359
  *
3199
3360
  * ```ts
3200
- * import { loadConfig } from "@bedrock-rbx/core";
3361
+ * import { asSha256Hex, shouldReuploadIcon } from "@bedrock-rbx/core";
3201
3362
  *
3202
- * return loadConfig({
3203
- * configFile: "bedrock.staging.config.yaml",
3204
- * cwd: "/path/that/does/not/have/a/config",
3205
- * }).then((result) => {
3206
- * expect(result.success).toBeFalse();
3207
- * if (!result.success) {
3208
- * expect(result.err.kind).toBe("fileNotFound");
3209
- * }
3210
- * });
3363
+ * const previous = asSha256Hex(
3364
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3365
+ * );
3366
+ * const fresh = asSha256Hex(
3367
+ * "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
3368
+ * );
3369
+ *
3370
+ * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": previous })).toBe(false);
3371
+ * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": fresh })).toBe(true);
3211
3372
  * ```
3212
3373
  */
3213
- declare function loadConfig(options?: LoadConfigOptions): Promise<Result$1<Config, ConfigError>>;
3374
+ declare function shouldReuploadIcon(currentHashes: Record<"en-us", Sha256Hex> | undefined, desiredHashes: Record<"en-us", Sha256Hex> | undefined): boolean;
3214
3375
  //#endregion
3215
- //#region src/shell/deploy.d.ts
3376
+ //#region src/core/kinds/index.d.ts
3216
3377
  /**
3217
- * Inputs for `deploy`. Every field except `environment` is optional;
3218
- * omitted dependencies are default-constructed from the project config
3219
- * and the environment variables `GITHUB_TOKEN` and `BEDROCK_API_KEY`.
3378
+ * Default {@link KindRegistry} composing every resource kind bedrock ships
3379
+ * out of the box. Iteration order (`gamePass`, `place`, `universe`,
3380
+ * `developerProduct`) matches the order `flattenConfig` emits entries
3381
+ * today, preserving the observable order of generated operations.
3382
+ *
3383
+ * @example
3384
+ *
3385
+ * ```ts
3386
+ * import { defaultKindRegistry } from "@bedrock-rbx/core";
3387
+ *
3388
+ * expect(defaultKindRegistry.gamePass.kind).toBe("gamePass");
3389
+ * expect(defaultKindRegistry.place.kind).toBe("place");
3390
+ * expect(defaultKindRegistry.universe.kind).toBe("universe");
3391
+ * expect(defaultKindRegistry.developerProduct.kind).toBe("developerProduct");
3392
+ * ```
3220
3393
  */
3221
- interface DeployOptions {
3222
- /** Pre-loaded, optionally-mutated project config. Omit to call `loadConfig()` automatically. */
3223
- readonly config?: Config;
3224
- /** Environment name; threaded into `StatePort.read` and the persisted snapshot. */
3225
- readonly environment: string;
3226
- /** `fetch` override plumbed into the default-constructed gist adapter when `statePort` is omitted. */
3227
- readonly fetch?: GistFetch;
3228
- /** Reads an environment variable; defaults to `(name) => process.env[name]`. */
3229
- readonly getEnv?: (name: string) => string | undefined;
3230
- /** Loader invoked when `config` is omitted; defaults to `loadConfig` from this package. */
3231
- readonly loadConfig?: (options?: LoadConfigOptions) => Promise<Result$1<Config, ConfigError>>;
3232
- /** Reads file bytes for resources that have file-backed inputs. Defaults to `node:fs/promises.readFile`. */
3233
- readonly readFile?: (path: string) => Promise<Uint8Array>;
3234
- /** Per-kind driver table consulted for create / update dispatch. Default-constructed from `BEDROCK_API_KEY` when omitted. */
3235
- readonly registry?: DriverRegistry;
3236
- /** Backend used to read the prior snapshot and persist the new one. Default-constructed from `config.state` and `GITHUB_TOKEN` when omitted. */
3237
- readonly statePort?: StatePort;
3238
- }
3394
+ declare const defaultKindRegistry: KindRegistry;
3395
+ //#endregion
3396
+ //#region src/core/state-file.d.ts
3239
3397
  /**
3240
- * Failure surfaced by `deploy`. Stage-tagged so callers can branch on
3241
- * `kind` to distinguish reconciliation failures (`stateReadFailed`,
3242
- * `applyFailed`, ...) from default-construction failures
3243
- * (`configLoadFailed`, `stateNotConfigured`, `unknownEnvironment`,
3244
- * `incompletePlaceEntry`, `incompleteUniverseEntry`, `missingCredential`,
3245
- * `unsupportedBackend`, `registryConfigMissing`).
3398
+ * Serialize a {@link BedrockState} to the on-disk JSON representation used by
3399
+ * state-port adapters.
3400
+ *
3401
+ * The on-disk shape wraps the in-memory state with a
3402
+ * `$bedrock: { version: N }` envelope so that a future breaking change to the
3403
+ * schema can be detected and rejected at parse time rather than silently
3404
+ * accepted. The top-level `version` field is not duplicated on disk.
3405
+ *
3406
+ * @example
3407
+ *
3408
+ * ```ts
3409
+ * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
3410
+ *
3411
+ * const state: BedrockState = {
3412
+ * environment: "production",
3413
+ * resources: [],
3414
+ * version: 1,
3415
+ * };
3416
+ *
3417
+ * const wire = serializeStateFile(state);
3418
+ * expect(JSON.parse(wire)).toStrictEqual({
3419
+ * $bedrock: { version: 1 },
3420
+ * environment: "production",
3421
+ * resources: [],
3422
+ * });
3423
+ * ```
3424
+ *
3425
+ * @param state - The in-memory state snapshot to serialize.
3426
+ * @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
3246
3427
  */
3247
- type DeployError = IncompletePlaceEntryError | IncompleteUniverseEntryError | MissingCredentialError | RegistryConfigError | StateNotConfiguredError | UnknownEnvironmentError | UnsupportedBackendError | {
3248
- readonly cause: ApplyError;
3249
- readonly kind: "applyFailed";
3250
- } | {
3251
- readonly cause: BuildDesiredError;
3252
- readonly kind: "buildDesiredFailed";
3253
- } | {
3254
- readonly cause: ConfigError;
3255
- readonly kind: "configLoadFailed";
3256
- } | {
3257
- readonly cause: StateError;
3258
- readonly kind: "stateReadFailed";
3259
- } | {
3260
- readonly cause: StateError;
3261
- readonly kind: "stateWriteFailed";
3262
- readonly unsavedState: BedrockState;
3263
- };
3428
+ declare function serializeStateFile(state: BedrockState): string;
3264
3429
  /**
3265
- * Run a full reconcile end-to-end. Default-constructs missing deps from
3266
- * the project config and the environment variables `GITHUB_TOKEN` and
3267
- * `BEDROCK_API_KEY`; never reads `process.env` when `statePort`,
3268
- * `registry`, and `config` are all supplied explicitly.
3430
+ * Parse a raw on-disk state file into a {@link BedrockState}.
3431
+ *
3432
+ * A backend that reports "no state file for this environment yet" must pass
3433
+ * `undefined`: that distinguishes a legitimate first deploy from a file that
3434
+ * exists but cannot be trusted.
3269
3435
  *
3270
- * @param options - Target environment plus optional overrides.
3271
- * @returns The persisted `BedrockState` on success, or a stage-tagged
3272
- * `DeployError` on failure.
3273
3436
  * @example
3274
3437
  *
3275
3438
  * ```ts
3276
- * import { deploy } from "@bedrock-rbx/core";
3439
+ * import { parseStateFile } from "@bedrock-rbx/core";
3277
3440
  *
3278
- * return deploy({ environment: "production" }).then((result) => {
3279
- * expect(result.success).toBeFalse();
3280
- * if (!result.success) {
3281
- * expect(["configLoadFailed", "stateNotConfigured"]).toContain(result.err.kind);
3282
- * }
3283
- * });
3441
+ * const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
3442
+ * expect(freshStart.success).toBeTrue();
3443
+ * if (freshStart.success) {
3444
+ * expect(freshStart.data).toBeUndefined();
3445
+ * }
3284
3446
  * ```
3285
3447
  *
3448
+ * @param raw - Raw file contents as a string, or `undefined` when the
3449
+ * backend reports no file exists yet.
3450
+ * @param file - Adapter-specific identifier included in any `StateError`
3451
+ * surfaced during parsing.
3452
+ * @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
3453
+ * file, or `Err(StateError)` for anything that cannot be trusted.
3454
+ */
3455
+ declare function parseStateFile(raw: string | undefined, file: string): Result$1<BedrockState | undefined, StateError>;
3456
+ //#endregion
3457
+ //#region src/core/validate-plan.d.ts
3458
+ /**
3459
+ * Plan-time invariant check that runs after `buildDesired` and before
3460
+ * `diff`. Walks paired `(kind, key)` entries and dispatches to each
3461
+ * kind module's optional `assertReconcilable` hook so kind-specific
3462
+ * rejections (e.g. Removing a developer-product icon, which the upstream
3463
+ * API has no documented unset path for) surface as typed errors before
3464
+ * `diff` runs and before any apply-side driver I/O is attempted.
3465
+ *
3466
+ * Pure and synchronous. Current-only entries (no matching desired) are
3467
+ * ignored: their reconciliation is `diff`'s concern, not this seam's.
3468
+ *
3469
+ * @param desired - Desired state from `buildDesired`.
3470
+ * @param current - Prior current state from the state port.
3471
+ * @returns `Ok(undefined)` when every paired entry passes its kind-level
3472
+ * reconcilability check, or the first `Err` returned by a hook.
3473
+ *
3286
3474
  * @example
3287
3475
  *
3288
3476
  * ```ts
3289
- * import { deploy, type BedrockState, type DriverRegistry, type StatePort } from "@bedrock-rbx/core";
3477
+ * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3290
3478
  *
3291
- * const store = new Map<string, BedrockState>();
3292
- * const statePort: StatePort = {
3293
- * async read(environment) {
3294
- * return { data: store.get(environment), success: true };
3295
- * },
3296
- * async write(state) {
3297
- * store.set(state.environment, state);
3298
- * return { data: undefined, success: true };
3299
- * },
3300
- * };
3301
- * const registry: DriverRegistry = {
3302
- * developerProduct: {
3303
- * create: async () => { throw new Error("unreachable: empty config"); },
3304
- * },
3305
- * gamePass: { create: async () => { throw new Error("unreachable: empty config"); } },
3306
- * place: { create: async () => { throw new Error("unreachable: empty config"); } },
3307
- * universe: { create: async () => { throw new Error("unreachable: empty config"); } },
3308
- * };
3479
+ * const result = validatePlan(
3480
+ * [
3481
+ * {
3482
+ * description: "Stocks the player up with 1,000 premium gems.",
3483
+ * isRegionalPricingEnabled: undefined,
3484
+ * key: asResourceKey("gem-pack"),
3485
+ * kind: "developerProduct",
3486
+ * name: "Gem Pack",
3487
+ * price: undefined,
3488
+ * storePageEnabled: undefined,
3489
+ * },
3490
+ * ],
3491
+ * [
3492
+ * {
3493
+ * description: "Stocks the player up with 1,000 premium gems.",
3494
+ * icon: { "en-us": "assets/gem-pack.png" },
3495
+ * iconFileHashes: {
3496
+ * "en-us": asSha256Hex(
3497
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3498
+ * ),
3499
+ * },
3500
+ * isRegionalPricingEnabled: undefined,
3501
+ * key: asResourceKey("gem-pack"),
3502
+ * kind: "developerProduct",
3503
+ * name: "Gem Pack",
3504
+ * outputs: { productId: asRobloxAssetId("9876543210") },
3505
+ * price: undefined,
3506
+ * storePageEnabled: undefined,
3507
+ * },
3508
+ * ],
3509
+ * );
3309
3510
  *
3310
- * return deploy({
3311
- * config: {
3312
- * environments: { production: {} },
3313
- * state: { backend: "gist", gistId: "abc" },
3314
- * passes: {},
3315
- * },
3316
- * environment: "production",
3317
- * registry,
3318
- * statePort,
3319
- * }).then((result) => {
3320
- * expect(result.success).toBeTrue();
3321
- * if (result.success) {
3322
- * expect(result.data.environment).toBe("production");
3323
- * expect(result.data.resources).toBeEmpty();
3324
- * }
3325
- * });
3511
+ * expect(result.success).toBeFalse();
3512
+ * if (!result.success) {
3513
+ * expect(result.err.kind).toBe("iconRemovalRejected");
3514
+ * }
3326
3515
  * ```
3327
3516
  */
3328
- declare function deploy(options: DeployOptions): Promise<Result$1<BedrockState, DeployError>>;
3517
+ declare function validatePlan(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): Result$1<undefined, BuildDesiredError>;
3329
3518
  //#endregion
3330
3519
  //#region src/shell/migrate-mantle-state.d.ts
3331
3520
  type ConfigFormat = "typescript" | "yaml";
@@ -3422,5 +3611,5 @@ interface MigrateMantleStateDeps {
3422
3611
  */
3423
3612
  declare function migrateMantleState(deps: MigrateMantleStateDeps): Promise<Result$1<MigrationReport, MigrateError>>;
3424
3613
  //#endregion
3425
- export { type ApplyError, type BaseOperation, type BedrockState, type BuildDesiredError, type Config, type ConfigContext, type ConfigEnvironmentUniverseId, type ConfigError, type ConfigInput, type ConfigRootUniverseId, type ConfigValidationIssue, type CreateOperation, DEFAULT_PREFIX_FORMAT, type DeployError, type DeployOptions, type DeveloperProductDesiredInput, type DeveloperProductDesiredState, type DeveloperProductDriverDeps, type DeveloperProductEntry, type DeveloperProductOutputs, type DisplayNamePrefixConfig, type DriverRegistry, type EnvironmentEntry, type GamePassDesiredInput, type GamePassDesiredState, type GamePassDriverDeps, type GamePassEntry, type GamePassOutputs, type GetEnvironmentError, type GistStateAdapterDeps, type GistStateConfig, type IncompletePlaceEntryError, type IncompleteUniverseEntryError, type KindIo, type KindRegistry, type LoadConfigOptions, type MigrateError, type MigrateMantleStateDeps, type MigrationReport, type MigrationSummary, type MigrationWarning, type MissingCredentialError, type NoopOperation, OpenCloudError, type Operation, type PlaceDesiredInput, type PlaceDesiredState, type PlaceDriverDeps, type PlaceEntry, type PlaceOutputs, type PriceFields, type RegistryConfigError, type ResolvedConfig, type ResolvedPlaceEntry, type ResolvedUniverseEntry, type ResourceCurrentState, type ResourceDesiredInput, type ResourceDesiredState, type ResourceDriver, type ResourceEntryByKind, type ResourceKey, type ResourceKind, type ResourceKindModule, type ResourceOutputs, type ResourceOutputsByKind, type Result, type RobloxAssetId, SOCIAL_LINK_FIELDS, type SelectEnvironmentError, type Sha256Hex, type SocialLink, type SocialLinkField, type StateConfig, type StateError, type StateNotConfiguredError, type StatePort, type StatesByEnvironment, UNIVERSE_SINGLETON_KEY, type UniverseDesiredInput, type UniverseDesiredState, type UniverseDriverDeps, type UniverseEntry, type UniverseOutputs, type UniverseOverlayWithId, type UniverseOverlayWithoutId, type UnknownEnvironmentError, type UnsupportedBackendError, type UpdateOperation, applyOps, asResourceKey, asRobloxAssetId, asSha256Hex, buildDefaultRegistry, buildDesired, buildStatePort, createDeveloperProductDriver, createGamePassDriver, createGistStateAdapter, createPlaceDriver, createUniverseDriver, defaultKindRegistry, defineConfig, deploy, derivePriceFields, diff, flattenConfig, getEnvironment, isGistStateConfig, isResourceKey, isRobloxAssetId, isSha256Hex, loadConfig, migrateMantleState, parseStateFile, renderDisplayNamePrefix, resolveStateConfig, selectEnvironment, serializeStateFile, shouldReuploadIcon, validateConfig, validateEnvironmentName, validatePlan };
3614
+ export { type ApplyError, type BaseOperation, type BedrockState, type BuildDesiredError, type ClackPort, type ClackProgressAdapterDeps, type Config, type ConfigContext, type ConfigEnvironmentUniverseId, type ConfigError, type ConfigInput, type ConfigRootUniverseId, type ConfigValidationIssue, type CreateOperation, DEFAULT_PREFIX_FORMAT, type DeployError, type DeployFailureEvent, type DeployOptions, type DeploySuccessEvent, type DeveloperProductDesiredInput, type DeveloperProductDesiredState, type DeveloperProductDriverDeps, type DeveloperProductEntry, type DeveloperProductOutputs, type DisplayNamePrefixConfig, type DriverRegistry, type EnvironmentEntry, type GamePassDesiredInput, type GamePassDesiredState, type GamePassDriverDeps, type GamePassEntry, type GamePassOutputs, type GetEnvironmentError, type GistStateAdapterDeps, type GistStateConfig, type IncompletePlaceEntryError, type IncompleteUniverseEntryError, type KindIo, type KindRegistry, type LoadConfigOptions, type MigrateError, type MigrateMantleStateDeps, type MigrationReport, type MigrationSummary, type MigrationWarning, type MissingCredentialError, type NoopOperation, OpenCloudError, type Operation, type PlaceDesiredInput, type PlaceDesiredState, type PlaceDriverDeps, type PlaceEntry, type PlaceOutputs, type PriceFields, type ProgressEvent, type ProgressPort, type RegistryConfigError, type ResolvedConfig, type ResolvedPlaceEntry, type ResolvedUniverseEntry, type ResourceCurrentState, type ResourceDesiredInput, type ResourceDesiredState, type ResourceDriver, type ResourceEntryByKind, type ResourceKey, type ResourceKind, type ResourceKindModule, type ResourceOutputs, type ResourceOutputsByKind, type Result, type RobloxAssetId, SOCIAL_LINK_FIELDS, type SelectEnvironmentError, type Sha256Hex, type SocialLink, type SocialLinkField, type StateConfig, type StateError, type StateNotConfiguredError, type StatePort, type StatesByEnvironment, UNIVERSE_SINGLETON_KEY, type UniverseDesiredInput, type UniverseDesiredState, type UniverseDriverDeps, type UniverseEntry, type UniverseOutputs, type UniverseOverlayWithId, type UniverseOverlayWithoutId, type UnknownEnvironmentError, type UnsupportedBackendError, type UpdateOperation, applyOps, asResourceKey, asRobloxAssetId, asSha256Hex, buildDefaultRegistry, buildDesired, buildStatePort, createClackPort, createClackProgressAdapter, createDeveloperProductDriver, createGamePassDriver, createGistStateAdapter, createNoOpProgressAdapter, createPlaceDriver, createUniverseDriver, defaultKindRegistry, defineConfig, deploy, derivePriceFields, diff, flattenConfig, getEnvironment, isGistStateConfig, isResourceKey, isRobloxAssetId, isSha256Hex, loadConfig, migrateMantleState, parseStateFile, renderDisplayNamePrefix, resolveStateConfig, selectEnvironment, serializeStateFile, shouldReuploadIcon, validateConfig, validateEnvironmentName, validatePlan };
3426
3615
  //# sourceMappingURL=index.d.mts.map