@akira-tl/forgerelay 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -2
- package/capabilities/shell-processes/GUIDE.md +1 -1
- package/dist/cli/init.js +14 -0
- package/dist/mcp/process/process-platform.js +67 -2
- package/dist/mcp/process/process-sessions.js +16 -22
- package/dist/runtime/instructions/powershell-skill.js +65 -0
- package/dist/runtime/shell/command-shell-runtime.js +85 -11
- package/dist/server.js +1 -1
- package/dist/subagents/sessions/execution.js +1 -1
- package/docs/gotchas.md +5 -3
- package/package.json +4 -2
- package/scripts/ci/powershell51-acceptance.mjs +483 -0
- package/scripts/ci/pwsh-acceptance.mjs +455 -0
- package/scripts/ci/verify.mjs +1 -0
- package/scripts/release/release-gate.test.mjs +14 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
console.log("Windows PowerShell 5.1 packaged acceptance skipped outside Windows.");
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const npmCli = process.env.npm_execpath;
|
|
16
|
+
if (!npmCli) throw new Error("Windows PowerShell acceptance must run through npm so npm_execpath is available");
|
|
17
|
+
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "forgerelay-powershell51-acceptance-"));
|
|
19
|
+
try {
|
|
20
|
+
const powershell = resolveWindowsPowerShell();
|
|
21
|
+
const version = powerShellVersion(powershell);
|
|
22
|
+
const [majorText, minorText] = version.split(".", 3);
|
|
23
|
+
const major = Number.parseInt(majorText ?? "", 10);
|
|
24
|
+
const minor = Number.parseInt(minorText ?? "", 10);
|
|
25
|
+
assert.equal(major, 5, `Windows PowerShell acceptance requires major version 5; got ${version}`);
|
|
26
|
+
assert.equal(minor, 1, `Windows PowerShell acceptance requires version 5.1; got ${version}`);
|
|
27
|
+
|
|
28
|
+
const { resolveConfiguredCommandShellRuntime } = await import("../../dist/runtime/shell/command-shell-runtime.js");
|
|
29
|
+
const resolvedRuntime = resolveConfiguredCommandShellRuntime({
|
|
30
|
+
mode: "pinned",
|
|
31
|
+
family: "powershell",
|
|
32
|
+
executable: powershell,
|
|
33
|
+
}, "win32", process.env);
|
|
34
|
+
assert.equal(resolvedRuntime.family, "powershell");
|
|
35
|
+
assert.equal(resolvedRuntime.executable, powershell);
|
|
36
|
+
assert.equal(resolvedRuntime.version, version);
|
|
37
|
+
assert.ok(resolvedRuntime.capabilities.includes("windows-powershell"));
|
|
38
|
+
assert.ok(resolvedRuntime.capabilities.includes("profile-isolation"));
|
|
39
|
+
assert.ok(!resolvedRuntime.capabilities.includes("pipeline-chain-operators"));
|
|
40
|
+
|
|
41
|
+
await exerciseAgentRuntime(resolvedRuntime);
|
|
42
|
+
await exerciseHookRuntime(resolvedRuntime);
|
|
43
|
+
await exercisePackagedPowerShellShim(powershell, version);
|
|
44
|
+
|
|
45
|
+
console.log(`Windows PowerShell 5.1 acceptance passed with ${powershell} (${version}).`);
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(root, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveWindowsPowerShell() {
|
|
51
|
+
const result = spawnSync("where.exe", ["powershell.exe"], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
if (result.error || result.status !== 0) {
|
|
56
|
+
throw new Error("Windows release acceptance requires Windows PowerShell 5.1 (powershell.exe) on PATH.");
|
|
57
|
+
}
|
|
58
|
+
const executable = result.stdout?.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
59
|
+
if (!executable) throw new Error("where.exe reported no powershell.exe path.");
|
|
60
|
+
return executable;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function powerShellVersion(executable) {
|
|
64
|
+
const result = spawnSync(
|
|
65
|
+
executable,
|
|
66
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.ToString()"],
|
|
67
|
+
{ encoding: "utf8", windowsHide: true },
|
|
68
|
+
);
|
|
69
|
+
if (result.error || result.status !== 0) {
|
|
70
|
+
throw new Error(`Unable to query ${executable} version: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
71
|
+
}
|
|
72
|
+
const version = result.stdout?.trim();
|
|
73
|
+
if (!version) throw new Error(`${executable} did not report a version.`);
|
|
74
|
+
return version;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function exerciseAgentRuntime(runtime) {
|
|
78
|
+
const [{ ProcessManager }, { BashOutputStore }] = await Promise.all([
|
|
79
|
+
import("../../dist/mcp/process/process-sessions.js"),
|
|
80
|
+
import("../../dist/activity/history/bash-output-store.js"),
|
|
81
|
+
]);
|
|
82
|
+
const durableStateDir = join(root, "durable-output-state");
|
|
83
|
+
const outputStore = new BashOutputStore(durableStateDir, {
|
|
84
|
+
outputId: () => "out_powershell51_pty_acceptance",
|
|
85
|
+
flushBytes: 1,
|
|
86
|
+
});
|
|
87
|
+
const manager = new ProcessManager({
|
|
88
|
+
commandShellRuntime: runtime,
|
|
89
|
+
outputAudit: outputStore,
|
|
90
|
+
});
|
|
91
|
+
const originalMarker = process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE;
|
|
92
|
+
process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE = "inherited environment";
|
|
93
|
+
try {
|
|
94
|
+
const node = powerShellLiteral(process.execPath);
|
|
95
|
+
const semantics = await manager.start({
|
|
96
|
+
workspaceId: "powershell51-agent",
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
command: [
|
|
99
|
+
'Write-Output "edition=$($PSVersionTable.PSEdition)"',
|
|
100
|
+
'Write-Output "version=$($PSVersionTable.PSVersion.ToString())"',
|
|
101
|
+
'Write-Output "env=$env:FORGERELAY_POWERSHELL51_ACCEPTANCE"',
|
|
102
|
+
"Write-Output 'pipe-unicode-雪'",
|
|
103
|
+
'1,2,3 | Measure-Object -Sum | ForEach-Object { Write-Output "sum=$($_.Sum)" }',
|
|
104
|
+
`$exe = ${node}`,
|
|
105
|
+
"& $exe -e 'console.log(JSON.stringify(process.argv.slice(1)))' 'native arg with spaces' 'plain-arg'",
|
|
106
|
+
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
|
|
107
|
+
"$redirect = Join-Path $env:TEMP 'forgerelay-powershell51-redirection.txt'",
|
|
108
|
+
"'redirected-through-windows-powershell' > $redirect",
|
|
109
|
+
"Get-Content $redirect",
|
|
110
|
+
"Remove-Item $redirect -Force",
|
|
111
|
+
].join("; "),
|
|
112
|
+
yieldTimeMs: 10_000,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(semantics.running, false);
|
|
115
|
+
assert.equal(semantics.exitCode, 0);
|
|
116
|
+
assert.match(semantics.output, /edition=Desktop/);
|
|
117
|
+
assert.match(semantics.output, /version=5\.1\./);
|
|
118
|
+
assert.match(semantics.output, /env=inherited environment/);
|
|
119
|
+
assert.match(semantics.output, /pipe-unicode-雪/);
|
|
120
|
+
assert.match(semantics.output, /sum=6/);
|
|
121
|
+
assert.match(semantics.output, /\["native arg with spaces","plain-arg"\]/);
|
|
122
|
+
assert.match(semantics.output, /redirected-through-windows-powershell/);
|
|
123
|
+
|
|
124
|
+
const errorSemantics = await manager.start({
|
|
125
|
+
workspaceId: "powershell51-agent",
|
|
126
|
+
cwd: process.cwd(),
|
|
127
|
+
command: [
|
|
128
|
+
"Write-Error 'nonterminating-windows-powershell-error'",
|
|
129
|
+
"Write-Output 'continued-after-nonterminating-error'",
|
|
130
|
+
"try { Get-Item 'forgerelay-definitely-missing-item' -ErrorAction Stop; exit 31 } catch { Write-Output 'terminating-error-caught' }",
|
|
131
|
+
].join("; "),
|
|
132
|
+
yieldTimeMs: 10_000,
|
|
133
|
+
});
|
|
134
|
+
assert.equal(errorSemantics.exitCode, 0);
|
|
135
|
+
assert.match(errorSemantics.output, /nonterminating-windows-powershell-error/);
|
|
136
|
+
assert.match(errorSemantics.output, /continued-after-nonterminating-error/);
|
|
137
|
+
assert.match(errorSemantics.output, /terminating-error-caught/);
|
|
138
|
+
|
|
139
|
+
const nativeExit = await manager.start({
|
|
140
|
+
workspaceId: "powershell51-agent",
|
|
141
|
+
cwd: process.cwd(),
|
|
142
|
+
command: `$exe = ${node}; & $exe -e 'process.exit(7)'; exit $LASTEXITCODE`,
|
|
143
|
+
yieldTimeMs: 10_000,
|
|
144
|
+
});
|
|
145
|
+
assert.equal(nativeExit.exitCode, 7);
|
|
146
|
+
|
|
147
|
+
const chainMarker = join(root, "powershell7-chain-operator-marker.txt");
|
|
148
|
+
const powerShell7OnlySyntax = await manager.start({
|
|
149
|
+
workspaceId: "powershell51-agent",
|
|
150
|
+
cwd: process.cwd(),
|
|
151
|
+
command: `Write-Output 'left-side' && Set-Content -LiteralPath ${powerShellLiteral(chainMarker)} -Value 'ran'`,
|
|
152
|
+
yieldTimeMs: 10_000,
|
|
153
|
+
});
|
|
154
|
+
assert.notEqual(powerShell7OnlySyntax.exitCode, 0);
|
|
155
|
+
assert.equal(existsSync(chainMarker), false, "PowerShell 7 pipeline-chain syntax unexpectedly executed under Windows PowerShell 5.1");
|
|
156
|
+
|
|
157
|
+
const background = await manager.start({
|
|
158
|
+
workspaceId: "powershell51-agent",
|
|
159
|
+
cwd: process.cwd(),
|
|
160
|
+
command: "Write-Output 'powershell51-background-start'; Start-Sleep -Milliseconds 250; Write-Output 'powershell51-background-done'",
|
|
161
|
+
yieldTimeMs: 5,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(background.running, true);
|
|
164
|
+
assert.ok(background.processId);
|
|
165
|
+
const completed = await manager.write({
|
|
166
|
+
workspaceId: "powershell51-agent",
|
|
167
|
+
processId: background.processId,
|
|
168
|
+
yieldTimeMs: 5_000,
|
|
169
|
+
});
|
|
170
|
+
assert.equal(completed.running, false);
|
|
171
|
+
assert.equal(completed.exitCode, 0);
|
|
172
|
+
assert.match(completed.output, /powershell51-background-done/);
|
|
173
|
+
|
|
174
|
+
const timedOut = await manager.start({
|
|
175
|
+
workspaceId: "powershell51-agent",
|
|
176
|
+
cwd: process.cwd(),
|
|
177
|
+
command: "Start-Sleep -Seconds 30",
|
|
178
|
+
yieldTimeMs: 5_000,
|
|
179
|
+
timeoutMs: 100,
|
|
180
|
+
});
|
|
181
|
+
assert.equal(timedOut.running, false);
|
|
182
|
+
assert.equal(timedOut.timedOut, true);
|
|
183
|
+
|
|
184
|
+
const interruptible = await manager.start({
|
|
185
|
+
workspaceId: "powershell51-agent",
|
|
186
|
+
cwd: process.cwd(),
|
|
187
|
+
command: "Write-Output 'powershell51-interrupt-ready'; Start-Sleep -Seconds 30",
|
|
188
|
+
yieldTimeMs: 5,
|
|
189
|
+
});
|
|
190
|
+
assert.equal(interruptible.running, true);
|
|
191
|
+
assert.ok(interruptible.processId);
|
|
192
|
+
const interrupted = await manager.write({
|
|
193
|
+
workspaceId: "powershell51-agent",
|
|
194
|
+
processId: interruptible.processId,
|
|
195
|
+
chars: "\u0003",
|
|
196
|
+
yieldTimeMs: 5_000,
|
|
197
|
+
});
|
|
198
|
+
assert.equal(interrupted.running, false);
|
|
199
|
+
|
|
200
|
+
await exercisePtyLifecycle(manager, outputStore, node);
|
|
201
|
+
} finally {
|
|
202
|
+
if (originalMarker === undefined) delete process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE;
|
|
203
|
+
else process.env.FORGERELAY_POWERSHELL51_ACCEPTANCE = originalMarker;
|
|
204
|
+
manager.shutdown();
|
|
205
|
+
outputStore.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function exercisePtyLifecycle(manager, outputStore, node) {
|
|
210
|
+
const pty = await manager.start({
|
|
211
|
+
workspaceId: "powershell51-agent",
|
|
212
|
+
workspaceRoot: process.cwd(),
|
|
213
|
+
audit: {
|
|
214
|
+
activityId: "act-powershell51-pty",
|
|
215
|
+
turnId: "turn-powershell51-pty",
|
|
216
|
+
conversationScopeId: "conversation-powershell51-pty",
|
|
217
|
+
},
|
|
218
|
+
cwd: process.cwd(),
|
|
219
|
+
command: [
|
|
220
|
+
"Write-Output 'powershell51-pty-ready-雪'",
|
|
221
|
+
"$line = [Console]::In.ReadLine()",
|
|
222
|
+
"Start-Sleep -Milliseconds 100",
|
|
223
|
+
'Write-Output "stdin=$line"',
|
|
224
|
+
'Write-Output "cols=$([Console]::WindowWidth);rows=$([Console]::WindowHeight)"',
|
|
225
|
+
"Write-Output 'powershell51-pty-unicode-雪'",
|
|
226
|
+
"exit 23",
|
|
227
|
+
].join("; "),
|
|
228
|
+
tty: true,
|
|
229
|
+
columns: 80,
|
|
230
|
+
rows: 24,
|
|
231
|
+
yieldTimeMs: 5,
|
|
232
|
+
});
|
|
233
|
+
assert.equal(pty.running, true);
|
|
234
|
+
assert.ok(pty.processId);
|
|
235
|
+
assert.equal(pty.outputId, "out_powershell51_pty_acceptance");
|
|
236
|
+
|
|
237
|
+
const interacted = await manager.write({
|
|
238
|
+
workspaceId: "powershell51-agent",
|
|
239
|
+
processId: pty.processId,
|
|
240
|
+
columns: 120,
|
|
241
|
+
rows: 30,
|
|
242
|
+
chars: "input-plain\r",
|
|
243
|
+
yieldTimeMs: 5_000,
|
|
244
|
+
});
|
|
245
|
+
assert.equal(interacted.running, false);
|
|
246
|
+
assert.equal(interacted.exitCode, 23);
|
|
247
|
+
const ptyOutput = `${pty.output}${interacted.output}`;
|
|
248
|
+
assert.match(ptyOutput, /powershell51-pty-ready-雪/);
|
|
249
|
+
assert.match(ptyOutput, /stdin=input-plain/);
|
|
250
|
+
assert.match(ptyOutput, /cols=120;rows=30/);
|
|
251
|
+
assert.match(ptyOutput, /powershell51-pty-unicode-雪/);
|
|
252
|
+
|
|
253
|
+
const durable = outputStore.read(pty.outputId);
|
|
254
|
+
assert.ok(durable);
|
|
255
|
+
assert.equal(durable.tty, true);
|
|
256
|
+
assert.equal(durable.exitCode, 23);
|
|
257
|
+
assert.equal(durable.status, "failed");
|
|
258
|
+
assert.match(durable.output, /powershell51-pty-ready-雪/);
|
|
259
|
+
assert.match(durable.output, /stdin=input-plain/);
|
|
260
|
+
assert.match(durable.output, /powershell51-pty-unicode-雪/);
|
|
261
|
+
|
|
262
|
+
const background = await manager.start({
|
|
263
|
+
workspaceId: "powershell51-agent",
|
|
264
|
+
cwd: process.cwd(),
|
|
265
|
+
command: "Write-Output 'powershell51-pty-background-start'; Start-Sleep -Milliseconds 250; Write-Output 'powershell51-pty-background-done'",
|
|
266
|
+
tty: true,
|
|
267
|
+
yieldTimeMs: 5,
|
|
268
|
+
});
|
|
269
|
+
assert.equal(background.running, true);
|
|
270
|
+
assert.ok(background.processId);
|
|
271
|
+
const backgroundDone = await manager.write({
|
|
272
|
+
workspaceId: "powershell51-agent",
|
|
273
|
+
processId: background.processId,
|
|
274
|
+
yieldTimeMs: 5_000,
|
|
275
|
+
});
|
|
276
|
+
assert.equal(backgroundDone.running, false);
|
|
277
|
+
assert.equal(backgroundDone.exitCode, 0);
|
|
278
|
+
assert.match(`${background.output}${backgroundDone.output}`, /powershell51-pty-background-done/);
|
|
279
|
+
|
|
280
|
+
const timedOut = await manager.start({
|
|
281
|
+
workspaceId: "powershell51-agent",
|
|
282
|
+
cwd: process.cwd(),
|
|
283
|
+
command: "Start-Sleep -Seconds 30",
|
|
284
|
+
tty: true,
|
|
285
|
+
yieldTimeMs: 5_000,
|
|
286
|
+
timeoutMs: 100,
|
|
287
|
+
});
|
|
288
|
+
assert.equal(timedOut.running, false);
|
|
289
|
+
assert.equal(timedOut.timedOut, true);
|
|
290
|
+
|
|
291
|
+
const pidPath = join(root, "powershell51-pty-child.pid");
|
|
292
|
+
const childScript = "require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)";
|
|
293
|
+
const interruptible = await manager.start({
|
|
294
|
+
workspaceId: "powershell51-agent",
|
|
295
|
+
cwd: process.cwd(),
|
|
296
|
+
command: [
|
|
297
|
+
"Write-Output 'powershell51-pty-interrupt-ready'",
|
|
298
|
+
`$exe = ${node}`,
|
|
299
|
+
`& $exe -e ${powerShellLiteral(childScript)} ${powerShellLiteral(pidPath)}`,
|
|
300
|
+
].join("; "),
|
|
301
|
+
tty: true,
|
|
302
|
+
yieldTimeMs: 5,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(interruptible.running, true);
|
|
305
|
+
assert.ok(interruptible.processId);
|
|
306
|
+
const childPid = Number.parseInt(await waitForFile(pidPath), 10);
|
|
307
|
+
assert.ok(Number.isInteger(childPid) && childPid > 0, `invalid PTY child pid: ${childPid}`);
|
|
308
|
+
assert.equal(windowsProcessExists(childPid), true);
|
|
309
|
+
|
|
310
|
+
const interrupted = await manager.write({
|
|
311
|
+
workspaceId: "powershell51-agent",
|
|
312
|
+
processId: interruptible.processId,
|
|
313
|
+
chars: "\u0003",
|
|
314
|
+
yieldTimeMs: 5_000,
|
|
315
|
+
});
|
|
316
|
+
assert.equal(interrupted.running, false);
|
|
317
|
+
await waitForWindowsProcessExit(childPid);
|
|
318
|
+
assert.equal(windowsProcessExists(childPid), false, `PTY child process ${childPid} leaked after interrupt`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function exerciseHookRuntime(runtime) {
|
|
322
|
+
const { HookRunner, parseHookConfig } = await import("../../dist/mcp/hooks/hooks.js");
|
|
323
|
+
const logging = {
|
|
324
|
+
level: "silent",
|
|
325
|
+
format: "json",
|
|
326
|
+
requests: false,
|
|
327
|
+
assets: false,
|
|
328
|
+
toolCalls: false,
|
|
329
|
+
shellCommands: false,
|
|
330
|
+
trustProxy: false,
|
|
331
|
+
};
|
|
332
|
+
const runner = new HookRunner(
|
|
333
|
+
parseHookConfig({
|
|
334
|
+
BeforeTool: [{
|
|
335
|
+
handlers: [{
|
|
336
|
+
name: "Windows PowerShell policy",
|
|
337
|
+
command: [
|
|
338
|
+
"if ($PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1) { exit 17 }",
|
|
339
|
+
"if ($env:FORGERELAY_WORKSPACE_ID -ne 'powershell51-hook') { exit 19 }",
|
|
340
|
+
"Write-Error 'windows powershell policy denied'",
|
|
341
|
+
"exit 13",
|
|
342
|
+
].join("; "),
|
|
343
|
+
}],
|
|
344
|
+
}],
|
|
345
|
+
}),
|
|
346
|
+
logging,
|
|
347
|
+
process.env,
|
|
348
|
+
undefined,
|
|
349
|
+
runtime,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
await assert.rejects(
|
|
353
|
+
() => runner.run("BeforeTool", {
|
|
354
|
+
workspaceId: "powershell51-hook",
|
|
355
|
+
workspaceRoot: process.cwd(),
|
|
356
|
+
workspaceMode: "checkout",
|
|
357
|
+
payload: { tool: "bash", command: "Write-Output 'agent command'" },
|
|
358
|
+
}),
|
|
359
|
+
/Windows PowerShell policy exited with code 13: .*windows powershell policy denied/i,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function exercisePackagedPowerShellShim(powershell, expectedVersion) {
|
|
364
|
+
const artifactDir = join(root, "artifact");
|
|
365
|
+
const prefix = join(root, "prefix");
|
|
366
|
+
const configDir = join(root, "config");
|
|
367
|
+
const stateDir = join(root, "state");
|
|
368
|
+
await Promise.all([
|
|
369
|
+
mkdir(artifactDir, { recursive: true }),
|
|
370
|
+
mkdir(prefix, { recursive: true }),
|
|
371
|
+
mkdir(configDir, { recursive: true }),
|
|
372
|
+
mkdir(stateDir, { recursive: true }),
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const packed = runNpm(["pack", "--json", "--pack-destination", artifactDir]);
|
|
376
|
+
const packResult = JSON.parse(packed.stdout);
|
|
377
|
+
const filename = packResult?.[0]?.filename;
|
|
378
|
+
if (!filename) throw new Error(`npm pack did not report a package filename: ${packed.stdout}`);
|
|
379
|
+
const tarball = join(artifactDir, filename);
|
|
380
|
+
assert.ok(existsSync(tarball), `packed artifact is missing: ${tarball}`);
|
|
381
|
+
|
|
382
|
+
runNpm(["install", "--global", "--prefix", prefix, tarball]);
|
|
383
|
+
const shim = join(prefix, "forgerelay.ps1");
|
|
384
|
+
assert.ok(existsSync(shim), `npm did not create the PowerShell launcher shim: ${shim}`);
|
|
385
|
+
|
|
386
|
+
await writeFile(
|
|
387
|
+
join(configDir, "config.json"),
|
|
388
|
+
JSON.stringify({
|
|
389
|
+
host: "127.0.0.1",
|
|
390
|
+
port: 7678,
|
|
391
|
+
allowedRoots: [process.cwd()],
|
|
392
|
+
stateDir,
|
|
393
|
+
worktreeRoot: join(root, "worktrees"),
|
|
394
|
+
commandShell: {
|
|
395
|
+
mode: "follow-launcher",
|
|
396
|
+
family: "powershell",
|
|
397
|
+
executable: powershell,
|
|
398
|
+
},
|
|
399
|
+
shellInstructions: false,
|
|
400
|
+
}, null, 2),
|
|
401
|
+
"utf8",
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const launcherEnv = {
|
|
405
|
+
...process.env,
|
|
406
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
407
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: "powershell51-acceptance-owner-token-long-enough",
|
|
408
|
+
};
|
|
409
|
+
delete launcherEnv.npm_lifecycle_event;
|
|
410
|
+
delete launcherEnv.FORGERELAY_COMMAND_SHELL;
|
|
411
|
+
|
|
412
|
+
const result = spawnSync(
|
|
413
|
+
powershell,
|
|
414
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-File", shim, "doctor"],
|
|
415
|
+
{
|
|
416
|
+
cwd: process.cwd(),
|
|
417
|
+
env: launcherEnv,
|
|
418
|
+
encoding: "utf8",
|
|
419
|
+
windowsHide: true,
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
if (result.error || result.status !== 0) {
|
|
423
|
+
throw new Error(`Packaged Windows PowerShell launcher failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
424
|
+
}
|
|
425
|
+
assert.match(
|
|
426
|
+
result.stdout ?? "",
|
|
427
|
+
new RegExp(`Command shell: powershell ${escapeRegExp(expectedVersion)} \\(.+; launcher\\)`),
|
|
428
|
+
);
|
|
429
|
+
assert.doesNotMatch(result.stdout ?? "", /Command shell: pwsh/);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function runNpm(args) {
|
|
433
|
+
const result = spawnSync(process.execPath, [npmCli, ...args], {
|
|
434
|
+
cwd: process.cwd(),
|
|
435
|
+
env: process.env,
|
|
436
|
+
encoding: "utf8",
|
|
437
|
+
windowsHide: true,
|
|
438
|
+
});
|
|
439
|
+
if (result.error || result.status !== 0) {
|
|
440
|
+
throw new Error(`npm ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function waitForFile(path, timeoutMs = 5_000) {
|
|
446
|
+
const deadline = Date.now() + timeoutMs;
|
|
447
|
+
while (Date.now() < deadline) {
|
|
448
|
+
try {
|
|
449
|
+
return await readFile(path, "utf8");
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
452
|
+
}
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`Timed out waiting for file: ${path}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function windowsProcessExists(pid) {
|
|
459
|
+
const result = spawnSync(
|
|
460
|
+
"tasklist.exe",
|
|
461
|
+
["/fi", `PID eq ${pid}`, "/fo", "csv", "/nh"],
|
|
462
|
+
{ encoding: "utf8", windowsHide: true },
|
|
463
|
+
);
|
|
464
|
+
if (result.error) throw result.error;
|
|
465
|
+
if (result.status !== 0) throw new Error(`tasklist.exe failed with exit ${result.status ?? "unknown"}`);
|
|
466
|
+
return new RegExp(`"${pid}"(?:,|$)`).test(result.stdout ?? "");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function waitForWindowsProcessExit(pid, timeoutMs = 5_000) {
|
|
470
|
+
const deadline = Date.now() + timeoutMs;
|
|
471
|
+
while (Date.now() < deadline) {
|
|
472
|
+
if (!windowsProcessExists(pid)) return;
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function powerShellLiteral(value) {
|
|
478
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function escapeRegExp(value) {
|
|
482
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
}
|