@rehearsal-db/core 0.1.0-beta.12 → 0.1.0-beta.13
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 +40 -0
- package/COMPATIBILITY.md +6 -0
- package/README.md +2 -2
- package/docs/commands.md +15 -11
- package/docs/getting-started.md +4 -3
- package/docs/production-source.md +138 -16
- package/docs/sanitization.md +73 -7
- package/docs/security-model.md +8 -0
- package/docs/standalone-workflow.md +24 -9
- package/docs/troubleshooting.md +12 -5
- package/docs/tutorial.md +2 -1
- package/package.json +2 -2
- package/src/baseline/artifact.mjs +27 -4
- package/src/baseline/privacy_engine.mjs +220 -9
- package/src/cli/arguments.mjs +3 -0
- package/src/cli/guided.mjs +8 -6
- package/src/cli/rehearsal.mjs +20 -2
- package/src/cli/renderers.mjs +17 -4
- package/src/cli/runtime_commands.mjs +92 -9
- package/src/cli/source_commands.mjs +26 -5
- package/src/project/setup.mjs +5 -23
- package/src/runtime/plan.mjs +7 -3
- package/src/runtime/restore.mjs +13 -5
- package/src/shared/diagnostics.mjs +7 -0
- package/src/shared/node_runtime.mjs +31 -0
- package/src/source/access.mjs +224 -49
- package/src/source/asset_transfer.mjs +35 -5
- package/src/source/baseline.mjs +42 -6
- package/src/source/external_reader.mjs +255 -0
- package/src/source/postgresql_access.mjs +143 -42
- package/src/source/privacy_paths.mjs +38 -0
|
@@ -372,12 +372,35 @@ export const createAndActivateBaseline = async ({
|
|
|
372
372
|
}
|
|
373
373
|
return manifest;
|
|
374
374
|
} catch (error) {
|
|
375
|
+
let cleanupFailure;
|
|
375
376
|
if (dataHandle) await dataHandle.close().catch(() => undefined);
|
|
376
|
-
await
|
|
377
|
-
await rm(buildDirectory, { recursive: true, force: true })
|
|
377
|
+
await makeArtifactTreeWritable(buildDirectory).catch(() => undefined);
|
|
378
|
+
await rm(buildDirectory, { recursive: true, force: true }).catch(
|
|
379
|
+
(cleanupError) => {
|
|
380
|
+
cleanupFailure = cleanupError;
|
|
381
|
+
},
|
|
382
|
+
);
|
|
378
383
|
if (finalCreated) {
|
|
379
|
-
await
|
|
380
|
-
await rm(finalDirectory, { recursive: true, force: true })
|
|
384
|
+
await makeArtifactTreeWritable(finalDirectory).catch(() => undefined);
|
|
385
|
+
await rm(finalDirectory, { recursive: true, force: true }).catch(
|
|
386
|
+
(cleanupError) => {
|
|
387
|
+
cleanupFailure ??= cleanupError;
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (
|
|
392
|
+
cleanupFailure &&
|
|
393
|
+
error &&
|
|
394
|
+
(typeof error === "object" || typeof error === "function")
|
|
395
|
+
) {
|
|
396
|
+
try {
|
|
397
|
+
Object.defineProperty(error, "cleanupError", {
|
|
398
|
+
value: cleanupFailure,
|
|
399
|
+
enumerable: false,
|
|
400
|
+
});
|
|
401
|
+
} catch {
|
|
402
|
+
// Preserve the preparation failure even when its Error is immutable.
|
|
403
|
+
}
|
|
381
404
|
}
|
|
382
405
|
throw error;
|
|
383
406
|
}
|
|
@@ -22,6 +22,7 @@ const ACTIONS = new Set([
|
|
|
22
22
|
const PSEUDONYM_FORMATS = new Set(["uuid", "email", "text", "integer"]);
|
|
23
23
|
const DERIVATION_KINDS = new Set([
|
|
24
24
|
"date-shift",
|
|
25
|
+
"path-map",
|
|
25
26
|
"json-object",
|
|
26
27
|
"json-array",
|
|
27
28
|
"approved-owner",
|
|
@@ -29,6 +30,8 @@ const DERIVATION_KINDS = new Set([
|
|
|
29
30
|
const KEY_BYTES = 32;
|
|
30
31
|
const DEFAULT_JSON_MAXIMUM_BYTES = 65_536;
|
|
31
32
|
const DEFAULT_JSON_MAXIMUM_DEPTH = 8;
|
|
33
|
+
const DEFAULT_PATH_MAXIMUM_BYTES = 2_048;
|
|
34
|
+
const DEFAULT_PATH_MAXIMUM_SEGMENTS = 32;
|
|
32
35
|
const MAXIMUM_POLICY_DEPTH = 16;
|
|
33
36
|
|
|
34
37
|
const isObject = (value) =>
|
|
@@ -142,6 +145,8 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
142
145
|
[
|
|
143
146
|
"kind",
|
|
144
147
|
"days",
|
|
148
|
+
"group",
|
|
149
|
+
"mapping",
|
|
145
150
|
"fields",
|
|
146
151
|
"items",
|
|
147
152
|
"approved",
|
|
@@ -158,12 +163,32 @@ const validateRecipe = (action, recipe, label, depth = 0) => {
|
|
|
158
163
|
}
|
|
159
164
|
if (recipe.kind === "date-shift") {
|
|
160
165
|
const days = positiveInteger(recipe.days, `${label}.recipe.days`, 3_650);
|
|
161
|
-
if (
|
|
166
|
+
if (
|
|
167
|
+
Object.keys(recipe).some(
|
|
168
|
+
(key) => !["kind", "days", "group"].includes(key),
|
|
169
|
+
)
|
|
170
|
+
) {
|
|
162
171
|
throw new Error(
|
|
163
172
|
`${label}.recipe contains fields not used by date-shift.`,
|
|
164
173
|
);
|
|
165
174
|
}
|
|
166
|
-
|
|
175
|
+
if (recipe.group !== undefined && !BINDING_NAME.test(recipe.group)) {
|
|
176
|
+
throw new Error(`${label}.recipe.group is invalid.`);
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
kind: "date-shift",
|
|
180
|
+
days,
|
|
181
|
+
...(recipe.group === undefined ? {} : { group: recipe.group }),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (recipe.kind === "path-map") {
|
|
185
|
+
if (Object.keys(recipe).some((key) => !["kind", "mapping"].includes(key))) {
|
|
186
|
+
throw new Error(`${label}.recipe contains fields not used by path-map.`);
|
|
187
|
+
}
|
|
188
|
+
if (!BINDING_NAME.test(recipe.mapping ?? "")) {
|
|
189
|
+
throw new Error(`${label}.recipe.mapping is invalid.`);
|
|
190
|
+
}
|
|
191
|
+
return Object.freeze({ kind: "path-map", mapping: recipe.mapping });
|
|
167
192
|
}
|
|
168
193
|
if (recipe.kind === "approved-owner") {
|
|
169
194
|
if (
|
|
@@ -280,7 +305,14 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
280
305
|
if (!isObject(policy)) throw new Error("Privacy policy must be an object.");
|
|
281
306
|
knownKeys(
|
|
282
307
|
policy,
|
|
283
|
-
[
|
|
308
|
+
[
|
|
309
|
+
"policyVersion",
|
|
310
|
+
"draft",
|
|
311
|
+
"migrationCutoff",
|
|
312
|
+
"bindings",
|
|
313
|
+
"pathMappings",
|
|
314
|
+
"tables",
|
|
315
|
+
],
|
|
284
316
|
"policy",
|
|
285
317
|
);
|
|
286
318
|
if (policy.policyVersion !== 2) {
|
|
@@ -323,6 +355,60 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
323
355
|
return [name, Object.freeze({ ...binding })];
|
|
324
356
|
}),
|
|
325
357
|
);
|
|
358
|
+
if (policy.pathMappings !== undefined && !isObject(policy.pathMappings)) {
|
|
359
|
+
throw new Error("policy.pathMappings must be an object.");
|
|
360
|
+
}
|
|
361
|
+
const pathMappings = Object.fromEntries(
|
|
362
|
+
Object.entries(policy.pathMappings ?? {}).map(([name, mapping]) => {
|
|
363
|
+
const label = `policy.pathMappings.${name}`;
|
|
364
|
+
if (!BINDING_NAME.test(name) || !isObject(mapping)) {
|
|
365
|
+
throw new Error("policy.pathMappings contains an invalid mapping.");
|
|
366
|
+
}
|
|
367
|
+
knownKeys(
|
|
368
|
+
mapping,
|
|
369
|
+
[
|
|
370
|
+
"binding",
|
|
371
|
+
"format",
|
|
372
|
+
"namespace",
|
|
373
|
+
"maxLength",
|
|
374
|
+
"maximumBytes",
|
|
375
|
+
"maximumSegments",
|
|
376
|
+
],
|
|
377
|
+
label,
|
|
378
|
+
);
|
|
379
|
+
if (!Object.hasOwn(bindings, mapping.binding ?? "")) {
|
|
380
|
+
throw new Error(`${label}.binding references an unknown binding.`);
|
|
381
|
+
}
|
|
382
|
+
if (!["uuid", "text"].includes(mapping.format)) {
|
|
383
|
+
throw new Error(`${label}.format must be uuid or text.`);
|
|
384
|
+
}
|
|
385
|
+
const normalized = {
|
|
386
|
+
binding: mapping.binding,
|
|
387
|
+
format: mapping.format,
|
|
388
|
+
namespace: nonEmpty(mapping.namespace, `${label}.namespace`),
|
|
389
|
+
maximumBytes: positiveInteger(
|
|
390
|
+
mapping.maximumBytes ?? DEFAULT_PATH_MAXIMUM_BYTES,
|
|
391
|
+
`${label}.maximumBytes`,
|
|
392
|
+
16_384,
|
|
393
|
+
),
|
|
394
|
+
maximumSegments: positiveInteger(
|
|
395
|
+
mapping.maximumSegments ?? DEFAULT_PATH_MAXIMUM_SEGMENTS,
|
|
396
|
+
`${label}.maximumSegments`,
|
|
397
|
+
256,
|
|
398
|
+
),
|
|
399
|
+
};
|
|
400
|
+
if (mapping.format === "text") {
|
|
401
|
+
normalized.maxLength = positiveInteger(
|
|
402
|
+
mapping.maxLength,
|
|
403
|
+
`${label}.maxLength`,
|
|
404
|
+
1_024,
|
|
405
|
+
);
|
|
406
|
+
} else if (mapping.maxLength !== undefined) {
|
|
407
|
+
throw new Error(`${label}.maxLength is supported only for text.`);
|
|
408
|
+
}
|
|
409
|
+
return [name, Object.freeze(normalized)];
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
326
412
|
const names = new Set();
|
|
327
413
|
const tables = policy.tables.map((table, tableIndex) => {
|
|
328
414
|
const label = `policy.tables[${tableIndex}]`;
|
|
@@ -423,12 +509,9 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
423
509
|
throw new Error(`${label}.ownerBinding references an unknown column.`);
|
|
424
510
|
}
|
|
425
511
|
const ownerColumn = columns.find((entry) => entry.name === column);
|
|
426
|
-
if (
|
|
427
|
-
ownerColumn.action !== "PSEUDONYMIZE" ||
|
|
428
|
-
ownerColumn.identity !== "YES"
|
|
429
|
-
) {
|
|
512
|
+
if (ownerColumn.action !== "PSEUDONYMIZE") {
|
|
430
513
|
throw new Error(
|
|
431
|
-
`${label}.ownerBinding column must be
|
|
514
|
+
`${label}.ownerBinding column must be explicitly pseudonymized.`,
|
|
432
515
|
);
|
|
433
516
|
}
|
|
434
517
|
ownerBinding = Object.freeze({ binding, column });
|
|
@@ -442,6 +525,31 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
442
525
|
if (columns.some(usesApprovedOwner) && !ownerBinding) {
|
|
443
526
|
throw new Error(`${label} uses approved-owner without ownerBinding.`);
|
|
444
527
|
}
|
|
528
|
+
const visitRecipes = (declaration, visit) => {
|
|
529
|
+
visit(declaration.recipe);
|
|
530
|
+
if (declaration.recipe?.kind === "approved-owner") {
|
|
531
|
+
visitRecipes(declaration.recipe.approved, visit);
|
|
532
|
+
visitRecipes(declaration.recipe.otherwise, visit);
|
|
533
|
+
} else if (declaration.recipe?.kind === "json-object") {
|
|
534
|
+
Object.values(declaration.recipe.fields).forEach((nested) =>
|
|
535
|
+
visitRecipes(nested, visit),
|
|
536
|
+
);
|
|
537
|
+
} else if (declaration.recipe?.kind === "json-array") {
|
|
538
|
+
visitRecipes(declaration.recipe.items, visit);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
for (const column of columns) {
|
|
542
|
+
visitRecipes(column, (recipe) => {
|
|
543
|
+
if (
|
|
544
|
+
recipe?.kind === "path-map" &&
|
|
545
|
+
!Object.hasOwn(pathMappings, recipe.mapping)
|
|
546
|
+
) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`${label} references unknown path mapping ${recipe.mapping}.`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
445
553
|
return Object.freeze({
|
|
446
554
|
schema,
|
|
447
555
|
name,
|
|
@@ -450,10 +558,33 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
450
558
|
columns: Object.freeze(columns),
|
|
451
559
|
});
|
|
452
560
|
});
|
|
561
|
+
const dateShiftGroups = new Map();
|
|
562
|
+
const visitDateShiftGroups = (declaration) => {
|
|
563
|
+
const recipe = declaration.recipe;
|
|
564
|
+
if (recipe?.kind === "date-shift" && recipe.group) {
|
|
565
|
+
const existing = dateShiftGroups.get(recipe.group);
|
|
566
|
+
if (existing !== undefined && existing !== recipe.days) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`Privacy date-shift group ${recipe.group} must use one days value.`,
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
dateShiftGroups.set(recipe.group, recipe.days);
|
|
572
|
+
}
|
|
573
|
+
if (recipe?.kind === "approved-owner") {
|
|
574
|
+
visitDateShiftGroups(recipe.approved);
|
|
575
|
+
visitDateShiftGroups(recipe.otherwise);
|
|
576
|
+
} else if (recipe?.kind === "json-object") {
|
|
577
|
+
Object.values(recipe.fields).forEach(visitDateShiftGroups);
|
|
578
|
+
} else if (recipe?.kind === "json-array") {
|
|
579
|
+
visitDateShiftGroups(recipe.items);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
tables.forEach((table) => table.columns.forEach(visitDateShiftGroups));
|
|
453
583
|
return Object.freeze({
|
|
454
584
|
policyVersion: 2,
|
|
455
585
|
migrationCutoff: policy.migrationCutoff,
|
|
456
586
|
bindings: Object.freeze(bindings),
|
|
587
|
+
pathMappings: Object.freeze(pathMappings),
|
|
457
588
|
tables: Object.freeze(tables),
|
|
458
589
|
});
|
|
459
590
|
};
|
|
@@ -485,6 +616,47 @@ const pseudonym = (key, recipe, value) => {
|
|
|
485
616
|
return token.slice(0, recipe.maxLength);
|
|
486
617
|
};
|
|
487
618
|
|
|
619
|
+
const remapPath = ({ key, mapping, bindingValue, value, label }) => {
|
|
620
|
+
if (typeof value !== "string") {
|
|
621
|
+
throw new Error(`${label} must be a Storage-style path string.`);
|
|
622
|
+
}
|
|
623
|
+
if (Buffer.byteLength(value) > mapping.maximumBytes) {
|
|
624
|
+
throw new Error(`${label} exceeds its reviewed byte limit.`);
|
|
625
|
+
}
|
|
626
|
+
const segments = value.split("/");
|
|
627
|
+
if (
|
|
628
|
+
!value ||
|
|
629
|
+
value.startsWith("/") ||
|
|
630
|
+
value.endsWith("/") ||
|
|
631
|
+
segments.length > mapping.maximumSegments ||
|
|
632
|
+
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
633
|
+
) {
|
|
634
|
+
throw new Error(`${label} is not a safe bounded Storage path.`);
|
|
635
|
+
}
|
|
636
|
+
if (
|
|
637
|
+
typeof bindingValue !== "string" ||
|
|
638
|
+
!bindingValue ||
|
|
639
|
+
bindingValue.includes("/") ||
|
|
640
|
+
bindingValue === "." ||
|
|
641
|
+
bindingValue === ".."
|
|
642
|
+
) {
|
|
643
|
+
throw new Error(`${label} has an invalid reviewed identity binding.`);
|
|
644
|
+
}
|
|
645
|
+
if (segments[0] !== bindingValue) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`${label} does not begin with its exact reviewed identity.`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const destination = [
|
|
651
|
+
String(pseudonym(key, mapping, bindingValue)),
|
|
652
|
+
...segments.slice(1),
|
|
653
|
+
].join("/");
|
|
654
|
+
if (Buffer.byteLength(destination) > mapping.maximumBytes) {
|
|
655
|
+
throw new Error(`${label} destination exceeds its reviewed byte limit.`);
|
|
656
|
+
}
|
|
657
|
+
return destination;
|
|
658
|
+
};
|
|
659
|
+
|
|
488
660
|
const assertJsonBounds = (value, recipe, path) => {
|
|
489
661
|
let bytes = 0;
|
|
490
662
|
const jsonStringBytes = (string) => {
|
|
@@ -545,6 +717,8 @@ const executeDeclaration = ({
|
|
|
545
717
|
key,
|
|
546
718
|
path,
|
|
547
719
|
ownerApproved,
|
|
720
|
+
pathMappings,
|
|
721
|
+
bindingValues,
|
|
548
722
|
}) => {
|
|
549
723
|
if (declaration.action === "KEEP") return value;
|
|
550
724
|
if (declaration.action === "EXCLUDE") return undefined;
|
|
@@ -558,10 +732,26 @@ const executeDeclaration = ({
|
|
|
558
732
|
const date = new Date(value);
|
|
559
733
|
if (!Number.isFinite(date.valueOf()))
|
|
560
734
|
throw new Error(`${path} is not a valid date.`);
|
|
561
|
-
const direction =
|
|
735
|
+
const direction = declaration.recipe.group
|
|
736
|
+
? digest(key, "date-shift-group", declaration.recipe.group)[0] % 2 === 0
|
|
737
|
+
? -1
|
|
738
|
+
: 1
|
|
739
|
+
: digest(key, path, value)[0] % 2 === 0
|
|
740
|
+
? -1
|
|
741
|
+
: 1;
|
|
562
742
|
date.setUTCDate(date.getUTCDate() + direction * declaration.recipe.days);
|
|
563
743
|
return date.toISOString();
|
|
564
744
|
}
|
|
745
|
+
if (declaration.recipe.kind === "path-map") {
|
|
746
|
+
const mapping = pathMappings[declaration.recipe.mapping];
|
|
747
|
+
return remapPath({
|
|
748
|
+
key,
|
|
749
|
+
mapping,
|
|
750
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
751
|
+
value,
|
|
752
|
+
label: path,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
565
755
|
if (declaration.recipe.kind === "approved-owner") {
|
|
566
756
|
if (ownerApproved === undefined) {
|
|
567
757
|
throw new Error(`${path} has no reviewed owner condition.`);
|
|
@@ -574,6 +764,8 @@ const executeDeclaration = ({
|
|
|
574
764
|
key,
|
|
575
765
|
path,
|
|
576
766
|
ownerApproved,
|
|
767
|
+
pathMappings,
|
|
768
|
+
bindingValues,
|
|
577
769
|
});
|
|
578
770
|
}
|
|
579
771
|
if (value === null && declaration.recipe.allowNull) return null;
|
|
@@ -587,6 +779,8 @@ const executeDeclaration = ({
|
|
|
587
779
|
key,
|
|
588
780
|
path: `${path}[${index}]`,
|
|
589
781
|
ownerApproved,
|
|
782
|
+
pathMappings,
|
|
783
|
+
bindingValues,
|
|
590
784
|
});
|
|
591
785
|
return transformed === undefined ? [] : [transformed];
|
|
592
786
|
});
|
|
@@ -611,6 +805,8 @@ const executeDeclaration = ({
|
|
|
611
805
|
key,
|
|
612
806
|
path: `${path}.${field}`,
|
|
613
807
|
ownerApproved,
|
|
808
|
+
pathMappings,
|
|
809
|
+
bindingValues,
|
|
614
810
|
});
|
|
615
811
|
return transformed === undefined ? [] : [[field, transformed]];
|
|
616
812
|
}),
|
|
@@ -652,6 +848,19 @@ export const createPrivacyEngine = ({
|
|
|
652
848
|
);
|
|
653
849
|
return Object.freeze({
|
|
654
850
|
keyFingerprint: createHash("sha256").update(secret).digest("hex"),
|
|
851
|
+
remapPath({ mapping: mappingName, value }) {
|
|
852
|
+
const mapping = normalized.pathMappings[mappingName];
|
|
853
|
+
if (!mapping) {
|
|
854
|
+
throw new Error(`Privacy path mapping ${mappingName} is not declared.`);
|
|
855
|
+
}
|
|
856
|
+
return remapPath({
|
|
857
|
+
key: secret,
|
|
858
|
+
mapping,
|
|
859
|
+
bindingValue: bindingValues.get(mapping.binding),
|
|
860
|
+
value,
|
|
861
|
+
label: `Privacy path mapping ${mappingName}`,
|
|
862
|
+
});
|
|
863
|
+
},
|
|
655
864
|
sanitize(record) {
|
|
656
865
|
if (
|
|
657
866
|
!isObject(record) ||
|
|
@@ -692,6 +901,8 @@ export const createPrivacyEngine = ({
|
|
|
692
901
|
key: secret,
|
|
693
902
|
path: `${relation}.${column.name}`,
|
|
694
903
|
ownerApproved,
|
|
904
|
+
pathMappings: normalized.pathMappings,
|
|
905
|
+
bindingValues,
|
|
695
906
|
});
|
|
696
907
|
return transformed === undefined ? [] : [[column.name, transformed]];
|
|
697
908
|
}),
|
package/src/cli/arguments.mjs
CHANGED
|
@@ -4,6 +4,7 @@ export const parseArguments = (arguments_) => {
|
|
|
4
4
|
const flags = {
|
|
5
5
|
json: false,
|
|
6
6
|
help: false,
|
|
7
|
+
version: false,
|
|
7
8
|
verbosity: "normal",
|
|
8
9
|
dryRun: false,
|
|
9
10
|
write: false,
|
|
@@ -30,6 +31,8 @@ export const parseArguments = (arguments_) => {
|
|
|
30
31
|
for (const argument of arguments_) {
|
|
31
32
|
if (argument === "--json") flags.json = true;
|
|
32
33
|
else if (argument === "--help" || argument === "-h") flags.help = true;
|
|
34
|
+
else if (argument === "--version" || argument === "-V")
|
|
35
|
+
flags.version = true;
|
|
33
36
|
else if (argument === "--verbose") flags.verbosity = "verbose";
|
|
34
37
|
else if (argument === "--debug") flags.verbosity = "debug";
|
|
35
38
|
else if (argument === "--dry-run") flags.dryRun = true;
|
package/src/cli/guided.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "../baseline/policy_review.mjs";
|
|
32
32
|
import { formatCount } from "../shared/human_output.mjs";
|
|
33
33
|
import { redactDiagnosticValue } from "../shared/diagnostics.mjs";
|
|
34
|
+
import { REHEARSAL_NODE_LABEL } from "../shared/node_runtime.mjs";
|
|
34
35
|
import {
|
|
35
36
|
renderBaselineInputInspection,
|
|
36
37
|
renderBaselinePreparation,
|
|
@@ -336,7 +337,7 @@ const guidedOptions = (state, hasLastDetails) => {
|
|
|
336
337
|
const options = [];
|
|
337
338
|
if (!state.node.supported) {
|
|
338
339
|
options.push({
|
|
339
|
-
label: "Show Node.js
|
|
340
|
+
label: "Show supported Node.js versions",
|
|
340
341
|
hint: "Required before Rehearsal can change this project",
|
|
341
342
|
command: "node-help",
|
|
342
343
|
});
|
|
@@ -427,7 +428,7 @@ const showGuidedStatus = ({ state, options, flags }) => {
|
|
|
427
428
|
const marker = (complete) =>
|
|
428
429
|
complete ? terminalStyle(flags, "32", "✓") : terminalStyle(flags, "2", "○");
|
|
429
430
|
const statusLines = [
|
|
430
|
-
`${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" :
|
|
431
|
+
`${marker(state.node.supported)} Node.js ${state.node.version}${state.node.supported ? "" : ` (${REHEARSAL_NODE_LABEL} required)`}`,
|
|
431
432
|
`${marker(state.target === "supabase" ? state.detected.hasSupabaseConfig : state.detected.hasPostgresqlMigrations)} ${state.target === "supabase" ? "Supabase" : "PostgreSQL"} project detected`,
|
|
432
433
|
`${marker(state.target === "supabase" ? state.detected.hasMigrations : state.detected.hasPostgresqlMigrations)} Migration history detected`,
|
|
433
434
|
`${marker(state.config === "valid")} Rehearsal configuration${state.config === "invalid" ? " needs attention" : ""}`,
|
|
@@ -685,12 +686,13 @@ export const createGuidedHome =
|
|
|
685
686
|
console.log(
|
|
686
687
|
[
|
|
687
688
|
"",
|
|
688
|
-
terminalStyle(flags, "1", "NODE.JS
|
|
689
|
+
terminalStyle(flags, "1", "SUPPORTED NODE.JS REQUIRED"),
|
|
689
690
|
`This shell is using Node.js ${state.node.version}. Rehearsal has not changed your project.`,
|
|
691
|
+
`Supported releases: ${REHEARSAL_NODE_LABEL}.`,
|
|
690
692
|
"",
|
|
691
|
-
"
|
|
692
|
-
" nvm install
|
|
693
|
-
" nvm use
|
|
693
|
+
"Recommended with nvm:",
|
|
694
|
+
" nvm install --lts",
|
|
695
|
+
" nvm use --lts",
|
|
694
696
|
"",
|
|
695
697
|
"Then reinstall Rehearsal in your scratch project and run npx rehearsal again.",
|
|
696
698
|
].join("\n"),
|
package/src/cli/rehearsal.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import * as prompts from "@clack/prompts";
|
|
|
15
15
|
import {
|
|
16
16
|
createRehearsalResult,
|
|
17
17
|
normalizeRehearsalError,
|
|
18
|
+
REHEARSAL_VERSION,
|
|
18
19
|
renderHumanError,
|
|
19
20
|
serializeRehearsalError,
|
|
20
21
|
} from "../shared/diagnostics.mjs";
|
|
@@ -311,6 +312,7 @@ const loadIdentityClaimPlan = async ({ flags, planOptions }) => {
|
|
|
311
312
|
const usage = () => `Usage: rehearsal <command> [options]
|
|
312
313
|
|
|
313
314
|
Commands:
|
|
315
|
+
version Print the installed Rehearsal version
|
|
314
316
|
guide Open the interactive, state-aware home screen
|
|
315
317
|
setup [--target=] [--write] Preview or create safe first-run scaffolding
|
|
316
318
|
init [--write] Preview or explicitly write safe starter config
|
|
@@ -343,7 +345,8 @@ Commands:
|
|
|
343
345
|
Preview or apply conservative disk cleanup
|
|
344
346
|
verify Verify the current local Rehearsal runtime
|
|
345
347
|
|
|
346
|
-
Options: --json --verbose --debug --plain
|
|
348
|
+
Options: --help, -h --version, -V --json --verbose --debug --plain
|
|
349
|
+
--config=<path> --target=supabase|postgresql
|
|
347
350
|
--confirm-source-access=<digest> --confirm-source-retirement=<digest>
|
|
348
351
|
--confirm-refresh=<digest>`;
|
|
349
352
|
|
|
@@ -362,6 +365,18 @@ const runGuidedHome = createGuidedHome({
|
|
|
362
365
|
});
|
|
363
366
|
|
|
364
367
|
const executeCommand = async ({ command, flags, planOptions, guided }) => {
|
|
368
|
+
if (command === "version" || flags.version) {
|
|
369
|
+
if (flags.json) {
|
|
370
|
+
console.log(
|
|
371
|
+
JSON.stringify(
|
|
372
|
+
{ name: "@rehearsal-db/core", version: REHEARSAL_VERSION },
|
|
373
|
+
null,
|
|
374
|
+
2,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
} else console.log(REHEARSAL_VERSION);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
365
380
|
if (command === "help" || flags.help) {
|
|
366
381
|
console.log(usage());
|
|
367
382
|
return;
|
|
@@ -454,7 +469,10 @@ const executeCommand = async ({ command, flags, planOptions, guided }) => {
|
|
|
454
469
|
? renderSourceAccessPlan(result.plan)
|
|
455
470
|
: [
|
|
456
471
|
"SOURCE ACCESS — READY",
|
|
457
|
-
|
|
472
|
+
`${result.receipt.accessMode === "external" ? "External" : "Temporary"} reader expires: ${result.receipt.expiresAt}`,
|
|
473
|
+
result.receipt.accessMode === "external"
|
|
474
|
+
? "Rehearsal made no source-side changes."
|
|
475
|
+
: "Rehearsal created only the reviewed temporary source access.",
|
|
458
476
|
`Local credential: ${result.credentialFile}`,
|
|
459
477
|
"",
|
|
460
478
|
"Next: run rehearsal baseline refresh, then retire source access.",
|
package/src/cli/renderers.mjs
CHANGED
|
@@ -507,7 +507,10 @@ export const renderSourceAccessPlan = (plan) =>
|
|
|
507
507
|
"SOURCE ACCESS — PREVIEW",
|
|
508
508
|
"",
|
|
509
509
|
`Target fingerprint: ${plan.review.targetFingerprint}`,
|
|
510
|
-
|
|
510
|
+
plan.review.reader.mode === "managed"
|
|
511
|
+
? `Managed temporary reader: ${plan.review.reader.role} (${plan.review.reader.validForMinutes} minutes)`
|
|
512
|
+
: `Externally managed reader: ${plan.review.reader.role} (maximum ${plan.review.reader.maximumValidForMinutes} minutes remaining)`,
|
|
513
|
+
`Source changes by Rehearsal: ${plan.review.reader.sourceChanges ? "export views and temporary roles" : "none"}`,
|
|
511
514
|
`Export schema: ${plan.review.exportSchema}`,
|
|
512
515
|
...plan.review.relations.map(
|
|
513
516
|
(relation) =>
|
|
@@ -516,7 +519,10 @@ export const renderSourceAccessPlan = (plan) =>
|
|
|
516
519
|
...(plan.review.assets.length
|
|
517
520
|
? plan.review.assets.map(
|
|
518
521
|
(asset) =>
|
|
519
|
-
` → Storage ${asset.bucket}/${
|
|
522
|
+
` → Storage ${asset.bucket}/${
|
|
523
|
+
asset.prefix ??
|
|
524
|
+
`[private prefix from ${asset.prefixEnvironmentVariable}]`
|
|
525
|
+
} (${asset.rights})`,
|
|
520
526
|
)
|
|
521
527
|
: [" ○ No source assets declared"]),
|
|
522
528
|
"",
|
|
@@ -574,7 +580,12 @@ export const renderSourceRetirement = ({ mode, plan }) =>
|
|
|
574
580
|
`Target fingerprint: ${plan.review.targetFingerprint}`,
|
|
575
581
|
`Reader: ${plan.review.readerRole}`,
|
|
576
582
|
`Export schema: ${plan.review.exportSchema}`,
|
|
577
|
-
...plan.review.
|
|
583
|
+
...(plan.review.accessMode === "managed"
|
|
584
|
+
? plan.review.views.map((view) => ` → remove view ${view}`)
|
|
585
|
+
: [
|
|
586
|
+
" → remove only Rehearsal's local credential and receipt",
|
|
587
|
+
" ✓ preserve the provider-owned reader and export views",
|
|
588
|
+
]),
|
|
578
589
|
"",
|
|
579
590
|
...(mode === "preview"
|
|
580
591
|
? [
|
|
@@ -582,7 +593,9 @@ export const renderSourceRetirement = ({ mode, plan }) =>
|
|
|
582
593
|
`Exact confirmation: --confirm-source-retirement=${plan.digest}`,
|
|
583
594
|
]
|
|
584
595
|
: [
|
|
585
|
-
|
|
596
|
+
plan.review.accessMode === "managed"
|
|
597
|
+
? "The exact reader, export views, and local credential files were retired."
|
|
598
|
+
: "Rehearsal's local credential and receipt were removed. Provider-owned database resources were preserved.",
|
|
586
599
|
]),
|
|
587
600
|
].join("\n");
|
|
588
601
|
|
|
@@ -211,6 +211,82 @@ const projectCommandEnvironment = (additional = {}) => ({
|
|
|
211
211
|
...additional,
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
const RUNTIME_STARTUP_FAILURE_PATTERN =
|
|
215
|
+
/container\b.*\b(?:is not ready|starting|unhealthy)|health(?:check)?\b.*\b(?:failed|starting|unhealthy)|out of memory|\boom\b|no space left|resource temporarily unavailable/iu;
|
|
216
|
+
|
|
217
|
+
export const classifyManagerFailure = ({
|
|
218
|
+
action,
|
|
219
|
+
output,
|
|
220
|
+
inferredCategory,
|
|
221
|
+
}) => {
|
|
222
|
+
if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output)) {
|
|
223
|
+
return "runtime_dependency_failure";
|
|
224
|
+
}
|
|
225
|
+
if (
|
|
226
|
+
action === "migrate" ||
|
|
227
|
+
(action === "run" &&
|
|
228
|
+
/migrateRuntime|candidate migration|migration up/iu.test(output))
|
|
229
|
+
) {
|
|
230
|
+
return "migration_candidate_failure";
|
|
231
|
+
}
|
|
232
|
+
return inferredCategory;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const parseDockerCapacity = (output) => {
|
|
236
|
+
const [cpuText, memoryText, runningText, totalText] = output
|
|
237
|
+
.trim()
|
|
238
|
+
.split(/\s+/u);
|
|
239
|
+
const values = [cpuText, memoryText, runningText, totalText].map(Number);
|
|
240
|
+
if (!values.every(Number.isFinite)) return null;
|
|
241
|
+
const [cpus, memoryBytes, runningContainers, containers] = values;
|
|
242
|
+
return { cpus, memoryBytes, runningContainers, containers };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const parseColimaMemory = (output) => {
|
|
246
|
+
const line = output
|
|
247
|
+
.split("\n")
|
|
248
|
+
.map((entry) => entry.trim())
|
|
249
|
+
.find((entry) => entry.startsWith("Mem:"));
|
|
250
|
+
if (!line) return null;
|
|
251
|
+
const columns = line.split(/\s+/u);
|
|
252
|
+
const totalBytes = Number(columns[1]);
|
|
253
|
+
const availableBytes = Number(columns[6]);
|
|
254
|
+
if (![totalBytes, availableBytes].every(Number.isFinite)) return null;
|
|
255
|
+
return { totalBytes, availableBytes };
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const diagnosticCommand = (command, args) =>
|
|
259
|
+
spawnSync(command, args, {
|
|
260
|
+
encoding: "utf8",
|
|
261
|
+
env: projectCommandEnvironment(),
|
|
262
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
263
|
+
timeout: 5_000,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const inspectContainerCapacity = () => {
|
|
267
|
+
const docker = diagnosticCommand("docker", [
|
|
268
|
+
"info",
|
|
269
|
+
"--format",
|
|
270
|
+
"{{.NCPU}} {{.MemTotal}} {{.ContainersRunning}} {{.Containers}}",
|
|
271
|
+
]);
|
|
272
|
+
const capacity =
|
|
273
|
+
docker.status === 0 ? parseDockerCapacity(docker.stdout) : null;
|
|
274
|
+
if (!capacity) return undefined;
|
|
275
|
+
|
|
276
|
+
const context = diagnosticCommand("docker", ["context", "show"]);
|
|
277
|
+
if (context.status !== 0 || context.stdout.trim() !== "colima") {
|
|
278
|
+
return { engine: "docker-compatible", ...capacity };
|
|
279
|
+
}
|
|
280
|
+
const memory = diagnosticCommand("colima", ["ssh", "--", "free", "-b"]);
|
|
281
|
+
return {
|
|
282
|
+
engine: "colima",
|
|
283
|
+
...capacity,
|
|
284
|
+
...(memory.status === 0
|
|
285
|
+
? { runtimeMemory: parseColimaMemory(memory.stdout) ?? undefined }
|
|
286
|
+
: {}),
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
214
290
|
export const topologyCommandEnvironment = (topology) =>
|
|
215
291
|
Object.fromEntries(
|
|
216
292
|
topology.targets.map((target) => [
|
|
@@ -344,14 +420,15 @@ export const createRuntimeCommands = ({
|
|
|
344
420
|
const inferred = normalizeRehearsalError(
|
|
345
421
|
new Error(completeOutput || `Rehearsal ${action} failed.`),
|
|
346
422
|
);
|
|
347
|
-
const category =
|
|
348
|
-
action
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
423
|
+
const category = classifyManagerFailure({
|
|
424
|
+
action,
|
|
425
|
+
output: completeOutput,
|
|
426
|
+
inferredCategory: inferred.category,
|
|
427
|
+
});
|
|
428
|
+
const startupFailure = category === "runtime_dependency_failure";
|
|
429
|
+
const engineCapacity = startupFailure
|
|
430
|
+
? inspectContainerCapacity()
|
|
431
|
+
: undefined;
|
|
355
432
|
const conciseOutput = completeOutput
|
|
356
433
|
.replaceAll(String.fromCodePoint(27), "")
|
|
357
434
|
.split("\n")
|
|
@@ -366,10 +443,16 @@ export const createRuntimeCommands = ({
|
|
|
366
443
|
message: `The local Rehearsal ${action} operation did not complete.`,
|
|
367
444
|
expected: "the isolated runtime operation to finish and verify",
|
|
368
445
|
actual: conciseOutput || `exit ${result.status}`,
|
|
369
|
-
context: { action },
|
|
446
|
+
context: { action, ...(engineCapacity ? { engineCapacity } : {}) },
|
|
370
447
|
refused: "The runtime was not reported as trusted.",
|
|
371
448
|
suggestions: [
|
|
372
449
|
"Review the concise failure above, then rerun rehearsal doctor before retrying.",
|
|
450
|
+
...(startupFailure
|
|
451
|
+
? [
|
|
452
|
+
"Stop unrelated local stacks or increase Docker/Colima capacity, then retry. Rehearsal will not change engine settings or bypass service health checks.",
|
|
453
|
+
"Run docker stats --no-stream to identify active containers using the shared engine.",
|
|
454
|
+
]
|
|
455
|
+
: []),
|
|
373
456
|
"Use --debug only when the safe diagnostic detail is needed.",
|
|
374
457
|
],
|
|
375
458
|
cause: new Error(completeOutput || `exit ${result.status}`),
|