@rehearsal-db/core 0.1.0-rc.8 → 1.0.0
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 +49 -2
- package/COMPATIBILITY.md +13 -8
- package/SECURITY.md +5 -3
- package/SUPPORT.md +4 -3
- package/dist/package.json +2 -2
- package/dist/src/application/interactive_task.d.mts +2 -6
- package/dist/src/application/interactive_task.mjs +4 -13
- package/dist/src/application/session.d.mts +5 -8
- package/dist/src/application/session.mjs +44 -19
- package/dist/src/baseline/privacy_policy.mjs +13 -1
- package/dist/src/cli/arguments.mjs +8 -1
- package/dist/src/cli/rehearsal.mjs +15 -3
- package/dist/src/cli/runtime_commands.mjs +14 -0
- package/dist/src/cli/terminal.mjs +24 -5
- package/dist/src/runtime/persistence.mjs +6 -3
- package/dist/src/shared/cancellation.d.mts +3 -1
- package/dist/src/shared/cancellation.mjs +12 -4
- package/dist/src/shared/terminal_input.d.mts +25 -0
- package/dist/src/shared/terminal_input.mjs +129 -0
- package/dist/src/source/access.mjs +11 -1
- package/docs/commands.md +23 -1
- package/docs/releasing.md +22 -0
- package/docs/stability.md +43 -18
- package/docs/tutorial.md +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,47 @@ a Changelog, and versions will follow Semantic Versioning after the package exis
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [1.0.0] - 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Stable compatibility
|
|
11
|
+
|
|
12
|
+
- Establish the documented CLI, configuration, policy, baseline, TypeScript and
|
|
13
|
+
JSON contracts as the 1.x compatibility boundary. This preserves rc.10's database,
|
|
14
|
+
authentication and restore behavior.
|
|
15
|
+
- Keep the complete MIT CLI free. The separately distributed Free Starter editor,
|
|
16
|
+
future Pro plans and additional database families do not expand the core support
|
|
17
|
+
matrix or change its safety boundaries.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Report unknown CLI commands and options as invalid input (exit 2), rather than
|
|
22
|
+
letting words in the input or help text misclassify them as migration or baseline
|
|
23
|
+
failures. The command remains refused and supported-command help is retained.
|
|
24
|
+
|
|
25
|
+
## [0.1.0-rc.10] - 2026-10-17
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Interactive Ctrl+C and Ctrl+Z retire owned work without leaving an invoking npm
|
|
30
|
+
job suspended. Terminal mode and input consumption are restored after cleanup;
|
|
31
|
+
startup and unanswered migration confirmations cancel safely, and unrelated jobs
|
|
32
|
+
are untouched.
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
- Stable promotion is evidence-based after owner review: complete the release and
|
|
37
|
+
consumer acceptance checks rather than waiting a fixed number of calendar days.
|
|
38
|
+
Exact-artifact, security, data-preservation and human publishing approvals remain.
|
|
39
|
+
|
|
40
|
+
## [0.1.0-rc.9] - 2026-10-17
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Wrong or stale source-access approval and review-required privacy drafts report
|
|
45
|
+
actionable configuration refusals instead of internal failures. Source access,
|
|
46
|
+
privacy review and baseline-preservation requirements are unchanged.
|
|
47
|
+
|
|
48
|
+
## [0.1.0-rc.8] - 2026-10-17
|
|
9
49
|
|
|
10
50
|
### Added
|
|
11
51
|
|
|
@@ -732,7 +772,14 @@ not unattended cleanup, and container age is not proof of user inactivity.
|
|
|
732
772
|
publication uses short-lived trusted OIDC, and every release tag must already exist on
|
|
733
773
|
protected `main`.
|
|
734
774
|
|
|
735
|
-
[Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/
|
|
775
|
+
[Unreleased]: https://github.com/Ddupasquier/rehearsal-db/compare/v1.0.0...HEAD
|
|
776
|
+
[1.0.0]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.10...v1.0.0
|
|
777
|
+
[0.1.0-rc.10]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.9...v0.1.0-rc.10
|
|
778
|
+
[0.1.0-rc.9]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.8...v0.1.0-rc.9
|
|
779
|
+
[0.1.0-rc.8]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.7...v0.1.0-rc.8
|
|
780
|
+
[0.1.0-rc.7]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.6...v0.1.0-rc.7
|
|
781
|
+
[0.1.0-rc.6]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.5...v0.1.0-rc.6
|
|
782
|
+
[0.1.0-rc.5]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.4...v0.1.0-rc.5
|
|
736
783
|
[0.1.0-rc.4]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.3...v0.1.0-rc.4
|
|
737
784
|
[0.1.0-rc.3]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.2...v0.1.0-rc.3
|
|
738
785
|
[0.1.0-rc.2]: https://github.com/Ddupasquier/rehearsal-db/compare/v0.1.0-rc.1...v0.1.0-rc.2
|
package/COMPATIBILITY.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# Rehearsal compatibility policy
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Starting with `1.0.0`, documented public contracts are backward compatible throughout
|
|
4
|
+
the `1.x` line. Minor releases may add compatible capabilities; patch releases fix
|
|
5
|
+
defects. An intentional breaking change requires a new major version, a changelog
|
|
6
|
+
entry and a migration path.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Historical `0.x` releases allowed intentional breaking changes in minor versions,
|
|
9
|
+
with changelog and migration guidance. Their patch releases remain compatible within
|
|
10
|
+
their minor line. Existing artifacts and granted license rights are unchanged.
|
|
11
|
+
|
|
12
|
+
The following are compatibility contracts for published releases:
|
|
8
13
|
|
|
9
14
|
- configuration property names, types, defaults, validation, and schema version;
|
|
10
15
|
- baseline format and checksum meaning;
|
|
@@ -17,12 +22,12 @@ A change to any of those requires either a compatible extension or a deliberate
|
|
|
17
22
|
transition. Rehearsal must never silently reinterpret an old config, baseline, digest,
|
|
18
23
|
or successful verification receipt.
|
|
19
24
|
|
|
20
|
-
The first `1.0.0` release requires external beta evidence, a support policy
|
|
21
|
-
public API
|
|
22
|
-
systems is not implied
|
|
25
|
+
The first `1.0.0` release requires external beta evidence, a support policy and the
|
|
26
|
+
settled public API described in [the stability policy](docs/stability.md). Supporting
|
|
27
|
+
additional database families, package managers or operating systems is not implied.
|
|
23
28
|
|
|
24
29
|
Rehearsal supports maintained even-numbered Node.js release lines that pass its CI
|
|
25
|
-
matrix.
|
|
30
|
+
matrix. Rehearsal 1.0 supports Node.js 22, 24, and 26; Node.js 24 LTS is the recommended
|
|
26
31
|
default and the release-build runtime. End-of-life and odd-numbered Node.js releases are
|
|
27
32
|
not supported. A future release may remove a Node.js line after its upstream end-of-life
|
|
28
33
|
date, with that change called out in the changelog.
|
package/SECURITY.md
CHANGED
|
@@ -9,9 +9,11 @@ Report suspected vulnerabilities through the
|
|
|
9
9
|
in this repository. A GitHub account is required. Do not open a
|
|
10
10
|
public issue containing an exploit, secret, hosted target, or source record.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Security updates target the latest released 1.x version. Update to the latest 1.x
|
|
13
|
+
release to receive fixes; older release candidates are not a maintained security line.
|
|
14
|
+
Reports concerning current `main` and prereleases are also welcome. A security fix may
|
|
15
|
+
tighten validation or refuse an unsafe workflow; it must not silently reinterpret
|
|
16
|
+
existing data or claim that a refused operation succeeded.
|
|
15
17
|
|
|
16
18
|
The threat boundary and remaining responsibilities live in
|
|
17
19
|
[docs/security-model.md](docs/security-model.md). Every
|
package/SUPPORT.md
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Use GitHub Issues for reproducible defects and setup problems that include a synthetic
|
|
4
4
|
reproduction. Include operating system, architecture, Node version, Supabase CLI version,
|
|
5
5
|
Docker engine, the failing command, safe diagnostics, and the smallest reproduction.
|
|
6
|
-
|
|
7
|
-
channel.
|
|
6
|
+
Search existing reports before opening a new issue.
|
|
8
7
|
|
|
9
8
|
Run `npx rehearsal support` or choose **Get help** in the guide to collect the environment
|
|
10
9
|
and readiness portion without assembling it by hand. The report intentionally excludes
|
|
@@ -15,6 +14,8 @@ Do not include credentials, connection strings, source rows, baseline artifacts,
|
|
|
15
14
|
URLs, or proprietary migrations. Report security problems through private vulnerability
|
|
16
15
|
reporting as described in [SECURITY.md](SECURITY.md).
|
|
17
16
|
|
|
18
|
-
The
|
|
17
|
+
The supported matrix and 1.x compatibility boundary are defined in
|
|
19
18
|
[COMPATIBILITY.md](COMPATIBILITY.md); unsupported environments are welcome as clearly
|
|
20
19
|
labelled experiments but cannot be treated as verified behavior.
|
|
20
|
+
|
|
21
|
+
Support is community-based; no response-time guarantee or production SLA is offered.
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rehearsal-db/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Catch PostgreSQL and Supabase migrations that pass on empty databases but fail on real data—before deploy.",
|
|
6
6
|
"repository": {
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"test:fixture:standalone": "npm run build && node scripts/verification/run_scenario.mjs standalone",
|
|
77
77
|
"test:fixture:privacy": "npm run build && node scripts/verification/run_scenario.mjs privacy",
|
|
78
78
|
"test:fixture:resources": "npm run build && node scripts/verification/run_scenario.mjs resources",
|
|
79
|
-
"release:verify": "node scripts/verification/release_gate.mjs",
|
|
79
|
+
"release:verify": "npm run build && node scripts/verification/release_gate.mjs",
|
|
80
80
|
"check": "npm run check:syntax && npm run typecheck && npm run test && npm run docs:build && npm run format:check && npm run package:audit",
|
|
81
81
|
"check:syntax": "find scripts tests -type f -name '*.mjs' -exec node --check {} +",
|
|
82
82
|
"typecheck": "npm run build && tsc --project tsconfig.json",
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/** Run identity discovery while an owned local application remains available. */
|
|
2
2
|
import type { EventEmitter } from "node:events";
|
|
3
|
+
import { type TerminalInput } from "../shared/terminal_input.mjs";
|
|
3
4
|
import { type ApplicationDiagnostics, type ApplicationSession, type ApplicationSessionSignal } from "./session.mjs";
|
|
4
5
|
type SignalTarget = Pick<EventEmitter, "prependListener" | "removeListener">;
|
|
5
|
-
interface SessionInput {
|
|
6
|
-
readonly isTTY?: boolean;
|
|
7
|
-
prependListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
8
|
-
removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
9
|
-
}
|
|
10
6
|
/**
|
|
11
7
|
* A terminal interruption aborts the task and retires the owned process group.
|
|
12
8
|
* A completed task leaves the session running so its caller can deliberately
|
|
@@ -16,7 +12,7 @@ export declare const runApplicationSessionTask: <Value>({ session, task, signalT
|
|
|
16
12
|
session: ApplicationSession;
|
|
17
13
|
task: (signal: AbortSignal) => Promise<Value>;
|
|
18
14
|
signalTarget?: SignalTarget;
|
|
19
|
-
input?:
|
|
15
|
+
input?: TerminalInput | null;
|
|
20
16
|
onReady?: (ready: ApplicationSession["ready"]) => void;
|
|
21
17
|
}) => Promise<Readonly<{
|
|
22
18
|
completed: true;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Run identity discovery while an owned local application remains available. */
|
|
2
|
+
import { acquireTerminalInput, } from "../shared/terminal_input.mjs";
|
|
2
3
|
import { summarizeProjectCommandFailure, } from "./session.mjs";
|
|
3
4
|
const SESSION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
4
5
|
/**
|
|
@@ -23,19 +24,10 @@ export const runApplicationSessionTask = async ({ session, task, signalTarget =
|
|
|
23
24
|
for (const [signal, handler] of handlers) {
|
|
24
25
|
signalTarget.prependListener(signal, handler);
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
-
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
28
|
-
if (bytes.includes(0x03)) {
|
|
29
|
-
finishInterruption({ kind: "interrupted", signal: "SIGINT" });
|
|
30
|
-
}
|
|
31
|
-
else if (bytes.includes(0x1a)) {
|
|
32
|
-
finishInterruption({ kind: "interrupted", signal: "SIGTSTP" });
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
if (input?.isTTY)
|
|
36
|
-
input.prependListener("data", onInput);
|
|
27
|
+
let releaseInput = () => undefined;
|
|
37
28
|
const abortController = new AbortController();
|
|
38
29
|
try {
|
|
30
|
+
releaseInput = acquireTerminalInput(input, (signal) => finishInterruption({ kind: "interrupted", signal }));
|
|
39
31
|
onReady(session.ready);
|
|
40
32
|
const outcome = await Promise.race([
|
|
41
33
|
interruption,
|
|
@@ -77,7 +69,6 @@ export const runApplicationSessionTask = async ({ session, task, signalTarget =
|
|
|
77
69
|
for (const [signal, handler] of handlers) {
|
|
78
70
|
signalTarget.removeListener(signal, handler);
|
|
79
71
|
}
|
|
80
|
-
|
|
81
|
-
input.removeListener("data", onInput);
|
|
72
|
+
releaseInput();
|
|
82
73
|
}
|
|
83
74
|
};
|
|
@@ -3,7 +3,8 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import type { EventEmitter } from "node:events";
|
|
4
4
|
import { waitForApplicationIdle } from "./activity_monitor.mjs";
|
|
5
5
|
import type { ApplicationActivity } from "./activity_contract.mjs";
|
|
6
|
-
|
|
6
|
+
import { type TerminalInput } from "../shared/terminal_input.mjs";
|
|
7
|
+
declare const SESSION_SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
7
8
|
type SessionSignal = (typeof SESSION_SIGNALS)[number];
|
|
8
9
|
export type ApplicationSessionSignal = SessionSignal;
|
|
9
10
|
export interface ApplicationReadiness {
|
|
@@ -29,11 +30,6 @@ interface ChildExit {
|
|
|
29
30
|
readonly status: number | null;
|
|
30
31
|
readonly signal: NodeJS.Signals | null;
|
|
31
32
|
}
|
|
32
|
-
interface SessionInput {
|
|
33
|
-
readonly isTTY?: boolean;
|
|
34
|
-
prependListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
35
|
-
removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
36
|
-
}
|
|
37
33
|
interface EnvironmentInput {
|
|
38
34
|
readonly files: Readonly<Record<string, string>> & {
|
|
39
35
|
readonly primary: string;
|
|
@@ -66,7 +62,7 @@ export declare const summarizeProjectCommandFailure: ({ error, status, signal, s
|
|
|
66
62
|
stdoutByteCount?: number;
|
|
67
63
|
stderrByteCount?: number;
|
|
68
64
|
}) => string;
|
|
69
|
-
export declare const startApplicationSession: ({ command, cwd, files, mappings, readiness, inheritedEnvironment, spawnImplementation, fetchImplementation, signalTarget, portPreflight, }: EnvironmentInput & {
|
|
65
|
+
export declare const startApplicationSession: ({ command, cwd, files, mappings, readiness, inheritedEnvironment, spawnImplementation, fetchImplementation, signalTarget, input, portPreflight, }: EnvironmentInput & {
|
|
70
66
|
command: string;
|
|
71
67
|
cwd: string;
|
|
72
68
|
readiness: ApplicationReadiness;
|
|
@@ -74,6 +70,7 @@ export declare const startApplicationSession: ({ command, cwd, files, mappings,
|
|
|
74
70
|
spawnImplementation?: typeof spawn;
|
|
75
71
|
fetchImplementation?: typeof fetch;
|
|
76
72
|
signalTarget?: SignalTarget;
|
|
73
|
+
input?: TerminalInput | null;
|
|
77
74
|
portPreflight?: (readiness: ApplicationReadiness) => Promise<void>;
|
|
78
75
|
}) => Promise<ApplicationSession>;
|
|
79
76
|
/**
|
|
@@ -83,7 +80,7 @@ export declare const startApplicationSession: ({ command, cwd, files, mappings,
|
|
|
83
80
|
export declare const holdApplicationSession: ({ session, signalTarget, input, onReady, activity, idleTimeoutSeconds, idleMonitorImplementation, onActivityAvailability, }: {
|
|
84
81
|
session: ApplicationSession;
|
|
85
82
|
signalTarget?: SignalTarget;
|
|
86
|
-
input?:
|
|
83
|
+
input?: TerminalInput | null;
|
|
87
84
|
onReady?: (ready: ApplicationSession["ready"]) => void;
|
|
88
85
|
activity?: ApplicationActivity | null;
|
|
89
86
|
idleTimeoutSeconds?: number | null;
|
|
@@ -7,6 +7,8 @@ import { createCleanProcessEnvironment, isLoopbackUrl, } from "../shared/process
|
|
|
7
7
|
import { assertApplicationPortAvailable } from "./port_preflight.mjs";
|
|
8
8
|
import { waitForApplicationIdle } from "./activity_monitor.mjs";
|
|
9
9
|
import { stopOwnedPosixProcessGroup } from "./process_group.mjs";
|
|
10
|
+
import { acquireTerminalInput, } from "../shared/terminal_input.mjs";
|
|
11
|
+
import { createOperationCancelledError, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
|
|
10
12
|
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
11
13
|
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
12
14
|
const SESSION_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
@@ -188,7 +190,7 @@ const stopOwnedChild = async (child) => {
|
|
|
188
190
|
throw new Error("Owned application process group did not stop within its shutdown bound. No unrelated process was targeted.");
|
|
189
191
|
}
|
|
190
192
|
};
|
|
191
|
-
export const startApplicationSession = async ({ command, cwd, files, mappings, readiness, inheritedEnvironment = process.env, spawnImplementation = spawn, fetchImplementation = fetch, signalTarget = process, portPreflight = (value) => assertApplicationPortAvailable({ url: value?.url }), }) => {
|
|
193
|
+
export const startApplicationSession = async ({ command, cwd, files, mappings, readiness, inheritedEnvironment = process.env, spawnImplementation = spawn, fetchImplementation = fetch, signalTarget = process, input = process.stdin, portPreflight = (value) => assertApplicationPortAvailable({ url: value?.url }), }) => {
|
|
192
194
|
const mapped = await buildApplicationEnvironment({ files, mappings });
|
|
193
195
|
const [program, ...args] = parseCommand(command);
|
|
194
196
|
await portPreflight(readiness);
|
|
@@ -228,13 +230,22 @@ export const startApplicationSession = async ({ command, cwd, files, mappings, r
|
|
|
228
230
|
});
|
|
229
231
|
let interruptStartup = () => undefined;
|
|
230
232
|
const interruption = new Promise((_, reject) => {
|
|
231
|
-
interruptStartup = (signal) => reject(
|
|
233
|
+
interruptStartup = (signal) => reject(createOperationCancelledError(signal));
|
|
232
234
|
});
|
|
233
|
-
|
|
235
|
+
let releaseInput = null;
|
|
236
|
+
const startupHandlers = new Map(SESSION_SIGNALS.map((signal) => [
|
|
237
|
+
signal,
|
|
238
|
+
() => {
|
|
239
|
+
if (!releaseInput || releaseInput.isCurrentOwner())
|
|
240
|
+
interruptStartup(signal);
|
|
241
|
+
},
|
|
242
|
+
]));
|
|
234
243
|
for (const [signal, handler] of startupHandlers) {
|
|
235
244
|
signalTarget.prependListener(signal, handler);
|
|
236
245
|
}
|
|
237
246
|
try {
|
|
247
|
+
releaseInput = acquireTerminalInput(input, interruptStartup);
|
|
248
|
+
throwIfOperationCancelled();
|
|
238
249
|
const ready = await Promise.race([
|
|
239
250
|
waitForReadiness({
|
|
240
251
|
readiness,
|
|
@@ -255,13 +266,19 @@ export const startApplicationSession = async ({ command, cwd, files, mappings, r
|
|
|
255
266
|
});
|
|
256
267
|
}
|
|
257
268
|
catch (error) {
|
|
258
|
-
|
|
269
|
+
try {
|
|
270
|
+
await stopApplication();
|
|
271
|
+
}
|
|
272
|
+
catch (cleanupError) {
|
|
273
|
+
throw new AggregateError([error, cleanupError], "Application startup failed and its owned process could not be stopped.");
|
|
274
|
+
}
|
|
259
275
|
throw error;
|
|
260
276
|
}
|
|
261
277
|
finally {
|
|
262
278
|
for (const [signal, handler] of startupHandlers) {
|
|
263
279
|
signalTarget.removeListener(signal, handler);
|
|
264
280
|
}
|
|
281
|
+
releaseInput?.();
|
|
265
282
|
}
|
|
266
283
|
};
|
|
267
284
|
/**
|
|
@@ -278,26 +295,23 @@ export const holdApplicationSession = async ({ session, signalTarget = process,
|
|
|
278
295
|
const interruption = new Promise((resolve) => {
|
|
279
296
|
finishInterruption = resolve;
|
|
280
297
|
});
|
|
298
|
+
let releaseInput = null;
|
|
281
299
|
const handlers = new Map(SESSION_SIGNALS.map((signal) => [
|
|
282
300
|
signal,
|
|
283
|
-
() =>
|
|
301
|
+
() => {
|
|
302
|
+
if (!releaseInput || releaseInput.isCurrentOwner())
|
|
303
|
+
finishInterruption({ kind: "interrupted", signal });
|
|
304
|
+
},
|
|
284
305
|
]));
|
|
285
306
|
for (const [signal, handler] of handlers) {
|
|
286
307
|
signalTarget.prependListener(signal, handler);
|
|
287
308
|
}
|
|
288
|
-
const onInput = (chunk) => {
|
|
289
|
-
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
290
|
-
if (bytes.includes(0x03)) {
|
|
291
|
-
finishInterruption({ kind: "interrupted", signal: "SIGINT" });
|
|
292
|
-
}
|
|
293
|
-
else if (bytes.includes(0x1a)) {
|
|
294
|
-
finishInterruption({ kind: "interrupted", signal: "SIGTSTP" });
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
if (input?.isTTY)
|
|
298
|
-
input.prependListener("data", onInput);
|
|
299
309
|
const activityController = new AbortController();
|
|
310
|
+
let operationFailed = false;
|
|
311
|
+
let operationError;
|
|
300
312
|
try {
|
|
313
|
+
releaseInput = acquireTerminalInput(input, (signal) => finishInterruption({ kind: "interrupted", signal }));
|
|
314
|
+
throwIfOperationCancelled();
|
|
301
315
|
if (idleTimeoutSeconds !== null &&
|
|
302
316
|
(!activity ||
|
|
303
317
|
!Number.isSafeInteger(idleTimeoutSeconds) ||
|
|
@@ -338,20 +352,31 @@ export const holdApplicationSession = async ({ session, signalTarget = process,
|
|
|
338
352
|
},
|
|
339
353
|
});
|
|
340
354
|
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
operationFailed = true;
|
|
357
|
+
operationError = error;
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
341
360
|
finally {
|
|
342
361
|
activityController.abort();
|
|
343
362
|
try {
|
|
344
363
|
// npm can forward the terminal's SIGINT after the CLI receives it.
|
|
345
364
|
// Keep our handlers installed until the owned process group is fully
|
|
346
365
|
// stopped so a duplicate signal cannot terminate Rehearsal mid-cleanup.
|
|
347
|
-
|
|
366
|
+
try {
|
|
367
|
+
await session.stop();
|
|
368
|
+
}
|
|
369
|
+
catch (cleanupError) {
|
|
370
|
+
if (operationFailed)
|
|
371
|
+
throw new AggregateError([operationError, cleanupError], "Application session failed and its owned process could not be stopped.");
|
|
372
|
+
throw cleanupError;
|
|
373
|
+
}
|
|
348
374
|
}
|
|
349
375
|
finally {
|
|
350
376
|
for (const [signal, handler] of handlers) {
|
|
351
377
|
signalTarget.removeListener(signal, handler);
|
|
352
378
|
}
|
|
353
|
-
|
|
354
|
-
input.removeListener("data", onInput);
|
|
379
|
+
releaseInput?.();
|
|
355
380
|
}
|
|
356
381
|
}
|
|
357
382
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Validate and normalize the complete reviewed privacy policy version 2.
|
|
3
3
|
*/
|
|
4
|
+
import { RehearsalError } from "../shared/diagnostics.mjs";
|
|
4
5
|
import { validatePrivacyDependencyGraph } from "./privacy_dependency_graph.mjs";
|
|
5
6
|
import { validatePrivacyRecipe } from "./privacy_recipe_validation.mjs";
|
|
6
7
|
import { ACTIONS, BINDING_NAME, DEFAULT_PATH_MAXIMUM_BYTES, DEFAULT_PATH_MAXIMUM_SEGMENTS, ENVIRONMENT_KEY, HEX_64, assertKnownKeys as knownKeys, isPrivacyObject as isObject, validateIdentifier as identifier, validateNonEmpty as nonEmpty, validatePositiveInteger as positiveInteger, } from "./privacy_validation_shared.mjs";
|
|
@@ -19,7 +20,18 @@ export const validateExecutablePrivacyPolicy = (policy) => {
|
|
|
19
20
|
throw new Error("Executable privacy policies must use policyVersion 2.");
|
|
20
21
|
}
|
|
21
22
|
if (policy.draft === true) {
|
|
22
|
-
throw new
|
|
23
|
+
throw new RehearsalError({
|
|
24
|
+
category: "configuration_invalid",
|
|
25
|
+
code: "CONFIGURATION_INVALID",
|
|
26
|
+
message: "The privacy policy is still a REVIEW REQUIRED draft.",
|
|
27
|
+
expected: "A complete, explicitly reviewed and approved privacy policy.",
|
|
28
|
+
actual: "REVIEW REQUIRED draft.",
|
|
29
|
+
refused: "This draft policy was not accepted for source export or baseline activation.",
|
|
30
|
+
suggestions: [
|
|
31
|
+
"Review every exported table, column, recipe and source-data boundary, and obtain explicit privacy approval before completing this draft.",
|
|
32
|
+
"Do not remove the draft marker merely to bypass review. After approval, rerun rehearsal baseline refresh.",
|
|
33
|
+
],
|
|
34
|
+
});
|
|
23
35
|
}
|
|
24
36
|
if (typeof policy.migrationCutoff !== "string" ||
|
|
25
37
|
!/^\d{14}$/u.test(policy.migrationCutoff)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Parse and classify the stable Rehearsal command-line contract. */
|
|
2
|
+
import { RehearsalError } from "../shared/diagnostics.mjs";
|
|
2
3
|
export const parseArguments = (arguments_) => {
|
|
3
4
|
const flags = {
|
|
4
5
|
json: false,
|
|
@@ -86,7 +87,13 @@ export const parseArguments = (arguments_) => {
|
|
|
86
87
|
else if (argument.startsWith("--image-id="))
|
|
87
88
|
(flags.imageIds ??= []).push(argument.slice("--image-id=".length));
|
|
88
89
|
else if (argument.startsWith("--"))
|
|
89
|
-
throw new
|
|
90
|
+
throw new RehearsalError({
|
|
91
|
+
category: "configuration_invalid",
|
|
92
|
+
code: "CONFIGURATION_INVALID",
|
|
93
|
+
message: `Unknown Rehearsal option: ${argument}.`,
|
|
94
|
+
refused: "No Rehearsal command was executed.",
|
|
95
|
+
suggestions: ["Run rehearsal --help to list supported options."],
|
|
96
|
+
});
|
|
90
97
|
else
|
|
91
98
|
positionals.push(argument);
|
|
92
99
|
}
|
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { performance } from "node:perf_hooks";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import * as prompts from "@clack/prompts";
|
|
11
|
-
import { normalizeRehearsalError, renderHumanError, serializeRehearsalError, } from "../shared/diagnostics.mjs";
|
|
11
|
+
import { RehearsalError, normalizeRehearsalError, renderHumanError, serializeRehearsalError, } from "../shared/diagnostics.mjs";
|
|
12
12
|
import { acquireProjectOperation, assertInstalledPackageFingerprint, createInstalledPackageFingerprint, } from "../shared/operation_guard.mjs";
|
|
13
13
|
import { commandMutatesProject, parseArguments, resetGuidedFlags, } from "./arguments.mjs";
|
|
14
14
|
import { usage } from "./basic_command_handlers.mjs";
|
|
@@ -70,7 +70,13 @@ const executeCommand = async ({ command, flags, planOptions, guided, }) => {
|
|
|
70
70
|
state,
|
|
71
71
|
});
|
|
72
72
|
if (!result) {
|
|
73
|
-
throw new
|
|
73
|
+
throw new RehearsalError({
|
|
74
|
+
category: "configuration_invalid",
|
|
75
|
+
code: "CONFIGURATION_INVALID",
|
|
76
|
+
message: `Unknown Rehearsal command: ${command}.\n\n${usage()}`,
|
|
77
|
+
refused: "No Rehearsal command was executed.",
|
|
78
|
+
suggestions: ["Run rehearsal --help to list supported commands."],
|
|
79
|
+
});
|
|
74
80
|
}
|
|
75
81
|
const exitCode = emitCommand({ command, flags, result });
|
|
76
82
|
if (exitCode !== undefined)
|
|
@@ -141,12 +147,18 @@ const main = async () => {
|
|
|
141
147
|
removeGuidedExitShortcut = null;
|
|
142
148
|
}
|
|
143
149
|
try {
|
|
144
|
-
|
|
150
|
+
const executeSelected = () => executeCommand({
|
|
145
151
|
command: selected,
|
|
146
152
|
flags,
|
|
147
153
|
planOptions,
|
|
148
154
|
guided: true,
|
|
149
155
|
});
|
|
156
|
+
if (opensApplication) {
|
|
157
|
+
await runWithOperationCancellation({ task: executeSelected });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
await executeSelected();
|
|
161
|
+
}
|
|
150
162
|
}
|
|
151
163
|
finally {
|
|
152
164
|
if (opensApplication) {
|
|
@@ -473,6 +473,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
473
473
|
};
|
|
474
474
|
const startRuntimeApplication = async ({ flags, planOptions, session: commandSession, onRuntimeStarted = () => undefined, }) => {
|
|
475
475
|
const topology = await loadRuntimeTopology(planOptions);
|
|
476
|
+
throwIfOperationCancelled();
|
|
476
477
|
const application = topology.primary.config.application;
|
|
477
478
|
const readiness = application.readiness;
|
|
478
479
|
if (!readiness) {
|
|
@@ -516,6 +517,18 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
516
517
|
mappings: application.environmentVariables,
|
|
517
518
|
readiness,
|
|
518
519
|
}));
|
|
520
|
+
try {
|
|
521
|
+
throwIfOperationCancelled();
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
try {
|
|
525
|
+
await applicationSession.stop();
|
|
526
|
+
}
|
|
527
|
+
catch (cleanupError) {
|
|
528
|
+
throw new AggregateError([error, cleanupError], "Application handoff was cancelled and its owned process could not be stopped.");
|
|
529
|
+
}
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
519
532
|
return {
|
|
520
533
|
started: started.runtime,
|
|
521
534
|
verified: verified.runtime,
|
|
@@ -527,6 +540,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
|
|
|
527
540
|
};
|
|
528
541
|
const openRuntimeApplication = async ({ flags, planOptions, session: commandSession, onReady = () => undefined, }) => {
|
|
529
542
|
const topology = await loadRuntimeTopology(planOptions);
|
|
543
|
+
throwIfOperationCancelled();
|
|
530
544
|
let runtimeStarted = false;
|
|
531
545
|
const { result, lifecycle } = await applyRuntimePersistence({
|
|
532
546
|
mode: topology.primary.config.lifecycle.open,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Terminal presentation and prompt primitives shared by guided CLI workflows. */
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import * as prompts from "@clack/prompts";
|
|
4
|
+
import { hasTerminalInputLease, reassertTerminalInputLease, } from "../shared/terminal_input.mjs";
|
|
5
|
+
import { getOperationCancellationSignal } from "../shared/cancellation.mjs";
|
|
4
6
|
const useColor = (flags) => !flags.plain &&
|
|
5
7
|
process.stdout.isTTY &&
|
|
6
8
|
process.env.NO_COLOR === undefined &&
|
|
@@ -54,22 +56,39 @@ export const promptForChoice = async ({ message, options, flags = {}, optionsAlr
|
|
|
54
56
|
}
|
|
55
57
|
};
|
|
56
58
|
export async function promptForConfirmation(message, flags = {}, { cancelValue = false } = {}) {
|
|
59
|
+
const signal = getOperationCancellationSignal();
|
|
60
|
+
if (signal?.aborted)
|
|
61
|
+
throw signal.reason;
|
|
57
62
|
if (useStyledPrompts(flags)) {
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
try {
|
|
64
|
+
const accepted = await prompts.confirm({
|
|
65
|
+
message,
|
|
66
|
+
initialValue: false,
|
|
67
|
+
...(signal ? { signal } : {}),
|
|
68
|
+
});
|
|
69
|
+
return prompts.isCancel(accepted) ? cancelValue : accepted;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
reassertTerminalInputLease(process.stdin);
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
const prompt = createInterface({
|
|
62
76
|
input: process.stdin,
|
|
63
77
|
output: process.stdout,
|
|
64
78
|
});
|
|
65
79
|
try {
|
|
66
|
-
const answer = (await prompt.question(`${message} (y/N)
|
|
80
|
+
const answer = (await prompt.question(`${message} (y/N) `, { signal }))
|
|
67
81
|
.trim()
|
|
68
82
|
.toLowerCase();
|
|
69
83
|
return answer === "y" || answer === "yes";
|
|
70
84
|
}
|
|
71
85
|
finally {
|
|
72
|
-
|
|
86
|
+
try {
|
|
87
|
+
prompt.close();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
reassertTerminalInputLease(process.stdin);
|
|
91
|
+
}
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
export const promptForPath = async ({ message, defaultValue, optional = false, flags = {}, validate, }) => {
|
|
@@ -203,7 +222,7 @@ export const promptForMultipleChoice = async ({ message, options, initialValues
|
|
|
203
222
|
export const installGuidedExitShortcut = (flags, { beforeExit } = {}) => {
|
|
204
223
|
let exiting = false;
|
|
205
224
|
const exit = () => {
|
|
206
|
-
if (exiting)
|
|
225
|
+
if (exiting || hasTerminalInputLease(process.stdin))
|
|
207
226
|
return;
|
|
208
227
|
exiting = true;
|
|
209
228
|
if (process.stdin.isTTY && process.stdin.isRaw)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Explicit, non-destructive persistence policy for project-owned runtimes. */
|
|
2
|
-
import { withOperationCancellationShield } from "../shared/cancellation.mjs";
|
|
2
|
+
import { getOperationCancellationSignal, withOperationCancellationShield, } from "../shared/cancellation.mjs";
|
|
3
3
|
export const shouldStopRuntime = (mode) => mode === "stop-after-run" || mode === "stop-on-application-exit";
|
|
4
4
|
export const applyRuntimePersistence = async ({ mode, operation, stop, canStop = () => true, }) => {
|
|
5
5
|
let result;
|
|
@@ -20,8 +20,11 @@ export const applyRuntimePersistence = async ({ mode, operation, stop, canStop =
|
|
|
20
20
|
stopped = true;
|
|
21
21
|
}
|
|
22
22
|
catch (stopError) {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// The shield has unwound here. A later interruption must not let the
|
|
24
|
+
// outer cancellation owner replace a real cleanup fault with clean130.
|
|
25
|
+
const cancellation = getOperationCancellationSignal();
|
|
26
|
+
if (operationFailed || cancellation?.aborted) {
|
|
27
|
+
throw new AggregateError([operationFailed ? operationError : cancellation.reason, stopError], "The Rehearsal operation failed and its owned runtime could not be stopped automatically.");
|
|
25
28
|
}
|
|
26
29
|
throw stopError;
|
|
27
30
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Cooperative cancellation state for one mutating CLI operation. */
|
|
2
2
|
import type { EventEmitter } from "node:events";
|
|
3
3
|
import { RehearsalError } from "./diagnostics.mjs";
|
|
4
|
+
import { type TerminalInput } from "./terminal_input.mjs";
|
|
4
5
|
export declare const OPERATION_CANCELLATION_SIGNALS: readonly ["SIGINT", "SIGTERM", "SIGHUP", "SIGTSTP"];
|
|
5
6
|
export type OperationCancellationSignal = (typeof OPERATION_CANCELLATION_SIGNALS)[number];
|
|
6
7
|
type SignalTarget = Pick<EventEmitter, "prependListener" | "removeListener">;
|
|
@@ -11,8 +12,9 @@ export declare const getOperationCancellationSignal: () => AbortSignal | undefin
|
|
|
11
12
|
export declare const throwIfOperationCancelled: () => void;
|
|
12
13
|
export declare const reachOperationCancellationCheckpoint: () => Promise<void>;
|
|
13
14
|
export declare const withOperationCancellationShield: <Value>(task: () => Promise<Value>) => Promise<Value>;
|
|
14
|
-
export declare const runWithOperationCancellation: <Value>({ task, signalTarget, }: {
|
|
15
|
+
export declare const runWithOperationCancellation: <Value>({ task, signalTarget, input, }: {
|
|
15
16
|
task: (signal: AbortSignal) => Promise<Value>;
|
|
16
17
|
signalTarget?: SignalTarget;
|
|
18
|
+
input?: TerminalInput | null;
|
|
17
19
|
}) => Promise<Value>;
|
|
18
20
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Cooperative cancellation state for one mutating CLI operation. */
|
|
2
2
|
import { RehearsalError } from "./diagnostics.mjs";
|
|
3
|
+
import { acquireTerminalInput, } from "./terminal_input.mjs";
|
|
3
4
|
export const OPERATION_CANCELLATION_SIGNALS = [
|
|
4
5
|
"SIGINT",
|
|
5
6
|
"SIGTERM",
|
|
@@ -8,6 +9,7 @@ export const OPERATION_CANCELLATION_SIGNALS = [
|
|
|
8
9
|
];
|
|
9
10
|
const CANCELLABLE_COMMANDS = new Set([
|
|
10
11
|
"migrate",
|
|
12
|
+
"open",
|
|
11
13
|
"refresh",
|
|
12
14
|
"reset",
|
|
13
15
|
"run",
|
|
@@ -48,24 +50,29 @@ export const withOperationCancellationShield = async (task) => {
|
|
|
48
50
|
shieldDepth -= 1;
|
|
49
51
|
}
|
|
50
52
|
};
|
|
51
|
-
export const runWithOperationCancellation = async ({ task, signalTarget = process, }) => {
|
|
53
|
+
export const runWithOperationCancellation = async ({ task, signalTarget = process, input = process.stdin, }) => {
|
|
52
54
|
if (activeSignal) {
|
|
53
55
|
throw new Error("A cancellable Rehearsal operation is already active.");
|
|
54
56
|
}
|
|
55
57
|
const controller = new AbortController();
|
|
56
58
|
activeSignal = controller.signal;
|
|
59
|
+
let releaseInput = null;
|
|
60
|
+
const interrupt = (signal) => {
|
|
61
|
+
if (!controller.signal.aborted)
|
|
62
|
+
controller.abort(createOperationCancelledError(signal));
|
|
63
|
+
};
|
|
57
64
|
const handlers = new Map(OPERATION_CANCELLATION_SIGNALS.map((signal) => [
|
|
58
65
|
signal,
|
|
59
66
|
() => {
|
|
60
|
-
if (!
|
|
61
|
-
|
|
62
|
-
}
|
|
67
|
+
if (!releaseInput || releaseInput.isCurrentOwner())
|
|
68
|
+
interrupt(signal);
|
|
63
69
|
},
|
|
64
70
|
]));
|
|
65
71
|
for (const [signal, handler] of handlers) {
|
|
66
72
|
signalTarget.prependListener(signal, handler);
|
|
67
73
|
}
|
|
68
74
|
try {
|
|
75
|
+
releaseInput = acquireTerminalInput(input, interrupt);
|
|
69
76
|
const result = await task(controller.signal);
|
|
70
77
|
throwIfOperationCancelled();
|
|
71
78
|
return result;
|
|
@@ -82,5 +89,6 @@ export const runWithOperationCancellation = async ({ task, signalTarget = proces
|
|
|
82
89
|
}
|
|
83
90
|
activeSignal = undefined;
|
|
84
91
|
shieldDepth = 0;
|
|
92
|
+
releaseInput?.();
|
|
85
93
|
}
|
|
86
94
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Own terminal control bytes without suspending the invoking shell's npm job. */
|
|
2
|
+
export interface TerminalInput {
|
|
3
|
+
readonly isTTY?: boolean;
|
|
4
|
+
readonly isRaw?: boolean;
|
|
5
|
+
readonly readableFlowing?: boolean | null;
|
|
6
|
+
setRawMode?(enabled: boolean): unknown;
|
|
7
|
+
resume?(): unknown;
|
|
8
|
+
pause?(): unknown;
|
|
9
|
+
prependListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
10
|
+
removeListener(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface TerminalInputLease {
|
|
13
|
+
(): void;
|
|
14
|
+
isCurrentOwner(): boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare const hasTerminalInputLease: (input: TerminalInput | null | undefined) => boolean;
|
|
17
|
+
/** Prompt libraries may restore cooked mode; resume only a still-owned lease. */
|
|
18
|
+
export declare const reassertTerminalInputLease: (input: TerminalInput | null | undefined) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Raw input turns Ctrl+C/Ctrl+Z into bytes instead of kernel signals to the
|
|
21
|
+
* entire foreground npm/shell job. Signal handlers remain responsible for
|
|
22
|
+
* externally delivered signals. The final overlapping lease restores only the
|
|
23
|
+
* mode/flow state this owner acquired; a pre-existing raw terminal stays raw.
|
|
24
|
+
*/
|
|
25
|
+
export declare const acquireTerminalInput: (input: TerminalInput | null | undefined, onInterrupt: (signal: "SIGINT" | "SIGTSTP") => void) => TerminalInputLease;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Own terminal control bytes without suspending the invoking shell's npm job. */
|
|
2
|
+
const leases = new WeakMap();
|
|
3
|
+
export const hasTerminalInputLease = (input) => !!input && leases.has(input);
|
|
4
|
+
/** Prompt libraries may restore cooked mode; resume only a still-owned lease. */
|
|
5
|
+
export const reassertTerminalInputLease = (input) => {
|
|
6
|
+
if (!input || !leases.get(input)?.interactive)
|
|
7
|
+
return;
|
|
8
|
+
if (input.isRaw !== true)
|
|
9
|
+
input.setRawMode?.(true);
|
|
10
|
+
input.resume?.();
|
|
11
|
+
};
|
|
12
|
+
const restoreInput = (input, lease) => {
|
|
13
|
+
if (!lease.interactive)
|
|
14
|
+
return;
|
|
15
|
+
const errors = [];
|
|
16
|
+
try {
|
|
17
|
+
if (lease.changedRaw)
|
|
18
|
+
input.setRawMode?.(lease.wasRaw);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
errors.push(error);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
// A previously untouched (null) stream must return to non-consuming mode,
|
|
25
|
+
// just like a previously paused stream; do not leave stdin keeping Node alive.
|
|
26
|
+
if (lease.wasFlowing)
|
|
27
|
+
input.resume?.();
|
|
28
|
+
else
|
|
29
|
+
input.pause?.();
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
errors.push(error);
|
|
33
|
+
}
|
|
34
|
+
if (errors.length === 1)
|
|
35
|
+
throw errors[0];
|
|
36
|
+
if (errors.length > 1)
|
|
37
|
+
throw new AggregateError(errors, "Terminal input restoration failed.");
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Raw input turns Ctrl+C/Ctrl+Z into bytes instead of kernel signals to the
|
|
41
|
+
* entire foreground npm/shell job. Signal handlers remain responsible for
|
|
42
|
+
* externally delivered signals. The final overlapping lease restores only the
|
|
43
|
+
* mode/flow state this owner acquired; a pre-existing raw terminal stays raw.
|
|
44
|
+
*/
|
|
45
|
+
export const acquireTerminalInput = (input, onInterrupt) => {
|
|
46
|
+
if (!input)
|
|
47
|
+
return Object.assign(() => undefined, { isCurrentOwner: () => true });
|
|
48
|
+
const owner = { interrupt: onInterrupt };
|
|
49
|
+
let lease = leases.get(input);
|
|
50
|
+
const first = !lease;
|
|
51
|
+
if (!lease) {
|
|
52
|
+
const owners = [];
|
|
53
|
+
lease = {
|
|
54
|
+
owners,
|
|
55
|
+
interactive: input.isTTY === true,
|
|
56
|
+
wasRaw: input.isRaw === true,
|
|
57
|
+
wasFlowing: input.readableFlowing === true,
|
|
58
|
+
changedRaw: input.isTTY === true &&
|
|
59
|
+
input.isRaw !== true &&
|
|
60
|
+
typeof input.setRawMode === "function",
|
|
61
|
+
dispatch: (chunk) => {
|
|
62
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
63
|
+
const signal = bytes.includes(0x03)
|
|
64
|
+
? "SIGINT"
|
|
65
|
+
: bytes.includes(0x1a)
|
|
66
|
+
? "SIGTSTP"
|
|
67
|
+
: null;
|
|
68
|
+
// Capture one owner before dispatch: synchronous release must not deliver
|
|
69
|
+
// the same byte again to an outer operation.
|
|
70
|
+
const current = owners.at(-1);
|
|
71
|
+
if (signal)
|
|
72
|
+
current?.interrupt(signal);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
leases.set(input, lease);
|
|
76
|
+
}
|
|
77
|
+
lease.owners.push(owner);
|
|
78
|
+
try {
|
|
79
|
+
if (first && lease.changedRaw)
|
|
80
|
+
input.setRawMode(true);
|
|
81
|
+
if (first && lease.interactive)
|
|
82
|
+
input.prependListener("data", lease.dispatch);
|
|
83
|
+
if (lease.interactive)
|
|
84
|
+
input.resume?.();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
const errors = [error];
|
|
88
|
+
lease.owners.splice(lease.owners.indexOf(owner), 1);
|
|
89
|
+
try {
|
|
90
|
+
if (first && lease.interactive)
|
|
91
|
+
input.removeListener("data", lease.dispatch);
|
|
92
|
+
}
|
|
93
|
+
catch (cleanupError) {
|
|
94
|
+
errors.push(cleanupError);
|
|
95
|
+
}
|
|
96
|
+
if (first) {
|
|
97
|
+
leases.delete(input);
|
|
98
|
+
try {
|
|
99
|
+
restoreInput(input, lease);
|
|
100
|
+
}
|
|
101
|
+
catch (cleanupError) {
|
|
102
|
+
errors.push(cleanupError);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (errors.length > 1)
|
|
106
|
+
throw new AggregateError(errors, "Terminal input could not be acquired or restored.");
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
let released = false;
|
|
110
|
+
const release = () => {
|
|
111
|
+
if (released)
|
|
112
|
+
return;
|
|
113
|
+
released = true;
|
|
114
|
+
lease.owners.splice(lease.owners.indexOf(owner), 1);
|
|
115
|
+
if (lease.owners.length === 0) {
|
|
116
|
+
leases.delete(input);
|
|
117
|
+
try {
|
|
118
|
+
if (lease.interactive)
|
|
119
|
+
input.removeListener("data", lease.dispatch);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
restoreInput(input, lease);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return Object.assign(release, {
|
|
127
|
+
isCurrentOwner: () => !released && lease.owners.at(-1) === owner,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Plans contain no credentials or raw owner identifiers.
|
|
4
4
|
*/
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { RehearsalError } from "../shared/diagnostics.mjs";
|
|
6
7
|
const IDENTIFIER = /^[a-z][a-z0-9_]{0,62}$/u;
|
|
7
8
|
const ENVIRONMENT_KEY = /^[A-Z][A-Z0-9_]{1,127}$/u;
|
|
8
9
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
@@ -539,7 +540,16 @@ export const assetEndpointFingerprint = (value) => {
|
|
|
539
540
|
};
|
|
540
541
|
export const assertSourceAccessConfirmation = (plan, confirmation) => {
|
|
541
542
|
if (confirmation !== plan.digest) {
|
|
542
|
-
throw new
|
|
543
|
+
throw new RehearsalError({
|
|
544
|
+
category: "configuration_invalid",
|
|
545
|
+
code: "CONFIGURATION_INVALID",
|
|
546
|
+
message: `Source access confirmation does not match this exact plan. Expected ${plan.digest}.`,
|
|
547
|
+
refused: "This confirmation check did not open a source connection or change source access.",
|
|
548
|
+
suggestions: [
|
|
549
|
+
"Run rehearsal source plan and review the complete current plan.",
|
|
550
|
+
"Only after explicit approval, rerun rehearsal source apply --confirm-source-access=<reviewed-full-sha256> using that plan's exact digest.",
|
|
551
|
+
],
|
|
552
|
+
});
|
|
543
553
|
}
|
|
544
554
|
return plan;
|
|
545
555
|
};
|
package/docs/commands.md
CHANGED
|
@@ -244,6 +244,12 @@ baseline-retention settings and fields that require manual review. They are not
|
|
|
244
244
|
baseline corruption or internal failures. Review never writes configuration or
|
|
245
245
|
executes proposed commands; unexpected faults retain their existing classification.
|
|
246
246
|
|
|
247
|
+
A wrong or stale source-access confirmation and an executable privacy policy still
|
|
248
|
+
marked as a review-required draft also return `configuration_invalid` /
|
|
249
|
+
`CONFIGURATION_INVALID` (exit `2`). Review a fresh `source plan` before confirming it;
|
|
250
|
+
complete the privacy review before retrying `baseline refresh`. These refusals do not
|
|
251
|
+
grant source access, export rows, or replace the current baseline.
|
|
252
|
+
|
|
247
253
|
Editor integrations should resolve the project's installed `@rehearsal-db/core`
|
|
248
254
|
manifest and its declared `rehearsal` binary. They should not import private package
|
|
249
255
|
files, search for a global installation, invoke through a shell, or parse styled output.
|
|
@@ -318,7 +324,8 @@ Clients should disable conflicting actions unless the state is `idle`.
|
|
|
318
324
|
|
|
319
325
|
### Cancelling a mutation
|
|
320
326
|
|
|
321
|
-
`run`, `reset`, `migrate`,
|
|
327
|
+
`run`, `reset`, `migrate`, confirmed `refresh`, and direct or guided `open` preparation support
|
|
328
|
+
package-owned cancellation.
|
|
322
329
|
Send `SIGINT`, `SIGTERM`, `SIGHUP`, or `SIGTSTP` to the Rehearsal CLI process. Rehearsal
|
|
323
330
|
handles repeated delivery as one request, forwards it only to the detached process group
|
|
324
331
|
it launched, waits for package rollback and child cleanup, releases the project lock, and
|
|
@@ -337,6 +344,14 @@ The CLI does not stop Docker itself, signal unrelated processes, prune shared re
|
|
|
337
344
|
or delete unrelated volumes. `SIGTSTP` is treated as cancellation and becomes `SIGTERM`
|
|
338
345
|
for the owned child group instead of suspending it mid-cleanup. Windows uses direct child
|
|
339
346
|
signalling; the detached process-group guarantee is supported on macOS and Linux.
|
|
347
|
+
During an interactive cancellable operation, Rehearsal temporarily owns terminal control
|
|
348
|
+
input so `Ctrl+C` and `Ctrl+Z` request cleanup without suspending an invoking npm job.
|
|
349
|
+
It restores the terminal's previous raw mode and consuming/non-consuming input state
|
|
350
|
+
after cleanup. Piped input is not consumed by this shortcut.
|
|
351
|
+
For direct or guided `open`, cancellation before the ready attached session returns this same
|
|
352
|
+
exit `130` contract, including interruption during runtime preparation or application
|
|
353
|
+
readiness. A cancellation during the ready application's handoff stops that owned
|
|
354
|
+
application before returning; cleanup failure is not reported as clean cancellation.
|
|
340
355
|
|
|
341
356
|
`SIGKILL`, host shutdown, and a force-quit cannot produce a result or run cleanup. After
|
|
342
357
|
one of those events, clients must not guess: run `activity --json`. A `stale` or `invalid`
|
|
@@ -378,6 +393,13 @@ default `keep-until-stop` leaves databases running; `stop-on-application-exit` s
|
|
|
378
393
|
with database and Storage changes preserved. Likewise, `lifecycle.run` can remain
|
|
379
394
|
`keep-until-stop` or use `stop-after-run` for bounded automation. JSON results include
|
|
380
395
|
the selected mode, final runtime state, and preserved-data guarantee.
|
|
396
|
+
While the ready application or copied-account sign-in session is attached, terminal
|
|
397
|
+
`Ctrl+C` and `Ctrl+Z` are handled as control bytes rather than suspending npm wrappers.
|
|
398
|
+
Rehearsal restores the prior terminal mode after retiring its owned application; it
|
|
399
|
+
never resumes or signals an invoking shell, npm ancestor, or unrelated job.
|
|
400
|
+
Once the ready application is attached, its handled `Ctrl+C`, `Ctrl+Z`, `SIGINT`, or
|
|
401
|
+
`SIGTSTP` closes the session with exit `0` as before; it does not also cancel an outer
|
|
402
|
+
preparation owner. Nested input ownership dispatches a control byte only once.
|
|
381
403
|
|
|
382
404
|
## Clean up disk space
|
|
383
405
|
|
package/docs/releasing.md
CHANGED
|
@@ -154,6 +154,28 @@ The independent Rehearsal Test Lab is the primary pre-release consumer environme
|
|
|
154
154
|
Real product integrations provide additional compatibility evidence; they do not
|
|
155
155
|
replace the package-owned release gate or Test Lab.
|
|
156
156
|
|
|
157
|
+
### Optional remote verification
|
|
158
|
+
|
|
159
|
+
When local resource guards refuse a heavy run, the existing full release check can
|
|
160
|
+
run on an isolated GitHub-hosted runner instead:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
gh workflow run verify.yml --ref YOUR_REVIEWED_RELEASE_BRANCH -f exact_release=true
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
This manual-only option runs `npm run release:verify` and the production dependency
|
|
167
|
+
audit. It adds no required branch check and has no publishing permission or npm
|
|
168
|
+
environment. It does not bypass a local resource guard or start local services.
|
|
169
|
+
The other existing verification jobs still run normally.
|
|
170
|
+
|
|
171
|
+
Download the run's `exact-release-<commit>-<attempt>` artifact within seven days.
|
|
172
|
+
It contains only the frozen tarball and release reports. Require a successful job
|
|
173
|
+
and all nine passed stages in `acceptance.json`, match the reviewed source commit
|
|
174
|
+
and tarball SHA-256, then use those exact bytes for consumer checks and publication
|
|
175
|
+
authorization. Reports retained after failure are failure evidence, not approval.
|
|
176
|
+
This is an execution option for the same release requirements, not a substitute for
|
|
177
|
+
consumer acceptance, supported-platform observations or human publication approval.
|
|
178
|
+
|
|
157
179
|
The workflow publishes beta and release-candidate versions under the `beta` dist-tag.
|
|
158
180
|
While Rehearsal has no stable release, it also moves `latest` to the same reviewed
|
|
159
181
|
prerelease. This keeps the npm package page and the ordinary
|
package/docs/stability.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Stability and the first stable release
|
|
2
2
|
|
|
3
3
|
Rehearsal leaves beta when the workflow it already promises is dependable. Stable
|
|
4
|
-
`
|
|
4
|
+
`1.0.0` does not require every planned database, desktop interface, or paid feature.
|
|
5
5
|
|
|
6
6
|
## What becomes a compatibility promise
|
|
7
7
|
|
|
8
|
-
Starting with `
|
|
8
|
+
Starting with `1.0.0`, Rehearsal treats these documented surfaces as public:
|
|
9
9
|
|
|
10
10
|
- CLI command names, flags, JSON result fields, and exit categories in
|
|
11
11
|
[CLI commands](commands.md);
|
|
@@ -45,21 +45,29 @@ release.
|
|
|
45
45
|
|
|
46
46
|
## Promotion sequence
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
is
|
|
51
|
-
|
|
52
|
-
1.
|
|
53
|
-
2.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
48
|
+
Promotion is evidence-based, not a calendar countdown. The initial seven-day RC
|
|
49
|
+
wait was replaced after owner review; passing time alone cannot make a candidate
|
|
50
|
+
ready. The sequence below is policy, not a claim that its checks have passed.
|
|
51
|
+
|
|
52
|
+
1. Freeze broad feature development on the release branch.
|
|
53
|
+
2. Select one immutable candidate from the reviewed release branch. A release candidate
|
|
54
|
+
may be published from protected `main` through the reviewed npm environment; another
|
|
55
|
+
prerelease is not required solely to verify the final stable tarball.
|
|
56
|
+
3. Run the complete Rehearsal Test Lab gate twice from clean isolated workspaces.
|
|
57
|
+
4. Perform one final package-only check in a real consumer using the exact frozen
|
|
58
|
+
stable tarball, not duplicate Rehearsal machinery. Preserve its reviewed settings
|
|
59
|
+
and persistent data; a package check does not require resetting its databases.
|
|
60
|
+
5. Review reports and existing scheduled Test Lab results for unresolved blockers.
|
|
61
|
+
A blocking correction needs its affected acceptance repeated against the exact
|
|
62
|
+
corrected candidate. Reuse sufficient unchanged evidence with its original scope;
|
|
63
|
+
do not relabel old or unavailable manual checks as new results. No minimum number
|
|
64
|
+
of calendar days replaces these checks.
|
|
65
|
+
6. Publish `1.0.0` only when no blocking issue remains and the exact tarball has passed
|
|
66
|
+
the required release checks, consumer acceptance, exact-artifact authorization and
|
|
67
|
+
protected human publication approval.
|
|
68
|
+
7. Independently verify registry bytes, provenance, signatures and channels, then
|
|
69
|
+
confirm the published package in the real consumer. Promotion is complete only
|
|
70
|
+
when the published artifact matches the tested tarball and these checks pass.
|
|
63
71
|
|
|
64
72
|
Test Lab is the primary verification system. A real consumer is the final compatibility
|
|
65
73
|
check, not a replacement for package-owned coverage. Real Google or GitHub consent does
|
|
@@ -69,6 +77,23 @@ not need to run on every build unless authentication behavior changes.
|
|
|
69
77
|
|
|
70
78
|
Beta and release-candidate versions publish under the `beta` tag. Before a stable
|
|
71
79
|
release exists, `latest` follows that reviewed prerelease so the ordinary install command
|
|
72
|
-
remains useful. Once stable `
|
|
80
|
+
remains useful. Once stable `1.0.0` exists, `latest` stays on the stable line and `beta`
|
|
73
81
|
continues to identify the newest prerelease. Stable publication never rewrites the
|
|
74
82
|
`beta` tag.
|
|
83
|
+
|
|
84
|
+
## Upgrading from a 0.1 release candidate
|
|
85
|
+
|
|
86
|
+
The first stable release preserves rc.10's database runtime and schema formats and
|
|
87
|
+
corrects invalid-command diagnostics. A package upgrade
|
|
88
|
+
does not require resetting a sandbox or replacing its reviewed configuration, key or
|
|
89
|
+
baseline. Automatic compatible updates do not cross the 0.x-to-1.x version boundary;
|
|
90
|
+
review the changelog and deliberately select the stable version:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install --save-dev --save-exact @rehearsal-db/core@1.0.0
|
|
94
|
+
npx --no-install rehearsal --version
|
|
95
|
+
npx --no-install rehearsal doctor
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Use the project's existing package manager and dependency location. Review its lockfile
|
|
99
|
+
and readiness output before the next sandbox operation; do not reset data just to update.
|
package/docs/tutorial.md
CHANGED
|
@@ -117,6 +117,6 @@ npm run test:fixture
|
|
|
117
117
|
The PostgreSQL equivalent is `npm run test:fixture:postgresql`.
|
|
118
118
|
|
|
119
119
|
The release workflow also runs `npm run test:fixture:onboarding` from the packed package.
|
|
120
|
-
That check creates fresh Supabase and PostgreSQL consumers, verifies
|
|
120
|
+
That check creates fresh Supabase and PostgreSQL consumers, verifies reviewed configuration
|
|
121
121
|
preservation, and exercises safe refusal and recovery for partial setup, occupied ports,
|
|
122
122
|
malformed configuration, and missing credentials.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rehearsal-db/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Catch PostgreSQL and Supabase migrations that pass on empty databases but fail on real data—before deploy.",
|
|
6
6
|
"repository": {
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"test:fixture:standalone": "npm run build && node scripts/verification/run_scenario.mjs standalone",
|
|
77
77
|
"test:fixture:privacy": "npm run build && node scripts/verification/run_scenario.mjs privacy",
|
|
78
78
|
"test:fixture:resources": "npm run build && node scripts/verification/run_scenario.mjs resources",
|
|
79
|
-
"release:verify": "node scripts/verification/release_gate.mjs",
|
|
79
|
+
"release:verify": "npm run build && node scripts/verification/release_gate.mjs",
|
|
80
80
|
"check": "npm run check:syntax && npm run typecheck && npm run test && npm run docs:build && npm run format:check && npm run package:audit",
|
|
81
81
|
"check:syntax": "find scripts tests -type f -name '*.mjs' -exec node --check {} +",
|
|
82
82
|
"typecheck": "npm run build && tsc --project tsconfig.json",
|