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

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.
@@ -3,6 +3,7 @@ import { ArkErrors, type } from "arktype";
3
3
  import { cancel, intro, log, outro } from "@clack/prompts";
4
4
  import process from "node:process";
5
5
  import { defu } from "defu";
6
+ import { createHash } from "node:crypto";
6
7
  import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
7
8
  import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
8
9
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
@@ -16,16 +17,22 @@ import { tmpdir } from "node:os";
16
17
  import { parseYAML, stringifyYAML } from "confbox";
17
18
  //#region src/cli/render.ts
18
19
  /**
19
- * Render a `DeployError` to the supplied `ClackPort` as a single error line.
20
- * Each variant produces a distinct, terse diagnostic; wrapped variants
21
- * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`, `stateReadFailed`,
22
- * `stateWriteFailed`) surface the inner cause's actionable detail (file path,
23
- * resource key, parser message, HTTP failure, validator issue) so the reader
24
- * does not have to inspect the full cause to act.
20
+ * Render a `DeployError` to the supplied `ClackPort`. Most variants emit a
21
+ * single error line; `applyFailed` emits one line per failing op in the
22
+ * aggregate (in Phase 1 then Phase 2 input order). Wrapped variants
23
+ * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`,
24
+ * `stateReadFailed`, `stateWriteFailed`) surface the inner cause's
25
+ * actionable detail (file path, resource key, parser message, HTTP failure,
26
+ * validator issue) so the reader does not have to inspect the full cause to
27
+ * act.
25
28
  * @param err - The deploy error to describe.
26
29
  * @param port - The output port the diagnostic is written to.
27
30
  */
28
31
  function renderDeployError(err, port) {
32
+ if (err.kind === "applyFailed") {
33
+ for (const failure of err.cause.failures) port.logError(`apply failed for '${failure.key}': ${applyCauseDetail(failure)}`);
34
+ return;
35
+ }
29
36
  port.logError(deployErrorMessage(err));
30
37
  }
31
38
  /**
@@ -110,18 +117,31 @@ function permissionDetail(err) {
110
117
  const scopeList = err.requiredScopes.map((scope) => `'${scope}'`).join(", ");
111
118
  return `${err.message} on ${err.operationKey}: missing required ${label} ${scopeList}. Grant ${pronoun} on the API key at https://create.roblox.com/credentials`;
112
119
  }
120
+ function safeStringify(value) {
121
+ if (value instanceof Error) return value.message;
122
+ try {
123
+ return String(value);
124
+ } catch {
125
+ return "<unprintable cause>";
126
+ }
127
+ }
113
128
  function applyCauseDetail(cause) {
114
129
  switch (cause.kind) {
115
130
  case "driverFailure":
116
131
  if (cause.cause instanceof PermissionError) return permissionDetail(cause.cause);
117
132
  return cause.cause.message;
133
+ case "unexpectedThrow": return `unexpected error: ${safeStringify(cause.cause)}`;
118
134
  case "updateUnsupported": return "update not supported";
119
135
  }
120
136
  }
121
137
  function buildDesiredDetail(cause) {
122
138
  switch (cause.kind) {
123
- case "fileReadFailed": return `(${cause.filePath}): ${cause.reason}`;
124
- case "iconRemovalRejected": return `: ${cause.message}`;
139
+ case "fileReadFailed": return `for '${cause.key}' (${cause.filePath}): ${cause.reason}`;
140
+ case "iconRemovalRejected": return `for '${cause.key}': ${cause.message}`;
141
+ case "redactedNameCollision": {
142
+ const [first, second] = cause.keys;
143
+ return `for '${first}' and '${second}': ${cause.message}`;
144
+ }
125
145
  }
126
146
  }
127
147
  function configErrorDetail(err) {
@@ -141,9 +161,9 @@ function stateErrorDetail(cause) {
141
161
  }
142
162
  function deployErrorMessage(err) {
143
163
  switch (err.kind) {
144
- case "applyFailed": return `apply failed for '${err.cause.key}': ${applyCauseDetail(err.cause)}`;
145
- case "buildDesiredFailed": return `build desired state failed for '${err.cause.key}' ${buildDesiredDetail(err.cause)}`;
164
+ case "buildDesiredFailed": return `build desired state failed ${buildDesiredDetail(err.cause)}`;
146
165
  case "configLoadFailed": return `config load failed: ${configErrorDetail(err.cause)}`;
166
+ case "incompletePassEntry": return `pass '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
147
167
  case "incompletePlaceEntry": return `place '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
148
168
  case "incompleteUniverseEntry": return `universe is missing '${err.missingField}' under environment '${err.environment}'`;
149
169
  case "missingCredential": return `missing credential: environment variable ${err.variable} is not set`;
@@ -183,77 +203,54 @@ function buildStatePortErrorMessage(err) {
183
203
  }
184
204
  }
185
205
  //#endregion
186
- //#region src/adapters/clack-progress-adapter.ts
206
+ //#region src/core/resolve-state-config.ts
187
207
  /**
188
- * Build a {@link ProgressPort} that renders events through a `ClackPort`.
189
- * Pattern-matches on the event `kind`: `deploySuccess` becomes a single
190
- * success line and `deployFailure` delegates to the package's deploy-error
191
- * rendering helper.
208
+ * Pick the `StateConfig` that applies to `environment`. Per-environment
209
+ * overrides win over the root block; if neither is present, returns
210
+ * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
211
+ * error instead of silently falling back.
192
212
  *
213
+ * @param config - Validated project config.
214
+ * @param environment - Target environment name.
215
+ * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
216
+ * neither the environment override nor the root block is set.
193
217
  * @example
194
218
  *
195
219
  * ```ts
196
- * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
197
- *
198
- * const lines: Array<string> = [];
199
- * const clack: ClackPort = {
200
- * cancel: (message) => lines.push(`cancel: ${message}`),
201
- * intro: (message) => lines.push(`intro: ${message}`),
202
- * logError: (message) => lines.push(`error: ${message}`),
203
- * logMessage: (message) => lines.push(`log: ${message}`),
204
- * logSuccess: (message) => lines.push(`ok: ${message}`),
205
- * outro: (message) => lines.push(`outro: ${message}`),
206
- * };
207
- *
208
- * const port = createClackProgressAdapter({ clack });
209
- *
210
- * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
211
- *
212
- * expect(lines).toEqual(["ok: production: 3 resources reconciled"]);
213
- * ```
214
- *
215
- * @param deps - The clack port the adapter renders through.
216
- * @returns A `ProgressPort` that renders via clack.
217
- */
218
- function createClackProgressAdapter(deps) {
219
- const { clack } = deps;
220
- return { emit(event) {
221
- switch (event.kind) {
222
- case "deployFailure":
223
- renderDeployError(event.error, clack);
224
- return;
225
- case "deploySuccess": clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
226
- }
227
- } };
228
- }
229
- //#endregion
230
- //#region src/core/derive-price-fields.ts
231
- /**
232
- * Translate a Mantle-style optional price into the Open Cloud wire shape.
233
- *
234
- * `desired.price === undefined` (no price declared) becomes
235
- * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
236
- * price (including `0`) becomes `{ isForSale: true, price }`. Both
237
- * `developerProduct` create and update paths share this helper so the
238
- * "absent ⇒ off-sale" semantics live in exactly one place.
239
- *
240
- * @param desired - Object carrying the user-declared `price`.
241
- * @returns The wire-shape `{ isForSale, price? }` fragment.
242
- *
243
- * @example
220
+ * import { resolveStateConfig } from "@bedrock-rbx/core";
244
221
  *
245
- * ```ts
246
- * import { derivePriceFields } from "@bedrock-rbx/core";
222
+ * const result = resolveStateConfig(
223
+ * {
224
+ * state: { backend: "gist", gistId: "root-gist" },
225
+ * environments: {
226
+ * production: { state: { backend: "gist", gistId: "prod-gist" } },
227
+ * },
228
+ * },
229
+ * "production",
230
+ * );
247
231
  *
248
- * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
249
- * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
232
+ * expect(result.success).toBeTrue();
233
+ * if (result.success) {
234
+ * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
235
+ * }
250
236
  * ```
251
237
  */
252
- function derivePriceFields(desired) {
253
- if (desired.price === void 0) return { isForSale: false };
238
+ function resolveStateConfig(config, environment) {
239
+ const override = config.environments[environment]?.state;
240
+ if (override !== void 0) return {
241
+ data: override,
242
+ success: true
243
+ };
244
+ if (config.state !== void 0) return {
245
+ data: config.state,
246
+ success: true
247
+ };
254
248
  return {
255
- isForSale: true,
256
- price: desired.price
249
+ err: {
250
+ environment,
251
+ kind: "stateNotConfigured"
252
+ },
253
+ success: false
257
254
  };
258
255
  }
259
256
  //#endregion
@@ -441,6 +438,62 @@ function asSha256Hex(raw) {
441
438
  return raw;
442
439
  }
443
440
  //#endregion
441
+ //#region src/core/environment.ts
442
+ /**
443
+ * Source pattern for environment names, including `^` and `$` anchors.
444
+ * Letters, digits, `-`, `_`, length 1-64.
445
+ *
446
+ * Exported so the config schema can validate `environments` keys against
447
+ * the same alphabet and length cap that adapters enforce on storage
448
+ * identifiers. Single source of truth: changing the alphabet here changes
449
+ * both the runtime check and the schema-level key constraint.
450
+ *
451
+ * Anchors are embedded so callers do not have to re-add them, matching
452
+ * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
453
+ */
454
+ const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
455
+ const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
456
+ /**
457
+ * Validate an environment name at a state-adapter boundary.
458
+ *
459
+ * Adapters that map environment names onto filesystem-like identifiers
460
+ * (gist filenames, S3 keys) must reject names that could collide or escape
461
+ * their storage layout. This helper accepts letters, digits, `-`, and `_`
462
+ * only, with length between 1 and 64, and returns a `StateError` for
463
+ * anything outside that set so the adapter can fail loudly instead of
464
+ * silently stripping characters.
465
+ *
466
+ * @example
467
+ *
468
+ * ```ts
469
+ * import { validateEnvironmentName } from "@bedrock-rbx/core";
470
+ *
471
+ * const ok = validateEnvironmentName("production");
472
+ * expect(ok.success).toBeTrue();
473
+ *
474
+ * const bad = validateEnvironmentName("prod/staging");
475
+ * expect(bad.success).toBeFalse();
476
+ * ```
477
+ *
478
+ * @param environment - Raw environment name supplied by a caller.
479
+ * @returns `Ok(environment)` when the name is safe to use, or
480
+ * `Err(StateError)` with a descriptive reason when it is not.
481
+ */
482
+ function validateEnvironmentName(environment) {
483
+ if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
484
+ err: {
485
+ file: environment,
486
+ kind: "stateError",
487
+ reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
488
+ },
489
+ success: false
490
+ };
491
+ return {
492
+ data: environment,
493
+ success: true
494
+ };
495
+ }
496
+ //#endregion
444
497
  //#region src/core/kinds/hash.ts
445
498
  /**
446
499
  * Compute the SHA-256 hex digest of a byte sequence. Shared by kind modules
@@ -454,17 +507,255 @@ async function sha256Hex(bytes) {
454
507
  return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
455
508
  }
456
509
  //#endregion
510
+ //#region src/core/redacted-icon.ts
511
+ const REDACTED_ICON_BYTES = new Uint8Array([
512
+ 137,
513
+ 80,
514
+ 78,
515
+ 71,
516
+ 13,
517
+ 10,
518
+ 26,
519
+ 10,
520
+ 0,
521
+ 0,
522
+ 0,
523
+ 13,
524
+ 73,
525
+ 72,
526
+ 68,
527
+ 82,
528
+ 0,
529
+ 0,
530
+ 0,
531
+ 64,
532
+ 0,
533
+ 0,
534
+ 0,
535
+ 64,
536
+ 8,
537
+ 0,
538
+ 0,
539
+ 0,
540
+ 0,
541
+ 143,
542
+ 2,
543
+ 46,
544
+ 2,
545
+ 0,
546
+ 0,
547
+ 0,
548
+ 137,
549
+ 73,
550
+ 68,
551
+ 65,
552
+ 84,
553
+ 120,
554
+ 156,
555
+ 237,
556
+ 86,
557
+ 209,
558
+ 14,
559
+ 128,
560
+ 32,
561
+ 8,
562
+ 244,
563
+ 83,
564
+ 252,
565
+ 148,
566
+ 254,
567
+ 255,
568
+ 167,
569
+ 174,
570
+ 37,
571
+ 98,
572
+ 130,
573
+ 189,
574
+ 20,
575
+ 110,
576
+ 57,
577
+ 119,
578
+ 108,
579
+ 26,
580
+ 194,
581
+ 188,
582
+ 64,
583
+ 15,
584
+ 42,
585
+ 229,
586
+ 160,
587
+ 164,
588
+ 13,
589
+ 0,
590
+ 142,
591
+ 160,
592
+ 44,
593
+ 144,
594
+ 2,
595
+ 1,
596
+ 200,
597
+ 3,
598
+ 242,
599
+ 96,
600
+ 82,
601
+ 45,
602
+ 176,
603
+ 31,
604
+ 176,
605
+ 161,
606
+ 244,
607
+ 60,
608
+ 0,
609
+ 0,
610
+ 153,
611
+ 81,
612
+ 245,
613
+ 235,
614
+ 161,
615
+ 142,
616
+ 219,
617
+ 222,
618
+ 188,
619
+ 254,
620
+ 187,
621
+ 128,
622
+ 50,
623
+ 224,
624
+ 116,
625
+ 181,
626
+ 168,
627
+ 205,
628
+ 106,
629
+ 134,
630
+ 202,
631
+ 113,
632
+ 0,
633
+ 0,
634
+ 109,
635
+ 150,
636
+ 173,
637
+ 101,
638
+ 97,
639
+ 0,
640
+ 58,
641
+ 239,
642
+ 67,
643
+ 4,
644
+ 254,
645
+ 29,
646
+ 239,
647
+ 83,
648
+ 168,
649
+ 232,
650
+ 177,
651
+ 51,
652
+ 144,
653
+ 176,
654
+ 251,
655
+ 80,
656
+ 101,
657
+ 209,
658
+ 82,
659
+ 80,
660
+ 239,
661
+ 0,
662
+ 240,
663
+ 85,
664
+ 216,
665
+ 15,
666
+ 216,
667
+ 15,
668
+ 200,
669
+ 131,
670
+ 89,
671
+ 197,
672
+ 180,
673
+ 1,
674
+ 0,
675
+ 255,
676
+ 15,
677
+ 86,
678
+ 184,
679
+ 5,
680
+ 2,
681
+ 228,
682
+ 255,
683
+ 207,
684
+ 224,
685
+ 4,
686
+ 233,
687
+ 243,
688
+ 166,
689
+ 219,
690
+ 234,
691
+ 149,
692
+ 21,
693
+ 116,
694
+ 0,
695
+ 0,
696
+ 0,
697
+ 0,
698
+ 73,
699
+ 69,
700
+ 78,
701
+ 68,
702
+ 174,
703
+ 66,
704
+ 96,
705
+ 130
706
+ ]);
707
+ /**
708
+ * Sentinel path written into a resource's `icon["en-us"]` field when
709
+ * redaction substitutes the bedrock-supplied placeholder image. Callers
710
+ * route this path through {@link withRedactedIcon} or through the
711
+ * `readBytes` short-circuit; neither touches the filesystem.
712
+ */
713
+ const REDACTED_ICON_PATH = "<bedrock:redacted-icon.png>";
714
+ /**
715
+ * `true` when `path` is the redacted-icon sentinel. `readBytes` and
716
+ * {@link withRedactedIcon} both use this predicate to decide whether to
717
+ * bypass the injected file reader.
718
+ *
719
+ * @param path - Icon path supplied by a flattened or normalized resource entry.
720
+ * @returns `true` for {@link REDACTED_ICON_PATH}; otherwise `false`.
721
+ */
722
+ function isRedactedIconPath(path) {
723
+ return path === REDACTED_ICON_PATH;
724
+ }
725
+ /**
726
+ * Wrap a `readFile` so the sentinel resolves to {@link REDACTED_ICON_BYTES}
727
+ * without touching the inner reader. Applied once at the shell deploy /
728
+ * preview boundary; the wrapped reader flows to every consumer (normalize,
729
+ * registry drivers) unchanged.
730
+ *
731
+ * @param readFile - Inner reader that handles every non-sentinel path.
732
+ * @returns Sentinel-aware reader with the same callable shape as `readFile`.
733
+ */
734
+ function withRedactedIcon(readFile) {
735
+ return async (path) => {
736
+ if (isRedactedIconPath(path)) return new Uint8Array(REDACTED_ICON_BYTES);
737
+ return readFile(path);
738
+ };
739
+ }
740
+ //#endregion
457
741
  //#region src/core/kinds/read-bytes.ts
458
742
  /**
459
743
  * Read file bytes via the injected reader, translating rejections into a
460
744
  * `fileReadFailed` `BuildDesiredError`. Shared by kind modules whose
461
- * pre-I/O normalization hashes a file the user declared by path.
745
+ * pre-I/O normalization hashes a file the user declared by path. The
746
+ * redacted-icon sentinel short-circuits to the embedded placeholder
747
+ * bytes without invoking the injected reader, so a redaction-substituted
748
+ * icon path produces a deterministic hash on every deploy.
462
749
  *
463
750
  * @param target - Path to read plus the resource key blamed on failure.
464
751
  * @param io - I/O surface carrying the injected `readFile` function.
465
752
  * @returns `Ok` with the bytes, or `Err` with a `fileReadFailed` error.
466
753
  */
467
754
  async function readBytes(target, io) {
755
+ if (isRedactedIconPath(target.filePath)) return {
756
+ data: new Uint8Array(REDACTED_ICON_BYTES),
757
+ success: true
758
+ };
468
759
  try {
469
760
  return {
470
761
  data: await io.readFile(target.filePath),
@@ -599,56 +890,499 @@ function shouldReuploadIcon(currentHashes, desiredHashes) {
599
890
  return !iconHashesEqual(currentHashes, desiredHashes);
600
891
  }
601
892
  //#endregion
602
- //#region src/core/plan-follow-up-patch.ts
893
+ //#region src/core/validate-universe-xor.ts
603
894
  /**
604
- * Plan the optional follow-up PATCH body needed after a developer-product
605
- * create POST. Returns `undefined` when no PATCH is required: either the
606
- * user did not declare `storePageEnabled`, or the create response already
607
- * matches the desired value.
895
+ * Walk the loose authored-shape and surface every place the
896
+ * universeId-XOR-between-root-and-env rule is violated. Pure: returns
897
+ * the issue list; the caller hands it to arktype's `ctx.reject` so each
898
+ * one lands at the offending config path. The schema's runtime narrow
899
+ * uses this to enforce the rule at validation time before the validated
900
+ * value is cast to the strict `Config` discriminated union.
608
901
  *
609
- * @param desired - Desired state for the developer product being created.
610
- * @param createResponse - The `storePageEnabled` value reported by the create POST response.
611
- * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
902
+ * @param value - Parsed config the schema is validating.
903
+ * @returns Zero or more issues. Empty when the config satisfies the rule.
612
904
  */
613
- function planFollowUpPatch(desired, createResponse) {
614
- if (desired.storePageEnabled === void 0) return;
615
- if (desired.storePageEnabled === createResponse.storePageEnabled) return;
616
- return { storePageEnabled: desired.storePageEnabled };
905
+ function collectUniverseIdIssues(value) {
906
+ const rootUniverseId = value.universe?.universeId;
907
+ const hasRootUniverseBlock = value.universe !== void 0;
908
+ const environmentEntries = Object.entries(value.environments);
909
+ const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
910
+ const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
911
+ const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
912
+ message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
913
+ path: ["universe", "universeId"]
914
+ }] : [];
915
+ return [...environmentIssues, ...rootIssues];
916
+ }
917
+ function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
918
+ return environmentEntries.flatMap(([environmentName, environment]) => {
919
+ if (environment.universe === void 0) return [];
920
+ if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
921
+ message: "universeId is declared at the root universe block; remove it from this environment overlay (root is authoritative) or remove it from the root and declare it on every environment.",
922
+ path: [
923
+ "environments",
924
+ environmentName,
925
+ "universe",
926
+ "universeId"
927
+ ]
928
+ }];
929
+ if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
930
+ message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
931
+ path: [
932
+ "environments",
933
+ environmentName,
934
+ "universe",
935
+ "universeId"
936
+ ]
937
+ }];
938
+ return [];
939
+ });
617
940
  }
618
941
  //#endregion
619
- //#region src/adapters/developer-product-driver.ts
942
+ //#region src/core/schema.ts
620
943
  /**
621
- * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
622
- * that maps a desired-state entry to an ocale create or update call and the
623
- * response back to a `ResourceCurrentState<"developerProduct">`. The
624
- * `update` path consumes the upstream `204 No Content` response and
625
- * synthesizes the post-update `ResourceCurrentState` from `desired` plus
626
- * the existing `current.outputs`, carrying `iconImageAssetId` forward when
627
- * present.
628
- *
629
- * Upstream `OpenCloudError` results pass through as `Result` failures.
630
- *
631
- * @param deps - Injected ocale client and owning universe.
632
- * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
944
+ * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
945
+ * autocomplete idiom prevents TypeScript from narrowing on
946
+ * `backend === "gist"` alone, so dispatch sites use this guard to
947
+ * preserve the `gistId` field shape.
633
948
  *
634
949
  * @example
635
950
  *
636
951
  * ```ts
637
- * import type { HttpClient } from "@bedrock-rbx/ocale";
638
- * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
639
- * import {
640
- * asResourceKey,
641
- * asRobloxAssetId,
642
- * createDeveloperProductDriver,
643
- * } from "@bedrock-rbx/core";
952
+ * import { isGistStateConfig } from "@bedrock-rbx/core";
953
+ * import type { StateConfig } from "@bedrock-rbx/core/config";
644
954
  *
645
- * const httpClient: HttpClient = {
646
- * async request() {
647
- * return {
648
- * data: {
649
- * body: {
650
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
651
- * description: "Stocks the player up with 1,000 premium gems.",
955
+ * const config: StateConfig = { backend: "gist", gistId: "abc" };
956
+ *
957
+ * expect(isGistStateConfig(config)).toBeTrue();
958
+ * if (isGistStateConfig(config)) {
959
+ * expect(config.gistId).toBe("abc");
960
+ * }
961
+ * ```
962
+ *
963
+ * @param config - Resolved state config to inspect.
964
+ * @returns `true` when `config.backend === "gist"`; otherwise `false`.
965
+ */
966
+ function isGistStateConfig(config) {
967
+ return config.backend === "gist";
968
+ }
969
+ const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
970
+ const OPTIONAL_STRING = "string | undefined";
971
+ const REDACTED_KEY = "redacted?";
972
+ const NON_EMPTY_OVERRIDE_MESSAGE = "a non-empty override object; use `redacted: true` for default placeholders";
973
+ /**
974
+ * Shared arktype constraint for any optional positive-integer field.
975
+ * Reused by per-kind entry schemas so positive-integer fields validate
976
+ * identically.
977
+ */
978
+ const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
979
+ /**
980
+ * Shared arktype constraint for any optional Robux-price field. The schema
981
+ * rejects negatives, fractional values, `NaN`, and `Infinity` at config
982
+ * validation time so a malformed price surfaces with a path attributing the
983
+ * failure to the offending field, rather than slipping through to the
984
+ * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
985
+ * schemas reuse this constant so all Robux-price fields validate
986
+ * identically.
987
+ */
988
+ const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
989
+ const gamePassRedacted = type({
990
+ "description?": "string",
991
+ "icon?": iconMap,
992
+ "name?": "string",
993
+ "price?": OPTIONAL_ROBUX_PRICE
994
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
995
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
996
+ return true;
997
+ }).or(OPTIONAL_BOOLEAN$2);
998
+ const placeRedacted = type({
999
+ "description?": "string",
1000
+ "displayName?": "string"
1001
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1002
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1003
+ return true;
1004
+ }).or(OPTIONAL_BOOLEAN$2);
1005
+ const productRedacted = type({
1006
+ "description?": "string",
1007
+ "icon?": iconMap,
1008
+ "name?": "string",
1009
+ "price?": OPTIONAL_ROBUX_PRICE
1010
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1011
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1012
+ return true;
1013
+ }).or(OPTIONAL_BOOLEAN$2);
1014
+ const environmentRedacted = type({
1015
+ "description?": "string",
1016
+ "displayName?": "string",
1017
+ "icon?": iconMap,
1018
+ "name?": "string",
1019
+ "price?": OPTIONAL_ROBUX_PRICE
1020
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1021
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1022
+ return true;
1023
+ }).or(OPTIONAL_BOOLEAN$2);
1024
+ const gamePassEntry = type({
1025
+ "name": "string",
1026
+ "description": "string",
1027
+ "icon": iconMap,
1028
+ "price?": OPTIONAL_ROBUX_PRICE,
1029
+ [REDACTED_KEY]: gamePassRedacted
1030
+ });
1031
+ const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
1032
+ const developerProductEntry = type({
1033
+ "name": "string",
1034
+ "description": "string",
1035
+ "icon?": iconMap,
1036
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1037
+ "price?": OPTIONAL_ROBUX_PRICE,
1038
+ [REDACTED_KEY]: productRedacted,
1039
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1040
+ }).onUndeclaredKey("reject");
1041
+ const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
1042
+ const ROBLOX_ID_DIGITS = "string.digits";
1043
+ const placeEntry = type({
1044
+ "description?": OPTIONAL_STRING,
1045
+ "displayName?": OPTIONAL_STRING,
1046
+ "filePath": "string",
1047
+ [REDACTED_KEY]: placeRedacted,
1048
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1049
+ }).onUndeclaredKey("reject");
1050
+ const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
1051
+ const socialLinkOrUndefined$1 = type({
1052
+ title: "string",
1053
+ uri: "string"
1054
+ }).onUndeclaredKey("reject").or("undefined");
1055
+ const universeEntry = type({
1056
+ "consoleEnabled?": OPTIONAL_BOOLEAN$2,
1057
+ "desktopEnabled?": OPTIONAL_BOOLEAN$2,
1058
+ "discordSocialLink?": socialLinkOrUndefined$1,
1059
+ "displayName?": OPTIONAL_STRING,
1060
+ "facebookSocialLink?": socialLinkOrUndefined$1,
1061
+ "guildedSocialLink?": socialLinkOrUndefined$1,
1062
+ "mobileEnabled?": OPTIONAL_BOOLEAN$2,
1063
+ "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
1064
+ "robloxGroupSocialLink?": socialLinkOrUndefined$1,
1065
+ "tabletEnabled?": OPTIONAL_BOOLEAN$2,
1066
+ "twitchSocialLink?": socialLinkOrUndefined$1,
1067
+ "twitterSocialLink?": socialLinkOrUndefined$1,
1068
+ "universeId?": ROBLOX_ID_DIGITS,
1069
+ "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
1070
+ "vrEnabled?": OPTIONAL_BOOLEAN$2,
1071
+ "youtubeSocialLink?": socialLinkOrUndefined$1
1072
+ }).onUndeclaredKey("reject");
1073
+ const stateConfig = type({
1074
+ "backend": "string",
1075
+ "gistId?": "string > 0"
1076
+ }).onUndeclaredKey("reject");
1077
+ const gamePassOverlay = type({
1078
+ "description?": "string",
1079
+ "icon?": iconMap,
1080
+ "name?": "string",
1081
+ "price?": OPTIONAL_ROBUX_PRICE,
1082
+ [REDACTED_KEY]: gamePassRedacted
1083
+ }).onUndeclaredKey("reject");
1084
+ const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
1085
+ const developerProductOverlay = type({
1086
+ "description?": "string",
1087
+ "icon?": iconMap,
1088
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1089
+ "name?": "string",
1090
+ "price?": OPTIONAL_ROBUX_PRICE,
1091
+ [REDACTED_KEY]: productRedacted,
1092
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1093
+ }).onUndeclaredKey("reject");
1094
+ const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
1095
+ const placeOverlay = type({
1096
+ "description?": OPTIONAL_STRING,
1097
+ "displayName?": OPTIONAL_STRING,
1098
+ "filePath?": "string",
1099
+ "placeId": ROBLOX_ID_DIGITS,
1100
+ [REDACTED_KEY]: placeRedacted,
1101
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1102
+ }).onUndeclaredKey("reject");
1103
+ const placesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject");
1104
+ const universeOverlay = universeEntry;
1105
+ const environmentEntry = type({
1106
+ "label?": OPTIONAL_STRING,
1107
+ "passes?": passesOverlayCollection,
1108
+ "places?": placesOverlayCollection,
1109
+ "products?": productsOverlayCollection,
1110
+ [REDACTED_KEY]: environmentRedacted,
1111
+ "state?": stateConfig,
1112
+ "universe?": universeOverlay
1113
+ }).onUndeclaredKey("reject");
1114
+ const rootSchema = type({
1115
+ "displayNamePrefix?": type({
1116
+ "enabled?": OPTIONAL_BOOLEAN$2,
1117
+ "format?": OPTIONAL_STRING
1118
+ }).onUndeclaredKey("reject"),
1119
+ "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
1120
+ if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
1121
+ return true;
1122
+ }),
1123
+ "extends?": "unknown",
1124
+ "passes?": passesCollection,
1125
+ "places?": placesCollection,
1126
+ "products?": productsCollection,
1127
+ "state?": stateConfig,
1128
+ "universe?": universeEntry
1129
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1130
+ return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
1131
+ return ctx.reject({
1132
+ message: issue.message,
1133
+ path: [...issue.path]
1134
+ });
1135
+ }, true);
1136
+ });
1137
+ /**
1138
+ * Validate a parsed config value against the runtime schema. Returns the
1139
+ * validated `Config` on success or a `validationFailed` `ConfigError` with
1140
+ * one issue per problem, each attributed to a field path. `sourceFile`
1141
+ * appears in the error so callers can point a human at the offending file.
1142
+ *
1143
+ * @param input - Parsed value from a config source (object tree from a
1144
+ * config loader, or a hand-built literal). Shape is checked, not assumed.
1145
+ * @param sourceFile - Path or identifier of the source file, used in the
1146
+ * `validationFailed` error.
1147
+ * @returns `Ok` with the validated `Config`, or `Err` with a
1148
+ * `validationFailed` error carrying each issue's field path.
1149
+ * @example
1150
+ *
1151
+ * ```ts
1152
+ * import { validateConfig } from "@bedrock-rbx/core";
1153
+ *
1154
+ * const ok = validateConfig(
1155
+ * {
1156
+ * environments: { production: {} },
1157
+ * passes: {
1158
+ * "vip-pass": {
1159
+ * description: "VIP perks.",
1160
+ * icon: { "en-us": "assets/vip.png" },
1161
+ * name: "VIP Pass",
1162
+ * price: 500,
1163
+ * },
1164
+ * },
1165
+ * },
1166
+ * "bedrock.config.ts",
1167
+ * );
1168
+ * expect(ok.success).toBeTrue();
1169
+ *
1170
+ * const err = validateConfig(
1171
+ * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
1172
+ * "bedrock.config.ts",
1173
+ * );
1174
+ * expect(err.success).toBeFalse();
1175
+ * if (!err.success) {
1176
+ * expect(err.err.kind).toBe("validationFailed");
1177
+ * }
1178
+ * ```
1179
+ */
1180
+ function validateConfig(input, sourceFile) {
1181
+ const validated = rootSchema(input);
1182
+ if (validated instanceof ArkErrors) return {
1183
+ err: {
1184
+ issues: Array.from(validated, (issue) => {
1185
+ return {
1186
+ message: issue.message,
1187
+ path: [...issue.path].map((segment) => String(segment))
1188
+ };
1189
+ }),
1190
+ kind: "validationFailed",
1191
+ sourceFile
1192
+ },
1193
+ success: false
1194
+ };
1195
+ return {
1196
+ data: validated,
1197
+ success: true
1198
+ };
1199
+ }
1200
+ //#endregion
1201
+ //#region src/adapters/clack-progress-adapter.ts
1202
+ /**
1203
+ * Build a {@link ProgressPort} that renders events through a `ClackPort`.
1204
+ * Pattern-matches on the event `kind`: per-resource events render one line each,
1205
+ * the aggregate `applySummary` becomes the deploy footer, and `stateWritten`
1206
+ * names the persistence backend resolved from the loaded `Config`.
1207
+ *
1208
+ * @example
1209
+ *
1210
+ * ```ts
1211
+ * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
1212
+ *
1213
+ * const lines: Array<string> = [];
1214
+ * const clack: ClackPort = {
1215
+ * cancel: (message) => lines.push(`cancel: ${message}`),
1216
+ * intro: (message) => lines.push(`intro: ${message}`),
1217
+ * logError: (message) => lines.push(`error: ${message}`),
1218
+ * logMessage: (message) => lines.push(`log: ${message}`),
1219
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
1220
+ * outro: (message) => lines.push(`outro: ${message}`),
1221
+ * };
1222
+ *
1223
+ * const port = createClackProgressAdapter({ clack });
1224
+ *
1225
+ * port.emit({ environment: "production", kind: "stateWritten" });
1226
+ *
1227
+ * expect(lines).toEqual(["log: State written to state"]);
1228
+ * ```
1229
+ *
1230
+ * @param deps - The clack port and optional config the adapter renders through.
1231
+ * @returns A `ProgressPort` that renders via clack.
1232
+ */
1233
+ function createClackProgressAdapter(deps) {
1234
+ return { emit(event) {
1235
+ renderEvent(event, deps);
1236
+ } };
1237
+ }
1238
+ function applySummaryLine(event) {
1239
+ return `Succeeded in ${(event.durationMs / 1e3).toFixed(1)}s: ${[
1240
+ `${event.created} create`,
1241
+ `${event.updated} update`,
1242
+ `${event.noop} noop`,
1243
+ `${event.failed} failed`
1244
+ ].join(", ")}`;
1245
+ }
1246
+ function stateConfigLabel(state) {
1247
+ if (isGistStateConfig(state)) return `gist:${state.gistId}`;
1248
+ return state.backend;
1249
+ }
1250
+ function formatStateLabel(config, environment) {
1251
+ if (config === void 0) return "state";
1252
+ const resolved = resolveStateConfig(config, environment);
1253
+ if (!resolved.success) return "state";
1254
+ return stateConfigLabel(resolved.data);
1255
+ }
1256
+ function extractResourceId(event) {
1257
+ switch (event.resourceKind) {
1258
+ case "developerProduct": return event.outputs.productId;
1259
+ case "gamePass": return event.outputs.assetId;
1260
+ case "place": return;
1261
+ case "universe": return event.outputs.rootPlaceId;
1262
+ }
1263
+ }
1264
+ function renderResourceOpSucceeded(event, clack) {
1265
+ if (event.opType === "create") {
1266
+ const id = extractResourceId(event);
1267
+ const suffix = id === void 0 ? "" : ` (id ${id})`;
1268
+ clack.logSuccess(`${event.resourceKind}.${event.key} created${suffix}`);
1269
+ return;
1270
+ }
1271
+ clack.logSuccess(`${event.resourceKind}.${event.key} ${event.changedFields.join(", ")} updated`);
1272
+ }
1273
+ function describeApplyError(error) {
1274
+ switch (error.kind) {
1275
+ case "driverFailure": return `failed: ${error.cause.message}`;
1276
+ case "unexpectedThrow": return "unexpected error";
1277
+ case "updateUnsupported": return "update not supported";
1278
+ }
1279
+ }
1280
+ function renderEvent(event, deps) {
1281
+ const { clack, config } = deps;
1282
+ switch (event.kind) {
1283
+ case "applySummary":
1284
+ clack.logMessage(applySummaryLine(event));
1285
+ return;
1286
+ case "deployFailure":
1287
+ renderDeployError(event.error, clack);
1288
+ return;
1289
+ case "deploySuccess":
1290
+ clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
1291
+ return;
1292
+ case "resourceOpFailed":
1293
+ clack.logError(`${event.resourceKind}.${event.key} ${describeApplyError(event.error)}`);
1294
+ return;
1295
+ case "resourceOpNoop":
1296
+ clack.logMessage(`${event.resourceKind}.${event.key} unchanged`);
1297
+ return;
1298
+ case "resourceOpStarted": return;
1299
+ case "resourceOpSucceeded":
1300
+ renderResourceOpSucceeded(event, clack);
1301
+ return;
1302
+ case "stateWritten": clack.logMessage(`State written to ${formatStateLabel(config, event.environment)}`);
1303
+ }
1304
+ }
1305
+ //#endregion
1306
+ //#region src/core/derive-price-fields.ts
1307
+ /**
1308
+ * Translate a Mantle-style optional price into the Open Cloud wire shape.
1309
+ *
1310
+ * `desired.price === undefined` (no price declared) becomes
1311
+ * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
1312
+ * price (including `0`) becomes `{ isForSale: true, price }`. Both
1313
+ * `developerProduct` create and update paths share this helper so the
1314
+ * "absent ⇒ off-sale" semantics live in exactly one place.
1315
+ *
1316
+ * @param desired - Object carrying the user-declared `price`.
1317
+ * @returns The wire-shape `{ isForSale, price? }` fragment.
1318
+ *
1319
+ * @example
1320
+ *
1321
+ * ```ts
1322
+ * import { derivePriceFields } from "@bedrock-rbx/core";
1323
+ *
1324
+ * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
1325
+ * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
1326
+ * ```
1327
+ */
1328
+ function derivePriceFields(desired) {
1329
+ if (desired.price === void 0) return { isForSale: false };
1330
+ return {
1331
+ isForSale: true,
1332
+ price: desired.price
1333
+ };
1334
+ }
1335
+ //#endregion
1336
+ //#region src/core/plan-follow-up-patch.ts
1337
+ /**
1338
+ * Plan the optional follow-up PATCH body needed after a developer-product
1339
+ * create POST. Returns `undefined` when no PATCH is required: either the
1340
+ * user did not declare `storePageEnabled`, or the create response already
1341
+ * matches the desired value.
1342
+ *
1343
+ * @param desired - Desired state for the developer product being created.
1344
+ * @param createResponse - The `storePageEnabled` value reported by the create POST response.
1345
+ * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
1346
+ */
1347
+ function planFollowUpPatch(desired, createResponse) {
1348
+ if (desired.storePageEnabled === void 0) return;
1349
+ if (desired.storePageEnabled === createResponse.storePageEnabled) return;
1350
+ return { storePageEnabled: desired.storePageEnabled };
1351
+ }
1352
+ //#endregion
1353
+ //#region src/adapters/developer-product-driver.ts
1354
+ /**
1355
+ * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
1356
+ * that maps a desired-state entry to an ocale create or update call and the
1357
+ * response back to a `ResourceCurrentState<"developerProduct">`. The
1358
+ * `update` path consumes the upstream `204 No Content` response and
1359
+ * synthesizes the post-update `ResourceCurrentState` from `desired` plus
1360
+ * the existing `current.outputs`, carrying `iconImageAssetId` forward when
1361
+ * present.
1362
+ *
1363
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
1364
+ *
1365
+ * @param deps - Injected ocale client and owning universe.
1366
+ * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
1367
+ *
1368
+ * @example
1369
+ *
1370
+ * ```ts
1371
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
1372
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
1373
+ * import {
1374
+ * asResourceKey,
1375
+ * asRobloxAssetId,
1376
+ * createDeveloperProductDriver,
1377
+ * } from "@bedrock-rbx/core";
1378
+ *
1379
+ * const httpClient: HttpClient = {
1380
+ * async request() {
1381
+ * return {
1382
+ * data: {
1383
+ * body: {
1384
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
1385
+ * description: "Stocks the player up with 1,000 premium gems.",
652
1386
  * iconImageAssetId: null,
653
1387
  * isForSale: false,
654
1388
  * isImmutable: false,
@@ -696,12 +1430,16 @@ function planFollowUpPatch(desired, createResponse) {
696
1430
  * ```
697
1431
  */
698
1432
  function createDeveloperProductDriver(deps) {
1433
+ const effective = {
1434
+ ...deps,
1435
+ readFile: withRedactedIcon(deps.readFile)
1436
+ };
699
1437
  return {
700
1438
  async create(desired) {
701
- return createOne(deps, desired);
1439
+ return createOne(effective, desired);
702
1440
  },
703
1441
  async update(current, desired) {
704
- return updateOne(deps, {
1442
+ return updateOne(effective, {
705
1443
  current,
706
1444
  desired
707
1445
  });
@@ -853,12 +1591,16 @@ async function updateOne(deps, { current, desired }) {
853
1591
  * ```
854
1592
  */
855
1593
  function createGamePassDriver(deps) {
1594
+ const effective = {
1595
+ ...deps,
1596
+ readFile: withRedactedIcon(deps.readFile)
1597
+ };
856
1598
  return {
857
1599
  async create(desired) {
858
- return createGamePass(deps, desired);
1600
+ return createGamePass(effective, desired);
859
1601
  },
860
1602
  async update(current, desired) {
861
- return updateGamePass(deps, {
1603
+ return updateGamePass(effective, {
862
1604
  current,
863
1605
  desired
864
1606
  });
@@ -930,62 +1672,6 @@ async function updateGamePass(deps, states) {
930
1672
  });
931
1673
  }
932
1674
  //#endregion
933
- //#region src/core/environment.ts
934
- /**
935
- * Source pattern for environment names, including `^` and `$` anchors.
936
- * Letters, digits, `-`, `_`, length 1-64.
937
- *
938
- * Exported so the config schema can validate `environments` keys against
939
- * the same alphabet and length cap that adapters enforce on storage
940
- * identifiers. Single source of truth: changing the alphabet here changes
941
- * both the runtime check and the schema-level key constraint.
942
- *
943
- * Anchors are embedded so callers do not have to re-add them, matching
944
- * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
945
- */
946
- const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
947
- const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
948
- /**
949
- * Validate an environment name at a state-adapter boundary.
950
- *
951
- * Adapters that map environment names onto filesystem-like identifiers
952
- * (gist filenames, S3 keys) must reject names that could collide or escape
953
- * their storage layout. This helper accepts letters, digits, `-`, and `_`
954
- * only, with length between 1 and 64, and returns a `StateError` for
955
- * anything outside that set so the adapter can fail loudly instead of
956
- * silently stripping characters.
957
- *
958
- * @example
959
- *
960
- * ```ts
961
- * import { validateEnvironmentName } from "@bedrock-rbx/core";
962
- *
963
- * const ok = validateEnvironmentName("production");
964
- * expect(ok.success).toBeTrue();
965
- *
966
- * const bad = validateEnvironmentName("prod/staging");
967
- * expect(bad.success).toBeFalse();
968
- * ```
969
- *
970
- * @param environment - Raw environment name supplied by a caller.
971
- * @returns `Ok(environment)` when the name is safe to use, or
972
- * `Err(StateError)` with a descriptive reason when it is not.
973
- */
974
- function validateEnvironmentName(environment) {
975
- if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
976
- err: {
977
- file: environment,
978
- kind: "stateError",
979
- reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
980
- },
981
- success: false
982
- };
983
- return {
984
- data: environment,
985
- success: true
986
- };
987
- }
988
- //#endregion
989
1675
  //#region src/core/state-file.ts
990
1676
  const envelopeSchema = type({
991
1677
  $bedrock: { version: "1" },
@@ -1197,12 +1883,26 @@ function toGistFile(entry) {
1197
1883
  size
1198
1884
  };
1199
1885
  }
1200
- function mapHttpError({ file, gistId, status }) {
1886
+ function isRateLimited(headers) {
1887
+ return headers.get("retry-after") !== null || headers.get("x-ratelimit-remaining") === "0";
1888
+ }
1889
+ function rateLimitReason(status, headers) {
1890
+ const retryAfter = headers.get("retry-after");
1891
+ if (retryAfter !== null) return `rate limited (${status}): retry after ${retryAfter}s`;
1892
+ return `rate limited (${status})`;
1893
+ }
1894
+ function mapHttpError({ file, gistId, response }) {
1895
+ const { headers, status } = response;
1201
1896
  if (status === 404) return {
1202
1897
  file,
1203
1898
  kind: "stateError",
1204
1899
  reason: `gist ${gistId} not found: check gistId`
1205
1900
  };
1901
+ if (status === 403 && isRateLimited(headers)) return {
1902
+ file,
1903
+ kind: "stateError",
1904
+ reason: rateLimitReason(status, headers)
1905
+ };
1206
1906
  if (status === 401 || status === 403) return {
1207
1907
  file,
1208
1908
  kind: "stateError",
@@ -1264,7 +1964,7 @@ async function fetchGistBody(ctx, file) {
1264
1964
  err: mapHttpError({
1265
1965
  file,
1266
1966
  gistId: ctx.gistId,
1267
- status: response.status
1967
+ response
1268
1968
  }),
1269
1969
  success: false
1270
1970
  };
@@ -1386,7 +2086,7 @@ async function writePath(ctx, state) {
1386
2086
  err: mapHttpError({
1387
2087
  file,
1388
2088
  gistId: ctx.gistId,
1389
- status: response.status
2089
+ response
1390
2090
  }),
1391
2091
  success: false
1392
2092
  };
@@ -1466,612 +2166,350 @@ const UNIVERSE_SINGLETON_KEY = asResourceKey("main");
1466
2166
  //#endregion
1467
2167
  //#region src/adapters/place-driver.ts
1468
2168
  /**
1469
- * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
1470
- * `update` are both thin wrappers over a shared publish helper because the
1471
- * upstream Open Cloud call is identical either way: there is no "create
1472
- * place" endpoint (the place is user-supplied input), only "publish version".
1473
- *
1474
- * Format is detected from the file extension (`.rbxl` → binary,
1475
- * `.rbxlx` → XML); any other extension returns an `ApiError`-backed failure
1476
- * without hitting the network.
1477
- *
1478
- * @param deps - Injected ocale client, file reader, and owning universe.
1479
- * @returns A driver indexable by `"place"` in a `DriverRegistry`.
1480
- * @throws Whatever `deps.readFile` rejects with.
1481
- *
1482
- * @example
1483
- *
1484
- * ```ts
1485
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1486
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1487
- * import {
1488
- * asResourceKey,
1489
- * asRobloxAssetId,
1490
- * asSha256Hex,
1491
- * createPlaceDriver,
1492
- * } from "@bedrock-rbx/core";
1493
- *
1494
- * const httpClient: HttpClient = {
1495
- * async request() {
1496
- * return {
1497
- * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1498
- * success: true,
1499
- * };
1500
- * },
1501
- * };
1502
- *
1503
- * const driver = createPlaceDriver({
1504
- * client: new PlacesClient({
1505
- * apiKey: "rbx-your-key",
1506
- * httpClient,
1507
- * sleep: async () => {},
1508
- * }),
1509
- * readFile: async () =>
1510
- * new Uint8Array([
1511
- * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
1512
- * 0x0a,
1513
- * ]),
1514
- * universeId: asRobloxAssetId("1234567890"),
1515
- * });
1516
- *
1517
- * return driver
1518
- * .create({
1519
- * description: undefined,
1520
- * displayName: undefined,
1521
- * fileHash: asSha256Hex(
1522
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1523
- * ),
1524
- * filePath: "places/start.rbxl",
1525
- * key: asResourceKey("start-place"),
1526
- * kind: "place",
1527
- * placeId: asRobloxAssetId("4711"),
1528
- * serverSize: undefined,
1529
- * })
1530
- * .then((result) => {
1531
- * expect(result.success).toBeTrue();
1532
- * if (result.success) {
1533
- * expect(result.data.outputs.versionNumber).toBe(1);
1534
- * }
1535
- * });
1536
- * ```
1537
- */
1538
- function createPlaceDriver(deps) {
1539
- return {
1540
- async create(desired) {
1541
- return publishPlace(deps, desired);
1542
- },
1543
- async update(_current, desired) {
1544
- return publishPlace(deps, desired);
1545
- }
1546
- };
1547
- }
1548
- function buildMetadataParameters(universeId, desired) {
1549
- const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
1550
- const value = desired[field];
1551
- return value === void 0 ? accumulator : {
1552
- ...accumulator,
1553
- [field]: value
1554
- };
1555
- }, {});
1556
- if (Object.keys(metadata).length === 0) return;
1557
- return {
1558
- ...metadata,
1559
- placeId: desired.placeId,
1560
- universeId
1561
- };
1562
- }
1563
- function detectFormat(filePath) {
1564
- if (filePath.endsWith(".rbxlx")) return "rbxlx";
1565
- if (filePath.endsWith(".rbxl")) return "rbxl";
1566
- }
1567
- async function publishVersion(deps, desired) {
1568
- const format = detectFormat(desired.filePath);
1569
- if (format === void 0) return {
1570
- err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
1571
- success: false
1572
- };
1573
- const body = await deps.readFile(desired.filePath);
1574
- return deps.client.publish({
1575
- body: Uint8Array.from(body),
1576
- format,
1577
- placeId: desired.placeId,
1578
- universeId: deps.universeId
1579
- });
1580
- }
1581
- async function publishPlace(deps, desired) {
1582
- const publishResult = await publishVersion(deps, desired);
1583
- if (!publishResult.success) return publishResult;
1584
- const metadataParameters = buildMetadataParameters(deps.universeId, desired);
1585
- if (metadataParameters !== void 0) {
1586
- const metadataResult = await deps.client.update(metadataParameters);
1587
- if (!metadataResult.success) return metadataResult;
1588
- }
1589
- return {
1590
- data: {
1591
- ...desired,
1592
- outputs: publishResult.data
1593
- },
1594
- success: true
1595
- };
1596
- }
1597
- //#endregion
1598
- //#region src/adapters/universe-driver.ts
1599
- /**
1600
- * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
1601
- * and `update` both delegate to a shared reconcile helper because Open
1602
- * Cloud cannot mint universes; the user supplies an existing `universeId`
1603
- * and bedrock adopts the universe on first apply.
1604
- *
1605
- * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
1606
- * as an adoption-error `ApiError` whose message names the config key and
1607
- * the `universeId`, so operators can tell adoption failure apart from
1608
- * transient upstream errors. A successful response whose `rootPlaceId` is
1609
- * absent surfaces as an `ApiError` with status 200, mirroring the
1610
- * malformed-response guard in `GamePassDriver`.
2169
+ * Wraps {@link PlacesClient} as a `ResourceDriver<"place">`. `create` and
2170
+ * `update` are both thin wrappers over a shared publish helper because the
2171
+ * upstream Open Cloud call is identical either way: there is no "create
2172
+ * place" endpoint (the place is user-supplied input), only "publish version".
1611
2173
  *
1612
- * When `displayName` is declared, the driver routes that field through
1613
- * `PlacesClient.update` on the root place after the universe PATCH
1614
- * succeeds. A subsequent places failure surfaces to the caller as the
1615
- * driver's error result without rolling back the prior universe patch,
1616
- * so callers observing a partial failure should reconcile by
1617
- * reapplying rather than assuming the universe-level fields are
1618
- * unchanged.
2174
+ * Format is detected from the file extension (`.rbxl` binary,
2175
+ * `.rbxlx` XML); any other extension returns an `ApiError`-backed failure
2176
+ * without hitting the network.
1619
2177
  *
1620
- * @param deps - Injected ocale clients (universes plus places for the
1621
- * read-only universe fields Roblox derives from the root place).
1622
- * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2178
+ * @param deps - Injected ocale client, file reader, and owning universe.
2179
+ * @returns A driver indexable by `"place"` in a `DriverRegistry`.
2180
+ * @throws Whatever `deps.readFile` rejects with.
1623
2181
  *
1624
2182
  * @example
1625
2183
  *
1626
2184
  * ```ts
1627
2185
  * import type { HttpClient } from "@bedrock-rbx/ocale";
1628
2186
  * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1629
- * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
1630
- * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
1631
2187
  * import {
2188
+ * asResourceKey,
1632
2189
  * asRobloxAssetId,
1633
- * createUniverseDriver,
1634
- * UNIVERSE_SINGLETON_KEY,
2190
+ * asSha256Hex,
2191
+ * createPlaceDriver,
1635
2192
  * } from "@bedrock-rbx/core";
1636
2193
  *
1637
- * const universeBodyHttpClient: HttpClient = {
2194
+ * const httpClient: HttpClient = {
1638
2195
  * async request() {
1639
2196
  * return {
1640
- * data: {
1641
- * body: validUniverseBody({
1642
- * path: "universes/1234567890",
1643
- * rootPlace: "universes/1234567890/places/4711",
1644
- * }),
1645
- * headers: {},
1646
- * status: 200,
1647
- * },
2197
+ * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1648
2198
  * success: true,
1649
2199
  * };
1650
2200
  * },
1651
2201
  * };
1652
2202
  *
1653
- * const driver = createUniverseDriver({
1654
- * places: new PlacesClient({
1655
- * apiKey: "rbx-your-key",
1656
- * httpClient: universeBodyHttpClient,
1657
- * sleep: async () => {},
1658
- * }),
1659
- * universes: new UniversesClient({
2203
+ * const driver = createPlaceDriver({
2204
+ * client: new PlacesClient({
1660
2205
  * apiKey: "rbx-your-key",
1661
- * httpClient: universeBodyHttpClient,
2206
+ * httpClient,
1662
2207
  * sleep: async () => {},
1663
2208
  * }),
2209
+ * readFile: async () =>
2210
+ * new Uint8Array([
2211
+ * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
2212
+ * 0x0a,
2213
+ * ]),
2214
+ * universeId: asRobloxAssetId("1234567890"),
1664
2215
  * });
1665
2216
  *
1666
2217
  * return driver
1667
2218
  * .create({
1668
- * consoleEnabled: undefined,
1669
- * desktopEnabled: true,
2219
+ * description: undefined,
1670
2220
  * displayName: undefined,
1671
- * key: UNIVERSE_SINGLETON_KEY,
1672
- * kind: "universe",
1673
- * mobileEnabled: undefined,
1674
- * privateServerPriceRobux: undefined,
1675
- * tabletEnabled: undefined,
1676
- * universeId: asRobloxAssetId("1234567890"),
1677
- * voiceChatEnabled: true,
1678
- * vrEnabled: undefined,
2221
+ * fileHash: asSha256Hex(
2222
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2223
+ * ),
2224
+ * filePath: "places/start.rbxl",
2225
+ * key: asResourceKey("start-place"),
2226
+ * kind: "place",
2227
+ * placeId: asRobloxAssetId("4711"),
2228
+ * serverSize: undefined,
1679
2229
  * })
1680
2230
  * .then((result) => {
1681
2231
  * expect(result.success).toBeTrue();
1682
2232
  * if (result.success) {
1683
- * expect(result.data.outputs.rootPlaceId).toBe("4711");
2233
+ * expect(result.data.outputs.versionNumber).toBe(1);
1684
2234
  * }
1685
2235
  * });
1686
2236
  * ```
1687
2237
  */
1688
- function createUniverseDriver(deps) {
2238
+ function createPlaceDriver(deps) {
1689
2239
  return {
1690
2240
  async create(desired) {
1691
- return reconcileUniverse({
1692
- deps,
1693
- desired
1694
- });
2241
+ return publishPlace(deps, desired);
1695
2242
  },
1696
2243
  async update(_current, desired) {
1697
- return reconcileUniverse({
1698
- deps,
1699
- desired
1700
- });
2244
+ return publishPlace(deps, desired);
1701
2245
  }
1702
2246
  };
1703
2247
  }
1704
- function toCurrentState(desired, rootPlaceId) {
1705
- return {
1706
- ...desired,
1707
- outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
1708
- };
1709
- }
1710
- function buildParameters(desired) {
1711
- const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
1712
- const isEnabled = desired[flag];
1713
- return isEnabled === void 0 ? accumulator : {
2248
+ function buildMetadataParameters(universeId, desired) {
2249
+ const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
2250
+ const value = desired[field];
2251
+ return value === void 0 ? accumulator : {
1714
2252
  ...accumulator,
1715
- [flag]: isEnabled
2253
+ [field]: value
1716
2254
  };
1717
- }, { universeId: desired.universeId });
2255
+ }, {});
2256
+ if (Object.keys(metadata).length === 0) return;
1718
2257
  return {
1719
- ..."privateServerPriceRobux" in desired ? {
1720
- ...base,
1721
- privateServerPriceRobux: desired.privateServerPriceRobux
1722
- } : base,
1723
- ...copyDeclaredSocialLinks(desired)
2258
+ ...metadata,
2259
+ placeId: desired.placeId,
2260
+ universeId
1724
2261
  };
1725
2262
  }
1726
- function wrapUpdateError(err, desired) {
1727
- if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
1728
- return err;
1729
- }
1730
- function hasUniverseLevelUpdate(desired) {
1731
- if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
1732
- if ("privateServerPriceRobux" in desired) return true;
1733
- return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2263
+ function detectFormat(filePath) {
2264
+ if (filePath.endsWith(".rbxlx")) return "rbxlx";
2265
+ if (filePath.endsWith(".rbxl")) return "rbxl";
1734
2266
  }
1735
- async function resolveUniverse(deps, desired) {
1736
- const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
1737
- if (!result.success) return {
1738
- err: wrapUpdateError(result.err, desired),
1739
- success: false
1740
- };
1741
- const { rootPlaceId } = result.data;
1742
- if (rootPlaceId === void 0) return {
1743
- err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
2267
+ async function publishVersion(deps, desired) {
2268
+ const format = detectFormat(desired.filePath);
2269
+ if (format === void 0) return {
2270
+ err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
1744
2271
  success: false
1745
- };
1746
- return {
1747
- data: { rootPlaceId },
1748
- success: true
1749
- };
1750
- }
1751
- async function reconcileUniverse(inputs) {
1752
- const { deps, desired } = inputs;
1753
- const universeResult = await resolveUniverse(deps, desired);
1754
- if (!universeResult.success) return universeResult;
1755
- const { rootPlaceId } = universeResult.data;
1756
- if (desired.displayName !== void 0) {
1757
- const placesResult = await deps.places.update({
1758
- displayName: desired.displayName,
1759
- placeId: rootPlaceId,
1760
- universeId: desired.universeId
1761
- });
1762
- if (!placesResult.success) return {
1763
- err: placesResult.err,
1764
- success: false
1765
- };
1766
- }
1767
- return {
1768
- data: toCurrentState(desired, rootPlaceId),
1769
- success: true
1770
- };
1771
- }
1772
- //#endregion
1773
- //#region src/cli/clack-port.ts
1774
- /**
1775
- * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
1776
- * resulting port writes to `process.stdout` via clack's defaults. Kept in
1777
- * its own module so consumers that never need the clack-backed rendering
1778
- * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
1779
- * into their bundle.
1780
- *
1781
- * @example
1782
- *
1783
- * ```ts
1784
- * import { createClackPort } from "@bedrock-rbx/core";
1785
- *
1786
- * const port = createClackPort();
1787
- *
1788
- * expect(typeof port.logSuccess).toBe("function");
1789
- * ```
1790
- *
1791
- * @returns A port whose six methods each invoke the matching clack helper.
1792
- */
1793
- function createClackPort() {
1794
- return {
1795
- cancel: (message) => {
1796
- cancel(message);
1797
- },
1798
- intro: (message) => {
1799
- intro(message);
1800
- },
1801
- logError: (message) => {
1802
- log.error(message);
1803
- },
1804
- logMessage: (message) => {
1805
- log.message(message);
1806
- },
1807
- logSuccess: (message) => {
1808
- log.success(message);
1809
- },
1810
- outro: (message) => {
1811
- outro(message);
1812
- }
1813
- };
1814
- }
1815
- //#endregion
1816
- //#region src/core/validate-universe-xor.ts
1817
- /**
1818
- * Walk the loose authored-shape and surface every place the
1819
- * universeId-XOR-between-root-and-env rule is violated. Pure: returns
1820
- * the issue list; the caller hands it to arktype's `ctx.reject` so each
1821
- * one lands at the offending config path. The schema's runtime narrow
1822
- * uses this to enforce the rule at validation time before the validated
1823
- * value is cast to the strict `Config` discriminated union.
1824
- *
1825
- * @param value - Parsed config the schema is validating.
1826
- * @returns Zero or more issues. Empty when the config satisfies the rule.
1827
- */
1828
- function collectUniverseIdIssues(value) {
1829
- const rootUniverseId = value.universe?.universeId;
1830
- const hasRootUniverseBlock = value.universe !== void 0;
1831
- const environmentEntries = Object.entries(value.environments);
1832
- const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
1833
- const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
1834
- const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
1835
- message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
1836
- path: ["universe", "universeId"]
1837
- }] : [];
1838
- return [...environmentIssues, ...rootIssues];
1839
- }
1840
- function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
1841
- return environmentEntries.flatMap(([environmentName, environment]) => {
1842
- if (environment.universe === void 0) return [];
1843
- if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
1844
- message: "universeId is declared at the root universe block; remove it from this environment overlay (root is authoritative) or remove it from the root and declare it on every environment.",
1845
- path: [
1846
- "environments",
1847
- environmentName,
1848
- "universe",
1849
- "universeId"
1850
- ]
1851
- }];
1852
- if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
1853
- message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
1854
- path: [
1855
- "environments",
1856
- environmentName,
1857
- "universe",
1858
- "universeId"
1859
- ]
1860
- }];
1861
- return [];
2272
+ };
2273
+ const body = await deps.readFile(desired.filePath);
2274
+ return deps.client.publish({
2275
+ body: Uint8Array.from(body),
2276
+ format,
2277
+ placeId: desired.placeId,
2278
+ universeId: deps.universeId
1862
2279
  });
1863
2280
  }
2281
+ async function publishPlace(deps, desired) {
2282
+ const publishResult = await publishVersion(deps, desired);
2283
+ if (!publishResult.success) return publishResult;
2284
+ const metadataParameters = buildMetadataParameters(deps.universeId, desired);
2285
+ if (metadataParameters !== void 0) {
2286
+ const metadataResult = await deps.client.update(metadataParameters);
2287
+ if (!metadataResult.success) return metadataResult;
2288
+ }
2289
+ return {
2290
+ data: {
2291
+ ...desired,
2292
+ outputs: publishResult.data
2293
+ },
2294
+ success: true
2295
+ };
2296
+ }
1864
2297
  //#endregion
1865
- //#region src/core/schema.ts
2298
+ //#region src/adapters/universe-driver.ts
1866
2299
  /**
1867
- * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
1868
- * autocomplete idiom prevents TypeScript from narrowing on
1869
- * `backend === "gist"` alone, so dispatch sites use this guard to
1870
- * preserve the `gistId` field shape.
2300
+ * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
2301
+ * and `update` both delegate to a shared reconcile helper because Open
2302
+ * Cloud cannot mint universes; the user supplies an existing `universeId`
2303
+ * and bedrock adopts the universe on first apply.
2304
+ *
2305
+ * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
2306
+ * as an adoption-error `ApiError` whose message names the config key and
2307
+ * the `universeId`, so operators can tell adoption failure apart from
2308
+ * transient upstream errors. A successful response whose `rootPlaceId` is
2309
+ * absent surfaces as an `ApiError` with status 200, mirroring the
2310
+ * malformed-response guard in `GamePassDriver`.
2311
+ *
2312
+ * When `displayName` is declared, the driver routes that field through
2313
+ * `PlacesClient.update` on the root place after the universe PATCH
2314
+ * succeeds. A subsequent places failure surfaces to the caller as the
2315
+ * driver's error result without rolling back the prior universe patch,
2316
+ * so callers observing a partial failure should reconcile by
2317
+ * reapplying rather than assuming the universe-level fields are
2318
+ * unchanged.
2319
+ *
2320
+ * @param deps - Injected ocale clients (universes plus places for the
2321
+ * read-only universe fields Roblox derives from the root place).
2322
+ * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
1871
2323
  *
1872
2324
  * @example
1873
2325
  *
1874
2326
  * ```ts
1875
- * import { isGistStateConfig } from "@bedrock-rbx/core";
1876
- * import type { StateConfig } from "@bedrock-rbx/core/config";
2327
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2328
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2329
+ * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
2330
+ * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
2331
+ * import {
2332
+ * asRobloxAssetId,
2333
+ * createUniverseDriver,
2334
+ * UNIVERSE_SINGLETON_KEY,
2335
+ * } from "@bedrock-rbx/core";
1877
2336
  *
1878
- * const config: StateConfig = { backend: "gist", gistId: "abc" };
2337
+ * const universeBodyHttpClient: HttpClient = {
2338
+ * async request() {
2339
+ * return {
2340
+ * data: {
2341
+ * body: validUniverseBody({
2342
+ * path: "universes/1234567890",
2343
+ * rootPlace: "universes/1234567890/places/4711",
2344
+ * }),
2345
+ * headers: {},
2346
+ * status: 200,
2347
+ * },
2348
+ * success: true,
2349
+ * };
2350
+ * },
2351
+ * };
1879
2352
  *
1880
- * expect(isGistStateConfig(config)).toBeTrue();
1881
- * if (isGistStateConfig(config)) {
1882
- * expect(config.gistId).toBe("abc");
1883
- * }
1884
- * ```
2353
+ * const driver = createUniverseDriver({
2354
+ * places: new PlacesClient({
2355
+ * apiKey: "rbx-your-key",
2356
+ * httpClient: universeBodyHttpClient,
2357
+ * sleep: async () => {},
2358
+ * }),
2359
+ * universes: new UniversesClient({
2360
+ * apiKey: "rbx-your-key",
2361
+ * httpClient: universeBodyHttpClient,
2362
+ * sleep: async () => {},
2363
+ * }),
2364
+ * });
1885
2365
  *
1886
- * @param config - Resolved state config to inspect.
1887
- * @returns `true` when `config.backend === "gist"`; otherwise `false`.
2366
+ * return driver
2367
+ * .create({
2368
+ * consoleEnabled: undefined,
2369
+ * desktopEnabled: true,
2370
+ * displayName: undefined,
2371
+ * key: UNIVERSE_SINGLETON_KEY,
2372
+ * kind: "universe",
2373
+ * mobileEnabled: undefined,
2374
+ * privateServerPriceRobux: undefined,
2375
+ * tabletEnabled: undefined,
2376
+ * universeId: asRobloxAssetId("1234567890"),
2377
+ * voiceChatEnabled: true,
2378
+ * vrEnabled: undefined,
2379
+ * })
2380
+ * .then((result) => {
2381
+ * expect(result.success).toBeTrue();
2382
+ * if (result.success) {
2383
+ * expect(result.data.outputs.rootPlaceId).toBe("4711");
2384
+ * }
2385
+ * });
2386
+ * ```
1888
2387
  */
1889
- function isGistStateConfig(config) {
1890
- return config.backend === "gist";
2388
+ function createUniverseDriver(deps) {
2389
+ return {
2390
+ async create(desired) {
2391
+ return reconcileUniverse({
2392
+ deps,
2393
+ desired
2394
+ });
2395
+ },
2396
+ async update(_current, desired) {
2397
+ return reconcileUniverse({
2398
+ deps,
2399
+ desired
2400
+ });
2401
+ }
2402
+ };
1891
2403
  }
1892
- const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
1893
- const OPTIONAL_STRING = "string | undefined";
1894
- /**
1895
- * Shared arktype constraint for any optional positive-integer field.
1896
- * Reused by per-kind entry schemas so positive-integer fields validate
1897
- * identically.
1898
- */
1899
- const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
1900
- /**
1901
- * Shared arktype constraint for any optional Robux-price field. The schema
1902
- * rejects negatives, fractional values, `NaN`, and `Infinity` at config
1903
- * validation time so a malformed price surfaces with a path attributing the
1904
- * failure to the offending field, rather than slipping through to the
1905
- * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
1906
- * schemas reuse this constant so all Robux-price fields validate
1907
- * identically.
1908
- */
1909
- const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
1910
- const gamePassEntry = type({
1911
- "name": "string",
1912
- "description": "string",
1913
- "icon": iconMap,
1914
- "price?": OPTIONAL_ROBUX_PRICE
1915
- });
1916
- const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
1917
- const developerProductEntry = type({
1918
- "name": "string",
1919
- "description": "string",
1920
- "icon?": iconMap,
1921
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1922
- "price?": OPTIONAL_ROBUX_PRICE,
1923
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
1924
- }).onUndeclaredKey("reject");
1925
- const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
1926
- const ROBLOX_ID_DIGITS = "string.digits";
1927
- const placeEntry = type({
1928
- "description?": OPTIONAL_STRING,
1929
- "displayName?": OPTIONAL_STRING,
1930
- "filePath": "string",
1931
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
1932
- }).onUndeclaredKey("reject");
1933
- const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
1934
- const socialLinkOrUndefined$1 = type({
1935
- title: "string",
1936
- uri: "string"
1937
- }).onUndeclaredKey("reject").or("undefined");
1938
- const universeEntry = type({
1939
- "consoleEnabled?": OPTIONAL_BOOLEAN$2,
1940
- "desktopEnabled?": OPTIONAL_BOOLEAN$2,
1941
- "discordSocialLink?": socialLinkOrUndefined$1,
1942
- "displayName?": OPTIONAL_STRING,
1943
- "facebookSocialLink?": socialLinkOrUndefined$1,
1944
- "guildedSocialLink?": socialLinkOrUndefined$1,
1945
- "mobileEnabled?": OPTIONAL_BOOLEAN$2,
1946
- "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
1947
- "robloxGroupSocialLink?": socialLinkOrUndefined$1,
1948
- "tabletEnabled?": OPTIONAL_BOOLEAN$2,
1949
- "twitchSocialLink?": socialLinkOrUndefined$1,
1950
- "twitterSocialLink?": socialLinkOrUndefined$1,
1951
- "universeId?": ROBLOX_ID_DIGITS,
1952
- "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
1953
- "vrEnabled?": OPTIONAL_BOOLEAN$2,
1954
- "youtubeSocialLink?": socialLinkOrUndefined$1
1955
- }).onUndeclaredKey("reject");
1956
- const stateConfig = type({
1957
- "backend": "string",
1958
- "gistId?": "string > 0"
1959
- }).onUndeclaredKey("reject");
1960
- const gamePassOverlay = type({
1961
- "description?": "string",
1962
- "icon?": iconMap,
1963
- "name?": "string",
1964
- "price?": OPTIONAL_ROBUX_PRICE
1965
- }).onUndeclaredKey("reject");
1966
- const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
1967
- const developerProductOverlay = type({
1968
- "description?": "string",
1969
- "icon?": iconMap,
1970
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1971
- "name?": "string",
1972
- "price?": OPTIONAL_ROBUX_PRICE,
1973
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
1974
- }).onUndeclaredKey("reject");
1975
- const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
1976
- const placeOverlay = type({
1977
- "description?": OPTIONAL_STRING,
1978
- "displayName?": OPTIONAL_STRING,
1979
- "filePath?": "string",
1980
- "placeId": ROBLOX_ID_DIGITS,
1981
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
1982
- }).onUndeclaredKey("reject");
1983
- const environmentEntry = type({
1984
- "label?": OPTIONAL_STRING,
1985
- "passes?": passesOverlayCollection,
1986
- "places?": type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject"),
1987
- "products?": productsOverlayCollection,
1988
- "state?": stateConfig,
1989
- "universe?": universeEntry
1990
- }).onUndeclaredKey("reject");
1991
- const rootSchema = type({
1992
- "displayNamePrefix?": type({
1993
- "enabled?": OPTIONAL_BOOLEAN$2,
1994
- "format?": OPTIONAL_STRING
1995
- }).onUndeclaredKey("reject"),
1996
- "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
1997
- if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
1998
- return true;
1999
- }),
2000
- "extends?": "unknown",
2001
- "passes?": passesCollection,
2002
- "places?": placesCollection,
2003
- "products?": productsCollection,
2004
- "state?": stateConfig,
2005
- "universe?": universeEntry
2006
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2007
- return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
2008
- return ctx.reject({
2009
- message: issue.message,
2010
- path: [...issue.path]
2404
+ function toCurrentState(desired, rootPlaceId) {
2405
+ return {
2406
+ ...desired,
2407
+ outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
2408
+ };
2409
+ }
2410
+ function buildParameters(desired) {
2411
+ const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
2412
+ const isEnabled = desired[flag];
2413
+ return isEnabled === void 0 ? accumulator : {
2414
+ ...accumulator,
2415
+ [flag]: isEnabled
2416
+ };
2417
+ }, { universeId: desired.universeId });
2418
+ return {
2419
+ ..."privateServerPriceRobux" in desired ? {
2420
+ ...base,
2421
+ privateServerPriceRobux: desired.privateServerPriceRobux
2422
+ } : base,
2423
+ ...copyDeclaredSocialLinks(desired)
2424
+ };
2425
+ }
2426
+ function wrapUpdateError(err, desired) {
2427
+ if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
2428
+ return err;
2429
+ }
2430
+ function hasUniverseLevelUpdate(desired) {
2431
+ if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
2432
+ if ("privateServerPriceRobux" in desired) return true;
2433
+ return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2434
+ }
2435
+ async function resolveUniverse(deps, desired) {
2436
+ const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
2437
+ if (!result.success) return {
2438
+ err: wrapUpdateError(result.err, desired),
2439
+ success: false
2440
+ };
2441
+ const { rootPlaceId } = result.data;
2442
+ if (rootPlaceId === void 0) return {
2443
+ err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
2444
+ success: false
2445
+ };
2446
+ return {
2447
+ data: { rootPlaceId },
2448
+ success: true
2449
+ };
2450
+ }
2451
+ async function reconcileUniverse(inputs) {
2452
+ const { deps, desired } = inputs;
2453
+ const universeResult = await resolveUniverse(deps, desired);
2454
+ if (!universeResult.success) return universeResult;
2455
+ const { rootPlaceId } = universeResult.data;
2456
+ if (desired.displayName !== void 0) {
2457
+ const placesResult = await deps.places.update({
2458
+ displayName: desired.displayName,
2459
+ placeId: rootPlaceId,
2460
+ universeId: desired.universeId
2011
2461
  });
2012
- }, true);
2013
- });
2462
+ if (!placesResult.success) return {
2463
+ err: placesResult.err,
2464
+ success: false
2465
+ };
2466
+ }
2467
+ return {
2468
+ data: toCurrentState(desired, rootPlaceId),
2469
+ success: true
2470
+ };
2471
+ }
2472
+ //#endregion
2473
+ //#region src/cli/clack-port.ts
2014
2474
  /**
2015
- * Validate a parsed config value against the runtime schema. Returns the
2016
- * validated `Config` on success or a `validationFailed` `ConfigError` with
2017
- * one issue per problem, each attributed to a field path. `sourceFile`
2018
- * appears in the error so callers can point a human at the offending file.
2475
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
2476
+ * resulting port writes to `process.stdout` via clack's defaults. Kept in
2477
+ * its own module so consumers that never need the clack-backed rendering
2478
+ * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
2479
+ * into their bundle.
2019
2480
  *
2020
- * @param input - Parsed value from a config source (object tree from a
2021
- * config loader, or a hand-built literal). Shape is checked, not assumed.
2022
- * @param sourceFile - Path or identifier of the source file, used in the
2023
- * `validationFailed` error.
2024
- * @returns `Ok` with the validated `Config`, or `Err` with a
2025
- * `validationFailed` error carrying each issue's field path.
2026
2481
  * @example
2027
2482
  *
2028
2483
  * ```ts
2029
- * import { validateConfig } from "@bedrock-rbx/core";
2484
+ * import { createClackPort } from "@bedrock-rbx/core";
2030
2485
  *
2031
- * const ok = validateConfig(
2032
- * {
2033
- * environments: { production: {} },
2034
- * passes: {
2035
- * "vip-pass": {
2036
- * description: "VIP perks.",
2037
- * icon: { "en-us": "assets/vip.png" },
2038
- * name: "VIP Pass",
2039
- * price: 500,
2040
- * },
2041
- * },
2042
- * },
2043
- * "bedrock.config.ts",
2044
- * );
2045
- * expect(ok.success).toBeTrue();
2486
+ * const port = createClackPort();
2046
2487
  *
2047
- * const err = validateConfig(
2048
- * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
2049
- * "bedrock.config.ts",
2050
- * );
2051
- * expect(err.success).toBeFalse();
2052
- * if (!err.success) {
2053
- * expect(err.err.kind).toBe("validationFailed");
2054
- * }
2488
+ * expect(typeof port.logSuccess).toBe("function");
2055
2489
  * ```
2490
+ *
2491
+ * @returns A port whose six methods each invoke the matching clack helper.
2056
2492
  */
2057
- function validateConfig(input, sourceFile) {
2058
- const validated = rootSchema(input);
2059
- if (validated instanceof ArkErrors) return {
2060
- err: {
2061
- issues: Array.from(validated, (issue) => {
2062
- return {
2063
- message: issue.message,
2064
- path: [...issue.path].map((segment) => String(segment))
2065
- };
2066
- }),
2067
- kind: "validationFailed",
2068
- sourceFile
2069
- },
2070
- success: false
2071
- };
2493
+ function createClackPort() {
2072
2494
  return {
2073
- data: validated,
2074
- success: true
2495
+ cancel: (message) => {
2496
+ cancel(message);
2497
+ },
2498
+ intro: (message) => {
2499
+ intro(message);
2500
+ },
2501
+ logError: (message) => {
2502
+ log.error(message);
2503
+ },
2504
+ logMessage: (message) => {
2505
+ log.message(message);
2506
+ },
2507
+ logSuccess: (message) => {
2508
+ log.success(message);
2509
+ },
2510
+ outro: (message) => {
2511
+ outro(message);
2512
+ }
2075
2513
  };
2076
2514
  }
2077
2515
  //#endregion
@@ -2083,6 +2521,7 @@ const entrySchema$3 = type({
2083
2521
  "icon?": iconMap,
2084
2522
  "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$1,
2085
2523
  "price?": OPTIONAL_ROBUX_PRICE,
2524
+ "redacted?": "boolean | undefined",
2086
2525
  "storePageEnabled?": OPTIONAL_BOOLEAN$1
2087
2526
  });
2088
2527
  function flatten$3(config) {
@@ -2130,8 +2569,19 @@ async function normalize$3(input, io) {
2130
2569
  success: true
2131
2570
  };
2132
2571
  }
2572
+ function changedFieldsBetween$3(desired, current) {
2573
+ return [
2574
+ ...desired.description === current.description ? [] : ["description"],
2575
+ ...desired.icon?.["en-us"] === current.icon?.["en-us"] ? [] : ["icon"],
2576
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2577
+ ...desired.name === current.name ? [] : ["name"],
2578
+ ...desired.price === current.price ? [] : ["price"],
2579
+ ...desired.isRegionalPricingEnabled === void 0 || desired.isRegionalPricingEnabled === current.isRegionalPricingEnabled ? [] : ["isRegionalPricingEnabled"],
2580
+ ...desired.storePageEnabled === void 0 || desired.storePageEnabled === current.storePageEnabled ? [] : ["storePageEnabled"]
2581
+ ];
2582
+ }
2133
2583
  function fieldsEqual$3(desired, current) {
2134
- return desired.description === current.description && desired.icon?.["en-us"] === current.icon?.["en-us"] && iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) && desired.name === current.name && desired.price === current.price && (desired.isRegionalPricingEnabled === void 0 || desired.isRegionalPricingEnabled === current.isRegionalPricingEnabled) && (desired.storePageEnabled === void 0 || desired.storePageEnabled === current.storePageEnabled);
2584
+ return changedFieldsBetween$3(desired, current).length === 0;
2135
2585
  }
2136
2586
  function assertReconcilable(current, desired) {
2137
2587
  if (current.iconFileHashes !== void 0 && desired.iconFileHashes === void 0) return {
@@ -2154,6 +2604,7 @@ function assertReconcilable(current, desired) {
2154
2604
  */
2155
2605
  const developerProductKind = {
2156
2606
  assertReconcilable,
2607
+ changedFieldsBetween: changedFieldsBetween$3,
2157
2608
  entrySchema: entrySchema$3,
2158
2609
  fieldsEqual: fieldsEqual$3,
2159
2610
  flatten: flatten$3,
@@ -2166,7 +2617,8 @@ const entrySchema$2 = type({
2166
2617
  "name": "string",
2167
2618
  "description": "string",
2168
2619
  "icon": iconMap,
2169
- "price?": OPTIONAL_ROBUX_PRICE
2620
+ "price?": OPTIONAL_ROBUX_PRICE,
2621
+ "redacted?": "boolean | undefined"
2170
2622
  });
2171
2623
  function flatten$2(config) {
2172
2624
  return Object.entries(config.passes ?? {}).map(([key, entry]) => {
@@ -2199,8 +2651,17 @@ async function normalize$2(input, io) {
2199
2651
  success: true
2200
2652
  };
2201
2653
  }
2654
+ function changedFieldsBetween$2(desired, current) {
2655
+ return [
2656
+ ...desired.description === current.description ? [] : ["description"],
2657
+ ...desired.icon["en-us"] === current.icon["en-us"] ? [] : ["icon"],
2658
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2659
+ ...desired.name === current.name ? [] : ["name"],
2660
+ ...desired.price === current.price ? [] : ["price"]
2661
+ ];
2662
+ }
2202
2663
  function fieldsEqual$2(desired, current) {
2203
- return desired.description === current.description && desired.icon["en-us"] === current.icon["en-us"] && iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) && desired.name === current.name && desired.price === current.price;
2664
+ return changedFieldsBetween$2(desired, current).length === 0;
2204
2665
  }
2205
2666
  /**
2206
2667
  * Resource-kind module for Roblox game passes. Owns the entry schema,
@@ -2208,6 +2669,7 @@ function fieldsEqual$2(desired, current) {
2208
2669
  * `gamePass` kind.
2209
2670
  */
2210
2671
  const gamePassKind = {
2672
+ changedFieldsBetween: changedFieldsBetween$2,
2211
2673
  entrySchema: entrySchema$2,
2212
2674
  fieldsEqual: fieldsEqual$2,
2213
2675
  flatten: flatten$2,
@@ -2256,12 +2718,19 @@ async function normalize$1(input, io) {
2256
2718
  success: true
2257
2719
  };
2258
2720
  }
2721
+ function changedFieldsBetween$1(desired, current) {
2722
+ return [
2723
+ ...desired.fileHash === current.fileHash ? [] : ["fileHash"],
2724
+ ...desired.filePath === current.filePath ? [] : ["filePath"],
2725
+ ...desired.placeId === current.placeId ? [] : ["placeId"],
2726
+ ...PLACE_MANAGED_METADATA_FIELDS.filter((field) => {
2727
+ const desiredValue = desired[field];
2728
+ return desiredValue !== void 0 && desiredValue !== current[field];
2729
+ })
2730
+ ];
2731
+ }
2259
2732
  function fieldsEqual$1(desired, current) {
2260
- if (desired.fileHash !== current.fileHash || desired.filePath !== current.filePath || desired.placeId !== current.placeId) return false;
2261
- return PLACE_MANAGED_METADATA_FIELDS.every((field) => {
2262
- const desiredValue = desired[field];
2263
- return desiredValue === void 0 || desiredValue === current[field];
2264
- });
2733
+ return changedFieldsBetween$1(desired, current).length === 0;
2265
2734
  }
2266
2735
  /**
2267
2736
  * Resource-kind module for Roblox places. Owns the entry schema,
@@ -2269,6 +2738,7 @@ function fieldsEqual$1(desired, current) {
2269
2738
  * kind.
2270
2739
  */
2271
2740
  const placeKind = {
2741
+ changedFieldsBetween: changedFieldsBetween$1,
2272
2742
  entrySchema: entrySchema$1,
2273
2743
  fieldsEqual: fieldsEqual$1,
2274
2744
  flatten: flatten$1,
@@ -2351,22 +2821,20 @@ function socialLinkEqual(a, b) {
2351
2821
  if (b === void 0) return false;
2352
2822
  return a.title === b.title && a.uri === b.uri;
2353
2823
  }
2354
- function declaredSocialLinksEqual(desired, current) {
2355
- for (const field of SOCIAL_LINK_FIELDS) {
2356
- if (!(field in desired)) continue;
2357
- if (!socialLinkEqual(desired[field], current[field])) return false;
2358
- }
2359
- return true;
2824
+ function changedFieldsBetween(desired, current) {
2825
+ return [
2826
+ ...desired.universeId === current.universeId ? [] : ["universeId"],
2827
+ ...UNIVERSE_MANAGED_FLAGS.filter((flag) => {
2828
+ const isDesiredEnabled = desired[flag];
2829
+ return isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag];
2830
+ }),
2831
+ ...desired.displayName === void 0 || desired.displayName === current.displayName ? [] : ["displayName"],
2832
+ ..."privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux ? ["privateServerPriceRobux"] : [],
2833
+ ...SOCIAL_LINK_FIELDS.filter((field) => field in desired && !socialLinkEqual(desired[field], current[field]))
2834
+ ];
2360
2835
  }
2361
2836
  function fieldsEqual(desired, current) {
2362
- if (desired.universeId !== current.universeId) return false;
2363
- for (const flag of UNIVERSE_MANAGED_FLAGS) {
2364
- const isDesiredEnabled = desired[flag];
2365
- if (isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag]) return false;
2366
- }
2367
- if (desired.displayName !== void 0 && desired.displayName !== current.displayName) return false;
2368
- if ("privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux) return false;
2369
- return declaredSocialLinksEqual(desired, current);
2837
+ return changedFieldsBetween(desired, current).length === 0;
2370
2838
  }
2371
2839
  //#endregion
2372
2840
  //#region src/core/kinds/index.ts
@@ -2392,6 +2860,7 @@ const defaultKindRegistry = {
2392
2860
  gamePass: gamePassKind,
2393
2861
  place: placeKind,
2394
2862
  universe: {
2863
+ changedFieldsBetween,
2395
2864
  entrySchema,
2396
2865
  fieldsEqual,
2397
2866
  flatten,
@@ -2412,8 +2881,12 @@ const defaultKindRegistry = {
2412
2881
  * `update` op if any declared field differs or a `noop` op if every field
2413
2882
  * matches.
2414
2883
  *
2415
- * Ops appear in the order their desired entries appear in the input array so
2416
- * callers can rely on declaration order when logging or applying ops.
2884
+ * Ops appear in the order their desired entries appear in the input array.
2885
+ * `applyOps` regroups them into Phase 1 (universe) and Phase 2 (everything
2886
+ * else) when dispatching; the execution order within Phase 2 is not
2887
+ * guaranteed because Phase 2 dispatches concurrently. Persisted state-file
2888
+ * order is determined by the merge in `deploy.runReconcile` (which retains
2889
+ * prior-snapshot positions for unchanged keys), not by this diff output.
2417
2890
  *
2418
2891
  * @param desired - Declared desired state from user config, already normalized
2419
2892
  * (file hashes computed, nullable wire values mapped to `undefined`).
@@ -2477,6 +2950,11 @@ const defaultKindRegistry = {
2477
2950
  * const ops = diff([unchanged, drifted, fresh], current);
2478
2951
  *
2479
2952
  * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
2953
+ *
2954
+ * const updateOp = ops[1]!;
2955
+ * if (updateOp.type === "update") {
2956
+ * expect(updateOp.changedFields).toStrictEqual(["name"]);
2957
+ * }
2480
2958
  * ```
2481
2959
  */
2482
2960
  function diff(desired, current) {
@@ -2486,21 +2964,21 @@ function diff(desired, current) {
2486
2964
  function compositeKey$1(resource) {
2487
2965
  return `${resource.kind}:${resource.key}`;
2488
2966
  }
2489
- function desiredFieldsEqual(desired, current) {
2490
- return defaultKindRegistry[desired.kind].fieldsEqual(desired, current);
2491
- }
2492
2967
  function operationFor(desired, current) {
2493
2968
  if (current === void 0) return {
2494
2969
  key: desired.key,
2495
2970
  desired,
2496
2971
  type: "create"
2497
2972
  };
2498
- if (desiredFieldsEqual(desired, current)) return {
2973
+ const changedFields = defaultKindRegistry[desired.kind].changedFieldsBetween(desired, current);
2974
+ if (changedFields.length === 0) return {
2499
2975
  key: desired.key,
2976
+ kind: desired.kind,
2500
2977
  type: "noop"
2501
2978
  };
2502
2979
  return {
2503
2980
  key: desired.key,
2981
+ changedFields,
2504
2982
  current,
2505
2983
  desired,
2506
2984
  type: "update"
@@ -2596,59 +3074,316 @@ function capitalize(value) {
2596
3074
  function flattenConfig(config) {
2597
3075
  return Object.values(defaultKindRegistry).flatMap((module) => module.flatten(config));
2598
3076
  }
2599
- //#endregion
2600
- //#region src/core/resolve-state-config.ts
2601
3077
  /**
2602
- * Pick the `StateConfig` that applies to `environment`. Per-environment
2603
- * overrides win over the root block; if neither is present, returns
2604
- * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
2605
- * error instead of silently falling back.
2606
- *
2607
- * @param config - Validated project config.
2608
- * @param environment - Target environment name.
2609
- * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
2610
- * neither the environment override nor the root block is set.
2611
- * @example
2612
- *
2613
- * ```ts
2614
- * import { resolveStateConfig } from "@bedrock-rbx/core";
2615
- *
2616
- * const result = resolveStateConfig(
2617
- * {
2618
- * state: { backend: "gist", gistId: "root-gist" },
2619
- * environments: {
2620
- * production: { state: { backend: "gist", gistId: "prod-gist" } },
2621
- * },
2622
- * },
2623
- * "production",
2624
- * );
3078
+ * Common prefix used to build the default name pushed for a redacted
3079
+ * developer-product. The full default produced by {@link defaultRedactedProductName}
3080
+ * is `${REDACTED_PRODUCT_NAME} ${suffix}`, where `suffix` is a 6-hex-char
3081
+ * digest of the resource key (see {@link redactedNameSuffix}). The suffix is
3082
+ * required because Roblox enforces per-universe uniqueness on
3083
+ * developer-product names, so a shared bare placeholder would collide across
3084
+ * multiple redacted entries. The prefix avoids the word `Redacted` and the
3085
+ * `#` separator because Roblox's text-moderation filter has been observed
3086
+ * silently replacing names matching `Redacted Product #<hex>` with
3087
+ * `########################`, which then causes downstream `DuplicateProductName`
3088
+ * errors when other redacted entries are moderated to the same string.
3089
+ */
3090
+ const REDACTED_PRODUCT_NAME = "Hidden Product";
3091
+ const PASS_PRODUCT_ENV_FIELDS = [
3092
+ "description",
3093
+ "icon",
3094
+ "name",
3095
+ "price"
3096
+ ];
3097
+ const PLACE_ENV_FIELDS = ["description", "displayName"];
3098
+ /**
3099
+ * Six-character lowercase hex digest of `SHA-256(key)`, used as the
3100
+ * disambiguating suffix on a redacted developer-product's default `name`.
3101
+ * Stable across config edits (driven only by the bedrock resource key, not
3102
+ * declaration order) and opaque to a Roblox player browsing the marketplace.
3103
+ * A natural collision is caught at plan time by `validatePlan`.
3104
+ *
3105
+ * @param key - Bedrock resource key for the developer product being redacted.
3106
+ * @returns The first six lowercase hex characters of the SHA-256 digest of `key`.
3107
+ */
3108
+ function redactedNameSuffix(key) {
3109
+ return createHash("sha256").update(key).digest("hex").slice(0, 6);
3110
+ }
3111
+ /**
3112
+ * Default redacted name for a developer product with the given resource key.
3113
+ * Combines {@link REDACTED_PRODUCT_NAME} with {@link redactedNameSuffix} so
3114
+ * each redacted entry resolves to a unique value the upstream API will accept.
2625
3115
  *
2626
- * expect(result.success).toBeTrue();
2627
- * if (result.success) {
2628
- * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
2629
- * }
2630
- * ```
3116
+ * @param key - Bedrock resource key for the developer product being redacted.
3117
+ * @returns The placeholder name pushed to Roblox for this product.
3118
+ */
3119
+ function defaultRedactedProductName(key) {
3120
+ return `${REDACTED_PRODUCT_NAME} ${redactedNameSuffix(key)}`;
3121
+ }
3122
+ /**
3123
+ * Pure transform that substitutes bedrock-supplied placeholder content for
3124
+ * every resource whose effective redaction state is truthy. Three layers
3125
+ * compose field-by-field per resource: env-resource (most-specific, from
3126
+ * `inputs.envResource`), root-resource (the `redacted` field on the
3127
+ * passed-in entry), and env-level (least-specific, `inputs.envLevel`).
3128
+ * The first non-undefined value sets state (`false` carves out); object
3129
+ * layers then contribute fields with the most-specific layer winning per
3130
+ * field, and bedrock defaults fill any field nobody set. Runs between
3131
+ * env-overlay merge and display-name prefix render so the rest of the
3132
+ * pipeline (flatten, normalize, diff, apply) operates on already-redacted
3133
+ * values and needs no special-case redaction logic.
3134
+ *
3135
+ * @param config - Post-merge `ResolvedConfig` produced by `selectEnvironment`.
3136
+ * @param inputs - Aggregated redaction layers. Omit to skip redaction
3137
+ * entirely. See {@link RedactionInputs} for the shape.
3138
+ * @returns A `ResolvedConfig` whose redacted entries carry placeholder
3139
+ * values; non-redacted entries pass through verbatim, and the input is
3140
+ * not mutated.
3141
+ */
3142
+ function applyRedaction(config, inputs) {
3143
+ const environmentLevel = inputs?.envLevel;
3144
+ const environmentResource = inputs?.envResource;
3145
+ const passes = redactPasses({
3146
+ collection: config.passes,
3147
+ envLevel: environmentLevel,
3148
+ envResource: environmentResource?.passes
3149
+ });
3150
+ const places = redactPlaces({
3151
+ collection: config.places,
3152
+ envLevel: environmentLevel,
3153
+ envResource: environmentResource?.places
3154
+ });
3155
+ const products = redactProducts({
3156
+ collection: config.products,
3157
+ envLevel: environmentLevel,
3158
+ envResource: environmentResource?.products
3159
+ });
3160
+ if (passes === config.passes && places === config.places && products === config.products) return config;
3161
+ return {
3162
+ ...config,
3163
+ ...passes === void 0 ? {} : { passes },
3164
+ ...places === void 0 ? {} : { places },
3165
+ ...products === void 0 ? {} : { products }
3166
+ };
3167
+ }
3168
+ /**
3169
+ * Inspect the pre-redaction merged config and produce one annotation per
3170
+ * resource flagged `redacted: true` at either the root entry or its
3171
+ * env-overlay counterpart. Callers thread the result into plan output so
3172
+ * authors can see which resources are redacted in the active environment
3173
+ * and whether their real-value edits are being suppressed.
3174
+ *
3175
+ * Operates on the pre-redaction view because the post-redaction config no
3176
+ * longer carries the real `name`/`description`/`icon` values needed to
3177
+ * detect divergence from the placeholder defaults.
3178
+ *
3179
+ * @param merged - `ResolvedConfig` produced by environment overlay merge,
3180
+ * before `applyRedaction` has substituted placeholders.
3181
+ * @param environmentResource - Per-kind env-overlay redaction layers
3182
+ * extracted from the active env entry. Omit when the caller has no
3183
+ * env-overlay layer.
3184
+ * @returns Zero or more annotations, one per redacted resource. Empty when
3185
+ * the config declares no redacted resources.
3186
+ */
3187
+ function collectRedactionAnnotations(merged, environmentResource) {
3188
+ const passes = Object.entries(merged.passes ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.passes?.[key] === true).map(([key, entry]) => {
3189
+ return {
3190
+ key: asResourceKey(key),
3191
+ hasRealValueEdits: passHasRealValueEdits(entry),
3192
+ kind: "gamePass"
3193
+ };
3194
+ });
3195
+ const products = Object.entries(merged.products ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.products?.[key] === true).map(([key, entry]) => {
3196
+ return {
3197
+ key: asResourceKey(key),
3198
+ hasRealValueEdits: productHasRealValueEdits(key, entry),
3199
+ kind: "developerProduct"
3200
+ };
3201
+ });
3202
+ return [...passes, ...products];
3203
+ }
3204
+ function pickEnvironmentFields(environmentLevel, fields) {
3205
+ if (environmentLevel === void 0 || typeof environmentLevel === "boolean") return environmentLevel;
3206
+ return Object.fromEntries(fields.map((field) => [field, environmentLevel[field]]));
3207
+ }
3208
+ /**
3209
+ * Walk redaction layers most-specific to least-specific and produce the
3210
+ * effective per-field override for one resource. Returns `undefined` when the
3211
+ * resource is not redacted; returns a (possibly empty) object when it is.
3212
+ * State step: the first non-undefined layer sets state -- `false` carves out,
3213
+ * `true` or object enables. Fields step: walk every object layer in the same
3214
+ * order, taking the first value per field. A field's value may itself be
3215
+ * `undefined` (the env-level projection produced by {@link pickEnvironmentFields}
3216
+ * includes every projected key, even when the env override left it absent);
3217
+ * downstream per-kind redact functions collapse those back to bedrock
3218
+ * placeholder defaults via `??`.
3219
+ *
3220
+ * @template Override - Per-kind override type the resource accepts.
3221
+ * @param layers - Layers ordered most-specific (index 0) to least-specific.
3222
+ * @returns The effective override, or `undefined` when not redacted.
3223
+ */
3224
+ function resolveEffectiveOverride(layers) {
3225
+ const firstNonUndefined = layers.find((layer) => layer !== void 0);
3226
+ if (firstNonUndefined === void 0 || firstNonUndefined === false) return;
3227
+ const effective = {};
3228
+ for (const layer of layers) {
3229
+ if (typeof layer !== "object") continue;
3230
+ for (const [field, value] of Object.entries(layer)) if (!(field in effective)) effective[field] = value;
3231
+ }
3232
+ return effective;
3233
+ }
3234
+ function resolveEntries(inputs) {
3235
+ const { collection, environmentForKind, envResource } = inputs;
3236
+ return Object.entries(collection).map(([key, entry]) => {
3237
+ return {
3238
+ key,
3239
+ entry,
3240
+ override: resolveEffectiveOverride([
3241
+ envResource?.[key],
3242
+ entry.redacted,
3243
+ environmentForKind
3244
+ ])
3245
+ };
3246
+ });
3247
+ }
3248
+ function redactCollection(inputs) {
3249
+ const { collection, environmentForKind, envResource, redact } = inputs;
3250
+ if (collection === void 0) return;
3251
+ const resolved = resolveEntries({
3252
+ collection,
3253
+ environmentForKind,
3254
+ envResource
3255
+ });
3256
+ if (resolved.every((item) => item.override === void 0)) return collection;
3257
+ return Object.fromEntries(resolved.map((item) => {
3258
+ return item.override === void 0 ? [item.key, item.entry] : [item.key, redact({
3259
+ key: item.key,
3260
+ entry: item.entry,
3261
+ override: item.override
3262
+ })];
3263
+ }));
3264
+ }
3265
+ function redactPass(entry, override) {
3266
+ return {
3267
+ ...entry,
3268
+ name: override.name ?? "Redacted Pass",
3269
+ description: override.description ?? "",
3270
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3271
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3272
+ };
3273
+ }
3274
+ function redactPasses(inputs) {
3275
+ const { collection, envLevel, envResource } = inputs;
3276
+ return redactCollection({
3277
+ collection,
3278
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3279
+ envResource,
3280
+ redact: (item) => redactPass(item.entry, item.override)
3281
+ });
3282
+ }
3283
+ function redactPlace(entry, override) {
3284
+ return {
3285
+ ...entry,
3286
+ description: override.description ?? "",
3287
+ displayName: override.displayName ?? entry.displayName
3288
+ };
3289
+ }
3290
+ function redactPlaces(inputs) {
3291
+ const { collection, envLevel, envResource } = inputs;
3292
+ return redactCollection({
3293
+ collection,
3294
+ environmentForKind: pickEnvironmentFields(envLevel, PLACE_ENV_FIELDS),
3295
+ envResource,
3296
+ redact: (item) => redactPlace(item.entry, item.override)
3297
+ });
3298
+ }
3299
+ function redactProduct(inputs) {
3300
+ const { key, entry, override } = inputs;
3301
+ return {
3302
+ ...entry,
3303
+ name: override.name ?? defaultRedactedProductName(key),
3304
+ description: override.description ?? "",
3305
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3306
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3307
+ };
3308
+ }
3309
+ function redactProducts(inputs) {
3310
+ const { collection, envLevel, envResource } = inputs;
3311
+ return redactCollection({
3312
+ collection,
3313
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3314
+ envResource,
3315
+ redact: redactProduct
3316
+ });
3317
+ }
3318
+ function passHasRealValueEdits(entry) {
3319
+ return entry.name !== "Redacted Pass" || entry.description !== "" || entry.icon["en-us"] !== "<bedrock:redacted-icon.png>" || entry.price !== void 0 && entry.price !== 99999;
3320
+ }
3321
+ function productHasRealValueEdits(key, entry) {
3322
+ return !(entry.name === defaultRedactedProductName(key) || entry.name === "Hidden Product") || entry.description !== "" || entry.icon !== void 0 && entry.icon["en-us"] !== "<bedrock:redacted-icon.png>" || entry.price !== void 0 && entry.price !== 99999;
3323
+ }
3324
+ //#endregion
3325
+ //#region src/core/select-environment.ts
3326
+ /**
3327
+ * Project a `Config` onto a single environment up to the pre-redaction
3328
+ * merge boundary. Looks up the env entry, deep-merges its resource overlay
3329
+ * over the root config, and runs the same pass, place, and universe
3330
+ * completeness checks {@link selectEnvironment} runs, so the returned
3331
+ * `merged` config honours the full `ResolvedConfig` contract. Real
3332
+ * `name`, `description`, and `icon` values on redacted resources stay
3333
+ * intact, letting callers inspect divergence from placeholder defaults
3334
+ * before {@link selectEnvironment} substitutes them.
3335
+ *
3336
+ * @param config - Validated project config.
3337
+ * @param environment - Environment name to project onto.
3338
+ * @returns The matched env entry plus the merged config, or any of the
3339
+ * `SelectEnvironmentError` failure modes.
2631
3340
  */
2632
- function resolveStateConfig(config, environment) {
2633
- const override = config.environments[environment]?.state;
2634
- if (override !== void 0) return {
2635
- data: override,
2636
- success: true
3341
+ function selectMergedEnvironment(config, environment) {
3342
+ const entry = config.environments[environment];
3343
+ if (entry === void 0) return {
3344
+ err: unknownEnvironment(config, environment),
3345
+ success: false
2637
3346
  };
2638
- if (config.state !== void 0) return {
2639
- data: config.state,
2640
- success: true
3347
+ const merged = mergeOverlays(config, entry);
3348
+ const incompletePass = findIncompletePass(merged, environment);
3349
+ if (incompletePass !== void 0) return {
3350
+ err: incompletePass,
3351
+ success: false
3352
+ };
3353
+ const incompletePlace = findIncompletePlace(merged, environment);
3354
+ if (incompletePlace !== void 0) return {
3355
+ err: incompletePlace,
3356
+ success: false
3357
+ };
3358
+ const incompleteUniverse = findIncompleteUniverse(merged, environment);
3359
+ if (incompleteUniverse !== void 0) return {
3360
+ err: incompleteUniverse,
3361
+ success: false
2641
3362
  };
2642
3363
  return {
2643
- err: {
2644
- environment,
2645
- kind: "stateNotConfigured"
3364
+ data: {
3365
+ entry,
3366
+ merged
2646
3367
  },
2647
- success: false
3368
+ success: true
3369
+ };
3370
+ }
3371
+ /**
3372
+ * Build the per-resource env-overlay redaction layer that `applyRedaction`
3373
+ * and `collectRedactionAnnotations` consume. Reads each redactable kind off
3374
+ * the environment entry and projects every entry's `redacted` field into
3375
+ * the layer; omits kinds the env entry does not declare.
3376
+ *
3377
+ * @param entry - Environment entry whose overlay redaction values to extract.
3378
+ * @returns A `EnvironmentResourceRedaction` ready to pass downstream.
3379
+ */
3380
+ function extractResourceRedaction(entry) {
3381
+ return {
3382
+ ...entry.passes ? { passes: extractRedactionLayer(entry.passes) } : {},
3383
+ ...entry.places ? { places: extractRedactionLayer(entry.places) } : {},
3384
+ ...entry.products ? { products: extractRedactionLayer(entry.products) } : {}
2648
3385
  };
2649
3386
  }
2650
- //#endregion
2651
- //#region src/core/select-environment.ts
2652
3387
  /**
2653
3388
  * Project a validated `Config` onto a single environment. Looks up the
2654
3389
  * matching `environments[environment]` entry, deep-merges its resource
@@ -2727,28 +3462,87 @@ function resolveStateConfig(config, environment) {
2727
3462
  * projection failed.
2728
3463
  */
2729
3464
  function selectEnvironment(config, environment) {
2730
- const entry = config.environments[environment];
2731
- if (entry === void 0) return {
2732
- err: unknownEnvironment(config, environment),
2733
- success: false
3465
+ const mergedResult = selectMergedEnvironment(config, environment);
3466
+ if (!mergedResult.success) return mergedResult;
3467
+ const { entry, merged } = mergedResult.data;
3468
+ return {
3469
+ data: redactAndPrefix({
3470
+ config,
3471
+ entry,
3472
+ merged
3473
+ }),
3474
+ success: true
2734
3475
  };
2735
- const projected = projectConfig({
2736
- config,
2737
- entry
2738
- });
2739
- const incompletePlace = findIncompletePlace(projected, environment);
2740
- if (incompletePlace !== void 0) return {
2741
- err: incompletePlace,
2742
- success: false
3476
+ }
3477
+ function findIncompletePass(merged, environment) {
3478
+ const { passes } = merged;
3479
+ if (passes === void 0) return;
3480
+ const candidates = passes;
3481
+ for (const [key, entry] of Object.entries(candidates)) {
3482
+ if (entry.name === void 0) return {
3483
+ key,
3484
+ environment,
3485
+ kind: "incompletePassEntry",
3486
+ missingField: "name"
3487
+ };
3488
+ if (entry.description === void 0) return {
3489
+ key,
3490
+ environment,
3491
+ kind: "incompletePassEntry",
3492
+ missingField: "description"
3493
+ };
3494
+ if (entry.icon === void 0) return {
3495
+ key,
3496
+ environment,
3497
+ kind: "incompletePassEntry",
3498
+ missingField: "icon"
3499
+ };
3500
+ }
3501
+ }
3502
+ function mergeEntry(overlay, base) {
3503
+ return defu(overlay, base ?? {});
3504
+ }
3505
+ function mergeKeyedRecord(overlay, base) {
3506
+ if (overlay === void 0) return base;
3507
+ return {
3508
+ ...base ?? {},
3509
+ ...Object.fromEntries(Object.entries(overlay).map(([key, partial]) => {
3510
+ return [key, mergeEntry(partial, base?.[key])];
3511
+ }))
2743
3512
  };
2744
- const incompleteUniverse = findIncompleteUniverse(projected, environment);
2745
- if (incompleteUniverse !== void 0) return {
2746
- err: incompleteUniverse,
2747
- success: false
3513
+ }
3514
+ function mergeUniverse(overlay, base) {
3515
+ if (overlay === void 0 && base === void 0) return;
3516
+ return defu(overlay ?? {}, base ?? {});
3517
+ }
3518
+ function stripRedacted(overlay) {
3519
+ if (overlay === void 0) return;
3520
+ return Object.fromEntries(Object.entries(overlay).map(([key, entryValue]) => {
3521
+ const { redacted: _redacted, ...rest } = entryValue;
3522
+ return [key, rest];
3523
+ }));
3524
+ }
3525
+ function mergeOverlays(config, entry) {
3526
+ const passes = mergeKeyedRecord(stripRedacted(entry.passes), config.passes);
3527
+ const places = mergeKeyedRecord(stripRedacted(entry.places), config.places);
3528
+ const products = mergeKeyedRecord(stripRedacted(entry.products), config.products);
3529
+ const universe = mergeUniverse(entry.universe, config.universe);
3530
+ const state = entry.state ?? config.state;
3531
+ const { places: _placesRoot, products: _productsRoot, universe: _universeRoot, ...rest } = config;
3532
+ return {
3533
+ ...rest,
3534
+ ...passes === void 0 ? {} : { passes },
3535
+ ...places === void 0 ? {} : { places },
3536
+ ...products === void 0 ? {} : { products },
3537
+ ...state === void 0 ? {} : { state },
3538
+ ...universe === void 0 ? {} : { universe }
2748
3539
  };
3540
+ }
3541
+ function unknownEnvironment(config, environment) {
2749
3542
  return {
2750
- data: projected,
2751
- success: true
3543
+ declared: Object.keys(config.environments),
3544
+ environment,
3545
+ kind: "unknownEnvironment"
2752
3546
  };
2753
3547
  }
2754
3548
  function findIncompleteUniverse(projected, environment) {
@@ -2779,21 +3573,10 @@ function findIncompletePlace(projected, environment) {
2779
3573
  };
2780
3574
  }
2781
3575
  }
2782
- function mergeEntry(overlay, base) {
2783
- return defu(overlay, base ?? {});
2784
- }
2785
- function mergeKeyedRecord(overlay, base) {
2786
- if (overlay === void 0) return base;
2787
- return {
2788
- ...base ?? {},
2789
- ...Object.fromEntries(Object.entries(overlay).map(([key, partial]) => {
2790
- return [key, mergeEntry(partial, base?.[key])];
2791
- }))
2792
- };
2793
- }
2794
- function mergeUniverse(overlay, base) {
2795
- if (overlay === void 0 && base === void 0) return;
2796
- return defu(overlay ?? {}, base ?? {});
3576
+ function extractRedactionLayer(overlay) {
3577
+ const layer = {};
3578
+ for (const [key, entryValue] of Object.entries(overlay)) if (entryValue.redacted !== void 0) layer[key] = entryValue.redacted;
3579
+ return layer;
2797
3580
  }
2798
3581
  function resolvePrefix(config, entry) {
2799
3582
  if (config.displayNamePrefix?.enabled === false) return;
@@ -2818,33 +3601,21 @@ function applyPlacesPrefix(places, prefix) {
2818
3601
  }];
2819
3602
  }));
2820
3603
  }
2821
- function projectConfig(inputs) {
2822
- const { config, entry } = inputs;
2823
- const passes = mergeKeyedRecord(entry.passes, config.passes);
2824
- const mergedPlaces = mergeKeyedRecord(entry.places, config.places);
2825
- const products = mergeKeyedRecord(entry.products, config.products);
2826
- const merged = mergeUniverse(entry.universe, config.universe);
3604
+ function redactAndPrefix(inputs) {
3605
+ const { config, entry, merged } = inputs;
3606
+ const redacted = applyRedaction(merged, {
3607
+ envLevel: entry.redacted,
3608
+ envResource: extractResourceRedaction(entry)
3609
+ });
2827
3610
  const prefix = resolvePrefix(config, entry);
2828
- const universe = applyUniversePrefix(merged, prefix);
2829
- const places = applyPlacesPrefix(mergedPlaces, prefix);
2830
- const state = entry.state ?? config.state;
2831
- const { places: _placesRoot, products: _productsRoot, universe: _universeRoot, ...rest } = config;
3611
+ const places = applyPlacesPrefix(redacted.places, prefix);
3612
+ const universe = applyUniversePrefix(redacted.universe, prefix);
2832
3613
  return {
2833
- ...rest,
2834
- ...passes === void 0 ? {} : { passes },
3614
+ ...redacted,
2835
3615
  ...places === void 0 ? {} : { places },
2836
- ...products === void 0 ? {} : { products },
2837
- ...state === void 0 ? {} : { state },
2838
3616
  ...universe === void 0 ? {} : { universe }
2839
3617
  };
2840
3618
  }
2841
- function unknownEnvironment(config, environment) {
2842
- return {
2843
- declared: Object.keys(config.environments),
2844
- environment,
2845
- kind: "unknownEnvironment"
2846
- };
2847
- }
2848
3619
  //#endregion
2849
3620
  //#region src/core/validate-plan.ts
2850
3621
  /**
@@ -2907,6 +3678,8 @@ function unknownEnvironment(config, environment) {
2907
3678
  * ```
2908
3679
  */
2909
3680
  function validatePlan(desired, current) {
3681
+ const collision = detectProductNameCollision(desired);
3682
+ if (collision !== void 0) return collision;
2910
3683
  const currentByKey = new Map(current.map((entry) => [compositeKey(entry), entry]));
2911
3684
  for (const entry of desired) {
2912
3685
  const matched = currentByKey.get(compositeKey(entry));
@@ -2922,136 +3695,116 @@ function validatePlan(desired, current) {
2922
3695
  function compositeKey(resource) {
2923
3696
  return `${resource.kind}:${resource.key}`;
2924
3697
  }
3698
+ function detectProductNameCollision(desired) {
3699
+ const seenByName = /* @__PURE__ */ new Map();
3700
+ for (const entry of desired) {
3701
+ if (entry.kind !== "developerProduct") continue;
3702
+ const prior = seenByName.get(entry.name);
3703
+ if (prior === void 0) {
3704
+ seenByName.set(entry.name, entry.key);
3705
+ continue;
3706
+ }
3707
+ return {
3708
+ err: {
3709
+ keys: [prior, entry.key],
3710
+ kind: "redactedNameCollision",
3711
+ message: `developer products '${prior}' and '${entry.key}' both resolve to the wire name '${entry.name}'. Roblox enforces per-universe uniqueness on developer-product names, so the second update would be rejected as DuplicateProductName. Set 'redacted: { name: "<unique>" }' on one of them to disambiguate.`,
3712
+ resolvedName: entry.name
3713
+ },
3714
+ success: false
3715
+ };
3716
+ }
3717
+ }
2925
3718
  //#endregion
2926
3719
  //#region src/shell/apply-ops.ts
2927
3720
  /**
2928
- * Dispatch each reconciliation operation to the matching resource driver
2929
- * with first-fail semantics: on the first `Err` (driver failure or
2930
- * `updateUnsupported`), the remaining operations are skipped and the error
2931
- * is returned verbatim.
3721
+ * Dispatch reconciliation operations to their matching drivers in two phases
3722
+ * with continue-on-failure semantics. Phase 1 runs universe ops sequentially
3723
+ * (singleton per environment; sequencing it before everything else avoids the
3724
+ * `displayName` race against the root `Place`). Phase 2 dispatches every
3725
+ * remaining non-noop op concurrently via `Promise.all`; every op is
3726
+ * attempted regardless of earlier failures.
2932
3727
  *
2933
3728
  * Behaviour:
2934
- * - `create` operations are routed to `registry[op.desired.kind].create`.
2935
- * - `update` operations are routed to `registry[op.desired.kind].update`
2936
- * when the driver exposes it; otherwise they short-circuit to an
2937
- * `updateUnsupported` Err without invoking the driver.
3729
+ * - `create` operations route to `registry[op.desired.kind].create`.
3730
+ * - `update` operations route to `registry[op.desired.kind].update` when the
3731
+ * driver exposes it; otherwise they yield an `updateUnsupported`
3732
+ * `ApplyError` without invoking the driver.
2938
3733
  * - `noop` operations are skipped entirely (no I/O, no dispatch).
2939
- *
2940
- * On success the returned array carries the driver outputs for every
2941
- * non-noop op, in dispatched order. Noops are not represented; callers
2942
- * needing a full post-apply snapshot merge with the pre-apply current
2943
- * state keyed by `ResourceKey`.
2944
- *
2945
- * @param ops - Reconciliation operations produced by `diff`, applied in order.
2946
- * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
2947
- * @returns `Ok(state)` when every operation succeeds, where `state` holds
2948
- * driver outputs for each non-noop op in dispatched order; or the first
2949
- * failure encountered.
2950
- * @throws Whatever the dispatched driver rejects with outside its `Result`
2951
- * return. A driver whose injected I/O (file reads, network calls, etc.)
2952
- * throws will surface that rejection here rather than translating it into
2953
- * a `Result` failure; wrap the call site in a try/catch when drivers are
2954
- * not trusted to contain their own rejections.
3734
+ * - A driver that throws outside its `Result` contract is caught at the
3735
+ * dispatch boundary and translated to an `unexpectedThrow` `ApplyError`
3736
+ * scoped to that op alone; the rest of the batch keeps running.
3737
+ *
3738
+ * On Ok the returned array carries driver outputs for every non-noop op
3739
+ * in phase order: Phase 1 universe entries first, then Phase 2 entries in
3740
+ * their input order. Noops are not represented; callers needing a full
3741
+ * post-apply snapshot merge with the pre-apply current state keyed by
3742
+ * `ResourceKey`.
3743
+ *
3744
+ * On Err the aggregate carries every survivor in `applied` (Phase 1 first,
3745
+ * then Phase 2 input order) and every failure in `failures` with the same
3746
+ * grouping. Neither array reflects completion order.
3747
+ *
3748
+ * @param ops - Reconciliation operations produced by `diff`, applied in
3749
+ * declaration order.
3750
+ * @param registry - Per-kind driver table; dispatch uses `op.desired.kind`
3751
+ * as the index.
3752
+ * @param reporting - Optional progress wiring. When supplied, `applyOps`
3753
+ * emits one `resourceOpStarted` and one terminal event per non-noop op,
3754
+ * one `resourceOpNoop` per noop op, and a final `applySummary` carrying
3755
+ * the per-type counts and the wall-clock apply duration. When omitted,
3756
+ * no events fire.
3757
+ * @returns `Ok(state)` when every op succeeded; otherwise
3758
+ * `Err(AggregateApplyError)` with the survivors and the non-empty
3759
+ * failures tuple.
2955
3760
  * @example
2956
3761
  *
2957
3762
  * ```ts
2958
- * import {
2959
- * applyOps,
2960
- * asResourceKey,
2961
- * asRobloxAssetId,
2962
- * asSha256Hex,
2963
- * type DriverRegistry,
2964
- * type Operation,
2965
- * } from "@bedrock-rbx/core";
3763
+ * import { applyOps, type DriverRegistry } from "@bedrock-rbx/core";
2966
3764
  *
2967
- * const registry: DriverRegistry = {
2968
- * gamePass: {
2969
- * async create(desired) {
2970
- * return {
2971
- * data: {
2972
- * ...desired,
2973
- * outputs: {
2974
- * assetId: asRobloxAssetId("9876543210"),
2975
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
2976
- * },
2977
- * },
2978
- * success: true,
2979
- * };
2980
- * },
2981
- * },
2982
- * place: {
2983
- * async create(desired) {
2984
- * return {
2985
- * data: { ...desired, outputs: { versionNumber: 1 } },
2986
- * success: true,
2987
- * };
2988
- * },
2989
- * },
2990
- * universe: {
2991
- * async create(desired) {
2992
- * return {
2993
- * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
2994
- * success: true,
2995
- * };
2996
- * },
2997
- * },
2998
- * developerProduct: {
2999
- * async create(desired) {
3000
- * return {
3001
- * data: {
3002
- * ...desired,
3003
- * outputs: { productId: asRobloxAssetId("8172635495") },
3004
- * },
3005
- * success: true,
3006
- * };
3007
- * },
3008
- * },
3765
+ * const noopRegistry: DriverRegistry = {
3766
+ * developerProduct: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3767
+ * gamePass: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3768
+ * place: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3769
+ * universe: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3009
3770
  * };
3010
3771
  *
3011
- * const ops: ReadonlyArray<Operation> = [
3012
- * {
3013
- * key: asResourceKey("vip-pass"),
3014
- * type: "create",
3015
- * desired: {
3016
- * key: asResourceKey("vip-pass"),
3017
- * name: "VIP Pass",
3018
- * description: "Grants VIP perks.",
3019
- * icon: { "en-us": "assets/vip-icon.png" },
3020
- * iconFileHashes: {
3021
- * "en-us": asSha256Hex(
3022
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3023
- * ),
3024
- * },
3025
- * kind: "gamePass",
3026
- * price: 500,
3027
- * },
3028
- * },
3029
- * ];
3030
- *
3031
- * return applyOps(ops, registry).then((result) => {
3032
- * expect(result.success).toBe(true);
3033
- * expect(result.success && result.data).toHaveLength(1);
3772
+ * return applyOps([], noopRegistry).then((result) => {
3773
+ * expect(result).toStrictEqual({ data: [], success: true });
3034
3774
  * });
3035
3775
  * ```
3036
3776
  */
3037
- async function applyOps(ops, registry) {
3038
- const applied = [];
3039
- for (const op of ops) {
3040
- if (op.type === "noop") continue;
3041
- const outcome = await dispatchOp(op, registry);
3042
- if (!outcome.success) return {
3043
- err: {
3044
- ...outcome.err,
3045
- appliedSoFar: applied
3046
- },
3047
- success: false
3048
- };
3049
- applied.push(outcome.data);
3050
- }
3051
- return {
3777
+ async function applyOps(ops, registry, reporting) {
3778
+ const start = Date.now();
3779
+ const { noopCount, phase1, phase2 } = partitionAndEmitNoops(ops, reporting);
3780
+ const pairs = await dispatchInPhases({
3781
+ phase1,
3782
+ phase2,
3783
+ registry,
3784
+ reporting
3785
+ });
3786
+ const end = Date.now();
3787
+ const { applied, failures } = partitionOutcomes(pairs.map((pair) => pair.outcome));
3788
+ emitApplySummary({
3789
+ end,
3790
+ failures,
3791
+ noopCount,
3792
+ pairs,
3793
+ reporting,
3794
+ start
3795
+ });
3796
+ const [head, ...tail] = failures;
3797
+ if (head === void 0) return {
3052
3798
  data: applied,
3053
3799
  success: true
3054
3800
  };
3801
+ return {
3802
+ err: {
3803
+ applied,
3804
+ failures: [head, ...tail]
3805
+ },
3806
+ success: false
3807
+ };
3055
3808
  }
3056
3809
  function driverFailure(key, cause) {
3057
3810
  return {
@@ -3085,7 +3838,7 @@ async function applyOne(op, driver) {
3085
3838
  const updated = await driver.update(op.current, op.desired);
3086
3839
  return updated.success ? updated : driverFailure(op.key, updated.err);
3087
3840
  }
3088
- async function dispatchOp(op, registry) {
3841
+ async function dispatchByKind(op, registry) {
3089
3842
  switch (op.desired.kind) {
3090
3843
  case "developerProduct": return applyOne(op, registry.developerProduct);
3091
3844
  case "gamePass": return applyOne(op, registry.gamePass);
@@ -3093,6 +3846,161 @@ async function dispatchOp(op, registry) {
3093
3846
  case "universe": return applyOne(op, registry.universe);
3094
3847
  }
3095
3848
  }
3849
+ async function dispatchOp(op, registry) {
3850
+ try {
3851
+ return await dispatchByKind(op, registry);
3852
+ } catch (err) {
3853
+ return {
3854
+ err: {
3855
+ key: op.key,
3856
+ cause: err,
3857
+ kind: "unexpectedThrow"
3858
+ },
3859
+ success: false
3860
+ };
3861
+ }
3862
+ }
3863
+ function createSucceededEvent(input) {
3864
+ const { key, environment, state } = input;
3865
+ switch (state.kind) {
3866
+ case "developerProduct": return {
3867
+ key,
3868
+ environment,
3869
+ kind: "resourceOpSucceeded",
3870
+ opType: "create",
3871
+ outputs: state.outputs,
3872
+ resourceKind: "developerProduct"
3873
+ };
3874
+ case "gamePass": return {
3875
+ key,
3876
+ environment,
3877
+ kind: "resourceOpSucceeded",
3878
+ opType: "create",
3879
+ outputs: state.outputs,
3880
+ resourceKind: "gamePass"
3881
+ };
3882
+ case "place": return {
3883
+ key,
3884
+ environment,
3885
+ kind: "resourceOpSucceeded",
3886
+ opType: "create",
3887
+ outputs: state.outputs,
3888
+ resourceKind: "place"
3889
+ };
3890
+ case "universe": return {
3891
+ key,
3892
+ environment,
3893
+ kind: "resourceOpSucceeded",
3894
+ opType: "create",
3895
+ outputs: state.outputs,
3896
+ resourceKind: "universe"
3897
+ };
3898
+ }
3899
+ }
3900
+ function toTerminalEvent(input) {
3901
+ const { environment, op, outcome } = input;
3902
+ if (!outcome.success) return {
3903
+ key: op.key,
3904
+ environment,
3905
+ error: outcome.err,
3906
+ kind: "resourceOpFailed",
3907
+ opType: op.type,
3908
+ resourceKind: op.desired.kind
3909
+ };
3910
+ if (op.type === "update") return {
3911
+ key: op.key,
3912
+ changedFields: op.changedFields,
3913
+ environment,
3914
+ kind: "resourceOpSucceeded",
3915
+ opType: "update",
3916
+ resourceKind: op.desired.kind
3917
+ };
3918
+ return createSucceededEvent({
3919
+ key: op.key,
3920
+ environment,
3921
+ state: outcome.data
3922
+ });
3923
+ }
3924
+ async function reportAndDispatch(input) {
3925
+ const { op, registry, reporting } = input;
3926
+ if (reporting !== void 0) reporting.progress.emit({
3927
+ key: op.key,
3928
+ environment: reporting.environment,
3929
+ kind: "resourceOpStarted",
3930
+ opType: op.type,
3931
+ resourceKind: op.desired.kind
3932
+ });
3933
+ const outcome = await dispatchOp(op, registry);
3934
+ if (reporting !== void 0) reporting.progress.emit(toTerminalEvent({
3935
+ environment: reporting.environment,
3936
+ op,
3937
+ outcome
3938
+ }));
3939
+ return {
3940
+ op,
3941
+ outcome
3942
+ };
3943
+ }
3944
+ async function dispatchInPhases(input) {
3945
+ const phase1Pairs = [];
3946
+ for (const op of input.phase1) phase1Pairs.push(await reportAndDispatch({
3947
+ op,
3948
+ registry: input.registry,
3949
+ reporting: input.reporting
3950
+ }));
3951
+ const phase2Pairs = await Promise.all(input.phase2.map(async (op) => {
3952
+ return reportAndDispatch({
3953
+ op,
3954
+ registry: input.registry,
3955
+ reporting: input.reporting
3956
+ });
3957
+ }));
3958
+ return [...phase1Pairs, ...phase2Pairs];
3959
+ }
3960
+ function emitApplySummary(input) {
3961
+ if (input.reporting === void 0) return;
3962
+ const created = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "create").length;
3963
+ const updated = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "update").length;
3964
+ input.reporting.progress.emit({
3965
+ created,
3966
+ durationMs: input.end - input.start,
3967
+ environment: input.reporting.environment,
3968
+ failed: input.failures.length,
3969
+ kind: "applySummary",
3970
+ noop: input.noopCount,
3971
+ updated
3972
+ });
3973
+ }
3974
+ function partitionOutcomes(outcomes) {
3975
+ return {
3976
+ applied: outcomes.flatMap((outcome) => outcome.success ? [outcome.data] : []),
3977
+ failures: outcomes.flatMap((outcome) => outcome.success ? [] : [outcome.err])
3978
+ };
3979
+ }
3980
+ function emitNoop(op, reporting) {
3981
+ if (reporting === void 0) return;
3982
+ reporting.progress.emit({
3983
+ key: op.key,
3984
+ environment: reporting.environment,
3985
+ kind: "resourceOpNoop",
3986
+ resourceKind: op.kind
3987
+ });
3988
+ }
3989
+ function partitionAndEmitNoops(ops, reporting) {
3990
+ const phase1 = [];
3991
+ const phase2 = [];
3992
+ let noopCount = 0;
3993
+ for (const op of ops) if (op.type === "noop") {
3994
+ noopCount += 1;
3995
+ emitNoop(op, reporting);
3996
+ } else if (op.desired.kind === "universe") phase1.push(op);
3997
+ else phase2.push(op);
3998
+ return {
3999
+ noopCount,
4000
+ phase1,
4001
+ phase2
4002
+ };
4003
+ }
3096
4004
  //#endregion
3097
4005
  //#region src/shell/build-default-registry.ts
3098
4006
  /**
@@ -3816,6 +4724,7 @@ async function resolveDeps(options) {
3816
4724
  return {
3817
4725
  data: {
3818
4726
  config: effective,
4727
+ progress: options.progress,
3819
4728
  readFile: readFile$2,
3820
4729
  registry: registry.data,
3821
4730
  statePort: statePort.data
@@ -3830,7 +4739,7 @@ function mergeResources(pre, applied) {
3830
4739
  return [...byKey.values()];
3831
4740
  }
3832
4741
  function buildSnapshot(inputs) {
3833
- const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.appliedSoFar;
4742
+ const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.applied;
3834
4743
  return {
3835
4744
  environment: inputs.environment,
3836
4745
  resources: mergeResources(inputs.priorResources, appliedResources),
@@ -3838,13 +4747,6 @@ function buildSnapshot(inputs) {
3838
4747
  };
3839
4748
  }
3840
4749
  function finalize(inputs) {
3841
- if (!inputs.applied.success) return {
3842
- err: {
3843
- cause: inputs.applied.err,
3844
- kind: "applyFailed"
3845
- },
3846
- success: false
3847
- };
3848
4750
  if (!inputs.written.success) return {
3849
4751
  err: {
3850
4752
  cause: inputs.written.err,
@@ -3853,6 +4755,13 @@ function finalize(inputs) {
3853
4755
  },
3854
4756
  success: false
3855
4757
  };
4758
+ if (!inputs.applied.success) return {
4759
+ err: {
4760
+ cause: inputs.applied.err,
4761
+ kind: "applyFailed"
4762
+ },
4763
+ success: false
4764
+ };
3856
4765
  return {
3857
4766
  data: inputs.merged,
3858
4767
  success: true
@@ -3884,16 +4793,24 @@ async function runReconcile(environment, deps) {
3884
4793
  },
3885
4794
  success: false
3886
4795
  };
3887
- const applied = await applyOps(diff(desired.data, priorResources), deps.registry);
4796
+ const applied = await applyOps(diff(desired.data, priorResources), deps.registry, deps.progress === void 0 ? void 0 : {
4797
+ environment,
4798
+ progress: deps.progress
4799
+ });
3888
4800
  const merged = buildSnapshot({
3889
4801
  applied,
3890
4802
  environment,
3891
4803
  priorResources
3892
4804
  });
4805
+ const written = await deps.statePort.write(merged);
4806
+ if (written.success) deps.progress?.emit({
4807
+ environment,
4808
+ kind: "stateWritten"
4809
+ });
3893
4810
  return finalize({
3894
4811
  applied,
3895
4812
  merged,
3896
- written: await deps.statePort.write(merged)
4813
+ written
3897
4814
  });
3898
4815
  }
3899
4816
  //#endregion
@@ -4462,6 +5379,14 @@ function buildRootPasses(primaryFold) {
4462
5379
  if (primaryFold.passes.length === 0) return;
4463
5380
  return Object.fromEntries(primaryFold.passes.map(({ key, entry }) => [key, entry]));
4464
5381
  }
5382
+ function buildFullPassOverlay(entry) {
5383
+ return {
5384
+ name: entry.name,
5385
+ description: entry.description,
5386
+ icon: entry.icon,
5387
+ ...entry.price !== void 0 && { price: entry.price }
5388
+ };
5389
+ }
4465
5390
  function buildPassOverlayEntry(entry, primary) {
4466
5391
  const overlay = {};
4467
5392
  if (!Object.is(primary.name, entry.name)) overlay.name = entry.name;
@@ -4475,7 +5400,7 @@ function buildPassesOverlay(fold, primary) {
4475
5400
  const overlay = {};
4476
5401
  for (const { key, entry } of fold.passes) {
4477
5402
  const primaryEntry = primaryByKey.get(key);
4478
- const passOverlay = primaryEntry === void 0 ? { ...entry } : buildPassOverlayEntry(entry, primaryEntry);
5403
+ const passOverlay = primaryEntry === void 0 ? buildFullPassOverlay(entry) : buildPassOverlayEntry(entry, primaryEntry);
4479
5404
  if (passOverlay !== void 0) overlay[key] = passOverlay;
4480
5405
  }
4481
5406
  return Object.keys(overlay).length === 0 ? void 0 : overlay;
@@ -5008,7 +5933,7 @@ const PRODUCT_ICON_KIND = "productIcon";
5008
5933
  * and the Roblox-assigned `iconImageAssetId` lands on the outputs.
5009
5934
  *
5010
5935
  * Resources whose payload is malformed (non-object, missing required string
5011
- * field, missing `productId`, malformed `fileHash`) are dropped silently.
5936
+ * field, missing `assetId`, malformed `fileHash`) are dropped silently.
5012
5937
  * Orphan `productIcon_<k>` resources (no matching product) emit one
5013
5938
  * `ambiguous` warning each.
5014
5939
  *
@@ -5071,7 +5996,7 @@ function readProductInputs(raw) {
5071
5996
  }
5072
5997
  function readProductOutputs(raw) {
5073
5998
  if (!isObjectPayload$1(raw)) return;
5074
- const productId = coerceRobloxId$2(raw["productId"]);
5999
+ const productId = coerceRobloxId$2(raw["assetId"]);
5075
6000
  if (productId === void 0) return;
5076
6001
  return { productId };
5077
6002
  }
@@ -6038,6 +6963,6 @@ function isFileMissing(err) {
6038
6963
  return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" && FILE_MISSING_CODES.has(err.code);
6039
6964
  }
6040
6965
  //#endregion
6041
- export { shouldReuploadIcon as A, renderDeployError as B, UNIVERSE_SINGLETON_KEY as C, validateEnvironmentName as D, serializeStateFile as E, isRobloxAssetId as F, renderStateWriteError as G, renderMigrateParseError as H, isSha256Hex as I, derivePriceFields as L, asRobloxAssetId as M, asSha256Hex as N, createGamePassDriver as O, isResourceKey as P, createClackProgressAdapter as R, SOCIAL_LINK_FIELDS as S, parseStateFile as T, renderMigrationSummary as U, renderMigrateError as V, renderParseError as W, isGistStateConfig as _, buildStatePort as a, createUniverseDriver as b, applyOps as c, resolveStateConfig as d, flattenConfig as f, defaultKindRegistry as g, diff as h, loadConfig$1 as i, asResourceKey as j, createDeveloperProductDriver as k, validatePlan as l, renderDisplayNamePrefix as m, serializeConfig as n, buildDesired as o, DEFAULT_PREFIX_FORMAT as p, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, selectEnvironment as u, validateConfig as v, createGistStateAdapter as w, createPlaceDriver as x, createClackPort as y, renderBuildStatePortError as z };
6966
+ export { createClackProgressAdapter as A, isSha256Hex as B, UNIVERSE_SINGLETON_KEY as C, createGamePassDriver as D, serializeStateFile as E, asResourceKey as F, renderMigrateParseError as G, renderBuildStatePortError as H, asRobloxAssetId as I, renderStateWriteError as J, renderMigrationSummary as K, asSha256Hex as L, validateConfig as M, shouldReuploadIcon as N, createDeveloperProductDriver as O, validateEnvironmentName as P, isResourceKey as R, SOCIAL_LINK_FIELDS as S, parseStateFile as T, renderDeployError as U, resolveStateConfig as V, renderMigrateError as W, diff as _, buildStatePort as a, createUniverseDriver as b, applyOps as c, selectEnvironment as d, selectMergedEnvironment as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, isGistStateConfig as j, derivePriceFields as k, validatePlan as l, flattenConfig as m, serializeConfig as n, buildDesired as o, collectRedactionAnnotations as p, renderParseError as q, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, extractResourceRedaction as u, defaultKindRegistry as v, createGistStateAdapter as w, createPlaceDriver as x, createClackPort as y, isRobloxAssetId as z };
6042
6967
 
6043
- //# sourceMappingURL=migrate-mantle-state-Dkk5zGHw.mjs.map
6968
+ //# sourceMappingURL=migrate-mantle-state-qejWFAR0.mjs.map