@rehearsal-db/core 0.1.0-beta.13 → 0.1.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +4 -1
  3. package/dist/package.json +100 -0
  4. package/dist/scripts/install/create_root_config.d.mts +3 -0
  5. package/dist/scripts/install/create_root_config.mjs +13 -0
  6. package/dist/scripts/runtime/manage_database.d.mts +6 -0
  7. package/{scripts → dist/scripts}/runtime/manage_database.mjs +6 -8
  8. package/dist/src/application/session.d.mts +96 -0
  9. package/dist/src/application/session.mjs +380 -0
  10. package/dist/src/baseline/artifact.d.mts +119 -0
  11. package/dist/src/baseline/artifact.mjs +522 -0
  12. package/dist/src/baseline/builder.d.mts +19 -0
  13. package/dist/src/baseline/builder.mjs +142 -0
  14. package/dist/src/baseline/input_discovery.d.mts +13 -0
  15. package/dist/src/baseline/input_discovery.mjs +149 -0
  16. package/dist/src/baseline/policy_review.d.mts +56 -0
  17. package/dist/src/baseline/policy_review.mjs +73 -0
  18. package/dist/src/baseline/preparation.d.mts +63 -0
  19. package/dist/src/baseline/preparation.mjs +295 -0
  20. package/dist/src/baseline/privacy_engine.d.mts +162 -0
  21. package/dist/src/baseline/privacy_engine.mjs +1162 -0
  22. package/dist/src/baseline/privacy_structured_json.d.mts +66 -0
  23. package/dist/src/baseline/privacy_structured_json.mjs +464 -0
  24. package/dist/src/baseline/sanitization_policy.d.mts +69 -0
  25. package/dist/src/baseline/sanitization_policy.mjs +227 -0
  26. package/dist/src/baseline/schema_snapshot.d.mts +19 -0
  27. package/dist/src/baseline/schema_snapshot.mjs +79 -0
  28. package/dist/src/cli/arguments.d.mts +43 -0
  29. package/dist/src/cli/arguments.mjs +135 -0
  30. package/dist/src/cli/guided.d.mts +51 -0
  31. package/dist/src/cli/guided.mjs +713 -0
  32. package/dist/src/cli/init_command.d.mts +21 -0
  33. package/dist/src/cli/init_command.mjs +90 -0
  34. package/dist/src/cli/project_commands.d.mts +157 -0
  35. package/dist/src/cli/project_commands.mjs +183 -0
  36. package/dist/src/cli/rehearsal.d.mts +7 -0
  37. package/dist/src/cli/rehearsal.mjs +706 -0
  38. package/dist/src/cli/renderers.d.mts +299 -0
  39. package/dist/src/cli/renderers.mjs +554 -0
  40. package/dist/src/cli/runtime_commands.d.mts +265 -0
  41. package/dist/src/cli/runtime_commands.mjs +599 -0
  42. package/dist/src/cli/source_commands.d.mts +432 -0
  43. package/dist/src/cli/source_commands.mjs +299 -0
  44. package/dist/src/cli/terminal.d.mts +59 -0
  45. package/dist/src/cli/terminal.mjs +230 -0
  46. package/dist/src/identity/claim.d.mts +144 -0
  47. package/dist/src/identity/claim.mjs +947 -0
  48. package/dist/src/identity/provider_configuration.d.mts +37 -0
  49. package/dist/src/identity/provider_configuration.mjs +132 -0
  50. package/dist/src/identity/storage.d.mts +21 -0
  51. package/dist/src/identity/storage.mjs +122 -0
  52. package/dist/src/index.d.mts +4 -0
  53. package/dist/src/index.mjs +3 -0
  54. package/dist/src/project/configuration.d.mts +181 -0
  55. package/dist/src/project/configuration.mjs +958 -0
  56. package/dist/src/project/install_scaffold.d.mts +42 -0
  57. package/dist/src/project/install_scaffold.mjs +263 -0
  58. package/dist/src/project/setup.d.mts +87 -0
  59. package/dist/src/project/setup.mjs +414 -0
  60. package/dist/src/project/support_report.d.mts +88 -0
  61. package/dist/src/project/support_report.mjs +83 -0
  62. package/dist/src/runtime/cleanup.d.mts +85 -0
  63. package/dist/src/runtime/cleanup.mjs +311 -0
  64. package/dist/src/runtime/migration_history.d.mts +51 -0
  65. package/dist/src/runtime/migration_history.mjs +170 -0
  66. package/dist/src/runtime/plan.d.mts +134 -0
  67. package/dist/src/runtime/plan.mjs +636 -0
  68. package/dist/src/runtime/policy.d.mts +66 -0
  69. package/dist/src/runtime/policy.mjs +369 -0
  70. package/dist/src/runtime/restore.d.mts +22 -0
  71. package/dist/src/runtime/restore.mjs +276 -0
  72. package/dist/src/runtime/service_environment.d.mts +11 -0
  73. package/dist/src/runtime/service_environment.mjs +58 -0
  74. package/dist/src/runtime/topology.d.mts +51 -0
  75. package/dist/src/runtime/topology.mjs +128 -0
  76. package/dist/src/shared/diagnostics.d.mts +97 -0
  77. package/dist/src/shared/diagnostics.mjs +170 -0
  78. package/dist/src/shared/human_output.d.mts +2 -0
  79. package/{src → dist/src}/shared/human_output.mjs +1 -3
  80. package/dist/src/shared/node_runtime.d.mts +14 -0
  81. package/dist/src/shared/node_runtime.mjs +25 -0
  82. package/dist/src/shared/operation_guard.d.mts +22 -0
  83. package/dist/src/shared/operation_guard.mjs +150 -0
  84. package/dist/src/shared/process_environment.d.mts +15 -0
  85. package/dist/src/shared/process_environment.mjs +50 -0
  86. package/dist/src/source/access.d.mts +165 -0
  87. package/dist/src/source/access.mjs +602 -0
  88. package/dist/src/source/asset_transfer.d.mts +27 -0
  89. package/dist/src/source/asset_transfer.mjs +202 -0
  90. package/dist/src/source/baseline.d.mts +66 -0
  91. package/dist/src/source/baseline.mjs +372 -0
  92. package/dist/src/source/external_reader.d.mts +42 -0
  93. package/dist/src/source/external_reader.mjs +278 -0
  94. package/dist/src/source/postgresql_access.d.mts +86 -0
  95. package/dist/src/source/postgresql_access.mjs +454 -0
  96. package/dist/src/source/privacy_paths.d.mts +10 -0
  97. package/dist/src/source/privacy_paths.mjs +27 -0
  98. package/dist/src/targets/driver.d.mts +2 -0
  99. package/dist/src/targets/driver.mjs +13 -0
  100. package/dist/src/targets/postgresql.d.mts +5 -0
  101. package/dist/src/targets/postgresql.mjs +641 -0
  102. package/dist/src/targets/supabase.d.mts +8 -0
  103. package/dist/src/targets/supabase.mjs +659 -0
  104. package/dist/src/targets/supabase_environment.d.mts +49 -0
  105. package/dist/src/targets/supabase_environment.mjs +162 -0
  106. package/dist/src/targets/target.d.mts +33 -0
  107. package/dist/src/targets/target.mjs +39 -0
  108. package/docs/README.md +1 -0
  109. package/docs/architecture.md +17 -9
  110. package/docs/configuration.md +49 -8
  111. package/docs/getting-started.md +28 -0
  112. package/docs/index.md +1 -0
  113. package/docs/production-source.md +43 -7
  114. package/docs/releasing.md +5 -3
  115. package/docs/roadmap.md +3 -2
  116. package/docs/sanitization.md +415 -14
  117. package/docs/standalone-workflow.md +12 -6
  118. package/docs/troubleshooting.md +5 -1
  119. package/docs/tutorial.md +5 -0
  120. package/docs/typescript.md +87 -0
  121. package/package.json +35 -29
  122. package/scripts/install/create_root_config.mjs +0 -36
  123. package/src/README.md +0 -17
  124. package/src/application/session.mjs +0 -435
  125. package/src/baseline/artifact.mjs +0 -684
  126. package/src/baseline/builder.mjs +0 -164
  127. package/src/baseline/input_discovery.mjs +0 -155
  128. package/src/baseline/policy_review.mjs +0 -92
  129. package/src/baseline/preparation.mjs +0 -323
  130. package/src/baseline/privacy_engine.mjs +0 -952
  131. package/src/baseline/sanitization_policy.mjs +0 -299
  132. package/src/baseline/schema_snapshot.mjs +0 -113
  133. package/src/cli/arguments.mjs +0 -126
  134. package/src/cli/guided.mjs +0 -814
  135. package/src/cli/init_command.mjs +0 -94
  136. package/src/cli/rehearsal.mjs +0 -970
  137. package/src/cli/renderers.mjs +0 -626
  138. package/src/cli/runtime_commands.mjs +0 -707
  139. package/src/cli/source_commands.mjs +0 -347
  140. package/src/cli/terminal.mjs +0 -275
  141. package/src/identity/claim.mjs +0 -1163
  142. package/src/identity/provider_configuration.mjs +0 -177
  143. package/src/identity/storage.mjs +0 -165
  144. package/src/project/configuration.mjs +0 -1337
  145. package/src/project/install_scaffold.mjs +0 -291
  146. package/src/project/setup.mjs +0 -466
  147. package/src/project/support_report.mjs +0 -105
  148. package/src/runtime/cleanup.mjs +0 -381
  149. package/src/runtime/migration_history.mjs +0 -220
  150. package/src/runtime/plan.mjs +0 -722
  151. package/src/runtime/policy.mjs +0 -438
  152. package/src/runtime/restore.mjs +0 -337
  153. package/src/runtime/service_environment.mjs +0 -82
  154. package/src/runtime/topology.mjs +0 -177
  155. package/src/shared/diagnostics.mjs +0 -207
  156. package/src/shared/node_runtime.mjs +0 -31
  157. package/src/shared/operation_guard.mjs +0 -162
  158. package/src/shared/process_environment.mjs +0 -68
  159. package/src/source/access.mjs +0 -753
  160. package/src/source/asset_transfer.mjs +0 -207
  161. package/src/source/baseline.mjs +0 -482
  162. package/src/source/external_reader.mjs +0 -255
  163. package/src/source/postgresql_access.mjs +0 -581
  164. package/src/source/privacy_paths.mjs +0 -38
  165. package/src/targets/postgresql.mjs +0 -808
  166. package/src/targets/supabase.mjs +0 -840
  167. package/src/targets/supabase_environment.mjs +0 -229
  168. package/src/targets/target.mjs +0 -66
  169. /package/{src/project → types}/configuration.d.mts +0 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * CLI orchestration for temporary production-source access and safe baseline
3
+ * replacement. Domain validation remains in src/source and src/baseline.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { readFile, stat } from "node:fs/promises";
7
+ import { join, relative } from "node:path";
8
+ import { activateBaselineGeneration, planBaselineGenerationPrune, planBaselineReplacementPrune, pruneBaselineGenerations, verifyActiveBaseline, } from "../baseline/artifact.mjs";
9
+ import { createPrivacyEngine, readPrivacyKey, } from "../baseline/privacy_engine.mjs";
10
+ import { loadRehearsalConfig } from "../project/configuration.mjs";
11
+ import { createSourceAccessPlan, validateSourceAccessPolicy, } from "../source/access.mjs";
12
+ import { streamApprovedSupabaseAssets } from "../source/asset_transfer.mjs";
13
+ import { refreshPostgresqlBaseline } from "../source/baseline.mjs";
14
+ import { resolvePrivacyMappedAssets } from "../source/privacy_paths.mjs";
15
+ import { applyPostgresqlSourceAccess, planPostgresqlSourceAccessRetirement, resolvePostgresqlSourceStatePaths, retirePostgresqlSourceAccess, } from "../source/postgresql_access.mjs";
16
+ import { loadRuntimeTopology } from "../runtime/topology.mjs";
17
+ const parseSourceAccessReceipt = (source) => {
18
+ const parsed = JSON.parse(source);
19
+ if (typeof parsed.targetFingerprint !== "string" ||
20
+ typeof parsed.planDigest !== "string" ||
21
+ typeof parsed.expiresAt !== "string") {
22
+ throw new Error("The source-access receipt has an invalid shape.");
23
+ }
24
+ return parsed;
25
+ };
26
+ export const loadPreparationContext = async (planOptions) => {
27
+ const loaded = await loadRehearsalConfig(planOptions);
28
+ if (!loaded.config.preparation) {
29
+ throw new Error("Source preparation is not configured. Review docs/standalone-workflow.md, then enable config.preparation.");
30
+ }
31
+ if (!loaded.paths.sourcePolicy || !loaded.paths.privacyKey) {
32
+ throw new Error("Source preparation paths are incomplete.");
33
+ }
34
+ const sourcePolicyBytes = await readFile(loaded.paths.sourcePolicy);
35
+ const sourcePolicy = validateSourceAccessPolicy(JSON.parse(sourcePolicyBytes.toString("utf8")));
36
+ return { loaded, sourcePolicy, sourcePolicyBytes };
37
+ };
38
+ export const runSourceAccess = async ({ flags, planOptions, }) => {
39
+ const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
40
+ const plan = createSourceAccessPlan({ policy: sourcePolicy });
41
+ if (!flags.sourceAccessConfirmation)
42
+ return { mode: "preview", plan };
43
+ const applied = await applyPostgresqlSourceAccess({
44
+ plan,
45
+ confirmation: flags.sourceAccessConfirmation,
46
+ projectRoot: loaded.projectRoot,
47
+ artifactRoot: loaded.paths.artifactDirectory,
48
+ });
49
+ return {
50
+ mode: "written",
51
+ plan,
52
+ receipt: applied.receipt,
53
+ credentialFile: relative(loaded.projectRoot, applied.credentialPath),
54
+ };
55
+ };
56
+ export const runSourceRetirement = async ({ flags, planOptions, }) => {
57
+ const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
58
+ const plan = await planPostgresqlSourceAccessRetirement({
59
+ projectRoot: loaded.projectRoot,
60
+ artifactRoot: loaded.paths.artifactDirectory,
61
+ targetFingerprint: sourcePolicy.targetFingerprint,
62
+ });
63
+ if (!flags.sourceRetirementConfirmation)
64
+ return { mode: "preview", plan };
65
+ await retirePostgresqlSourceAccess({
66
+ plan,
67
+ confirmation: flags.sourceRetirementConfirmation,
68
+ ...(sourcePolicy.administratorEnvironmentVariable
69
+ ? {
70
+ administratorEnvironmentVariable: sourcePolicy.administratorEnvironmentVariable,
71
+ }
72
+ : {}),
73
+ projectRoot: loaded.projectRoot,
74
+ artifactRoot: loaded.paths.artifactDirectory,
75
+ credentialFile: sourcePolicy.reader.credentialFile,
76
+ });
77
+ return { mode: "written", plan };
78
+ };
79
+ const readSourceReaderCredential = async (path) => {
80
+ const details = await stat(path);
81
+ if (!details.isFile() || (details.mode & 0o077) !== 0) {
82
+ throw new Error("Source reader credential must be an owner-only regular file.");
83
+ }
84
+ const lines = (await readFile(path, "utf8")).split(/\r?\n/u).filter(Boolean);
85
+ if (lines.length !== 1 ||
86
+ !lines[0]?.startsWith("REHEARSAL_SOURCE_DATABASE_URL=")) {
87
+ throw new Error("Source reader credential file has an invalid shape.");
88
+ }
89
+ const value = lines[0].slice("REHEARSAL_SOURCE_DATABASE_URL=".length);
90
+ if (!value)
91
+ throw new Error("Source reader credential file is empty.");
92
+ return value;
93
+ };
94
+ export const runSourceBaselineRefresh = async ({ planOptions, }) => {
95
+ const { loaded, sourcePolicy } = await loadPreparationContext(planOptions);
96
+ const preparation = loaded.config.preparation;
97
+ const privacyKeyPath = loaded.paths.privacyKey;
98
+ if (!preparation || !privacyKeyPath) {
99
+ throw new Error("Source preparation configuration is incomplete.");
100
+ }
101
+ const { credentialPath } = resolvePostgresqlSourceStatePaths({
102
+ projectRoot: loaded.projectRoot,
103
+ artifactRoot: loaded.paths.artifactDirectory,
104
+ credentialFile: sourcePolicy.reader.credentialFile,
105
+ });
106
+ const [sourceAccessReceipt, privacyPolicyBytes, privacyKey, connectionString,] = await Promise.all([
107
+ readFile(join(loaded.paths.artifactDirectory, "source-access-receipt.json"), "utf8").then(parseSourceAccessReceipt),
108
+ readFile(loaded.paths.sanitizationPolicy),
109
+ readPrivacyKey(privacyKeyPath),
110
+ readSourceReaderCredential(credentialPath),
111
+ ]);
112
+ const privacyPolicy = JSON.parse(privacyPolicyBytes.toString("utf8"));
113
+ const assetPrivacyEngine = createPrivacyEngine({
114
+ policy: privacyPolicy,
115
+ key: privacyKey,
116
+ });
117
+ const assetDeclarations = resolvePrivacyMappedAssets({
118
+ sourcePolicy,
119
+ privacyPolicy,
120
+ });
121
+ return refreshPostgresqlBaseline({
122
+ connectionString,
123
+ sourcePolicy,
124
+ sourceAccessReceipt,
125
+ privacyPolicyBytes,
126
+ privacyKey,
127
+ artifactRoot: loaded.paths.artifactDirectory,
128
+ migrationDirectory: loaded.paths.migrationDirectory,
129
+ limits: {
130
+ batchRows: preparation.batchRows,
131
+ maximumRows: preparation.maximumRows,
132
+ maximumBytes: preparation.maximumBytes,
133
+ diskHeadroomBytes: preparation.diskHeadroomBytes,
134
+ },
135
+ assets: sourcePolicy.assetReader
136
+ ? streamApprovedSupabaseAssets({
137
+ baseUrl: process.env[sourcePolicy.assetReader.baseUrlEnvironmentVariable] ??
138
+ "",
139
+ token: process.env[sourcePolicy.assetReader.tokenEnvironmentVariable] ??
140
+ "",
141
+ declarations: assetDeclarations,
142
+ maximumObjects: sourcePolicy.assetReader.maximumObjects,
143
+ maximumObjectBytes: sourcePolicy.assetReader.maximumObjectBytes,
144
+ maximumTotalBytes: sourcePolicy.assetReader.maximumTotalBytes,
145
+ pathMapper: ({ mapping, value }) => assetPrivacyEngine.remapPath({ mapping, value }),
146
+ })
147
+ : [],
148
+ });
149
+ };
150
+ export const buildRefreshWorkflowPlan = async (planOptions) => {
151
+ const [{ loaded, sourcePolicy, sourcePolicyBytes }, topology] = await Promise.all([
152
+ loadPreparationContext(planOptions),
153
+ loadRuntimeTopology(planOptions),
154
+ ]);
155
+ const hashFile = async (path) => path
156
+ ? createHash("sha256")
157
+ .update(await readFile(path))
158
+ .digest("hex")
159
+ : null;
160
+ const [currentBaseline, sourceAccessReceipt, replacementCleanup, privacyPolicySha256, runtimeConfigurations,] = await Promise.all([
161
+ verifyActiveBaseline({ artifactRoot: loaded.paths.artifactDirectory }),
162
+ readFile(join(loaded.paths.artifactDirectory, "source-access-receipt.json"), "utf8").then(parseSourceAccessReceipt),
163
+ planBaselineReplacementPrune({
164
+ artifactRoot: loaded.paths.artifactDirectory,
165
+ retain: loaded.config.cleanup.retainBaselineGenerations,
166
+ }),
167
+ hashFile(loaded.paths.sanitizationPolicy),
168
+ Promise.all(topology.targets.map(async (target) => ({
169
+ target: target.name,
170
+ configSha256: await hashFile(target.configPath),
171
+ runtimePolicySha256: await hashFile(target.paths.runtimePolicy),
172
+ runtimeAdapterSha256: await hashFile(target.paths.runtimeAdapter),
173
+ localServiceConfigSha256: await hashFile(target.paths.rehearsalConfig),
174
+ }))),
175
+ ]);
176
+ if (sourceAccessReceipt.targetFingerprint !== sourcePolicy.targetFingerprint ||
177
+ !/^[a-f0-9]{64}$/u.test(sourceAccessReceipt.planDigest ?? "")) {
178
+ throw new Error("The source-access receipt does not match the selected refresh policy.");
179
+ }
180
+ const sourceReaderExpiry = new Date(sourceAccessReceipt.expiresAt).valueOf();
181
+ if (!Number.isFinite(sourceReaderExpiry) ||
182
+ sourceReaderExpiry <= Date.now()) {
183
+ throw new Error("The temporary source reader has expired. Preview and apply source access again before refreshing.");
184
+ }
185
+ const review = {
186
+ planVersion: 1,
187
+ operation: "refresh-and-replace-local-copy",
188
+ currentBaseline: currentBaseline.generationId,
189
+ source: {
190
+ targetFingerprint: sourcePolicy.targetFingerprint,
191
+ accessPlanDigest: sourceAccessReceipt.planDigest,
192
+ readerExpiresAt: sourceAccessReceipt.expiresAt,
193
+ sourcePolicySha256: createHash("sha256")
194
+ .update(sourcePolicyBytes)
195
+ .digest("hex"),
196
+ privacyPolicySha256,
197
+ },
198
+ runtimeConfigurations,
199
+ runtimeTargets: topology.targets.map((target) => target.name),
200
+ dependentPreparations: topology.dependents.flatMap((target) => target.declaration?.prepareCommand
201
+ ? [{ target: target.name, command: target.declaration.prepareCommand }]
202
+ : []),
203
+ retention: {
204
+ generations: loaded.config.cleanup.retainBaselineGenerations,
205
+ removeAfterReplacement: replacementCleanup.removedAfterReplacement,
206
+ keepFromCurrent: replacementCleanup.retainedAfterReplacement,
207
+ },
208
+ steps: [
209
+ "build and verify a new immutable baseline beside the current baseline",
210
+ "activate the new baseline only after verification",
211
+ "discard runtime edits and reset the complete local runtime stack",
212
+ "run configured dependent preparation commands",
213
+ "remove only the old baseline generations listed in this plan",
214
+ ],
215
+ };
216
+ return {
217
+ loaded,
218
+ topology,
219
+ plan: {
220
+ review,
221
+ digest: createHash("sha256")
222
+ .update(`${JSON.stringify(review, null, "\t")}\n`)
223
+ .digest("hex"),
224
+ },
225
+ };
226
+ };
227
+ export const createRefreshWorkflow = ({ runRuntimeStack, prepareDependentTargets, topologyCommandEnvironment, buildPlan = buildRefreshWorkflowPlan, refreshBaseline = runSourceBaselineRefresh, }) => async ({ flags, planOptions }) => {
228
+ const context = await buildPlan(planOptions);
229
+ if (!flags.refreshConfirmation) {
230
+ return { mode: "preview", plan: context.plan };
231
+ }
232
+ if (flags.refreshConfirmation !== context.plan.digest) {
233
+ throw new Error(`Refresh confirmation does not match this exact plan. Expected ${context.plan.digest}. Nothing changed.`);
234
+ }
235
+ let refreshed;
236
+ let runtime;
237
+ let preparations = [];
238
+ try {
239
+ refreshed = await refreshBaseline({ planOptions });
240
+ const stack = await runRuntimeStack({
241
+ command: "reset",
242
+ flags,
243
+ planOptions,
244
+ });
245
+ runtime = stack.runtime;
246
+ preparations = prepareDependentTargets({
247
+ topology: stack.topology,
248
+ environment: topologyCommandEnvironment(stack.topology),
249
+ });
250
+ }
251
+ catch (error) {
252
+ if (refreshed) {
253
+ await activateBaselineGeneration({
254
+ artifactRoot: context.loaded.paths.artifactDirectory,
255
+ generationId: context.plan.review.currentBaseline,
256
+ });
257
+ try {
258
+ const rollback = await runRuntimeStack({
259
+ command: "reset",
260
+ flags,
261
+ planOptions,
262
+ });
263
+ prepareDependentTargets({
264
+ topology: rollback.topology,
265
+ environment: topologyCommandEnvironment(rollback.topology),
266
+ });
267
+ }
268
+ catch (rollbackError) {
269
+ throw new AggregateError([error, rollbackError], "The refreshed runtime failed and the previous local runtime could not be restored automatically. The previous immutable baseline is active; run rehearsal reset after correcting the reported problem.");
270
+ }
271
+ throw new Error("The refreshed runtime failed verification. Rehearsal restored the previous baseline and local runtime.", { cause: error });
272
+ }
273
+ throw error;
274
+ }
275
+ let cleanup;
276
+ try {
277
+ const result = await pruneBaselineGenerations({
278
+ artifactRoot: context.loaded.paths.artifactDirectory,
279
+ retain: context.plan.review.retention.generations,
280
+ expectedRemoved: context.plan.review.retention.removeAfterReplacement,
281
+ });
282
+ cleanup = { state: "complete", removed: result.removed };
283
+ }
284
+ catch {
285
+ const remaining = await planBaselineGenerationPrune({
286
+ artifactRoot: context.loaded.paths.artifactDirectory,
287
+ retain: context.plan.review.retention.generations,
288
+ }).catch(() => ({ removed: [] }));
289
+ cleanup = { state: "incomplete", remaining: remaining.removed.length };
290
+ }
291
+ return {
292
+ mode: "written",
293
+ plan: context.plan,
294
+ refreshed,
295
+ runtime,
296
+ preparations,
297
+ cleanup,
298
+ };
299
+ };
@@ -0,0 +1,59 @@
1
+ /** Terminal presentation and prompt primitives shared by guided CLI workflows. */
2
+ export interface TerminalFlags {
3
+ readonly plain?: boolean;
4
+ readonly json?: boolean;
5
+ }
6
+ export interface ChoiceOption<Value = unknown> {
7
+ readonly label: string;
8
+ readonly hint?: string;
9
+ readonly command?: string;
10
+ readonly value?: Value;
11
+ }
12
+ interface ValueChoiceOption<Value> extends ChoiceOption<Value> {
13
+ readonly value: Value;
14
+ }
15
+ type PathValidator = (value: string) => string | undefined;
16
+ export declare const terminalStyle: (flags: TerminalFlags, code: string, value: string) => string;
17
+ export declare const isHumanTerminal: (flags: TerminalFlags) => boolean;
18
+ export declare const useStyledPrompts: (flags: TerminalFlags) => boolean;
19
+ export declare const formatDuration: (durationMs: number) => string;
20
+ export declare const promptForChoice: <Option extends ChoiceOption>({ message, options, flags, optionsAlreadyShown, }: {
21
+ message: string;
22
+ options: readonly Option[];
23
+ flags?: TerminalFlags;
24
+ optionsAlreadyShown?: boolean;
25
+ }) => Promise<Option | undefined>;
26
+ export declare function promptForConfirmation(message: string, flags: TerminalFlags, options: {
27
+ cancelValue: null;
28
+ }): Promise<boolean | null>;
29
+ export declare function promptForConfirmation(message: string, flags?: TerminalFlags, options?: {
30
+ cancelValue?: boolean;
31
+ }): Promise<boolean>;
32
+ export declare const promptForPath: ({ message, defaultValue, optional, flags, validate, }: {
33
+ message: string;
34
+ defaultValue?: string;
35
+ optional?: boolean;
36
+ flags?: TerminalFlags;
37
+ validate?: PathValidator;
38
+ }) => Promise<string | undefined>;
39
+ export declare const promptForSelection: <Value>({ message, options, flags, }: {
40
+ message: string;
41
+ options: readonly ValueChoiceOption<Value>[];
42
+ flags?: TerminalFlags;
43
+ }) => Promise<Value | undefined>;
44
+ export declare const promptForDiscoveredPath: ({ message, candidates, optional, flags, }: {
45
+ message: string;
46
+ candidates: readonly string[];
47
+ optional?: boolean;
48
+ flags?: TerminalFlags;
49
+ }) => Promise<string | undefined>;
50
+ export declare const promptForMultipleChoice: ({ message, options, initialValues, flags, }: {
51
+ message: string;
52
+ options: readonly ValueChoiceOption<string>[];
53
+ initialValues?: readonly string[];
54
+ flags?: TerminalFlags;
55
+ }) => Promise<string[] | undefined>;
56
+ export declare const installGuidedExitShortcut: (flags: TerminalFlags, { beforeExit }?: {
57
+ beforeExit?: () => void;
58
+ }) => (() => void);
59
+ export {};
@@ -0,0 +1,230 @@
1
+ /** Terminal presentation and prompt primitives shared by guided CLI workflows. */
2
+ import { createInterface } from "node:readline/promises";
3
+ import * as prompts from "@clack/prompts";
4
+ const useColor = (flags) => !flags.plain &&
5
+ process.stdout.isTTY &&
6
+ process.env.NO_COLOR === undefined &&
7
+ process.env.TERM !== "dumb";
8
+ export const terminalStyle = (flags, code, value) => (useColor(flags) ? `\u001B[${code}m${value}\u001B[0m` : value);
9
+ export const isHumanTerminal = (flags) => !flags.json && process.stdin.isTTY && process.stdout.isTTY;
10
+ export const useStyledPrompts = (flags) => isHumanTerminal(flags) && useColor(flags);
11
+ export const formatDuration = (durationMs) => durationMs < 1_000
12
+ ? `${Math.round(durationMs)}ms`
13
+ : `${(durationMs / 1_000).toFixed(1)}s`;
14
+ export const promptForChoice = async ({ message, options, flags = {}, optionsAlreadyShown = false, }) => {
15
+ if (useStyledPrompts(flags)) {
16
+ const selected = await prompts.select({
17
+ message,
18
+ maxItems: options.length,
19
+ options: options.map((option, index) => ({
20
+ value: index,
21
+ label: option.label,
22
+ ...(option.hint === undefined ? {} : { hint: option.hint }),
23
+ })),
24
+ });
25
+ return prompts.isCancel(selected)
26
+ ? options.find((option) => option.command === "exit")
27
+ : options[selected];
28
+ }
29
+ if (!optionsAlreadyShown) {
30
+ console.log([
31
+ "",
32
+ ...options.map((option, index) => ` ${index === 0 ? "›" : " "} ${index + 1}. ${option.label}${option.hint ? ` — ${option.hint}` : ""}`),
33
+ "",
34
+ ].join("\n"));
35
+ }
36
+ const prompt = createInterface({
37
+ input: process.stdin,
38
+ output: process.stdout,
39
+ });
40
+ try {
41
+ while (true) {
42
+ const answer = (await prompt.question(`${message} [1]: `)).trim();
43
+ const selected = answer === "" ? 1 : Number(answer);
44
+ if (Number.isInteger(selected) &&
45
+ selected >= 1 &&
46
+ selected <= options.length) {
47
+ return options[selected - 1];
48
+ }
49
+ console.log(`Choose a number from 1 to ${options.length}.`);
50
+ }
51
+ }
52
+ finally {
53
+ prompt.close();
54
+ }
55
+ };
56
+ export async function promptForConfirmation(message, flags = {}, { cancelValue = false } = {}) {
57
+ if (useStyledPrompts(flags)) {
58
+ const accepted = await prompts.confirm({ message, initialValue: false });
59
+ return prompts.isCancel(accepted) ? cancelValue : accepted;
60
+ }
61
+ const prompt = createInterface({
62
+ input: process.stdin,
63
+ output: process.stdout,
64
+ });
65
+ try {
66
+ const answer = (await prompt.question(`${message} (y/N) `))
67
+ .trim()
68
+ .toLowerCase();
69
+ return answer === "y" || answer === "yes";
70
+ }
71
+ finally {
72
+ prompt.close();
73
+ }
74
+ }
75
+ export const promptForPath = async ({ message, defaultValue, optional = false, flags = {}, validate, }) => {
76
+ if (useStyledPrompts(flags)) {
77
+ const answer = await prompts.text({
78
+ message,
79
+ placeholder: defaultValue ?? (optional ? "Leave blank for none" : ""),
80
+ ...(defaultValue === undefined ? {} : { defaultValue }),
81
+ validate: (value) => {
82
+ const resolved = String(value || defaultValue || "").trim();
83
+ if (!optional && !resolved)
84
+ return "Enter a value.";
85
+ return resolved && validate ? validate(resolved) : undefined;
86
+ },
87
+ });
88
+ if (prompts.isCancel(answer))
89
+ return undefined;
90
+ return String(answer || defaultValue || "").trim() || undefined;
91
+ }
92
+ const prompt = createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout,
95
+ });
96
+ try {
97
+ while (true) {
98
+ const suffix = defaultValue
99
+ ? ` [${defaultValue}]`
100
+ : optional
101
+ ? ""
102
+ : " (required)";
103
+ const answer = (await prompt.question(`${message}${suffix}: `)).trim();
104
+ const value = answer || defaultValue || "";
105
+ const invalid = value && validate ? validate(value) : undefined;
106
+ if (invalid) {
107
+ console.log(invalid);
108
+ continue;
109
+ }
110
+ if (value || optional)
111
+ return value || undefined;
112
+ console.log("Enter a project-relative path.");
113
+ }
114
+ }
115
+ finally {
116
+ prompt.close();
117
+ }
118
+ };
119
+ export const promptForSelection = async ({ message, options, flags = {}, }) => {
120
+ const selected = await promptForChoice({ message, options, flags });
121
+ return selected?.value;
122
+ };
123
+ export const promptForDiscoveredPath = async ({ message, candidates, optional = false, flags = {}, }) => {
124
+ if (candidates.length === 0)
125
+ return promptForPath({ message, optional, flags });
126
+ const noneValue = "\0none";
127
+ const manualValue = "\0manual";
128
+ const selected = await promptForSelection({
129
+ message,
130
+ flags,
131
+ options: [
132
+ ...(optional
133
+ ? [
134
+ {
135
+ label: "No storage assets",
136
+ hint: "Continue without an asset manifest",
137
+ value: noneValue,
138
+ },
139
+ ]
140
+ : []),
141
+ ...candidates.map((path) => ({ label: path, value: path })),
142
+ { label: "Enter another path", value: manualValue },
143
+ ],
144
+ });
145
+ if (!selected || selected === noneValue)
146
+ return undefined;
147
+ if (selected !== manualValue)
148
+ return selected;
149
+ return promptForPath({ message, optional, flags });
150
+ };
151
+ export const promptForMultipleChoice = async ({ message, options, initialValues = [], flags = {}, }) => {
152
+ if (useStyledPrompts(flags)) {
153
+ const selected = await prompts.multiselect({
154
+ message,
155
+ options: options.map((option) => ({
156
+ value: option.value,
157
+ label: option.label,
158
+ ...(option.hint === undefined ? {} : { hint: option.hint }),
159
+ })),
160
+ initialValues: [...initialValues],
161
+ required: false,
162
+ });
163
+ return prompts.isCancel(selected) ? undefined : selected;
164
+ }
165
+ const initial = new Set(initialValues);
166
+ console.log("");
167
+ console.log(message);
168
+ for (const [index, option] of options.entries()) {
169
+ console.log(` ${index + 1}. ${option.label}${initial.has(option.value) ? " (suggested)" : ""}`);
170
+ }
171
+ const defaultNumbers = options
172
+ .map((option, index) => (initial.has(option.value) ? index + 1 : undefined))
173
+ .filter(Boolean)
174
+ .join(",");
175
+ const prompt = createInterface({
176
+ input: process.stdin,
177
+ output: process.stdout,
178
+ });
179
+ try {
180
+ while (true) {
181
+ const suffix = defaultNumbers ? ` [${defaultNumbers}]` : " [none]";
182
+ const answer = (await prompt.question(`Choose comma-separated numbers, all, or none${suffix}: `))
183
+ .trim()
184
+ .toLowerCase();
185
+ if (!answer)
186
+ return [...initialValues];
187
+ if (answer === "none")
188
+ return [];
189
+ if (answer === "all")
190
+ return options.map((option) => option.value);
191
+ const indexes = answer.split(",").map((value) => Number(value.trim()));
192
+ if (indexes.length > 0 &&
193
+ indexes.every((index) => Number.isInteger(index) && index >= 1 && index <= options.length)) {
194
+ return [...new Set(indexes)].map((index) => options[index - 1].value);
195
+ }
196
+ console.log(`Choose numbers from 1 to ${options.length}, all, or none.`);
197
+ }
198
+ }
199
+ finally {
200
+ prompt.close();
201
+ }
202
+ };
203
+ export const installGuidedExitShortcut = (flags, { beforeExit } = {}) => {
204
+ let exiting = false;
205
+ const exit = () => {
206
+ if (exiting)
207
+ return;
208
+ exiting = true;
209
+ if (process.stdin.isTTY && process.stdin.isRaw)
210
+ process.stdin.setRawMode(false);
211
+ beforeExit?.();
212
+ process.stdout.write("\u001B[?25h");
213
+ if (useStyledPrompts(flags))
214
+ prompts.outro("Rehearsal exited.");
215
+ else
216
+ console.log("\nRehearsal exited.");
217
+ process.exit(0);
218
+ };
219
+ const onInput = (chunk) => {
220
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
221
+ if (bytes.includes(0x1a))
222
+ exit();
223
+ };
224
+ process.stdin.prependListener("data", onInput);
225
+ process.prependListener("SIGTSTP", exit);
226
+ return () => {
227
+ process.stdin.removeListener("data", onInput);
228
+ process.removeListener("SIGTSTP", exit);
229
+ };
230
+ };