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