dsh-patch-edit-plus 0.1.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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Codex `apply_patch` syntax parser.
3
+ *
4
+ * Accepts the OpenAI Codex CLI patch grammar:
5
+ * ```
6
+ * *** Begin Patch
7
+ * *** Add File: path
8
+ * +content lines
9
+ * *** Update File: path
10
+ * *** Move to: new-path
11
+ * @@ optional context label
12
+ * context
13
+ * -removed
14
+ * +added
15
+ * *** End of File
16
+ * *** Delete File: path
17
+ * *** End Patch
18
+ * ```
19
+ * A wrapper (heredoc, code fence, prose) around the markers is tolerated by
20
+ * slicing from `*** Begin Patch` to the last `*** End Patch`.
21
+ * @module dsh-patch-edit-plus/parse/codex
22
+ */
23
+ import { parseError, unrecognizedPatchError } from '../errors.js';
24
+ import { normalizeLines } from './unified.js';
25
+ const BEGIN = '*** Begin Patch';
26
+ const END = '*** End Patch';
27
+ const ADD_FILE = '*** Add File: ';
28
+ const DELETE_FILE = '*** Delete File: ';
29
+ const UPDATE_FILE = '*** Update File: ';
30
+ const MOVE_TO = '*** Move to: ';
31
+ const END_OF_FILE = '*** End of File';
32
+ const ENVIRONMENT_ID = '*** Environment ID:';
33
+ /**
34
+ * Parse a Codex `apply_patch` patch into the shared IR.
35
+ * @throws {@link PatchError} code `PARSE` for malformed or unrecognized input.
36
+ */
37
+ export function parseCodexPatch(patch) {
38
+ const slice = extractPatchBody(patch);
39
+ if (slice === null)
40
+ throw unrecognizedPatchError();
41
+ const lines = normalizeLines(slice);
42
+ // normalizeLines dropped the trailing empty element of "*** End Patch\n".
43
+ if (lines.at(-1) !== END)
44
+ throw parseError(lines.length, `patch must end with "${END}"`);
45
+ const ops = [];
46
+ let index = 1; // skip "*** Begin Patch"
47
+ while (index < lines.length) {
48
+ const line = lines[index];
49
+ const lineNo = index + 1;
50
+ if (line === END) {
51
+ index += 1;
52
+ continue;
53
+ }
54
+ if (line.startsWith(ENVIRONMENT_ID)) {
55
+ throw parseError(lineNo, `${ENVIRONMENT_ID} patches are environment-bound and not supported here; regenerate the patch without an Environment ID`);
56
+ }
57
+ if (line.startsWith(ADD_FILE)) {
58
+ const op = parseAddFile(lines, index);
59
+ ops.push(op.op);
60
+ index = op.nextIndex;
61
+ continue;
62
+ }
63
+ if (line.startsWith(DELETE_FILE)) {
64
+ const path = line.slice(DELETE_FILE.length).trim();
65
+ if (path === '')
66
+ throw parseError(lineNo, `${DELETE_FILE}requires a file path`);
67
+ ops.push({ kind: 'delete', path });
68
+ index += 1;
69
+ continue;
70
+ }
71
+ if (line.startsWith(UPDATE_FILE)) {
72
+ const op = parseUpdateFile(lines, index);
73
+ ops.push(op.op);
74
+ index = op.nextIndex;
75
+ continue;
76
+ }
77
+ throw parseError(lineNo, `unexpected line "${line.slice(0, 48)}"; expected ${ADD_FILE}/${UPDATE_FILE}/${DELETE_FILE} or ${END}`);
78
+ }
79
+ if (ops.length === 0)
80
+ throw parseError(1, 'patch contains no file operations');
81
+ return { format: 'codex', ops };
82
+ }
83
+ /**
84
+ * Slice the patch body out of an optional wrapper: returns the text from
85
+ * `*** Begin Patch` through the LAST `*** End Patch` line (inclusive), or
86
+ * `null` when the markers are absent.
87
+ */
88
+ function extractPatchBody(patch) {
89
+ const normalized = patch.replaceAll('\r\n', '\n');
90
+ const begin = normalized.indexOf(BEGIN);
91
+ if (begin < 0)
92
+ return null;
93
+ const endMarker = normalized.lastIndexOf(END);
94
+ if (endMarker < begin)
95
+ return null;
96
+ return normalized.slice(begin, endMarker + END.length);
97
+ }
98
+ function parseAddFile(lines, start) {
99
+ const path = lines[start].slice(ADD_FILE.length).trim();
100
+ if (path === '')
101
+ throw parseError(start + 1, `${ADD_FILE}requires a file path`);
102
+ const content = [];
103
+ let index = start + 1;
104
+ while (index < lines.length) {
105
+ const line = lines[index];
106
+ if (line.startsWith('+')) {
107
+ content.push(line.slice(1));
108
+ index += 1;
109
+ continue;
110
+ }
111
+ if (line === END || line.startsWith('*** '))
112
+ break;
113
+ throw parseError(index + 1, `content lines of "${path}" must start with "+"; got "${line.slice(0, 48)}"`);
114
+ }
115
+ return { op: { kind: 'add', path, lines: content, trailingNewline: content.length > 0 }, nextIndex: index };
116
+ }
117
+ function parseUpdateFile(lines, start) {
118
+ const path = lines[start].slice(UPDATE_FILE.length).trim();
119
+ if (path === '')
120
+ throw parseError(start + 1, `${UPDATE_FILE}requires a file path`);
121
+ let index = start + 1;
122
+ let moveTo;
123
+ if (lines[index]?.startsWith(MOVE_TO)) {
124
+ const target = lines[index].slice(MOVE_TO.length).trim();
125
+ if (target === '')
126
+ throw parseError(index + 1, `${MOVE_TO}requires a file path`);
127
+ moveTo = target;
128
+ index += 1;
129
+ }
130
+ const hunks = [];
131
+ while (index < lines.length) {
132
+ const line = lines[index];
133
+ if (line === END)
134
+ break;
135
+ if (line.startsWith(UPDATE_FILE) || line.startsWith(ADD_FILE) || line.startsWith(DELETE_FILE))
136
+ break;
137
+ if (!line.startsWith('@@')) {
138
+ throw parseError(index + 1, `expected an "@@" hunk header inside the update of "${path}"; got "${line.slice(0, 48)}"`);
139
+ }
140
+ const label = line.slice(2).trim();
141
+ const parsed = parseCodexHunk(lines, index + 1, path);
142
+ hunks.push({ contextLabel: label === '' ? undefined : label, lines: parsed.lines, endOfFile: parsed.endOfFile });
143
+ index = parsed.nextIndex;
144
+ }
145
+ if (hunks.length === 0) {
146
+ // A bare `Update File` + `Move to` without hunks is a pure rename.
147
+ if (moveTo === undefined)
148
+ throw parseError(start + 1, `update of "${path}" contains no hunks`);
149
+ }
150
+ return { op: { kind: 'update', path, moveTo, hunks }, nextIndex: index };
151
+ }
152
+ function parseCodexHunk(lines, start, path) {
153
+ const result = [];
154
+ let endOfFile = false;
155
+ let index = start;
156
+ while (index < lines.length) {
157
+ const line = lines[index];
158
+ if (line === END_OF_FILE) {
159
+ endOfFile = true;
160
+ index += 1;
161
+ break;
162
+ }
163
+ if (line === END)
164
+ break;
165
+ if (line.startsWith('@@'))
166
+ break;
167
+ if (line.startsWith('*** '))
168
+ break;
169
+ const marker = line.charAt(0);
170
+ const text = line.slice(1);
171
+ if (marker === ' ')
172
+ result.push({ kind: 'context', text });
173
+ else if (marker === '-')
174
+ result.push({ kind: 'delete', text });
175
+ else if (marker === '+')
176
+ result.push({ kind: 'add', text });
177
+ else if (line === '')
178
+ result.push({ kind: 'context', text: '' });
179
+ else
180
+ throw parseError(index + 1, `hunk lines of "${path}" must start with " ", "-", or "+"; got "${line.slice(0, 48)}"`);
181
+ index += 1;
182
+ }
183
+ if (result.length === 0)
184
+ throw parseError(start + 1, `hunk of "${path}" is empty`);
185
+ if (result.every(line => line.kind === 'context')) {
186
+ throw parseError(start + 1, `hunk of "${path}" contains no changes (context lines only)`);
187
+ }
188
+ return { lines: result, endOfFile, nextIndex: index };
189
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Unified intermediate representation shared by both patch parsers.
3
+ *
4
+ * The unified-diff parser and the Codex `apply_patch` parser both emit
5
+ * `ParsedPatch`; downstream planning and application see a single shape so
6
+ * there is exactly one apply path regardless of the input dialect.
7
+ * @module dsh-patch-edit-plus/parse/types
8
+ */
9
+ export {};
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Git / unified diff parser.
3
+ *
4
+ * Produces the shared IR (`ParsedPatch`). Accepts full git diffs (with
5
+ * `diff --git` headers, mode lines and rename detection) and bare unified
6
+ * diffs (`---`/`+++` + hunks). Parsing is strict: malformed input raises
7
+ * `PatchError`('PARSE') with a 1-based line number, never a best-effort guess.
8
+ * @module dsh-patch-edit-plus/parse/unified
9
+ */
10
+ import { parseError, unrecognizedPatchError } from '../errors.js';
11
+ /** Normalize CRLF/CR to LF and split into lines. */
12
+ export function normalizeLines(text) {
13
+ const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
14
+ if (normalized === '')
15
+ return [];
16
+ const lines = normalized.split('\n');
17
+ // A trailing newline yields a final empty element; that is not a line.
18
+ if (lines.at(-1) === '')
19
+ lines.pop();
20
+ return lines;
21
+ }
22
+ /** Strip `a/`/`b/` prefixes and quotes from a git header path. */
23
+ function stripPrefix(raw) {
24
+ let path = raw;
25
+ if (path.startsWith('"') && path.endsWith('"') && path.length >= 2) {
26
+ path = path.slice(1, -1).replaceAll('\\"', '"').replaceAll('\\\\', '\\');
27
+ }
28
+ if (path.startsWith('a/') || path.startsWith('b/'))
29
+ path = path.slice(2);
30
+ return path;
31
+ }
32
+ /** True when the path token means "no file" (add or delete). */
33
+ function isDevNull(path) {
34
+ return path === '/dev/null' || path === 'a/dev/null' || path === 'b/dev/null';
35
+ }
36
+ const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
37
+ /**
38
+ * Parse a git/unified diff into the shared IR.
39
+ * @throws {@link PatchError} code `PARSE` for any malformed or unrecognized input.
40
+ */
41
+ export function parseUnifiedDiff(patch) {
42
+ const lines = normalizeLines(patch);
43
+ if (lines.length === 0)
44
+ throw unrecognizedPatchError();
45
+ const sections = [];
46
+ let current = null;
47
+ let index = 0;
48
+ while (index < lines.length) {
49
+ const line = lines[index];
50
+ const lineNo = index + 1;
51
+ if (line.startsWith('diff --git ')) {
52
+ current = { oldPath: null, newPath: null, newFile: false, deletedFile: false, renameFrom: null, renameTo: null, hunks: [], startLine: lineNo };
53
+ sections.push(current);
54
+ index += 1;
55
+ continue;
56
+ }
57
+ if (line.startsWith('--- ')) {
58
+ if (current === null) {
59
+ // Bare unified diff without a git header: open an implicit section.
60
+ current = { oldPath: null, newPath: null, newFile: false, deletedFile: false, renameFrom: null, renameTo: null, hunks: [], startLine: lineNo };
61
+ sections.push(current);
62
+ }
63
+ const token = line.slice(4).trim();
64
+ current.oldPath = isDevNull(token) ? null : stripPrefix(token);
65
+ index += 1;
66
+ continue;
67
+ }
68
+ if (line.startsWith('+++ ')) {
69
+ if (current === null)
70
+ throw parseError(lineNo, '+++ header without a preceding --- header');
71
+ const token = line.slice(4).trim();
72
+ current.newPath = isDevNull(token) ? null : stripPrefix(token);
73
+ index += 1;
74
+ continue;
75
+ }
76
+ if (line.startsWith('new file mode')) {
77
+ if (current === null)
78
+ throw parseError(lineNo, 'mode line outside a file section');
79
+ current.newFile = true;
80
+ index += 1;
81
+ continue;
82
+ }
83
+ if (line.startsWith('deleted file mode')) {
84
+ if (current === null)
85
+ throw parseError(lineNo, 'mode line outside a file section');
86
+ current.deletedFile = true;
87
+ index += 1;
88
+ continue;
89
+ }
90
+ if (line.startsWith('rename from ')) {
91
+ if (current === null)
92
+ throw parseError(lineNo, 'rename line outside a file section');
93
+ current.renameFrom = stripPrefix(line.slice(12).trim());
94
+ index += 1;
95
+ continue;
96
+ }
97
+ if (line.startsWith('rename to ')) {
98
+ if (current === null)
99
+ throw parseError(lineNo, 'rename line outside a file section');
100
+ current.renameTo = stripPrefix(line.slice(10).trim());
101
+ index += 1;
102
+ continue;
103
+ }
104
+ if (line.startsWith('old mode ') || line.startsWith('new mode ') || line.startsWith('similarity index ')
105
+ || line.startsWith('dissimilarity index ') || line.startsWith('index ')) {
106
+ if (current === null)
107
+ throw parseError(lineNo, `"${line.slice(0, 24)}" outside a file section`);
108
+ index += 1;
109
+ continue;
110
+ }
111
+ if (line.startsWith('Binary files ') || line === 'GIT binary patch') {
112
+ throw parseError(lineNo, 'binary patches are not supported; apply the change to the text file instead');
113
+ }
114
+ if (line.startsWith('@@')) {
115
+ if (current === null)
116
+ throw parseError(lineNo, 'hunk header outside a file section');
117
+ const hunk = parseHunkHeader(line, lineNo);
118
+ index += 1;
119
+ const body = parseHunkBody(lines, index, hunk);
120
+ index = body.nextIndex;
121
+ current.hunks.push({ oldStart: hunk.oldStart, lines: body.lines, endOfFile: false, oldNoNewline: body.oldNoNewline, newNoNewline: body.newNoNewline });
122
+ continue;
123
+ }
124
+ // Unknown content: only tolerated as section noise inside a section.
125
+ if (current === null)
126
+ throw unrecognizedPatchError();
127
+ throw parseError(lineNo, `unexpected line "${line.slice(0, 48)}"`);
128
+ }
129
+ if (sections.length === 0)
130
+ throw unrecognizedPatchError();
131
+ return { format: 'unified', ops: sections.map(toOp) };
132
+ }
133
+ function parseHunkHeader(line, lineNo) {
134
+ const match = HUNK_HEADER.exec(line);
135
+ if (match === null)
136
+ throw parseError(lineNo, `malformed hunk header "${line.slice(0, 48)}"`);
137
+ const oldStart = Number(match[1]);
138
+ const oldCount = match[2] === undefined ? 1 : Number(match[2]);
139
+ const newStart = Number(match[3]);
140
+ const newCount = match[4] === undefined ? 1 : Number(match[4]);
141
+ if (!Number.isSafeInteger(oldStart) || !Number.isSafeInteger(newStart)) {
142
+ throw parseError(lineNo, 'hunk header line numbers out of range');
143
+ }
144
+ return { oldStart, oldCount, newStart, newCount };
145
+ }
146
+ function parseHunkBody(lines, start, header) {
147
+ const result = [];
148
+ let oldSeen = 0;
149
+ let newSeen = 0;
150
+ let oldNoNewline = false;
151
+ let newNoNewline = false;
152
+ let index = start;
153
+ while (index < lines.length) {
154
+ const line = lines[index];
155
+ if (oldSeen >= header.oldCount && newSeen >= header.newCount
156
+ && (line.startsWith('diff --git ') || line.startsWith('@@') || line.startsWith('--- ') || line.startsWith('*** ')))
157
+ break;
158
+ if (line.startsWith('\\')) {
159
+ // "" — applies to the side of the most
160
+ // recent body line: after `-` it marks the old tail, after `+` the new
161
+ // tail, after a context line both.
162
+ if (result.length === 0)
163
+ throw parseError(index + 1, '\\ "no newline" marker before any hunk line');
164
+ const previous = result.at(-1);
165
+ if (previous?.kind === 'delete')
166
+ oldNoNewline = true;
167
+ else if (previous?.kind === 'add')
168
+ newNoNewline = true;
169
+ else {
170
+ oldNoNewline = true;
171
+ newNoNewline = true;
172
+ }
173
+ index += 1;
174
+ continue;
175
+ }
176
+ const marker = line.charAt(0);
177
+ const text = line.slice(1);
178
+ if (marker === ' ' && (oldSeen < header.oldCount || newSeen < header.newCount)) {
179
+ result.push({ kind: 'context', text });
180
+ oldSeen += 1;
181
+ newSeen += 1;
182
+ }
183
+ else if (marker === '-') {
184
+ result.push({ kind: 'delete', text });
185
+ oldSeen += 1;
186
+ }
187
+ else if (marker === '+') {
188
+ result.push({ kind: 'add', text });
189
+ newSeen += 1;
190
+ }
191
+ else if (marker === '' && oldSeen < header.oldCount && newSeen < header.newCount) {
192
+ // A genuinely empty context line whose leading space was stripped.
193
+ result.push({ kind: 'context', text: '' });
194
+ oldSeen += 1;
195
+ newSeen += 1;
196
+ }
197
+ else {
198
+ throw parseError(index + 1, `unexpected hunk line "${line.slice(0, 48)}"`);
199
+ }
200
+ index += 1;
201
+ }
202
+ if (oldSeen !== header.oldCount || newSeen !== header.newCount) {
203
+ throw parseError(start + 1, `hunk header counts (old ${header.oldCount}, new ${header.newCount}) do not match the hunk body (old ${oldSeen}, new ${newSeen})`);
204
+ }
205
+ if (result.every(line => line.kind === 'context')) {
206
+ throw parseError(start + 1, 'hunk contains no changes (context lines only)');
207
+ }
208
+ return { lines: result, oldNoNewline, newNoNewline, nextIndex: index };
209
+ }
210
+ function assertPlainPath(path, lineNo) {
211
+ if (path === null)
212
+ throw parseError(lineNo, 'file section without a usable path');
213
+ return path;
214
+ }
215
+ /** Convert one parsed file section into an IR operation. */
216
+ function toOp(section) {
217
+ const headerLine = section.startLine;
218
+ const oldPath = section.oldPath ?? section.renameFrom;
219
+ const newPath = section.newPath ?? section.renameTo;
220
+ if (section.newFile || (section.oldPath === null && section.newPath !== null)) {
221
+ const path = assertPlainPath(newPath, headerLine);
222
+ const addLines = [];
223
+ for (const hunk of section.hunks) {
224
+ for (const line of hunk.lines) {
225
+ if (line.kind === 'add')
226
+ addLines.push(line.text);
227
+ else if (line.kind === 'delete')
228
+ throw parseError(headerLine, `new-file section for "${path}" contains delete lines`);
229
+ }
230
+ }
231
+ const lastNewNoNewline = section.hunks.at(-1)?.newNoNewline ?? false;
232
+ return { kind: 'add', path, lines: addLines, trailingNewline: addLines.length === 0 ? false : !lastNewNoNewline };
233
+ }
234
+ if (section.deletedFile || (section.newPath === null && section.oldPath !== null)) {
235
+ const path = assertPlainPath(oldPath, headerLine);
236
+ return { kind: 'delete', path };
237
+ }
238
+ const path = assertPlainPath(oldPath, headerLine);
239
+ const moveTo = newPath !== null && newPath !== path
240
+ ? newPath
241
+ : section.renameTo !== null && section.renameTo !== path ? section.renameTo : undefined;
242
+ return { kind: 'update', path, moveTo, hunks: section.hunks };
243
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tool registration with three-layer name-conflict protection.
3
+ *
4
+ * Layer 1: probe `ctx.tools.get(name)` before building the definition.
5
+ * Layer 2: `conflictPolicy` — `rename` (default) retries suffixed names,
6
+ * `skip` logs and loads without the tool, `fail` throws.
7
+ * Layer 3: `try/catch` around `register()` because get-then-register races.
8
+ * @module dsh-patch-edit-plus/register
9
+ */
10
+ import { PatchError } from './errors.js';
11
+ import { buildApplyPatchTool } from './tool.js';
12
+ const MAX_RENAME_ATTEMPTS = 9;
13
+ /**
14
+ * Resolve an unused tool name and register the frozen definition.
15
+ * @returns the registration disposer, or `null` when registration was skipped.
16
+ * @throws {@link PatchError} only under `conflictPolicy: 'fail'`.
17
+ */
18
+ export function registerApplyPatchTool(ctx, cfg, logger) {
19
+ const log = logger ?? resolveLogger(ctx);
20
+ const tools = ctx.tools;
21
+ const base = cfg.toolName;
22
+ let resolvedName = base;
23
+ if (tools.get(resolvedName) !== undefined) {
24
+ if (cfg.conflictPolicy === 'skip') {
25
+ log.error?.(`dsh-patch-edit-plus: tool name "${base}" is already registered; conflictPolicy is "skip", so no tool was registered. `);
26
+ return null;
27
+ }
28
+ if (cfg.conflictPolicy === 'fail') {
29
+ throw new PatchError('CONFLICT', `Tool name "${base}" is already registered. Set a different dsh-patch-edit-plus toolName, or change conflictPolicy to "rename" (default) or "skip".`);
30
+ }
31
+ // rename
32
+ const suffix = cfg.renameSuffix;
33
+ let found = null;
34
+ for (let attempt = 1; attempt <= MAX_RENAME_ATTEMPTS && found === null; attempt += 1) {
35
+ const candidate = attempt === 1 ? `${base}${suffix}` : `${base}${suffix}${attempt}`;
36
+ if (tools.get(candidate) === undefined)
37
+ found = candidate;
38
+ }
39
+ if (found === null) {
40
+ throw new PatchError('CONFLICT', `Could not find a free tool name for "${base}" after ${MAX_RENAME_ATTEMPTS} rename attempts. Set an explicit dsh-patch-edit-plus toolName.`);
41
+ }
42
+ log.warn?.(`dsh-patch-edit-plus: tool name "${base}" is already registered; registering as "${found}" instead.`);
43
+ resolvedName = found;
44
+ }
45
+ const definition = buildApplyPatchTool(resolvedName, ctx, cfg);
46
+ try {
47
+ return tools.register(definition);
48
+ }
49
+ catch (error) {
50
+ if (cfg.conflictPolicy === 'fail')
51
+ throw error;
52
+ const detail = error instanceof Error ? error.message : String(error);
53
+ log.error?.(`dsh-patch-edit-plus: registering tool "${resolvedName}" failed (${detail}). The plugin stays loaded without the tool; set a unique toolName to resolve the conflict.`);
54
+ return null;
55
+ }
56
+ }
57
+ function resolveLogger(ctx) {
58
+ const candidate = ctx.logger;
59
+ return typeof candidate === 'object' && candidate !== null ? candidate : {};
60
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Shell seam for Delete / Move (the `ctx.fs` contract has no delete/rename).
3
+ *
4
+ * Security posture:
5
+ * - Command templates are CONSTANTS; paths travel only via the environment
6
+ * (`DSH_PATCH_SOURCE` / `DSH_PATCH_TARGET`), never interpolated into the
7
+ * command string — shell metacharacters in a path cannot inject commands.
8
+ * - Every run carries a `sandboxPolicy` so sandboxing executors fence it, and
9
+ * the reported `ShellSandboxInfo` distinguishes "policy denied" from
10
+ * "command failed".
11
+ * @module dsh-patch-edit-plus/shellops
12
+ */
13
+ import { PatchError, unsupportedShellError } from './errors.js';
14
+ /** Constant POSIX templates; paths arrive via env, never via interpolation. */
15
+ export const POSIX_REMOVE = 'rm -f -- "$DSH_PATCH_TARGET"';
16
+ export const POSIX_MOVE = 'mv -f -- "$DSH_PATCH_SOURCE" "$DSH_PATCH_TARGET"';
17
+ /** Constant PowerShell templates (Windows default). */
18
+ export const PWSH_REMOVE = 'Remove-Item -LiteralPath $env:DSH_PATCH_TARGET -Force';
19
+ export const PWSH_MOVE = 'Move-Item -LiteralPath $env:DSH_PATCH_SOURCE -Destination $env:DSH_PATCH_TARGET -Force';
20
+ const SHELL_TIMEOUT_MS = 30_000;
21
+ /** Resolve the configured dialect; `auto` picks pwsh on win32, posix elsewhere. */
22
+ export function resolveDialect(cfg) {
23
+ if (cfg.shellDialect === 'auto')
24
+ return process.platform === 'win32' ? 'pwsh' : 'posix';
25
+ return cfg.shellDialect;
26
+ }
27
+ /** The constant delete template for the resolved dialect (config override wins). */
28
+ export function deleteTemplate(cfg) {
29
+ return cfg.deleteCommand ?? (resolveDialect(cfg) === 'pwsh' ? PWSH_REMOVE : POSIX_REMOVE);
30
+ }
31
+ /** The constant move template for the resolved dialect (config override wins). */
32
+ export function moveTemplate(cfg) {
33
+ return cfg.moveCommand ?? (resolveDialect(cfg) === 'pwsh' ? PWSH_MOVE : POSIX_MOVE);
34
+ }
35
+ /** Structurally resolve `ctx.shell` without assuming the service is mounted. */
36
+ export function resolveShell(ctx) {
37
+ try {
38
+ const shell = ctx.shell;
39
+ return shell !== undefined && typeof shell.run === 'function' && typeof shell.resolve === 'function' ? shell : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /** True when Delete/Move can run at all under the current configuration. */
46
+ export function shellReady(ctx, cfg) {
47
+ return cfg.deleteBackend === 'shell' && resolveShell(ctx) !== undefined;
48
+ }
49
+ /** Throw the precise structured UNSUPPORTED error when Delete/Move cannot run. */
50
+ export function assertShellReady(ctx, cfg) {
51
+ if (cfg.deleteBackend !== 'shell') {
52
+ throw unsupportedShellError('Delete/Move operations are disabled by configuration (deleteBackend: "none").');
53
+ }
54
+ if (resolveShell(ctx) === undefined) {
55
+ throw unsupportedShellError('Delete/Move operations need a shell capability, but no shell executor is mounted in this session.');
56
+ }
57
+ }
58
+ /** Remove one file via the sandbox-aware shell. */
59
+ export async function removeFile(ctx, exec, cfg, target, sandboxPolicy) {
60
+ const shell = requireShell(ctx, cfg);
61
+ const request = {
62
+ command: deleteTemplate(cfg),
63
+ env: { DSH_PATCH_TARGET: ctx.fs.processPath(target) },
64
+ signal: exec.signal,
65
+ timeoutMs: SHELL_TIMEOUT_MS,
66
+ sandboxPolicy,
67
+ };
68
+ const result = await runShell(shell, request);
69
+ interpretResult(result, 'delete', target.displayPath);
70
+ }
71
+ /** Move (rename) one file via the sandbox-aware shell. */
72
+ export async function moveFile(ctx, exec, cfg, source, target, sandboxPolicy) {
73
+ const shell = requireShell(ctx, cfg);
74
+ const request = {
75
+ command: moveTemplate(cfg),
76
+ env: { DSH_PATCH_SOURCE: ctx.fs.processPath(source), DSH_PATCH_TARGET: ctx.fs.processPath(target) },
77
+ signal: exec.signal,
78
+ timeoutMs: SHELL_TIMEOUT_MS,
79
+ sandboxPolicy,
80
+ };
81
+ const result = await runShell(shell, request);
82
+ interpretResult(result, 'move', `${source.displayPath} -> ${target.displayPath}`);
83
+ }
84
+ function requireShell(ctx, cfg) {
85
+ assertShellReady(ctx, cfg);
86
+ return resolveShell(ctx);
87
+ }
88
+ async function runShell(shell, request) {
89
+ try {
90
+ return await shell.run(shell.resolve(request));
91
+ }
92
+ catch (error) {
93
+ const detail = error instanceof Error ? error.message : String(error);
94
+ throw new PatchError('IO', `shell execution failed: ${detail}`);
95
+ }
96
+ }
97
+ /** Distinguish "sandbox policy denied" from "the command itself failed". */
98
+ function interpretResult(result, verb, subject) {
99
+ if (result.sandbox?.denied === true) {
100
+ const mode = result.sandbox.mode ?? 'unknown';
101
+ throw new PatchError('VALIDATION', `The sandbox denied the ${verb} of "${subject}" (mode: ${mode}). `
102
+ + 'The path is likely outside the writable workspace. Ask the user for a wider permission preset, or apply the change manually.');
103
+ }
104
+ if (result.aborted || result.signal !== null) {
105
+ throw new PatchError('IO', `The ${verb} of "${subject}" was aborted before it completed.`);
106
+ }
107
+ if (result.exitCode !== 0) {
108
+ const stderrTail = result.stderr.text.trim().split('\n').at(-1) ?? '';
109
+ throw new PatchError('IO', `The ${verb} of "${subject}" failed with exit code ${result.exitCode ?? 'null'}${stderrTail === '' ? '' : `: ${stderrTail.slice(0, 200)}`}`);
110
+ }
111
+ }