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