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