@rehearsal-db/core 0.1.0-rc.9 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/COMPATIBILITY.md +56 -8
  3. package/README.md +31 -12
  4. package/SECURITY.md +5 -3
  5. package/SUPPORT.md +4 -3
  6. package/dist/package.json +3 -3
  7. package/dist/scripts/install/create_root_config.mjs +9 -2
  8. package/dist/src/application/interactive_task.d.mts +2 -6
  9. package/dist/src/application/interactive_task.mjs +4 -13
  10. package/dist/src/application/session.d.mts +5 -8
  11. package/dist/src/application/session.mjs +44 -19
  12. package/dist/src/cli/arguments.mjs +8 -1
  13. package/dist/src/cli/rehearsal.mjs +27 -5
  14. package/dist/src/cli/runtime_commands.mjs +16 -17
  15. package/dist/src/cli/runtime_planning.mjs +6 -0
  16. package/dist/src/cli/terminal.mjs +24 -5
  17. package/dist/src/project/install_scaffold.mjs +7 -0
  18. package/dist/src/project/project_discovery_metadata.mjs +9 -11
  19. package/dist/src/runtime/persistence.mjs +6 -3
  20. package/dist/src/runtime/plan.mjs +26 -15
  21. package/dist/src/shared/cancellation.d.mts +3 -1
  22. package/dist/src/shared/cancellation.mjs +12 -4
  23. package/dist/src/shared/host_platform.d.mts +16 -0
  24. package/dist/src/shared/host_platform.mjs +109 -0
  25. package/dist/src/shared/process_environment.d.mts +2 -1
  26. package/dist/src/shared/process_environment.mjs +47 -5
  27. package/dist/src/shared/terminal_input.d.mts +25 -0
  28. package/dist/src/shared/terminal_input.mjs +129 -0
  29. package/docs/commands.md +17 -1
  30. package/docs/getting-started.md +48 -0
  31. package/docs/index.md +15 -9
  32. package/docs/releasing.md +50 -24
  33. package/docs/roadmap.md +8 -3
  34. package/docs/stability.md +48 -19
  35. package/docs/tutorial.md +1 -1
  36. package/docs/vscode.md +199 -41
  37. package/package.json +3 -3
@@ -14,6 +14,7 @@ import { RehearsalError, normalizeRehearsalError, redactDiagnosticValue, } from
14
14
  import { assertInstalledPackageFingerprint } from "../shared/operation_guard.mjs";
15
15
  import { getOperationCancellationSignal, throwIfOperationCancelled, } from "../shared/cancellation.mjs";
16
16
  import { runOwnedProcess } from "../shared/owned_process.mjs";
17
+ import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
17
18
  import { getOwnedOperationProgressId, trackOperationStage, } from "../shared/operation_progress.mjs";
18
19
  import { buildRuntimeManagerArguments } from "../targets/target.mjs";
19
20
  import { parseRuntimeStatusSnapshot, renderRuntimeStatusSnapshot, } from "../runtime/status.mjs";
@@ -55,23 +56,7 @@ const parseCommand = (source) => {
55
56
  throw new Error("Application proof command is empty.");
56
57
  return values;
57
58
  };
58
- const projectCommandEnvironment = (additional = {}) => ({
59
- ...Object.fromEntries([
60
- "CI",
61
- "COLORTERM",
62
- "FORCE_COLOR",
63
- "HOME",
64
- "LANG",
65
- "LC_ALL",
66
- "NO_COLOR",
67
- "PATH",
68
- "SHELL",
69
- "TERM",
70
- "TMPDIR",
71
- "USER",
72
- ].flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]])),
73
- ...additional,
74
- });
59
+ const projectCommandEnvironment = (additional = {}) => createCleanProcessEnvironment({ overrides: additional });
75
60
  const RUNTIME_STARTUP_FAILURE_PATTERN = /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;
76
61
  export const classifyManagerFailure = ({ action, output, inferredCategory, }) => {
77
62
  if (RUNTIME_STARTUP_FAILURE_PATTERN.test(output) ||
@@ -473,6 +458,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
473
458
  };
474
459
  const startRuntimeApplication = async ({ flags, planOptions, session: commandSession, onRuntimeStarted = () => undefined, }) => {
475
460
  const topology = await loadRuntimeTopology(planOptions);
461
+ throwIfOperationCancelled();
476
462
  const application = topology.primary.config.application;
477
463
  const readiness = application.readiness;
478
464
  if (!readiness) {
@@ -516,6 +502,18 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
516
502
  mappings: application.environmentVariables,
517
503
  readiness,
518
504
  }));
505
+ try {
506
+ throwIfOperationCancelled();
507
+ }
508
+ catch (error) {
509
+ try {
510
+ await applicationSession.stop();
511
+ }
512
+ catch (cleanupError) {
513
+ throw new AggregateError([error, cleanupError], "Application handoff was cancelled and its owned process could not be stopped.");
514
+ }
515
+ throw error;
516
+ }
519
517
  return {
520
518
  started: started.runtime,
521
519
  verified: verified.runtime,
@@ -527,6 +525,7 @@ export const createRuntimeCommands = ({ packageRoot, projectRoot, managerPath, g
527
525
  };
528
526
  const openRuntimeApplication = async ({ flags, planOptions, session: commandSession, onReady = () => undefined, }) => {
529
527
  const topology = await loadRuntimeTopology(planOptions);
528
+ throwIfOperationCancelled();
530
529
  let runtimeStarted = false;
531
530
  const { result, lifecycle } = await applyRuntimePersistence({
532
531
  mode: topology.primary.config.lifecycle.open,
@@ -1,6 +1,7 @@
1
1
  import { buildRehearsalPlan, runRehearsalDoctor } from "../runtime/plan.mjs";
2
2
  import { inspectRuntimeTopologyCandidates, loadRuntimeTopology, } from "../runtime/topology.mjs";
3
3
  import { redactDiagnosticValue } from "../shared/diagnostics.mjs";
4
+ import { inspectHostPlatform } from "../shared/host_platform.mjs";
4
5
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
5
6
  export const buildRuntimeStackPlan = async (planOptions) => {
6
7
  const topology = await loadRuntimeTopology(planOptions);
@@ -52,6 +53,11 @@ export const buildRuntimeStackPlan = async (planOptions) => {
52
53
  };
53
54
  };
54
55
  export const runRuntimeStackDoctor = async (planOptions) => {
56
+ const host = await inspectHostPlatform({
57
+ projectRoot: planOptions.projectRoot ?? process.cwd(),
58
+ });
59
+ if (!host.supported)
60
+ return runRehearsalDoctor(planOptions);
55
61
  let topology;
56
62
  try {
57
63
  topology = await loadRuntimeTopology(planOptions);
@@ -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
- const accepted = await prompts.confirm({ message, initialValue: false });
59
- return prompts.isCancel(accepted) ? cancelValue : accepted;
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
- prompt.close();
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)
@@ -6,6 +6,7 @@ import { access, readFile, readdir, stat } from "node:fs/promises";
6
6
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { applyRehearsalSetup, planRehearsalSetup } from "./setup.mjs";
8
8
  import { findRehearsalConfigPath, inspectDetectedProject, loadRehearsalConfig, } from "./configuration.mjs";
9
+ import { inspectHostPlatform } from "../shared/host_platform.mjs";
9
10
  const PACKAGE_NAME = "@rehearsal-db/core";
10
11
  const DEPENDENCY_GROUPS = Object.freeze([
11
12
  "dependencies",
@@ -224,6 +225,12 @@ export const createInstalledRehearsalScaffold = async ({ packageRoot, environmen
224
225
  });
225
226
  if (resolution.status !== "resolved")
226
227
  return resolution;
228
+ const host = await inspectHostPlatform({
229
+ projectRoot: resolution.projectRoot,
230
+ });
231
+ if (!host.supported) {
232
+ return skip(`${host.detail} ${host.remediation}`.replace(/\.$/u, ""));
233
+ }
227
234
  const detected = await inspectDetectedProject({
228
235
  projectRoot: resolution.projectRoot,
229
236
  });
@@ -7,6 +7,7 @@ import { lstat, readFile, readdir } from "node:fs/promises";
7
7
  import { basename, join, relative, resolve, sep } from "node:path";
8
8
  import { promisify } from "node:util";
9
9
  import { CONFIG_FILENAMES } from "./configuration_discovery.mjs";
10
+ import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
10
11
  const MAX_METADATA_BYTES = 1024 * 1024;
11
12
  const execFileAsync = promisify(execFile);
12
13
  const inside = (root, path) => {
@@ -112,17 +113,14 @@ export const probeContainerEngineWithoutStarting = async () => {
112
113
  await execFileAsync("docker", ["info", "--format", "{{.ServerVersion}}"], {
113
114
  timeout: 2_000,
114
115
  maxBuffer: 64 * 1024,
115
- env: Object.fromEntries([
116
- "COLIMA_HOME",
117
- "DOCKER_CONFIG",
118
- "DOCKER_CONTEXT",
119
- "DOCKER_HOST",
120
- "HOME",
121
- "PATH",
122
- "USER",
123
- ]
124
- .map((key) => [key, process.env[key]])
125
- .filter((entry) => Boolean(entry[1]))),
116
+ env: createCleanProcessEnvironment({
117
+ passthroughKeys: [
118
+ "COLIMA_HOME",
119
+ "DOCKER_CONFIG",
120
+ "DOCKER_CONTEXT",
121
+ "DOCKER_HOST",
122
+ ],
123
+ }),
126
124
  });
127
125
  return "running";
128
126
  }
@@ -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
- if (operationFailed) {
24
- throw new AggregateError([operationError, stopError], "The Rehearsal operation failed and its owned runtime could not be stopped automatically.");
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
  }
@@ -21,6 +21,8 @@ import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
21
21
  import { readBoundRuntimeSanitizationPolicy, validateRuntimeSanitizationPolicy, } from "../baseline/sanitization_policy.mjs";
22
22
  import { buildAuthenticationReview, buildBaselineScopeReview, } from "./project_review.mjs";
23
23
  import { readVerifiedBaselineShapeEvidence } from "../source/shape_files.mjs";
24
+ import { inspectHostPlatform } from "../shared/host_platform.mjs";
25
+ import { createCleanProcessEnvironment } from "../shared/process_environment.mjs";
24
26
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
25
27
  const hasErrorCode = (error, code) => typeof error === "object" &&
26
28
  error !== null &&
@@ -29,7 +31,7 @@ const hasErrorCode = (error, code) => typeof error === "object" &&
29
31
  const commandAvailable = (command, args = ["--version"]) => {
30
32
  const result = spawnSync(command, args, {
31
33
  encoding: "utf8",
32
- env: Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined ? [] : [[key, process.env[key]]])),
34
+ env: createCleanProcessEnvironment(),
33
35
  stdio: ["ignore", "pipe", "pipe"],
34
36
  });
35
37
  return {
@@ -378,6 +380,23 @@ export const inspectRehearsalMigrations = async (options = {}) => {
378
380
  };
379
381
  };
380
382
  export const runRehearsalDoctor = async (options = {}) => {
383
+ const host = await inspectHostPlatform({
384
+ projectRoot: options.projectRoot ?? process.cwd(),
385
+ });
386
+ if (!host.supported) {
387
+ return {
388
+ state: "NOT READY",
389
+ checks: [
390
+ {
391
+ id: "operating-system",
392
+ label: "Operating-system support",
393
+ status: "fail",
394
+ detail: host.detail,
395
+ remediation: host.remediation,
396
+ },
397
+ ],
398
+ };
399
+ }
381
400
  let loaded;
382
401
  try {
383
402
  loaded = await loadRehearsalConfig(options);
@@ -426,12 +445,9 @@ export const runRehearsalDoctor = async (options = {}) => {
426
445
  id: "operating-system",
427
446
  label: "Operating-system support",
428
447
  run: async () => {
429
- if (!["darwin", "linux"].includes(process.platform)) {
430
- throw new Error(`${process.platform} is not supported by the version-1 contract.`);
431
- }
432
- return `${process.platform} is in the supported matrix`;
448
+ return host.detail;
433
449
  },
434
- remediation: "Use macOS or Linux; WSL and native Windows require separate proof before support.",
450
+ remediation: host.remediation,
435
451
  },
436
452
  {
437
453
  id: "package-manager",
@@ -594,9 +610,7 @@ export const runRehearsalDoctor = async (options = {}) => {
594
610
  ], {
595
611
  cwd: projectRoot,
596
612
  encoding: "utf8",
597
- env: Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined
598
- ? []
599
- : [[key, process.env[key]]])),
613
+ env: createCleanProcessEnvironment(),
600
614
  stdio: ["ignore", "pipe", "pipe"],
601
615
  });
602
616
  if (status.status !== 0 ||
@@ -614,12 +628,9 @@ export const runRehearsalDoctor = async (options = {}) => {
614
628
  const status = spawnSync("supabase", ["status", "-o", "env", "--workdir", paths.runtimeWorkdir], {
615
629
  cwd: projectRoot,
616
630
  encoding: "utf8",
617
- env: {
618
- ...Object.fromEntries(["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap((key) => process.env[key] === undefined
619
- ? []
620
- : [[key, process.env[key]]])),
621
- ...serviceEnvironment,
622
- },
631
+ env: createCleanProcessEnvironment({
632
+ overrides: serviceEnvironment,
633
+ }),
623
634
  stdio: ["ignore", "pipe", "pipe"],
624
635
  });
625
636
  if (status.status !== 0 ||
@@ -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 (!controller.signal.aborted) {
61
- controller.abort(createOperationCancelledError(signal));
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,16 @@
1
+ export declare const NATIVE_WINDOWS_REMEDIATION = "Use WSL2, not a native Windows terminal: run wsl --install -d Ubuntu in PowerShell and confirm version 2 with wsl --list --verbose (upgrade with wsl --set-version Ubuntu 2 if needed). Enable Docker Desktop Settings > Resources > WSL integration for that distribution. Keep the project in its Linux home filesystem (for example ~/projects, not /mnt/c), install a supported Linux Node.js version and project tools there, open it with VS Code Remote - WSL, then rerun rehearsal doctor inside WSL2.";
2
+ export interface HostPlatformInspection {
3
+ readonly kind: "macos" | "linux" | "wsl2" | "wsl-unverified" | "windows" | "other";
4
+ readonly supported: boolean;
5
+ readonly detail: string;
6
+ readonly remediation: string;
7
+ }
8
+ export interface HostPlatformOptions {
9
+ readonly projectRoot?: string;
10
+ readonly platform?: NodeJS.Platform;
11
+ readonly kernelRelease?: string;
12
+ readonly readKernelRelease?: () => Promise<string>;
13
+ readonly inspectFilesystem?: (path: string) => Promise<number>;
14
+ }
15
+ export declare const inspectHostPlatform: ({ projectRoot, platform, kernelRelease, readKernelRelease, inspectFilesystem, }?: HostPlatformOptions) => Promise<HostPlatformInspection>;
16
+ export declare const assertSupportedHostPlatform: (options?: HostPlatformOptions) => Promise<void>;
@@ -0,0 +1,109 @@
1
+ /** Read-only host capability checks; never substitute environment hints for proof. */
2
+ import { open, statfs } from "node:fs/promises";
3
+ import { release } from "node:os";
4
+ import { RehearsalError } from "./diagnostics.mjs";
5
+ export const NATIVE_WINDOWS_REMEDIATION = "Use WSL2, not a native Windows terminal: run wsl --install -d Ubuntu in PowerShell and confirm version 2 with wsl --list --verbose (upgrade with wsl --set-version Ubuntu 2 if needed). Enable Docker Desktop Settings > Resources > WSL integration for that distribution. Keep the project in its Linux home filesystem (for example ~/projects, not /mnt/c), install a supported Linux Node.js version and project tools there, open it with VS Code Remote - WSL, then rerun rehearsal doctor inside WSL2.";
6
+ const readProcKernelRelease = async () => {
7
+ const file = await open("/proc/sys/kernel/osrelease", "r");
8
+ try {
9
+ const buffer = Buffer.alloc(512);
10
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
11
+ return buffer.subarray(0, bytesRead).toString("utf8");
12
+ }
13
+ finally {
14
+ await file.close();
15
+ }
16
+ };
17
+ // WSL's Windows drive mounts do not provide the private modes and atomic symlink
18
+ // contract used by baselines. Accept proven Linux filesystem types, not paths.
19
+ const WSL_POSIX_FILESYSTEM_TYPES = new Set([
20
+ 0xef53, // ext2/ext3/ext4 (WSL2 distribution filesystem)
21
+ 0x58465342, // XFS
22
+ 0x9123683e, // Btrfs
23
+ ]);
24
+ export const inspectHostPlatform = async ({ projectRoot = process.cwd(), platform = process.platform, kernelRelease = release(), readKernelRelease = readProcKernelRelease, inspectFilesystem = async (path) => (await statfs(path)).type, } = {}) => {
25
+ if (platform === "darwin") {
26
+ return {
27
+ kind: "macos",
28
+ supported: true,
29
+ detail: "macOS is in the supported host matrix; project readiness checks still apply.",
30
+ remediation: "Resolve the remaining project readiness checks on macOS.",
31
+ };
32
+ }
33
+ if (platform === "win32") {
34
+ return {
35
+ kind: "windows",
36
+ supported: false,
37
+ detail: "Native Windows runtime and preparation operations are unsupported: POSIX private-file, process-group, and atomic baseline-pointer guarantees have not been established.",
38
+ remediation: NATIVE_WINDOWS_REMEDIATION,
39
+ };
40
+ }
41
+ if (platform !== "linux") {
42
+ return {
43
+ kind: "other",
44
+ supported: false,
45
+ detail: "This operating system is outside the verified macOS/Linux runtime contract.",
46
+ remediation: "Use a supported macOS or Linux environment. Windows users should use WSL2 with the project on its Linux home filesystem and Docker Desktop WSL integration.",
47
+ };
48
+ }
49
+ let procRelease = "";
50
+ try {
51
+ procRelease = (await readKernelRelease()).slice(0, 512);
52
+ }
53
+ catch {
54
+ // os.release() is independent kernel evidence when procfs is unavailable.
55
+ }
56
+ const evidence = `${kernelRelease.slice(0, 512)}\n${procRelease}`;
57
+ if (!/microsoft|\bwsl2?\b/iu.test(evidence)) {
58
+ return {
59
+ kind: "linux",
60
+ supported: true,
61
+ detail: "Linux is in the supported host matrix; project readiness checks still apply.",
62
+ remediation: "Resolve the remaining project readiness checks on Linux.",
63
+ };
64
+ }
65
+ if (!/microsoft-standard|\bwsl2\b/iu.test(evidence)) {
66
+ return {
67
+ kind: "wsl-unverified",
68
+ supported: false,
69
+ detail: "A WSL kernel was detected, but WSL2 kernel capabilities were not proven. WSL1 is unsupported.",
70
+ remediation: NATIVE_WINDOWS_REMEDIATION,
71
+ };
72
+ }
73
+ let filesystemType = null;
74
+ try {
75
+ filesystemType = await inspectFilesystem(projectRoot);
76
+ }
77
+ catch {
78
+ // Unknown filesystem evidence must not be interpreted as POSIX capability.
79
+ }
80
+ if (filesystemType === null ||
81
+ !WSL_POSIX_FILESYSTEM_TYPES.has(filesystemType)) {
82
+ return {
83
+ kind: "wsl2",
84
+ supported: false,
85
+ detail: "WSL2 was detected, but this project is not on a proven Linux filesystem. Windows drive mounts and unknown filesystem types are unsupported for private baseline data.",
86
+ remediation: NATIVE_WINDOWS_REMEDIATION,
87
+ };
88
+ }
89
+ return {
90
+ kind: "wsl2",
91
+ supported: true,
92
+ detail: "WSL2 kernel and a Linux project filesystem were detected. Run in VS Code Remote - WSL with Linux tools and Docker Desktop WSL integration; the remaining readiness and private-file checks still apply.",
93
+ remediation: NATIVE_WINDOWS_REMEDIATION,
94
+ };
95
+ };
96
+ export const assertSupportedHostPlatform = async (options = {}) => {
97
+ const inspection = await inspectHostPlatform(options);
98
+ if (inspection.supported)
99
+ return;
100
+ throw new RehearsalError({
101
+ category: "unsafe_environment",
102
+ code: "HOST_PLATFORM_UNSUPPORTED",
103
+ message: inspection.detail,
104
+ expected: "A supported macOS/Linux runtime with its existing safety guarantees.",
105
+ actual: inspection.kind,
106
+ refused: "No project configuration, child process, or runtime operation was started.",
107
+ suggestions: [inspection.remediation],
108
+ });
109
+ };
@@ -8,8 +8,9 @@ export interface CleanProcessEnvironmentOptions {
8
8
  inheritedEnvironment?: ProcessEnvironment;
9
9
  overrides?: ProcessEnvironment;
10
10
  passthroughKeys?: readonly string[];
11
+ platform?: NodeJS.Platform;
11
12
  }
12
13
  export declare const isLoopbackUrl: (value: string) => boolean;
13
14
  export declare const assertLoopbackUrl: (label: string, value: string) => void;
14
15
  export declare const pickEnvironmentVariables: (source: ProcessEnvironment, keys: readonly string[]) => Record<string, string>;
15
- export declare const createCleanProcessEnvironment: ({ inheritedEnvironment, overrides, passthroughKeys, }?: CleanProcessEnvironmentOptions) => Record<string, string>;
16
+ export declare const createCleanProcessEnvironment: ({ inheritedEnvironment, overrides, passthroughKeys, platform, }?: CleanProcessEnvironmentOptions) => Record<string, string>;
@@ -21,6 +21,17 @@ const SAFE_SYSTEM_ENVIRONMENT_KEYS = Object.freeze([
21
21
  "TEMP",
22
22
  "USER",
23
23
  ]);
24
+ // Windows resolves environment names case-insensitively. Keep only infrastructure
25
+ // needed to locate executables and user configuration, never ambient credentials.
26
+ const SAFE_WINDOWS_ENVIRONMENT_KEYS = Object.freeze([
27
+ "SystemRoot",
28
+ "WINDIR",
29
+ "ComSpec",
30
+ "PATHEXT",
31
+ "USERPROFILE",
32
+ "APPDATA",
33
+ "LOCALAPPDATA",
34
+ ]);
24
35
  const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]);
25
36
  export const isLoopbackUrl = (value) => {
26
37
  try {
@@ -41,10 +52,41 @@ export const pickEnvironmentVariables = (source, keys) => Object.fromEntries(key
41
52
  const value = source[key];
42
53
  return value === undefined ? [] : [[key, value]];
43
54
  }));
44
- export const createCleanProcessEnvironment = ({ inheritedEnvironment = process.env, overrides = {}, passthroughKeys = [], } = {}) => ({
45
- ...pickEnvironmentVariables(inheritedEnvironment, [
55
+ const pickWindowsEnvironmentVariables = (source, keys) => {
56
+ const selected = {};
57
+ const seen = new Set();
58
+ for (const key of keys) {
59
+ const normalized = key.toUpperCase();
60
+ if (seen.has(normalized))
61
+ continue;
62
+ seen.add(normalized);
63
+ const sourceKey = source[key] !== undefined
64
+ ? key
65
+ : Object.keys(source).find((candidate) => candidate.toUpperCase() === normalized &&
66
+ source[candidate] !== undefined);
67
+ if (sourceKey !== undefined)
68
+ selected[key] = source[sourceKey];
69
+ }
70
+ return selected;
71
+ };
72
+ export const createCleanProcessEnvironment = ({ inheritedEnvironment = process.env, overrides = {}, passthroughKeys = [], platform = process.platform, } = {}) => {
73
+ const keys = [
46
74
  ...SAFE_SYSTEM_ENVIRONMENT_KEYS,
75
+ ...(platform === "win32" ? SAFE_WINDOWS_ENVIRONMENT_KEYS : []),
47
76
  ...passthroughKeys,
48
- ]),
49
- ...pickEnvironmentVariables(overrides, Object.keys(overrides)),
50
- });
77
+ ];
78
+ if (platform !== "win32") {
79
+ return {
80
+ ...pickEnvironmentVariables(inheritedEnvironment, keys),
81
+ ...pickEnvironmentVariables(overrides, Object.keys(overrides)),
82
+ };
83
+ }
84
+ const environment = pickWindowsEnvironmentVariables(inheritedEnvironment, keys);
85
+ for (const [key, value] of Object.entries(overrides)) {
86
+ if (value === undefined)
87
+ continue;
88
+ const existing = Object.keys(environment).find((candidate) => candidate.toUpperCase() === key.toUpperCase());
89
+ environment[existing ?? key] = value;
90
+ }
91
+ return environment;
92
+ };
@@ -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;