@bedrock-rbx/core 0.1.0-beta.1

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.
@@ -0,0 +1,1476 @@
1
+ #!/usr/bin/env node
2
+ import { T as serializeStateFile, a as buildStatePort, d as resolveStateConfig, f as flattenConfig, h as diff, i as loadConfig, l as validatePlan, n as serializeConfig, o as buildDesired, r as deploy, t as migrateMantleState, u as selectEnvironment } from "../migrate-mantle-state-DqbJ1TLq.mjs";
3
+ import { PermissionError } from "@bedrock-rbx/ocale";
4
+ import process from "node:process";
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import sade from "sade";
8
+ import { cancel, intro, isCancel, log, outro, path, select, text } from "@clack/prompts";
9
+ //#region package.json
10
+ var version = "0.1.0-beta.1";
11
+ //#endregion
12
+ //#region src/cli/parse-options.ts
13
+ const RECOGNIZED_FLAGS$1 = new Set([
14
+ "api-key",
15
+ "apiKey",
16
+ "config",
17
+ "env",
18
+ "github-token",
19
+ "githubToken"
20
+ ]);
21
+ const SADE_RESERVED$1 = new Set([
22
+ "--",
23
+ "_",
24
+ "h",
25
+ "help",
26
+ "v",
27
+ "version"
28
+ ]);
29
+ /**
30
+ * Translate the raw sade options POJO into a typed `CommonOptions`. Reads
31
+ * `BEDROCK_ENVIRONMENT` from `process.env` as a fallback when `--env` is
32
+ * absent; inject `getEnvironment` to redirect or isolate the lookup. No
33
+ * clack, no sade types. Reused by `bedrock deploy` and `bedrock diff`.
34
+ * @param rawOptions - The options object sade hands the action callback.
35
+ * @param getEnvironment - Reads an environment variable; consulted as a
36
+ * fallback for `--env` when no flag was supplied. Defaults to a
37
+ * `process.env` reader.
38
+ * @returns `Ok(CommonOptions)` on success, or `Err(ParseOptionsError)` when a
39
+ * required flag is missing or an unrecognized flag was supplied.
40
+ */
41
+ function parseCommonOptions(rawOptions, getEnvironment = readProcessEnvironment$1) {
42
+ for (const key of Object.keys(rawOptions)) if (!RECOGNIZED_FLAGS$1.has(key) && !SADE_RESERVED$1.has(key)) return {
43
+ err: {
44
+ flag: key,
45
+ kind: "unknownFlag"
46
+ },
47
+ success: false
48
+ };
49
+ const environments = resolveEnvironments(rawOptions["env"], getEnvironment);
50
+ if (!environments.success) return environments;
51
+ const apiKey = pickString(rawOptions, "apiKey", "api-key");
52
+ const configFile = pickString(rawOptions, "config");
53
+ const githubToken = pickString(rawOptions, "githubToken", "github-token");
54
+ return {
55
+ data: {
56
+ environments: environments.data,
57
+ ...apiKey === void 0 ? {} : { apiKey },
58
+ ...configFile === void 0 ? {} : { configFile },
59
+ ...githubToken === void 0 ? {} : { githubToken }
60
+ },
61
+ success: true
62
+ };
63
+ }
64
+ function resolveEnvironments(raw, getEnvironment) {
65
+ if (raw === void 0) {
66
+ const fallback = getEnvironment("BEDROCK_ENVIRONMENT");
67
+ if (fallback === void 0) return {
68
+ err: {
69
+ flag: "env",
70
+ kind: "missingRequired"
71
+ },
72
+ success: false
73
+ };
74
+ return {
75
+ data: [fallback],
76
+ success: true
77
+ };
78
+ }
79
+ const candidates = Array.isArray(raw) ? raw : [raw];
80
+ const environments = [];
81
+ for (const candidate of candidates) {
82
+ if (typeof candidate !== "string") return {
83
+ err: {
84
+ flag: "env",
85
+ kind: "invalidValue"
86
+ },
87
+ success: false
88
+ };
89
+ environments.push(candidate);
90
+ }
91
+ return {
92
+ data: environments,
93
+ success: true
94
+ };
95
+ }
96
+ function readProcessEnvironment$1(name) {
97
+ return process.env[name];
98
+ }
99
+ function pickString(rawOptions, ...keys) {
100
+ for (const key of keys) {
101
+ const value = rawOptions[key];
102
+ if (typeof value === "string") return value;
103
+ }
104
+ }
105
+ //#endregion
106
+ //#region src/cli/render.ts
107
+ /**
108
+ * Render a `DeployError` to the supplied `ClackPort` as a single error line.
109
+ * Each variant produces a distinct, terse diagnostic; wrapped variants
110
+ * (`applyFailed`, `buildDesiredFailed`, `configLoadFailed`, `stateReadFailed`,
111
+ * `stateWriteFailed`) surface the inner cause's actionable detail (file path,
112
+ * resource key, parser message, HTTP failure, validator issue) so the reader
113
+ * does not have to inspect the full cause to act.
114
+ * @param err - The deploy error to describe.
115
+ * @param port - The output port the diagnostic is written to.
116
+ */
117
+ function renderDeployError(err, port) {
118
+ port.logError(deployErrorMessage(err));
119
+ }
120
+ /**
121
+ * Render a `ParseOptionsError` to the supplied `ClackPort` as a single
122
+ * error line. Each variant names the offending flag so the diagnostic
123
+ * pinpoints what the caller needs to change.
124
+ * @param err - The parse error to describe.
125
+ * @param port - The output port the diagnostic is written to.
126
+ */
127
+ function renderParseError(err, port) {
128
+ port.logError(parseErrorMessage(err));
129
+ }
130
+ /**
131
+ * Construct a `ClackPort` whose methods delegate to `@clack/prompts`. The
132
+ * resulting port writes to `process.stdout` via clack's defaults.
133
+ * @returns A port whose six methods each invoke the matching clack helper.
134
+ */
135
+ function createClackPort() {
136
+ return {
137
+ cancel: (message) => {
138
+ cancel(message);
139
+ },
140
+ intro: (message) => {
141
+ intro(message);
142
+ },
143
+ logError: (message) => {
144
+ log.error(message);
145
+ },
146
+ logMessage: (message) => {
147
+ log.message(message);
148
+ },
149
+ logSuccess: (message) => {
150
+ log.success(message);
151
+ },
152
+ outro: (message) => {
153
+ outro(message);
154
+ }
155
+ };
156
+ }
157
+ /**
158
+ * Render a `ParseMigrateError` to the supplied `ClackPort`. Reuses
159
+ * `parseErrorMessage` for the three flag-shape variants and adds a
160
+ * dedicated message for `unknownSource` listing the supported sources.
161
+ * @param err - The parse error to describe.
162
+ * @param port - The output port the diagnostic is written to.
163
+ */
164
+ function renderMigrateParseError(err, port) {
165
+ port.logError(migrateParseErrorMessage(err));
166
+ }
167
+ /**
168
+ * Render a `MigrateError` to the supplied `ClackPort` as a single error
169
+ * line. Each variant points at the offending Mantle state file path,
170
+ * primary-environment input, or wrapped `ConfigError` so the reader can
171
+ * act without inspecting the raw error object.
172
+ * @param err - The migrate error to describe.
173
+ * @param port - The output port the diagnostic is written to.
174
+ */
175
+ function renderMigrateError(err, port) {
176
+ port.logError(migrateErrorMessage(err));
177
+ }
178
+ /**
179
+ * Render a `MissingCredentialError` or `UnsupportedBackendError`
180
+ * surfaced when the migrate command tried to default-construct the
181
+ * configured `StatePort` and was missing its inputs.
182
+ * @param err - The error returned by `buildStatePort`.
183
+ * @param port - The output port the diagnostic is written to.
184
+ */
185
+ function renderBuildStatePortError(err, port) {
186
+ port.logError(buildStatePortErrorMessage(err));
187
+ }
188
+ /**
189
+ * Render the post-migrate review prompt to the supplied `ClackPort`.
190
+ * Three outcomes:
191
+ *
192
+ * - Any `ambiguous` warnings exist: emit a single error line directing
193
+ * the user to the report. The migration ran but there are decisions
194
+ * the user still needs to make before deploy will be meaningful.
195
+ * - No `ambiguous` warnings but non-zero `blocked` / `deferred` /
196
+ * `interpretive`: emit a single success line pointing at the report
197
+ * for auditing.
198
+ * - All counts zero: silent. The closing `outro("migrate succeeded")`
199
+ * already speaks for the run.
200
+ * @param input - Counts plus the path of the Markdown report.
201
+ * @param port - The output port the line is written to.
202
+ */
203
+ function renderMigrationSummary(input, port) {
204
+ const { ambiguousCount, blockedCount, deferredCount, interpretiveCount } = input.summary;
205
+ if (ambiguousCount > 0) {
206
+ port.logError(`action required: ${String(ambiguousCount)} fields need your input. See ${input.reportPath}`);
207
+ return;
208
+ }
209
+ const reviewable = blockedCount + deferredCount + interpretiveCount;
210
+ if (reviewable > 0) port.logSuccess(`migration complete; see ${input.reportPath} for ${String(reviewable)} auto-mapped or skipped fields`);
211
+ }
212
+ /**
213
+ * Render a `StateError` produced when the migrator wrote a per-environment
214
+ * state through the `StatePort`. Names the environment alongside the
215
+ * adapter's failure reason so the reader knows which write failed.
216
+ * @param input - Environment + state-error to describe.
217
+ * @param port - The output port the diagnostic is written to.
218
+ */
219
+ function renderStateWriteError(input, port) {
220
+ port.logError(`state write failed for '${input.environment}' (${input.err.file}): ${input.err.reason}`);
221
+ }
222
+ function permissionDetail(err) {
223
+ const isPlural = err.requiredScopes.length > 1;
224
+ const label = isPlural ? "scopes" : "scope";
225
+ const pronoun = isPlural ? "them" : "it";
226
+ const scopeList = err.requiredScopes.map((scope) => `'${scope}'`).join(", ");
227
+ return `${err.message} on ${err.operationKey}: missing required ${label} ${scopeList}. Grant ${pronoun} on the API key at https://create.roblox.com/credentials`;
228
+ }
229
+ function applyCauseDetail(cause) {
230
+ switch (cause.kind) {
231
+ case "driverFailure":
232
+ if (cause.cause instanceof PermissionError) return permissionDetail(cause.cause);
233
+ return cause.cause.message;
234
+ case "updateUnsupported": return "update not supported";
235
+ }
236
+ }
237
+ function buildDesiredDetail(cause) {
238
+ switch (cause.kind) {
239
+ case "fileReadFailed": return `(${cause.filePath}): ${cause.reason}`;
240
+ case "iconRemovalRejected": return `: ${cause.message}`;
241
+ }
242
+ }
243
+ function configErrorDetail(err) {
244
+ switch (err.kind) {
245
+ case "configFunctionFailed": return `${err.sourceFile}: config function threw: ${err.message}`;
246
+ case "fileNotFound": return `no bedrock config under ${err.searchedFrom}`;
247
+ case "luauRuntimeMissing": return `${err.sourceFile}: ${err.hint}`;
248
+ case "parseFailed": return `${err.sourceFile}: ${err.message}`;
249
+ case "validationFailed": {
250
+ const first = err.issues[0];
251
+ return first === void 0 ? `${err.sourceFile}: invalid` : `${err.sourceFile}: ${first.path.join(".")} ${first.message}`;
252
+ }
253
+ }
254
+ }
255
+ function stateErrorDetail(cause) {
256
+ return `(${cause.file}): ${cause.reason}`;
257
+ }
258
+ function deployErrorMessage(err) {
259
+ switch (err.kind) {
260
+ case "applyFailed": return `apply failed for '${err.cause.key}': ${applyCauseDetail(err.cause)}`;
261
+ case "buildDesiredFailed": return `build desired state failed for '${err.cause.key}' ${buildDesiredDetail(err.cause)}`;
262
+ case "configLoadFailed": return `config load failed: ${configErrorDetail(err.cause)}`;
263
+ case "incompletePlaceEntry": return `place '${err.key}' is missing '${err.missingField}' under environment '${err.environment}'`;
264
+ case "incompleteUniverseEntry": return `universe is missing '${err.missingField}' under environment '${err.environment}'`;
265
+ case "missingCredential": return `missing credential: environment variable ${err.variable} is not set`;
266
+ case "registryConfigMissing": return `registry config missing '${err.missing}' (${err.hint})`;
267
+ case "stateNotConfigured": return `state not configured for environment '${err.environment}'`;
268
+ case "stateReadFailed": return `state read failed ${stateErrorDetail(err.cause)}`;
269
+ case "stateWriteFailed": return `state write failed ${stateErrorDetail(err.cause)}`;
270
+ case "unknownEnvironment": return `unknown environment '${err.environment}' (declared: ${err.declared.join(", ")})`;
271
+ case "unsupportedBackend": return `unsupported state backend '${err.backend}' (${err.hint})`;
272
+ }
273
+ }
274
+ function parseErrorMessage(err) {
275
+ switch (err.kind) {
276
+ case "invalidValue": return `invalid value for flag '--${err.flag}' (expected a string)`;
277
+ case "missingRequired": return `missing required flag --${err.flag}`;
278
+ case "unknownFlag": return `unknown flag '--${err.flag}'`;
279
+ }
280
+ }
281
+ function migrateParseErrorMessage(err) {
282
+ if (err.kind === "unknownSource") return `unknown migration source '${err.received}' (supported: ${err.supported.join(", ")})`;
283
+ return parseErrorMessage(err);
284
+ }
285
+ function migrateErrorMessage(err) {
286
+ switch (err.kind) {
287
+ case "internalError": return `migrate internal error: ${err.reason} (${configErrorDetail(err.cause)})`;
288
+ case "primaryEnvironmentNotFound": return `primary environment '${err.primary}' not found (available: ${err.available.join(", ")})`;
289
+ case "primaryEnvironmentRequired": return `primary environment required (available: ${err.available.join(", ")})`;
290
+ case "stateFileNotFound": return `Mantle state file not found at '${err.path}'`;
291
+ case "stateParseFailed": return `Mantle state file at '${err.path}' could not be parsed: ${err.reason}`;
292
+ case "unsupportedMantleStateVersion": return `unsupported Mantle state version '${err.found}' (supported: ${err.supported.join(", ")})`;
293
+ }
294
+ }
295
+ function buildStatePortErrorMessage(err) {
296
+ switch (err.kind) {
297
+ case "missingCredential": return `missing credential: environment variable ${err.variable} is not set`;
298
+ case "unsupportedBackend": return `unsupported state backend '${err.backend}' (${err.hint})`;
299
+ }
300
+ }
301
+ //#endregion
302
+ //#region src/cli/commands/deploy.ts
303
+ /**
304
+ * Build the sade action for `bedrock deploy`. The returned function consumes
305
+ * the raw options object sade hands the action callback, parses it via
306
+ * `parseCommonOptions`, loads the project config once, and dispatches
307
+ * `deploy()` for each `--env` value in order. Per-env successes and failures
308
+ * render through clack as a single line each; the aggregated exit code is
309
+ * `EXIT_OK` only when every env succeeded.
310
+ * @param deps - Dependency overrides; missing slots are default-constructed
311
+ * from real implementations.
312
+ * @returns An async sade action that returns once `deps.exit` was invoked.
313
+ */
314
+ function deployCommand(deps) {
315
+ const resolved = resolveDeploy(deps);
316
+ return async (rawOptions) => {
317
+ const code = await runDeploy(rawOptions, resolved);
318
+ resolved.exit(code);
319
+ };
320
+ }
321
+ function resolveDeploy(deps) {
322
+ return {
323
+ clack: deps.clack ?? createClackPort(),
324
+ deploy: deps.deploy ?? deploy,
325
+ exit: deps.exit ?? ((code) => process.exit(code)),
326
+ loadConfig: deps.loadConfig ?? loadConfig
327
+ };
328
+ }
329
+ function loadOptionsFor$1(parsed) {
330
+ return parsed.configFile === void 0 ? void 0 : { configFile: parsed.configFile };
331
+ }
332
+ async function dispatchEnvironments$1(inputs) {
333
+ const { config, environments, getEnv, resolved } = inputs;
334
+ const failed = [];
335
+ for (const environment of environments) {
336
+ const result = await resolved.deploy({
337
+ config,
338
+ environment,
339
+ getEnv
340
+ });
341
+ if (result.success) resolved.clack.logSuccess(`${environment}: ${result.data.resources.length} resources reconciled`);
342
+ else {
343
+ renderDeployError(result.err, resolved.clack);
344
+ failed.push(environment);
345
+ }
346
+ }
347
+ return failed;
348
+ }
349
+ function buildGetEnvironment$1(parsed) {
350
+ return (name) => {
351
+ if (name === "ROBLOX_API_KEY" && parsed.apiKey !== void 0) return parsed.apiKey;
352
+ if (name === "GITHUB_TOKEN" && parsed.githubToken !== void 0) return parsed.githubToken;
353
+ return process.env[name];
354
+ };
355
+ }
356
+ function cancelAsFailed$1(clack) {
357
+ clack.cancel("deploy failed");
358
+ }
359
+ async function runDeploy(rawOptions, resolved) {
360
+ resolved.clack.intro("bedrock deploy");
361
+ const parsed = parseCommonOptions(rawOptions);
362
+ if (!parsed.success) {
363
+ renderParseError(parsed.err, resolved.clack);
364
+ cancelAsFailed$1(resolved.clack);
365
+ return 1;
366
+ }
367
+ const loaded = await resolved.loadConfig(loadOptionsFor$1(parsed.data));
368
+ if (!loaded.success) {
369
+ renderDeployError({
370
+ cause: loaded.err,
371
+ kind: "configLoadFailed"
372
+ }, resolved.clack);
373
+ cancelAsFailed$1(resolved.clack);
374
+ return 1;
375
+ }
376
+ if ((await dispatchEnvironments$1({
377
+ config: loaded.data,
378
+ environments: parsed.data.environments,
379
+ getEnv: buildGetEnvironment$1(parsed.data),
380
+ resolved
381
+ })).length > 0) {
382
+ cancelAsFailed$1(resolved.clack);
383
+ return 1;
384
+ }
385
+ resolved.clack.outro("deploy succeeded");
386
+ return 0;
387
+ }
388
+ //#endregion
389
+ //#region src/shell/preview-diff.ts
390
+ /**
391
+ * Compute the operations `deploy` would apply for a target environment
392
+ * without writing state. Default-constructs missing deps from the project
393
+ * config and `GITHUB_TOKEN`; never reads `process.env` when `statePort`
394
+ * and `config` are both supplied explicitly.
395
+ *
396
+ * @param options - Target environment plus optional overrides.
397
+ * @returns The computed operations on success, or a stage-tagged
398
+ * `PreviewDiffError` on failure.
399
+ */
400
+ async function previewDiff(options) {
401
+ const resolved = await resolveDeps(options);
402
+ if (!resolved.success) return resolved;
403
+ return runPreview(options.environment, resolved.data);
404
+ }
405
+ async function pickConfig(options) {
406
+ if (options.config !== void 0) return {
407
+ data: options.config,
408
+ success: true
409
+ };
410
+ const loaded = await (options.loadConfig ?? loadConfig)();
411
+ if (!loaded.success) return {
412
+ err: {
413
+ cause: loaded.err,
414
+ kind: "configLoadFailed"
415
+ },
416
+ success: false
417
+ };
418
+ return {
419
+ data: loaded.data,
420
+ success: true
421
+ };
422
+ }
423
+ function readProcessEnvironment(name) {
424
+ return process.env[name];
425
+ }
426
+ function getEnvironmentOf(options) {
427
+ return options.getEnv ?? readProcessEnvironment;
428
+ }
429
+ function pickStatePort(options, config) {
430
+ if (options.statePort !== void 0) return {
431
+ data: options.statePort,
432
+ success: true
433
+ };
434
+ const stateConfig = resolveStateConfig(config, options.environment);
435
+ if (!stateConfig.success) return {
436
+ err: stateConfig.err,
437
+ success: false
438
+ };
439
+ return buildStatePort({
440
+ fetch: options.fetch,
441
+ getEnv: getEnvironmentOf(options),
442
+ stateConfig: stateConfig.data
443
+ });
444
+ }
445
+ async function resolveDeps(options) {
446
+ const config = await pickConfig(options);
447
+ if (!config.success) return config;
448
+ const selected = selectEnvironment(config.data, options.environment);
449
+ if (!selected.success) return {
450
+ err: selected.err,
451
+ success: false
452
+ };
453
+ const effective = selected.data;
454
+ const readFile$1 = options.readFile ?? readFile;
455
+ const statePort = pickStatePort(options, effective);
456
+ if (!statePort.success) return statePort;
457
+ return {
458
+ data: {
459
+ config: effective,
460
+ readFile: readFile$1,
461
+ statePort: statePort.data
462
+ },
463
+ success: true
464
+ };
465
+ }
466
+ async function runPreview(environment, deps) {
467
+ const desired = await buildDesired(flattenConfig(deps.config), deps.readFile);
468
+ if (!desired.success) return {
469
+ err: {
470
+ cause: desired.err,
471
+ kind: "buildDesiredFailed"
472
+ },
473
+ success: false
474
+ };
475
+ const prior = await deps.statePort.read(environment);
476
+ if (!prior.success) return {
477
+ err: {
478
+ cause: prior.err,
479
+ kind: "stateReadFailed"
480
+ },
481
+ success: false
482
+ };
483
+ const priorResources = prior.data?.resources ?? [];
484
+ const validated = validatePlan(desired.data, priorResources);
485
+ if (!validated.success) return {
486
+ err: {
487
+ cause: validated.err,
488
+ kind: "buildDesiredFailed"
489
+ },
490
+ success: false
491
+ };
492
+ return {
493
+ data: {
494
+ environment,
495
+ ops: diff(desired.data, priorResources)
496
+ },
497
+ success: true
498
+ };
499
+ }
500
+ //#endregion
501
+ //#region src/cli/commands/diff.ts
502
+ /**
503
+ * Build the sade action for `bedrock diff`. The returned function consumes
504
+ * the raw options object sade hands the action callback, parses it via
505
+ * `parseCommonOptions`, loads the project config once, and dispatches
506
+ * `previewDiff()` for each `--env` value in order. Per-env successes render
507
+ * the operations list (or a `No drift` line when every op is a noop);
508
+ * failures render via `renderDeployError`. The aggregated exit code is
509
+ * `EXIT_OK` only when every env succeeded.
510
+ * @param deps - Dependency overrides; missing slots are default-constructed
511
+ * from real implementations.
512
+ * @returns An async sade action that returns once `deps.exit` was invoked.
513
+ */
514
+ function diffCommand(deps) {
515
+ const resolved = resolveDiff(deps);
516
+ return async (rawOptions) => {
517
+ const code = await runDiff(rawOptions, resolved);
518
+ resolved.exit(code);
519
+ };
520
+ }
521
+ function resolveDiff(deps) {
522
+ return {
523
+ clack: deps.clack ?? createClackPort(),
524
+ exit: deps.exit ?? ((code) => process.exit(code)),
525
+ loadConfig: deps.loadConfig ?? loadConfig,
526
+ previewDiff: deps.previewDiff ?? previewDiff
527
+ };
528
+ }
529
+ function loadOptionsFor(parsed) {
530
+ return parsed.configFile === void 0 ? void 0 : { configFile: parsed.configFile };
531
+ }
532
+ function buildGetEnvironment(parsed) {
533
+ return (name) => {
534
+ if (name === "ROBLOX_API_KEY" && parsed.apiKey !== void 0) return parsed.apiKey;
535
+ if (name === "GITHUB_TOKEN" && parsed.githubToken !== void 0) return parsed.githubToken;
536
+ return process.env[name];
537
+ };
538
+ }
539
+ function cancelAsFailed(clack) {
540
+ clack.cancel("diff failed");
541
+ }
542
+ function describeOp(op) {
543
+ switch (op.type) {
544
+ case "create": return `+ ${op.desired.kind}:${op.key}`;
545
+ case "update": return `~ ${op.desired.kind}:${op.key}`;
546
+ }
547
+ }
548
+ function isDriftOp(op) {
549
+ return op.type !== "noop";
550
+ }
551
+ function renderPreview(preview, clack) {
552
+ const drift = preview.ops.filter(isDriftOp);
553
+ if (drift.length === 0) {
554
+ clack.logSuccess(`No drift for "${preview.environment}"`);
555
+ return false;
556
+ }
557
+ clack.logMessage(`Pending changes for "${preview.environment}":`);
558
+ for (const op of drift) clack.logMessage(describeOp(op));
559
+ return true;
560
+ }
561
+ async function dispatchEnvironments(inputs) {
562
+ const { config, environments, getEnv, resolved } = inputs;
563
+ const failed = [];
564
+ let hasDrift = false;
565
+ for (const environment of environments) {
566
+ const result = await resolved.previewDiff({
567
+ config,
568
+ environment,
569
+ getEnv
570
+ });
571
+ if (result.success) {
572
+ if (renderPreview(result.data, resolved.clack)) hasDrift = true;
573
+ } else {
574
+ renderDeployError(result.err, resolved.clack);
575
+ failed.push(environment);
576
+ }
577
+ }
578
+ return {
579
+ failed,
580
+ hasDrift
581
+ };
582
+ }
583
+ function outroFor(hasDrift) {
584
+ return hasDrift ? "run bedrock deploy to apply pending changes" : "all environments are up to date";
585
+ }
586
+ async function runDiff(rawOptions, resolved) {
587
+ resolved.clack.intro("bedrock diff");
588
+ const parsed = parseCommonOptions(rawOptions);
589
+ if (!parsed.success) {
590
+ renderParseError(parsed.err, resolved.clack);
591
+ cancelAsFailed(resolved.clack);
592
+ return 1;
593
+ }
594
+ const loaded = await resolved.loadConfig(loadOptionsFor(parsed.data));
595
+ if (!loaded.success) {
596
+ renderDeployError({
597
+ cause: loaded.err,
598
+ kind: "configLoadFailed"
599
+ }, resolved.clack);
600
+ cancelAsFailed(resolved.clack);
601
+ return 1;
602
+ }
603
+ const outcome = await dispatchEnvironments({
604
+ config: loaded.data,
605
+ environments: parsed.data.environments,
606
+ getEnv: buildGetEnvironment(parsed.data),
607
+ resolved
608
+ });
609
+ if (outcome.failed.length > 0) {
610
+ cancelAsFailed(resolved.clack);
611
+ return 1;
612
+ }
613
+ resolved.clack.outro(outroFor(outcome.hasDrift));
614
+ return 0;
615
+ }
616
+ //#endregion
617
+ //#region src/cli/default-migrate-prompt-port.ts
618
+ const FORMAT_OPTIONS = [{
619
+ hint: "recommended",
620
+ label: "TypeScript",
621
+ value: "typescript"
622
+ }, {
623
+ label: "YAML",
624
+ value: "yaml"
625
+ }];
626
+ const BACKEND_OPTIONS = [{
627
+ label: "GitHub Gist",
628
+ value: "gist"
629
+ }, {
630
+ hint: "writes .bedrock/state/<env>.json next to bedrock.config",
631
+ label: "Local files",
632
+ value: "local"
633
+ }];
634
+ const SOURCE_LABELS = { mantle: "Mantle" };
635
+ const defaultHelpers = {
636
+ isCancel,
637
+ path,
638
+ select,
639
+ text
640
+ };
641
+ /**
642
+ * Construct a `MigratePromptPort` whose methods delegate to
643
+ * `@clack/prompts`. Each prompt translates clack's cancel sentinel into
644
+ * a typed `Err({ kind: "cancelled" })` so the migrate command branches
645
+ * on `Result` like every other shell call.
646
+ *
647
+ * @param helpers - Test-only seam for swapping the three clack
648
+ * primitives. Production callers omit this argument.
649
+ * @returns A live `MigratePromptPort` ready to drive interactively.
650
+ */
651
+ function createDefaultMigratePromptPort(helpers = defaultHelpers) {
652
+ return {
653
+ promptConfigFormat: async () => promptConfigFormatFrom(helpers),
654
+ promptGistId: async () => promptGistIdFrom(helpers),
655
+ promptMigrationSource: async (sources) => selectMigrationSource(helpers, sources),
656
+ promptPrimaryEnvironment: async (environments) => selectPrimaryEnvironment(helpers, environments),
657
+ promptStateBackend: async () => promptStateBackendFrom(helpers),
658
+ promptStateFilePath: async () => promptStateFilePathFrom(helpers)
659
+ };
660
+ }
661
+ async function fromSelect(helpers, inputs) {
662
+ const result = await helpers.select({
663
+ message: inputs.message,
664
+ options: inputs.options.map((option) => {
665
+ return {
666
+ ...option.hint === void 0 ? {} : { hint: option.hint },
667
+ label: option.label,
668
+ value: option.value
669
+ };
670
+ }),
671
+ ...inputs.initialValue === void 0 ? {} : { initialValue: inputs.initialValue }
672
+ });
673
+ if (helpers.isCancel(result)) return {
674
+ err: { kind: "cancelled" },
675
+ success: false
676
+ };
677
+ return {
678
+ data: result,
679
+ success: true
680
+ };
681
+ }
682
+ async function selectMigrationSource(helpers, sources) {
683
+ return fromSelect(helpers, {
684
+ initialValue: sources[0],
685
+ message: "Migrate from?",
686
+ options: sources.map((source) => ({
687
+ label: SOURCE_LABELS[source],
688
+ value: source
689
+ }))
690
+ });
691
+ }
692
+ async function promptConfigFormatFrom(helpers) {
693
+ return fromSelect(helpers, {
694
+ initialValue: "typescript",
695
+ message: "Output config format?",
696
+ options: FORMAT_OPTIONS
697
+ });
698
+ }
699
+ function validateNonEmpty(value) {
700
+ if (value === void 0 || value.trim() === "") return "Required";
701
+ }
702
+ async function fromText(helpers, options) {
703
+ const result = await helpers.text(options);
704
+ if (helpers.isCancel(result)) return {
705
+ err: { kind: "cancelled" },
706
+ success: false
707
+ };
708
+ return {
709
+ data: result,
710
+ success: true
711
+ };
712
+ }
713
+ async function promptGistIdFrom(helpers) {
714
+ return fromText(helpers, {
715
+ message: "Gist ID for state storage?",
716
+ placeholder: "abc123",
717
+ validate: validateNonEmpty
718
+ });
719
+ }
720
+ async function selectPrimaryEnvironment(helpers, environments) {
721
+ return fromSelect(helpers, {
722
+ message: "Which environment should be the primary?\nThe migrator uses it as the baseline for the generated config.",
723
+ options: environments.map((name) => ({
724
+ label: name,
725
+ value: name
726
+ }))
727
+ });
728
+ }
729
+ async function promptStateBackendFrom(helpers) {
730
+ return fromSelect(helpers, {
731
+ initialValue: "gist",
732
+ message: "State backend?",
733
+ options: BACKEND_OPTIONS
734
+ });
735
+ }
736
+ async function fromPath(helpers, options) {
737
+ const result = await helpers.path(options);
738
+ if (helpers.isCancel(result)) return {
739
+ err: { kind: "cancelled" },
740
+ success: false
741
+ };
742
+ return {
743
+ data: result,
744
+ success: true
745
+ };
746
+ }
747
+ async function promptStateFilePathFrom(helpers) {
748
+ return fromPath(helpers, {
749
+ initialValue: ".mantle-state.yml",
750
+ message: "Path to the Mantle state file?",
751
+ validate: validateNonEmpty
752
+ });
753
+ }
754
+ //#endregion
755
+ //#region src/cli/parse-migrate-options.ts
756
+ /**
757
+ * Sources `bedrock migrate --from <source>` accepts. Today only `"mantle"`
758
+ * is wired up; widening this tuple turns on additional sources without
759
+ * touching the parser.
760
+ */
761
+ const SUPPORTED_MIGRATION_SOURCES = ["mantle"];
762
+ const RECOGNIZED_FLAGS = new Set(["from"]);
763
+ const SADE_RESERVED = new Set([
764
+ "--",
765
+ "_",
766
+ "h",
767
+ "help",
768
+ "v",
769
+ "version"
770
+ ]);
771
+ /**
772
+ * Translate the raw sade options POJO into a typed `MigrateOptions`. The
773
+ * positional `<stateFilePath>` argument is not handled here: sade hands
774
+ * positional values to the action callback ahead of the options object,
775
+ * and the migrate command falls back to an interactive prompt when it
776
+ * is absent. This parser only covers the `--from` flag, which is also
777
+ * optional and prompted for when omitted.
778
+ *
779
+ * @param rawOptions - The options object sade hands the action callback.
780
+ * @returns `Ok(MigrateOptions)` on success, or `Err(ParseMigrateError)`
781
+ * describing the offending flag or value.
782
+ */
783
+ function parseMigrateOptions(rawOptions) {
784
+ for (const key of Object.keys(rawOptions)) if (!RECOGNIZED_FLAGS.has(key) && !SADE_RESERVED.has(key)) return {
785
+ err: {
786
+ flag: key,
787
+ kind: "unknownFlag"
788
+ },
789
+ success: false
790
+ };
791
+ const fromRaw = rawOptions["from"];
792
+ if (fromRaw === void 0) return {
793
+ data: { from: void 0 },
794
+ success: true
795
+ };
796
+ if (typeof fromRaw !== "string") return {
797
+ err: {
798
+ flag: "from",
799
+ kind: "invalidValue"
800
+ },
801
+ success: false
802
+ };
803
+ if (!isMigrationSource(fromRaw)) return {
804
+ err: {
805
+ kind: "unknownSource",
806
+ received: fromRaw,
807
+ supported: SUPPORTED_MIGRATION_SOURCES
808
+ },
809
+ success: false
810
+ };
811
+ return {
812
+ data: { from: fromRaw },
813
+ success: true
814
+ };
815
+ }
816
+ function isMigrationSource(value) {
817
+ return SUPPORTED_MIGRATION_SOURCES.includes(value);
818
+ }
819
+ //#endregion
820
+ //#region src/cli/commands/describe-unknown.ts
821
+ /**
822
+ * Format a thrown value as a human-readable string for clack diagnostic
823
+ * lines. Native `Error` instances surface their `message`; everything
824
+ * else is coerced via `String()` so non-Error throws (rejection of a
825
+ * primitive, for example) still render legibly.
826
+ *
827
+ * @param value - The caught value to describe.
828
+ * @returns A short string suitable for inlining into a CLI error line.
829
+ */
830
+ function describeUnknown(value) {
831
+ return value instanceof Error ? value.message : String(value);
832
+ }
833
+ //#endregion
834
+ //#region src/cli/commands/write-migrated-states.ts
835
+ /**
836
+ * Persist every per-environment state in the migration report to the
837
+ * resolved target. Dispatches to the GitHub Gist `StatePort` adapter or
838
+ * to a local-file dump under `target.outputDir`. On failure the writer
839
+ * has already rendered the error to `deps.clack`; the caller only needs
840
+ * to translate the Err into an exit code.
841
+ *
842
+ * @param inputs - Resolved deps, the migration report, and the target
843
+ * the writer should dispatch on.
844
+ * @returns `Ok(void)` once every environment has been written; `Err(void)`
845
+ * on the first failure (already rendered to clack).
846
+ */
847
+ async function writeMigratedStates(inputs) {
848
+ if (inputs.target.backend === "local") return writeStatesToLocal({
849
+ ...inputs,
850
+ target: inputs.target
851
+ });
852
+ return writeStatesToGist({
853
+ ...inputs,
854
+ target: inputs.target
855
+ });
856
+ }
857
+ async function writeStatesToGist(inputs) {
858
+ const { deps, report, target } = inputs;
859
+ const portResult = deps.buildStatePort({
860
+ getEnv: (name) => process.env[name],
861
+ stateConfig: target.stateConfig
862
+ });
863
+ if (!portResult.success) {
864
+ renderBuildStatePortError(portResult.err, deps.clack);
865
+ return {
866
+ err: void 0,
867
+ success: false
868
+ };
869
+ }
870
+ for (const [environment, state] of Object.entries(report.statesByEnvironment)) {
871
+ const writeResult = await portResult.data.write(state);
872
+ if (!writeResult.success) {
873
+ renderStateWriteError({
874
+ environment,
875
+ err: writeResult.err
876
+ }, deps.clack);
877
+ return {
878
+ err: void 0,
879
+ success: false
880
+ };
881
+ }
882
+ deps.clack.logSuccess(`${environment}: ${state.resources.length} resources migrated`);
883
+ }
884
+ return {
885
+ data: void 0,
886
+ success: true
887
+ };
888
+ }
889
+ async function writeStatesToLocal(inputs) {
890
+ const { deps, report, target } = inputs;
891
+ try {
892
+ await deps.mkdir(target.outputDir);
893
+ } catch (err) {
894
+ deps.clack.logError(`local state directory create failed (${target.outputDir}): ${describeUnknown(err)}`);
895
+ return {
896
+ err: void 0,
897
+ success: false
898
+ };
899
+ }
900
+ for (const [environment, state] of Object.entries(report.statesByEnvironment)) {
901
+ const filePath = join(target.outputDir, `${environment}.json`);
902
+ try {
903
+ await deps.writeFile(filePath, serializeStateFile(state));
904
+ } catch (err) {
905
+ deps.clack.logError(`local state write failed (${filePath}): ${describeUnknown(err)}`);
906
+ return {
907
+ err: void 0,
908
+ success: false
909
+ };
910
+ }
911
+ deps.clack.logSuccess(`${environment}: ${state.resources.length} resources migrated`);
912
+ }
913
+ return {
914
+ data: void 0,
915
+ success: true
916
+ };
917
+ }
918
+ //#endregion
919
+ //#region src/core/migrate/render-migration-report-md.ts
920
+ /**
921
+ * Render a {@link MigrationReportFile} as the Markdown body written to
922
+ * `.bedrock/migration-report.md`. Pure derivation: the same input shape
923
+ * the JSON serializer consumes feeds this renderer, so the Markdown view
924
+ * round-trips through the JSON file.
925
+ *
926
+ * Output structure:
927
+ * - Header with the four counts.
928
+ * - Sections in user-action priority order (Action required, Blocked,
929
+ * Deferred, Interpretive). Sections with no matching warnings are
930
+ * omitted entirely.
931
+ * - Within each section, warnings are grouped by their non-path
932
+ * discriminator (`hint` for ambiguous, `reason` for blocked/deferred,
933
+ * `rule` for interpretive). First-appearance order is preserved.
934
+ * - Interpretive entries render as `mantlePath -> bedrockPath` so the
935
+ * user can verify the auto-applied mapping at a glance.
936
+ *
937
+ * @param file - The summary plus warnings to render.
938
+ * @returns Markdown source ending with a trailing newline.
939
+ */
940
+ function renderMigrationReportMarkdown(file) {
941
+ const sections = [
942
+ renderActionRequired(file.warnings),
943
+ renderBlocked(file.warnings),
944
+ renderDeferred(file.warnings),
945
+ renderInterpretive(file.warnings)
946
+ ].filter((section) => section !== "");
947
+ return [renderHeader(file), ...sections].join("\n");
948
+ }
949
+ function renderHeader(file) {
950
+ return [
951
+ "# Migration report",
952
+ "",
953
+ `ambiguous: ${String(file.summary.ambiguousCount)}`,
954
+ `blocked: ${String(file.summary.blockedCount)}`,
955
+ `deferred: ${String(file.summary.deferredCount)}`,
956
+ `interpretive: ${String(file.summary.interpretiveCount)}`,
957
+ ""
958
+ ].join("\n");
959
+ }
960
+ function suffixOf(mantlePath) {
961
+ return mantlePath.slice(mantlePath.indexOf(".") + 1);
962
+ }
963
+ function groupByKey(warnings, key) {
964
+ const orderedKeys = [...new Set(warnings.map(key))];
965
+ return new Map(orderedKeys.map((groupKey) => [groupKey, warnings.filter((warning) => key(warning) === groupKey)]));
966
+ }
967
+ function environmentOf(mantlePath) {
968
+ const dot = mantlePath.indexOf(".");
969
+ return dot === -1 ? void 0 : mantlePath.slice(0, dot);
970
+ }
971
+ function environmentsOf(members) {
972
+ const environments = members.map((warning) => environmentOf(warning.mantlePath)).filter((environment) => environment !== void 0);
973
+ return [...new Set(environments)];
974
+ }
975
+ function renderBullet(subject, members) {
976
+ const environments = environmentsOf(members);
977
+ return environments.length === 0 ? `- ${subject}` : `- ${subject} (${environments.join(", ")})`;
978
+ }
979
+ function renderSection(input) {
980
+ if (input.warnings.length === 0) return "";
981
+ const blocks = [...groupByKey(input.warnings, input.groupKey)].map(([heading, members]) => {
982
+ const lines = [...groupByKey(members, input.subject)].map(([key, subjectMembers]) => renderBullet(key, subjectMembers));
983
+ return [
984
+ `### ${heading}`,
985
+ "",
986
+ ...lines,
987
+ ""
988
+ ].join("\n");
989
+ });
990
+ return [
991
+ `## ${input.title}`,
992
+ "",
993
+ ...blocks
994
+ ].join("\n");
995
+ }
996
+ function renderActionRequired(warnings) {
997
+ return renderSection({
998
+ groupKey: (warning) => warning.hint,
999
+ subject: (warning) => suffixOf(warning.mantlePath),
1000
+ title: "Action required",
1001
+ warnings: warnings.filter((warning) => {
1002
+ return warning.kind === "ambiguous";
1003
+ })
1004
+ });
1005
+ }
1006
+ function renderBlocked(warnings) {
1007
+ return renderSection({
1008
+ groupKey: (warning) => warning.reason,
1009
+ subject: (warning) => suffixOf(warning.mantlePath),
1010
+ title: "Won't migrate (no Open Cloud equivalent)",
1011
+ warnings: warnings.filter((warning) => {
1012
+ return warning.kind === "blocked";
1013
+ })
1014
+ });
1015
+ }
1016
+ function renderDeferred(warnings) {
1017
+ return renderSection({
1018
+ groupKey: (warning) => warning.reason,
1019
+ subject: (warning) => suffixOf(warning.mantlePath),
1020
+ title: "Coming later (skipped for now)",
1021
+ warnings: warnings.filter((warning) => {
1022
+ return warning.kind === "deferred";
1023
+ })
1024
+ });
1025
+ }
1026
+ function renderInterpretive(warnings) {
1027
+ return renderSection({
1028
+ groupKey: (warning) => warning.rule,
1029
+ subject: (warning) => `${suffixOf(warning.mantlePath)} -> ${warning.bedrockPath}`,
1030
+ title: "Auto-mapped (please verify)",
1031
+ warnings: warnings.filter((warning) => {
1032
+ return warning.kind === "interpretive";
1033
+ })
1034
+ });
1035
+ }
1036
+ //#endregion
1037
+ //#region src/core/migrate/serialize-migration-report.ts
1038
+ /**
1039
+ * Serialize a {@link MigrationReportFile} as the bytes written to
1040
+ * `.bedrock/migration-report.json`. The output is canonical JSON:
1041
+ * pretty-printed with two-space indentation, terminated with a trailing newline.
1042
+ *
1043
+ * @param file - The summary plus warnings to serialize.
1044
+ * @returns A UTF-8 source string ending with a trailing newline.
1045
+ */
1046
+ function serializeMigrationReport(file) {
1047
+ return `${JSON.stringify(file, void 0, 2)}\n`;
1048
+ }
1049
+ //#endregion
1050
+ //#region src/cli/commands/write-migration-report.ts
1051
+ const REPORT_DIR_NAME = ".bedrock";
1052
+ const JSON_FILE_NAME = "migration-report.json";
1053
+ const MD_FILE_NAME = "migration-report.md";
1054
+ /**
1055
+ * Persist the migration report as `.bedrock/migration-report.json` and a
1056
+ * pure derivation `.bedrock/migration-report.md` next to the per-environment
1057
+ * state files. Always written so the user can compare runs even when no
1058
+ * warnings were emitted; the CLI summary line that points at the Markdown
1059
+ * file is gated on warning count separately.
1060
+ *
1061
+ * @param input - Resolved deps, the migration report, and the path of the
1062
+ * Mantle state file the report describes.
1063
+ * @returns `Ok` with both file paths once both writes succeed; `Err` on the
1064
+ * first failure (already rendered to clack).
1065
+ */
1066
+ async function writeMigrationReport(input) {
1067
+ const { deps, report, stateFilePath } = input;
1068
+ const reportDirectory = join(dirname(stateFilePath), REPORT_DIR_NAME);
1069
+ const jsonPath = join(reportDirectory, JSON_FILE_NAME);
1070
+ const mdPath = join(reportDirectory, MD_FILE_NAME);
1071
+ const file = {
1072
+ summary: report.summary,
1073
+ warnings: report.warnings
1074
+ };
1075
+ try {
1076
+ await deps.mkdir(reportDirectory);
1077
+ } catch (err) {
1078
+ deps.clack.logError(`migration report directory create failed (${reportDirectory}): ${describeUnknown(err)}`);
1079
+ return {
1080
+ err: void 0,
1081
+ success: false
1082
+ };
1083
+ }
1084
+ try {
1085
+ await deps.writeFile(jsonPath, serializeMigrationReport(file));
1086
+ } catch (err) {
1087
+ deps.clack.logError(`migration report write failed (${jsonPath}): ${describeUnknown(err)}`);
1088
+ return {
1089
+ err: void 0,
1090
+ success: false
1091
+ };
1092
+ }
1093
+ try {
1094
+ await deps.writeFile(mdPath, renderMigrationReportMarkdown(file));
1095
+ } catch (err) {
1096
+ deps.clack.logError(`migration report write failed (${mdPath}): ${describeUnknown(err)}`);
1097
+ return {
1098
+ err: void 0,
1099
+ success: false
1100
+ };
1101
+ }
1102
+ return {
1103
+ data: {
1104
+ jsonPath,
1105
+ mdPath
1106
+ },
1107
+ success: true
1108
+ };
1109
+ }
1110
+ //#endregion
1111
+ //#region src/cli/commands/finalize-migration.ts
1112
+ /**
1113
+ * Persist the migration's per-environment state, the bedrock config, and
1114
+ * the migration report (JSON + Markdown) in that order. Each failure
1115
+ * surface has already been rendered to clack by its respective writer; on
1116
+ * the first failure the function returns `Err` so the caller can exit
1117
+ * after a single shared `cancel("migrate failed")` line.
1118
+ *
1119
+ * @param inputs - Resolved deps, the migration report, and the resolved
1120
+ * state-target plus paths the writers consume.
1121
+ * @returns `Ok` carrying the on-disk Markdown report path (used by the
1122
+ * terminal summary line) once every writer succeeded; `Err(void)` once
1123
+ * any writer reported failure.
1124
+ */
1125
+ async function persistMigration(inputs) {
1126
+ if (!(await persistStateAndConfig(inputs)).success) return {
1127
+ err: void 0,
1128
+ success: false
1129
+ };
1130
+ const reportPaths = await writeMigrationReport({
1131
+ deps: {
1132
+ clack: inputs.deps.clack,
1133
+ mkdir: inputs.deps.mkdir,
1134
+ writeFile: inputs.deps.writeFile
1135
+ },
1136
+ report: inputs.report,
1137
+ stateFilePath: inputs.stateFilePath
1138
+ });
1139
+ return reportPaths.success ? {
1140
+ data: reportPaths.data.mdPath,
1141
+ success: true
1142
+ } : {
1143
+ err: void 0,
1144
+ success: false
1145
+ };
1146
+ }
1147
+ async function writeBedrockConfig(inputs) {
1148
+ const { configFilePath, configFormat, deps, report, target } = inputs;
1149
+ const { state: _ignoredState, ...configWithoutState } = report.config;
1150
+ const bytes = serializeConfig({
1151
+ config: target.backend === "gist" ? {
1152
+ ...configWithoutState,
1153
+ state: target.stateConfig
1154
+ } : configWithoutState,
1155
+ configFormat
1156
+ });
1157
+ try {
1158
+ await deps.writeFile(configFilePath, bytes);
1159
+ } catch (err) {
1160
+ deps.clack.logError(`config file write failed (${configFilePath}): ${describeUnknown(err)}`);
1161
+ return {
1162
+ err: void 0,
1163
+ success: false
1164
+ };
1165
+ }
1166
+ deps.clack.logSuccess(`wrote ${configFilePath}`);
1167
+ return {
1168
+ data: void 0,
1169
+ success: true
1170
+ };
1171
+ }
1172
+ async function persistStateAndConfig(inputs) {
1173
+ if (!(await writeMigratedStates({
1174
+ deps: inputs.deps,
1175
+ report: inputs.report,
1176
+ target: inputs.target
1177
+ })).success) return {
1178
+ err: void 0,
1179
+ success: false
1180
+ };
1181
+ return (await writeBedrockConfig(inputs)).success ? {
1182
+ data: void 0,
1183
+ success: true
1184
+ } : {
1185
+ err: void 0,
1186
+ success: false
1187
+ };
1188
+ }
1189
+ //#endregion
1190
+ //#region src/cli/commands/resolve-migrate-inputs.ts
1191
+ /**
1192
+ * Resolve the path to the input Mantle state file. The positional CLI
1193
+ * argument wins; when it is absent the user is asked through the prompt
1194
+ * port. Cancelling the prompt surfaces as `Err("cancelled")`.
1195
+ *
1196
+ * @param pathArgument - The positional `<stateFilePath>` value sade
1197
+ * handed the action callback, or `undefined` when omitted.
1198
+ * @param promptPort - The migrate prompt port whose `promptStateFilePath`
1199
+ * is used as the interactive fallback.
1200
+ * @returns `Ok(path)` on success, or `Err("cancelled")` if the user
1201
+ * aborted the prompt.
1202
+ */
1203
+ async function resolveStateFilePath(pathArgument, promptPort) {
1204
+ if (pathArgument !== void 0) return {
1205
+ data: pathArgument,
1206
+ success: true
1207
+ };
1208
+ const promptResult = await promptPort.promptStateFilePath();
1209
+ if (!promptResult.success) return {
1210
+ err: "cancelled",
1211
+ success: false
1212
+ };
1213
+ return {
1214
+ data: promptResult.data,
1215
+ success: true
1216
+ };
1217
+ }
1218
+ /**
1219
+ * Resolve which source format to migrate from. A validated `--from`
1220
+ * value wins; when it is absent the user picks from
1221
+ * {@link SUPPORTED_MIGRATION_SOURCES} through the prompt port.
1222
+ * Cancelling the prompt surfaces as `Err("cancelled")`.
1223
+ *
1224
+ * @param from - The validated `--from` value, or `undefined` when the
1225
+ * flag was omitted.
1226
+ * @param promptPort - The migrate prompt port whose
1227
+ * `promptMigrationSource` is used as the interactive fallback.
1228
+ * @returns `Ok(source)` on success, or `Err("cancelled")` if the user
1229
+ * aborted the prompt.
1230
+ */
1231
+ async function resolveMigrationSource(from, promptPort) {
1232
+ if (from !== void 0) return {
1233
+ data: from,
1234
+ success: true
1235
+ };
1236
+ const promptResult = await promptPort.promptMigrationSource(SUPPORTED_MIGRATION_SOURCES);
1237
+ if (!promptResult.success) return {
1238
+ err: "cancelled",
1239
+ success: false
1240
+ };
1241
+ return {
1242
+ data: promptResult.data,
1243
+ success: true
1244
+ };
1245
+ }
1246
+ //#endregion
1247
+ //#region src/cli/commands/migrate.ts
1248
+ const FAILED_OUTRO = "migrate failed";
1249
+ const CANCELLED_OUTRO = "migrate cancelled";
1250
+ /**
1251
+ * Build the sade action for `bedrock migrate`. The returned function
1252
+ * consumes the optional positional path argument and the raw options
1253
+ * object sade hands the action callback. The command parses `--from`,
1254
+ * resolves the state file path (positional or interactive), prompts for
1255
+ * the output config format, runs the migrator, prompts for the state
1256
+ * backend coordinates, writes the per-environment states through the
1257
+ * configured `StatePort`, and emits an enriched bedrock config to disk.
1258
+ *
1259
+ * @param deps - Dependency overrides; missing slots are default-constructed
1260
+ * from real implementations.
1261
+ * @returns An async sade action that returns once `deps.exit` was invoked.
1262
+ */
1263
+ function migrateCommand(deps) {
1264
+ const resolved = resolveMigrate(deps);
1265
+ return async (pathArgument, rawOptions) => {
1266
+ const code = await runMigrate({
1267
+ pathArg: pathArgument,
1268
+ rawOptions,
1269
+ resolved
1270
+ });
1271
+ resolved.exit(code);
1272
+ };
1273
+ }
1274
+ function resolveMigrate(deps) {
1275
+ return {
1276
+ buildStatePort: deps.buildStatePort ?? buildStatePort,
1277
+ clack: deps.clack ?? createClackPort(),
1278
+ exit: deps.exit ?? ((code) => process.exit(code)),
1279
+ migrateMantleState: deps.migrateMantleState ?? migrateMantleState,
1280
+ mkdir: deps.mkdir ?? (async (path) => void await mkdir(path, { recursive: true })),
1281
+ promptPort: deps.migratePromptPort ?? createDefaultMigratePromptPort(),
1282
+ writeFile: deps.writeFile ?? (async (path, contents) => writeFile(path, contents, "utf8"))
1283
+ };
1284
+ }
1285
+ function cancel$1(resolved) {
1286
+ resolved.clack.cancel(CANCELLED_OUTRO);
1287
+ return 1;
1288
+ }
1289
+ function failAfterRender(resolved) {
1290
+ resolved.clack.cancel(FAILED_OUTRO);
1291
+ return 1;
1292
+ }
1293
+ function renderedFailure(err, resolved) {
1294
+ renderMigrateError(err, resolved.clack);
1295
+ resolved.clack.cancel(FAILED_OUTRO);
1296
+ return {
1297
+ err: "rendered",
1298
+ success: false
1299
+ };
1300
+ }
1301
+ async function callMigrator(inputs) {
1302
+ const callDeps = {
1303
+ configFormat: inputs.configFormat,
1304
+ stateFilePath: inputs.stateFilePath,
1305
+ ...inputs.primaryEnvironment === void 0 ? {} : { primaryEnvironment: inputs.primaryEnvironment }
1306
+ };
1307
+ try {
1308
+ return await inputs.resolved.migrateMantleState(callDeps);
1309
+ } catch (err) {
1310
+ return {
1311
+ err: {
1312
+ cause: err,
1313
+ kind: "ioError",
1314
+ path: inputs.stateFilePath
1315
+ },
1316
+ success: false
1317
+ };
1318
+ }
1319
+ }
1320
+ function renderIoFailure(err, resolved) {
1321
+ resolved.clack.logError(`failed to read Mantle state file '${err.path}': ${describeUnknown(err.cause)}`);
1322
+ resolved.clack.cancel(FAILED_OUTRO);
1323
+ return {
1324
+ err: "rendered",
1325
+ success: false
1326
+ };
1327
+ }
1328
+ async function runMigratorWithPrompt(inputs) {
1329
+ const first = await callMigrator(inputs);
1330
+ if (first.success) return {
1331
+ data: first.data,
1332
+ success: true
1333
+ };
1334
+ if (first.err.kind === "ioError") return renderIoFailure(first.err, inputs.resolved);
1335
+ if (first.err.kind !== "primaryEnvironmentRequired") return renderedFailure(first.err, inputs.resolved);
1336
+ const primary = await inputs.resolved.promptPort.promptPrimaryEnvironment(first.err.available);
1337
+ if (!primary.success) return {
1338
+ err: "cancelled",
1339
+ success: false
1340
+ };
1341
+ const second = await callMigrator({
1342
+ ...inputs,
1343
+ primaryEnvironment: primary.data
1344
+ });
1345
+ if (second.success) return {
1346
+ data: second.data,
1347
+ success: true
1348
+ };
1349
+ if (second.err.kind === "ioError") return renderIoFailure(second.err, inputs.resolved);
1350
+ return renderedFailure(second.err, inputs.resolved);
1351
+ }
1352
+ async function finalize(inputs) {
1353
+ const persisted = await persistMigration(inputs);
1354
+ if (!persisted.success) {
1355
+ inputs.deps.clack.cancel(FAILED_OUTRO);
1356
+ return 1;
1357
+ }
1358
+ renderMigrationSummary({
1359
+ reportPath: persisted.data,
1360
+ summary: inputs.report.summary
1361
+ }, inputs.deps.clack);
1362
+ inputs.deps.clack.outro("migrate succeeded");
1363
+ return 0;
1364
+ }
1365
+ function configFileFor(stateFilePath, format) {
1366
+ const extension = format === "typescript" ? "ts" : "yaml";
1367
+ return join(dirname(stateFilePath), `bedrock.config.${extension}`);
1368
+ }
1369
+ async function promptForStateTarget(resolved, stateFilePath) {
1370
+ const backend = await resolved.promptPort.promptStateBackend();
1371
+ if (!backend.success) return {
1372
+ err: "cancelled",
1373
+ success: false
1374
+ };
1375
+ if (backend.data === "local") return {
1376
+ data: {
1377
+ backend: "local",
1378
+ outputDir: join(dirname(stateFilePath), ".bedrock", "state")
1379
+ },
1380
+ success: true
1381
+ };
1382
+ const gistId = await resolved.promptPort.promptGistId();
1383
+ if (!gistId.success) return {
1384
+ err: "cancelled",
1385
+ success: false
1386
+ };
1387
+ return {
1388
+ data: {
1389
+ backend: "gist",
1390
+ stateConfig: {
1391
+ backend: "gist",
1392
+ gistId: gistId.data
1393
+ }
1394
+ },
1395
+ success: true
1396
+ };
1397
+ }
1398
+ function finalizeDeps(resolved) {
1399
+ return {
1400
+ buildStatePort: resolved.buildStatePort,
1401
+ clack: resolved.clack,
1402
+ mkdir: resolved.mkdir,
1403
+ writeFile: resolved.writeFile
1404
+ };
1405
+ }
1406
+ async function runWithStateFilePath(stateFilePath, resolved) {
1407
+ const formatResult = await resolved.promptPort.promptConfigFormat();
1408
+ if (!formatResult.success) return cancel$1(resolved);
1409
+ const reportResult = await runMigratorWithPrompt({
1410
+ configFormat: formatResult.data,
1411
+ resolved,
1412
+ stateFilePath
1413
+ });
1414
+ if (!reportResult.success) return reportResult.err === "cancelled" ? cancel$1(resolved) : 1;
1415
+ const targetResult = await promptForStateTarget(resolved, stateFilePath);
1416
+ if (!targetResult.success) return cancel$1(resolved);
1417
+ return finalize({
1418
+ configFilePath: configFileFor(stateFilePath, formatResult.data),
1419
+ configFormat: formatResult.data,
1420
+ deps: finalizeDeps(resolved),
1421
+ report: reportResult.data,
1422
+ stateFilePath,
1423
+ target: targetResult.data
1424
+ });
1425
+ }
1426
+ async function dispatchBySource(inputs) {
1427
+ const { resolved, source, stateFilePath } = inputs;
1428
+ const handler = { mantle: async () => runWithStateFilePath(stateFilePath, resolved) }[source];
1429
+ return handler();
1430
+ }
1431
+ async function runMigrate(inputs) {
1432
+ const { pathArg, rawOptions, resolved } = inputs;
1433
+ resolved.clack.intro("bedrock migrate");
1434
+ const parsed = parseMigrateOptions(rawOptions);
1435
+ if (!parsed.success) {
1436
+ renderMigrateParseError(parsed.err, resolved.clack);
1437
+ return failAfterRender(resolved);
1438
+ }
1439
+ const source = await resolveMigrationSource(parsed.data.from, resolved.promptPort);
1440
+ if (!source.success) return cancel$1(resolved);
1441
+ const stateFilePath = await resolveStateFilePath(pathArg, resolved.promptPort);
1442
+ if (!stateFilePath.success) return cancel$1(resolved);
1443
+ return dispatchBySource({
1444
+ resolved,
1445
+ source: source.data,
1446
+ stateFilePath: stateFilePath.data
1447
+ });
1448
+ }
1449
+ //#endregion
1450
+ //#region src/cli/index.ts
1451
+ const PROGRAM_NAME = "bedrock";
1452
+ const PROGRAM_DESCRIBE = "Infrastructure-as-Code deployment tool for Roblox";
1453
+ /**
1454
+ * Construct the bedrock CLI program. Pure factory: no `process.argv` parsing,
1455
+ * no clack output, no exits. Callers (the `run.ts` shim, integration tests)
1456
+ * call `.parse()` on the returned sade instance.
1457
+ * @param deps - Dependency overrides for command actions. Each command
1458
+ * resolves its own defaults from any omitted slots.
1459
+ * @returns A configured sade program with the bedrock name, description, and
1460
+ * the currently installed `@bedrock-rbx/core` version, plus the registered
1461
+ * `deploy`, `diff`, and `migrate` commands.
1462
+ */
1463
+ function createProg(deps = {}) {
1464
+ const prog = sade(PROGRAM_NAME).describe(PROGRAM_DESCRIBE).version(version);
1465
+ prog.command("deploy").describe("Reconcile a project's resources against the configured environment(s)").option("--env", "Target environment (repeat for multiple)").option("--config", "Config file path (overrides discovery)").option("--api-key", "Override the ROBLOX_API_KEY environment variable").option("--github-token", "Override the GITHUB_TOKEN environment variable").action(deployCommand(deps));
1466
+ prog.command("diff").describe("Preview the operations a deploy would apply, without writing state").option("--env", "Target environment (repeat for multiple)").option("--config", "Config file path (overrides discovery)").option("--api-key", "Override the ROBLOX_API_KEY environment variable").option("--github-token", "Override the GITHUB_TOKEN environment variable").action(diffCommand(deps));
1467
+ prog.command("migrate [stateFilePath]").describe("Translate a state file from another tool into a bedrock project").option("--from", "Source format to migrate from (mantle; prompted if omitted)").action(migrateCommand(deps));
1468
+ return prog;
1469
+ }
1470
+ //#endregion
1471
+ //#region src/cli/run.ts
1472
+ createProg().parse(process.argv);
1473
+ //#endregion
1474
+ export {};
1475
+
1476
+ //# sourceMappingURL=run.mjs.map