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

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.
@@ -1,8 +1,10 @@
1
+ import { cancel, intro, log, outro } from "@clack/prompts";
1
2
  import { ApiError, PermissionError } from "@bedrock-rbx/ocale";
2
3
  import { ArkErrors, type } from "arktype";
3
- import { cancel, intro, log, outro } from "@clack/prompts";
4
+ import { execFile, spawn } from "node:child_process";
4
5
  import process from "node:process";
5
6
  import { defu } from "defu";
7
+ import { createHash } from "node:crypto";
6
8
  import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
7
9
  import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
8
10
  import { PlacesClient } from "@bedrock-rbx/ocale/places";
@@ -11,21 +13,69 @@ import { readFile } from "node:fs/promises";
11
13
  import { loadConfig } from "c12";
12
14
  import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
13
15
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
14
- import { execFile } from "node:child_process";
15
16
  import { tmpdir } from "node:os";
16
17
  import { parseYAML, stringifyYAML } from "confbox";
18
+ //#region src/cli/clack-port.ts
19
+ /**
20
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
21
+ * resulting port writes to `process.stdout` via clack's defaults. Kept in
22
+ * its own module so consumers that never need the clack-backed rendering
23
+ * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
24
+ * into their bundle.
25
+ *
26
+ * @example
27
+ *
28
+ * ```ts
29
+ * import { createClackPort } from "@bedrock-rbx/core";
30
+ *
31
+ * const port = createClackPort();
32
+ *
33
+ * expect(typeof port.logSuccess).toBe("function");
34
+ * ```
35
+ *
36
+ * @returns A port whose six methods each invoke the matching clack helper.
37
+ */
38
+ function createClackPort() {
39
+ return {
40
+ cancel: (message) => {
41
+ cancel(message);
42
+ },
43
+ intro: (message) => {
44
+ intro(message);
45
+ },
46
+ logError: (message) => {
47
+ log.error(message);
48
+ },
49
+ logMessage: (message) => {
50
+ log.message(message);
51
+ },
52
+ logSuccess: (message) => {
53
+ log.success(message);
54
+ },
55
+ outro: (message) => {
56
+ outro(message);
57
+ }
58
+ };
59
+ }
60
+ //#endregion
17
61
  //#region src/cli/render.ts
18
62
  /**
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.
63
+ * Render a `DeployError` to the supplied `ClackPort`. Most variants emit a
64
+ * single error line; `applyFailed` emits one line per failing op in the
65
+ * aggregate (in Phase 1 then Phase 2 input order). Wrapped variants
66
+ * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`,
67
+ * `stateReadFailed`, `stateWriteFailed`) surface the inner cause's
68
+ * actionable detail (file path, resource key, parser message, HTTP failure,
69
+ * validator issue) so the reader does not have to inspect the full cause to
70
+ * act.
25
71
  * @param err - The deploy error to describe.
26
72
  * @param port - The output port the diagnostic is written to.
27
73
  */
28
74
  function renderDeployError(err, port) {
75
+ if (err.kind === "applyFailed") {
76
+ for (const failure of err.cause.failures) port.logError(`apply failed for '${failure.key}': ${applyCauseDetail(failure)}`);
77
+ return;
78
+ }
29
79
  port.logError(deployErrorMessage(err));
30
80
  }
31
81
  /**
@@ -39,6 +89,30 @@ function renderParseError(err, port) {
39
89
  port.logError(parseErrorMessage(err));
40
90
  }
41
91
  /**
92
+ * Render a `SpawnOverrideError` to the supplied `ClackPort` as a single
93
+ * error line that names the environment alongside the failure mode. On
94
+ * `launchFailed` the child never produced output of its own, so the parent
95
+ * carries the diagnostic; on `nonZeroExit` the parent's line attributes the
96
+ * exit code to a specific environment when several spawns are running.
97
+ * @param input - Environment + spawn-override error to describe.
98
+ * @param port - The output port the diagnostic is written to.
99
+ */
100
+ function renderOverrideError(input, port) {
101
+ port.logError(overrideErrorMessage(input));
102
+ }
103
+ /**
104
+ * Render the failure surfaced when override discovery throws a non-absence
105
+ * filesystem error (for example `EACCES` on a `.bedrock/<command>.ts` that
106
+ * exists but cannot be read). Discovery refuses to fall through to the
107
+ * built-in path in that case, so the CLI reports the cause and exits rather
108
+ * than crashing on the unhandled throw.
109
+ * @param error - The value thrown during override discovery.
110
+ * @param port - The output port the diagnostic is written to.
111
+ */
112
+ function renderOverrideDiscoveryError(error, port) {
113
+ port.logError(`override discovery failed: ${safeStringify(error)}`);
114
+ }
115
+ /**
42
116
  * Render a `ParseMigrateError` to the supplied `ClackPort`. Reuses
43
117
  * `parseErrorMessage` for the three flag-shape variants and adds a
44
118
  * dedicated message for `unknownSource` listing the supported sources.
@@ -110,18 +184,31 @@ function permissionDetail(err) {
110
184
  const scopeList = err.requiredScopes.map((scope) => `'${scope}'`).join(", ");
111
185
  return `${err.message} on ${err.operationKey}: missing required ${label} ${scopeList}. Grant ${pronoun} on the API key at https://create.roblox.com/credentials`;
112
186
  }
187
+ function safeStringify(value) {
188
+ if (value instanceof Error) return value.message;
189
+ try {
190
+ return String(value);
191
+ } catch {
192
+ return "<unprintable cause>";
193
+ }
194
+ }
113
195
  function applyCauseDetail(cause) {
114
196
  switch (cause.kind) {
115
197
  case "driverFailure":
116
198
  if (cause.cause instanceof PermissionError) return permissionDetail(cause.cause);
117
199
  return cause.cause.message;
200
+ case "unexpectedThrow": return `unexpected error: ${safeStringify(cause.cause)}`;
118
201
  case "updateUnsupported": return "update not supported";
119
202
  }
120
203
  }
121
204
  function buildDesiredDetail(cause) {
122
205
  switch (cause.kind) {
123
- case "fileReadFailed": return `(${cause.filePath}): ${cause.reason}`;
124
- case "iconRemovalRejected": return `: ${cause.message}`;
206
+ case "fileReadFailed": return `for '${cause.key}' (${cause.filePath}): ${cause.reason}`;
207
+ case "iconRemovalRejected": return `for '${cause.key}': ${cause.message}`;
208
+ case "redactedNameCollision": {
209
+ const [first, second] = cause.keys;
210
+ return `for '${first}' and '${second}': ${cause.message}`;
211
+ }
125
212
  }
126
213
  }
127
214
  function configErrorDetail(err) {
@@ -141,8 +228,7 @@ function stateErrorDetail(cause) {
141
228
  }
142
229
  function deployErrorMessage(err) {
143
230
  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)}`;
231
+ case "buildDesiredFailed": return `build desired state failed ${buildDesiredDetail(err.cause)}`;
146
232
  case "configLoadFailed": return `config load failed: ${configErrorDetail(err.cause)}`;
147
233
  case "incompletePassEntry": return `pass '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
148
234
  case "incompletePlaceEntry": return `place '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
@@ -163,6 +249,11 @@ function parseErrorMessage(err) {
163
249
  case "unknownFlag": return `unknown flag '--${err.flag}'`;
164
250
  }
165
251
  }
252
+ function overrideErrorMessage(input) {
253
+ const { environment, err } = input;
254
+ if (err.kind === "launchFailed") return `${environment}: failed to launch override - ${err.cause.message}`;
255
+ return `${environment}: override exited with code ${String(err.exitCode)}`;
256
+ }
166
257
  function migrateParseErrorMessage(err) {
167
258
  if (err.kind === "unknownSource") return `unknown migration source '${err.received}' (supported: ${err.supported.join(", ")})`;
168
259
  return parseErrorMessage(err);
@@ -184,77 +275,54 @@ function buildStatePortErrorMessage(err) {
184
275
  }
185
276
  }
186
277
  //#endregion
187
- //#region src/adapters/clack-progress-adapter.ts
278
+ //#region src/core/resolve-state-config.ts
188
279
  /**
189
- * Build a {@link ProgressPort} that renders events through a `ClackPort`.
190
- * Pattern-matches on the event `kind`: `deploySuccess` becomes a single
191
- * success line and `deployFailure` delegates to the package's deploy-error
192
- * rendering helper.
280
+ * Pick the `StateConfig` that applies to `environment`. Per-environment
281
+ * overrides win over the root block; if neither is present, returns
282
+ * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
283
+ * error instead of silently falling back.
193
284
  *
285
+ * @param config - Validated project config.
286
+ * @param environment - Target environment name.
287
+ * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
288
+ * neither the environment override nor the root block is set.
194
289
  * @example
195
290
  *
196
291
  * ```ts
197
- * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
198
- *
199
- * const lines: Array<string> = [];
200
- * const clack: ClackPort = {
201
- * cancel: (message) => lines.push(`cancel: ${message}`),
202
- * intro: (message) => lines.push(`intro: ${message}`),
203
- * logError: (message) => lines.push(`error: ${message}`),
204
- * logMessage: (message) => lines.push(`log: ${message}`),
205
- * logSuccess: (message) => lines.push(`ok: ${message}`),
206
- * outro: (message) => lines.push(`outro: ${message}`),
207
- * };
208
- *
209
- * const port = createClackProgressAdapter({ clack });
210
- *
211
- * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 });
212
- *
213
- * expect(lines).toEqual(["ok: production: 3 resources reconciled"]);
214
- * ```
215
- *
216
- * @param deps - The clack port the adapter renders through.
217
- * @returns A `ProgressPort` that renders via clack.
218
- */
219
- function createClackProgressAdapter(deps) {
220
- const { clack } = deps;
221
- return { emit(event) {
222
- switch (event.kind) {
223
- case "deployFailure":
224
- renderDeployError(event.error, clack);
225
- return;
226
- case "deploySuccess": clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
227
- }
228
- } };
229
- }
230
- //#endregion
231
- //#region src/core/derive-price-fields.ts
232
- /**
233
- * Translate a Mantle-style optional price into the Open Cloud wire shape.
234
- *
235
- * `desired.price === undefined` (no price declared) becomes
236
- * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
237
- * price (including `0`) becomes `{ isForSale: true, price }`. Both
238
- * `developerProduct` create and update paths share this helper so the
239
- * "absent ⇒ off-sale" semantics live in exactly one place.
240
- *
241
- * @param desired - Object carrying the user-declared `price`.
242
- * @returns The wire-shape `{ isForSale, price? }` fragment.
243
- *
244
- * @example
292
+ * import { resolveStateConfig } from "@bedrock-rbx/core";
245
293
  *
246
- * ```ts
247
- * import { derivePriceFields } from "@bedrock-rbx/core";
294
+ * const result = resolveStateConfig(
295
+ * {
296
+ * state: { backend: "gist", gistId: "root-gist" },
297
+ * environments: {
298
+ * production: { state: { backend: "gist", gistId: "prod-gist" } },
299
+ * },
300
+ * },
301
+ * "production",
302
+ * );
248
303
  *
249
- * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
250
- * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
304
+ * expect(result.success).toBeTrue();
305
+ * if (result.success) {
306
+ * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
307
+ * }
251
308
  * ```
252
309
  */
253
- function derivePriceFields(desired) {
254
- if (desired.price === void 0) return { isForSale: false };
310
+ function resolveStateConfig(config, environment) {
311
+ const override = config.environments[environment]?.state;
312
+ if (override !== void 0) return {
313
+ data: override,
314
+ success: true
315
+ };
316
+ if (config.state !== void 0) return {
317
+ data: config.state,
318
+ success: true
319
+ };
255
320
  return {
256
- isForSale: true,
257
- price: desired.price
321
+ err: {
322
+ environment,
323
+ kind: "stateNotConfigured"
324
+ },
325
+ success: false
258
326
  };
259
327
  }
260
328
  //#endregion
@@ -442,6 +510,62 @@ function asSha256Hex(raw) {
442
510
  return raw;
443
511
  }
444
512
  //#endregion
513
+ //#region src/core/environment.ts
514
+ /**
515
+ * Source pattern for environment names, including `^` and `$` anchors.
516
+ * Letters, digits, `-`, `_`, length 1-64.
517
+ *
518
+ * Exported so the config schema can validate `environments` keys against
519
+ * the same alphabet and length cap that adapters enforce on storage
520
+ * identifiers. Single source of truth: changing the alphabet here changes
521
+ * both the runtime check and the schema-level key constraint.
522
+ *
523
+ * Anchors are embedded so callers do not have to re-add them, matching
524
+ * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
525
+ */
526
+ const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
527
+ const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
528
+ /**
529
+ * Validate an environment name at a state-adapter boundary.
530
+ *
531
+ * Adapters that map environment names onto filesystem-like identifiers
532
+ * (gist filenames, S3 keys) must reject names that could collide or escape
533
+ * their storage layout. This helper accepts letters, digits, `-`, and `_`
534
+ * only, with length between 1 and 64, and returns a `StateError` for
535
+ * anything outside that set so the adapter can fail loudly instead of
536
+ * silently stripping characters.
537
+ *
538
+ * @example
539
+ *
540
+ * ```ts
541
+ * import { validateEnvironmentName } from "@bedrock-rbx/core";
542
+ *
543
+ * const ok = validateEnvironmentName("production");
544
+ * expect(ok.success).toBeTrue();
545
+ *
546
+ * const bad = validateEnvironmentName("prod/staging");
547
+ * expect(bad.success).toBeFalse();
548
+ * ```
549
+ *
550
+ * @param environment - Raw environment name supplied by a caller.
551
+ * @returns `Ok(environment)` when the name is safe to use, or
552
+ * `Err(StateError)` with a descriptive reason when it is not.
553
+ */
554
+ function validateEnvironmentName(environment) {
555
+ if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
556
+ err: {
557
+ file: environment,
558
+ kind: "stateError",
559
+ reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
560
+ },
561
+ success: false
562
+ };
563
+ return {
564
+ data: environment,
565
+ success: true
566
+ };
567
+ }
568
+ //#endregion
445
569
  //#region src/core/kinds/hash.ts
446
570
  /**
447
571
  * Compute the SHA-256 hex digest of a byte sequence. Shared by kind modules
@@ -838,272 +962,736 @@ function shouldReuploadIcon(currentHashes, desiredHashes) {
838
962
  return !iconHashesEqual(currentHashes, desiredHashes);
839
963
  }
840
964
  //#endregion
841
- //#region src/core/plan-follow-up-patch.ts
965
+ //#region src/core/validate-universe-xor.ts
842
966
  /**
843
- * Plan the optional follow-up PATCH body needed after a developer-product
844
- * create POST. Returns `undefined` when no PATCH is required: either the
845
- * user did not declare `storePageEnabled`, or the create response already
846
- * matches the desired value.
967
+ * Walk the loose authored-shape and surface every place the
968
+ * universeId-XOR-between-root-and-env rule is violated. Pure: returns
969
+ * the issue list; the caller hands it to arktype's `ctx.reject` so each
970
+ * one lands at the offending config path. The schema's runtime narrow
971
+ * uses this to enforce the rule at validation time before the validated
972
+ * value is cast to the strict `Config` discriminated union.
847
973
  *
848
- * @param desired - Desired state for the developer product being created.
849
- * @param createResponse - The `storePageEnabled` value reported by the create POST response.
850
- * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
974
+ * @param value - Parsed config the schema is validating.
975
+ * @returns Zero or more issues. Empty when the config satisfies the rule.
851
976
  */
852
- function planFollowUpPatch(desired, createResponse) {
853
- if (desired.storePageEnabled === void 0) return;
854
- if (desired.storePageEnabled === createResponse.storePageEnabled) return;
855
- return { storePageEnabled: desired.storePageEnabled };
977
+ function collectUniverseIdIssues(value) {
978
+ const rootUniverseId = value.universe?.universeId;
979
+ const hasRootUniverseBlock = value.universe !== void 0;
980
+ const environmentEntries = Object.entries(value.environments);
981
+ const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
982
+ const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
983
+ const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
984
+ message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
985
+ path: ["universe", "universeId"]
986
+ }] : [];
987
+ return [...environmentIssues, ...rootIssues];
856
988
  }
857
- //#endregion
858
- //#region src/adapters/developer-product-driver.ts
859
- /**
860
- * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
861
- * that maps a desired-state entry to an ocale create or update call and the
862
- * response back to a `ResourceCurrentState<"developerProduct">`. The
863
- * `update` path consumes the upstream `204 No Content` response and
864
- * synthesizes the post-update `ResourceCurrentState` from `desired` plus
865
- * the existing `current.outputs`, carrying `iconImageAssetId` forward when
866
- * present.
867
- *
868
- * Upstream `OpenCloudError` results pass through as `Result` failures.
869
- *
870
- * @param deps - Injected ocale client and owning universe.
871
- * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
872
- *
873
- * @example
874
- *
875
- * ```ts
876
- * import type { HttpClient } from "@bedrock-rbx/ocale";
877
- * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
878
- * import {
879
- * asResourceKey,
880
- * asRobloxAssetId,
881
- * createDeveloperProductDriver,
882
- * } from "@bedrock-rbx/core";
883
- *
884
- * const httpClient: HttpClient = {
885
- * async request() {
886
- * return {
887
- * data: {
888
- * body: {
889
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
890
- * description: "Stocks the player up with 1,000 premium gems.",
891
- * iconImageAssetId: null,
892
- * isForSale: false,
893
- * isImmutable: false,
894
- * name: "Gem Pack",
895
- * priceInformation: null,
896
- * productId: 9_876_543_210,
897
- * storePageEnabled: false,
898
- * universeId: 1_234_567_890,
899
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
900
- * },
901
- * headers: {},
902
- * status: 200,
903
- * },
904
- * success: true,
905
- * };
906
- * },
907
- * };
908
- *
909
- * const driver = createDeveloperProductDriver({
910
- * client: new DeveloperProductsClient({
911
- * apiKey: "rbx-your-key",
912
- * httpClient,
913
- * sleep: async () => {},
914
- * }),
915
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
916
- * universeId: asRobloxAssetId("1234567890"),
917
- * });
918
- *
919
- * return driver
920
- * .create({
921
- * description: "Stocks the player up with 1,000 premium gems.",
922
- * isRegionalPricingEnabled: undefined,
923
- * key: asResourceKey("gem-pack"),
924
- * kind: "developerProduct",
925
- * name: "Gem Pack",
926
- * price: undefined,
927
- * storePageEnabled: undefined,
928
- * })
929
- * .then((result) => {
930
- * expect(result.success).toBeTrue();
931
- * if (result.success) {
932
- * expect(result.data.outputs.productId).toBe("9876543210");
933
- * }
934
- * });
935
- * ```
936
- */
937
- function createDeveloperProductDriver(deps) {
938
- const effective = {
939
- ...deps,
940
- readFile: withRedactedIcon(deps.readFile)
941
- };
942
- return {
943
- async create(desired) {
944
- return createOne(effective, desired);
945
- },
946
- async update(current, desired) {
947
- return updateOne(effective, {
948
- current,
949
- desired
950
- });
951
- }
952
- };
953
- }
954
- function toCurrentState$2(desired, data) {
955
- const iconImageAssetId = data.iconImageAssetId === void 0 ? void 0 : asRobloxAssetId(data.iconImageAssetId);
956
- return {
957
- data: {
958
- ...desired,
959
- outputs: {
960
- productId: asRobloxAssetId(data.id),
961
- ...iconImageAssetId === void 0 ? {} : { iconImageAssetId }
962
- }
963
- },
964
- success: true
965
- };
966
- }
967
- async function applyFollowUpPatch(deps, { created, desired }) {
968
- const followUp = planFollowUpPatch(desired, created);
969
- if (followUp === void 0) return toCurrentState$2(desired, created);
970
- if ((await deps.client.update({
971
- productId: asRobloxAssetId(created.id),
972
- universeId: deps.universeId,
973
- ...followUp
974
- })).success) return toCurrentState$2(desired, created);
975
- return toCurrentState$2({
976
- ...desired,
977
- storePageEnabled: created.storePageEnabled
978
- }, created);
979
- }
980
- async function createOne(deps, desired) {
981
- const imageFile = desired.icon === void 0 ? void 0 : await deps.readFile(desired.icon["en-us"]);
982
- const created = await deps.client.create({
983
- name: desired.name,
984
- description: desired.description,
985
- universeId: deps.universeId,
986
- ...imageFile === void 0 ? {} : { imageFile },
987
- ...derivePriceFields(desired),
988
- ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled }
989
- });
990
- if (!created.success) return created;
991
- return applyFollowUpPatch(deps, {
992
- created: created.data,
993
- desired
994
- });
995
- }
996
- async function updateOne(deps, { current, desired }) {
997
- const imageFile = desired.icon !== void 0 && shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes) ? await deps.readFile(desired.icon["en-us"]) : void 0;
998
- const result = await deps.client.update({
999
- name: desired.name,
1000
- description: desired.description,
1001
- productId: current.outputs.productId,
1002
- universeId: deps.universeId,
1003
- ...imageFile === void 0 ? {} : { imageFile },
1004
- ...derivePriceFields(desired),
1005
- ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled },
1006
- ...desired.storePageEnabled === void 0 ? {} : { storePageEnabled: desired.storePageEnabled }
989
+ function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
990
+ return environmentEntries.flatMap(([environmentName, environment]) => {
991
+ if (environment.universe === void 0) return [];
992
+ if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
993
+ 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.",
994
+ path: [
995
+ "environments",
996
+ environmentName,
997
+ "universe",
998
+ "universeId"
999
+ ]
1000
+ }];
1001
+ if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
1002
+ message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
1003
+ path: [
1004
+ "environments",
1005
+ environmentName,
1006
+ "universe",
1007
+ "universeId"
1008
+ ]
1009
+ }];
1010
+ return [];
1007
1011
  });
1008
- if (!result.success) return result;
1009
- return {
1010
- data: {
1011
- ...desired,
1012
- outputs: current.outputs
1013
- },
1014
- success: true
1015
- };
1016
1012
  }
1017
1013
  //#endregion
1018
- //#region src/adapters/game-pass-driver.ts
1014
+ //#region src/core/schema.ts
1019
1015
  /**
1020
- * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
1021
- * a desired-state entry to an ocale create call and the response back to a
1022
- * `ResourceCurrentState<"gamePass">`.
1023
- *
1024
- * Upstream `OpenCloudError` results pass through as `Result` failures.
1025
- * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
1026
- * shape and propagate as promise rejections; shell callers are expected to
1027
- * translate them if a unified error surface is required.
1028
- *
1029
- * @param deps - Injected ocale client, file reader, and owning universe.
1030
- * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
1031
- * @throws Whatever `deps.readFile` rejects with.
1016
+ * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
1017
+ * autocomplete idiom prevents TypeScript from narrowing on
1018
+ * `backend === "gist"` alone, so dispatch sites use this guard to
1019
+ * preserve the `gistId` field shape.
1032
1020
  *
1033
1021
  * @example
1034
1022
  *
1035
1023
  * ```ts
1036
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1037
- * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
1038
- * import {
1039
- * asResourceKey,
1040
- * asRobloxAssetId,
1041
- * asSha256Hex,
1042
- * createGamePassDriver,
1043
- * } from "@bedrock-rbx/core";
1044
- *
1045
- * const httpClient: HttpClient = {
1046
- * async request() {
1047
- * return {
1048
- * data: {
1049
- * body: {
1050
- * createdTimestamp: "2024-01-15T10:30:00.000Z",
1051
- * description: "Grants VIP perks.",
1052
- * gamePassId: 9_876_543_210,
1053
- * iconAssetId: 1_122_334_455,
1054
- * isForSale: true,
1055
- * name: "VIP Pass",
1056
- * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1057
- * },
1058
- * headers: {},
1059
- * status: 200,
1060
- * },
1061
- * success: true,
1062
- * };
1063
- * },
1064
- * };
1024
+ * import { isGistStateConfig } from "@bedrock-rbx/core";
1025
+ * import type { StateConfig } from "@bedrock-rbx/core/config";
1065
1026
  *
1066
- * const driver = createGamePassDriver({
1067
- * client: new GamePassesClient({
1068
- * apiKey: "rbx-your-key",
1069
- * httpClient,
1070
- * sleep: async () => {},
1071
- * }),
1072
- * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1073
- * universeId: asRobloxAssetId("1234567890"),
1074
- * });
1027
+ * const config: StateConfig = { backend: "gist", gistId: "abc" };
1075
1028
  *
1076
- * return driver
1077
- * .create({
1078
- * description: "Grants VIP perks.",
1079
- * icon: { "en-us": "assets/vip-icon.png" },
1080
- * iconFileHashes: {
1081
- * "en-us": asSha256Hex(
1082
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1083
- * ),
1084
- * },
1085
- * key: asResourceKey("vip-pass"),
1086
- * kind: "gamePass",
1087
- * name: "VIP Pass",
1088
- * price: 500,
1089
- * })
1090
- * .then((result) => {
1091
- * expect(result.success).toBeTrue();
1092
- * if (result.success) {
1093
- * expect(result.data.outputs.assetId).toBe("9876543210");
1094
- * }
1095
- * });
1029
+ * expect(isGistStateConfig(config)).toBeTrue();
1030
+ * if (isGistStateConfig(config)) {
1031
+ * expect(config.gistId).toBe("abc");
1032
+ * }
1096
1033
  * ```
1034
+ *
1035
+ * @param config - Resolved state config to inspect.
1036
+ * @returns `true` when `config.backend === "gist"`; otherwise `false`.
1097
1037
  */
1098
- function createGamePassDriver(deps) {
1099
- const effective = {
1100
- ...deps,
1101
- readFile: withRedactedIcon(deps.readFile)
1102
- };
1103
- return {
1104
- async create(desired) {
1105
- return createGamePass(effective, desired);
1106
- },
1038
+ function isGistStateConfig(config) {
1039
+ return config.backend === "gist";
1040
+ }
1041
+ const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
1042
+ const OPTIONAL_STRING = "string | undefined";
1043
+ const REDACTED_KEY = "redacted?";
1044
+ const NON_EMPTY_OVERRIDE_MESSAGE = "a non-empty override object; use `redacted: true` for default placeholders";
1045
+ /**
1046
+ * Shared arktype constraint for any optional positive-integer field.
1047
+ * Reused by per-kind entry schemas so positive-integer fields validate
1048
+ * identically.
1049
+ */
1050
+ const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
1051
+ /**
1052
+ * Shared arktype constraint for any optional Robux-price field. The schema
1053
+ * rejects negatives, fractional values, `NaN`, and `Infinity` at config
1054
+ * validation time so a malformed price surfaces with a path attributing the
1055
+ * failure to the offending field, rather than slipping through to the
1056
+ * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
1057
+ * schemas reuse this constant so all Robux-price fields validate
1058
+ * identically.
1059
+ */
1060
+ const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
1061
+ const gamePassRedacted = type({
1062
+ "description?": "string",
1063
+ "icon?": iconMap,
1064
+ "name?": "string",
1065
+ "price?": OPTIONAL_ROBUX_PRICE
1066
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1067
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1068
+ return true;
1069
+ }).or(OPTIONAL_BOOLEAN$2);
1070
+ const placeRedacted = type({
1071
+ "description?": "string",
1072
+ "displayName?": "string"
1073
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1074
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1075
+ return true;
1076
+ }).or(OPTIONAL_BOOLEAN$2);
1077
+ const productRedacted = type({
1078
+ "description?": "string",
1079
+ "icon?": iconMap,
1080
+ "name?": "string",
1081
+ "price?": OPTIONAL_ROBUX_PRICE
1082
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1083
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1084
+ return true;
1085
+ }).or(OPTIONAL_BOOLEAN$2);
1086
+ const environmentRedacted = type({
1087
+ "description?": "string",
1088
+ "displayName?": "string",
1089
+ "icon?": iconMap,
1090
+ "name?": "string",
1091
+ "price?": OPTIONAL_ROBUX_PRICE
1092
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1093
+ if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
1094
+ return true;
1095
+ }).or(OPTIONAL_BOOLEAN$2);
1096
+ const gamePassEntry = type({
1097
+ "name": "string",
1098
+ "description": "string",
1099
+ "icon": iconMap,
1100
+ "price?": OPTIONAL_ROBUX_PRICE,
1101
+ [REDACTED_KEY]: gamePassRedacted
1102
+ });
1103
+ const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
1104
+ const developerProductEntry = type({
1105
+ "name": "string",
1106
+ "description": "string",
1107
+ "icon?": iconMap,
1108
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1109
+ "price?": OPTIONAL_ROBUX_PRICE,
1110
+ [REDACTED_KEY]: productRedacted,
1111
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1112
+ }).onUndeclaredKey("reject");
1113
+ const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
1114
+ const ROBLOX_ID_DIGITS = "string.digits";
1115
+ const placeEntry = type({
1116
+ "description?": OPTIONAL_STRING,
1117
+ "displayName?": OPTIONAL_STRING,
1118
+ "filePath": "string",
1119
+ [REDACTED_KEY]: placeRedacted,
1120
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1121
+ }).onUndeclaredKey("reject");
1122
+ const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
1123
+ const socialLinkOrUndefined$1 = type({
1124
+ title: "string",
1125
+ uri: "string"
1126
+ }).onUndeclaredKey("reject").or("undefined");
1127
+ const universeEntry = type({
1128
+ "consoleEnabled?": OPTIONAL_BOOLEAN$2,
1129
+ "desktopEnabled?": OPTIONAL_BOOLEAN$2,
1130
+ "discordSocialLink?": socialLinkOrUndefined$1,
1131
+ "displayName?": OPTIONAL_STRING,
1132
+ "facebookSocialLink?": socialLinkOrUndefined$1,
1133
+ "guildedSocialLink?": socialLinkOrUndefined$1,
1134
+ "mobileEnabled?": OPTIONAL_BOOLEAN$2,
1135
+ "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
1136
+ "robloxGroupSocialLink?": socialLinkOrUndefined$1,
1137
+ "tabletEnabled?": OPTIONAL_BOOLEAN$2,
1138
+ "twitchSocialLink?": socialLinkOrUndefined$1,
1139
+ "twitterSocialLink?": socialLinkOrUndefined$1,
1140
+ "universeId?": ROBLOX_ID_DIGITS,
1141
+ "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
1142
+ "vrEnabled?": OPTIONAL_BOOLEAN$2,
1143
+ "youtubeSocialLink?": socialLinkOrUndefined$1
1144
+ }).onUndeclaredKey("reject");
1145
+ const stateConfig = type({
1146
+ "backend": "string",
1147
+ "gistId?": "string > 0"
1148
+ }).onUndeclaredKey("reject");
1149
+ const gamePassOverlay = type({
1150
+ "description?": "string",
1151
+ "icon?": iconMap,
1152
+ "name?": "string",
1153
+ "price?": OPTIONAL_ROBUX_PRICE,
1154
+ [REDACTED_KEY]: gamePassRedacted
1155
+ }).onUndeclaredKey("reject");
1156
+ const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
1157
+ const developerProductOverlay = type({
1158
+ "description?": "string",
1159
+ "icon?": iconMap,
1160
+ "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
1161
+ "name?": "string",
1162
+ "price?": OPTIONAL_ROBUX_PRICE,
1163
+ [REDACTED_KEY]: productRedacted,
1164
+ "storePageEnabled?": OPTIONAL_BOOLEAN$2
1165
+ }).onUndeclaredKey("reject");
1166
+ const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
1167
+ const placeOverlay = type({
1168
+ "description?": OPTIONAL_STRING,
1169
+ "displayName?": OPTIONAL_STRING,
1170
+ "filePath?": "string",
1171
+ "placeId": ROBLOX_ID_DIGITS,
1172
+ [REDACTED_KEY]: placeRedacted,
1173
+ "serverSize?": OPTIONAL_POSITIVE_INTEGER
1174
+ }).onUndeclaredKey("reject");
1175
+ const placesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject");
1176
+ const universeOverlay = universeEntry;
1177
+ const environmentEntry = type({
1178
+ "label?": OPTIONAL_STRING,
1179
+ "passes?": passesOverlayCollection,
1180
+ "places?": placesOverlayCollection,
1181
+ "products?": productsOverlayCollection,
1182
+ [REDACTED_KEY]: environmentRedacted,
1183
+ "state?": stateConfig,
1184
+ "universe?": universeOverlay
1185
+ }).onUndeclaredKey("reject");
1186
+ const rootSchema = type({
1187
+ "displayNamePrefix?": type({
1188
+ "enabled?": OPTIONAL_BOOLEAN$2,
1189
+ "format?": OPTIONAL_STRING
1190
+ }).onUndeclaredKey("reject"),
1191
+ "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
1192
+ if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
1193
+ return true;
1194
+ }),
1195
+ "extends?": "unknown",
1196
+ "passes?": passesCollection,
1197
+ "places?": placesCollection,
1198
+ "products?": productsCollection,
1199
+ "state?": stateConfig,
1200
+ "universe?": universeEntry
1201
+ }).onUndeclaredKey("reject").narrow((value, ctx) => {
1202
+ return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
1203
+ return ctx.reject({
1204
+ message: issue.message,
1205
+ path: [...issue.path]
1206
+ });
1207
+ }, true);
1208
+ });
1209
+ /**
1210
+ * Validate a parsed config value against the runtime schema. Returns the
1211
+ * validated `Config` on success or a `validationFailed` `ConfigError` with
1212
+ * one issue per problem, each attributed to a field path. `sourceFile`
1213
+ * appears in the error so callers can point a human at the offending file.
1214
+ *
1215
+ * @param input - Parsed value from a config source (object tree from a
1216
+ * config loader, or a hand-built literal). Shape is checked, not assumed.
1217
+ * @param sourceFile - Path or identifier of the source file, used in the
1218
+ * `validationFailed` error.
1219
+ * @returns `Ok` with the validated `Config`, or `Err` with a
1220
+ * `validationFailed` error carrying each issue's field path.
1221
+ * @example
1222
+ *
1223
+ * ```ts
1224
+ * import { validateConfig } from "@bedrock-rbx/core";
1225
+ *
1226
+ * const ok = validateConfig(
1227
+ * {
1228
+ * environments: { production: {} },
1229
+ * passes: {
1230
+ * "vip-pass": {
1231
+ * description: "VIP perks.",
1232
+ * icon: { "en-us": "assets/vip.png" },
1233
+ * name: "VIP Pass",
1234
+ * price: 500,
1235
+ * },
1236
+ * },
1237
+ * },
1238
+ * "bedrock.config.ts",
1239
+ * );
1240
+ * expect(ok.success).toBeTrue();
1241
+ *
1242
+ * const err = validateConfig(
1243
+ * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
1244
+ * "bedrock.config.ts",
1245
+ * );
1246
+ * expect(err.success).toBeFalse();
1247
+ * if (!err.success) {
1248
+ * expect(err.err.kind).toBe("validationFailed");
1249
+ * }
1250
+ * ```
1251
+ */
1252
+ function validateConfig(input, sourceFile) {
1253
+ const validated = rootSchema(input);
1254
+ if (validated instanceof ArkErrors) return {
1255
+ err: {
1256
+ issues: Array.from(validated, (issue) => {
1257
+ return {
1258
+ message: issue.message,
1259
+ path: [...issue.path].map((segment) => String(segment))
1260
+ };
1261
+ }),
1262
+ kind: "validationFailed",
1263
+ sourceFile
1264
+ },
1265
+ success: false
1266
+ };
1267
+ return {
1268
+ data: validated,
1269
+ success: true
1270
+ };
1271
+ }
1272
+ //#endregion
1273
+ //#region src/adapters/clack-progress-adapter.ts
1274
+ /**
1275
+ * Build a {@link ProgressPort} that renders events through a `ClackPort`.
1276
+ * Pattern-matches on the event `kind`: per-resource events render one line each,
1277
+ * the aggregate `applySummary` becomes the deploy footer, and `stateWritten`
1278
+ * names the persistence backend resolved from the loaded `Config`.
1279
+ *
1280
+ * @example
1281
+ *
1282
+ * ```ts
1283
+ * import { createClackProgressAdapter, type ClackPort } from "@bedrock-rbx/core";
1284
+ *
1285
+ * const lines: Array<string> = [];
1286
+ * const clack: ClackPort = {
1287
+ * cancel: (message) => lines.push(`cancel: ${message}`),
1288
+ * intro: (message) => lines.push(`intro: ${message}`),
1289
+ * logError: (message) => lines.push(`error: ${message}`),
1290
+ * logMessage: (message) => lines.push(`log: ${message}`),
1291
+ * logSuccess: (message) => lines.push(`ok: ${message}`),
1292
+ * outro: (message) => lines.push(`outro: ${message}`),
1293
+ * };
1294
+ *
1295
+ * const port = createClackProgressAdapter({ clack });
1296
+ *
1297
+ * port.emit({ environment: "production", kind: "stateWritten" });
1298
+ *
1299
+ * expect(lines).toEqual(["log: State written to state"]);
1300
+ * ```
1301
+ *
1302
+ * @param deps - The clack port and optional config the adapter renders through.
1303
+ * @returns A `ProgressPort` that renders via clack.
1304
+ */
1305
+ function createClackProgressAdapter(deps) {
1306
+ return { emit(event) {
1307
+ renderEvent(event, deps);
1308
+ } };
1309
+ }
1310
+ /**
1311
+ * Build a {@link ProgressPort} for the default CLI rendering path: wires a
1312
+ * fresh {@link createClackPort} into {@link createClackProgressAdapter}. The
1313
+ * `config` argument (raw `Config` or env-resolved `ResolvedConfig`) is
1314
+ * forwarded so `stateWritten` events can name the persistence backend; pass
1315
+ * `undefined` when the config has not yet loaded.
1316
+ *
1317
+ * Internal: used by `deploy()`'s default-port resolver when callers omit
1318
+ * `progress` and `BEDROCK_CLI` is set.
1319
+ *
1320
+ * @param config - Pre-loaded or env-resolved config used to format the
1321
+ * state-backend label, or `undefined` to render the generic placeholder.
1322
+ * @returns A clack-backed `ProgressPort` that writes to `process.stdout`.
1323
+ */
1324
+ function createDefaultProgressAdapter(config) {
1325
+ const clack = createClackPort();
1326
+ return config === void 0 ? createClackProgressAdapter({ clack }) : createClackProgressAdapter({
1327
+ clack,
1328
+ config
1329
+ });
1330
+ }
1331
+ function applySummaryLine(event) {
1332
+ return `Succeeded in ${(event.durationMs / 1e3).toFixed(1)}s: ${[
1333
+ `${event.created} create`,
1334
+ `${event.updated} update`,
1335
+ `${event.noop} noop`,
1336
+ `${event.failed} failed`
1337
+ ].join(", ")}`;
1338
+ }
1339
+ function stateConfigLabel(state) {
1340
+ if (isGistStateConfig(state)) return `gist:${state.gistId}`;
1341
+ return state.backend;
1342
+ }
1343
+ function formatStateLabel(config, environment) {
1344
+ if (config === void 0) return "state";
1345
+ const resolved = resolveStateConfig(config, environment);
1346
+ if (!resolved.success) return "state";
1347
+ return stateConfigLabel(resolved.data);
1348
+ }
1349
+ function extractResourceId(event) {
1350
+ switch (event.resourceKind) {
1351
+ case "developerProduct": return event.outputs.productId;
1352
+ case "gamePass": return event.outputs.assetId;
1353
+ case "place": return;
1354
+ case "universe": return event.outputs.rootPlaceId;
1355
+ }
1356
+ }
1357
+ function renderResourceOpSucceeded(event, clack) {
1358
+ if (event.opType === "create") {
1359
+ const id = extractResourceId(event);
1360
+ const suffix = id === void 0 ? "" : ` (id ${id})`;
1361
+ clack.logSuccess(`${event.resourceKind}.${event.key} created${suffix}`);
1362
+ return;
1363
+ }
1364
+ clack.logSuccess(`${event.resourceKind}.${event.key} ${event.changedFields.join(", ")} updated`);
1365
+ }
1366
+ function describeApplyError(error) {
1367
+ switch (error.kind) {
1368
+ case "driverFailure": return `failed: ${error.cause.message}`;
1369
+ case "unexpectedThrow": return "unexpected error";
1370
+ case "updateUnsupported": return "update not supported";
1371
+ }
1372
+ }
1373
+ function renderEvent(event, deps) {
1374
+ const { clack, config } = deps;
1375
+ switch (event.kind) {
1376
+ case "applySummary":
1377
+ clack.logMessage(applySummaryLine(event));
1378
+ return;
1379
+ case "deployFailure":
1380
+ renderDeployError(event.error, clack);
1381
+ return;
1382
+ case "deploySuccess":
1383
+ clack.logSuccess(`${event.environment}: ${event.resourceCount} resources reconciled`);
1384
+ return;
1385
+ case "resourceOpFailed":
1386
+ clack.logError(`${event.resourceKind}.${event.key} ${describeApplyError(event.error)}`);
1387
+ return;
1388
+ case "resourceOpNoop":
1389
+ clack.logMessage(`${event.resourceKind}.${event.key} unchanged`);
1390
+ return;
1391
+ case "resourceOpStarted": return;
1392
+ case "resourceOpSucceeded":
1393
+ renderResourceOpSucceeded(event, clack);
1394
+ return;
1395
+ case "stateWritten": clack.logMessage(`State written to ${formatStateLabel(config, event.environment)}`);
1396
+ }
1397
+ }
1398
+ //#endregion
1399
+ //#region src/core/derive-price-fields.ts
1400
+ /**
1401
+ * Translate a Mantle-style optional price into the Open Cloud wire shape.
1402
+ *
1403
+ * `desired.price === undefined` (no price declared) becomes
1404
+ * `{ isForSale: false }` and the `price` key is omitted entirely. A defined
1405
+ * price (including `0`) becomes `{ isForSale: true, price }`. Both
1406
+ * `developerProduct` create and update paths share this helper so the
1407
+ * "absent ⇒ off-sale" semantics live in exactly one place.
1408
+ *
1409
+ * @param desired - Object carrying the user-declared `price`.
1410
+ * @returns The wire-shape `{ isForSale, price? }` fragment.
1411
+ *
1412
+ * @example
1413
+ *
1414
+ * ```ts
1415
+ * import { derivePriceFields } from "@bedrock-rbx/core";
1416
+ *
1417
+ * expect(derivePriceFields({ price: undefined })).toStrictEqual({ isForSale: false });
1418
+ * expect(derivePriceFields({ price: 250 })).toStrictEqual({ isForSale: true, price: 250 });
1419
+ * ```
1420
+ */
1421
+ function derivePriceFields(desired) {
1422
+ if (desired.price === void 0) return { isForSale: false };
1423
+ return {
1424
+ isForSale: true,
1425
+ price: desired.price
1426
+ };
1427
+ }
1428
+ //#endregion
1429
+ //#region src/core/plan-follow-up-patch.ts
1430
+ /**
1431
+ * Plan the optional follow-up PATCH body needed after a developer-product
1432
+ * create POST. Returns `undefined` when no PATCH is required: either the
1433
+ * user did not declare `storePageEnabled`, or the create response already
1434
+ * matches the desired value.
1435
+ *
1436
+ * @param desired - Desired state for the developer product being created.
1437
+ * @param createResponse - The `storePageEnabled` value reported by the create POST response.
1438
+ * @returns The PATCH body to issue, or `undefined` when no follow-up is needed.
1439
+ */
1440
+ function planFollowUpPatch(desired, createResponse) {
1441
+ if (desired.storePageEnabled === void 0) return;
1442
+ if (desired.storePageEnabled === createResponse.storePageEnabled) return;
1443
+ return { storePageEnabled: desired.storePageEnabled };
1444
+ }
1445
+ //#endregion
1446
+ //#region src/adapters/developer-product-driver.ts
1447
+ /**
1448
+ * Wraps {@link DeveloperProductsClient} as a `ResourceDriver<"developerProduct">`
1449
+ * that maps a desired-state entry to an ocale create or update call and the
1450
+ * response back to a `ResourceCurrentState<"developerProduct">`. The
1451
+ * `update` path consumes the upstream `204 No Content` response and
1452
+ * synthesizes the post-update `ResourceCurrentState` from `desired` plus
1453
+ * the existing `current.outputs`, carrying `iconImageAssetId` forward when
1454
+ * present.
1455
+ *
1456
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
1457
+ *
1458
+ * @param deps - Injected ocale client and owning universe.
1459
+ * @returns A driver indexable by `"developerProduct"` in a `DriverRegistry`.
1460
+ *
1461
+ * @example
1462
+ *
1463
+ * ```ts
1464
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
1465
+ * import { DeveloperProductsClient } from "@bedrock-rbx/ocale/developer-products";
1466
+ * import {
1467
+ * asResourceKey,
1468
+ * asRobloxAssetId,
1469
+ * createDeveloperProductDriver,
1470
+ * } from "@bedrock-rbx/core";
1471
+ *
1472
+ * const httpClient: HttpClient = {
1473
+ * async request() {
1474
+ * return {
1475
+ * data: {
1476
+ * body: {
1477
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
1478
+ * description: "Stocks the player up with 1,000 premium gems.",
1479
+ * iconImageAssetId: null,
1480
+ * isForSale: false,
1481
+ * isImmutable: false,
1482
+ * name: "Gem Pack",
1483
+ * priceInformation: null,
1484
+ * productId: 9_876_543_210,
1485
+ * storePageEnabled: false,
1486
+ * universeId: 1_234_567_890,
1487
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1488
+ * },
1489
+ * headers: {},
1490
+ * status: 200,
1491
+ * },
1492
+ * success: true,
1493
+ * };
1494
+ * },
1495
+ * };
1496
+ *
1497
+ * const driver = createDeveloperProductDriver({
1498
+ * client: new DeveloperProductsClient({
1499
+ * apiKey: "rbx-your-key",
1500
+ * httpClient,
1501
+ * sleep: async () => {},
1502
+ * }),
1503
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1504
+ * universeId: asRobloxAssetId("1234567890"),
1505
+ * });
1506
+ *
1507
+ * return driver
1508
+ * .create({
1509
+ * description: "Stocks the player up with 1,000 premium gems.",
1510
+ * isRegionalPricingEnabled: undefined,
1511
+ * key: asResourceKey("gem-pack"),
1512
+ * kind: "developerProduct",
1513
+ * name: "Gem Pack",
1514
+ * price: undefined,
1515
+ * storePageEnabled: undefined,
1516
+ * })
1517
+ * .then((result) => {
1518
+ * expect(result.success).toBeTrue();
1519
+ * if (result.success) {
1520
+ * expect(result.data.outputs.productId).toBe("9876543210");
1521
+ * }
1522
+ * });
1523
+ * ```
1524
+ */
1525
+ function createDeveloperProductDriver(deps) {
1526
+ const effective = {
1527
+ ...deps,
1528
+ readFile: withRedactedIcon(deps.readFile)
1529
+ };
1530
+ return {
1531
+ async create(desired) {
1532
+ return createOne(effective, desired);
1533
+ },
1534
+ async update(current, desired) {
1535
+ return updateOne(effective, {
1536
+ current,
1537
+ desired
1538
+ });
1539
+ }
1540
+ };
1541
+ }
1542
+ function toCurrentState$2(desired, data) {
1543
+ const iconImageAssetId = data.iconImageAssetId === void 0 ? void 0 : asRobloxAssetId(data.iconImageAssetId);
1544
+ return {
1545
+ data: {
1546
+ ...desired,
1547
+ outputs: {
1548
+ productId: asRobloxAssetId(data.id),
1549
+ ...iconImageAssetId === void 0 ? {} : { iconImageAssetId }
1550
+ }
1551
+ },
1552
+ success: true
1553
+ };
1554
+ }
1555
+ async function applyFollowUpPatch(deps, { created, desired }) {
1556
+ const followUp = planFollowUpPatch(desired, created);
1557
+ if (followUp === void 0) return toCurrentState$2(desired, created);
1558
+ if ((await deps.client.update({
1559
+ productId: asRobloxAssetId(created.id),
1560
+ universeId: deps.universeId,
1561
+ ...followUp
1562
+ })).success) return toCurrentState$2(desired, created);
1563
+ return toCurrentState$2({
1564
+ ...desired,
1565
+ storePageEnabled: created.storePageEnabled
1566
+ }, created);
1567
+ }
1568
+ async function createOne(deps, desired) {
1569
+ const imageFile = desired.icon === void 0 ? void 0 : await deps.readFile(desired.icon["en-us"]);
1570
+ const created = await deps.client.create({
1571
+ name: desired.name,
1572
+ description: desired.description,
1573
+ universeId: deps.universeId,
1574
+ ...imageFile === void 0 ? {} : { imageFile },
1575
+ ...derivePriceFields(desired),
1576
+ ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled }
1577
+ });
1578
+ if (!created.success) return created;
1579
+ return applyFollowUpPatch(deps, {
1580
+ created: created.data,
1581
+ desired
1582
+ });
1583
+ }
1584
+ async function updateOne(deps, { current, desired }) {
1585
+ const imageFile = desired.icon !== void 0 && shouldReuploadIcon(current.iconFileHashes, desired.iconFileHashes) ? await deps.readFile(desired.icon["en-us"]) : void 0;
1586
+ const result = await deps.client.update({
1587
+ name: desired.name,
1588
+ description: desired.description,
1589
+ productId: current.outputs.productId,
1590
+ universeId: deps.universeId,
1591
+ ...imageFile === void 0 ? {} : { imageFile },
1592
+ ...derivePriceFields(desired),
1593
+ ...desired.isRegionalPricingEnabled === void 0 ? {} : { isRegionalPricingEnabled: desired.isRegionalPricingEnabled },
1594
+ ...desired.storePageEnabled === void 0 ? {} : { storePageEnabled: desired.storePageEnabled }
1595
+ });
1596
+ if (!result.success) return result;
1597
+ return {
1598
+ data: {
1599
+ ...desired,
1600
+ outputs: current.outputs
1601
+ },
1602
+ success: true
1603
+ };
1604
+ }
1605
+ //#endregion
1606
+ //#region src/adapters/game-pass-driver.ts
1607
+ /**
1608
+ * Wraps {@link GamePassesClient} as a `ResourceDriver<"gamePass">` that maps
1609
+ * a desired-state entry to an ocale create call and the response back to a
1610
+ * `ResourceCurrentState<"gamePass">`.
1611
+ *
1612
+ * Upstream `OpenCloudError` results pass through as `Result` failures.
1613
+ * Filesystem errors from `deps.readFile` do not fit the `OpenCloudError`
1614
+ * shape and propagate as promise rejections; shell callers are expected to
1615
+ * translate them if a unified error surface is required.
1616
+ *
1617
+ * @param deps - Injected ocale client, file reader, and owning universe.
1618
+ * @returns A driver indexable by `"gamePass"` in a `DriverRegistry`.
1619
+ * @throws Whatever `deps.readFile` rejects with.
1620
+ *
1621
+ * @example
1622
+ *
1623
+ * ```ts
1624
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
1625
+ * import { GamePassesClient } from "@bedrock-rbx/ocale/game-passes";
1626
+ * import {
1627
+ * asResourceKey,
1628
+ * asRobloxAssetId,
1629
+ * asSha256Hex,
1630
+ * createGamePassDriver,
1631
+ * } from "@bedrock-rbx/core";
1632
+ *
1633
+ * const httpClient: HttpClient = {
1634
+ * async request() {
1635
+ * return {
1636
+ * data: {
1637
+ * body: {
1638
+ * createdTimestamp: "2024-01-15T10:30:00.000Z",
1639
+ * description: "Grants VIP perks.",
1640
+ * gamePassId: 9_876_543_210,
1641
+ * iconAssetId: 1_122_334_455,
1642
+ * isForSale: true,
1643
+ * name: "VIP Pass",
1644
+ * updatedTimestamp: "2024-01-15T10:30:00.000Z",
1645
+ * },
1646
+ * headers: {},
1647
+ * status: 200,
1648
+ * },
1649
+ * success: true,
1650
+ * };
1651
+ * },
1652
+ * };
1653
+ *
1654
+ * const driver = createGamePassDriver({
1655
+ * client: new GamePassesClient({
1656
+ * apiKey: "rbx-your-key",
1657
+ * httpClient,
1658
+ * sleep: async () => {},
1659
+ * }),
1660
+ * readFile: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
1661
+ * universeId: asRobloxAssetId("1234567890"),
1662
+ * });
1663
+ *
1664
+ * return driver
1665
+ * .create({
1666
+ * description: "Grants VIP perks.",
1667
+ * icon: { "en-us": "assets/vip-icon.png" },
1668
+ * iconFileHashes: {
1669
+ * "en-us": asSha256Hex(
1670
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1671
+ * ),
1672
+ * },
1673
+ * key: asResourceKey("vip-pass"),
1674
+ * kind: "gamePass",
1675
+ * name: "VIP Pass",
1676
+ * price: 500,
1677
+ * })
1678
+ * .then((result) => {
1679
+ * expect(result.success).toBeTrue();
1680
+ * if (result.success) {
1681
+ * expect(result.data.outputs.assetId).toBe("9876543210");
1682
+ * }
1683
+ * });
1684
+ * ```
1685
+ */
1686
+ function createGamePassDriver(deps) {
1687
+ const effective = {
1688
+ ...deps,
1689
+ readFile: withRedactedIcon(deps.readFile)
1690
+ };
1691
+ return {
1692
+ async create(desired) {
1693
+ return createGamePass(effective, desired);
1694
+ },
1107
1695
  async update(current, desired) {
1108
1696
  return updateGamePass(effective, {
1109
1697
  current,
@@ -1177,62 +1765,6 @@ async function updateGamePass(deps, states) {
1177
1765
  });
1178
1766
  }
1179
1767
  //#endregion
1180
- //#region src/core/environment.ts
1181
- /**
1182
- * Source pattern for environment names, including `^` and `$` anchors.
1183
- * Letters, digits, `-`, `_`, length 1-64.
1184
- *
1185
- * Exported so the config schema can validate `environments` keys against
1186
- * the same alphabet and length cap that adapters enforce on storage
1187
- * identifiers. Single source of truth: changing the alphabet here changes
1188
- * both the runtime check and the schema-level key constraint.
1189
- *
1190
- * Anchors are embedded so callers do not have to re-add them, matching
1191
- * the `RESOURCE_KEY_PATTERN_SOURCE` convention in `types/ids.ts`.
1192
- */
1193
- const ENV_NAME_PATTERN_SOURCE = "^[A-Za-z0-9_-]{1,64}$";
1194
- const ENVIRONMENT_NAME_PATTERN = new RegExp(ENV_NAME_PATTERN_SOURCE);
1195
- /**
1196
- * Validate an environment name at a state-adapter boundary.
1197
- *
1198
- * Adapters that map environment names onto filesystem-like identifiers
1199
- * (gist filenames, S3 keys) must reject names that could collide or escape
1200
- * their storage layout. This helper accepts letters, digits, `-`, and `_`
1201
- * only, with length between 1 and 64, and returns a `StateError` for
1202
- * anything outside that set so the adapter can fail loudly instead of
1203
- * silently stripping characters.
1204
- *
1205
- * @example
1206
- *
1207
- * ```ts
1208
- * import { validateEnvironmentName } from "@bedrock-rbx/core";
1209
- *
1210
- * const ok = validateEnvironmentName("production");
1211
- * expect(ok.success).toBeTrue();
1212
- *
1213
- * const bad = validateEnvironmentName("prod/staging");
1214
- * expect(bad.success).toBeFalse();
1215
- * ```
1216
- *
1217
- * @param environment - Raw environment name supplied by a caller.
1218
- * @returns `Ok(environment)` when the name is safe to use, or
1219
- * `Err(StateError)` with a descriptive reason when it is not.
1220
- */
1221
- function validateEnvironmentName(environment) {
1222
- if (!ENVIRONMENT_NAME_PATTERN.test(environment)) return {
1223
- err: {
1224
- file: environment,
1225
- kind: "stateError",
1226
- reason: `invalid environment name: must match ${String(ENVIRONMENT_NAME_PATTERN)}`
1227
- },
1228
- success: false
1229
- };
1230
- return {
1231
- data: environment,
1232
- success: true
1233
- };
1234
- }
1235
- //#endregion
1236
1768
  //#region src/core/state-file.ts
1237
1769
  const envelopeSchema = type({
1238
1770
  $bedrock: { version: "1" },
@@ -1361,7 +1893,9 @@ const GITHUB_API_BASE = "https://api.github.com";
1361
1893
  const GITHUB_API_VERSION = "2026-03-10";
1362
1894
  const USER_AGENT = "bedrock";
1363
1895
  const MAX_INLINE_BYTES = 1e7;
1364
- const MAX_RETRIES = 3;
1896
+ const MAX_RETRIES = 6;
1897
+ const BASE_BACKOFF_MS = 500;
1898
+ const MAX_BACKOFF_MS = 16e3;
1365
1899
  const RETRYABLE_STATUSES = new Set([
1366
1900
  409,
1367
1901
  502,
@@ -1404,6 +1938,7 @@ function createGistStateAdapter(deps) {
1404
1938
  const ctx = {
1405
1939
  fetchFn: deps.fetch ?? globalThis.fetch.bind(globalThis),
1406
1940
  gistId: deps.gistId,
1941
+ random: deps.random ?? Math.random,
1407
1942
  sleep: deps.sleep ?? defaultSleep,
1408
1943
  token: deps.token
1409
1944
  };
@@ -1444,12 +1979,26 @@ function toGistFile(entry) {
1444
1979
  size
1445
1980
  };
1446
1981
  }
1447
- function mapHttpError({ file, gistId, status }) {
1982
+ function isRateLimited(headers) {
1983
+ return headers.get("retry-after") !== null || headers.get("x-ratelimit-remaining") === "0";
1984
+ }
1985
+ function rateLimitReason(status, headers) {
1986
+ const retryAfter = headers.get("retry-after");
1987
+ if (retryAfter !== null) return `rate limited (${status}): retry after ${retryAfter}s`;
1988
+ return `rate limited (${status})`;
1989
+ }
1990
+ function mapHttpError({ file, gistId, response }) {
1991
+ const { headers, status } = response;
1448
1992
  if (status === 404) return {
1449
1993
  file,
1450
1994
  kind: "stateError",
1451
1995
  reason: `gist ${gistId} not found: check gistId`
1452
1996
  };
1997
+ if (status === 403 && isRateLimited(headers)) return {
1998
+ file,
1999
+ kind: "stateError",
2000
+ reason: rateLimitReason(status, headers)
2001
+ };
1453
2002
  if (status === 401 || status === 403) return {
1454
2003
  file,
1455
2004
  kind: "stateError",
@@ -1485,14 +2034,15 @@ async function sendGet(ctx) {
1485
2034
  function isRetryableStatus(status) {
1486
2035
  return RETRYABLE_STATUSES.has(status);
1487
2036
  }
1488
- function backoffMs(attempt) {
1489
- return 1e3 * 2 ** attempt;
2037
+ function backoffMs(attempt, random) {
2038
+ const half = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt) / 2;
2039
+ return half + random() * half;
1490
2040
  }
1491
- async function withRetry(sleep, operation) {
2041
+ async function withRetry(retry, operation) {
1492
2042
  let response = await operation();
1493
2043
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1494
2044
  if (response.ok || !isRetryableStatus(response.status)) return response;
1495
- await sleep(backoffMs(attempt));
2045
+ await retry.sleep(backoffMs(attempt, retry.random));
1496
2046
  response = await operation();
1497
2047
  }
1498
2048
  return response;
@@ -1500,7 +2050,7 @@ async function withRetry(sleep, operation) {
1500
2050
  async function fetchGistBody(ctx, file) {
1501
2051
  let response;
1502
2052
  try {
1503
- response = await withRetry(ctx.sleep, async () => sendGet(ctx));
2053
+ response = await withRetry(ctx, async () => sendGet(ctx));
1504
2054
  } catch (err) {
1505
2055
  return {
1506
2056
  err: networkError(err, file),
@@ -1511,7 +2061,7 @@ async function fetchGistBody(ctx, file) {
1511
2061
  err: mapHttpError({
1512
2062
  file,
1513
2063
  gistId: ctx.gistId,
1514
- status: response.status
2064
+ response
1515
2065
  }),
1516
2066
  success: false
1517
2067
  };
@@ -1530,14 +2080,14 @@ function stateErr(file, reason) {
1530
2080
  success: false
1531
2081
  };
1532
2082
  }
1533
- async function readGistContent({ entry, fetchFn, file, sleep }) {
2083
+ async function readGistContent({ entry, fetchFn, file, retry }) {
1534
2084
  if (entry.size > MAX_INLINE_BYTES) return stateErr(file, `state file too large: ${entry.size} bytes`);
1535
2085
  if (entry.isTruncated) {
1536
2086
  if (entry.rawUrl === void 0) return stateErr(file, "truncated gist file missing raw_url");
1537
2087
  const { rawUrl } = entry;
1538
2088
  let rawResponse;
1539
2089
  try {
1540
- rawResponse = await withRetry(sleep, async () => fetchFn(rawUrl));
2090
+ rawResponse = await withRetry(retry, async () => fetchFn(rawUrl));
1541
2091
  } catch (err) {
1542
2092
  return {
1543
2093
  err: networkError(err, file),
@@ -1563,7 +2113,7 @@ async function readPath(ctx, environment) {
1563
2113
  entry,
1564
2114
  fetchFn: ctx.fetchFn,
1565
2115
  file,
1566
- sleep: ctx.sleep
2116
+ retry: ctx
1567
2117
  });
1568
2118
  }
1569
2119
  async function sendPatch(ctx, body) {
@@ -1612,7 +2162,7 @@ async function writePath(ctx, state) {
1612
2162
  const body = JSON.stringify({ files: { [fileName(state.environment)]: { content: serializeStateFile(state) } } });
1613
2163
  let response;
1614
2164
  try {
1615
- response = await withRetry(ctx.sleep, async () => sendPatch(ctx, body));
2165
+ response = await withRetry(ctx, async () => sendPatch(ctx, body));
1616
2166
  } catch (err) {
1617
2167
  return {
1618
2168
  err: networkError(err, file),
@@ -1633,12 +2183,36 @@ async function writePath(ctx, state) {
1633
2183
  err: mapHttpError({
1634
2184
  file,
1635
2185
  gistId: ctx.gistId,
1636
- status: response.status
2186
+ response
1637
2187
  }),
1638
2188
  success: false
1639
2189
  };
1640
2190
  }
1641
2191
  //#endregion
2192
+ //#region src/adapters/no-op-progress-adapter.ts
2193
+ /**
2194
+ * Build a {@link ProgressPort} that silently drops every event. Useful for
2195
+ * tests and programmatic callers who want to invoke deploy logic without
2196
+ * any rendering.
2197
+ *
2198
+ * @example
2199
+ *
2200
+ * ```ts
2201
+ * import { createNoOpProgressAdapter } from "@bedrock-rbx/core";
2202
+ *
2203
+ * const port = createNoOpProgressAdapter();
2204
+ *
2205
+ * expect(() =>
2206
+ * port.emit({ environment: "production", kind: "deploySuccess", resourceCount: 3 }),
2207
+ * ).not.toThrow();
2208
+ * ```
2209
+ *
2210
+ * @returns A `ProgressPort` whose `emit` method is a no-op.
2211
+ */
2212
+ function createNoOpProgressAdapter() {
2213
+ return { emit() {} };
2214
+ }
2215
+ //#endregion
1642
2216
  //#region src/core/resources.ts
1643
2217
  /**
1644
2218
  * Ordered list of optional metadata fields the driver routes through
@@ -1724,634 +2298,484 @@ const UNIVERSE_SINGLETON_KEY = asResourceKey("main");
1724
2298
  *
1725
2299
  * @param deps - Injected ocale client, file reader, and owning universe.
1726
2300
  * @returns A driver indexable by `"place"` in a `DriverRegistry`.
1727
- * @throws Whatever `deps.readFile` rejects with.
1728
- *
1729
- * @example
1730
- *
1731
- * ```ts
1732
- * import type { HttpClient } from "@bedrock-rbx/ocale";
1733
- * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1734
- * import {
1735
- * asResourceKey,
1736
- * asRobloxAssetId,
1737
- * asSha256Hex,
1738
- * createPlaceDriver,
1739
- * } from "@bedrock-rbx/core";
1740
- *
1741
- * const httpClient: HttpClient = {
1742
- * async request() {
1743
- * return {
1744
- * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1745
- * success: true,
1746
- * };
1747
- * },
1748
- * };
1749
- *
1750
- * const driver = createPlaceDriver({
1751
- * client: new PlacesClient({
1752
- * apiKey: "rbx-your-key",
1753
- * httpClient,
1754
- * sleep: async () => {},
1755
- * }),
1756
- * readFile: async () =>
1757
- * new Uint8Array([
1758
- * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
1759
- * 0x0a,
1760
- * ]),
1761
- * universeId: asRobloxAssetId("1234567890"),
1762
- * });
1763
- *
1764
- * return driver
1765
- * .create({
1766
- * description: undefined,
1767
- * displayName: undefined,
1768
- * fileHash: asSha256Hex(
1769
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1770
- * ),
1771
- * filePath: "places/start.rbxl",
1772
- * key: asResourceKey("start-place"),
1773
- * kind: "place",
1774
- * placeId: asRobloxAssetId("4711"),
1775
- * serverSize: undefined,
1776
- * })
1777
- * .then((result) => {
1778
- * expect(result.success).toBeTrue();
1779
- * if (result.success) {
1780
- * expect(result.data.outputs.versionNumber).toBe(1);
1781
- * }
1782
- * });
1783
- * ```
1784
- */
1785
- function createPlaceDriver(deps) {
1786
- return {
1787
- async create(desired) {
1788
- return publishPlace(deps, desired);
1789
- },
1790
- async update(_current, desired) {
1791
- return publishPlace(deps, desired);
1792
- }
1793
- };
1794
- }
1795
- function buildMetadataParameters(universeId, desired) {
1796
- const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
1797
- const value = desired[field];
1798
- return value === void 0 ? accumulator : {
1799
- ...accumulator,
1800
- [field]: value
1801
- };
1802
- }, {});
1803
- if (Object.keys(metadata).length === 0) return;
1804
- return {
1805
- ...metadata,
1806
- placeId: desired.placeId,
1807
- universeId
1808
- };
1809
- }
1810
- function detectFormat(filePath) {
1811
- if (filePath.endsWith(".rbxlx")) return "rbxlx";
1812
- if (filePath.endsWith(".rbxl")) return "rbxl";
1813
- }
1814
- async function publishVersion(deps, desired) {
1815
- const format = detectFormat(desired.filePath);
1816
- if (format === void 0) return {
1817
- err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
1818
- success: false
1819
- };
1820
- const body = await deps.readFile(desired.filePath);
1821
- return deps.client.publish({
1822
- body: Uint8Array.from(body),
1823
- format,
1824
- placeId: desired.placeId,
1825
- universeId: deps.universeId
1826
- });
1827
- }
1828
- async function publishPlace(deps, desired) {
1829
- const publishResult = await publishVersion(deps, desired);
1830
- if (!publishResult.success) return publishResult;
1831
- const metadataParameters = buildMetadataParameters(deps.universeId, desired);
1832
- if (metadataParameters !== void 0) {
1833
- const metadataResult = await deps.client.update(metadataParameters);
1834
- if (!metadataResult.success) return metadataResult;
1835
- }
1836
- return {
1837
- data: {
1838
- ...desired,
1839
- outputs: publishResult.data
1840
- },
1841
- success: true
1842
- };
1843
- }
1844
- //#endregion
1845
- //#region src/adapters/universe-driver.ts
1846
- /**
1847
- * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
1848
- * and `update` both delegate to a shared reconcile helper because Open
1849
- * Cloud cannot mint universes; the user supplies an existing `universeId`
1850
- * and bedrock adopts the universe on first apply.
1851
- *
1852
- * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
1853
- * as an adoption-error `ApiError` whose message names the config key and
1854
- * the `universeId`, so operators can tell adoption failure apart from
1855
- * transient upstream errors. A successful response whose `rootPlaceId` is
1856
- * absent surfaces as an `ApiError` with status 200, mirroring the
1857
- * malformed-response guard in `GamePassDriver`.
1858
- *
1859
- * When `displayName` is declared, the driver routes that field through
1860
- * `PlacesClient.update` on the root place after the universe PATCH
1861
- * succeeds. A subsequent places failure surfaces to the caller as the
1862
- * driver's error result without rolling back the prior universe patch,
1863
- * so callers observing a partial failure should reconcile by
1864
- * reapplying rather than assuming the universe-level fields are
1865
- * unchanged.
1866
- *
1867
- * @param deps - Injected ocale clients (universes plus places for the
1868
- * read-only universe fields Roblox derives from the root place).
1869
- * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2301
+ * @throws Whatever `deps.readFile` rejects with.
1870
2302
  *
1871
2303
  * @example
1872
2304
  *
1873
2305
  * ```ts
1874
2306
  * import type { HttpClient } from "@bedrock-rbx/ocale";
1875
2307
  * import { PlacesClient } from "@bedrock-rbx/ocale/places";
1876
- * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
1877
- * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
1878
2308
  * import {
2309
+ * asResourceKey,
1879
2310
  * asRobloxAssetId,
1880
- * createUniverseDriver,
1881
- * UNIVERSE_SINGLETON_KEY,
2311
+ * asSha256Hex,
2312
+ * createPlaceDriver,
1882
2313
  * } from "@bedrock-rbx/core";
1883
2314
  *
1884
- * const universeBodyHttpClient: HttpClient = {
2315
+ * const httpClient: HttpClient = {
1885
2316
  * async request() {
1886
2317
  * return {
1887
- * data: {
1888
- * body: validUniverseBody({
1889
- * path: "universes/1234567890",
1890
- * rootPlace: "universes/1234567890/places/4711",
1891
- * }),
1892
- * headers: {},
1893
- * status: 200,
1894
- * },
2318
+ * data: { body: { versionNumber: 1 }, headers: {}, status: 200 },
1895
2319
  * success: true,
1896
2320
  * };
1897
2321
  * },
1898
2322
  * };
1899
2323
  *
1900
- * const driver = createUniverseDriver({
1901
- * places: new PlacesClient({
1902
- * apiKey: "rbx-your-key",
1903
- * httpClient: universeBodyHttpClient,
1904
- * sleep: async () => {},
1905
- * }),
1906
- * universes: new UniversesClient({
2324
+ * const driver = createPlaceDriver({
2325
+ * client: new PlacesClient({
1907
2326
  * apiKey: "rbx-your-key",
1908
- * httpClient: universeBodyHttpClient,
2327
+ * httpClient,
1909
2328
  * sleep: async () => {},
1910
2329
  * }),
2330
+ * readFile: async () =>
2331
+ * new Uint8Array([
2332
+ * 0x3c, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x21, 0x89, 0xff, 0x0d, 0x0a, 0x1a,
2333
+ * 0x0a,
2334
+ * ]),
2335
+ * universeId: asRobloxAssetId("1234567890"),
1911
2336
  * });
1912
2337
  *
1913
2338
  * return driver
1914
2339
  * .create({
1915
- * consoleEnabled: undefined,
1916
- * desktopEnabled: true,
2340
+ * description: undefined,
1917
2341
  * displayName: undefined,
1918
- * key: UNIVERSE_SINGLETON_KEY,
1919
- * kind: "universe",
1920
- * mobileEnabled: undefined,
1921
- * privateServerPriceRobux: undefined,
1922
- * tabletEnabled: undefined,
1923
- * universeId: asRobloxAssetId("1234567890"),
1924
- * voiceChatEnabled: true,
1925
- * vrEnabled: undefined,
2342
+ * fileHash: asSha256Hex(
2343
+ * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2344
+ * ),
2345
+ * filePath: "places/start.rbxl",
2346
+ * key: asResourceKey("start-place"),
2347
+ * kind: "place",
2348
+ * placeId: asRobloxAssetId("4711"),
2349
+ * serverSize: undefined,
1926
2350
  * })
1927
2351
  * .then((result) => {
1928
2352
  * expect(result.success).toBeTrue();
1929
2353
  * if (result.success) {
1930
- * expect(result.data.outputs.rootPlaceId).toBe("4711");
2354
+ * expect(result.data.outputs.versionNumber).toBe(1);
1931
2355
  * }
1932
2356
  * });
1933
2357
  * ```
1934
2358
  */
1935
- function createUniverseDriver(deps) {
2359
+ function createPlaceDriver(deps) {
1936
2360
  return {
1937
2361
  async create(desired) {
1938
- return reconcileUniverse({
1939
- deps,
1940
- desired
1941
- });
2362
+ return publishPlace(deps, desired);
1942
2363
  },
1943
2364
  async update(_current, desired) {
1944
- return reconcileUniverse({
1945
- deps,
1946
- desired
1947
- });
2365
+ return publishPlace(deps, desired);
1948
2366
  }
1949
2367
  };
1950
2368
  }
1951
- function toCurrentState(desired, rootPlaceId) {
1952
- return {
1953
- ...desired,
1954
- outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
1955
- };
1956
- }
1957
- function buildParameters(desired) {
1958
- const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
1959
- const isEnabled = desired[flag];
1960
- return isEnabled === void 0 ? accumulator : {
2369
+ function buildMetadataParameters(universeId, desired) {
2370
+ const metadata = PLACE_MANAGED_METADATA_FIELDS.reduce((accumulator, field) => {
2371
+ const value = desired[field];
2372
+ return value === void 0 ? accumulator : {
1961
2373
  ...accumulator,
1962
- [flag]: isEnabled
2374
+ [field]: value
1963
2375
  };
1964
- }, { universeId: desired.universeId });
2376
+ }, {});
2377
+ if (Object.keys(metadata).length === 0) return;
1965
2378
  return {
1966
- ..."privateServerPriceRobux" in desired ? {
1967
- ...base,
1968
- privateServerPriceRobux: desired.privateServerPriceRobux
1969
- } : base,
1970
- ...copyDeclaredSocialLinks(desired)
2379
+ ...metadata,
2380
+ placeId: desired.placeId,
2381
+ universeId
1971
2382
  };
1972
2383
  }
1973
- function wrapUpdateError(err, desired) {
1974
- if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
1975
- return err;
1976
- }
1977
- function hasUniverseLevelUpdate(desired) {
1978
- if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
1979
- if ("privateServerPriceRobux" in desired) return true;
1980
- return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2384
+ function detectFormat(filePath) {
2385
+ if (filePath.endsWith(".rbxlx")) return "rbxlx";
2386
+ if (filePath.endsWith(".rbxl")) return "rbxl";
1981
2387
  }
1982
- async function resolveUniverse(deps, desired) {
1983
- const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
1984
- if (!result.success) return {
1985
- err: wrapUpdateError(result.err, desired),
1986
- success: false
1987
- };
1988
- const { rootPlaceId } = result.data;
1989
- if (rootPlaceId === void 0) return {
1990
- err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
2388
+ async function publishVersion(deps, desired) {
2389
+ const format = detectFormat(desired.filePath);
2390
+ if (format === void 0) return {
2391
+ err: new ApiError(`Unsupported place file extension for ${desired.filePath}; expected .rbxl or .rbxlx`, { statusCode: 0 }),
1991
2392
  success: false
1992
2393
  };
1993
- return {
1994
- data: { rootPlaceId },
1995
- success: true
1996
- };
2394
+ const body = await deps.readFile(desired.filePath);
2395
+ return deps.client.publish({
2396
+ body: Uint8Array.from(body),
2397
+ format,
2398
+ placeId: desired.placeId,
2399
+ universeId: deps.universeId
2400
+ });
1997
2401
  }
1998
- async function reconcileUniverse(inputs) {
1999
- const { deps, desired } = inputs;
2000
- const universeResult = await resolveUniverse(deps, desired);
2001
- if (!universeResult.success) return universeResult;
2002
- const { rootPlaceId } = universeResult.data;
2003
- if (desired.displayName !== void 0) {
2004
- const placesResult = await deps.places.update({
2005
- displayName: desired.displayName,
2006
- placeId: rootPlaceId,
2007
- universeId: desired.universeId
2008
- });
2009
- if (!placesResult.success) return {
2010
- err: placesResult.err,
2011
- success: false
2012
- };
2402
+ async function publishPlace(deps, desired) {
2403
+ const publishResult = await publishVersion(deps, desired);
2404
+ if (!publishResult.success) return publishResult;
2405
+ const metadataParameters = buildMetadataParameters(deps.universeId, desired);
2406
+ if (metadataParameters !== void 0) {
2407
+ const metadataResult = await deps.client.update(metadataParameters);
2408
+ if (!metadataResult.success) return metadataResult;
2013
2409
  }
2014
2410
  return {
2015
- data: toCurrentState(desired, rootPlaceId),
2016
- success: true
2017
- };
2018
- }
2019
- //#endregion
2020
- //#region src/cli/clack-port.ts
2021
- /**
2022
- * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
2023
- * resulting port writes to `process.stdout` via clack's defaults. Kept in
2024
- * its own module so consumers that never need the clack-backed rendering
2025
- * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
2026
- * into their bundle.
2027
- *
2028
- * @example
2029
- *
2030
- * ```ts
2031
- * import { createClackPort } from "@bedrock-rbx/core";
2032
- *
2033
- * const port = createClackPort();
2034
- *
2035
- * expect(typeof port.logSuccess).toBe("function");
2036
- * ```
2037
- *
2038
- * @returns A port whose six methods each invoke the matching clack helper.
2039
- */
2040
- function createClackPort() {
2041
- return {
2042
- cancel: (message) => {
2043
- cancel(message);
2044
- },
2045
- intro: (message) => {
2046
- intro(message);
2047
- },
2048
- logError: (message) => {
2049
- log.error(message);
2050
- },
2051
- logMessage: (message) => {
2052
- log.message(message);
2053
- },
2054
- logSuccess: (message) => {
2055
- log.success(message);
2411
+ data: {
2412
+ ...desired,
2413
+ outputs: publishResult.data
2056
2414
  },
2057
- outro: (message) => {
2058
- outro(message);
2059
- }
2415
+ success: true
2060
2416
  };
2061
2417
  }
2062
2418
  //#endregion
2063
- //#region src/core/validate-universe-xor.ts
2064
- /**
2065
- * Walk the loose authored-shape and surface every place the
2066
- * universeId-XOR-between-root-and-env rule is violated. Pure: returns
2067
- * the issue list; the caller hands it to arktype's `ctx.reject` so each
2068
- * one lands at the offending config path. The schema's runtime narrow
2069
- * uses this to enforce the rule at validation time before the validated
2070
- * value is cast to the strict `Config` discriminated union.
2071
- *
2072
- * @param value - Parsed config the schema is validating.
2073
- * @returns Zero or more issues. Empty when the config satisfies the rule.
2074
- */
2075
- function collectUniverseIdIssues(value) {
2076
- const rootUniverseId = value.universe?.universeId;
2077
- const hasRootUniverseBlock = value.universe !== void 0;
2078
- const environmentEntries = Object.entries(value.environments);
2079
- const hasEnvironmentUniverseId = environmentEntries.some(([, environment]) => environment.universe?.universeId !== void 0);
2080
- const environmentIssues = collectEnvironmentIssues(rootUniverseId, environmentEntries);
2081
- const rootIssues = hasRootUniverseBlock && rootUniverseId === void 0 && !hasEnvironmentUniverseId ? [{
2082
- message: "universeId must be declared on the root universe block, or on every environment that declares its own universe overlay.",
2083
- path: ["universe", "universeId"]
2084
- }] : [];
2085
- return [...environmentIssues, ...rootIssues];
2086
- }
2087
- function collectEnvironmentIssues(rootUniverseId, environmentEntries) {
2088
- return environmentEntries.flatMap(([environmentName, environment]) => {
2089
- if (environment.universe === void 0) return [];
2090
- if (rootUniverseId !== void 0 && environment.universe.universeId !== void 0) return [{
2091
- 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.",
2092
- path: [
2093
- "environments",
2094
- environmentName,
2095
- "universe",
2096
- "universeId"
2097
- ]
2098
- }];
2099
- if (rootUniverseId === void 0 && environment.universe.universeId === void 0) return [{
2100
- message: "universeId must be declared on this environment overlay because the root universe block does not provide one.",
2101
- path: [
2102
- "environments",
2103
- environmentName,
2104
- "universe",
2105
- "universeId"
2106
- ]
2107
- }];
2108
- return [];
2109
- });
2110
- }
2111
- //#endregion
2112
- //#region src/core/schema.ts
2419
+ //#region src/adapters/universe-driver.ts
2113
2420
  /**
2114
- * Narrow a `StateConfig` to the `GistStateConfig` arm. The `(string & {})`
2115
- * autocomplete idiom prevents TypeScript from narrowing on
2116
- * `backend === "gist"` alone, so dispatch sites use this guard to
2117
- * preserve the `gistId` field shape.
2421
+ * Wraps {@link UniversesClient} as a `ResourceDriver<"universe">`. `create`
2422
+ * and `update` both delegate to a shared reconcile helper because Open
2423
+ * Cloud cannot mint universes; the user supplies an existing `universeId`
2424
+ * and bedrock adopts the universe on first apply.
2425
+ *
2426
+ * A `NotFound` error (HTTP 404) from `UniversesClient.update` is repackaged
2427
+ * as an adoption-error `ApiError` whose message names the config key and
2428
+ * the `universeId`, so operators can tell adoption failure apart from
2429
+ * transient upstream errors. A successful response whose `rootPlaceId` is
2430
+ * absent surfaces as an `ApiError` with status 200, mirroring the
2431
+ * malformed-response guard in `GamePassDriver`.
2432
+ *
2433
+ * When `displayName` is declared, the driver routes that field through
2434
+ * `PlacesClient.update` on the root place after the universe PATCH
2435
+ * succeeds. A subsequent places failure surfaces to the caller as the
2436
+ * driver's error result without rolling back the prior universe patch,
2437
+ * so callers observing a partial failure should reconcile by
2438
+ * reapplying rather than assuming the universe-level fields are
2439
+ * unchanged.
2440
+ *
2441
+ * @param deps - Injected ocale clients (universes plus places for the
2442
+ * read-only universe fields Roblox derives from the root place).
2443
+ * @returns A driver indexable by `"universe"` in a `DriverRegistry`.
2118
2444
  *
2119
2445
  * @example
2120
2446
  *
2121
2447
  * ```ts
2122
- * import { isGistStateConfig } from "@bedrock-rbx/core";
2123
- * import type { StateConfig } from "@bedrock-rbx/core/config";
2448
+ * import type { HttpClient } from "@bedrock-rbx/ocale";
2449
+ * import { PlacesClient } from "@bedrock-rbx/ocale/places";
2450
+ * import { UniversesClient } from "@bedrock-rbx/ocale/universes";
2451
+ * import { validUniverseBody } from "@bedrock-rbx/ocale/testing";
2452
+ * import {
2453
+ * asRobloxAssetId,
2454
+ * createUniverseDriver,
2455
+ * UNIVERSE_SINGLETON_KEY,
2456
+ * } from "@bedrock-rbx/core";
2124
2457
  *
2125
- * const config: StateConfig = { backend: "gist", gistId: "abc" };
2458
+ * const universeBodyHttpClient: HttpClient = {
2459
+ * async request() {
2460
+ * return {
2461
+ * data: {
2462
+ * body: validUniverseBody({
2463
+ * path: "universes/1234567890",
2464
+ * rootPlace: "universes/1234567890/places/4711",
2465
+ * }),
2466
+ * headers: {},
2467
+ * status: 200,
2468
+ * },
2469
+ * success: true,
2470
+ * };
2471
+ * },
2472
+ * };
2126
2473
  *
2127
- * expect(isGistStateConfig(config)).toBeTrue();
2128
- * if (isGistStateConfig(config)) {
2129
- * expect(config.gistId).toBe("abc");
2130
- * }
2131
- * ```
2474
+ * const driver = createUniverseDriver({
2475
+ * places: new PlacesClient({
2476
+ * apiKey: "rbx-your-key",
2477
+ * httpClient: universeBodyHttpClient,
2478
+ * sleep: async () => {},
2479
+ * }),
2480
+ * universes: new UniversesClient({
2481
+ * apiKey: "rbx-your-key",
2482
+ * httpClient: universeBodyHttpClient,
2483
+ * sleep: async () => {},
2484
+ * }),
2485
+ * });
2132
2486
  *
2133
- * @param config - Resolved state config to inspect.
2134
- * @returns `true` when `config.backend === "gist"`; otherwise `false`.
2487
+ * return driver
2488
+ * .create({
2489
+ * consoleEnabled: undefined,
2490
+ * desktopEnabled: true,
2491
+ * displayName: undefined,
2492
+ * key: UNIVERSE_SINGLETON_KEY,
2493
+ * kind: "universe",
2494
+ * mobileEnabled: undefined,
2495
+ * privateServerPriceRobux: undefined,
2496
+ * tabletEnabled: undefined,
2497
+ * universeId: asRobloxAssetId("1234567890"),
2498
+ * voiceChatEnabled: true,
2499
+ * vrEnabled: undefined,
2500
+ * })
2501
+ * .then((result) => {
2502
+ * expect(result.success).toBeTrue();
2503
+ * if (result.success) {
2504
+ * expect(result.data.outputs.rootPlaceId).toBe("4711");
2505
+ * }
2506
+ * });
2507
+ * ```
2135
2508
  */
2136
- function isGistStateConfig(config) {
2137
- return config.backend === "gist";
2509
+ function createUniverseDriver(deps) {
2510
+ return {
2511
+ async create(desired) {
2512
+ return reconcileUniverse({
2513
+ deps,
2514
+ desired
2515
+ });
2516
+ },
2517
+ async update(_current, desired) {
2518
+ return reconcileUniverse({
2519
+ deps,
2520
+ desired
2521
+ });
2522
+ }
2523
+ };
2138
2524
  }
2139
- const OPTIONAL_BOOLEAN$2 = "boolean | undefined";
2140
- const OPTIONAL_STRING = "string | undefined";
2141
- const REDACTED_KEY = "redacted?";
2142
- const NON_EMPTY_OVERRIDE_MESSAGE = "a non-empty override object; use `redacted: true` for default placeholders";
2143
- /**
2144
- * Shared arktype constraint for any optional positive-integer field.
2145
- * Reused by per-kind entry schemas so positive-integer fields validate
2146
- * identically.
2147
- */
2148
- const OPTIONAL_POSITIVE_INTEGER = "(number.integer >= 1) | undefined";
2149
- /**
2150
- * Shared arktype constraint for any optional Robux-price field. The schema
2151
- * rejects negatives, fractional values, `NaN`, and `Infinity` at config
2152
- * validation time so a malformed price surfaces with a path attributing the
2153
- * failure to the offending field, rather than slipping through to the
2154
- * Roblox API and surfacing as an opaque error at apply time. Per-kind entry
2155
- * schemas reuse this constant so all Robux-price fields validate
2156
- * identically.
2157
- */
2158
- const OPTIONAL_ROBUX_PRICE = "number.integer >= 0 | undefined";
2159
- const gamePassRedacted = type({
2160
- "description?": "string",
2161
- "icon?": iconMap,
2162
- "name?": "string"
2163
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2164
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2165
- return true;
2166
- }).or(OPTIONAL_BOOLEAN$2);
2167
- const placeRedacted = type({
2168
- "description?": "string",
2169
- "displayName?": "string"
2170
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2171
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2172
- return true;
2173
- }).or(OPTIONAL_BOOLEAN$2);
2174
- const productRedacted = type({
2175
- "description?": "string",
2176
- "icon?": iconMap,
2177
- "name?": "string"
2178
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2179
- if (Object.keys(value).length === 0) return ctx.mustBe(NON_EMPTY_OVERRIDE_MESSAGE);
2180
- return true;
2181
- }).or(OPTIONAL_BOOLEAN$2);
2182
- const gamePassEntry = type({
2183
- "name": "string",
2184
- "description": "string",
2185
- "icon": iconMap,
2186
- "price?": OPTIONAL_ROBUX_PRICE,
2187
- [REDACTED_KEY]: gamePassRedacted
2188
- });
2189
- const passesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassEntry }).onUndeclaredKey("reject");
2190
- const developerProductEntry = type({
2191
- "name": "string",
2192
- "description": "string",
2193
- "icon?": iconMap,
2194
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
2195
- "price?": OPTIONAL_ROBUX_PRICE,
2196
- [REDACTED_KEY]: productRedacted,
2197
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
2198
- }).onUndeclaredKey("reject");
2199
- const productsCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductEntry }).onUndeclaredKey("reject");
2200
- const ROBLOX_ID_DIGITS = "string.digits";
2201
- const placeEntry = type({
2202
- "description?": OPTIONAL_STRING,
2203
- "displayName?": OPTIONAL_STRING,
2204
- "filePath": "string",
2205
- [REDACTED_KEY]: placeRedacted,
2206
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
2207
- }).onUndeclaredKey("reject");
2208
- const placesCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeEntry }).onUndeclaredKey("reject");
2209
- const socialLinkOrUndefined$1 = type({
2210
- title: "string",
2211
- uri: "string"
2212
- }).onUndeclaredKey("reject").or("undefined");
2213
- const universeEntry = type({
2214
- "consoleEnabled?": OPTIONAL_BOOLEAN$2,
2215
- "desktopEnabled?": OPTIONAL_BOOLEAN$2,
2216
- "discordSocialLink?": socialLinkOrUndefined$1,
2217
- "displayName?": OPTIONAL_STRING,
2218
- "facebookSocialLink?": socialLinkOrUndefined$1,
2219
- "guildedSocialLink?": socialLinkOrUndefined$1,
2220
- "mobileEnabled?": OPTIONAL_BOOLEAN$2,
2221
- "privateServerPriceRobux?": OPTIONAL_ROBUX_PRICE,
2222
- "robloxGroupSocialLink?": socialLinkOrUndefined$1,
2223
- "tabletEnabled?": OPTIONAL_BOOLEAN$2,
2224
- "twitchSocialLink?": socialLinkOrUndefined$1,
2225
- "twitterSocialLink?": socialLinkOrUndefined$1,
2226
- "universeId?": ROBLOX_ID_DIGITS,
2227
- "voiceChatEnabled?": OPTIONAL_BOOLEAN$2,
2228
- "vrEnabled?": OPTIONAL_BOOLEAN$2,
2229
- "youtubeSocialLink?": socialLinkOrUndefined$1
2230
- }).onUndeclaredKey("reject");
2231
- const stateConfig = type({
2232
- "backend": "string",
2233
- "gistId?": "string > 0"
2234
- }).onUndeclaredKey("reject");
2235
- const gamePassOverlay = type({
2236
- "description?": "string",
2237
- "icon?": iconMap,
2238
- "name?": "string",
2239
- "price?": OPTIONAL_ROBUX_PRICE,
2240
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2
2241
- }).onUndeclaredKey("reject");
2242
- const passesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: gamePassOverlay }).onUndeclaredKey("reject");
2243
- const developerProductOverlay = type({
2244
- "description?": "string",
2245
- "icon?": iconMap,
2246
- "isRegionalPricingEnabled?": OPTIONAL_BOOLEAN$2,
2247
- "name?": "string",
2248
- "price?": OPTIONAL_ROBUX_PRICE,
2249
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2250
- "storePageEnabled?": OPTIONAL_BOOLEAN$2
2251
- }).onUndeclaredKey("reject");
2252
- const productsOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: developerProductOverlay }).onUndeclaredKey("reject");
2253
- const placeOverlay = type({
2254
- "description?": OPTIONAL_STRING,
2255
- "displayName?": OPTIONAL_STRING,
2256
- "filePath?": "string",
2257
- "placeId": ROBLOX_ID_DIGITS,
2258
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2259
- "serverSize?": OPTIONAL_POSITIVE_INTEGER
2260
- }).onUndeclaredKey("reject");
2261
- const placesOverlayCollection = type({ [`[/${RESOURCE_KEY_PATTERN_SOURCE}/]`]: placeOverlay }).onUndeclaredKey("reject");
2262
- const universeOverlay = universeEntry;
2263
- const environmentEntry = type({
2264
- "label?": OPTIONAL_STRING,
2265
- "passes?": passesOverlayCollection,
2266
- "places?": placesOverlayCollection,
2267
- "products?": productsOverlayCollection,
2268
- [REDACTED_KEY]: OPTIONAL_BOOLEAN$2,
2269
- "state?": stateConfig,
2270
- "universe?": universeOverlay
2271
- }).onUndeclaredKey("reject");
2272
- const rootSchema = type({
2273
- "displayNamePrefix?": type({
2274
- "enabled?": OPTIONAL_BOOLEAN$2,
2275
- "format?": OPTIONAL_STRING
2276
- }).onUndeclaredKey("reject"),
2277
- "environments": type({ [`[/${ENV_NAME_PATTERN_SOURCE}/]`]: environmentEntry }).onUndeclaredKey("reject").narrow((value, ctx) => {
2278
- if (Object.keys(value).length === 0) return ctx.mustBe("an environments record with at least one declared environment");
2279
- return true;
2280
- }),
2281
- "extends?": "unknown",
2282
- "passes?": passesCollection,
2283
- "places?": placesCollection,
2284
- "products?": productsCollection,
2285
- "state?": stateConfig,
2286
- "universe?": universeEntry
2287
- }).onUndeclaredKey("reject").narrow((value, ctx) => {
2288
- return collectUniverseIdIssues(value).reduce((_accumulator, issue) => {
2289
- return ctx.reject({
2290
- message: issue.message,
2291
- path: [...issue.path]
2525
+ function toCurrentState(desired, rootPlaceId) {
2526
+ return {
2527
+ ...desired,
2528
+ outputs: { rootPlaceId: asRobloxAssetId(rootPlaceId) }
2529
+ };
2530
+ }
2531
+ function buildParameters(desired) {
2532
+ const base = UNIVERSE_MANAGED_FLAGS.reduce((accumulator, flag) => {
2533
+ const isEnabled = desired[flag];
2534
+ return isEnabled === void 0 ? accumulator : {
2535
+ ...accumulator,
2536
+ [flag]: isEnabled
2537
+ };
2538
+ }, { universeId: desired.universeId });
2539
+ return {
2540
+ ..."privateServerPriceRobux" in desired ? {
2541
+ ...base,
2542
+ privateServerPriceRobux: desired.privateServerPriceRobux
2543
+ } : base,
2544
+ ...copyDeclaredSocialLinks(desired)
2545
+ };
2546
+ }
2547
+ function wrapUpdateError(err, desired) {
2548
+ if (err instanceof ApiError && err.statusCode === 404) return new ApiError(`Universe ${desired.universeId} (key '${desired.key}') was not found; adoption failed`, { statusCode: 404 });
2549
+ return err;
2550
+ }
2551
+ function hasUniverseLevelUpdate(desired) {
2552
+ if (UNIVERSE_MANAGED_FLAGS.some((flag) => desired[flag] !== void 0)) return true;
2553
+ if ("privateServerPriceRobux" in desired) return true;
2554
+ return SOCIAL_LINK_FIELDS.some((field) => field in desired);
2555
+ }
2556
+ async function resolveUniverse(deps, desired) {
2557
+ const result = hasUniverseLevelUpdate(desired) ? await deps.universes.update(buildParameters(desired)) : await deps.universes.get({ universeId: desired.universeId });
2558
+ if (!result.success) return {
2559
+ err: wrapUpdateError(result.err, desired),
2560
+ success: false
2561
+ };
2562
+ const { rootPlaceId } = result.data;
2563
+ if (rootPlaceId === void 0) return {
2564
+ err: new ApiError(`Malformed universe response for ${desired.universeId}: rootPlaceId missing`, { statusCode: 200 }),
2565
+ success: false
2566
+ };
2567
+ return {
2568
+ data: { rootPlaceId },
2569
+ success: true
2570
+ };
2571
+ }
2572
+ async function reconcileUniverse(inputs) {
2573
+ const { deps, desired } = inputs;
2574
+ const universeResult = await resolveUniverse(deps, desired);
2575
+ if (!universeResult.success) return universeResult;
2576
+ const { rootPlaceId } = universeResult.data;
2577
+ if (desired.displayName !== void 0) {
2578
+ const placesResult = await deps.places.update({
2579
+ displayName: desired.displayName,
2580
+ placeId: rootPlaceId,
2581
+ universeId: desired.universeId
2292
2582
  });
2293
- }, true);
2294
- });
2583
+ if (!placesResult.success) return {
2584
+ err: placesResult.err,
2585
+ success: false
2586
+ };
2587
+ }
2588
+ return {
2589
+ data: toCurrentState(desired, rootPlaceId),
2590
+ success: true
2591
+ };
2592
+ }
2593
+ //#endregion
2594
+ //#region src/cli/default-spawner.ts
2295
2595
  /**
2296
- * Validate a parsed config value against the runtime schema. Returns the
2297
- * validated `Config` on success or a `validationFailed` `ConfigError` with
2298
- * one issue per problem, each attributed to a field path. `sourceFile`
2299
- * appears in the error so callers can point a human at the offending file.
2596
+ * Translate a `child.on("close", code, signal)` payload into the
2597
+ * {@link Spawner.spawn} return shape. Extracted from the adapter so the
2598
+ * signal-terminated branch can be exercised without launching a real
2599
+ * process. The caller normalizes node's `null` to `undefined` at the
2600
+ * boundary so this helper never sees `null`.
2601
+ * @param code - Exit code reported by the child, or `undefined` if the
2602
+ * child was terminated by a signal before exiting.
2603
+ * @param signal - Signal name reported by the child, or `undefined` when
2604
+ * no signal terminated it.
2605
+ * @returns `Ok(code)` for a clean exit (including `0`); otherwise
2606
+ * `Err(launchFailed)` carrying a synthetic Error whose message names
2607
+ * the signal.
2608
+ */
2609
+ function classifySpawnClose(code, signal) {
2610
+ if (code !== void 0) return {
2611
+ data: code,
2612
+ success: true
2613
+ };
2614
+ return {
2615
+ err: {
2616
+ cause: /* @__PURE__ */ new Error(`spawned process terminated by signal ${signal ?? "unknown"}`),
2617
+ kind: "launchFailed"
2618
+ },
2619
+ success: false
2620
+ };
2621
+ }
2622
+ /**
2623
+ * Construct a {@link Spawner} backed by `node:child_process.spawn` with
2624
+ * `stdio` inherited from the parent process. The child's environment is
2625
+ * `process.env` overlaid with {@link SpawnInvocation.envOverrides} (overrides
2626
+ * win on key collision).
2627
+ *
2628
+ * - Exit codes resolve as `Ok(exitCode)` (including `0`).
2629
+ * - `ENOENT` and other launch-time errors resolve as `Err(launchFailed)`
2630
+ * with the original error in `cause` (its `code` field carries the
2631
+ * errno where present).
2632
+ * - Children terminated by signal before producing an exit code collapse
2633
+ * into `launchFailed` with a synthetic `Error` whose message names the
2634
+ * signal; a distinct variant lands the day a caller needs to act on the
2635
+ * difference.
2636
+ *
2637
+ * @returns A `Spawner` whose `spawn` settles once the child closes.
2638
+ * @example
2639
+ *
2640
+ * ```ts
2641
+ * import { createDefaultSpawner } from "@bedrock-rbx/core";
2642
+ * import process from "node:process";
2643
+ *
2644
+ * const spawner = createDefaultSpawner();
2645
+ *
2646
+ * return spawner
2647
+ * .spawn({
2648
+ * args: ["-e", "process.exit(0)"],
2649
+ * command: process.execPath,
2650
+ * envOverrides: {},
2651
+ * })
2652
+ * .then((result) => {
2653
+ * expect(result.success).toBeTrue();
2654
+ * if (result.success) {
2655
+ * expect(result.data).toBe(0);
2656
+ * }
2657
+ * });
2658
+ * ```
2659
+ */
2660
+ function createDefaultSpawner() {
2661
+ return { spawn: spawnViaChildProcess };
2662
+ }
2663
+ async function spawnViaChildProcess(invocation) {
2664
+ return new Promise((resolve) => {
2665
+ const child = spawn(invocation.command, [...invocation.args], {
2666
+ env: {
2667
+ ...process.env,
2668
+ ...invocation.envOverrides
2669
+ },
2670
+ stdio: "inherit"
2671
+ });
2672
+ child.once("error", (error) => {
2673
+ resolve({
2674
+ err: {
2675
+ cause: error,
2676
+ kind: "launchFailed"
2677
+ },
2678
+ success: false
2679
+ });
2680
+ });
2681
+ child.once("close", (code, signal) => {
2682
+ resolve(classifySpawnClose(code ?? void 0, signal ?? void 0));
2683
+ });
2684
+ });
2685
+ }
2686
+ //#endregion
2687
+ //#region src/cli/credential-environment-overrides.ts
2688
+ /**
2689
+ * Map CLI credential flags to their corresponding env-var names, omitting
2690
+ * entries whose flag is `undefined`.
2691
+ * @param flags - CLI credential flag values to translate.
2692
+ * @returns An immutable record of env-var names to their override values.
2693
+ */
2694
+ function buildCredentialOverrides(flags) {
2695
+ const overrides = {};
2696
+ if (flags.apiKey !== void 0) overrides["BEDROCK_API_KEY"] = flags.apiKey;
2697
+ if (flags.githubToken !== void 0) overrides["BEDROCK_GITHUB_TOKEN"] = flags.githubToken;
2698
+ return overrides;
2699
+ }
2700
+ //#endregion
2701
+ //#region src/cli/dispatch-override.ts
2702
+ /**
2703
+ * Dispatch a single `.bedrock/<command>.ts` override invocation through the
2704
+ * supplied {@link Spawner}. Encapsulates the spawn protocol:
2705
+ *
2706
+ * - argv = `[overridePath, "--env", environment]`, with `"--config", configFile`
2707
+ * appended when supplied.
2708
+ * - `apiKey` becomes the `BEDROCK_API_KEY` env-var override; `githubToken`
2709
+ * becomes `BEDROCK_GITHUB_TOKEN`. Neither value appears in argv.
2710
+ * - `BEDROCK_CLI=1` is always set in the env. The override's `deploy()`
2711
+ * reads this on the `getEnv` seam to default to the clack progress
2712
+ * adapter; absent that downstream wiring, the variable is a forward-
2713
+ * compatible signal a future caller can act on.
2714
+ *
2715
+ * The dispatcher itself reads no ambient state: every input arrives via the
2716
+ * `invocation` argument and the `Spawner` port is the only side-effect seam.
2717
+ *
2718
+ * @param invocation - Path, environment, and parsed deploy-flag inputs.
2719
+ * @param spawner - Port the dispatcher hands the resolved
2720
+ * {@link SpawnInvocation} to.
2721
+ * @returns `Ok(undefined)` when the child exited zero; otherwise an
2722
+ * {@link SpawnOverrideError} discriminating launch vs non-zero exit.
2300
2723
  *
2301
- * @param input - Parsed value from a config source (object tree from a
2302
- * config loader, or a hand-built literal). Shape is checked, not assumed.
2303
- * @param sourceFile - Path or identifier of the source file, used in the
2304
- * `validationFailed` error.
2305
- * @returns `Ok` with the validated `Config`, or `Err` with a
2306
- * `validationFailed` error carrying each issue's field path.
2307
2724
  * @example
2308
2725
  *
2309
2726
  * ```ts
2310
- * import { validateConfig } from "@bedrock-rbx/core";
2727
+ * import { dispatchOverride, type Spawner } from "@bedrock-rbx/core";
2311
2728
  *
2312
- * const ok = validateConfig(
2313
- * {
2314
- * environments: { production: {} },
2315
- * passes: {
2316
- * "vip-pass": {
2317
- * description: "VIP perks.",
2318
- * icon: { "en-us": "assets/vip.png" },
2319
- * name: "VIP Pass",
2320
- * price: 500,
2321
- * },
2322
- * },
2729
+ * const spawner: Spawner = {
2730
+ * async spawn() {
2731
+ * return { data: 0, success: true };
2323
2732
  * },
2324
- * "bedrock.config.ts",
2325
- * );
2326
- * expect(ok.success).toBeTrue();
2733
+ * };
2327
2734
  *
2328
- * const err = validateConfig(
2329
- * { environments: { production: {} }, passes: { "vip-pass": { name: "VIP" } } },
2330
- * "bedrock.config.ts",
2331
- * );
2332
- * expect(err.success).toBeFalse();
2333
- * if (!err.success) {
2334
- * expect(err.err.kind).toBe("validationFailed");
2335
- * }
2735
+ * return dispatchOverride(
2736
+ * {
2737
+ * environment: "production",
2738
+ * overridePath: "/abs/.bedrock/deploy.ts",
2739
+ * },
2740
+ * spawner,
2741
+ * ).then((result) => {
2742
+ * expect(result.success).toBeTrue();
2743
+ * });
2336
2744
  * ```
2337
2745
  */
2338
- function validateConfig(input, sourceFile) {
2339
- const validated = rootSchema(input);
2340
- if (validated instanceof ArkErrors) return {
2746
+ async function dispatchOverride(invocation, spawner) {
2747
+ const args = [
2748
+ invocation.overridePath,
2749
+ "--env",
2750
+ invocation.environment
2751
+ ];
2752
+ if (invocation.configFile !== void 0) args.push("--config", invocation.configFile);
2753
+ const credentialOverrides = buildCredentialOverrides(invocation);
2754
+ const launched = await spawner.spawn({
2755
+ args,
2756
+ command: "bun",
2757
+ envOverrides: {
2758
+ ...credentialOverrides,
2759
+ BEDROCK_CLI: "1"
2760
+ }
2761
+ });
2762
+ if (!launched.success) return {
2341
2763
  err: {
2342
- issues: Array.from(validated, (issue) => {
2343
- return {
2344
- message: issue.message,
2345
- path: [...issue.path].map((segment) => String(segment))
2346
- };
2347
- }),
2348
- kind: "validationFailed",
2349
- sourceFile
2764
+ cause: launched.err.cause,
2765
+ kind: "launchFailed"
2766
+ },
2767
+ success: false
2768
+ };
2769
+ const exitCode = launched.data;
2770
+ if (exitCode !== 0) return {
2771
+ err: {
2772
+ exitCode,
2773
+ kind: "nonZeroExit"
2350
2774
  },
2351
2775
  success: false
2352
2776
  };
2353
2777
  return {
2354
- data: validated,
2778
+ data: void 0,
2355
2779
  success: true
2356
2780
  };
2357
2781
  }
@@ -2412,8 +2836,19 @@ async function normalize$3(input, io) {
2412
2836
  success: true
2413
2837
  };
2414
2838
  }
2839
+ function changedFieldsBetween$3(desired, current) {
2840
+ return [
2841
+ ...desired.description === current.description ? [] : ["description"],
2842
+ ...desired.icon?.["en-us"] === current.icon?.["en-us"] ? [] : ["icon"],
2843
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2844
+ ...desired.name === current.name ? [] : ["name"],
2845
+ ...desired.price === current.price ? [] : ["price"],
2846
+ ...desired.isRegionalPricingEnabled === void 0 || desired.isRegionalPricingEnabled === current.isRegionalPricingEnabled ? [] : ["isRegionalPricingEnabled"],
2847
+ ...desired.storePageEnabled === void 0 || desired.storePageEnabled === current.storePageEnabled ? [] : ["storePageEnabled"]
2848
+ ];
2849
+ }
2415
2850
  function fieldsEqual$3(desired, current) {
2416
- 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);
2851
+ return changedFieldsBetween$3(desired, current).length === 0;
2417
2852
  }
2418
2853
  function assertReconcilable(current, desired) {
2419
2854
  if (current.iconFileHashes !== void 0 && desired.iconFileHashes === void 0) return {
@@ -2432,10 +2867,11 @@ function assertReconcilable(current, desired) {
2432
2867
  /**
2433
2868
  * Resource-kind module for Roblox developer products. Owns the entry
2434
2869
  * schema, flattening, icon-hash normalization, drift-equality, and the
2435
- * plan-time icon-removal rejection for the `developerProduct` kind.
2870
+ * pre-reconcile icon-removal rejection for the `developerProduct` kind.
2436
2871
  */
2437
2872
  const developerProductKind = {
2438
2873
  assertReconcilable,
2874
+ changedFieldsBetween: changedFieldsBetween$3,
2439
2875
  entrySchema: entrySchema$3,
2440
2876
  fieldsEqual: fieldsEqual$3,
2441
2877
  flatten: flatten$3,
@@ -2482,8 +2918,17 @@ async function normalize$2(input, io) {
2482
2918
  success: true
2483
2919
  };
2484
2920
  }
2921
+ function changedFieldsBetween$2(desired, current) {
2922
+ return [
2923
+ ...desired.description === current.description ? [] : ["description"],
2924
+ ...desired.icon["en-us"] === current.icon["en-us"] ? [] : ["icon"],
2925
+ ...iconHashesEqual(current.iconFileHashes, desired.iconFileHashes) ? [] : ["iconFileHashes"],
2926
+ ...desired.name === current.name ? [] : ["name"],
2927
+ ...desired.price === current.price ? [] : ["price"]
2928
+ ];
2929
+ }
2485
2930
  function fieldsEqual$2(desired, current) {
2486
- 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;
2931
+ return changedFieldsBetween$2(desired, current).length === 0;
2487
2932
  }
2488
2933
  /**
2489
2934
  * Resource-kind module for Roblox game passes. Owns the entry schema,
@@ -2491,6 +2936,7 @@ function fieldsEqual$2(desired, current) {
2491
2936
  * `gamePass` kind.
2492
2937
  */
2493
2938
  const gamePassKind = {
2939
+ changedFieldsBetween: changedFieldsBetween$2,
2494
2940
  entrySchema: entrySchema$2,
2495
2941
  fieldsEqual: fieldsEqual$2,
2496
2942
  flatten: flatten$2,
@@ -2539,12 +2985,19 @@ async function normalize$1(input, io) {
2539
2985
  success: true
2540
2986
  };
2541
2987
  }
2988
+ function changedFieldsBetween$1(desired, current) {
2989
+ return [
2990
+ ...desired.fileHash === current.fileHash ? [] : ["fileHash"],
2991
+ ...desired.filePath === current.filePath ? [] : ["filePath"],
2992
+ ...desired.placeId === current.placeId ? [] : ["placeId"],
2993
+ ...PLACE_MANAGED_METADATA_FIELDS.filter((field) => {
2994
+ const desiredValue = desired[field];
2995
+ return desiredValue !== void 0 && desiredValue !== current[field];
2996
+ })
2997
+ ];
2998
+ }
2542
2999
  function fieldsEqual$1(desired, current) {
2543
- if (desired.fileHash !== current.fileHash || desired.filePath !== current.filePath || desired.placeId !== current.placeId) return false;
2544
- return PLACE_MANAGED_METADATA_FIELDS.every((field) => {
2545
- const desiredValue = desired[field];
2546
- return desiredValue === void 0 || desiredValue === current[field];
2547
- });
3000
+ return changedFieldsBetween$1(desired, current).length === 0;
2548
3001
  }
2549
3002
  /**
2550
3003
  * Resource-kind module for Roblox places. Owns the entry schema,
@@ -2552,6 +3005,7 @@ function fieldsEqual$1(desired, current) {
2552
3005
  * kind.
2553
3006
  */
2554
3007
  const placeKind = {
3008
+ changedFieldsBetween: changedFieldsBetween$1,
2555
3009
  entrySchema: entrySchema$1,
2556
3010
  fieldsEqual: fieldsEqual$1,
2557
3011
  flatten: flatten$1,
@@ -2634,22 +3088,20 @@ function socialLinkEqual(a, b) {
2634
3088
  if (b === void 0) return false;
2635
3089
  return a.title === b.title && a.uri === b.uri;
2636
3090
  }
2637
- function declaredSocialLinksEqual(desired, current) {
2638
- for (const field of SOCIAL_LINK_FIELDS) {
2639
- if (!(field in desired)) continue;
2640
- if (!socialLinkEqual(desired[field], current[field])) return false;
2641
- }
2642
- return true;
3091
+ function changedFieldsBetween(desired, current) {
3092
+ return [
3093
+ ...desired.universeId === current.universeId ? [] : ["universeId"],
3094
+ ...UNIVERSE_MANAGED_FLAGS.filter((flag) => {
3095
+ const isDesiredEnabled = desired[flag];
3096
+ return isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag];
3097
+ }),
3098
+ ...desired.displayName === void 0 || desired.displayName === current.displayName ? [] : ["displayName"],
3099
+ ..."privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux ? ["privateServerPriceRobux"] : [],
3100
+ ...SOCIAL_LINK_FIELDS.filter((field) => field in desired && !socialLinkEqual(desired[field], current[field]))
3101
+ ];
2643
3102
  }
2644
3103
  function fieldsEqual(desired, current) {
2645
- if (desired.universeId !== current.universeId) return false;
2646
- for (const flag of UNIVERSE_MANAGED_FLAGS) {
2647
- const isDesiredEnabled = desired[flag];
2648
- if (isDesiredEnabled !== void 0 && isDesiredEnabled !== current[flag]) return false;
2649
- }
2650
- if (desired.displayName !== void 0 && desired.displayName !== current.displayName) return false;
2651
- if ("privateServerPriceRobux" in desired && desired.privateServerPriceRobux !== current.privateServerPriceRobux) return false;
2652
- return declaredSocialLinksEqual(desired, current);
3104
+ return changedFieldsBetween(desired, current).length === 0;
2653
3105
  }
2654
3106
  //#endregion
2655
3107
  //#region src/core/kinds/index.ts
@@ -2675,6 +3127,7 @@ const defaultKindRegistry = {
2675
3127
  gamePass: gamePassKind,
2676
3128
  place: placeKind,
2677
3129
  universe: {
3130
+ changedFieldsBetween,
2678
3131
  entrySchema,
2679
3132
  fieldsEqual,
2680
3133
  flatten,
@@ -2695,12 +3148,16 @@ const defaultKindRegistry = {
2695
3148
  * `update` op if any declared field differs or a `noop` op if every field
2696
3149
  * matches.
2697
3150
  *
2698
- * Ops appear in the order their desired entries appear in the input array so
2699
- * callers can rely on declaration order when logging or applying ops.
3151
+ * Ops appear in the order their desired entries appear in the input array.
3152
+ * `applyOps` regroups them into Phase 1 (universe) and Phase 2 (everything
3153
+ * else) when dispatching; the execution order within Phase 2 is not
3154
+ * guaranteed because Phase 2 dispatches concurrently. Persisted state-file
3155
+ * order is determined by the merge in `deploy.runReconcile` (which retains
3156
+ * prior-snapshot positions for unchanged keys), not by this diff output.
2700
3157
  *
2701
3158
  * @param desired - Declared desired state from user config, already normalized
2702
3159
  * (file hashes computed, nullable wire values mapped to `undefined`).
2703
- * @param current - Last-known live state from the state file.
3160
+ * @param current - Last-known current state from the state file.
2704
3161
  * @returns Operations to reconcile the two snapshots.
2705
3162
  *
2706
3163
  * @example
@@ -2760,6 +3217,11 @@ const defaultKindRegistry = {
2760
3217
  * const ops = diff([unchanged, drifted, fresh], current);
2761
3218
  *
2762
3219
  * expect(ops.map((op) => op.type)).toEqual(["noop", "update", "create"]);
3220
+ *
3221
+ * const updateOp = ops[1]!;
3222
+ * if (updateOp.type === "update") {
3223
+ * expect(updateOp.changedFields).toStrictEqual(["name"]);
3224
+ * }
2763
3225
  * ```
2764
3226
  */
2765
3227
  function diff(desired, current) {
@@ -2769,21 +3231,21 @@ function diff(desired, current) {
2769
3231
  function compositeKey$1(resource) {
2770
3232
  return `${resource.kind}:${resource.key}`;
2771
3233
  }
2772
- function desiredFieldsEqual(desired, current) {
2773
- return defaultKindRegistry[desired.kind].fieldsEqual(desired, current);
2774
- }
2775
3234
  function operationFor(desired, current) {
2776
3235
  if (current === void 0) return {
2777
3236
  key: desired.key,
2778
3237
  desired,
2779
3238
  type: "create"
2780
3239
  };
2781
- if (desiredFieldsEqual(desired, current)) return {
3240
+ const changedFields = defaultKindRegistry[desired.kind].changedFieldsBetween(desired, current);
3241
+ if (changedFields.length === 0) return {
2782
3242
  key: desired.key,
3243
+ kind: desired.kind,
2783
3244
  type: "noop"
2784
3245
  };
2785
3246
  return {
2786
3247
  key: desired.key,
3248
+ changedFields,
2787
3249
  current,
2788
3250
  desired,
2789
3251
  type: "update"
@@ -2879,79 +3341,89 @@ function capitalize(value) {
2879
3341
  function flattenConfig(config) {
2880
3342
  return Object.values(defaultKindRegistry).flatMap((module) => module.flatten(config));
2881
3343
  }
2882
- //#endregion
2883
- //#region src/core/resolve-state-config.ts
2884
3344
  /**
2885
- * Pick the `StateConfig` that applies to `environment`. Per-environment
2886
- * overrides win over the root block; if neither is present, returns
2887
- * `Err(stateNotConfigured)` so the deploy boundary can surface a typed
2888
- * error instead of silently falling back.
2889
- *
2890
- * @param config - Validated project config.
2891
- * @param environment - Target environment name.
2892
- * @returns The resolved `StateConfig`, or `Err(stateNotConfigured)` when
2893
- * neither the environment override nor the root block is set.
2894
- * @example
2895
- *
2896
- * ```ts
2897
- * import { resolveStateConfig } from "@bedrock-rbx/core";
2898
- *
2899
- * const result = resolveStateConfig(
2900
- * {
2901
- * state: { backend: "gist", gistId: "root-gist" },
2902
- * environments: {
2903
- * production: { state: { backend: "gist", gistId: "prod-gist" } },
2904
- * },
2905
- * },
2906
- * "production",
2907
- * );
3345
+ * Common prefix used to build the default name pushed for a redacted
3346
+ * developer-product. The full default produced by {@link defaultRedactedProductName}
3347
+ * is `${REDACTED_PRODUCT_NAME} ${suffix}`, where `suffix` is a 6-hex-char
3348
+ * digest of the resource key (see {@link redactedNameSuffix}). The suffix is
3349
+ * required because Roblox enforces per-universe uniqueness on
3350
+ * developer-product names, so a shared bare placeholder would collide across
3351
+ * multiple redacted entries. The prefix avoids the word `Redacted` and the
3352
+ * `#` separator because Roblox's text-moderation filter has been observed
3353
+ * silently replacing names matching `Redacted Product #<hex>` with
3354
+ * `########################`, which then causes downstream `DuplicateProductName`
3355
+ * errors when other redacted entries are moderated to the same string.
3356
+ */
3357
+ const REDACTED_PRODUCT_NAME = "Hidden Product";
3358
+ const PASS_PRODUCT_ENV_FIELDS = [
3359
+ "description",
3360
+ "icon",
3361
+ "name",
3362
+ "price"
3363
+ ];
3364
+ const PLACE_ENV_FIELDS = ["description", "displayName"];
3365
+ /**
3366
+ * Six-character lowercase hex digest of `SHA-256(key)`, used as the
3367
+ * disambiguating suffix on a redacted developer-product's default `name`.
3368
+ * Stable across config edits (driven only by the bedrock resource key, not
3369
+ * declaration order) and opaque to a Roblox player browsing the marketplace.
3370
+ * A natural collision is caught before any apply-side driver I/O by `assertAllReconcilable`.
3371
+ *
3372
+ * @param key - Bedrock resource key for the developer product being redacted.
3373
+ * @returns The first six lowercase hex characters of the SHA-256 digest of `key`.
3374
+ */
3375
+ function redactedNameSuffix(key) {
3376
+ return createHash("sha256").update(key).digest("hex").slice(0, 6);
3377
+ }
3378
+ /**
3379
+ * Default redacted name for a developer product with the given resource key.
3380
+ * Combines {@link REDACTED_PRODUCT_NAME} with {@link redactedNameSuffix} so
3381
+ * each redacted entry resolves to a unique value the upstream API will accept.
2908
3382
  *
2909
- * expect(result.success).toBeTrue();
2910
- * if (result.success) {
2911
- * expect(result.data).toContainEntry(["gistId", "prod-gist"]);
2912
- * }
2913
- * ```
3383
+ * @param key - Bedrock resource key for the developer product being redacted.
3384
+ * @returns The placeholder name pushed to Roblox for this product.
2914
3385
  */
2915
- function resolveStateConfig(config, environment) {
2916
- const override = config.environments[environment]?.state;
2917
- if (override !== void 0) return {
2918
- data: override,
2919
- success: true
2920
- };
2921
- if (config.state !== void 0) return {
2922
- data: config.state,
2923
- success: true
2924
- };
2925
- return {
2926
- err: {
2927
- environment,
2928
- kind: "stateNotConfigured"
2929
- },
2930
- success: false
2931
- };
3386
+ function defaultRedactedProductName(key) {
3387
+ return `${REDACTED_PRODUCT_NAME} ${redactedNameSuffix(key)}`;
2932
3388
  }
2933
3389
  /**
2934
3390
  * Pure transform that substitutes bedrock-supplied placeholder content for
2935
- * every resource whose effective `redacted` flag is truthy. The effective
2936
- * flag is the per-resource `redacted` value when set, otherwise the
2937
- * `environmentRedacted` fallback. A `redacted` object form replaces
2938
- * matching fields with the supplied values and falls back to the bedrock
2939
- * defaults for the rest. Runs between env-overlay merge and display-name
2940
- * prefix render so the rest of the pipeline (flatten, normalize, diff,
2941
- * apply) operates on already-redacted values and needs no special-case
2942
- * redaction logic.
3391
+ * every resource whose effective redaction state is truthy. Three layers
3392
+ * compose field-by-field per resource: env-resource (most-specific, from
3393
+ * `inputs.envResource`), root-resource (the `redacted` field on the
3394
+ * passed-in entry), and env-level (least-specific, `inputs.envLevel`).
3395
+ * The first non-undefined value sets state (`false` carves out); object
3396
+ * layers then contribute fields with the most-specific layer winning per
3397
+ * field, and bedrock defaults fill any field nobody set. Runs between
3398
+ * env-overlay merge and display-name prefix render so the rest of the
3399
+ * pipeline (flatten, normalize, diff, apply) operates on already-redacted
3400
+ * values and needs no special-case redaction logic.
2943
3401
  *
2944
3402
  * @param config - Post-merge `ResolvedConfig` produced by `selectEnvironment`.
2945
- * @param environmentRedacted - Environment-level redaction toggle. Resources
2946
- * that omit a per-resource `redacted` flag inherit this value.
3403
+ * @param inputs - Aggregated redaction layers. Omit to skip redaction
3404
+ * entirely. See {@link RedactionInputs} for the shape.
2947
3405
  * @returns A `ResolvedConfig` whose redacted entries carry placeholder
2948
3406
  * values; non-redacted entries pass through verbatim, and the input is
2949
3407
  * not mutated.
2950
3408
  */
2951
- function applyRedaction(config, environmentRedacted = false) {
2952
- const passes = redactPasses(config.passes, environmentRedacted);
2953
- const places = redactPlaces(config.places, environmentRedacted);
2954
- const products = redactProducts(config.products, environmentRedacted);
3409
+ function applyRedaction(config, inputs) {
3410
+ const environmentLevel = inputs?.envLevel;
3411
+ const environmentResource = inputs?.envResource;
3412
+ const passes = redactPasses({
3413
+ collection: config.passes,
3414
+ envLevel: environmentLevel,
3415
+ envResource: environmentResource?.passes
3416
+ });
3417
+ const places = redactPlaces({
3418
+ collection: config.places,
3419
+ envLevel: environmentLevel,
3420
+ envResource: environmentResource?.places
3421
+ });
3422
+ const products = redactProducts({
3423
+ collection: config.products,
3424
+ envLevel: environmentLevel,
3425
+ envResource: environmentResource?.products
3426
+ });
2955
3427
  if (passes === config.passes && places === config.places && products === config.products) return config;
2956
3428
  return {
2957
3429
  ...config,
@@ -2962,9 +3434,10 @@ function applyRedaction(config, environmentRedacted = false) {
2962
3434
  }
2963
3435
  /**
2964
3436
  * Inspect the pre-redaction merged config and produce one annotation per
2965
- * resource flagged `redacted: true`. Callers thread the result into plan
2966
- * output so authors can see which resources are redacted in the active
2967
- * environment and whether their real-value edits are being suppressed.
3437
+ * resource flagged `redacted: true` at either the root entry or its
3438
+ * env-overlay counterpart. Callers thread the result into plan output so
3439
+ * authors can see which resources are redacted in the active environment
3440
+ * and whether their real-value edits are being suppressed.
2968
3441
  *
2969
3442
  * Operates on the pre-redaction view because the post-redaction config no
2970
3443
  * longer carries the real `name`/`description`/`icon` values needed to
@@ -2972,42 +3445,107 @@ function applyRedaction(config, environmentRedacted = false) {
2972
3445
  *
2973
3446
  * @param merged - `ResolvedConfig` produced by environment overlay merge,
2974
3447
  * before `applyRedaction` has substituted placeholders.
3448
+ * @param environmentResource - Per-kind env-overlay redaction layers
3449
+ * extracted from the active env entry. Omit when the caller has no
3450
+ * env-overlay layer.
2975
3451
  * @returns Zero or more annotations, one per redacted resource. Empty when
2976
3452
  * the config declares no redacted resources.
2977
3453
  */
2978
- function collectRedactionAnnotations(merged) {
2979
- const passes = Object.entries(merged.passes ?? {}).filter(([, entry]) => entry.redacted === true).map(([key, entry]) => {
3454
+ function collectRedactionAnnotations(merged, environmentResource) {
3455
+ const passes = Object.entries(merged.passes ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.passes?.[key] === true).map(([key, entry]) => {
2980
3456
  return {
2981
3457
  key: asResourceKey(key),
2982
3458
  hasRealValueEdits: passHasRealValueEdits(entry),
2983
3459
  kind: "gamePass"
2984
3460
  };
2985
3461
  });
2986
- const products = Object.entries(merged.products ?? {}).filter(([, entry]) => entry.redacted === true).map(([key, entry]) => {
3462
+ const products = Object.entries(merged.products ?? {}).filter(([key, entry]) => entry.redacted === true || environmentResource?.products?.[key] === true).map(([key, entry]) => {
2987
3463
  return {
2988
3464
  key: asResourceKey(key),
2989
- hasRealValueEdits: productHasRealValueEdits(entry),
3465
+ hasRealValueEdits: productHasRealValueEdits(key, entry),
2990
3466
  kind: "developerProduct"
2991
3467
  };
2992
3468
  });
2993
3469
  return [...passes, ...products];
2994
3470
  }
3471
+ function pickEnvironmentFields(environmentLevel, fields) {
3472
+ if (environmentLevel === void 0 || typeof environmentLevel === "boolean") return environmentLevel;
3473
+ return Object.fromEntries(fields.map((field) => [field, environmentLevel[field]]));
3474
+ }
3475
+ /**
3476
+ * Walk redaction layers most-specific to least-specific and produce the
3477
+ * effective per-field override for one resource. Returns `undefined` when the
3478
+ * resource is not redacted; returns a (possibly empty) object when it is.
3479
+ * State step: the first non-undefined layer sets state -- `false` carves out,
3480
+ * `true` or object enables. Fields step: walk every object layer in the same
3481
+ * order, taking the first value per field. A field's value may itself be
3482
+ * `undefined` (the env-level projection produced by {@link pickEnvironmentFields}
3483
+ * includes every projected key, even when the env override left it absent);
3484
+ * downstream per-kind redact functions collapse those back to bedrock
3485
+ * placeholder defaults via `??`.
3486
+ *
3487
+ * @template Override - Per-kind override type the resource accepts.
3488
+ * @param layers - Layers ordered most-specific (index 0) to least-specific.
3489
+ * @returns The effective override, or `undefined` when not redacted.
3490
+ */
3491
+ function resolveEffectiveOverride(layers) {
3492
+ const firstNonUndefined = layers.find((layer) => layer !== void 0);
3493
+ if (firstNonUndefined === void 0 || firstNonUndefined === false) return;
3494
+ const effective = {};
3495
+ for (const layer of layers) {
3496
+ if (typeof layer !== "object") continue;
3497
+ for (const [field, value] of Object.entries(layer)) if (!(field in effective)) effective[field] = value;
3498
+ }
3499
+ return effective;
3500
+ }
3501
+ function resolveEntries(inputs) {
3502
+ const { collection, environmentForKind, envResource } = inputs;
3503
+ return Object.entries(collection).map(([key, entry]) => {
3504
+ return {
3505
+ key,
3506
+ entry,
3507
+ override: resolveEffectiveOverride([
3508
+ envResource?.[key],
3509
+ entry.redacted,
3510
+ environmentForKind
3511
+ ])
3512
+ };
3513
+ });
3514
+ }
3515
+ function redactCollection(inputs) {
3516
+ const { collection, environmentForKind, envResource, redact } = inputs;
3517
+ if (collection === void 0) return;
3518
+ const resolved = resolveEntries({
3519
+ collection,
3520
+ environmentForKind,
3521
+ envResource
3522
+ });
3523
+ if (resolved.every((item) => item.override === void 0)) return collection;
3524
+ return Object.fromEntries(resolved.map((item) => {
3525
+ return item.override === void 0 ? [item.key, item.entry] : [item.key, redact({
3526
+ key: item.key,
3527
+ entry: item.entry,
3528
+ override: item.override
3529
+ })];
3530
+ }));
3531
+ }
2995
3532
  function redactPass(entry, override) {
2996
3533
  return {
2997
3534
  ...entry,
2998
3535
  name: override.name ?? "Redacted Pass",
2999
3536
  description: override.description ?? "",
3000
- icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" }
3537
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3538
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3001
3539
  };
3002
3540
  }
3003
- function redactPasses(passes, environmentRedacted) {
3004
- if (passes === void 0) return;
3005
- if (!Object.values(passes).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return passes;
3006
- return Object.fromEntries(Object.entries(passes).map(([key, entry]) => {
3007
- const effective = entry.redacted ?? environmentRedacted;
3008
- if (effective === false) return [key, entry];
3009
- return [key, redactPass(entry, typeof effective === "object" ? effective : {})];
3010
- }));
3541
+ function redactPasses(inputs) {
3542
+ const { collection, envLevel, envResource } = inputs;
3543
+ return redactCollection({
3544
+ collection,
3545
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3546
+ envResource,
3547
+ redact: (item) => redactPass(item.entry, item.override)
3548
+ });
3011
3549
  }
3012
3550
  function redactPlace(entry, override) {
3013
3551
  return {
@@ -3016,37 +3554,39 @@ function redactPlace(entry, override) {
3016
3554
  displayName: override.displayName ?? entry.displayName
3017
3555
  };
3018
3556
  }
3019
- function redactPlaces(places, environmentRedacted) {
3020
- if (places === void 0) return;
3021
- if (!Object.values(places).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return places;
3022
- return Object.fromEntries(Object.entries(places).map(([key, entry]) => {
3023
- const effective = entry.redacted ?? environmentRedacted;
3024
- if (effective === false) return [key, entry];
3025
- return [key, redactPlace(entry, typeof effective === "object" ? effective : {})];
3026
- }));
3557
+ function redactPlaces(inputs) {
3558
+ const { collection, envLevel, envResource } = inputs;
3559
+ return redactCollection({
3560
+ collection,
3561
+ environmentForKind: pickEnvironmentFields(envLevel, PLACE_ENV_FIELDS),
3562
+ envResource,
3563
+ redact: (item) => redactPlace(item.entry, item.override)
3564
+ });
3027
3565
  }
3028
- function redactProduct(entry, override) {
3566
+ function redactProduct(inputs) {
3567
+ const { key, entry, override } = inputs;
3029
3568
  return {
3030
3569
  ...entry,
3031
- name: override.name ?? "Redacted Product",
3570
+ name: override.name ?? defaultRedactedProductName(key),
3032
3571
  description: override.description ?? "",
3033
- icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" }
3572
+ icon: override.icon ?? { "en-us": "<bedrock:redacted-icon.png>" },
3573
+ ...entry.price === void 0 ? {} : { price: override.price ?? 99999 }
3034
3574
  };
3035
3575
  }
3036
- function redactProducts(products, environmentRedacted) {
3037
- if (products === void 0) return;
3038
- if (!Object.values(products).some((entry) => (entry.redacted ?? environmentRedacted) !== false)) return products;
3039
- return Object.fromEntries(Object.entries(products).map(([key, entry]) => {
3040
- const effective = entry.redacted ?? environmentRedacted;
3041
- if (effective === false) return [key, entry];
3042
- return [key, redactProduct(entry, typeof effective === "object" ? effective : {})];
3043
- }));
3576
+ function redactProducts(inputs) {
3577
+ const { collection, envLevel, envResource } = inputs;
3578
+ return redactCollection({
3579
+ collection,
3580
+ environmentForKind: pickEnvironmentFields(envLevel, PASS_PRODUCT_ENV_FIELDS),
3581
+ envResource,
3582
+ redact: redactProduct
3583
+ });
3044
3584
  }
3045
3585
  function passHasRealValueEdits(entry) {
3046
- return entry.name !== "Redacted Pass" || entry.description !== "" || entry.icon["en-us"] !== "<bedrock:redacted-icon.png>";
3586
+ return entry.name !== "Redacted Pass" || entry.description !== "" || entry.icon["en-us"] !== "<bedrock:redacted-icon.png>" || entry.price !== void 0 && entry.price !== 99999;
3047
3587
  }
3048
- function productHasRealValueEdits(entry) {
3049
- return entry.name !== "Redacted Product" || entry.description !== "" || entry.icon !== void 0 && entry.icon["en-us"] !== "<bedrock:redacted-icon.png>";
3588
+ function productHasRealValueEdits(key, entry) {
3589
+ 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;
3050
3590
  }
3051
3591
  //#endregion
3052
3592
  //#region src/core/select-environment.ts
@@ -3096,6 +3636,22 @@ function selectMergedEnvironment(config, environment) {
3096
3636
  };
3097
3637
  }
3098
3638
  /**
3639
+ * Build the per-resource env-overlay redaction layer that `applyRedaction`
3640
+ * and `collectRedactionAnnotations` consume. Reads each redactable kind off
3641
+ * the environment entry and projects every entry's `redacted` field into
3642
+ * the layer; omits kinds the env entry does not declare.
3643
+ *
3644
+ * @param entry - Environment entry whose overlay redaction values to extract.
3645
+ * @returns A `EnvironmentResourceRedaction` ready to pass downstream.
3646
+ */
3647
+ function extractResourceRedaction(entry) {
3648
+ return {
3649
+ ...entry.passes ? { passes: extractRedactionLayer(entry.passes) } : {},
3650
+ ...entry.places ? { places: extractRedactionLayer(entry.places) } : {},
3651
+ ...entry.products ? { products: extractRedactionLayer(entry.products) } : {}
3652
+ };
3653
+ }
3654
+ /**
3099
3655
  * Project a validated `Config` onto a single environment. Looks up the
3100
3656
  * matching `environments[environment]` entry, deep-merges its resource
3101
3657
  * overlay (`passes`, `places`, `universe`) over the root config via defu,
@@ -3226,10 +3782,17 @@ function mergeUniverse(overlay, base) {
3226
3782
  if (overlay === void 0 && base === void 0) return;
3227
3783
  return defu(overlay ?? {}, base ?? {});
3228
3784
  }
3785
+ function stripRedacted(overlay) {
3786
+ if (overlay === void 0) return;
3787
+ return Object.fromEntries(Object.entries(overlay).map(([key, entryValue]) => {
3788
+ const { redacted: _redacted, ...rest } = entryValue;
3789
+ return [key, rest];
3790
+ }));
3791
+ }
3229
3792
  function mergeOverlays(config, entry) {
3230
- const passes = mergeKeyedRecord(entry.passes, config.passes);
3231
- const places = mergeKeyedRecord(entry.places, config.places);
3232
- const products = mergeKeyedRecord(entry.products, config.products);
3793
+ const passes = mergeKeyedRecord(stripRedacted(entry.passes), config.passes);
3794
+ const places = mergeKeyedRecord(stripRedacted(entry.places), config.places);
3795
+ const products = mergeKeyedRecord(stripRedacted(entry.products), config.products);
3233
3796
  const universe = mergeUniverse(entry.universe, config.universe);
3234
3797
  const state = entry.state ?? config.state;
3235
3798
  const { places: _placesRoot, products: _productsRoot, universe: _universeRoot, ...rest } = config;
@@ -3277,6 +3840,11 @@ function findIncompletePlace(projected, environment) {
3277
3840
  };
3278
3841
  }
3279
3842
  }
3843
+ function extractRedactionLayer(overlay) {
3844
+ const layer = {};
3845
+ for (const [key, entryValue] of Object.entries(overlay)) if (entryValue.redacted !== void 0) layer[key] = entryValue.redacted;
3846
+ return layer;
3847
+ }
3280
3848
  function resolvePrefix(config, entry) {
3281
3849
  if (config.displayNamePrefix?.enabled === false) return;
3282
3850
  const { label } = entry;
@@ -3302,7 +3870,10 @@ function applyPlacesPrefix(places, prefix) {
3302
3870
  }
3303
3871
  function redactAndPrefix(inputs) {
3304
3872
  const { config, entry, merged } = inputs;
3305
- const redacted = applyRedaction(merged, entry.redacted);
3873
+ const redacted = applyRedaction(merged, {
3874
+ envLevel: entry.redacted,
3875
+ envResource: extractResourceRedaction(entry)
3876
+ });
3306
3877
  const prefix = resolvePrefix(config, entry);
3307
3878
  const places = applyPlacesPrefix(redacted.places, prefix);
3308
3879
  const universe = applyUniversePrefix(redacted.universe, prefix);
@@ -3313,212 +3884,95 @@ function redactAndPrefix(inputs) {
3313
3884
  };
3314
3885
  }
3315
3886
  //#endregion
3316
- //#region src/core/validate-plan.ts
3317
- /**
3318
- * Plan-time invariant check that runs after `buildDesired` and before
3319
- * `diff`. Walks paired `(kind, key)` entries and dispatches to each
3320
- * kind module's optional `assertReconcilable` hook so kind-specific
3321
- * rejections (e.g. Removing a developer-product icon, which the upstream
3322
- * API has no documented unset path for) surface as typed errors before
3323
- * `diff` runs and before any apply-side driver I/O is attempted.
3324
- *
3325
- * Pure and synchronous. Current-only entries (no matching desired) are
3326
- * ignored: their reconciliation is `diff`'s concern, not this seam's.
3327
- *
3328
- * @param desired - Desired state from `buildDesired`.
3329
- * @param current - Prior current state from the state port.
3330
- * @returns `Ok(undefined)` when every paired entry passes its kind-level
3331
- * reconcilability check, or the first `Err` returned by a hook.
3332
- *
3333
- * @example
3334
- *
3335
- * ```ts
3336
- * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3337
- *
3338
- * const result = validatePlan(
3339
- * [
3340
- * {
3341
- * description: "Stocks the player up with 1,000 premium gems.",
3342
- * isRegionalPricingEnabled: undefined,
3343
- * key: asResourceKey("gem-pack"),
3344
- * kind: "developerProduct",
3345
- * name: "Gem Pack",
3346
- * price: undefined,
3347
- * storePageEnabled: undefined,
3348
- * },
3349
- * ],
3350
- * [
3351
- * {
3352
- * description: "Stocks the player up with 1,000 premium gems.",
3353
- * icon: { "en-us": "assets/gem-pack.png" },
3354
- * iconFileHashes: {
3355
- * "en-us": asSha256Hex(
3356
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3357
- * ),
3358
- * },
3359
- * isRegionalPricingEnabled: undefined,
3360
- * key: asResourceKey("gem-pack"),
3361
- * kind: "developerProduct",
3362
- * name: "Gem Pack",
3363
- * outputs: { productId: asRobloxAssetId("9876543210") },
3364
- * price: undefined,
3365
- * storePageEnabled: undefined,
3366
- * },
3367
- * ],
3368
- * );
3369
- *
3370
- * expect(result.success).toBeFalse();
3371
- * if (!result.success) {
3372
- * expect(result.err.kind).toBe("iconRemovalRejected");
3373
- * }
3374
- * ```
3375
- */
3376
- function validatePlan(desired, current) {
3377
- const currentByKey = new Map(current.map((entry) => [compositeKey(entry), entry]));
3378
- for (const entry of desired) {
3379
- const matched = currentByKey.get(compositeKey(entry));
3380
- if (matched === void 0) continue;
3381
- const check = defaultKindRegistry[entry.kind].assertReconcilable?.(matched, entry);
3382
- if (check !== void 0 && !check.success) return check;
3383
- }
3384
- return {
3385
- data: void 0,
3386
- success: true
3387
- };
3388
- }
3389
- function compositeKey(resource) {
3390
- return `${resource.kind}:${resource.key}`;
3391
- }
3392
- //#endregion
3393
3887
  //#region src/shell/apply-ops.ts
3394
3888
  /**
3395
- * Dispatch each reconciliation operation to the matching resource driver
3396
- * with first-fail semantics: on the first `Err` (driver failure or
3397
- * `updateUnsupported`), the remaining operations are skipped and the error
3398
- * is returned verbatim.
3889
+ * Dispatch reconciliation operations to their matching drivers in two phases
3890
+ * with continue-on-failure semantics. Phase 1 runs universe ops sequentially
3891
+ * (singleton per environment; sequencing it before everything else avoids the
3892
+ * `displayName` race against the root `Place`). Phase 2 dispatches every
3893
+ * remaining non-noop op concurrently via `Promise.all`; every op is
3894
+ * attempted regardless of earlier failures.
3399
3895
  *
3400
3896
  * Behaviour:
3401
- * - `create` operations are routed to `registry[op.desired.kind].create`.
3402
- * - `update` operations are routed to `registry[op.desired.kind].update`
3403
- * when the driver exposes it; otherwise they short-circuit to an
3404
- * `updateUnsupported` Err without invoking the driver.
3897
+ * - `create` operations route to `registry[op.desired.kind].create`.
3898
+ * - `update` operations route to `registry[op.desired.kind].update` when the
3899
+ * driver exposes it; otherwise they yield an `updateUnsupported`
3900
+ * `ApplyError` without invoking the driver.
3405
3901
  * - `noop` operations are skipped entirely (no I/O, no dispatch).
3406
- *
3407
- * On success the returned array carries the driver outputs for every
3408
- * non-noop op, in dispatched order. Noops are not represented; callers
3409
- * needing a full post-apply snapshot merge with the pre-apply current
3410
- * state keyed by `ResourceKey`.
3411
- *
3412
- * @param ops - Reconciliation operations produced by `diff`, applied in order.
3413
- * @param registry - Per-kind driver table; dispatch uses `op.desired.kind` as the index.
3414
- * @returns `Ok(state)` when every operation succeeds, where `state` holds
3415
- * driver outputs for each non-noop op in dispatched order; or the first
3416
- * failure encountered.
3417
- * @throws Whatever the dispatched driver rejects with outside its `Result`
3418
- * return. A driver whose injected I/O (file reads, network calls, etc.)
3419
- * throws will surface that rejection here rather than translating it into
3420
- * a `Result` failure; wrap the call site in a try/catch when drivers are
3421
- * not trusted to contain their own rejections.
3902
+ * - A driver that throws outside its `Result` contract is caught at the
3903
+ * dispatch boundary and translated to an `unexpectedThrow` `ApplyError`
3904
+ * scoped to that op alone; the rest of the batch keeps running.
3905
+ *
3906
+ * On Ok the returned array carries driver outputs for every non-noop op
3907
+ * in phase order: Phase 1 universe entries first, then Phase 2 entries in
3908
+ * their input order. Noops are not represented; callers needing a full
3909
+ * post-apply snapshot merge with the pre-apply current state keyed by
3910
+ * `ResourceKey`.
3911
+ *
3912
+ * On Err the aggregate carries every survivor in `applied` (Phase 1 first,
3913
+ * then Phase 2 input order) and every failure in `failures` with the same
3914
+ * grouping. Neither array reflects completion order.
3915
+ *
3916
+ * @param ops - Reconciliation operations produced by `diff`, applied in
3917
+ * declaration order.
3918
+ * @param registry - Per-kind driver table; dispatch uses `op.desired.kind`
3919
+ * as the index.
3920
+ * @param reporting - Optional progress wiring. When supplied, `applyOps`
3921
+ * emits one `resourceOpStarted` and one terminal event per non-noop op,
3922
+ * one `resourceOpNoop` per noop op, and a final `applySummary` carrying
3923
+ * the per-type counts and the wall-clock apply duration. When omitted,
3924
+ * no events fire.
3925
+ * @returns `Ok(state)` when every op succeeded; otherwise
3926
+ * `Err(AggregateApplyError)` with the survivors and the non-empty
3927
+ * failures tuple.
3422
3928
  * @example
3423
3929
  *
3424
3930
  * ```ts
3425
- * import {
3426
- * applyOps,
3427
- * asResourceKey,
3428
- * asRobloxAssetId,
3429
- * asSha256Hex,
3430
- * type DriverRegistry,
3431
- * type Operation,
3432
- * } from "@bedrock-rbx/core";
3931
+ * import { applyOps, type DriverRegistry } from "@bedrock-rbx/core";
3433
3932
  *
3434
- * const registry: DriverRegistry = {
3435
- * gamePass: {
3436
- * async create(desired) {
3437
- * return {
3438
- * data: {
3439
- * ...desired,
3440
- * outputs: {
3441
- * assetId: asRobloxAssetId("9876543210"),
3442
- * iconAssetIds: { "en-us": asRobloxAssetId("1122334455") },
3443
- * },
3444
- * },
3445
- * success: true,
3446
- * };
3447
- * },
3448
- * },
3449
- * place: {
3450
- * async create(desired) {
3451
- * return {
3452
- * data: { ...desired, outputs: { versionNumber: 1 } },
3453
- * success: true,
3454
- * };
3455
- * },
3456
- * },
3457
- * universe: {
3458
- * async create(desired) {
3459
- * return {
3460
- * data: { ...desired, outputs: { rootPlaceId: asRobloxAssetId("4711") } },
3461
- * success: true,
3462
- * };
3463
- * },
3464
- * },
3465
- * developerProduct: {
3466
- * async create(desired) {
3467
- * return {
3468
- * data: {
3469
- * ...desired,
3470
- * outputs: { productId: asRobloxAssetId("8172635495") },
3471
- * },
3472
- * success: true,
3473
- * };
3474
- * },
3475
- * },
3933
+ * const noopRegistry: DriverRegistry = {
3934
+ * developerProduct: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3935
+ * gamePass: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3936
+ * place: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3937
+ * universe: { create: async () => ({ err: new Error("stub") as never, success: false }) },
3476
3938
  * };
3477
3939
  *
3478
- * const ops: ReadonlyArray<Operation> = [
3479
- * {
3480
- * key: asResourceKey("vip-pass"),
3481
- * type: "create",
3482
- * desired: {
3483
- * key: asResourceKey("vip-pass"),
3484
- * name: "VIP Pass",
3485
- * description: "Grants VIP perks.",
3486
- * icon: { "en-us": "assets/vip-icon.png" },
3487
- * iconFileHashes: {
3488
- * "en-us": asSha256Hex(
3489
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3490
- * ),
3491
- * },
3492
- * kind: "gamePass",
3493
- * price: 500,
3494
- * },
3495
- * },
3496
- * ];
3497
- *
3498
- * return applyOps(ops, registry).then((result) => {
3499
- * expect(result.success).toBe(true);
3500
- * expect(result.success && result.data).toHaveLength(1);
3940
+ * return applyOps([], noopRegistry).then((result) => {
3941
+ * expect(result).toStrictEqual({ data: [], success: true });
3501
3942
  * });
3502
3943
  * ```
3503
3944
  */
3504
- async function applyOps(ops, registry) {
3505
- const applied = [];
3506
- for (const op of ops) {
3507
- if (op.type === "noop") continue;
3508
- const outcome = await dispatchOp(op, registry);
3509
- if (!outcome.success) return {
3510
- err: {
3511
- ...outcome.err,
3512
- appliedSoFar: applied
3513
- },
3514
- success: false
3515
- };
3516
- applied.push(outcome.data);
3517
- }
3518
- return {
3945
+ async function applyOps(ops, registry, reporting) {
3946
+ const start = Date.now();
3947
+ const { noopCount, phase1, phase2 } = partitionAndEmitNoops(ops, reporting);
3948
+ const pairs = await dispatchInPhases({
3949
+ phase1,
3950
+ phase2,
3951
+ registry,
3952
+ reporting
3953
+ });
3954
+ const end = Date.now();
3955
+ const { applied, failures } = partitionOutcomes(pairs.map((pair) => pair.outcome));
3956
+ emitApplySummary({
3957
+ end,
3958
+ failures,
3959
+ noopCount,
3960
+ pairs,
3961
+ reporting,
3962
+ start
3963
+ });
3964
+ const [head, ...tail] = failures;
3965
+ if (head === void 0) return {
3519
3966
  data: applied,
3520
3967
  success: true
3521
3968
  };
3969
+ return {
3970
+ err: {
3971
+ applied,
3972
+ failures: [head, ...tail]
3973
+ },
3974
+ success: false
3975
+ };
3522
3976
  }
3523
3977
  function driverFailure(key, cause) {
3524
3978
  return {
@@ -3552,7 +4006,7 @@ async function applyOne(op, driver) {
3552
4006
  const updated = await driver.update(op.current, op.desired);
3553
4007
  return updated.success ? updated : driverFailure(op.key, updated.err);
3554
4008
  }
3555
- async function dispatchOp(op, registry) {
4009
+ async function dispatchByKind(op, registry) {
3556
4010
  switch (op.desired.kind) {
3557
4011
  case "developerProduct": return applyOne(op, registry.developerProduct);
3558
4012
  case "gamePass": return applyOne(op, registry.gamePass);
@@ -3560,6 +4014,161 @@ async function dispatchOp(op, registry) {
3560
4014
  case "universe": return applyOne(op, registry.universe);
3561
4015
  }
3562
4016
  }
4017
+ async function dispatchOp(op, registry) {
4018
+ try {
4019
+ return await dispatchByKind(op, registry);
4020
+ } catch (err) {
4021
+ return {
4022
+ err: {
4023
+ key: op.key,
4024
+ cause: err,
4025
+ kind: "unexpectedThrow"
4026
+ },
4027
+ success: false
4028
+ };
4029
+ }
4030
+ }
4031
+ function createSucceededEvent(input) {
4032
+ const { key, environment, state } = input;
4033
+ switch (state.kind) {
4034
+ case "developerProduct": return {
4035
+ key,
4036
+ environment,
4037
+ kind: "resourceOpSucceeded",
4038
+ opType: "create",
4039
+ outputs: state.outputs,
4040
+ resourceKind: "developerProduct"
4041
+ };
4042
+ case "gamePass": return {
4043
+ key,
4044
+ environment,
4045
+ kind: "resourceOpSucceeded",
4046
+ opType: "create",
4047
+ outputs: state.outputs,
4048
+ resourceKind: "gamePass"
4049
+ };
4050
+ case "place": return {
4051
+ key,
4052
+ environment,
4053
+ kind: "resourceOpSucceeded",
4054
+ opType: "create",
4055
+ outputs: state.outputs,
4056
+ resourceKind: "place"
4057
+ };
4058
+ case "universe": return {
4059
+ key,
4060
+ environment,
4061
+ kind: "resourceOpSucceeded",
4062
+ opType: "create",
4063
+ outputs: state.outputs,
4064
+ resourceKind: "universe"
4065
+ };
4066
+ }
4067
+ }
4068
+ function toTerminalEvent(input) {
4069
+ const { environment, op, outcome } = input;
4070
+ if (!outcome.success) return {
4071
+ key: op.key,
4072
+ environment,
4073
+ error: outcome.err,
4074
+ kind: "resourceOpFailed",
4075
+ opType: op.type,
4076
+ resourceKind: op.desired.kind
4077
+ };
4078
+ if (op.type === "update") return {
4079
+ key: op.key,
4080
+ changedFields: op.changedFields,
4081
+ environment,
4082
+ kind: "resourceOpSucceeded",
4083
+ opType: "update",
4084
+ resourceKind: op.desired.kind
4085
+ };
4086
+ return createSucceededEvent({
4087
+ key: op.key,
4088
+ environment,
4089
+ state: outcome.data
4090
+ });
4091
+ }
4092
+ async function reportAndDispatch(input) {
4093
+ const { op, registry, reporting } = input;
4094
+ if (reporting !== void 0) reporting.progress.emit({
4095
+ key: op.key,
4096
+ environment: reporting.environment,
4097
+ kind: "resourceOpStarted",
4098
+ opType: op.type,
4099
+ resourceKind: op.desired.kind
4100
+ });
4101
+ const outcome = await dispatchOp(op, registry);
4102
+ if (reporting !== void 0) reporting.progress.emit(toTerminalEvent({
4103
+ environment: reporting.environment,
4104
+ op,
4105
+ outcome
4106
+ }));
4107
+ return {
4108
+ op,
4109
+ outcome
4110
+ };
4111
+ }
4112
+ async function dispatchInPhases(input) {
4113
+ const phase1Pairs = [];
4114
+ for (const op of input.phase1) phase1Pairs.push(await reportAndDispatch({
4115
+ op,
4116
+ registry: input.registry,
4117
+ reporting: input.reporting
4118
+ }));
4119
+ const phase2Pairs = await Promise.all(input.phase2.map(async (op) => {
4120
+ return reportAndDispatch({
4121
+ op,
4122
+ registry: input.registry,
4123
+ reporting: input.reporting
4124
+ });
4125
+ }));
4126
+ return [...phase1Pairs, ...phase2Pairs];
4127
+ }
4128
+ function emitApplySummary(input) {
4129
+ if (input.reporting === void 0) return;
4130
+ const created = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "create").length;
4131
+ const updated = input.pairs.filter((pair) => pair.outcome.success && pair.op.type === "update").length;
4132
+ input.reporting.progress.emit({
4133
+ created,
4134
+ durationMs: input.end - input.start,
4135
+ environment: input.reporting.environment,
4136
+ failed: input.failures.length,
4137
+ kind: "applySummary",
4138
+ noop: input.noopCount,
4139
+ updated
4140
+ });
4141
+ }
4142
+ function partitionOutcomes(outcomes) {
4143
+ return {
4144
+ applied: outcomes.flatMap((outcome) => outcome.success ? [outcome.data] : []),
4145
+ failures: outcomes.flatMap((outcome) => outcome.success ? [] : [outcome.err])
4146
+ };
4147
+ }
4148
+ function emitNoop(op, reporting) {
4149
+ if (reporting === void 0) return;
4150
+ reporting.progress.emit({
4151
+ key: op.key,
4152
+ environment: reporting.environment,
4153
+ kind: "resourceOpNoop",
4154
+ resourceKind: op.kind
4155
+ });
4156
+ }
4157
+ function partitionAndEmitNoops(ops, reporting) {
4158
+ const phase1 = [];
4159
+ const phase2 = [];
4160
+ let noopCount = 0;
4161
+ for (const op of ops) if (op.type === "noop") {
4162
+ noopCount += 1;
4163
+ emitNoop(op, reporting);
4164
+ } else if (op.desired.kind === "universe") phase1.push(op);
4165
+ else phase2.push(op);
4166
+ return {
4167
+ noopCount,
4168
+ phase1,
4169
+ phase2
4170
+ };
4171
+ }
3563
4172
  //#endregion
3564
4173
  //#region src/shell/build-default-registry.ts
3565
4174
  /**
@@ -3728,7 +4337,7 @@ const STATE_PORT_HINT = "pass a custom statePort via opts.statePort";
3728
4337
  * const port = buildStatePort({
3729
4338
  * fetch: async () =>
3730
4339
  * new Response(JSON.stringify({ files: {} }), { status: 200 }),
3731
- * getEnv: (name) => (name === "GITHUB_TOKEN" ? "ghp_example" : undefined),
4340
+ * getEnv: (name) => (name === "BEDROCK_GITHUB_TOKEN" ? "ghp_example" : undefined),
3732
4341
  * stateConfig: { backend: "gist", gistId: "abc123" },
3733
4342
  * });
3734
4343
  *
@@ -3751,12 +4360,12 @@ function buildStatePort(deps) {
3751
4360
  };
3752
4361
  }
3753
4362
  function buildGistStatePort(stateConfig, deps) {
3754
- const token = deps.getEnv("GITHUB_TOKEN");
4363
+ const token = deps.getEnv("BEDROCK_GITHUB_TOKEN") ?? deps.getEnv("GITHUB_TOKEN");
3755
4364
  if (token === void 0) return {
3756
4365
  err: {
3757
4366
  kind: "missingCredential",
3758
4367
  purpose: "stateBackend",
3759
- variable: "GITHUB_TOKEN"
4368
+ variable: "BEDROCK_GITHUB_TOKEN"
3760
4369
  },
3761
4370
  success: false
3762
4371
  };
@@ -3770,6 +4379,62 @@ function buildGistStatePort(stateConfig, deps) {
3770
4379
  };
3771
4380
  }
3772
4381
  //#endregion
4382
+ //#region src/core/assert-all-reconcilable.ts
4383
+ /**
4384
+ * Batch reconcilability check that runs after `buildDesired` and before
4385
+ * `diff`. Walks paired `(kind, key)` entries and dispatches to each
4386
+ * kind module's optional `assertReconcilable` hook so kind-specific
4387
+ * rejections (e.g. Removing a developer-product icon, which the upstream
4388
+ * API has no documented unset path for) surface as typed errors before
4389
+ * `diff` runs and before any apply-side driver I/O is attempted.
4390
+ *
4391
+ * Pure and synchronous. Current-only entries (no matching desired) are
4392
+ * ignored: their reconciliation is `diff`'s concern, not this seam's.
4393
+ *
4394
+ * @param desired - Desired state from `buildDesired`.
4395
+ * @param current - Prior current state from the state port.
4396
+ * @returns `Ok(undefined)` when every paired entry passes its kind-level
4397
+ * reconcilability check, or the first `Err` returned by a hook.
4398
+ */
4399
+ function assertAllReconcilable(desired, current) {
4400
+ const collision = detectProductNameCollision(desired);
4401
+ if (collision !== void 0) return collision;
4402
+ const currentByKey = new Map(current.map((entry) => [compositeKey(entry), entry]));
4403
+ for (const entry of desired) {
4404
+ const matched = currentByKey.get(compositeKey(entry));
4405
+ if (matched === void 0) continue;
4406
+ const check = defaultKindRegistry[entry.kind].assertReconcilable?.(matched, entry);
4407
+ if (check !== void 0 && !check.success) return check;
4408
+ }
4409
+ return {
4410
+ data: void 0,
4411
+ success: true
4412
+ };
4413
+ }
4414
+ function compositeKey(resource) {
4415
+ return `${resource.kind}:${resource.key}`;
4416
+ }
4417
+ function detectProductNameCollision(desired) {
4418
+ const seenByName = /* @__PURE__ */ new Map();
4419
+ for (const entry of desired) {
4420
+ if (entry.kind !== "developerProduct") continue;
4421
+ const prior = seenByName.get(entry.name);
4422
+ if (prior === void 0) {
4423
+ seenByName.set(entry.name, entry.key);
4424
+ continue;
4425
+ }
4426
+ return {
4427
+ err: {
4428
+ keys: [prior, entry.key],
4429
+ kind: "redactedNameCollision",
4430
+ 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.`,
4431
+ resolvedName: entry.name
4432
+ },
4433
+ success: false
4434
+ };
4435
+ }
4436
+ }
4437
+ //#endregion
3773
4438
  //#region src/shell/load-config-internal.ts
3774
4439
  const LUAU_BOOTSTRAP_TEMP_PREFIX = "bedrock-luau-bootstrap-";
3775
4440
  /**
@@ -4143,10 +4808,26 @@ function attributeLoadError(err, cwd) {
4143
4808
  //#endregion
4144
4809
  //#region src/shell/deploy.ts
4145
4810
  /**
4811
+ * Decide whether `BEDROCK_CLI` should select the clack-backed default
4812
+ * progress adapter. Exported for direct unit coverage of the boundary
4813
+ * (`undefined` and empty string both flip to no-op; any non-empty value
4814
+ * picks clack).
4815
+ *
4816
+ * @param value - Raw `BEDROCK_CLI` value as returned by `getEnv`.
4817
+ * @returns `true` if the clack adapter should be the default.
4818
+ */
4819
+ function isCliEnvironmentFlagSet(value) {
4820
+ return value !== void 0 && value !== "";
4821
+ }
4822
+ /**
4146
4823
  * Run a full reconcile end-to-end. Default-constructs missing deps from
4147
- * the project config and the environment variables `GITHUB_TOKEN` and
4148
- * `BEDROCK_API_KEY`; never reads `process.env` when `statePort`,
4149
- * `registry`, and `config` are all supplied explicitly.
4824
+ * the project config and the environment variables `BEDROCK_GITHUB_TOKEN`
4825
+ * and `BEDROCK_API_KEY`; emits a terminal `deploySuccess` or `deployFailure`
4826
+ * event through the resolved `progress` port. When `progress` is omitted,
4827
+ * the default port comes from `BEDROCK_CLI`: a non-empty value selects the
4828
+ * clack-backed adapter, any other reading selects the no-op adapter. No
4829
+ * environment lookups happen when `statePort`, `registry`, `config`, and
4830
+ * `progress` are all supplied explicitly.
4150
4831
  *
4151
4832
  * @param options - Target environment plus optional overrides.
4152
4833
  * @returns The persisted `BedrockState` on success, or a stage-tagged
@@ -4207,9 +4888,15 @@ function attributeLoadError(err, cwd) {
4207
4888
  * ```
4208
4889
  */
4209
4890
  async function deploy(options) {
4210
- const resolved = await resolveDeps(options);
4211
- if (!resolved.success) return resolved;
4212
- return runReconcile(options.environment, resolved.data);
4891
+ if (options.progress !== void 0) return runAndEmit(options, options.progress);
4892
+ if (!isCliEnvironmentFlagSet(getEnvironmentOf(options)("BEDROCK_CLI"))) return runAndEmit(options, createNoOpProgressAdapter());
4893
+ return runWithDeferredClackProgress(options);
4894
+ }
4895
+ function readProcessEnvironment(name) {
4896
+ return process.env[name];
4897
+ }
4898
+ function getEnvironmentOf(options) {
4899
+ return options.getEnv ?? readProcessEnvironment;
4213
4900
  }
4214
4901
  async function pickConfig(options) {
4215
4902
  if (options.config !== void 0) return {
@@ -4229,12 +4916,6 @@ async function pickConfig(options) {
4229
4916
  success: true
4230
4917
  };
4231
4918
  }
4232
- function readProcessEnvironment(name) {
4233
- return process.env[name];
4234
- }
4235
- function getEnvironmentOf(options) {
4236
- return options.getEnv ?? readProcessEnvironment;
4237
- }
4238
4919
  function pickStatePort(options, config) {
4239
4920
  if (options.statePort !== void 0) return {
4240
4921
  data: options.statePort,
@@ -4297,7 +4978,7 @@ function mergeResources(pre, applied) {
4297
4978
  return [...byKey.values()];
4298
4979
  }
4299
4980
  function buildSnapshot(inputs) {
4300
- const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.appliedSoFar;
4981
+ const appliedResources = inputs.applied.success ? inputs.applied.data : inputs.applied.err.applied;
4301
4982
  return {
4302
4983
  environment: inputs.environment,
4303
4984
  resources: mergeResources(inputs.priorResources, appliedResources),
@@ -4305,13 +4986,6 @@ function buildSnapshot(inputs) {
4305
4986
  };
4306
4987
  }
4307
4988
  function finalize(inputs) {
4308
- if (!inputs.applied.success) return {
4309
- err: {
4310
- cause: inputs.applied.err,
4311
- kind: "applyFailed"
4312
- },
4313
- success: false
4314
- };
4315
4989
  if (!inputs.written.success) return {
4316
4990
  err: {
4317
4991
  cause: inputs.written.err,
@@ -4320,6 +4994,13 @@ function finalize(inputs) {
4320
4994
  },
4321
4995
  success: false
4322
4996
  };
4997
+ if (!inputs.applied.success) return {
4998
+ err: {
4999
+ cause: inputs.applied.err,
5000
+ kind: "applyFailed"
5001
+ },
5002
+ success: false
5003
+ };
4323
5004
  return {
4324
5005
  data: inputs.merged,
4325
5006
  success: true
@@ -4343,7 +5024,7 @@ async function runReconcile(environment, deps) {
4343
5024
  success: false
4344
5025
  };
4345
5026
  const priorResources = prior.data?.resources ?? [];
4346
- const validated = validatePlan(desired.data, priorResources);
5027
+ const validated = assertAllReconcilable(desired.data, priorResources);
4347
5028
  if (!validated.success) return {
4348
5029
  err: {
4349
5030
  cause: validated.err,
@@ -4351,17 +5032,80 @@ async function runReconcile(environment, deps) {
4351
5032
  },
4352
5033
  success: false
4353
5034
  };
4354
- const applied = await applyOps(diff(desired.data, priorResources), deps.registry);
5035
+ const applied = await applyOps(diff(desired.data, priorResources), deps.registry, {
5036
+ environment,
5037
+ progress: deps.progress
5038
+ });
4355
5039
  const merged = buildSnapshot({
4356
5040
  applied,
4357
5041
  environment,
4358
5042
  priorResources
4359
5043
  });
5044
+ const written = await deps.statePort.write(merged);
5045
+ if (written.success) deps.progress.emit({
5046
+ environment,
5047
+ kind: "stateWritten"
5048
+ });
4360
5049
  return finalize({
4361
5050
  applied,
4362
5051
  merged,
4363
- written: await deps.statePort.write(merged)
5052
+ written
5053
+ });
5054
+ }
5055
+ async function runDeploy(options, progress) {
5056
+ const resolved = await resolveDeps(options);
5057
+ if (!resolved.success) return resolved;
5058
+ return runReconcile(options.environment, {
5059
+ ...resolved.data,
5060
+ progress
5061
+ });
5062
+ }
5063
+ function emitTerminalEvent(inputs) {
5064
+ const { environment, progress, result } = inputs;
5065
+ if (result.success) {
5066
+ progress.emit({
5067
+ environment,
5068
+ kind: "deploySuccess",
5069
+ resourceCount: result.data.resources.length
5070
+ });
5071
+ return;
5072
+ }
5073
+ progress.emit({
5074
+ environment,
5075
+ error: result.err,
5076
+ kind: "deployFailure"
5077
+ });
5078
+ }
5079
+ async function runAndEmit(options, progress) {
5080
+ const result = await runDeploy(options, progress);
5081
+ emitTerminalEvent({
5082
+ environment: options.environment,
5083
+ progress,
5084
+ result
5085
+ });
5086
+ return result;
5087
+ }
5088
+ async function runWithDeferredClackProgress(options) {
5089
+ const resolved = await resolveDeps(options);
5090
+ const progress = createDefaultProgressAdapter(resolved.success ? resolved.data.config : options.config);
5091
+ if (!resolved.success) {
5092
+ emitTerminalEvent({
5093
+ environment: options.environment,
5094
+ progress,
5095
+ result: resolved
5096
+ });
5097
+ return resolved;
5098
+ }
5099
+ const result = await runReconcile(options.environment, {
5100
+ ...resolved.data,
5101
+ progress
5102
+ });
5103
+ emitTerminalEvent({
5104
+ environment: options.environment,
5105
+ progress,
5106
+ result
4364
5107
  });
5108
+ return result;
4365
5109
  }
4366
5110
  //#endregion
4367
5111
  //#region src/core/migrate/build-state.ts
@@ -5483,7 +6227,7 @@ const PRODUCT_ICON_KIND = "productIcon";
5483
6227
  * and the Roblox-assigned `iconImageAssetId` lands on the outputs.
5484
6228
  *
5485
6229
  * Resources whose payload is malformed (non-object, missing required string
5486
- * field, missing `productId`, malformed `fileHash`) are dropped silently.
6230
+ * field, missing `assetId`, malformed `fileHash`) are dropped silently.
5487
6231
  * Orphan `productIcon_<k>` resources (no matching product) emit one
5488
6232
  * `ambiguous` warning each.
5489
6233
  *
@@ -5546,7 +6290,7 @@ function readProductInputs(raw) {
5546
6290
  }
5547
6291
  function readProductOutputs(raw) {
5548
6292
  if (!isObjectPayload$1(raw)) return;
5549
- const productId = coerceRobloxId$2(raw["productId"]);
6293
+ const productId = coerceRobloxId$2(raw["assetId"]);
5550
6294
  if (productId === void 0) return;
5551
6295
  return { productId };
5552
6296
  }
@@ -6513,6 +7257,6 @@ function isFileMissing(err) {
6513
7257
  return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" && FILE_MISSING_CODES.has(err.code);
6514
7258
  }
6515
7259
  //#endregion
6516
- export { createGamePassDriver as A, createClackProgressAdapter as B, createPlaceDriver as C, parseStateFile as D, createGistStateAdapter as E, asSha256Hex as F, renderMigrationSummary as G, renderDeployError as H, isResourceKey as I, renderParseError as K, isRobloxAssetId as L, shouldReuploadIcon as M, asResourceKey as N, serializeStateFile as O, asRobloxAssetId as P, isSha256Hex as R, createUniverseDriver as S, UNIVERSE_SINGLETON_KEY as T, renderMigrateError as U, renderBuildStatePortError as V, renderMigrateParseError as W, diff as _, buildStatePort as a, validateConfig as b, applyOps as c, selectMergedEnvironment as d, collectRedactionAnnotations as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, createDeveloperProductDriver as j, validateEnvironmentName as k, validatePlan as l, flattenConfig as m, serializeConfig as n, buildDesired as o, resolveStateConfig as p, renderStateWriteError as q, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, selectEnvironment as u, defaultKindRegistry as v, SOCIAL_LINK_FIELDS as w, createClackPort as x, isGistStateConfig as y, derivePriceFields as z };
7260
+ export { renderStateWriteError as $, createGamePassDriver as A, asSha256Hex as B, createPlaceDriver as C, createGistStateAdapter as D, createNoOpProgressAdapter as E, validateConfig as F, renderBuildStatePortError as G, isRobloxAssetId as H, shouldReuploadIcon as I, renderMigrateParseError as J, renderDeployError as K, validateEnvironmentName as L, derivePriceFields as M, createClackProgressAdapter as N, parseStateFile as O, isGistStateConfig as P, renderParseError as Q, asResourceKey as R, createUniverseDriver as S, UNIVERSE_SINGLETON_KEY as T, isSha256Hex as U, isResourceKey as V, resolveStateConfig as W, renderOverrideDiscoveryError as X, renderMigrationSummary as Y, renderOverrideError as Z, diff as _, assertAllReconcilable as a, buildCredentialOverrides as b, buildDefaultRegistry as c, selectEnvironment as d, createClackPort as et, selectMergedEnvironment as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, createDeveloperProductDriver as j, serializeStateFile as k, applyOps as l, flattenConfig as m, serializeConfig as n, buildStatePort as o, collectRedactionAnnotations as p, renderMigrateError as q, deploy as r, buildDesired as s, migrateMantleState as t, extractResourceRedaction as u, defaultKindRegistry as v, SOCIAL_LINK_FIELDS as w, createDefaultSpawner as x, dispatchOverride as y, asRobloxAssetId as z };
6517
7261
 
6518
- //# sourceMappingURL=migrate-mantle-state-CQjWBZwT.mjs.map
7262
+ //# sourceMappingURL=migrate-mantle-state-ClQ40EFD.mjs.map