@bedrock-rbx/core 0.1.0-beta.11 → 0.1.0-beta.13

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
- 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";
1
+ import { C as UniverseOverlayWithoutId, D as ConfigValidationIssue, E as ConfigError, S as UniverseOverlayWithId, T as validateConfig, _ as ResolvedPlaceEntry, a as ConfigEnvironmentUniverseId, b as StateConfig, c as DisplayNamePrefixConfig, d as GistStateConfig, f as PlaceEntry, g as ResolvedConfig, h as RedactedPlaceOverride, i as Config, l as EnvironmentEntry, m as RedactedGamePassOverride, n as ConfigInput, o as ConfigRootUniverseId, p as RedactedDeveloperProductOverride, r as defineConfig, s as DeveloperProductEntry, t as ConfigContext, u as GamePassEntry, v as ResolvedUniverseEntry, w as isGistStateConfig, x as UniverseEntry, y as ResourceEntryByKind } from "./define-config-Bd0XIiSX.mjs";
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,729 @@ 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
1215
  /**
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`.
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.
1237
1221
  */
1238
- type GistFetch = (input: globalThis.Request | string | URL, init?: RequestInit) => Promise<Response>;
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";
1229
+ }
1239
1230
  /**
1240
- * Configuration for {@link createGistStateAdapter}.
1241
- */
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;
1231
+ * Failure surfaced when a merged `passes` entry is missing a required
1232
+ * field. The most common path here is an overlay-only pass declared
1233
+ * under `environments.X.passes` with no matching root entry: the overlay
1234
+ * shape is `Partial<GamePassEntry>`, so a typo on the ResourceKey
1235
+ * silently produces an incomplete entry that would otherwise be filled
1236
+ * in by `applyRedaction` (when `redacted: true` is set) and pushed as a
1237
+ * phantom placeholder pass. Surfacing the missing field at the
1238
+ * resolution boundary keeps that case attributable instead of letting
1239
+ * normalize fail later with a less specific error.
1240
+ */
1241
+ interface IncompletePassEntryError {
1242
+ /** ResourceKey of the pass entry that is missing a required field. */
1243
+ readonly key: string;
1244
+ /** Environment whose overlay was projected onto the config. */
1245
+ readonly environment: string;
1246
+ /** Literal discriminator for narrowing. */
1247
+ readonly kind: "incompletePassEntry";
1248
+ /** Field that the merged entry lacks. */
1249
+ readonly missingField: "description" | "icon" | "name";
1254
1250
  }
1251
+ /** Failure modes returned by {@link selectEnvironment}. */
1252
+ type SelectEnvironmentError = IncompletePassEntryError | IncompletePlaceEntryError | IncompleteUniverseEntryError | UnknownEnvironmentError;
1255
1253
  /**
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.
1254
+ * Project a validated `Config` onto a single environment. Looks up the
1255
+ * matching `environments[environment]` entry, deep-merges its resource
1256
+ * overlay (`passes`, `places`, `universe`) over the root config via defu,
1257
+ * and applies the env-level state override when present (the env entry's
1258
+ * `state` field wins; otherwise the root `state` flows through).
1261
1259
  *
1262
- * @example
1260
+ * Pure: no I/O. Returns a `ResolvedConfig` ready to feed into downstream
1261
+ * functions (`flattenConfig`, `buildDefaultRegistry`, `resolveStateConfig`).
1262
+ * The post-merge view promotes `places` from `Record<string, PlaceEntry>`
1263
+ * (root: file-paths only) to `Record<string, ResolvedPlaceEntry>` (root +
1264
+ * overlay merged). `environments` and `extends` are passed through
1265
+ * unchanged because they preserve the shape relationship to `Config`;
1266
+ * downstream consumers do not read them post-merge.
1263
1267
  *
1264
- * ```ts
1265
- * import { createGistStateAdapter } from "@bedrock-rbx/core";
1268
+ * Defu's merge semantics are deliberate: keyed-map collections merge by
1269
+ * key (so a place declared in both root and overlay produces a single
1270
+ * entry whose overlay-supplied fields win), and `null` / `undefined` in
1271
+ * the overlay are skipped (so the overlay never deletes a root field).
1272
+ * State has its own resolution path (a single replacement, not a
1273
+ * deep-merge) because it is a tagged union: a deep-merge of
1274
+ * `{ backend: "s3" }` over `{ backend: "gist", gistId }` would produce
1275
+ * a malformed `{ backend: "s3", gistId }`.
1266
1276
  *
1267
- * const port = createGistStateAdapter({
1268
- * fetch: async () =>
1269
- * new Response(JSON.stringify({ files: {} }), { status: 200 }),
1270
- * gistId: "abc123def456",
1271
- * token: "ghp_example",
1272
- * });
1277
+ * State is left absent when neither the env override nor the root block
1278
+ * provides one. Callers that require a resolved `StateConfig` should
1279
+ * route through `resolveStateConfig` or `buildStatePort`; the absent
1280
+ * case surfaces as a typed `stateNotConfigured` there.
1273
1281
  *
1274
- * return port.read("production").then((result) => {
1275
- * expect(result.success).toBeTrue();
1276
- * if (result.success) {
1277
- * expect(result.data).toBeUndefined();
1278
- * }
1279
- * });
1280
- * ```
1282
+ * Limitation in v1: a per-environment universe overlay that introduces a
1283
+ * brand-new universe block may still have optional fields missing, since
1284
+ * the overlay type only requires the identity-bearing key. The resolver
1285
+ * surfaces the entry as-is; the universe driver reports the missing
1286
+ * field when it tries to consume the entry. Universe is a singleton with
1287
+ * 20+ optional fields, so the same `incompletePlaceEntry`-style validation
1288
+ * is deferred to a separate follow-up.
1281
1289
  *
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.
1290
+ * When the project sets a `displayNamePrefix` (or omits it, in which case
1291
+ * prefixing defaults to enabled) and the chosen environment declares a
1292
+ * non-empty `label`, the resolver renders the configured template via
1293
+ * `renderDisplayNamePrefix` and prepends the result to `universe.displayName`
1294
+ * and every declared place `displayName`. An undeclared `displayName`, an
1295
+ * empty/absent label, or an explicit `displayNamePrefix.enabled: false` all
1296
+ * skip prefixing for the affected fields.
1294
1297
  *
1295
1298
  * @example
1296
1299
  *
1297
1300
  * ```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";
1301
+ * import { selectEnvironment } from "@bedrock-rbx/core";
1302
+ * import type { Config } from "@bedrock-rbx/core/config";
1301
1303
  *
1302
- * const httpClient: HttpClient = {
1303
- * async request() {
1304
- * return { data: { body: {}, headers: {}, status: 200 }, success: true };
1304
+ * const config: Config = {
1305
+ * environments: {
1306
+ * production: { universe: { universeId: "999" } },
1305
1307
  * },
1308
+ * state: { backend: "gist", gistId: "abc123" },
1309
+ * universe: { voiceChatEnabled: true },
1306
1310
  * };
1307
1311
  *
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
- * };
1312
+ * const result = selectEnvironment(config, "production");
1317
1313
  *
1318
- * expect(deps.universeId).toBe("1234567890");
1314
+ * expect(result.success).toBeTrue();
1315
+ * if (result.success) {
1316
+ * expect(result.data.universe?.universeId).toBe("999");
1317
+ * expect(result.data.universe?.voiceChatEnabled).toBeTrue();
1318
+ * expect(result.data.state?.backend).toBe("gist");
1319
+ * }
1319
1320
  * ```
1321
+ *
1322
+ * @param config - Validated project config carrying at least one
1323
+ * environment under `environments`.
1324
+ * @param environment - Environment name to project onto. Must be a key
1325
+ * of `config.environments`.
1326
+ * @returns `Ok(ResolvedConfig)` with the merged resource fields and the
1327
+ * resolved state, or `Err(SelectEnvironmentError)` describing why the
1328
+ * projection failed.
1320
1329
  */
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
- }
1330
+ declare function selectEnvironment(config: Config, environment: string): Result$1<ResolvedConfig, SelectEnvironmentError>;
1331
+ //#endregion
1332
+ //#region src/ports/resource-driver.d.ts
1329
1333
  /**
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".
1334
+ * Plugin contract for a resource adapter: the interface a third-party author
1335
+ * implements to teach Bedrock how to reconcile one {@link ResourceKind} against
1336
+ * its upstream API.
1334
1337
  *
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.
1338
+ * `ResourceDriver<K>` is a *driven* (secondary) port in hexagonal terms; the
1339
+ * name "driver" follows Terraform, Pulumi, and Mantle IaC convention for a
1340
+ * component that talks to a specific resource API.
1338
1341
  *
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.
1342
+ * @template K - The {@link ResourceKind} discriminator this driver handles.
1342
1343
  *
1343
1344
  * @example
1344
1345
  *
1345
1346
  * ```ts
1346
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1347
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1348
1347
  * import {
1349
1348
  * asResourceKey,
1350
1349
  * asRobloxAssetId,
1351
1350
  * asSha256Hex,
1352
- * createPlaceDriver,
1351
+ * type ResourceDriver,
1353
1352
  * } from "@bedrock-rbx/core";
1354
1353
  *
1355
- * const httpClient: HttpClient = {
1356
- * async request() {
1354
+ * const gamePassDriver: ResourceDriver<"gamePass"> = {
1355
+ * async create(desired) {
1357
1356
  * return {
1358
- * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1357
+ * data: {
1358
+ * ...desired,
1359
+ * outputs: {
1360
+ * assetId: asRobloxAssetId("9876543210"),
1361
+ * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
1362
+ * },
1363
+ * },
1359
1364
  * success: true,
1360
1365
  * };
1361
1366
  * },
1362
1367
  * };
1363
1368
  *
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
1369
+ * return gamePassDriver
1379
1370
  * .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,
1371
+ * description: "Grants VIP perks.",
1372
+ * icon: { "en-us": "assets/vip-icon.png" },
1373
+ * iconFileHashes: {
1374
+ * "en-us": asSha256Hex(
1375
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1376
+ * ),
1377
+ * },
1378
+ * key: asResourceKey("vip-pass"),
1379
+ * kind: "gamePass",
1380
+ * name: "VIP Pass",
1381
+ * price: undefined,
1390
1382
  * })
1391
1383
  * .then((result) => {
1392
1384
  * expect(result.success).toBeTrue();
1393
1385
  * if (result.success) {
1394
- * expect(result.data.outputs.versionNumber).toBe(1);
1386
+ * expect(result.data.outputs.assetId).toBe("9876543210");
1395
1387
  * }
1396
1388
  * });
1397
1389
  * ```
1398
1390
  */
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;
1391
+ interface ResourceDriver<K extends ResourceKind> {
1392
+ /**
1393
+ * Create the resource upstream from its desired state and return the
1394
+ * resulting current state (desired fields + Roblox-assigned outputs).
1395
+ */
1396
+ create(desired: Extract<ResourceDesiredState, {
1397
+ kind: K;
1398
+ }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
1399
+ /**
1400
+ * Reconcile an upstream resource whose managed content has drifted from its
1401
+ * desired state. Receives the last-known current state so the driver can
1402
+ * compute a minimal patch (or no-op upstream, for file-backed kinds where
1403
+ * republishing is unconditional).
1404
+ *
1405
+ * Optional. Drivers whose upstream API has no update operation omit this
1406
+ * method; `applyOps` surfaces an `updateUnsupported` error at dispatch time
1407
+ * instead.
1408
+ */
1409
+ update?(current: ResourceCurrentState<K>, desired: Extract<ResourceDesiredState, {
1410
+ kind: K;
1411
+ }>): Promise<Result$1<ResourceCurrentState<K>, OpenCloudError$1>>;
1415
1412
  }
1416
1413
  /**
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`.
1414
+ * Polymorphic dispatch table keyed by {@link ResourceKind}, mapping each kind
1415
+ * to the {@link ResourceDriver} that handles it. `applyOps` indexes the
1416
+ * registry by `op.desired.kind` to reach the matching driver with full type
1417
+ * safety: adding a new kind to `ResourceDesiredState` is a compile error until
1418
+ * a matching registry entry is supplied.
1440
1419
  *
1441
1420
  * @example
1442
1421
  *
1443
1422
  * ```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";
1423
+ * import { OpenCloudError, type DriverRegistry } from "@bedrock-rbx/core";
1453
1424
  *
1454
- * const universeBodyHttpClient: HttpClient = {
1455
- * async request() {
1456
- * return {
1457
- * data: {
1458
- * body: validUniverseBody({
1459
- * path: "universes/1234567890",
1460
- * rootPlace: "universes/1234567890/places/4711",
1461
- * }),
1462
- * headers: {},
1463
- * status: 200,
1464
- * },
1465
- * success: true,
1466
- * };
1425
+ * const registry: DriverRegistry = {
1426
+ * gamePass: {
1427
+ * async create() {
1428
+ * return { err: new OpenCloudError("not implemented"), success: false };
1429
+ * },
1430
+ * },
1431
+ * place: {
1432
+ * async create() {
1433
+ * return { err: new OpenCloudError("not implemented"), success: false };
1434
+ * },
1435
+ * },
1436
+ * universe: {
1437
+ * async create() {
1438
+ * return { err: new OpenCloudError("not implemented"), success: false };
1439
+ * },
1440
+ * },
1441
+ * developerProduct: {
1442
+ * async create() {
1443
+ * return { err: new OpenCloudError("not implemented"), success: false };
1444
+ * },
1467
1445
  * },
1468
1446
  * };
1469
1447
  *
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
1484
- * .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,
1496
- * })
1497
- * .then((result) => {
1498
- * expect(result.success).toBeTrue();
1499
- * if (result.success) {
1500
- * expect(result.data.outputs.rootPlaceId).toBe("4711");
1501
- * }
1502
- * });
1503
- * ```
1504
- */
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;
1519
- }
1520
- /**
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.
1531
- *
1532
- * @example
1533
- *
1534
- * ```ts
1535
- * import { derivePriceFields } from "@bedrock-rbx/core";
1536
- *
1537
- * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
1538
- * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
1448
+ * expect(registry.gamePass).toBeObject();
1539
1449
  * ```
1540
1450
  */
1541
- declare function derivePriceFields(desired: {
1542
- readonly price: number | undefined;
1543
- }): PriceFields;
1451
+ type DriverRegistry = { [K in ResourceKind]: ResourceDriver<K> };
1544
1452
  //#endregion
1545
1453
  //#region src/core/operations.d.ts
1546
1454
  /**
@@ -1738,162 +1646,228 @@ interface NoopOperation extends BaseOperation {
1738
1646
  */
1739
1647
  type Operation = CreateOperation | NoopOperation | UpdateOperation;
1740
1648
  //#endregion
1741
- //#region src/core/diff.d.ts
1649
+ //#region src/shell/apply-ops.d.ts
1742
1650
  /**
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.
1651
+ * Failure surfaced by `applyOps` when an operation cannot be applied.
1652
+ * Plain-data discriminated union; narrow on `kind`, do not `instanceof` it.
1755
1653
  *
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.
1654
+ * `appliedSoFar` carries the driver outputs from operations that succeeded
1655
+ * before the failing one, in dispatched order. Callers persist this so a
1656
+ * follow-up reconcile does not duplicate Roblox-side resources that have
1657
+ * already been created or updated.
1760
1658
  *
1761
1659
  * @example
1762
1660
  *
1763
1661
  * ```ts
1764
- * import {
1765
- * asResourceKey,
1766
- * asRobloxAssetId,
1767
- * asSha256Hex,
1768
- * diff,
1769
- * type GamePassDesiredState,
1770
- * type ResourceCurrentState,
1771
- * } from "@bedrock-rbx/core";
1662
+ * import { asResourceKey, type ApplyError } from "@bedrock-rbx/core";
1772
1663
  *
1773
- * const hash = asSha256Hex(
1774
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1775
- * );
1664
+ * function describe(err: ApplyError): string {
1665
+ * switch (err.kind) {
1666
+ * case "driverFailure": {
1667
+ * return `driver failed for ${err.key}: ${err.cause.message}`;
1668
+ * }
1669
+ * case "updateUnsupported": {
1670
+ * return `update not supported for ${err.key}`;
1671
+ * }
1672
+ * }
1673
+ * }
1776
1674
  *
1777
- * const unchanged: GamePassDesiredState = {
1778
- * description: "Grants VIP perks.",
1779
- * icon: { "en-us": "assets/vip-icon.png" },
1780
- * iconFileHashes: { "en-us": hash },
1675
+ * const err: ApplyError = {
1781
1676
  * 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",
1677
+ * appliedSoFar: [],
1678
+ * kind: "updateUnsupported",
1795
1679
  * };
1796
1680
  *
1797
- * const current: ReadonlyArray<ResourceCurrentState> = [
1798
- * {
1799
- * ...unchanged,
1800
- * outputs: {
1801
- * assetId: asRobloxAssetId("111"),
1802
- * iconAssetIds: { "en-us": asRobloxAssetId("222") },
1681
+ * expect(describe(err)).toBe("update not supported for vip-pass");
1682
+ * ```
1683
+ */
1684
+ type ApplyError = {
1685
+ readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
1686
+ readonly cause: OpenCloudError$1;
1687
+ readonly key: ResourceKey;
1688
+ readonly kind: "driverFailure";
1689
+ } | {
1690
+ readonly appliedSoFar: ReadonlyArray<ResourceCurrentState>;
1691
+ readonly key: ResourceKey;
1692
+ readonly kind: "updateUnsupported";
1693
+ };
1694
+ /**
1695
+ * Dispatch each reconciliation operation to the matching resource driver
1696
+ * with first-fail semantics: on the first `Err` (driver failure or
1697
+ * `updateUnsupported`), the remaining operations are skipped and the error
1698
+ * is returned verbatim.
1699
+ *
1700
+ * Behaviour:
1701
+ * - `create` operations are routed to `registry[op.desired.kind].create`.
1702
+ * - `update` operations are routed to `registry[op.desired.kind].update`
1703
+ * when the driver exposes it; otherwise they short-circuit to an
1704
+ * `updateUnsupported` Err without invoking the driver.
1705
+ * - `noop` operations are skipped entirely (no I/O, no dispatch).
1706
+ *
1707
+ * On success the returned array carries the driver outputs for every
1708
+ * non-noop op, in dispatched order. Noops are not represented; callers
1709
+ * needing a full post-apply snapshot merge with the pre-apply current
1710
+ * state keyed by `ResourceKey`.
1711
+ *
1712
+ * @param ops - Reconciliation operations produced by `diff`, applied in order.
1713
+ * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
1714
+ * @returns `Ok(state)` when every operation succeeds, where `state` holds
1715
+ * driver outputs for each non-noop op in dispatched order; or the first
1716
+ * failure encountered.
1717
+ * @throws Whatever the dispatched driver rejects with outside its `Result`
1718
+ * return. A driver whose injected I/O (file reads, network calls, etc.)
1719
+ * throws will surface that rejection here rather than translating it into
1720
+ * a `Result` failure; wrap the call site in a try/catch when drivers are
1721
+ * not trusted to contain their own rejections.
1722
+ * @example
1723
+ *
1724
+ * ```ts
1725
+ * import {
1726
+ * applyOps,
1727
+ * asResourceKey,
1728
+ * asRobloxAssetId,
1729
+ * asSha256Hex,
1730
+ * type DriverRegistry,
1731
+ * type Operation,
1732
+ * } from "@bedrock-rbx/core";
1733
+ *
1734
+ * const registry: DriverRegistry = {
1735
+ * gamePass: {
1736
+ * async create(desired) {
1737
+ * return {
1738
+ * data: {
1739
+ * ...desired,
1740
+ * outputs: {
1741
+ * assetId: asRobloxAssetId("9876543210"),
1742
+ * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
1743
+ * },
1744
+ * },
1745
+ * success: true,
1746
+ * };
1747
+ * },
1748
+ * },
1749
+ * place: {
1750
+ * async create(desired) {
1751
+ * return {
1752
+ * data: { ...desired, outputs: { versionNumber: 1 } },
1753
+ * success: true,
1754
+ * };
1755
+ * },
1756
+ * },
1757
+ * universe: {
1758
+ * async create(desired) {
1759
+ * return {
1760
+ * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
1761
+ * success: true,
1762
+ * };
1763
+ * },
1764
+ * },
1765
+ * developerProduct: {
1766
+ * async create(desired) {
1767
+ * return {
1768
+ * data: {
1769
+ * ...desired,
1770
+ * outputs: { productId: asRobloxAssetId("8172635495") },
1771
+ * },
1772
+ * success: true,
1773
+ * };
1803
1774
  * },
1804
1775
  * },
1776
+ * };
1777
+ *
1778
+ * const ops: ReadonlyArray<Operation> = [
1805
1779
  * {
1806
- * ...drifted,
1807
- * name: "Legend Pass",
1808
- * outputs: {
1809
- * assetId: asRobloxAssetId("333"),
1810
- * iconAssetIds: { "en-us": asRobloxAssetId("444") },
1780
+ * key: asResourceKey("vip-pass"),
1781
+ * type: "create",
1782
+ * desired: {
1783
+ * key: asResourceKey("vip-pass"),
1784
+ * name: "VIP Pass",
1785
+ * description: "Grants VIP perks.",
1786
+ * icon: { "en-us": "assets/vip-icon.png" },
1787
+ * iconFileHashes: {
1788
+ * "en-us": asSha256Hex(
1789
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1790
+ * ),
1791
+ * },
1792
+ * kind: "gamePass",
1793
+ * price: 500,
1811
1794
  * },
1812
1795
  * },
1813
1796
  * ];
1814
1797
  *
1815
- * const ops = diff([unchanged, drifted, fresh], current);
1816
- *
1817
- * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
1798
+ * return applyOps(ops, registry).then((result) => {
1799
+ * expect(result.success).toBe(true);
1800
+ * expect(result.success && result.data).toHaveLength(1);
1801
+ * });
1818
1802
  * ```
1819
1803
  */
1820
- declare function diff(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): ReadonlyArray<Operation>;
1804
+ declare function applyOps(ops: ReadonlyArray<Operation>, registry: DriverRegistry): Promise<Result$1<ReadonlyArray<ResourceCurrentState>, ApplyError>>;
1821
1805
  //#endregion
1822
- //#region src/core/display-name-prefix.d.ts
1806
+ //#region src/shell/build-default-registry.d.ts
1823
1807
  /**
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"`.
1808
+ * Failure surfaced when default-constructing a registry needs a config
1809
+ * field that is not present. The deploy boundary wraps this in a
1810
+ * `DeployError` so the caller sees a typed Result instead of a downstream
1811
+ * driver error.
1827
1812
  */
1828
- declare const DEFAULT_PREFIX_FORMAT = "[{LABEL}] ";
1813
+ interface RegistryConfigError {
1814
+ /** Suggested fix routed back to the caller. */
1815
+ readonly hint: string;
1816
+ /** Literal discriminator for narrowing. */
1817
+ readonly kind: "registryConfigMissing";
1818
+ /** Which config field was missing. */
1819
+ readonly missing: "universeId";
1820
+ }
1821
+ /** Inputs for {@link buildDefaultRegistry}. */
1822
+ interface BuildDefaultRegistryDeps {
1823
+ /** Resolved project config; supplies `universe.universeId` and is read for nothing else. */
1824
+ readonly config: ResolvedConfig;
1825
+ /** Reads an environment variable; injected so tests stay free of `process.env`. */
1826
+ readonly getEnv: (name: string) => string | undefined;
1827
+ /** Reader plumbed into kind-specific drivers that ingest file bytes. */
1828
+ readonly readFile: (path: string) => Promise<Uint8Array>;
1829
+ }
1829
1830
  /**
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.
1831
+ * Construct the default `DriverRegistry` from `config.universe.universeId`
1832
+ * and `BEDROCK_API_KEY`. Reads the API key via the injected `getEnv` seam
1833
+ * and surfaces `missingCredential` or `registryConfigMissing` as typed
1834
+ * Results instead of throwing.
1844
1835
  *
1845
1836
  * @example
1846
1837
  *
1847
1838
  * ```ts
1848
- * import { renderDisplayNamePrefix } from "@bedrock-rbx/core";
1839
+ * import { buildDefaultRegistry } from "@bedrock-rbx/core";
1849
1840
  *
1850
- * expect(renderDisplayNamePrefix("staging")).toBe("[STAGING] ");
1851
- * expect(renderDisplayNamePrefix("staging", "{Label}: ")).toBe("Staging: ");
1852
- * expect(renderDisplayNamePrefix("dev", "{LABEL}-{label}")).toBe("DEV-dev");
1841
+ * const registry = buildDefaultRegistry({
1842
+ * config: {
1843
+ * environments: { production: {} },
1844
+ * state: { backend: "gist", gistId: "abc" },
1845
+ * universe: { universeId: "1234567890" },
1846
+ * },
1847
+ * getEnv: () => "rbx-test",
1848
+ * readFile: async () => new Uint8Array(),
1849
+ * });
1850
+ *
1851
+ * expect(registry.success).toBeTrue();
1853
1852
  * ```
1853
+ *
1854
+ * @param deps - Validated config plus credential and file-reader seams.
1855
+ * @returns A `DriverRegistry` on success, or a typed Err describing the
1856
+ * missing API key or the missing universe declaration.
1854
1857
  */
1855
- declare function renderDisplayNamePrefix(label: string, format?: string): string;
1858
+ declare function buildDefaultRegistry(deps: BuildDefaultRegistryDeps): Result$1<DriverRegistry, MissingCredentialError | RegistryConfigError>;
1856
1859
  //#endregion
1857
- //#region src/core/environment.d.ts
1860
+ //#region src/core/flatten.d.ts
1858
1861
  /**
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.
1862
+ * Pre-I/O game-pass input the flattener emits. Extends the authored
1863
+ * `GamePassEntry` with the tag discriminator and the `ResourceKey`-branded
1864
+ * key so `buildDesired` can consume a flat tagged list and layer on the
1865
+ * SHA-256 icon digest.
1867
1866
  *
1868
1867
  * @example
1869
1868
  *
1870
1869
  * ```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";
1870
+ * import { asResourceKey, type GamePassDesiredInput } from "@bedrock-rbx/core";
1897
1871
  *
1898
1872
  * const input: GamePassDesiredInput = {
1899
1873
  * description: "Grants VIP perks.",
@@ -2110,47 +2084,6 @@ type ResourceDesiredInput = DeveloperProductDesiredInput | GamePassDesiredInput
2110
2084
  */
2111
2085
  declare function flattenConfig(config: ResolvedConfig): ReadonlyArray<ResourceDesiredInput>;
2112
2086
  //#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
2087
  //#region src/core/kinds/module.d.ts
2155
2088
  /**
2156
2089
  * Failure surfaced during desired-state preparation. Two variants today:
@@ -2325,1007 +2258,1284 @@ type InputFor<K extends ResourceKind> = Extract<ResourceDesiredInput, {
2325
2258
  readonly kind: K;
2326
2259
  }>;
2327
2260
  //#endregion
2328
- //#region src/core/icons.d.ts
2261
+ //#region src/shell/build-desired.d.ts
2329
2262
  /**
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.
2263
+ * Layer file I/O onto a flat tagged list of resource inputs to produce
2264
+ * `ResourceDesiredState`.
2340
2265
  *
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.
2266
+ * For each input, reads the file bytes via the injected `readFile`, computes
2267
+ * the SHA-256 hex digest, and assembles the branded desired-state record
2268
+ * that `diff` consumes. Entries are processed sequentially so the first
2269
+ * failure's attribution is deterministic.
2345
2270
  *
2271
+ * @param inputs - Flat tagged resource inputs from `flattenConfig`.
2272
+ * @param readFile - Reads file bytes for a given path; rejection becomes a
2273
+ * `fileReadFailed` Err.
2274
+ * @returns `Ok` with the desired-state array (same length and order as
2275
+ * `inputs`), or `Err` with the first I/O failure.
2346
2276
  * @example
2347
2277
  *
2348
2278
  * ```ts
2349
- * import { asSha256Hex, shouldReuploadIcon } from "@bedrock-rbx/core";
2279
+ * import { asResourceKey, buildDesired } from "@bedrock-rbx/core";
2350
2280
  *
2351
- * const previous = asSha256Hex(
2352
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2353
- * );
2354
- * const fresh = asSha256Hex(
2355
- * "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
2356
- * );
2281
+ * async function readFile(): Promise<Uint8Array> {
2282
+ * return new Uint8Array([1, 2, 3]);
2283
+ * }
2357
2284
  *
2358
- * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": previous })).toBe(false);
2359
- * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": fresh })).toBe(true);
2285
+ * return buildDesired(
2286
+ * [
2287
+ * {
2288
+ * description: "Grants VIP perks.",
2289
+ * icon: { "en-us": "assets/vip-icon.png" },
2290
+ * key: asResourceKey("vip-pass"),
2291
+ * kind: "gamePass",
2292
+ * name: "VIP Pass",
2293
+ * price: 500,
2294
+ * },
2295
+ * ],
2296
+ * readFile,
2297
+ * ).then((result) => {
2298
+ * expect(result.success).toBeTrue();
2299
+ * if (result.success) {
2300
+ * expect(result.data).toHaveLength(1);
2301
+ * expect(result.data[0]!.kind).toBe("gamePass");
2302
+ * }
2303
+ * });
2360
2304
  * ```
2361
2305
  */
2362
- declare function shouldReuploadIcon(currentHashes: Record<"en-us", Sha256Hex> | undefined, desiredHashes: Record<"en-us", Sha256Hex> | undefined): boolean;
2306
+ declare function buildDesired(inputs: ReadonlyArray<ResourceDesiredInput>, readFile: (path: string) => Promise<Uint8Array>): Promise<Result$1<ReadonlyArray<ResourceDesiredState>, BuildDesiredError>>;
2363
2307
  //#endregion
2364
- //#region src/core/kinds/index.d.ts
2308
+ //#region src/shell/load-config.d.ts
2365
2309
  /**
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.
2310
+ * Options for {@link loadConfig}. Matches a subset of c12's loader options;
2311
+ * additional fields land with the issues that introduce each flow.
2312
+ */
2313
+ interface LoadConfigOptions {
2314
+ /**
2315
+ * Path to a specific config file to load, including its extension.
2316
+ * Resolved relative to `cwd` when not absolute. Loaded as-is with no
2317
+ * extension search; if the file does not exist at the given path,
2318
+ * `loadConfig` returns `fileNotFound`. When omitted, `loadConfig`
2319
+ * discovers `bedrock.config.{ts,js,...}` from `cwd`.
2320
+ */
2321
+ readonly configFile?: string;
2322
+ /**
2323
+ * Directory to search from. Defaults to `process.cwd()` at call time, so
2324
+ * each invocation sees the current working directory.
2325
+ */
2326
+ readonly cwd?: string;
2327
+ }
2328
+ /**
2329
+ * Discover, parse, and validate the project config.
2330
+ *
2331
+ * Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json}`, `.bedrockrc*`,
2332
+ * and `package.json#bedrock` starting at `options.cwd` (or the current
2333
+ * working directory). Returns a fresh, mutable `Config` on every call so
2334
+ * long-running scripts see up-to-date values.
2335
+ *
2336
+ * When the exported default is a function (sync or async), `loadConfig`
2337
+ * invokes it with an empty `ConfigContext` and awaits the result before
2338
+ * validating.
2339
+ *
2340
+ * Errors return via `Result`:
2341
+ * - `fileNotFound` - no config file was discovered under the search path.
2342
+ * - `parseFailed` - a config file was found but could not be parsed (for
2343
+ * example, malformed YAML or JSON).
2344
+ * - `validationFailed` - a file was found and parsed, but its content did
2345
+ * not satisfy the runtime schema.
2346
+ * - `configFunctionFailed` - a function-form config threw or its returned
2347
+ * promise rejected while being invoked.
2370
2348
  *
2349
+ * @param options - Loader options.
2350
+ * @returns `Ok` with the validated `Config`, or `Err` with a `ConfigError`.
2371
2351
  * @example
2372
2352
  *
2373
2353
  * ```ts
2374
- * import { defaultKindRegistry } from "@bedrock-rbx/core";
2354
+ * import { loadConfig } from "@bedrock-rbx/core";
2375
2355
  *
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");
2356
+ * return loadConfig({
2357
+ * configFile: "bedrock.staging.config.yaml",
2358
+ * cwd: "/path/that/does/not/have/a/config",
2359
+ * }).then((result) => {
2360
+ * expect(result.success).toBeFalse();
2361
+ * if (!result.success) {
2362
+ * expect(result.err.kind).toBe("fileNotFound");
2363
+ * }
2364
+ * });
2380
2365
  * ```
2381
2366
  */
2382
- declare const defaultKindRegistry: KindRegistry;
2367
+ declare function loadConfig(options?: LoadConfigOptions): Promise<Result$1<Config, ConfigError>>;
2383
2368
  //#endregion
2384
- //#region src/core/migrate/migration-report.d.ts
2369
+ //#region src/shell/deploy.d.ts
2385
2370
  /**
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`).
2371
+ * Inputs for `deploy`. Every field except `environment` is optional;
2372
+ * omitted dependencies are default-constructed from the project config
2373
+ * and the environment variables `GITHUB_TOKEN` and `BEDROCK_API_KEY`.
2391
2374
  */
2392
- type StatesByEnvironment = Readonly<Record<string, BedrockState>>;
2375
+ interface DeployOptions {
2376
+ /** Pre-loaded, optionally-mutated project config. Omit to call `loadConfig()` automatically. */
2377
+ readonly config?: Config;
2378
+ /** Environment name; threaded into `StatePort.read` and the persisted snapshot. */
2379
+ readonly environment: string;
2380
+ /** `fetch` override plumbed into the default-constructed gist adapter when `statePort` is omitted. */
2381
+ readonly fetch?: GistFetch;
2382
+ /** Reads an environment variable; defaults to `(name) => process.env[name]`. */
2383
+ readonly getEnv?: (name: string) => string | undefined;
2384
+ /** Loader invoked when `config` is omitted; defaults to `loadConfig` from this package. */
2385
+ readonly loadConfig?: (options?: LoadConfigOptions) => Promise<Result$1<Config, ConfigError>>;
2386
+ /** Reads file bytes for resources that have file-backed inputs. Defaults to `node:fs/promises.readFile`. */
2387
+ readonly readFile?: (path: string) => Promise<Uint8Array>;
2388
+ /** Per-kind driver table consulted for create / update dispatch. Default-constructed from `BEDROCK_API_KEY` when omitted. */
2389
+ readonly registry?: DriverRegistry;
2390
+ /** Backend used to read the prior snapshot and persist the new one. Default-constructed from `config.state` and `GITHUB_TOKEN` when omitted. */
2391
+ readonly statePort?: StatePort;
2392
+ }
2393
2393
  /**
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.
2394
+ * Failure surfaced by `deploy`. Stage-tagged so callers can branch on
2395
+ * `kind` to distinguish reconciliation failures (`stateReadFailed`,
2396
+ * `applyFailed`, ...) from default-construction failures
2397
+ * (`configLoadFailed`, `stateNotConfigured`, `unknownEnvironment`,
2398
+ * `incompletePlaceEntry`, `incompleteUniverseEntry`, `missingCredential`,
2399
+ * `unsupportedBackend`, `registryConfigMissing`).
2397
2400
  */
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
- }
2401
+ type DeployError = IncompletePassEntryError | IncompletePlaceEntryError | IncompleteUniverseEntryError | MissingCredentialError | RegistryConfigError | StateNotConfiguredError | UnknownEnvironmentError | UnsupportedBackendError | {
2402
+ readonly cause: ApplyError;
2403
+ readonly kind: "applyFailed";
2404
+ } | {
2405
+ readonly cause: BuildDesiredError;
2406
+ readonly kind: "buildDesiredFailed";
2407
+ } | {
2408
+ readonly cause: ConfigError;
2409
+ readonly kind: "configLoadFailed";
2410
+ } | {
2411
+ readonly cause: StateError;
2412
+ readonly kind: "stateReadFailed";
2413
+ } | {
2414
+ readonly cause: StateError;
2415
+ readonly kind: "stateWriteFailed";
2416
+ readonly unsavedState: BedrockState;
2417
+ };
2408
2418
  /**
2409
- * Discriminated union describing one observation the migrator made about a
2410
- * Mantle field that did not flow straight into bedrock config or state.
2419
+ * Run a full reconcile end-to-end. Default-constructs missing deps from
2420
+ * the project config and the environment variables `GITHUB_TOKEN` and
2421
+ * `BEDROCK_API_KEY`; never reads `process.env` when `statePort`,
2422
+ * `registry`, and `config` are all supplied explicitly.
2411
2423
  *
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.
2424
+ * @param options - Target environment plus optional overrides.
2425
+ * @returns The persisted `BedrockState` on success, or a stage-tagged
2426
+ * `DeployError` on failure.
2427
+ * @example
2422
2428
  *
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`.
2429
+ * ```ts
2430
+ * import { deploy } from "@bedrock-rbx/core";
2448
2431
  *
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.
2432
+ * return deploy({ environment: "production" }).then((result) => {
2433
+ * expect(result.success).toBeFalse();
2434
+ * if (!result.success) {
2435
+ * expect(["configLoadFailed", "stateNotConfigured"]).toContain(result.err.kind);
2436
+ * }
2437
+ * });
2438
+ * ```
2439
+ *
2440
+ * @example
2441
+ *
2442
+ * ```ts
2443
+ * import { deploy, type BedrockState, type DriverRegistry, type StatePort } from "@bedrock-rbx/core";
2444
+ *
2445
+ * const store = new Map<string, BedrockState>();
2446
+ * const statePort: StatePort = {
2447
+ * async read(environment) {
2448
+ * return { data: store.get(environment), success: true };
2449
+ * },
2450
+ * async write(state) {
2451
+ * store.set(state.environment, state);
2452
+ * return { data: undefined, success: true };
2453
+ * },
2454
+ * };
2455
+ * const registry: DriverRegistry = {
2456
+ * developerProduct: {
2457
+ * create: async () => { throw new Error("unreachable: empty config"); },
2458
+ * },
2459
+ * gamePass: { create: async () => { throw new Error("unreachable: empty config"); } },
2460
+ * place: { create: async () => { throw new Error("unreachable: empty config"); } },
2461
+ * universe: { create: async () => { throw new Error("unreachable: empty config"); } },
2462
+ * };
2463
+ *
2464
+ * return deploy({
2465
+ * config: {
2466
+ * environments: { production: {} },
2467
+ * state: { backend: "gist", gistId: "abc" },
2468
+ * passes: {},
2469
+ * },
2470
+ * environment: "production",
2471
+ * registry,
2472
+ * statePort,
2473
+ * }).then((result) => {
2474
+ * expect(result.success).toBeTrue();
2475
+ * if (result.success) {
2476
+ * expect(result.data.environment).toBe("production");
2477
+ * expect(result.data.resources).toBeEmpty();
2478
+ * }
2479
+ * });
2480
+ * ```
2466
2481
  */
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
- };
2482
+ declare function deploy(options: DeployOptions): Promise<Result$1<BedrockState, DeployError>>;
2483
+ //#endregion
2484
+ //#region src/cli/render.d.ts
2490
2485
  /**
2491
- * Result returned by a successful `migrateMantleState` call.
2486
+ * Output port the CLI renders through. Mirrors the subset of `@clack/prompts`
2487
+ * the bedrock CLI uses today; tests inject a fake to assert what was rendered.
2492
2488
  *
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).
2489
+ * @example
2496
2490
  *
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.
2491
+ * ```ts
2492
+ * import type { ClackPort } from "@bedrock-rbx/core";
2493
+ *
2494
+ * const lines: Array<string> = [];
2495
+ * const port: ClackPort = {
2496
+ * cancel: (message) => lines.push(`cancel: ${message}`),
2497
+ * intro: (message) => lines.push(`intro: ${message}`),
2498
+ * logError: (message) => lines.push(`error: ${message}`),
2499
+ * logMessage: (message) => lines.push(`log: ${message}`),
2500
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
2501
+ * outro: (message) => lines.push(`outro: ${message}`),
2502
+ * };
2500
2503
  *
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.
2504
+ * port.logSuccess("done");
2504
2505
  *
2505
- * `warnings` and `summary` describe what the migrator did *not* migrate
2506
- * verbatim, classified for triage. The skeleton emits no warnings.
2506
+ * expect(lines).toEqual(["ok: done"]);
2507
+ * ```
2507
2508
  */
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>;
2509
+ interface ClackPort {
2510
+ /** End an interactive flow with a cancellation marker. */
2511
+ cancel(message: string): void;
2512
+ /** Open a framed section with a title (used for command intros). */
2513
+ intro(message: string): void;
2514
+ /** Render a single error line inside an open frame. */
2515
+ logError(message: string): void;
2516
+ /** Render a single neutral line inside an open frame. */
2517
+ logMessage(message: string): void;
2518
+ /** Render a single success line inside an open frame. */
2519
+ logSuccess(message: string): void;
2520
+ /** Close the current framed section with a final message. */
2521
+ outro(message: string): void;
2519
2522
  }
2520
2523
  //#endregion
2521
- //#region src/core/resolve-state-config.d.ts
2524
+ //#region src/ports/progress-port.d.ts
2522
2525
  /**
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.
2526
+ * Per-environment outcome event emitted after a deploy completes
2527
+ * successfully. Carries the environment name and the count of resources
2528
+ * present in the persisted state snapshot.
2527
2529
  */
2528
- interface StateNotConfiguredError {
2529
- /** Environment that the resolver was called against. */
2530
+ interface DeploySuccessEvent {
2531
+ /** The environment that finished reconciling. */
2530
2532
  readonly environment: string;
2531
- /** Literal discriminator for narrowing. */
2532
- readonly kind: "stateNotConfigured";
2533
+ /** Discriminator tag. */
2534
+ readonly kind: "deploySuccess";
2535
+ /** Number of resources in the post-deploy state snapshot. */
2536
+ readonly resourceCount: number;
2533
2537
  }
2534
2538
  /**
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.
2539
+ * Per-environment outcome event emitted when a deploy fails. Carries the
2540
+ * environment name and the full {@link DeployError} so a renderer can
2541
+ * delegate to the existing diagnostic helpers.
2539
2542
  */
2540
- interface StateResolutionInputs {
2541
- readonly environments: Record<string, undefined | {
2542
- readonly state?: StateConfig;
2543
- }>;
2544
- readonly state?: StateConfig;
2543
+ interface DeployFailureEvent {
2544
+ /** The environment whose deploy failed. */
2545
+ readonly environment: string;
2546
+ /** Stage-tagged failure reason returned by the shell `deploy` function. */
2547
+ readonly error: DeployError;
2548
+ /** Discriminator tag. */
2549
+ readonly kind: "deployFailure";
2545
2550
  }
2546
2551
  /**
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.
2552
+ * Discriminated union of progress events the CLI emits while a deploy
2553
+ * runs. The variant set is additive: future per-stage and per-resource
2554
+ * events land as new `kind` values without breaking existing adapters.
2555
+ */
2556
+ type ProgressEvent = DeployFailureEvent | DeploySuccessEvent;
2557
+ /**
2558
+ * Plugin contract for receiving deploy outcomes: the interface an adapter
2559
+ * (clack renderer, JSON logger, custom UI) implements to let the CLI hand
2560
+ * off events without re-implementing rendering logic.
2561
+ *
2562
+ * `ProgressPort` is a *driven* (secondary) port in hexagonal terms.
2551
2563
  *
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
2564
  * @example
2557
2565
  *
2558
2566
  * ```ts
2559
- * import { resolveStateConfig } from "@bedrock-rbx/core";
2567
+ * import type { ProgressEvent, ProgressPort } from "@bedrock-rbx/core";
2560
2568
  *
2561
- * const result = resolveStateConfig(
2562
- * {
2563
- * state: { backend: "gist", gistId: "root-gist" },
2564
- * environments: {
2565
- * production: { state: { backend: "gist", gistId: "prod-gist" } },
2566
- * },
2569
+ * let received: ReadonlyArray<ProgressEvent> = [];
2570
+ * const port: ProgressPort = {
2571
+ * emit(event) {
2572
+ * received = [...received, event];
2567
2573
  * },
2568
- * "production",
2569
- * );
2574
+ * };
2570
2575
  *
2571
- * expect(result.success).toBeTrue();
2572
- * if (result.success) {
2573
- * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
2574
- * }
2576
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
2577
+ *
2578
+ * expect(received).toEqual([
2579
+ * { environment: "production", kind: "deploySuccess", resourceCount: 3 },
2580
+ * ]);
2575
2581
  * ```
2576
2582
  */
2577
- declare function resolveStateConfig(config: StateResolutionInputs, environment: string): Result$1<StateConfig, StateNotConfiguredError>;
2583
+ interface ProgressPort {
2584
+ /** Hand a single progress event to the adapter for rendering or logging. */
2585
+ emit(event: ProgressEvent): void;
2586
+ }
2578
2587
  //#endregion
2579
- //#region src/core/select-environment.d.ts
2588
+ //#region src/adapters/clack-progress-adapter.d.ts
2580
2589
  /**
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.
2590
+ * Configuration for {@link createClackProgressAdapter}.
2585
2591
  */
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";
2592
+ interface ClackProgressAdapterDeps {
2593
+ /** Output port the events are rendered through. */
2594
+ readonly clack: ClackPort;
2593
2595
  }
2594
2596
  /**
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.
2597
+ * Build a {@link ProgressPort} that renders events through a `ClackPort`.
2598
+ * Pattern-matches on the event `kind`: `deploySuccess` becomes a single
2599
+ * success line and `deployFailure` delegates to the package's deploy-error
2600
+ * rendering helper.
2601
+ *
2602
+ * @example
2603
+ *
2604
+ * ```ts
2605
+ * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
2606
+ *
2607
+ * const lines: Array<string> = [];
2608
+ * const clack: ClackPort = {
2609
+ * cancel: (message) => lines.push(`cancel: ${message}`),
2610
+ * intro: (message) => lines.push(`intro: ${message}`),
2611
+ * logError: (message) => lines.push(`error: ${message}`),
2612
+ * logMessage: (message) => lines.push(`log: ${message}`),
2613
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
2614
+ * outro: (message) => lines.push(`outro: ${message}`),
2615
+ * };
2616
+ *
2617
+ * const port = createClackProgressAdapter({ clack });
2618
+ *
2619
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
2620
+ *
2621
+ * expect(lines).toEqual(["ok: production: 3 resources reconciled"]);
2622
+ * ```
2623
+ *
2624
+ * @param deps - The clack port the adapter renders through.
2625
+ * @returns A `ProgressPort` that renders via clack.
2602
2626
  */
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
- }
2627
+ declare function createClackProgressAdapter(deps: ClackProgressAdapterDeps): ProgressPort;
2628
+ //#endregion
2629
+ //#region src/adapters/developer-product-driver.d.ts
2613
2630
  /**
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";
2631
+ * Dependencies of `createDeveloperProductDriver`. `universeId` is captured
2632
+ * at construction time (matching `GamePassDriverDeps`) so each driver
2633
+ * instance is bound to a single universe; multi-universe deploys construct
2634
+ * one driver per universe. `readFile` exists on the driver (not upstream
2635
+ * in shell) because icon hashes flow through `diff` but bytes do not.
2636
+ *
2637
+ * @example
2638
+ *
2639
+ * ```ts
2640
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2641
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
2642
+ * import { asRobloxAssetId, type DeveloperProductDriverDeps } from "@bedrock-rbx/core";
2643
+ *
2644
+ * const httpClient: HttpClient = {
2645
+ * async request() {
2646
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2647
+ * },
2648
+ * };
2649
+ *
2650
+ * const deps: DeveloperProductDriverDeps = {
2651
+ * client: new DeveloperProductsClient({
2652
+ * apiKey: "rbx-your-key",
2653
+ * httpClient,
2654
+ * sleep: async () => {},
2655
+ * }),
2656
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2657
+ * universeId: asRobloxAssetId("1234567890"),
2658
+ * };
2659
+ *
2660
+ * expect(deps.universeId).toBe("1234567890");
2661
+ * ```
2662
+ */
2663
+ interface DeveloperProductDriverDeps {
2664
+ /** Configured developer-products client from `@bedrock-rbx/ocale/developer-products`. */
2665
+ readonly client: DeveloperProductsClient;
2666
+ /** Reads icon bytes for upload; rejections propagate out of `create` and `update`. */
2667
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2668
+ /** Universe that owns every developer product this driver creates. */
2669
+ readonly universeId: RobloxAssetId;
2627
2670
  }
2628
- /** Failure modes returned by {@link selectEnvironment}. */
2629
- type SelectEnvironmentError = IncompletePlaceEntryError | IncompleteUniverseEntryError | UnknownEnvironmentError;
2630
2671
  /**
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).
2672
+ * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
2673
+ * that maps a desired-state entry to an ocale create or update call and the
2674
+ * response back to a `ResourceCurrentState<"developerProduct">`. The
2675
+ * `update` path consumes the upstream `204 No Content` response and
2676
+ * synthesizes the post-update `ResourceCurrentState` from `desired` plus
2677
+ * the existing `current.outputs`, carrying `iconImageAssetId` forward when
2678
+ * present.
2636
2679
  *
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.
2680
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
2644
2681
  *
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 }`.
2682
+ * @param deps - Injected ocale client and owning universe.
2683
+ * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
2653
2684
  *
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.
2685
+ * @example
2658
2686
  *
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.
2687
+ * ```ts
2688
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2689
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
2690
+ * import {
2691
+ * asResourceKey,
2692
+ * asRobloxAssetId,
2693
+ * createDeveloperProductDriver,
2694
+ * } from "@bedrock-rbx/core";
2695
+ *
2696
+ * const httpClient: HttpClient = {
2697
+ * async request() {
2698
+ * return {
2699
+ * data: {
2700
+ * body: {
2701
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
2702
+ * description: "Stocks the player up with 1,000 premium gems.",
2703
+ * iconImageAssetId: null,
2704
+ * isForSale: false,
2705
+ * isImmutable: false,
2706
+ * name: "Gem Pack",
2707
+ * priceInformation: null,
2708
+ * productId: 9_876_543_210,
2709
+ * storePageEnabled: false,
2710
+ * universeId: 1_234_567_890,
2711
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
2712
+ * },
2713
+ * headers: {},
2714
+ * status: 200,
2715
+ * },
2716
+ * success: true,
2717
+ * };
2718
+ * },
2719
+ * };
2720
+ *
2721
+ * const driver = createDeveloperProductDriver({
2722
+ * client: new DeveloperProductsClient({
2723
+ * apiKey: "rbx-your-key",
2724
+ * httpClient,
2725
+ * sleep: async () => {},
2726
+ * }),
2727
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2728
+ * universeId: asRobloxAssetId("1234567890"),
2729
+ * });
2730
+ *
2731
+ * return driver
2732
+ * .create({
2733
+ * description: "Stocks the player up with 1,000 premium gems.",
2734
+ * isRegionalPricingEnabled: undefined,
2735
+ * key: asResourceKey("gem-pack"),
2736
+ * kind: "developerProduct",
2737
+ * name: "Gem Pack",
2738
+ * price: undefined,
2739
+ * storePageEnabled: undefined,
2740
+ * })
2741
+ * .then((result) => {
2742
+ * expect(result.success).toBeTrue();
2743
+ * if (result.success) {
2744
+ * expect(result.data.outputs.productId).toBe("9876543210");
2745
+ * }
2746
+ * });
2747
+ * ```
2748
+ */
2749
+ declare function createDeveloperProductDriver(deps: DeveloperProductDriverDeps): ResourceDriver<"developerProduct">;
2750
+ //#endregion
2751
+ //#region src/adapters/game-pass-driver.d.ts
2752
+ /**
2753
+ * `universeId` is captured at construction time rather than on
2754
+ * `GamePassDesiredState` so state files round-trip with Mantle's `PassInputs`
2755
+ * shape. `readFile` exists on the driver (not upstream in shell) because icon
2756
+ * hashes flow through `diff` but bytes do not.
2757
+ *
2758
+ * @example
2759
+ *
2760
+ * ```ts
2761
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2762
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
2763
+ * import { asRobloxAssetId, type GamePassDriverDeps } from "@bedrock-rbx/core";
2764
+ *
2765
+ * const httpClient: HttpClient = {
2766
+ * async request() {
2767
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2768
+ * },
2769
+ * };
2770
+ *
2771
+ * const deps: GamePassDriverDeps = {
2772
+ * client: new GamePassesClient({
2773
+ * apiKey: "rbx-your-key",
2774
+ * httpClient,
2775
+ * sleep: async () => {},
2776
+ * }),
2777
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2778
+ * universeId: asRobloxAssetId("1234567890"),
2779
+ * };
2780
+ *
2781
+ * expect(deps.universeId).toBe("1234567890");
2782
+ * ```
2783
+ */
2784
+ interface GamePassDriverDeps {
2785
+ /** Configured game-passes client from `@bedrock-rbx/ocale/game-passes`. */
2786
+ readonly client: GamePassesClient;
2787
+ /** Reads icon bytes for upload; rejections propagate out of `create`. */
2788
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2789
+ /** Universe that owns every game pass this driver creates. */
2790
+ readonly universeId: RobloxAssetId;
2791
+ }
2792
+ /**
2793
+ * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
2794
+ * a desired-state entry to an ocale create call and the response back to a
2795
+ * `ResourceCurrentState<"gamePass">`.
2796
+ *
2797
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
2798
+ * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
2799
+ * shape and propagate as promise rejections; shell callers are expected to
2800
+ * translate them if a unified error surface is required.
2801
+ *
2802
+ * @param deps - Injected ocale client, file reader, and owning universe.
2803
+ * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
2804
+ * @throws Whatever `deps.readFile` rejects with.
2805
+ *
2806
+ * @example
2807
+ *
2808
+ * ```ts
2809
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2810
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
2811
+ * import {
2812
+ * asResourceKey,
2813
+ * asRobloxAssetId,
2814
+ * asSha256Hex,
2815
+ * createGamePassDriver,
2816
+ * } from "@bedrock-rbx/core";
2817
+ *
2818
+ * const httpClient: HttpClient = {
2819
+ * async request() {
2820
+ * return {
2821
+ * data: {
2822
+ * body: {
2823
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
2824
+ * description: "Grants VIP perks.",
2825
+ * gamePassId: 9_876_543_210,
2826
+ * iconAssetId: 1_122_334_455,
2827
+ * isForSale: true,
2828
+ * name: "VIP Pass",
2829
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
2830
+ * },
2831
+ * headers: {},
2832
+ * status: 200,
2833
+ * },
2834
+ * success: true,
2835
+ * };
2836
+ * },
2837
+ * };
2838
+ *
2839
+ * const driver = createGamePassDriver({
2840
+ * client: new GamePassesClient({
2841
+ * apiKey: "rbx-your-key",
2842
+ * httpClient,
2843
+ * sleep: async () => {},
2844
+ * }),
2845
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
2846
+ * universeId: asRobloxAssetId("1234567890"),
2847
+ * });
2848
+ *
2849
+ * return driver
2850
+ * .create({
2851
+ * description: "Grants VIP perks.",
2852
+ * icon: { "en-us": "assets/vip-icon.png" },
2853
+ * iconFileHashes: {
2854
+ * "en-us": asSha256Hex(
2855
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2856
+ * ),
2857
+ * },
2858
+ * key: asResourceKey("vip-pass"),
2859
+ * kind: "gamePass",
2860
+ * name: "VIP Pass",
2861
+ * price: 500,
2862
+ * })
2863
+ * .then((result) => {
2864
+ * expect(result.success).toBeTrue();
2865
+ * if (result.success) {
2866
+ * expect(result.data.outputs.assetId).toBe("9876543210");
2867
+ * }
2868
+ * });
2869
+ * ```
2870
+ */
2871
+ declare function createGamePassDriver(deps: GamePassDriverDeps): ResourceDriver<"gamePass">;
2872
+ //#endregion
2873
+ //#region src/adapters/no-op-progress-adapter.d.ts
2874
+ /**
2875
+ * Build a {@link ProgressPort} that silently drops every event. Useful for
2876
+ * tests and programmatic callers who want to invoke deploy logic without
2877
+ * any rendering.
2878
+ *
2879
+ * @example
2880
+ *
2881
+ * ```ts
2882
+ * import { createNoOpProgressAdapter } from "@bedrock-rbx/core";
2666
2883
  *
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.
2884
+ * const port = createNoOpProgressAdapter();
2885
+ *
2886
+ * expect(() =>
2887
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 }),
2888
+ * ).not.toThrow();
2889
+ * ```
2890
+ *
2891
+ * @returns A `ProgressPort` whose `emit` method is a no-op.
2892
+ */
2893
+ declare function createNoOpProgressAdapter(): ProgressPort;
2894
+ //#endregion
2895
+ //#region src/adapters/place-driver.d.ts
2896
+ /**
2897
+ * Dependencies of `createPlaceDriver`. `universeId` is captured at
2898
+ * construction time (matching `GamePassDriverDeps`) so each driver instance
2899
+ * is bound to a single universe; multi-universe deploys construct one driver
2900
+ * per universe. `readFile` is injected because `diff` operates on file hashes
2901
+ * while the driver is the only place that needs the raw bytes.
2674
2902
  *
2675
2903
  * @example
2676
2904
  *
2677
2905
  * ```ts
2678
- * import { selectEnvironment } from "@bedrock-rbx/core";
2679
- * import type { Config } from "@bedrock-rbx/core/config";
2906
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2907
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2908
+ * import { asRobloxAssetId, type PlaceDriverDeps } from "@bedrock-rbx/core";
2680
2909
  *
2681
- * const config: Config = {
2682
- * environments: {
2683
- * production: { universe: { universeId: "999" } },
2910
+ * const httpClient: HttpClient = {
2911
+ * async request() {
2912
+ * return { data: { body: {}, headers: {}, status: 200 }, success: true };
2684
2913
  * },
2685
- * state: { backend: "gist", gistId: "abc123" },
2686
- * universe: { voiceChatEnabled: true },
2687
2914
  * };
2688
2915
  *
2689
- * const result = selectEnvironment(config, "production");
2916
+ * const deps: PlaceDriverDeps = {
2917
+ * client: new PlacesClient({
2918
+ * apiKey: "rbx-your-key",
2919
+ * httpClient,
2920
+ * sleep: async () => {},
2921
+ * }),
2922
+ * readFile: async () => new Uint8Array(),
2923
+ * universeId: asRobloxAssetId("1234567890"),
2924
+ * };
2690
2925
  *
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
- * }
2926
+ * expect(deps.universeId).toBe("1234567890");
2697
2927
  * ```
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
2928
  */
2707
- declare function selectEnvironment(config: Config, environment: string): Result$1<ResolvedConfig, SelectEnvironmentError>;
2708
- //#endregion
2709
- //#region src/core/state-file.d.ts
2929
+ interface PlaceDriverDeps {
2930
+ /** Configured places client from `@bedrock-rbx/ocale/places`. */
2931
+ readonly client: PlacesClient;
2932
+ /** Reads place-file bytes for upload; rejections propagate out of the driver. */
2933
+ readonly readFile: (path: string) => Promise<Uint8Array>;
2934
+ /** Universe that owns every place this driver publishes. */
2935
+ readonly universeId: RobloxAssetId;
2936
+ }
2710
2937
  /**
2711
- * Serialize a {@link BedrockState} to the on-disk JSON representation used by
2712
- * state-port adapters.
2938
+ * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
2939
+ * `update` are both thin wrappers over a shared publish helper because the
2940
+ * upstream Open Cloud call is identical either way: there is no "create
2941
+ * place" endpoint (the place is user-supplied input), only "publish version".
2713
2942
  *
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.
2943
+ * Format is detected from the file extension (`.rbxl` → binary,
2944
+ * `.rbxlx` XML); any other extension returns an `ApiError`-backed failure
2945
+ * without hitting the network.
2946
+ *
2947
+ * @param deps - Injected ocale client, file reader, and owning universe.
2948
+ * @returns A driver indexable by `"place"` in a `DriverRegistry`.
2949
+ * @throws Whatever `deps.readFile` rejects with.
2718
2950
  *
2719
2951
  * @example
2720
2952
  *
2721
2953
  * ```ts
2722
- * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
2954
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2955
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2956
+ * import {
2957
+ * asResourceKey,
2958
+ * asRobloxAssetId,
2959
+ * asSha256Hex,
2960
+ * createPlaceDriver,
2961
+ * } from "@bedrock-rbx/core";
2723
2962
  *
2724
- * const state: BedrockState = {
2725
- * environment: "production",
2726
- * resources: [],
2727
- * version: 1,
2963
+ * const httpClient: HttpClient = {
2964
+ * async request() {
2965
+ * return {
2966
+ * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
2967
+ * success: true,
2968
+ * };
2969
+ * },
2728
2970
  * };
2729
2971
  *
2730
- * const wire = serializeStateFile(state);
2731
- * expect(JSON.parse(wire)).toStrictEqual({
2732
- * $bedrock: { version: 1 },
2733
- * environment: "production",
2734
- * resources: [],
2972
+ * const driver = createPlaceDriver({
2973
+ * client: new PlacesClient({
2974
+ * apiKey: "rbx-your-key",
2975
+ * httpClient,
2976
+ * sleep: async () => {},
2977
+ * }),
2978
+ * readFile: async () =>
2979
+ * new Uint8Array([
2980
+ * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
2981
+ * 0x0a,
2982
+ * ]),
2983
+ * universeId: asRobloxAssetId("1234567890"),
2735
2984
  * });
2736
- * ```
2737
2985
  *
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.
2986
+ * return driver
2987
+ * .create({
2988
+ * description: undefined,
2989
+ * displayName: undefined,
2990
+ * fileHash: asSha256Hex(
2991
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2992
+ * ),
2993
+ * filePath: "places/start.rbxl",
2994
+ * key: asResourceKey("start-place"),
2995
+ * kind: "place",
2996
+ * placeId: asRobloxAssetId("4711"),
2997
+ * serverSize: undefined,
2998
+ * })
2999
+ * .then((result) => {
3000
+ * expect(result.success).toBeTrue();
3001
+ * if (result.success) {
3002
+ * expect(result.data.outputs.versionNumber).toBe(1);
3003
+ * }
3004
+ * });
3005
+ * ```
2740
3006
  */
2741
- declare function serializeStateFile(state: BedrockState): string;
3007
+ declare function createPlaceDriver(deps: PlaceDriverDeps): ResourceDriver<"place">;
3008
+ //#endregion
3009
+ //#region src/adapters/universe-driver.d.ts
2742
3010
  /**
2743
- * Parse a raw on-disk state file into a {@link BedrockState}.
3011
+ * Dependencies of `createUniverseDriver`. The driver reconciles the
3012
+ * universe singleton against both the universes endpoint and the root
3013
+ * place (for fields Roblox marks read-only on the universe, like
3014
+ * `displayName`). There is no `universeId` at construction time because
3015
+ * the universe *is* the resource the driver reconciles, so the ID rides
3016
+ * along on each `UniverseDesiredState`.
3017
+ */
3018
+ interface UniverseDriverDeps {
3019
+ /** Configured places client from `@bedrock-rbx/ocale/places`. */
3020
+ readonly places: PlacesClient;
3021
+ /** Configured universes client from `@bedrock-rbx/ocale/universes`. */
3022
+ readonly universes: UniversesClient;
3023
+ }
3024
+ /**
3025
+ * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
3026
+ * and `update` both delegate to a shared reconcile helper because Open
3027
+ * Cloud cannot mint universes; the user supplies an existing `universeId`
3028
+ * and bedrock adopts the universe on first apply.
2744
3029
  *
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.
3030
+ * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
3031
+ * as an adoption-error `ApiError` whose message names the config key and
3032
+ * the `universeId`, so operators can tell adoption failure apart from
3033
+ * transient upstream errors. A successful response whose `rootPlaceId` is
3034
+ * absent surfaces as an `ApiError` with status 200, mirroring the
3035
+ * malformed-response guard in `GamePassDriver`.
3036
+ *
3037
+ * When `displayName` is declared, the driver routes that field through
3038
+ * `PlacesClient.update` on the root place after the universe PATCH
3039
+ * succeeds. A subsequent places failure surfaces to the caller as the
3040
+ * driver's error result without rolling back the prior universe patch,
3041
+ * so callers observing a partial failure should reconcile by
3042
+ * reapplying rather than assuming the universe-level fields are
3043
+ * unchanged.
3044
+ *
3045
+ * @param deps - Injected ocale clients (universes plus places for the
3046
+ * read-only universe fields Roblox derives from the root place).
3047
+ * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2748
3048
  *
2749
3049
  * @example
2750
3050
  *
2751
3051
  * ```ts
2752
- * import { parseStateFile } from "@bedrock-rbx/core";
3052
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
3053
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
3054
+ * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
3055
+ * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
3056
+ * import {
3057
+ * asRobloxAssetId,
3058
+ * createUniverseDriver,
3059
+ * UNIVERSE_SINGLETON_KEY,
3060
+ * } from "@bedrock-rbx/core";
2753
3061
  *
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
- * ```
3062
+ * const universeBodyHttpClient: HttpClient = {
3063
+ * async request() {
3064
+ * return {
3065
+ * data: {
3066
+ * body: validUniverseBody({
3067
+ * path: "universes/1234567890",
3068
+ * rootPlace: "universes/1234567890/places/4711",
3069
+ * }),
3070
+ * headers: {},
3071
+ * status: 200,
3072
+ * },
3073
+ * success: true,
3074
+ * };
3075
+ * },
3076
+ * };
2760
3077
  *
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.
3078
+ * const driver = createUniverseDriver({
3079
+ * places: new PlacesClient({
3080
+ * apiKey: "rbx-your-key",
3081
+ * httpClient: universeBodyHttpClient,
3082
+ * sleep: async () => {},
3083
+ * }),
3084
+ * universes: new UniversesClient({
3085
+ * apiKey: "rbx-your-key",
3086
+ * httpClient: universeBodyHttpClient,
3087
+ * sleep: async () => {},
3088
+ * }),
3089
+ * });
3090
+ *
3091
+ * return driver
3092
+ * .create({
3093
+ * consoleEnabled: undefined,
3094
+ * desktopEnabled: true,
3095
+ * displayName: undefined,
3096
+ * key: UNIVERSE_SINGLETON_KEY,
3097
+ * kind: "universe",
3098
+ * mobileEnabled: undefined,
3099
+ * privateServerPriceRobux: undefined,
3100
+ * tabletEnabled: undefined,
3101
+ * universeId: asRobloxAssetId("1234567890"),
3102
+ * voiceChatEnabled: true,
3103
+ * vrEnabled: undefined,
3104
+ * })
3105
+ * .then((result) => {
3106
+ * expect(result.success).toBeTrue();
3107
+ * if (result.success) {
3108
+ * expect(result.data.outputs.rootPlaceId).toBe("4711");
3109
+ * }
3110
+ * });
3111
+ * ```
2767
3112
  */
2768
- declare function parseStateFile(raw: string | undefined, file: string): Result$1<BedrockState | undefined, StateError>;
3113
+ declare function createUniverseDriver(deps: UniverseDriverDeps): ResourceDriver<"universe">;
2769
3114
  //#endregion
2770
- //#region src/core/validate-plan.d.ts
3115
+ //#region src/cli/clack-port.d.ts
2771
3116
  /**
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.
3117
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
3118
+ * resulting port writes to `process.stdout` via clack's defaults. Kept in
3119
+ * its own module so consumers that never need the clack-backed rendering
3120
+ * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
3121
+ * into their bundle.
2786
3122
  *
2787
3123
  * @example
2788
3124
  *
2789
3125
  * ```ts
2790
- * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3126
+ * import { createClackPort } from "@bedrock-rbx/core";
2791
3127
  *
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
- * );
3128
+ * const port = createClackPort();
2823
3129
  *
2824
- * expect(result.success).toBeFalse();
2825
- * if (!result.success) {
2826
- * expect(result.err.kind).toBe("iconRemovalRejected");
2827
- * }
3130
+ * expect(typeof port.logSuccess).toBe("function");
2828
3131
  * ```
3132
+ *
3133
+ * @returns A port whose six methods each invoke the matching clack helper.
2829
3134
  */
2830
- declare function validatePlan(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): Result$1<undefined, BuildDesiredError>;
3135
+ declare function createClackPort(): ClackPort;
2831
3136
  //#endregion
2832
- //#region src/shell/apply-ops.d.ts
3137
+ //#region src/core/derive-price-fields.d.ts
2833
3138
  /**
2834
- * Failure surfaced by `applyOps` when an operation cannot be applied.
2835
- * Plain-data discriminated union; narrow on `kind`, do not `instanceof` it.
3139
+ * Wire-shape pricing fragment produced by {@link derivePriceFields}: the
3140
+ * `isForSale` flag and an optional numeric `price`. Mirrors the multipart
3141
+ * fields the Open Cloud `developer-products` create and update endpoints
3142
+ * accept for setting Robux pricing.
3143
+ */
3144
+ interface PriceFields {
3145
+ /** Whether the developer product should be purchasable. */
3146
+ readonly isForSale: boolean;
3147
+ /** Default price in Robux; absent when the product is off-sale. */
3148
+ readonly price?: number;
3149
+ }
3150
+ /**
3151
+ * Translate a Mantle-style optional price into the Open Cloud wire shape.
2836
3152
  *
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.
3153
+ * `desired.price === undefined` (no price declared) becomes
3154
+ * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
3155
+ * price (including `0`) becomes `{ isForSale: true, price }`. Both
3156
+ * `developerProduct` create and update paths share this helper so the
3157
+ * "absent ⇒ off-sale" semantics live in exactly one place.
3158
+ *
3159
+ * @param desired - Object carrying the user-declared `price`.
3160
+ * @returns The wire-shape `{ isForSale, price? }` fragment.
2841
3161
  *
2842
3162
  * @example
2843
3163
  *
2844
3164
  * ```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
- * };
3165
+ * import { derivePriceFields } from "@bedrock-rbx/core";
2863
3166
  *
2864
- * expect(describe(err)).toBe("update not supported for vip-pass");
3167
+ * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
3168
+ * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
2865
3169
  * ```
2866
3170
  */
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
- };
3171
+ declare function derivePriceFields(desired: {
3172
+ readonly price: number | undefined;
3173
+ }): PriceFields;
3174
+ //#endregion
3175
+ //#region src/core/diff.d.ts
2877
3176
  /**
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.
3177
+ * Computes the operations required to reconcile `current` state with `desired`
3178
+ * state. Pure and synchronous: no I/O, no side effects, no `Result` wrapper.
2882
3179
  *
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).
3180
+ * Each entry in `desired` is matched to `current` by `(kind, key)`: resources
3181
+ * are uniquely identified by that pair, so a `place` and a `universe` keyed
3182
+ * `"main"` are independent slots. A `(kind, key)` pair present only in
3183
+ * `desired` produces a `create` op; a pair present in both produces an
3184
+ * `update` op if any declared field differs or a `noop` op if every field
3185
+ * matches.
2889
3186
  *
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`.
3187
+ * Ops appear in the order their desired entries appear in the input array so
3188
+ * callers can rely on declaration order when logging or applying ops.
3189
+ *
3190
+ * @param desired - Declared desired state from user config, already normalized
3191
+ * (file hashes computed, nullable wire values mapped to `undefined`).
3192
+ * @param current - Last-known live state from the state file.
3193
+ * @returns Operations to reconcile the two snapshots.
2894
3194
  *
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
3195
  * @example
2906
3196
  *
2907
3197
  * ```ts
2908
3198
  * import {
2909
- * applyOps,
2910
3199
  * asResourceKey,
2911
3200
  * asRobloxAssetId,
2912
3201
  * asSha256Hex,
2913
- * type DriverRegistry,
2914
- * type Operation,
3202
+ * diff,
3203
+ * type GamePassDesiredState,
3204
+ * type ResourceCurrentState,
2915
3205
  * } from "@bedrock-rbx/core";
2916
3206
  *
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
- * },
3207
+ * const hash = asSha256Hex(
3208
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3209
+ * );
3210
+ *
3211
+ * const unchanged: GamePassDesiredState = {
3212
+ * description: "Grants VIP perks.",
3213
+ * icon: { "en-us": "assets/vip-icon.png" },
3214
+ * iconFileHashes: { "en-us": hash },
3215
+ * key: asResourceKey("vip-pass"),
3216
+ * kind: "gamePass",
3217
+ * name: "VIP Pass",
3218
+ * price: 500,
3219
+ * };
3220
+ * const drifted: GamePassDesiredState = {
3221
+ * ...unchanged,
3222
+ * key: asResourceKey("legend-pass"),
3223
+ * name: "Legend Pass (renamed)",
3224
+ * };
3225
+ * const fresh: GamePassDesiredState = {
3226
+ * ...unchanged,
3227
+ * key: asResourceKey("rookie-pass"),
3228
+ * name: "Rookie Pass",
2959
3229
  * };
2960
3230
  *
2961
- * const ops: ReadonlyArray<Operation> = [
3231
+ * const current: ReadonlyArray<ResourceCurrentState> = [
2962
3232
  * {
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,
3233
+ * ...unchanged,
3234
+ * outputs: {
3235
+ * assetId: asRobloxAssetId("111"),
3236
+ * iconAssetIds: { "en-us": asRobloxAssetId("222") },
3237
+ * },
3238
+ * },
3239
+ * {
3240
+ * ...drifted,
3241
+ * name: "Legend Pass",
3242
+ * outputs: {
3243
+ * assetId: asRobloxAssetId("333"),
3244
+ * iconAssetIds: { "en-us": asRobloxAssetId("444") },
2977
3245
  * },
2978
3246
  * },
2979
3247
  * ];
2980
3248
  *
2981
- * return applyOps(ops, registry).then((result) => {
2982
- * expect(result.success).toBe(true);
2983
- * expect(result.success && result.data).toHaveLength(1);
2984
- * });
3249
+ * const ops = diff([unchanged, drifted, fresh], current);
3250
+ *
3251
+ * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
2985
3252
  * ```
2986
3253
  */
2987
- declare function applyOps(ops: ReadonlyArray<Operation>, registry: DriverRegistry): Promise<Result$1<ReadonlyArray<ResourceCurrentState>, ApplyError>>;
3254
+ declare function diff(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): ReadonlyArray<Operation>;
2988
3255
  //#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
- }
3256
+ //#region src/core/display-name-prefix.d.ts
3004
3257
  /**
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.
3258
+ * Default template applied when a project enables display-name prefixing
3259
+ * without supplying its own `displayNamePrefix.format`. Yields outputs
3260
+ * like `[STAGING] ` for an environment whose `label` is `"staging"`.
3008
3261
  */
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
- }
3262
+ declare const DEFAULT_PREFIX_FORMAT = "[{LABEL}] ";
3026
3263
  /**
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.
3264
+ * Render the prefix that selectEnvironment prepends to declared display
3265
+ * names when a project enables `displayNamePrefix`. The template
3266
+ * recognizes three placeholders:
3267
+ *
3268
+ * - `{label}`: label as written.
3269
+ * - `{LABEL}`: upper-cased label.
3270
+ * - `{Label}`: capitalized label (first character upper, rest as written).
3271
+ *
3272
+ * Other characters in the template flow through verbatim.
3273
+ *
3274
+ * @param label - Environment label declared on `EnvironmentEntry.label`.
3275
+ * @param format - Template string. Falls back to
3276
+ * {@link DEFAULT_PREFIX_FORMAT} when omitted.
3277
+ * @returns The rendered prefix string.
3031
3278
  *
3032
3279
  * @example
3033
3280
  *
3034
3281
  * ```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
- * });
3282
+ * import { renderDisplayNamePrefix } from "@bedrock-rbx/core";
3043
3283
  *
3044
- * expect(port.success).toBeTrue();
3284
+ * expect(renderDisplayNamePrefix("staging")).toBe("[STAGING] ");
3285
+ * expect(renderDisplayNamePrefix("staging", "{Label}: ")).toBe("Staging: ");
3286
+ * expect(renderDisplayNamePrefix("dev", "{LABEL}-{label}")).toBe("DEV-dev");
3045
3287
  * ```
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
3288
  */
3051
- declare function buildStatePort(deps: BuildStatePortDeps): Result$1<StatePort, MissingCredentialError | UnsupportedBackendError>;
3289
+ declare function renderDisplayNamePrefix(label: string, format?: string): string;
3052
3290
  //#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
- }
3291
+ //#region src/core/environment.d.ts
3077
3292
  /**
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.
3293
+ * Validate an environment name at a state-adapter boundary.
3294
+ *
3295
+ * Adapters that map environment names onto filesystem-like identifiers
3296
+ * (gist filenames, S3 keys) must reject names that could collide or escape
3297
+ * their storage layout. This helper accepts letters, digits, `-`, and `_`
3298
+ * only, with length between 1 and 64, and returns a `StateError` for
3299
+ * anything outside that set so the adapter can fail loudly instead of
3300
+ * silently stripping characters.
3082
3301
  *
3083
3302
  * @example
3084
3303
  *
3085
3304
  * ```ts
3086
- * import { buildDefaultRegistry } from "@bedrock-rbx/core";
3305
+ * import { validateEnvironmentName } from "@bedrock-rbx/core";
3087
3306
  *
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
- * });
3307
+ * const ok = validateEnvironmentName("production");
3308
+ * expect(ok.success).toBeTrue();
3097
3309
  *
3098
- * expect(registry.success).toBeTrue();
3310
+ * const bad = validateEnvironmentName("prod/staging");
3311
+ * expect(bad.success).toBeFalse();
3099
3312
  * ```
3100
3313
  *
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.
3314
+ * @param environment - Raw environment name supplied by a caller.
3315
+ * @returns `Ok(environment)` when the name is safe to use, or
3316
+ * `Err(StateError)` with a descriptive reason when it is not.
3104
3317
  */
3105
- declare function buildDefaultRegistry(deps: BuildDefaultRegistryDeps): Result$1<DriverRegistry, MissingCredentialError | RegistryConfigError>;
3318
+ declare function validateEnvironmentName(environment: string): Result$1<string, StateError>;
3106
3319
  //#endregion
3107
- //#region src/shell/build-desired.d.ts
3320
+ //#region src/core/get-environment.d.ts
3108
3321
  /**
3109
- * Layer file I/O onto a flat tagged list of resource inputs to produce
3110
- * `ResourceDesiredState`.
3322
+ * Failure modes returned by {@link getEnvironment}.
3323
+ */
3324
+ type GetEnvironmentError = {
3325
+ readonly kind: "missingEnvironment";
3326
+ } | {
3327
+ readonly kind: "multipleEnvironments";
3328
+ readonly values: ReadonlyArray<string>;
3329
+ };
3330
+ /**
3331
+ * Resolve the deploy environment for an override script invocation.
3111
3332
  *
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.
3333
+ * Reads `--env <name>` from the supplied argv first, falls back to
3334
+ * `BEDROCK_ENVIRONMENT` from the supplied env reader. Returns
3335
+ * `missingEnvironment` when neither is present and `multipleEnvironments`
3336
+ * (with every offending value) when argv contains more than one `--env`
3337
+ * flag. Both inputs default to the running process so override scripts
3338
+ * under `.bedrock/` can call `getEnvironment()` with no arguments.
3116
3339
  *
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.
3340
+ * @param argv - Argument list to scan for `--env <name>` flags. Defaults to
3341
+ * `process.argv.slice(2)` when omitted.
3342
+ * @param readEnvironment - Reads an environment variable; consulted as a
3343
+ * fallback when no `--env` flag is present. Defaults to a `process.env`
3344
+ * reader when omitted.
3345
+ * @returns `Ok(environment)` on success, `Err(GetEnvironmentError)` otherwise.
3122
3346
  * @example
3123
3347
  *
3124
3348
  * ```ts
3125
- * import { asResourceKey, buildDesired } from "@bedrock-rbx/core";
3349
+ * import { getEnvironment } from "@bedrock-rbx/core";
3126
3350
  *
3127
- * async function readFile(): Promise<Uint8Array> {
3128
- * return new Uint8Array([1, 2, 3]);
3129
- * }
3351
+ * const result = getEnvironment(["--env", "production"], () => undefined);
3130
3352
  *
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
- * });
3353
+ * expect(result.success).toBeTrue();
3354
+ * if (result.success) {
3355
+ * expect(result.data).toBe("production");
3356
+ * }
3150
3357
  * ```
3151
3358
  */
3152
- declare function buildDesired(inputs: ReadonlyArray<ResourceDesiredInput>, readFile: (path: string) => Promise<Uint8Array>): Promise<Result$1<ReadonlyArray<ResourceDesiredState>, BuildDesiredError>>;
3359
+ declare function getEnvironment(argv?: ReadonlyArray<string>, readEnvironment?: (name: string) => string | undefined): Result$1<string, GetEnvironmentError>;
3153
3360
  //#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
- }
3361
+ //#region src/core/icons.d.ts
3174
3362
  /**
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.
3363
+ * Cost-gate for icon re-uploads. Returns `true` when the locally-hashed
3364
+ * desired icon differs from the hash recorded on the prior current-state
3365
+ * entry, signalling that the driver must re-upload before reconciling.
3366
+ * Returns `false` when the hashes match (no re-upload needed) and when
3367
+ * both sides report no icon.
3181
3368
  *
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.
3369
+ * The signature takes hash maps directly (not whole-state) so the helper
3370
+ * is independent of any specific resource-kind shape; every icon-bearing
3371
+ * driver projects its own `iconFileHashes` and `outputs.iconFileHashes`
3372
+ * fields before calling.
3185
3373
  *
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.
3374
+ * @param currentHashes - Hashes recorded on the prior current-state entry.
3375
+ * @param desiredHashes - Hashes layered onto the desired-state entry by
3376
+ * `normalize` from the local icon file's bytes.
3377
+ * @returns `true` when the driver should re-upload the icon.
3194
3378
  *
3195
- * @param options - Loader options.
3196
- * @returns `Ok` with the validated `Config`, or `Err` with a `ConfigError`.
3197
3379
  * @example
3198
3380
  *
3199
3381
  * ```ts
3200
- * import { loadConfig } from "@bedrock-rbx/core";
3382
+ * import { asSha256Hex, shouldReuploadIcon } from "@bedrock-rbx/core";
3201
3383
  *
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
- * });
3384
+ * const previous = asSha256Hex(
3385
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3386
+ * );
3387
+ * const fresh = asSha256Hex(
3388
+ * "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
3389
+ * );
3390
+ *
3391
+ * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": previous })).toBe(false);
3392
+ * expect(shouldReuploadIcon({ "en-us": previous }, { "en-us": fresh })).toBe(true);
3211
3393
  * ```
3212
3394
  */
3213
- declare function loadConfig(options?: LoadConfigOptions): Promise<Result$1<Config, ConfigError>>;
3395
+ declare function shouldReuploadIcon(currentHashes: Record<"en-us", Sha256Hex> | undefined, desiredHashes: Record<"en-us", Sha256Hex> | undefined): boolean;
3214
3396
  //#endregion
3215
- //#region src/shell/deploy.d.ts
3397
+ //#region src/core/kinds/index.d.ts
3216
3398
  /**
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`.
3399
+ * Default {@link KindRegistry} composing every resource kind bedrock ships
3400
+ * out of the box. Iteration order (`gamePass`, `place`, `universe`,
3401
+ * `developerProduct`) matches the order `flattenConfig` emits entries
3402
+ * today, preserving the observable order of generated operations.
3403
+ *
3404
+ * @example
3405
+ *
3406
+ * ```ts
3407
+ * import { defaultKindRegistry } from "@bedrock-rbx/core";
3408
+ *
3409
+ * expect(defaultKindRegistry.gamePass.kind).toBe("gamePass");
3410
+ * expect(defaultKindRegistry.place.kind).toBe("place");
3411
+ * expect(defaultKindRegistry.universe.kind).toBe("universe");
3412
+ * expect(defaultKindRegistry.developerProduct.kind).toBe("developerProduct");
3413
+ * ```
3220
3414
  */
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
- }
3415
+ declare const defaultKindRegistry: KindRegistry;
3416
+ //#endregion
3417
+ //#region src/core/state-file.d.ts
3239
3418
  /**
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`).
3419
+ * Serialize a {@link BedrockState} to the on-disk JSON representation used by
3420
+ * state-port adapters.
3421
+ *
3422
+ * The on-disk shape wraps the in-memory state with a
3423
+ * `$bedrock: { version: N }` envelope so that a future breaking change to the
3424
+ * schema can be detected and rejected at parse time rather than silently
3425
+ * accepted. The top-level `version` field is not duplicated on disk.
3426
+ *
3427
+ * @example
3428
+ *
3429
+ * ```ts
3430
+ * import { serializeStateFile, type BedrockState } from "@bedrock-rbx/core";
3431
+ *
3432
+ * const state: BedrockState = {
3433
+ * environment: "production",
3434
+ * resources: [],
3435
+ * version: 1,
3436
+ * };
3437
+ *
3438
+ * const wire = serializeStateFile(state);
3439
+ * expect(JSON.parse(wire)).toStrictEqual({
3440
+ * $bedrock: { version: 1 },
3441
+ * environment: "production",
3442
+ * resources: [],
3443
+ * });
3444
+ * ```
3445
+ *
3446
+ * @param state - The in-memory state snapshot to serialize.
3447
+ * @returns A pretty-printed JSON string ready to hand to a state adapter's write method.
3246
3448
  */
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
- };
3449
+ declare function serializeStateFile(state: BedrockState): string;
3264
3450
  /**
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.
3451
+ * Parse a raw on-disk state file into a {@link BedrockState}.
3452
+ *
3453
+ * A backend that reports "no state file for this environment yet" must pass
3454
+ * `undefined`: that distinguishes a legitimate first deploy from a file that
3455
+ * exists but cannot be trusted.
3269
3456
  *
3270
- * @param options - Target environment plus optional overrides.
3271
- * @returns The persisted `BedrockState` on success, or a stage-tagged
3272
- * `DeployError` on failure.
3273
3457
  * @example
3274
3458
  *
3275
3459
  * ```ts
3276
- * import { deploy } from "@bedrock-rbx/core";
3460
+ * import { parseStateFile } from "@bedrock-rbx/core";
3277
3461
  *
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
- * });
3462
+ * const freshStart = parseStateFile(undefined, "gist:abc123/state.production.json");
3463
+ * expect(freshStart.success).toBeTrue();
3464
+ * if (freshStart.success) {
3465
+ * expect(freshStart.data).toBeUndefined();
3466
+ * }
3284
3467
  * ```
3285
3468
  *
3469
+ * @param raw - Raw file contents as a string, or `undefined` when the
3470
+ * backend reports no file exists yet.
3471
+ * @param file - Adapter-specific identifier included in any `StateError`
3472
+ * surfaced during parsing.
3473
+ * @returns `Ok(undefined)` for a missing file, `Ok(state)` for a parseable
3474
+ * file, or `Err(StateError)` for anything that cannot be trusted.
3475
+ */
3476
+ declare function parseStateFile(raw: string | undefined, file: string): Result$1<BedrockState | undefined, StateError>;
3477
+ //#endregion
3478
+ //#region src/core/validate-plan.d.ts
3479
+ /**
3480
+ * Plan-time invariant check that runs after `buildDesired` and before
3481
+ * `diff`. Walks paired `(kind, key)` entries and dispatches to each
3482
+ * kind module's optional `assertReconcilable` hook so kind-specific
3483
+ * rejections (e.g. Removing a developer-product icon, which the upstream
3484
+ * API has no documented unset path for) surface as typed errors before
3485
+ * `diff` runs and before any apply-side driver I/O is attempted.
3486
+ *
3487
+ * Pure and synchronous. Current-only entries (no matching desired) are
3488
+ * ignored: their reconciliation is `diff`'s concern, not this seam's.
3489
+ *
3490
+ * @param desired - Desired state from `buildDesired`.
3491
+ * @param current - Prior current state from the state port.
3492
+ * @returns `Ok(undefined)` when every paired entry passes its kind-level
3493
+ * reconcilability check, or the first `Err` returned by a hook.
3494
+ *
3286
3495
  * @example
3287
3496
  *
3288
3497
  * ```ts
3289
- * import { deploy, type BedrockState, type DriverRegistry, type StatePort } from "@bedrock-rbx/core";
3498
+ * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3290
3499
  *
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
- * };
3500
+ * const result = validatePlan(
3501
+ * [
3502
+ * {
3503
+ * description: "Stocks the player up with 1,000 premium gems.",
3504
+ * isRegionalPricingEnabled: undefined,
3505
+ * key: asResourceKey("gem-pack"),
3506
+ * kind: "developerProduct",
3507
+ * name: "Gem Pack",
3508
+ * price: undefined,
3509
+ * storePageEnabled: undefined,
3510
+ * },
3511
+ * ],
3512
+ * [
3513
+ * {
3514
+ * description: "Stocks the player up with 1,000 premium gems.",
3515
+ * icon: { "en-us": "assets/gem-pack.png" },
3516
+ * iconFileHashes: {
3517
+ * "en-us": asSha256Hex(
3518
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3519
+ * ),
3520
+ * },
3521
+ * isRegionalPricingEnabled: undefined,
3522
+ * key: asResourceKey("gem-pack"),
3523
+ * kind: "developerProduct",
3524
+ * name: "Gem Pack",
3525
+ * outputs: { productId: asRobloxAssetId("9876543210") },
3526
+ * price: undefined,
3527
+ * storePageEnabled: undefined,
3528
+ * },
3529
+ * ],
3530
+ * );
3309
3531
  *
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
- * });
3532
+ * expect(result.success).toBeFalse();
3533
+ * if (!result.success) {
3534
+ * expect(result.err.kind).toBe("iconRemovalRejected");
3535
+ * }
3326
3536
  * ```
3327
3537
  */
3328
- declare function deploy(options: DeployOptions): Promise<Result$1<BedrockState, DeployError>>;
3538
+ declare function validatePlan(desired: ReadonlyArray<ResourceDesiredState>, current: ReadonlyArray<ResourceCurrentState>): Result$1<undefined, BuildDesiredError>;
3329
3539
  //#endregion
3330
3540
  //#region src/shell/migrate-mantle-state.d.ts
3331
3541
  type ConfigFormat = "typescript" | "yaml";
@@ -3422,5 +3632,5 @@ interface MigrateMantleStateDeps {
3422
3632
  */
3423
3633
  declare function migrateMantleState(deps: MigrateMantleStateDeps): Promise<Result$1<MigrationReport, MigrateError>>;
3424
3634
  //#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 };
3635
+ 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 RedactedDeveloperProductOverride, type RedactedGamePassOverride, type RedactedPlaceOverride, 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
3636
  //# sourceMappingURL=index.d.mts.map