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