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

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
  /**
@@ -4356,14 +4580,9 @@ async function evaluateLuauWithLute(absPath) {
4356
4580
  */
4357
4581
  async function loadConfigWith(deps, options) {
4358
4582
  const cwd = options?.cwd ?? process.cwd();
4359
- const configFile = options?.configFile === void 0 ? void 0 : resolveConfigPath(cwd, options.configFile);
4360
- if (configFile !== void 0 && !isExistingFile(configFile)) return {
4361
- err: {
4362
- kind: "fileNotFound",
4363
- searchedFrom: cwd
4364
- },
4365
- success: false
4366
- };
4583
+ const explicit = resolveExplicitConfigFile(cwd, options?.configFile);
4584
+ if (!explicit.success) return explicit;
4585
+ const configFile = explicit.data ?? discoverConfigFallback(cwd);
4367
4586
  let resolved;
4368
4587
  try {
4369
4588
  resolved = await loadConfig({
@@ -4394,9 +4613,12 @@ async function loadConfigWith(deps, options) {
4394
4613
  /**
4395
4614
  * Discover, parse, and validate the project config.
4396
4615
  *
4397
- * Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json}`, `.bedrockrc*`,
4398
- * and `package.json#bedrock` starting at `options.cwd` (or the current
4399
- * working directory). Returns a fresh, mutable `Config` on every call so
4616
+ * Looks for `bedrock.config.{ts,js,mjs,cjs,yaml,yml,json,luau}`,
4617
+ * `.bedrockrc*`, and `package.json#bedrock` starting at `options.cwd` (or the
4618
+ * current working directory). When no config sits at the project root, the
4619
+ * loader also probes `.bedrock/bedrock.config.*` so users can colocate the
4620
+ * file with their other `.bedrock/` artifacts. The project root always wins
4621
+ * on collision. Returns a fresh, mutable `Config` on every call so
4400
4622
  * long-running scripts see up-to-date values.
4401
4623
  *
4402
4624
  * When the exported default is a function (sync or async), `loadConfig`
@@ -4483,6 +4705,8 @@ const NATIVE_CONFIG_EXTENSIONS = [
4483
4705
  "yaml",
4484
4706
  "yml"
4485
4707
  ];
4708
+ const DISCOVERY_EXTENSIONS = [...NATIVE_CONFIG_EXTENSIONS, "luau"];
4709
+ const BEDROCK_CONFIG_DIRECTORY = ".bedrock";
4486
4710
  /**
4487
4711
  * Internal-only wrapper used at the c12 boundary: makeLuauResolver maps an
4488
4712
  * evaluator `Err` into this throwable, which `attributeLoadError` unwraps
@@ -4496,6 +4720,44 @@ var EvaluatorThrow = class extends Error {
4496
4720
  this.configError = configError;
4497
4721
  }
4498
4722
  };
4723
+ function resolveExplicitConfigFile(cwd, configFile) {
4724
+ if (configFile === void 0) return {
4725
+ data: void 0,
4726
+ success: true
4727
+ };
4728
+ const resolved = resolveConfigPath(cwd, configFile);
4729
+ if (!isExistingFile(resolved)) return {
4730
+ err: {
4731
+ kind: "fileNotFound",
4732
+ searchedFrom: cwd
4733
+ },
4734
+ success: false
4735
+ };
4736
+ return {
4737
+ data: resolved,
4738
+ success: true
4739
+ };
4740
+ }
4741
+ function findConfigInDirectory(directory) {
4742
+ for (const extension of DISCOVERY_EXTENSIONS) {
4743
+ const candidate = join(directory, `bedrock.config.${extension}`);
4744
+ if (isExistingFile(candidate)) return candidate;
4745
+ }
4746
+ }
4747
+ /**
4748
+ * Pick the `.bedrock/bedrock.config.*` fallback only when no `bedrock.config.*`
4749
+ * exists at the project root, letting c12 run its own discovery so a root file
4750
+ * always wins. Other c12 sources (`.bedrockrc`, `package.json#bedrock`) are
4751
+ * still merged in by c12 either way; the configFile we hand back wins
4752
+ * overlapping keys per c12's standard layering precedence.
4753
+ * @param cwd - The directory to search.
4754
+ * @returns Absolute path of the `.bedrock/` candidate, or `undefined` to defer
4755
+ * to c12's own discovery on the project root.
4756
+ */
4757
+ function discoverConfigFallback(cwd) {
4758
+ if (findConfigInDirectory(cwd) !== void 0) return;
4759
+ return findConfigInDirectory(join(cwd, BEDROCK_CONFIG_DIRECTORY));
4760
+ }
4499
4761
  /**
4500
4762
  * Decide which Luau file the resolver should evaluate for a given c12 source,
4501
4763
  * or `undefined` to defer to c12's built-in loaders.
@@ -4556,15 +4818,18 @@ function extractConfigFileFromStack(err) {
4556
4818
  if (match !== null) return match[0];
4557
4819
  }
4558
4820
  }
4559
- function discoverConfigFile(cwd) {
4821
+ function findConfigEntry(directory) {
4560
4822
  let entries;
4561
4823
  try {
4562
- entries = readdirSync(cwd);
4824
+ entries = readdirSync(directory);
4563
4825
  } catch {
4564
4826
  return;
4565
4827
  }
4566
4828
  const match = entries.toSorted().find((entry) => entry.startsWith("bedrock.config."));
4567
- return match === void 0 ? void 0 : join(cwd, match);
4829
+ return match === void 0 ? void 0 : join(directory, match);
4830
+ }
4831
+ function discoverConfigFile(cwd) {
4832
+ return findConfigEntry(cwd) ?? findConfigEntry(join(cwd, BEDROCK_CONFIG_DIRECTORY));
4568
4833
  }
4569
4834
  function attributeLoadError(err, cwd) {
4570
4835
  if (err instanceof EvaluatorThrow) return err.configError;
@@ -4584,10 +4849,26 @@ function attributeLoadError(err, cwd) {
4584
4849
  //#endregion
4585
4850
  //#region src/shell/deploy.ts
4586
4851
  /**
4852
+ * Decide whether `BEDROCK_CLI` should select the clack-backed default
4853
+ * progress adapter. Exported for direct unit coverage of the boundary
4854
+ * (`undefined` and empty string both flip to no-op; any non-empty value
4855
+ * picks clack).
4856
+ *
4857
+ * @param value - Raw `BEDROCK_CLI` value as returned by `getEnv`.
4858
+ * @returns `true` if the clack adapter should be the default.
4859
+ */
4860
+ function isCliEnvironmentFlagSet(value) {
4861
+ return value !== void 0 && value !== "";
4862
+ }
4863
+ /**
4587
4864
  * 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.
4865
+ * the project config and the environment variables `BEDROCK_GITHUB_TOKEN`
4866
+ * and `BEDROCK_API_KEY`; emits a terminal `deploySuccess` or `deployFailure`
4867
+ * event through the resolved `progress` port. When `progress` is omitted,
4868
+ * the default port comes from `BEDROCK_CLI`: a non-empty value selects the
4869
+ * clack-backed adapter, any other reading selects the no-op adapter. No
4870
+ * environment lookups happen when `statePort`, `registry`, `config`, and
4871
+ * `progress` are all supplied explicitly.
4591
4872
  *
4592
4873
  * @param options - Target environment plus optional overrides.
4593
4874
  * @returns The persisted `BedrockState` on success, or a stage-tagged
@@ -4648,9 +4929,15 @@ function attributeLoadError(err, cwd) {
4648
4929
  * ```
4649
4930
  */
4650
4931
  async function deploy(options) {
4651
- const resolved = await resolveDeps(options);
4652
- if (!resolved.success) return resolved;
4653
- return runReconcile(options.environment, resolved.data);
4932
+ if (options.progress !== void 0) return runAndEmit(options, options.progress);
4933
+ if (!isCliEnvironmentFlagSet(getEnvironmentOf(options)("BEDROCK_CLI"))) return runAndEmit(options, createNoOpProgressAdapter());
4934
+ return runWithDeferredClackProgress(options);
4935
+ }
4936
+ function readProcessEnvironment(name) {
4937
+ return process.env[name];
4938
+ }
4939
+ function getEnvironmentOf(options) {
4940
+ return options.getEnv ?? readProcessEnvironment;
4654
4941
  }
4655
4942
  async function pickConfig(options) {
4656
4943
  if (options.config !== void 0) return {
@@ -4670,12 +4957,6 @@ async function pickConfig(options) {
4670
4957
  success: true
4671
4958
  };
4672
4959
  }
4673
- function readProcessEnvironment(name) {
4674
- return process.env[name];
4675
- }
4676
- function getEnvironmentOf(options) {
4677
- return options.getEnv ?? readProcessEnvironment;
4678
- }
4679
4960
  function pickStatePort(options, config) {
4680
4961
  if (options.statePort !== void 0) return {
4681
4962
  data: options.statePort,
@@ -4724,7 +5005,6 @@ async function resolveDeps(options) {
4724
5005
  return {
4725
5006
  data: {
4726
5007
  config: effective,
4727
- progress: options.progress,
4728
5008
  readFile: readFile$2,
4729
5009
  registry: registry.data,
4730
5010
  statePort: statePort.data
@@ -4785,7 +5065,7 @@ async function runReconcile(environment, deps) {
4785
5065
  success: false
4786
5066
  };
4787
5067
  const priorResources = prior.data?.resources ?? [];
4788
- const validated = validatePlan(desired.data, priorResources);
5068
+ const validated = assertAllReconcilable(desired.data, priorResources);
4789
5069
  if (!validated.success) return {
4790
5070
  err: {
4791
5071
  cause: validated.err,
@@ -4793,7 +5073,7 @@ async function runReconcile(environment, deps) {
4793
5073
  },
4794
5074
  success: false
4795
5075
  };
4796
- const applied = await applyOps(diff(desired.data, priorResources), deps.registry, deps.progress === void 0 ? void 0 : {
5076
+ const applied = await applyOps(diff(desired.data, priorResources), deps.registry, {
4797
5077
  environment,
4798
5078
  progress: deps.progress
4799
5079
  });
@@ -4803,7 +5083,7 @@ async function runReconcile(environment, deps) {
4803
5083
  priorResources
4804
5084
  });
4805
5085
  const written = await deps.statePort.write(merged);
4806
- if (written.success) deps.progress?.emit({
5086
+ if (written.success) deps.progress.emit({
4807
5087
  environment,
4808
5088
  kind: "stateWritten"
4809
5089
  });
@@ -4813,6 +5093,61 @@ async function runReconcile(environment, deps) {
4813
5093
  written
4814
5094
  });
4815
5095
  }
5096
+ async function runDeploy(options, progress) {
5097
+ const resolved = await resolveDeps(options);
5098
+ if (!resolved.success) return resolved;
5099
+ return runReconcile(options.environment, {
5100
+ ...resolved.data,
5101
+ progress
5102
+ });
5103
+ }
5104
+ function emitTerminalEvent(inputs) {
5105
+ const { environment, progress, result } = inputs;
5106
+ if (result.success) {
5107
+ progress.emit({
5108
+ environment,
5109
+ kind: "deploySuccess",
5110
+ resourceCount: result.data.resources.length
5111
+ });
5112
+ return;
5113
+ }
5114
+ progress.emit({
5115
+ environment,
5116
+ error: result.err,
5117
+ kind: "deployFailure"
5118
+ });
5119
+ }
5120
+ async function runAndEmit(options, progress) {
5121
+ const result = await runDeploy(options, progress);
5122
+ emitTerminalEvent({
5123
+ environment: options.environment,
5124
+ progress,
5125
+ result
5126
+ });
5127
+ return result;
5128
+ }
5129
+ async function runWithDeferredClackProgress(options) {
5130
+ const resolved = await resolveDeps(options);
5131
+ const progress = createDefaultProgressAdapter(resolved.success ? resolved.data.config : options.config);
5132
+ if (!resolved.success) {
5133
+ emitTerminalEvent({
5134
+ environment: options.environment,
5135
+ progress,
5136
+ result: resolved
5137
+ });
5138
+ return resolved;
5139
+ }
5140
+ const result = await runReconcile(options.environment, {
5141
+ ...resolved.data,
5142
+ progress
5143
+ });
5144
+ emitTerminalEvent({
5145
+ environment: options.environment,
5146
+ progress,
5147
+ result
5148
+ });
5149
+ return result;
5150
+ }
4816
5151
  //#endregion
4817
5152
  //#region src/core/migrate/build-state.ts
4818
5153
  /**
@@ -6963,6 +7298,6 @@ function isFileMissing(err) {
6963
7298
  return typeof err === "object" && err !== null && "code" in err && typeof err.code === "string" && FILE_MISSING_CODES.has(err.code);
6964
7299
  }
6965
7300
  //#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 };
7301
+ 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
7302
 
6968
- //# sourceMappingURL=migrate-mantle-state-qejWFAR0.mjs.map
7303
+ //# sourceMappingURL=migrate-mantle-state-F4zdhxV4.mjs.map