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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
+ import { cancel, intro, log, outro } from "@clack/prompts";
1
2
  import { ApiError, PermissionError } from "@bedrock-rbx/ocale";
2
3
  import { ArkErrors, type } from "arktype";
3
- import { cancel, intro, log, outro } from "@clack/prompts";
4
+ import { execFile, spawn } from "node:child_process";
4
5
  import process from "node:process";
5
6
  import { defu } from "defu";
6
7
  import { createHash } from "node:crypto";
@@ -12,9 +13,51 @@ import { readFile } from "node:fs/promises";
12
13
  import { loadConfig } from "c12";
13
14
  import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
14
15
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
15
- import { execFile } from "node:child_process";
16
16
  import { tmpdir } from "node:os";
17
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
18
61
  //#region src/cli/render.ts
19
62
  /**
20
63
  * Render a `DeployError` to the supplied `ClackPort`. Most variants emit a
@@ -46,6 +89,30 @@ function renderParseError(err, port) {
46
89
  port.logError(parseErrorMessage(err));
47
90
  }
48
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
+ /**
49
116
  * Render a `ParseMigrateError` to the supplied `ClackPort`. Reuses
50
117
  * `parseErrorMessage` for the three flag-shape variants and adds a
51
118
  * dedicated message for `unknownSource` listing the supported sources.
@@ -182,6 +249,11 @@ function parseErrorMessage(err) {
182
249
  case "unknownFlag": return `unknown flag '--${err.flag}'`;
183
250
  }
184
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
+ }
185
257
  function migrateParseErrorMessage(err) {
186
258
  if (err.kind === "unknownSource") return `unknown migration source '${err.received}' (supported: ${err.supported.join(", ")})`;
187
259
  return parseErrorMessage(err);
@@ -1235,6 +1307,27 @@ function createClackProgressAdapter(deps) {
1235
1307
  renderEvent(event, deps);
1236
1308
  } };
1237
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
+ }
1238
1331
  function applySummaryLine(event) {
1239
1332
  return `Succeeded in ${(event.durationMs / 1e3).toFixed(1)}s: ${[
1240
1333
  `${event.created} create`,
@@ -1800,7 +1893,9 @@ const GITHUB_API_BASE = "https://api.github.com";
1800
1893
  const GITHUB_API_VERSION = "2026-03-10";
1801
1894
  const USER_AGENT = "bedrock";
1802
1895
  const MAX_INLINE_BYTES = 1e7;
1803
- const MAX_RETRIES = 3;
1896
+ const MAX_RETRIES = 6;
1897
+ const BASE_BACKOFF_MS = 500;
1898
+ const MAX_BACKOFF_MS = 16e3;
1804
1899
  const RETRYABLE_STATUSES = new Set([
1805
1900
  409,
1806
1901
  502,
@@ -1843,6 +1938,7 @@ function createGistStateAdapter(deps) {
1843
1938
  const ctx = {
1844
1939
  fetchFn: deps.fetch ?? globalThis.fetch.bind(globalThis),
1845
1940
  gistId: deps.gistId,
1941
+ random: deps.random ?? Math.random,
1846
1942
  sleep: deps.sleep ?? defaultSleep,
1847
1943
  token: deps.token
1848
1944
  };
@@ -1938,14 +2034,15 @@ async function sendGet(ctx) {
1938
2034
  function isRetryableStatus(status) {
1939
2035
  return RETRYABLE_STATUSES.has(status);
1940
2036
  }
1941
- function backoffMs(attempt) {
1942
- return 1e3 * 2 ** attempt;
2037
+ function backoffMs(attempt, random) {
2038
+ const half = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt) / 2;
2039
+ return half + random() * half;
1943
2040
  }
1944
- async function withRetry(sleep, operation) {
2041
+ async function withRetry(retry, operation) {
1945
2042
  let response = await operation();
1946
2043
  for (let attempt = 0; attempt < MAX_RETRIES; attempt += 1) {
1947
2044
  if (response.ok || !isRetryableStatus(response.status)) return response;
1948
- await sleep(backoffMs(attempt));
2045
+ await retry.sleep(backoffMs(attempt, retry.random));
1949
2046
  response = await operation();
1950
2047
  }
1951
2048
  return response;
@@ -1953,7 +2050,7 @@ async function withRetry(sleep, operation) {
1953
2050
  async function fetchGistBody(ctx, file) {
1954
2051
  let response;
1955
2052
  try {
1956
- response = await withRetry(ctx.sleep, async () => sendGet(ctx));
2053
+ response = await withRetry(ctx, async () => sendGet(ctx));
1957
2054
  } catch (err) {
1958
2055
  return {
1959
2056
  err: networkError(err, file),
@@ -1983,14 +2080,14 @@ function stateErr(file, reason) {
1983
2080
  success: false
1984
2081
  };
1985
2082
  }
1986
- async function readGistContent({ entry, fetchFn, file, sleep }) {
2083
+ async function readGistContent({ entry, fetchFn, file, retry }) {
1987
2084
  if (entry.size > MAX_INLINE_BYTES) return stateErr(file, `state file too large: ${entry.size} bytes`);
1988
2085
  if (entry.isTruncated) {
1989
2086
  if (entry.rawUrl === void 0) return stateErr(file, "truncated gist file missing raw_url");
1990
2087
  const { rawUrl } = entry;
1991
2088
  let rawResponse;
1992
2089
  try {
1993
- rawResponse = await withRetry(sleep, async () => fetchFn(rawUrl));
2090
+ rawResponse = await withRetry(retry, async () => fetchFn(rawUrl));
1994
2091
  } catch (err) {
1995
2092
  return {
1996
2093
  err: networkError(err, file),
@@ -2016,7 +2113,7 @@ async function readPath(ctx, environment) {
2016
2113
  entry,
2017
2114
  fetchFn: ctx.fetchFn,
2018
2115
  file,
2019
- sleep: ctx.sleep
2116
+ retry: ctx
2020
2117
  });
2021
2118
  }
2022
2119
  async function sendPatch(ctx, body) {
@@ -2065,7 +2162,7 @@ async function writePath(ctx, state) {
2065
2162
  const body = JSON.stringify({ files: { [fileName(state.environment)]: { content: serializeStateFile(state) } } });
2066
2163
  let response;
2067
2164
  try {
2068
- response = await withRetry(ctx.sleep, async () => sendPatch(ctx, body));
2165
+ response = await withRetry(ctx, async () => sendPatch(ctx, body));
2069
2166
  } catch (err) {
2070
2167
  return {
2071
2168
  err: networkError(err, file),
@@ -2092,6 +2189,30 @@ async function writePath(ctx, state) {
2092
2189
  };
2093
2190
  }
2094
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
2095
2216
  //#region src/core/resources.ts
2096
2217
  /**
2097
2218
  * Ordered list of optional metadata fields the driver routes through
@@ -2470,46 +2591,192 @@ async function reconcileUniverse(inputs) {
2470
2591
  };
2471
2592
  }
2472
2593
  //#endregion
2473
- //#region src/cli/clack-port.ts
2594
+ //#region src/cli/default-spawner.ts
2474
2595
  /**
2475
- * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
2476
- * resulting port writes to `process.stdout` via clack's defaults. Kept in
2477
- * its own module so consumers that never need the clack-backed rendering
2478
- * (programmatic deploys, custom adapters) do not pull `@clack/prompts`
2479
- * into their bundle.
2480
- *
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.
2481
2638
  * @example
2482
2639
  *
2483
2640
  * ```ts
2484
- * import { createClackPort } from "@bedrock-rbx/core";
2641
+ * import { createDefaultSpawner } from "@bedrock-rbx/core";
2642
+ * import process from "node:process";
2485
2643
  *
2486
- * const port = createClackPort();
2644
+ * const spawner = createDefaultSpawner();
2487
2645
  *
2488
- * expect(typeof port.logSuccess).toBe("function");
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
+ * });
2489
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.
2490
2723
  *
2491
- * @returns A port whose six methods each invoke the matching clack helper.
2724
+ * @example
2725
+ *
2726
+ * ```ts
2727
+ * import { dispatchOverride, type Spawner } from "@bedrock-rbx/core";
2728
+ *
2729
+ * const spawner: Spawner = {
2730
+ * async spawn() {
2731
+ * return { data: 0, success: true };
2732
+ * },
2733
+ * };
2734
+ *
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
+ * });
2744
+ * ```
2492
2745
  */
2493
- function createClackPort() {
2494
- return {
2495
- cancel: (message) => {
2496
- cancel(message);
2497
- },
2498
- intro: (message) => {
2499
- intro(message);
2500
- },
2501
- logError: (message) => {
2502
- log.error(message);
2503
- },
2504
- logMessage: (message) => {
2505
- log.message(message);
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 {
2763
+ err: {
2764
+ cause: launched.err.cause,
2765
+ kind: "launchFailed"
2506
2766
  },
2507
- logSuccess: (message) => {
2508
- log.success(message);
2767
+ success: false
2768
+ };
2769
+ const exitCode = launched.data;
2770
+ if (exitCode !== 0) return {
2771
+ err: {
2772
+ exitCode,
2773
+ kind: "nonZeroExit"
2509
2774
  },
2510
- outro: (message) => {
2511
- outro(message);
2512
- }
2775
+ success: false
2776
+ };
2777
+ return {
2778
+ data: void 0,
2779
+ success: true
2513
2780
  };
2514
2781
  }
2515
2782
  //#endregion
@@ -2600,7 +2867,7 @@ function assertReconcilable(current, desired) {
2600
2867
  /**
2601
2868
  * Resource-kind module for Roblox developer products. Owns the entry
2602
2869
  * schema, flattening, icon-hash normalization, drift-equality, and the
2603
- * plan-time icon-removal rejection for the `developerProduct` kind.
2870
+ * pre-reconcile icon-removal rejection for the `developerProduct` kind.
2604
2871
  */
2605
2872
  const developerProductKind = {
2606
2873
  assertReconcilable,
@@ -2890,7 +3157,7 @@ const defaultKindRegistry = {
2890
3157
  *
2891
3158
  * @param desired - Declared desired state from user config, already normalized
2892
3159
  * (file hashes computed, nullable wire values mapped to `undefined`).
2893
- * @param current - Last-known live state from the state file.
3160
+ * @param current - Last-known current state from the state file.
2894
3161
  * @returns Operations to reconcile the two snapshots.
2895
3162
  *
2896
3163
  * @example
@@ -3100,7 +3367,7 @@ const PLACE_ENV_FIELDS = ["description", "displayName"];
3100
3367
  * disambiguating suffix on a redacted developer-product's default `name`.
3101
3368
  * Stable across config edits (driven only by the bedrock resource key, not
3102
3369
  * declaration order) and opaque to a Roblox player browsing the marketplace.
3103
- * A natural collision is caught at plan time by `validatePlan`.
3370
+ * A natural collision is caught before any apply-side driver I/O by `assertAllReconcilable`.
3104
3371
  *
3105
3372
  * @param key - Bedrock resource key for the developer product being redacted.
3106
3373
  * @returns The first six lowercase hex characters of the SHA-256 digest of `key`.
@@ -3617,105 +3884,6 @@ function redactAndPrefix(inputs) {
3617
3884
  };
3618
3885
  }
3619
3886
  //#endregion
3620
- //#region src/core/validate-plan.ts
3621
- /**
3622
- * Plan-time invariant check that runs after `buildDesired` and before
3623
- * `diff`. Walks paired `(kind, key)` entries and dispatches to each
3624
- * kind module's optional `assertReconcilable` hook so kind-specific
3625
- * rejections (e.g. Removing a developer-product icon, which the upstream
3626
- * API has no documented unset path for) surface as typed errors before
3627
- * `diff` runs and before any apply-side driver I/O is attempted.
3628
- *
3629
- * Pure and synchronous. Current-only entries (no matching desired) are
3630
- * ignored: their reconciliation is `diff`'s concern, not this seam's.
3631
- *
3632
- * @param desired - Desired state from `buildDesired`.
3633
- * @param current - Prior current state from the state port.
3634
- * @returns `Ok(undefined)` when every paired entry passes its kind-level
3635
- * reconcilability check, or the first `Err` returned by a hook.
3636
- *
3637
- * @example
3638
- *
3639
- * ```ts
3640
- * import { asResourceKey, asRobloxAssetId, asSha256Hex, validatePlan } from "@bedrock-rbx/core";
3641
- *
3642
- * const result = validatePlan(
3643
- * [
3644
- * {
3645
- * description: "Stocks the player up with 1,000 premium gems.",
3646
- * isRegionalPricingEnabled: undefined,
3647
- * key: asResourceKey("gem-pack"),
3648
- * kind: "developerProduct",
3649
- * name: "Gem Pack",
3650
- * price: undefined,
3651
- * storePageEnabled: undefined,
3652
- * },
3653
- * ],
3654
- * [
3655
- * {
3656
- * description: "Stocks the player up with 1,000 premium gems.",
3657
- * icon: { "en-us": "assets/gem-pack.png" },
3658
- * iconFileHashes: {
3659
- * "en-us": asSha256Hex(
3660
- * "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
3661
- * ),
3662
- * },
3663
- * isRegionalPricingEnabled: undefined,
3664
- * key: asResourceKey("gem-pack"),
3665
- * kind: "developerProduct",
3666
- * name: "Gem Pack",
3667
- * outputs: { productId: asRobloxAssetId("9876543210") },
3668
- * price: undefined,
3669
- * storePageEnabled: undefined,
3670
- * },
3671
- * ],
3672
- * );
3673
- *
3674
- * expect(result.success).toBeFalse();
3675
- * if (!result.success) {
3676
- * expect(result.err.kind).toBe("iconRemovalRejected");
3677
- * }
3678
- * ```
3679
- */
3680
- function validatePlan(desired, current) {
3681
- const collision = detectProductNameCollision(desired);
3682
- if (collision !== void 0) return collision;
3683
- const currentByKey = new Map(current.map((entry) => [compositeKey(entry), entry]));
3684
- for (const entry of desired) {
3685
- const matched = currentByKey.get(compositeKey(entry));
3686
- if (matched === void 0) continue;
3687
- const check = defaultKindRegistry[entry.kind].assertReconcilable?.(matched, entry);
3688
- if (check !== void 0 && !check.success) return check;
3689
- }
3690
- return {
3691
- data: void 0,
3692
- success: true
3693
- };
3694
- }
3695
- function compositeKey(resource) {
3696
- return `${resource.kind}:${resource.key}`;
3697
- }
3698
- function detectProductNameCollision(desired) {
3699
- const seenByName = /* @__PURE__ */ new Map();
3700
- for (const entry of desired) {
3701
- if (entry.kind !== "developerProduct") continue;
3702
- const prior = seenByName.get(entry.name);
3703
- if (prior === void 0) {
3704
- seenByName.set(entry.name, entry.key);
3705
- continue;
3706
- }
3707
- return {
3708
- err: {
3709
- keys: [prior, entry.key],
3710
- kind: "redactedNameCollision",
3711
- message: `developer products '${prior}' and '${entry.key}' both resolve to the wire name '${entry.name}'. Roblox enforces per-universe uniqueness on developer-product names, so the second update would be rejected as DuplicateProductName. Set 'redacted: { name: "<unique>" }' on one of them to disambiguate.`,
3712
- resolvedName: entry.name
3713
- },
3714
- success: false
3715
- };
3716
- }
3717
- }
3718
- //#endregion
3719
3887
  //#region src/shell/apply-ops.ts
3720
3888
  /**
3721
3889
  * Dispatch reconciliation operations to their matching drivers in two phases
@@ -4169,7 +4337,7 @@ const STATE_PORT_HINT = "pass a custom statePort via opts.statePort";
4169
4337
  * const port = buildStatePort({
4170
4338
  * fetch: async () =>
4171
4339
  * new Response(JSON.stringify({ files: {} }), { status: 200 }),
4172
- * getEnv: (name) => (name === "GITHUB_TOKEN" ? "ghp_example" : undefined),
4340
+ * getEnv: (name) => (name === "BEDROCK_GITHUB_TOKEN" ? "ghp_example" : undefined),
4173
4341
  * stateConfig: { backend: "gist", gistId: "abc123" },
4174
4342
  * });
4175
4343
  *
@@ -4192,12 +4360,12 @@ function buildStatePort(deps) {
4192
4360
  };
4193
4361
  }
4194
4362
  function buildGistStatePort(stateConfig, deps) {
4195
- const token = deps.getEnv("GITHUB_TOKEN");
4363
+ const token = deps.getEnv("BEDROCK_GITHUB_TOKEN") ?? deps.getEnv("GITHUB_TOKEN");
4196
4364
  if (token === void 0) return {
4197
4365
  err: {
4198
4366
  kind: "missingCredential",
4199
4367
  purpose: "stateBackend",
4200
- variable: "GITHUB_TOKEN"
4368
+ variable: "BEDROCK_GITHUB_TOKEN"
4201
4369
  },
4202
4370
  success: false
4203
4371
  };
@@ -4211,6 +4379,62 @@ function buildGistStatePort(stateConfig, deps) {
4211
4379
  };
4212
4380
  }
4213
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
4214
4438
  //#region src/shell/load-config-internal.ts
4215
4439
  const LUAU_BOOTSTRAP_TEMP_PREFIX = "bedrock-luau-bootstrap-";
4216
4440
  /**
@@ -4584,10 +4808,26 @@ function attributeLoadError(err, cwd) {
4584
4808
  //#endregion
4585
4809
  //#region src/shell/deploy.ts
4586
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
+ /**
4587
4823
  * Run a full reconcile end-to-end. Default-constructs missing deps from
4588
- * the project config and the environment variables `GITHUB_TOKEN` and
4589
- * `BEDROCK_API_KEY`; never reads `process.env` when `statePort`,
4590
- * `registry`, and `config` are all supplied explicitly.
4824
+ * the project config and the environment variables `BEDROCK_GITHUB_TOKEN`
4825
+ * and `BEDROCK_API_KEY`; emits a terminal `deploySuccess` or `deployFailure`
4826
+ * event through the resolved `progress` port. When `progress` is omitted,
4827
+ * the default port comes from `BEDROCK_CLI`: a non-empty value selects the
4828
+ * clack-backed adapter, any other reading selects the no-op adapter. No
4829
+ * environment lookups happen when `statePort`, `registry`, `config`, and
4830
+ * `progress` are all supplied explicitly.
4591
4831
  *
4592
4832
  * @param options - Target environment plus optional overrides.
4593
4833
  * @returns The persisted `BedrockState` on success, or a stage-tagged
@@ -4648,9 +4888,15 @@ function attributeLoadError(err, cwd) {
4648
4888
  * ```
4649
4889
  */
4650
4890
  async function deploy(options) {
4651
- const resolved = await resolveDeps(options);
4652
- if (!resolved.success) return resolved;
4653
- return runReconcile(options.environment, resolved.data);
4891
+ if (options.progress !== void 0) return runAndEmit(options, options.progress);
4892
+ if (!isCliEnvironmentFlagSet(getEnvironmentOf(options)("BEDROCK_CLI"))) return runAndEmit(options, createNoOpProgressAdapter());
4893
+ return runWithDeferredClackProgress(options);
4894
+ }
4895
+ function readProcessEnvironment(name) {
4896
+ return process.env[name];
4897
+ }
4898
+ function getEnvironmentOf(options) {
4899
+ return options.getEnv ?? readProcessEnvironment;
4654
4900
  }
4655
4901
  async function pickConfig(options) {
4656
4902
  if (options.config !== void 0) return {
@@ -4670,12 +4916,6 @@ async function pickConfig(options) {
4670
4916
  success: true
4671
4917
  };
4672
4918
  }
4673
- function readProcessEnvironment(name) {
4674
- return process.env[name];
4675
- }
4676
- function getEnvironmentOf(options) {
4677
- return options.getEnv ?? readProcessEnvironment;
4678
- }
4679
4919
  function pickStatePort(options, config) {
4680
4920
  if (options.statePort !== void 0) return {
4681
4921
  data: options.statePort,
@@ -4724,7 +4964,6 @@ async function resolveDeps(options) {
4724
4964
  return {
4725
4965
  data: {
4726
4966
  config: effective,
4727
- progress: options.progress,
4728
4967
  readFile: readFile$2,
4729
4968
  registry: registry.data,
4730
4969
  statePort: statePort.data
@@ -4785,7 +5024,7 @@ async function runReconcile(environment, deps) {
4785
5024
  success: false
4786
5025
  };
4787
5026
  const priorResources = prior.data?.resources ?? [];
4788
- const validated = validatePlan(desired.data, priorResources);
5027
+ const validated = assertAllReconcilable(desired.data, priorResources);
4789
5028
  if (!validated.success) return {
4790
5029
  err: {
4791
5030
  cause: validated.err,
@@ -4793,7 +5032,7 @@ async function runReconcile(environment, deps) {
4793
5032
  },
4794
5033
  success: false
4795
5034
  };
4796
- const applied = await applyOps(diff(desired.data, priorResources), deps.registry, deps.progress === void 0 ? void 0 : {
5035
+ const applied = await applyOps(diff(desired.data, priorResources), deps.registry, {
4797
5036
  environment,
4798
5037
  progress: deps.progress
4799
5038
  });
@@ -4803,7 +5042,7 @@ async function runReconcile(environment, deps) {
4803
5042
  priorResources
4804
5043
  });
4805
5044
  const written = await deps.statePort.write(merged);
4806
- if (written.success) deps.progress?.emit({
5045
+ if (written.success) deps.progress.emit({
4807
5046
  environment,
4808
5047
  kind: "stateWritten"
4809
5048
  });
@@ -4813,6 +5052,61 @@ async function runReconcile(environment, deps) {
4813
5052
  written
4814
5053
  });
4815
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
5107
+ });
5108
+ return result;
5109
+ }
4816
5110
  //#endregion
4817
5111
  //#region src/core/migrate/build-state.ts
4818
5112
  /**
@@ -6963,6 +7257,6 @@ function isFileMissing(err) {
6963
7257
  return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" && FILE_MISSING_CODES.has(err.code);
6964
7258
  }
6965
7259
  //#endregion
6966
- export { createClackProgressAdapter as A, isSha256Hex as B, UNIVERSE_SINGLETON_KEY as C, createGamePassDriver as D, serializeStateFile as E, asResourceKey as F, renderMigrateParseError as G, renderBuildStatePortError as H, asRobloxAssetId as I, renderStateWriteError as J, renderMigrationSummary as K, asSha256Hex as L, validateConfig as M, shouldReuploadIcon as N, createDeveloperProductDriver as O, validateEnvironmentName as P, isResourceKey as R, SOCIAL_LINK_FIELDS as S, parseStateFile as T, renderDeployError as U, resolveStateConfig as V, renderMigrateError as W, diff as _, buildStatePort as a, createUniverseDriver as b, applyOps as c, selectEnvironment as d, selectMergedEnvironment as f, renderDisplayNamePrefix as g, DEFAULT_PREFIX_FORMAT as h, loadConfig$1 as i, isGistStateConfig as j, derivePriceFields as k, validatePlan as l, flattenConfig as m, serializeConfig as n, buildDesired as o, collectRedactionAnnotations as p, renderParseError as q, deploy as r, buildDefaultRegistry as s, migrateMantleState as t, extractResourceRedaction as u, defaultKindRegistry as v, createGistStateAdapter as w, createPlaceDriver as x, createClackPort as y, isRobloxAssetId as z };
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 };
6967
7261
 
6968
- //# sourceMappingURL=migrate-mantle-state-qejWFAR0.mjs.map
7262
+ //# sourceMappingURL=migrate-mantle-state-ClQ40EFD.mjs.map