@aefree/pi-plastic 0.7.6 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +48 -174
  3. package/docs/2026-10-06-development.md +101 -0
  4. package/docs/2026-10-06-diffs-and-patches.md +136 -0
  5. package/docs/2026-10-06-discovery.md +135 -0
  6. package/docs/2026-10-06-integrations.md +49 -0
  7. package/docs/2026-10-06-merging.md +153 -0
  8. package/docs/2026-10-06-safety-and-receipts.md +95 -0
  9. package/docs/2026-10-06-setup.md +101 -0
  10. package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
  11. package/docs/2026-10-06-status.md +130 -0
  12. package/docs/2026-10-06-tools-and-loading.md +64 -0
  13. package/docs/2026-10-06-workspace-operations.md +142 -0
  14. package/docs/README.md +65 -0
  15. package/extensions/bash-cm-diff-guard.ts +1 -1
  16. package/index.ts +4 -687
  17. package/package.json +10 -4
  18. package/skills/using-plastic/SKILL.md +11 -5
  19. package/skills/using-plastic/references/branch-management.md +5 -1
  20. package/skills/using-plastic/references/changeset-operations.md +9 -8
  21. package/skills/using-plastic/references/code-review-creation.md +25 -18
  22. package/skills/using-plastic/references/integration.md +9 -5
  23. package/skills/using-plastic/references/quick-reference.md +4 -4
  24. package/skills/using-plastic/references/reviewing-changes.md +10 -10
  25. package/skills/using-plastic/references/shelving.md +26 -11
  26. package/skills/using-plastic/references/troubleshooting.md +4 -4
  27. package/src/diff/local-snapshot.ts +74 -0
  28. package/src/diff/text.ts +187 -0
  29. package/src/domain/add-contract.ts +24 -0
  30. package/src/domain/branch-create-contract.ts +38 -0
  31. package/src/domain/branch-delete-contract.ts +15 -0
  32. package/src/domain/branches.ts +263 -0
  33. package/src/domain/checkin-contract.ts +70 -0
  34. package/src/domain/closeout-contract.ts +30 -0
  35. package/src/domain/copied-merge.ts +42 -0
  36. package/src/domain/diff-base-xml.ts +65 -0
  37. package/src/domain/diff-pending-xml.ts +34 -0
  38. package/src/domain/merge-output.ts +327 -0
  39. package/src/domain/paths.ts +253 -0
  40. package/src/domain/pending.ts +499 -0
  41. package/src/domain/removal-contract.ts +25 -0
  42. package/src/domain/revisions.ts +301 -0
  43. package/src/domain/server-merge-contract.ts +45 -0
  44. package/src/domain/server-merge-resolutions.ts +61 -0
  45. package/src/domain/status-xml.ts +122 -0
  46. package/src/domain/switch-contract.ts +60 -0
  47. package/src/domain/switch-policy.ts +95 -0
  48. package/src/domain/undo-contract.ts +24 -0
  49. package/src/domain/update-contract.ts +16 -0
  50. package/src/domain/workspace-merge-contract.ts +41 -0
  51. package/src/execution/add-command.ts +118 -0
  52. package/src/execution/branch-create-command.ts +118 -0
  53. package/src/execution/branch-list-command.ts +75 -0
  54. package/src/execution/checkin-command.ts +116 -0
  55. package/src/execution/cli-version.ts +31 -0
  56. package/src/execution/cm.ts +59 -0
  57. package/src/execution/code-review-find-command.ts +75 -0
  58. package/src/execution/context.ts +20 -0
  59. package/src/execution/diff-revisions-command.ts +74 -0
  60. package/src/execution/process.ts +260 -0
  61. package/src/execution/removal-command.ts +118 -0
  62. package/src/execution/server-merge-command.ts +92 -0
  63. package/src/execution/shelveset-list-command.ts +75 -0
  64. package/src/execution/status-command.ts +29 -0
  65. package/src/execution/status-xml-command.ts +72 -0
  66. package/src/execution/status-xml-limits.ts +1 -0
  67. package/src/execution/switch-command.ts +118 -0
  68. package/src/execution/test-seams.ts +10 -0
  69. package/src/execution/undo-command.ts +118 -0
  70. package/src/execution/update-command.ts +118 -0
  71. package/src/execution/workspace-list-command.ts +75 -0
  72. package/src/execution/workspace-merge-command.ts +118 -0
  73. package/src/operations/add-receipt.ts +37 -0
  74. package/src/operations/arguments.ts +3 -0
  75. package/src/operations/branch-create.ts +74 -0
  76. package/src/operations/branch-delete-receipt.ts +25 -0
  77. package/src/operations/branch-list.ts +94 -0
  78. package/src/operations/branch-reads.ts +94 -0
  79. package/src/operations/branches.ts +111 -0
  80. package/src/operations/checkin-receipt.ts +140 -0
  81. package/src/operations/checkin.ts +102 -0
  82. package/src/operations/closeout-receipt.ts +74 -0
  83. package/src/operations/closeout.ts +19 -0
  84. package/src/operations/code-review-find.ts +97 -0
  85. package/src/operations/consolidated-diff.ts +144 -0
  86. package/src/operations/diff-revisions.ts +157 -0
  87. package/src/operations/diff.ts +53 -0
  88. package/src/operations/merge.ts +26 -0
  89. package/src/operations/object-delete-receipt.ts +21 -0
  90. package/src/operations/object-write-receipt.ts +105 -0
  91. package/src/operations/patch-receipt.ts +96 -0
  92. package/src/operations/patch-support.ts +375 -0
  93. package/src/operations/patch.ts +25 -0
  94. package/src/operations/removal-receipt.ts +42 -0
  95. package/src/operations/reviews.ts +90 -0
  96. package/src/operations/server-merge.ts +130 -0
  97. package/src/operations/shelveset-list.ts +90 -0
  98. package/src/operations/shelvesets.ts +90 -0
  99. package/src/operations/status.ts +129 -0
  100. package/src/operations/switch-receipt.ts +85 -0
  101. package/src/operations/switch.ts +25 -0
  102. package/src/operations/undo-receipt.ts +37 -0
  103. package/src/operations/update-receipt.ts +35 -0
  104. package/src/operations/workspace-list.ts +92 -0
  105. package/src/operations/workspace-merge-receipt.ts +50 -0
  106. package/src/operations/workspace.ts +120 -0
  107. package/src/pi/add-output.ts +52 -0
  108. package/src/pi/arguments.ts +298 -0
  109. package/src/pi/branch-create-output.ts +83 -0
  110. package/src/pi/branch-delete-output.ts +46 -0
  111. package/src/pi/branch-list-output.ts +59 -0
  112. package/src/pi/branch-output.ts +63 -0
  113. package/src/pi/capability-diagnostics.ts +23 -0
  114. package/src/pi/checkin-output.ts +68 -0
  115. package/src/pi/closeout-output.ts +87 -0
  116. package/src/pi/code-review-find-output.ts +50 -0
  117. package/src/pi/diff-output.ts +66 -0
  118. package/src/pi/diff-revisions-output.ts +55 -0
  119. package/src/pi/object-delete-output.ts +15 -0
  120. package/src/pi/object-write-output.ts +54 -0
  121. package/src/pi/patch-output.ts +69 -0
  122. package/src/pi/register.ts +188 -0
  123. package/src/pi/removal-output.ts +59 -0
  124. package/src/pi/schemas.ts +146 -0
  125. package/src/pi/server-merge-output.ts +83 -0
  126. package/src/pi/shelveset-list-output.ts +50 -0
  127. package/src/pi/status-output.ts +204 -0
  128. package/src/pi/switch-output.ts +80 -0
  129. package/src/pi/tool-registry.ts +72 -0
  130. package/src/pi/undo-output.ts +52 -0
  131. package/src/pi/update-output.ts +48 -0
  132. package/src/pi/workspace-list-output.ts +53 -0
  133. package/src/pi/workspace-merge-output.ts +46 -0
  134. package/src/plastic-core.ts +14 -5775
  135. package/src/plastic-renderers.ts +102 -22
  136. package/src/plastic-tool-loading.ts +7 -12
  137. package/src/presentation/add-results.ts +5 -0
  138. package/src/presentation/branch-create.ts +7 -0
  139. package/src/presentation/branch-delete-results.ts +7 -0
  140. package/src/presentation/checkin-results.ts +14 -0
  141. package/src/presentation/closeout-results.ts +2 -0
  142. package/src/presentation/diff-results.ts +237 -0
  143. package/src/presentation/removal-results.ts +7 -0
  144. package/src/presentation/results.ts +84 -0
  145. package/src/presentation/switch-results.ts +7 -0
  146. package/src/presentation/undo-results.ts +5 -0
  147. package/src/presentation/update-results.ts +5 -0
  148. package/src/presentation/workspace-merge-results.ts +6 -0
@@ -0,0 +1,31 @@
1
+ import { runCmRaw, normalizeFindOutputLines } from "./cm";
2
+
3
+ let cachedCmVersion: string | null = null;
4
+ let cmVersionPromise: Promise<string> | null = null;
5
+
6
+ export const getCmVersion = async (workdir?: string): Promise<string> =>
7
+ {
8
+ if (cachedCmVersion)
9
+ {
10
+ return cachedCmVersion;
11
+ }
12
+
13
+ if (!cmVersionPromise)
14
+ {
15
+ cmVersionPromise = runCmRaw(["version"], workdir)
16
+ .then((value) =>
17
+ {
18
+ const parsed = normalizeFindOutputLines(value)[0] ?? "unknown";
19
+ cachedCmVersion = parsed;
20
+ cmVersionPromise = null;
21
+ return parsed;
22
+ })
23
+ .catch(() =>
24
+ {
25
+ cmVersionPromise = null;
26
+ return "unknown";
27
+ });
28
+ }
29
+
30
+ return cmVersionPromise;
31
+ };
@@ -0,0 +1,59 @@
1
+ import { getActiveAbortSignal, commandExecutionStorage } from "./context";
2
+ import { getCmExecutable, spawnAndCollect } from "./process";
3
+
4
+ export const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` when only changed paths are needed. Do not diff routinely; when change-boundary evidence is necessary, use `plastic_diff` with explicit mode=file, revisions, or workspace and bounded scope.";
5
+
6
+ export const ensureCmCommandAllowed = (args: string[]): void =>
7
+ {
8
+ const command = (args[0] ?? "").trim().toLowerCase();
9
+ if (command === "diff" || command === "differences")
10
+ {
11
+ throw new Error(BLOCKED_CM_DIFF_MESSAGE);
12
+ }
13
+ };
14
+
15
+ export const runCm = async (args: string[], workdir?: string, input?: string, signal: AbortSignal | undefined = getActiveAbortSignal()): Promise<string> =>
16
+ {
17
+ ensureCmCommandAllowed(args);
18
+ const cwd = workdir ?? process.cwd();
19
+ const { stdout, stderr, exitCode, aborted } = await spawnAndCollect(getCmExecutable(), args, cwd, input, signal, commandExecutionStorage.getStore());
20
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
21
+
22
+ if (aborted)
23
+ {
24
+ throw new Error(output.length > 0 ? `cm ${args.join(" ")} aborted.\n\n${output}` : `cm ${args.join(" ")} aborted.`);
25
+ }
26
+
27
+ if (exitCode !== 0)
28
+ {
29
+ throw new Error(output.length > 0 ? output : `cm ${args.join(" ")} failed with exit code ${exitCode}`);
30
+ }
31
+
32
+ return output.length > 0 ? output : "(no output)";
33
+ };
34
+
35
+ export const runCmRaw = async (args: string[], workdir?: string, input?: string, signal: AbortSignal | undefined = getActiveAbortSignal()): Promise<string> =>
36
+ {
37
+ ensureCmCommandAllowed(args);
38
+ const cwd = workdir ?? process.cwd();
39
+ const { stdout, stderr, exitCode, aborted } = await spawnAndCollect(getCmExecutable(), args, cwd, input, signal, commandExecutionStorage.getStore());
40
+
41
+ if (aborted)
42
+ {
43
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
44
+ throw new Error(output.length > 0 ? `cm ${args.join(" ")} aborted.\n\n${output}` : `cm ${args.join(" ")} aborted.`);
45
+ }
46
+
47
+ if (exitCode !== 0)
48
+ {
49
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
50
+ throw new Error(output.length > 0 ? output : `cm ${args.join(" ")} failed with exit code ${exitCode}`);
51
+ }
52
+
53
+ return stdout ?? "";
54
+ };
55
+
56
+ export const normalizeFindOutputLines = (output: string): string[] => output
57
+ .split(/\r?\n/)
58
+ .map((line) => line.trim())
59
+ .filter((line) => line.length > 0 && line !== "(no output)");
@@ -0,0 +1,75 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ export const CODE_REVIEW_FIND_CAPTURE_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, timeoutMs: 30000, records: 20000 } as const;
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
5
+ import { getCmExecutable } from "./process";
6
+ export class CodeReviewFindCommandError extends Error {
7
+ constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") { super(`Code review find read failed (${code}).`); }
8
+ }
9
+
10
+ // Code-review-find-only strict byte transport. Native/default and other read routes are unchanged.
11
+ export async function runCodeReviewFindIdsCommand(args: string[], workdir?: string): Promise<Buffer> {
12
+ const signal = getActiveAbortSignal();
13
+ if (signal?.aborted) throw new CodeReviewFindCommandError("aborted");
14
+ const dependencies = commandExecutionStorage.getStore() ?? {};
15
+ let child;
16
+ try { child = (dependencies.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
17
+ catch { throw new CodeReviewFindCommandError("command_failed"); }
18
+ const schedule = dependencies.setTimeout ?? setTimeout;
19
+ const cancel = dependencies.clearTimeout ?? clearTimeout;
20
+ let aborted = false, incomplete = false, failed = false, settled = false;
21
+ let killTimer: NodeJS.Timeout | undefined;
22
+ const terminate = () => {
23
+ if (settled) return;
24
+ try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
25
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
26
+ };
27
+ const collectors: Array<{ dispose(): void }> = [];
28
+ const collect = (stream: Readable | null, cap: number) => {
29
+ let length = 0;
30
+ const chunks: Buffer[] = [];
31
+ if (!stream) { failed = true; return Promise.resolve(Buffer.alloc(0)); }
32
+ if (stream.readableEncoding) { failed = true; terminate(); }
33
+ return new Promise<Buffer>(resolve => {
34
+ let done = false;
35
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
36
+ const data = (chunk: unknown) => {
37
+ // Already-decoded streams cannot attest exact source bytes.
38
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
39
+ const available = cap - length;
40
+ if (chunk.byteLength > available) { incomplete = true; terminate(); }
41
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
42
+ // Keep draining beyond the cap until process/streams settle.
43
+ };
44
+ const error = () => { failed = true; terminate(); finish(); };
45
+ const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
46
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
47
+ collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
48
+ });
49
+ };
50
+ let onClose: (code: number | null) => void;
51
+ let onError: () => void;
52
+ const exit = new Promise<number>(resolve => {
53
+ onClose = code => { settled = true; resolve(code ?? 1); };
54
+ onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
55
+ child.once("close", onClose); child.once("error", onError);
56
+ });
57
+ const stdout = collect(child.stdout, CODE_REVIEW_FIND_CAPTURE_LIMITS.stdoutBytes);
58
+ const stderr = collect(child.stderr, CODE_REVIEW_FIND_CAPTURE_LIMITS.stderrBytes);
59
+ const onAbort = () => { aborted = true; terminate(); };
60
+ signal?.addEventListener("abort", onAbort, { once: true });
61
+ const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? CODE_REVIEW_FIND_CAPTURE_LIMITS.timeoutMs, CODE_REVIEW_FIND_CAPTURE_LIMITS.timeoutMs));
62
+ if (signal?.aborted) onAbort();
63
+ try {
64
+ const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
65
+ if (aborted || signal?.aborted) throw new CodeReviewFindCommandError("aborted");
66
+ if (incomplete) throw new CodeReviewFindCommandError("capture_incomplete");
67
+ if (failed || code !== 0 || errors.length) throw new CodeReviewFindCommandError("command_failed");
68
+ return bytes;
69
+ } finally {
70
+ cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
71
+ signal?.removeEventListener("abort", onAbort);
72
+ child.removeListener("close", onClose!); child.removeListener("error", onError!);
73
+ for (const collector of collectors) collector.dispose();
74
+ }
75
+ }
@@ -0,0 +1,20 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { SpawnAndCollectDependencies } from "./process";
3
+
4
+ const abortSignalStorage = new AsyncLocalStorage<AbortSignal | undefined>();
5
+ export const commandExecutionStorage = new AsyncLocalStorage<SpawnAndCollectDependencies | undefined>();
6
+
7
+ export const getActiveAbortSignal = (): AbortSignal | undefined => abortSignalStorage.getStore();
8
+
9
+ export const runWithAbortSignal = async <T>(
10
+ signal: AbortSignal | undefined,
11
+ fn: () => Promise<T>,
12
+ commandExecution?: SpawnAndCollectDependencies,
13
+ ): Promise<T> =>
14
+ {
15
+ const inheritedSignal = getActiveAbortSignal();
16
+ const inheritedExecution = commandExecutionStorage.getStore();
17
+ // Nested public-tool execution must retain an injected process seam and the
18
+ // outer abort signal instead of silently dropping either boundary.
19
+ return abortSignalStorage.run(signal ?? inheritedSignal, () => commandExecutionStorage.run(commandExecution ?? inheritedExecution, fn));
20
+ };
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ export const DIFF_REVISIONS_CAPTURE_LIMITS = { stdoutBytes: 1048576, stderrBytes: 65536, timeoutMs: 30000 } as const;
4
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
5
+ export class DiffRevisionsCommandError extends Error {
6
+ constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete") { super(`Revision comparison read failed (${code}).`); }
7
+ }
8
+
9
+ // Revision-comparison-only strict byte transport. Native/default and other read routes are unchanged.
10
+ export async function runDiffRevisionsCommand(executable: string, args: string[], cwd: string, allowedExitCodes: readonly number[]): Promise<{ stdout: Buffer; exitCode: number }> {
11
+ const signal = getActiveAbortSignal();
12
+ if (signal?.aborted) throw new DiffRevisionsCommandError("aborted");
13
+ const dependencies = commandExecutionStorage.getStore() ?? {};
14
+ let child;
15
+ try { child = (dependencies.spawn ?? spawn)(executable, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
16
+ catch { throw new DiffRevisionsCommandError("command_failed"); }
17
+ const schedule = dependencies.setTimeout ?? setTimeout;
18
+ const cancel = dependencies.clearTimeout ?? clearTimeout;
19
+ let aborted = false, incomplete = false, failed = false, settled = false;
20
+ let killTimer: NodeJS.Timeout | undefined;
21
+ const terminate = () => {
22
+ if (settled) return;
23
+ try { child.kill("SIGTERM"); } catch { /* process may already have exited */ }
24
+ if (killTimer === undefined) killTimer = schedule(() => { if (!settled) { try { child.kill("SIGKILL"); } catch { /* process may already have exited */ } } }, dependencies.abortKillDelayMs ?? 5000);
25
+ };
26
+ const collectors: Array<{ dispose(): void }> = [];
27
+ const collect = (stream: Readable | null, cap: number) => {
28
+ let length = 0;
29
+ const chunks: Buffer[] = [];
30
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
31
+ if (stream.readableEncoding) { failed = true; terminate(); }
32
+ return new Promise<Buffer>(resolve => {
33
+ let done = false;
34
+ const finish = () => { if (!done) { done = true; resolve(Buffer.concat(chunks, length)); } };
35
+ const data = (chunk: unknown) => {
36
+ // Already-decoded streams cannot attest exact source bytes.
37
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
38
+ const available = cap - length;
39
+ if (chunk.byteLength > available) { incomplete = true; terminate(); }
40
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); chunks.push(part); length += part.length; }
41
+ // Keep draining beyond the cap until process/streams settle.
42
+ };
43
+ const error = () => { failed = true; terminate(); finish(); };
44
+ const closed = () => { if (!done && !stream.readableEnded) failed = true; finish(); };
45
+ stream.on("data", data); stream.once("end", finish); stream.once("error", error); stream.once("close", closed);
46
+ collectors.push({ dispose: () => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", closed); } });
47
+ });
48
+ };
49
+ let onClose: (code: number | null, signal?: string | null) => void;
50
+ let onError: () => void;
51
+ const exit = new Promise<number>(resolve => {
52
+ onClose = (code, signal) => { settled = true; if (code === null || signal) failed = true; resolve(code ?? -1); };
53
+ onError = () => { failed = true; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve(1); };
54
+ child.once("close", onClose); child.once("error", onError);
55
+ });
56
+ const stdout = collect(child.stdout, DIFF_REVISIONS_CAPTURE_LIMITS.stdoutBytes);
57
+ const stderr = collect(child.stderr, DIFF_REVISIONS_CAPTURE_LIMITS.stderrBytes);
58
+ const onAbort = () => { aborted = true; terminate(); };
59
+ signal?.addEventListener("abort", onAbort, { once: true });
60
+ const timeout = schedule(() => { incomplete = true; terminate(); }, Math.min(dependencies.timeoutMs ?? DIFF_REVISIONS_CAPTURE_LIMITS.timeoutMs, DIFF_REVISIONS_CAPTURE_LIMITS.timeoutMs));
61
+ if (signal?.aborted) onAbort();
62
+ try {
63
+ const [code, bytes, errors] = await Promise.all([exit, stdout, stderr]);
64
+ if (aborted || signal?.aborted) throw new DiffRevisionsCommandError("aborted");
65
+ if (incomplete) throw new DiffRevisionsCommandError("capture_incomplete");
66
+ if (failed || !allowedExitCodes.includes(code) || errors.length) throw new DiffRevisionsCommandError("command_failed");
67
+ return { stdout: bytes, exitCode: code };
68
+ } finally {
69
+ cancel(timeout); if (killTimer !== undefined) cancel(killTimer);
70
+ signal?.removeEventListener("abort", onAbort);
71
+ child.removeListener("close", onClose!); child.removeListener("error", onError!);
72
+ for (const collector of collectors) collector.dispose();
73
+ }
74
+ }
@@ -0,0 +1,260 @@
1
+ import { spawn } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ export type SpawnResult = {
5
+ stdout: string;
6
+ stderr: string;
7
+ exitCode: number;
8
+ aborted: boolean;
9
+ timedOut?: boolean;
10
+ stdoutTruncated?: boolean;
11
+ stdoutTotalChars?: number;
12
+ stderrTruncated?: boolean;
13
+ stderrTotalChars?: number;
14
+ };
15
+
16
+ export type ExecutableEnvironment = Record<string, string | undefined>;
17
+
18
+ export const resolveExecutable = (environment: ExecutableEnvironment, overrideVariable: string, fallback: string): string =>
19
+ {
20
+ const override = environment[overrideVariable]?.trim();
21
+ return override || fallback;
22
+ };
23
+
24
+ export const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_CM_EXECUTABLE", "cm");
25
+ // `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
26
+ // Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
27
+ export const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
28
+ export const executableNotFoundError = (command: string, error: Error): Error =>
29
+ {
30
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT")
31
+ {
32
+ return error;
33
+ }
34
+
35
+ if (command === getCmExecutable())
36
+ {
37
+ return new Error(`Unable to launch Plastic SCM executable '${command}' (ENOENT). Set PI_PLASTIC_CM_EXECUTABLE to the full path to cm, or add cm to PATH.`);
38
+ }
39
+
40
+ if (command === getDiffExecutable())
41
+ {
42
+ return new Error(`Unable to launch GNU/POSIX text diff executable '${command}' (ENOENT). Set PI_PLASTIC_DIFF_EXECUTABLE to the full path of GNU/POSIX diff (paths containing spaces are supported), or install bare diff on PATH. Pi does not discover Git Bash paths automatically.`);
43
+ }
44
+
45
+ return new Error(`Unable to launch executable '${command}' (ENOENT). Verify that it is installed and available on PATH.`);
46
+ };
47
+
48
+ export const writeInput = async (stdin: NodeJS.WritableStream | null | undefined, input: string): Promise<void> =>
49
+ {
50
+ if (!stdin)
51
+ {
52
+ return;
53
+ }
54
+
55
+ await new Promise<void>((resolvePromise, rejectPromise) =>
56
+ {
57
+ stdin.write(`${input}\n`, (error) =>
58
+ {
59
+ if (error)
60
+ {
61
+ rejectPromise(error);
62
+ return;
63
+ }
64
+
65
+ stdin.end();
66
+ resolvePromise();
67
+ });
68
+ });
69
+ };
70
+
71
+ export const readStream = async (stream: NodeJS.ReadableStream | null | undefined, maxChars?: number): Promise<{ output: string; truncated: boolean; totalChars: number }> =>
72
+ {
73
+ if (!stream)
74
+ {
75
+ return { output: "", truncated: false, totalChars: 0 };
76
+ }
77
+
78
+ let output = "";
79
+ let totalChars = 0;
80
+ const decoder = new StringDecoder("utf8");
81
+ const append = (text: string): void =>
82
+ {
83
+ totalChars += text.length;
84
+ if (maxChars === undefined || output.length < maxChars)
85
+ {
86
+ output += maxChars === undefined ? text : text.slice(0, Math.max(0, maxChars - output.length));
87
+ }
88
+ };
89
+ for await (const chunk of stream)
90
+ {
91
+ // Byte boundaries are not character boundaries. Already-decoded streams
92
+ // retain their strings; flush pending bytes before switching to text.
93
+ append(typeof chunk === "string" ? decoder.end() + chunk : decoder.write(chunk));
94
+ }
95
+ append(decoder.end());
96
+ return { output, truncated: maxChars !== undefined && totalChars > maxChars, totalChars };
97
+ };
98
+
99
+ export type SpawnAndCollectDependencies = {
100
+ spawn?: typeof spawn;
101
+ setTimeout?: (callback: () => void, delay: number) => NodeJS.Timeout;
102
+ clearTimeout?: (timeout: NodeJS.Timeout) => void;
103
+ abortKillDelayMs?: number;
104
+ timeoutMs?: number;
105
+ outputLimitChars?: number;
106
+ };
107
+
108
+ export const spawnAndCollect = async (
109
+ command: string,
110
+ args: string[],
111
+ cwd: string,
112
+ input?: string,
113
+ signal?: AbortSignal,
114
+ dependencies: SpawnAndCollectDependencies = {},
115
+ ): Promise<SpawnResult> =>
116
+ {
117
+ if (signal?.aborted)
118
+ {
119
+ throw new Error(`cm ${args.join(" ")} aborted before start.`);
120
+ }
121
+
122
+ let proc: ReturnType<typeof spawn>;
123
+ try
124
+ {
125
+ proc = (dependencies.spawn ?? spawn)(command, args, {
126
+ cwd,
127
+ stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
128
+ shell: false,
129
+ });
130
+ }
131
+ catch (error)
132
+ {
133
+ throw executableNotFoundError(command, error instanceof Error ? error : new Error(String(error)));
134
+ }
135
+
136
+ const scheduleTimeout = dependencies.setTimeout ?? setTimeout;
137
+ const cancelTimeout = dependencies.clearTimeout ?? clearTimeout;
138
+ const stdoutPromise = readStream(proc.stdout, dependencies.outputLimitChars);
139
+ const stderrPromise = readStream(proc.stderr, dependencies.outputLimitChars);
140
+ let aborted = false;
141
+ let timedOut = false;
142
+ let terminalSettled = false;
143
+ let killTimeout: NodeJS.Timeout | undefined;
144
+ let operationTimeout: NodeJS.Timeout | undefined;
145
+
146
+ let resolveExit: (exitCode: number) => void;
147
+ let rejectExit: (error: Error) => void;
148
+ const exitPromise = new Promise<number>((resolvePromise, rejectPromise) =>
149
+ {
150
+ resolveExit = resolvePromise;
151
+ rejectExit = rejectPromise;
152
+ });
153
+ const onProcessError = (error: Error) =>
154
+ {
155
+ if (terminalSettled)
156
+ {
157
+ return;
158
+ }
159
+
160
+ terminalSettled = true;
161
+ rejectExit(executableNotFoundError(command, error));
162
+ };
163
+ const onProcessClose = (code: number | null) =>
164
+ {
165
+ if (terminalSettled)
166
+ {
167
+ return;
168
+ }
169
+
170
+ terminalSettled = true;
171
+ resolveExit(code ?? 1);
172
+ };
173
+
174
+ // Register terminal listeners before any asynchronous stdin work can cause an error or close.
175
+ proc.once("error", onProcessError);
176
+ proc.once("close", onProcessClose);
177
+
178
+ const terminate = () =>
179
+ {
180
+ if (terminalSettled)
181
+ {
182
+ return;
183
+ }
184
+
185
+ try
186
+ {
187
+ proc.kill("SIGTERM");
188
+ }
189
+ catch
190
+ {
191
+ // The process may have settled between the state check and signal.
192
+ }
193
+ killTimeout = scheduleTimeout(() =>
194
+ {
195
+ // ChildProcess.killed only means a signal was sent. It does not mean the process settled.
196
+ if (!terminalSettled)
197
+ {
198
+ try
199
+ {
200
+ proc.kill("SIGKILL");
201
+ }
202
+ catch
203
+ {
204
+ // The process may have settled between the state check and signal.
205
+ }
206
+ }
207
+ }, dependencies.abortKillDelayMs ?? 5000);
208
+ };
209
+ const onAbort = () =>
210
+ {
211
+ aborted = true;
212
+ terminate();
213
+ };
214
+
215
+ signal?.addEventListener("abort", onAbort, { once: true });
216
+ if (dependencies.timeoutMs !== undefined)
217
+ {
218
+ operationTimeout = scheduleTimeout(() =>
219
+ {
220
+ timedOut = true;
221
+ terminate();
222
+ }, dependencies.timeoutMs);
223
+ }
224
+
225
+ try
226
+ {
227
+ const exitCode = input
228
+ ? await Promise.race([
229
+ exitPromise,
230
+ writeInput(proc.stdin, input).then(() => exitPromise),
231
+ ])
232
+ : await exitPromise;
233
+ const [stdoutResult, stderrResult] = await Promise.all([stdoutPromise, stderrPromise]);
234
+ return {
235
+ stdout: stdoutResult.output,
236
+ stderr: stderrResult.output,
237
+ exitCode,
238
+ aborted,
239
+ timedOut,
240
+ stdoutTruncated: stdoutResult.truncated,
241
+ stdoutTotalChars: stdoutResult.totalChars,
242
+ stderrTruncated: stderrResult.truncated,
243
+ stderrTotalChars: stderrResult.totalChars,
244
+ };
245
+ }
246
+ finally
247
+ {
248
+ signal?.removeEventListener("abort", onAbort);
249
+ proc.removeListener("error", onProcessError);
250
+ proc.removeListener("close", onProcessClose);
251
+ if (killTimeout)
252
+ {
253
+ cancelTimeout(killTimeout);
254
+ }
255
+ if (operationTimeout)
256
+ {
257
+ cancelTimeout(operationTimeout);
258
+ }
259
+ }
260
+ };
@@ -0,0 +1,118 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable } from "node:stream";
3
+ import { commandExecutionStorage, getActiveAbortSignal } from "./context";
4
+ import { getCmExecutable } from "./process";
5
+ export type RemovalAttempt = { state: "not-attempted" | "not-started" | "started" | "unknown"; terminal: "not-observed" | "observed"; exitCode: number | null; aborted: boolean; timedOut: boolean };
6
+ export const emptyRemovalAttempt = (): RemovalAttempt => ({ state: "not-attempted", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false });
7
+ export type RemovalCapture = { stdoutBytes: number; stderrBytes: number; stdoutRetainedBytes: number; stderrRetainedBytes: number; truncated: boolean; complete: boolean; validUtf8: boolean };
8
+ export type RemovalCommandObservation = { attempt: RemovalAttempt; capture: RemovalCapture; stdout: string | null; stderr: string | null; failed: boolean };
9
+ // Forced-retirement guards have no per-command state or retained buffers.
10
+ const ignoreRetiredError = () => {};
11
+ /** Selected-tool byte capture; never retries, changes encoding, or discards effect uncertainty. */
12
+ export async function captureRemovalCommand(args: string[], workdir?: string, timeoutMs = 30000): Promise<RemovalCommandObservation> {
13
+ const attempt = emptyRemovalAttempt();
14
+ const capture: RemovalCapture = { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false };
15
+ const result: RemovalCommandObservation = { attempt, capture, stdout: null, stderr: null, failed: false };
16
+ const signal = getActiveAbortSignal(), deps = commandExecutionStorage.getStore() ?? {};
17
+ if (signal?.aborted) { attempt.aborted = true; return result; }
18
+ let child: ReturnType<typeof spawn>;
19
+ try { child = (deps.spawn ?? spawn)(getCmExecutable(), args, { cwd: workdir ?? process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"] }); }
20
+ catch { attempt.state = "not-started"; result.failed = true; return result; }
21
+ attempt.state = "unknown";
22
+ const schedule = deps.setTimeout ?? setTimeout, cancel = deps.clearTimeout ?? clearTimeout;
23
+ let settled = false, failed = false, started = false, retired = false, finalized = false;
24
+ let terminationRequested = false, killTimer: NodeJS.Timeout | undefined, retireTimer: NodeJS.Timeout | undefined;
25
+ let settleExit!: () => void;
26
+ const finishers: Array<() => void> = [], disposers: Array<() => void> = [];
27
+ const retire = () => {
28
+ if (retired || finalized) return;
29
+ retired = true; failed = true;
30
+ // Retirement is not an observed terminal event or proof that the child stopped.
31
+ for (const emitter of [child, child.stdout, child.stderr]) emitter?.on("error", ignoreRetiredError);
32
+ settleExit();
33
+ for (const finish of finishers) finish();
34
+ child.stdout?.destroy(); child.stderr?.destroy();
35
+ };
36
+ const terminate = () => {
37
+ if (terminationRequested || retired || finalized) return;
38
+ terminationRequested = true;
39
+ const grace = deps.abortKillDelayMs ?? 5000;
40
+ if (!settled) { try { child.kill("SIGTERM"); } catch { /* not termination proof */ } }
41
+ killTimer = schedule(() => {
42
+ if (finalized || retired) return;
43
+ if (!settled) { try { child.kill("SIGKILL"); } catch { /* not termination proof */ } }
44
+ // Same bounded drain grace after KILL; terminal state never gates retirement.
45
+ retireTimer = schedule(retire, grace);
46
+ }, grace);
47
+ };
48
+ const collect = (stream: Readable | null, cap: number, name: "stdout" | "stderr") => {
49
+ let length = 0; const parts: Buffer[] = [];
50
+ if (!stream) { failed = true; terminate(); return Promise.resolve(Buffer.alloc(0)); }
51
+ if (stream.readableEncoding) { failed = true; terminate(); }
52
+ return new Promise<Buffer>(resolve => {
53
+ let done = false;
54
+ const finish = () => { if (!done) { done = true; const bytes = Buffer.concat(parts, length); parts.length = 0; resolve(bytes); } };
55
+ finishers.push(finish);
56
+ const data = (chunk: unknown) => {
57
+ if (done || retired || finalized) return;
58
+ if (!(chunk instanceof Uint8Array)) { failed = true; terminate(); return; }
59
+ const key = name === "stdout" ? "stdoutBytes" : "stderrBytes";
60
+ capture[key] = Math.min(Number.MAX_SAFE_INTEGER, capture[key] + chunk.byteLength);
61
+ const available = cap - length;
62
+ if (chunk.byteLength > available) { capture.truncated = true; terminate(); }
63
+ if (available > 0) { const part = Buffer.from(chunk.subarray(0, available)); parts.push(part); length += part.length; }
64
+ };
65
+ const error = () => { if (retired || finalized) return; failed = true; terminate(); finish(); };
66
+ const close = () => { if (retired || finalized) return; if (!done && !stream.readableEnded) failed = true; finish(); };
67
+ stream.on("data", data); stream.once("end", finish); stream.on("error", error); stream.once("close", close);
68
+ disposers.push(() => { stream.removeListener("data", data); stream.removeListener("end", finish); stream.removeListener("error", error); stream.removeListener("close", close); });
69
+ });
70
+ };
71
+ let onClose!: (code: number | null, terminalSignal?: string | null) => void, onError!: (error: NodeJS.ErrnoException) => void;
72
+ const onSpawn = () => { if (retired || finalized) return; started = true; attempt.state = "started"; };
73
+ const exit = new Promise<void>(resolve => {
74
+ settleExit = resolve;
75
+ onClose = (code, terminalSignal) => {
76
+ if (settled || retired || finalized) return; settled = true; attempt.terminal = "observed";
77
+ attempt.exitCode = Number.isInteger(code) && code !== null && code >= 0 && code <= 2147483647 ? code : null;
78
+ // A terminal event is not a substitute for the native start event.
79
+ if (!started) failed = true;
80
+ if (attempt.exitCode === null || terminalSignal) failed = true;
81
+ resolve();
82
+ };
83
+ onError = error => {
84
+ if (retired || finalized) return;
85
+ failed = true;
86
+ if (!started && ["ENOENT", "EACCES", "ENOTDIR"].includes(error.code ?? "")) {
87
+ attempt.state = "not-started"; settled = true; child.stdout?.destroy(); child.stderr?.destroy(); resolve();
88
+ } else { terminate(); }
89
+ };
90
+ child.once("spawn", onSpawn); child.once("close", onClose); child.on("error", onError);
91
+ });
92
+ const out = collect(child.stdout, 65536, "stdout"), err = collect(child.stderr, 16384, "stderr");
93
+ const onAbort = () => { if (retired || finalized) return; attempt.aborted = true; terminate(); };
94
+ signal?.addEventListener("abort", onAbort, { once: true });
95
+ const timer = schedule(() => { if (retired || finalized) return; attempt.timedOut = true; terminate(); }, Math.min(deps.timeoutMs ?? timeoutMs, timeoutMs));
96
+ if (signal?.aborted) onAbort();
97
+ try {
98
+ const [, stdout, stderr] = await Promise.all([exit, out, err]);
99
+ capture.stdoutRetainedBytes = stdout.length; capture.stderrRetainedBytes = stderr.length;
100
+ result.failed = failed;
101
+ try {
102
+ // Retain a literal UTF-8 BOM rather than silently removing source bytes.
103
+ result.stdout = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stdout);
104
+ result.stderr = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(stderr);
105
+ capture.validUtf8 = true;
106
+ } catch { result.stdout = null; result.stderr = null; }
107
+ if (signal?.aborted) attempt.aborted = true;
108
+ capture.complete = !failed && !result.failed && !attempt.aborted && !attempt.timedOut && capture.validUtf8 && !capture.truncated && attempt.terminal === "observed";
109
+ return result;
110
+ } finally {
111
+ finalized = true;
112
+ cancel(timer); if (killTimer !== undefined) cancel(killTimer); if (retireTimer !== undefined) cancel(retireTimer);
113
+ signal?.removeEventListener("abort", onAbort);
114
+ child.removeListener("spawn", onSpawn); child.removeListener("close", onClose); child.removeListener("error", onError);
115
+ for (const dispose of disposers) dispose();
116
+ finishers.length = 0; disposers.length = 0;
117
+ }
118
+ }