harnery 0.43.0 → 0.44.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.
- package/README.md +3 -3
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +41 -48
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +38 -11
- package/dist/commands/diagnostics.d.ts.map +1 -1
- package/dist/commands/diagnostics.js +10 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +22 -7
- package/dist/commands/governor.js +22 -10
- package/dist/commands/inbox.d.ts +1 -1
- package/dist/commands/inbox.d.ts.map +1 -1
- package/dist/commands/inbox.js +55 -2
- package/dist/commands/rm.d.ts.map +1 -1
- package/dist/commands/rm.js +28 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +16 -1
- package/dist/commands/supervisor.d.ts.map +1 -1
- package/dist/commands/supervisor.js +7 -2
- package/dist/commands/toc.d.ts +14 -0
- package/dist/commands/toc.d.ts.map +1 -1
- package/dist/commands/toc.js +7 -4
- package/dist/commands/workflow.js +24 -30
- package/dist/core/agents/live-authority-v3.d.ts +1 -0
- package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
- package/dist/core/agents/live-authority-v3.js +11 -0
- package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
- package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
- package/dist/core/agents/live-lifecycle-v3.js +28 -7
- package/dist/core/agents/mailbox.d.ts +9 -8
- package/dist/core/agents/mailbox.d.ts.map +1 -1
- package/dist/core/agents/mailbox.js +24 -17
- package/dist/core/agents/render/prompt-context.d.ts +2 -0
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +8 -12
- package/dist/core/agents/render/session-context.d.ts +2 -8
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +7 -30
- package/dist/core/agents/rules/stop-hook.js +3 -3
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +25 -14
- package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-writer.js +5 -0
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +5 -0
- package/dist/core/agents/state/proc-start.d.ts +19 -0
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +41 -0
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +180 -70
- package/dist/core/agents/state/unchanged-file.d.ts +44 -0
- package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
- package/dist/core/agents/state/unchanged-file.js +390 -0
- package/dist/core/context/index.d.ts +22 -10
- package/dist/core/context/index.d.ts.map +1 -1
- package/dist/core/context/index.js +381 -119
- package/dist/core/context/observation.d.ts +26 -0
- package/dist/core/context/observation.d.ts.map +1 -0
- package/dist/core/context/observation.js +11 -0
- package/dist/core/diagnostics/bundle.d.ts.map +1 -1
- package/dist/core/diagnostics/bundle.js +25 -14
- package/dist/core/diagnostics/path-identity.d.ts +3 -0
- package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
- package/dist/core/diagnostics/path-identity.js +12 -0
- package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
- package/dist/core/diagnostics/sanitize.js +4 -3
- package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
- package/dist/core/events/v3/authority-outbox.js +29 -0
- package/dist/core/events/v3/generation-custody.d.ts +5 -0
- package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
- package/dist/core/events/v3/generation-custody.js +50 -0
- package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
- package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +77 -64
- package/dist/core/governor/planning.d.ts.map +1 -1
- package/dist/core/governor/planning.js +14 -1
- package/dist/core/governor/service-read.d.ts +10 -2
- package/dist/core/governor/service-read.d.ts.map +1 -1
- package/dist/core/governor/service-read.js +105 -22
- package/dist/core/governor/service.d.ts +20 -2
- package/dist/core/governor/service.d.ts.map +1 -1
- package/dist/core/governor/service.js +72 -47
- package/dist/core/hooks/cli.js +33 -5
- package/dist/core/inbox/contract.d.ts +2 -1
- package/dist/core/inbox/contract.d.ts.map +1 -1
- package/dist/core/inbox/index.d.ts +2 -0
- package/dist/core/inbox/index.d.ts.map +1 -1
- package/dist/core/inbox/index.js +2 -0
- package/dist/core/inbox/peer-messages.d.ts +37 -0
- package/dist/core/inbox/peer-messages.d.ts.map +1 -0
- package/dist/core/inbox/peer-messages.js +72 -0
- package/dist/core/inbox/service.d.ts +8 -1
- package/dist/core/inbox/service.d.ts.map +1 -1
- package/dist/core/inbox/service.js +67 -21
- package/dist/core/inbox/targets.d.ts +22 -0
- package/dist/core/inbox/targets.d.ts.map +1 -0
- package/dist/core/inbox/targets.js +108 -0
- package/dist/core/inbox/watcher.js +7 -3
- package/dist/core/journal/index.d.ts.map +1 -1
- package/dist/core/journal/index.js +17 -2
- package/dist/core/resources/contract.d.ts +3 -0
- package/dist/core/resources/contract.d.ts.map +1 -1
- package/dist/core/resources/service-status.d.ts +14 -1
- package/dist/core/resources/service-status.d.ts.map +1 -1
- package/dist/core/resources/service-status.js +23 -13
- package/dist/core/resources/service.d.ts +9 -1
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +47 -27
- package/dist/core/resources/status.d.ts +2 -5
- package/dist/core/resources/status.d.ts.map +1 -1
- package/dist/core/resources/status.js +36 -7
- package/dist/core/servers/index.d.ts +24 -13
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +132 -92
- package/dist/core/servers/tunnels.d.ts +3 -5
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +3 -9
- package/dist/core/supervisor/contract.d.ts +12 -0
- package/dist/core/supervisor/contract.d.ts.map +1 -1
- package/dist/core/supervisor/explanations.d.ts.map +1 -1
- package/dist/core/supervisor/explanations.js +2 -1
- package/dist/core/supervisor/findings.d.ts.map +1 -1
- package/dist/core/supervisor/findings.js +54 -9
- package/dist/core/supervisor/service.d.ts +9 -1
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +59 -29
- package/dist/core/supervisor/services.d.ts.map +1 -1
- package/dist/core/supervisor/services.js +22 -1
- package/dist/core/supervisor/status.d.ts +17 -2
- package/dist/core/supervisor/status.d.ts.map +1 -1
- package/dist/core/supervisor/status.js +70 -12
- package/dist/core/supervisor/timeline.d.ts.map +1 -1
- package/dist/core/supervisor/timeline.js +2 -1
- package/dist/core/work/state.js +7 -7
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +105 -32
- package/dist/core/workflow/failure-evidence.d.ts +16 -0
- package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
- package/dist/core/workflow/failure-evidence.js +114 -0
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -1
- package/dist/core/workflow/live-session-v3.d.ts +6 -0
- package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
- package/dist/core/workflow/live-session-v3.js +126 -5
- package/dist/core/workflow/proof.d.ts +5 -5
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +115 -20
- package/dist/core/workflow/run-state.d.ts +3 -1
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +31 -27
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +19 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +13 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +19 -0
- package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
- package/dist/core/workflow/spawn-opencode.js +14 -0
- package/dist/core/workflow/types.d.ts +64 -17
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.d.ts +0 -1
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.js +0 -1
- package/dist/core/workflow/workspaces/leases.d.ts +3 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +30 -16
- package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +270 -6
- package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
- package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/ownership.js +194 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/reclaim.js +14 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +21 -13
- package/dist/core/workflow/workspaces/state.d.ts +1 -0
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +21 -1
- package/dist/lib/browser/windows-chrome.d.ts +100 -12
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
- package/dist/lib/browser/windows-chrome.js +262 -73
- package/dist/lib/docs-links.d.ts.map +1 -1
- package/dist/lib/docs-links.js +45 -2
- package/dist/lib/guarded-remove.d.ts +7 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +106 -12
- package/dist/lib/identities/assume.d.ts.map +1 -1
- package/dist/lib/identities/assume.js +4 -0
- package/dist/lib/zip/index.d.ts +41 -0
- package/dist/lib/zip/index.d.ts.map +1 -0
- package/dist/lib/zip/index.js +398 -0
- package/package.json +7 -1
- package/src/commands/agents.ts +54 -63
- package/src/commands/browse.ts +50 -14
- package/src/commands/diagnostics.ts +10 -0
- package/src/commands/doctor.ts +26 -7
- package/src/commands/governor.ts +23 -10
- package/src/commands/inbox.ts +52 -2
- package/src/commands/rm.ts +43 -4
- package/src/commands/servers.ts +16 -4
- package/src/commands/supervisor.ts +7 -2
- package/src/commands/toc.ts +14 -6
- package/src/commands/workflow.ts +30 -30
- package/src/core/agents/live-authority-v3.ts +17 -0
- package/src/core/agents/live-lifecycle-v3.ts +52 -14
- package/src/core/agents/mailbox.ts +26 -17
- package/src/core/agents/render/prompt-context.ts +10 -12
- package/src/core/agents/render/session-context.ts +9 -32
- package/src/core/agents/rules/stop-hook.ts +3 -3
- package/src/core/agents/state/heartbeat-writer.ts +29 -12
- package/src/core/agents/state/live-coordination-writer.ts +29 -0
- package/src/core/agents/state/pidmap.ts +13 -0
- package/src/core/agents/state/proc-start.ts +57 -0
- package/src/core/agents/state/stale-sweep.ts +247 -82
- package/src/core/agents/state/unchanged-file.ts +456 -0
- package/src/core/context/index.ts +446 -136
- package/src/core/context/observation.ts +29 -0
- package/src/core/diagnostics/bundle.ts +23 -18
- package/src/core/diagnostics/path-identity.ts +15 -0
- package/src/core/diagnostics/sanitize.ts +4 -3
- package/src/core/events/v3/authority-outbox.ts +56 -0
- package/src/core/events/v3/generation-custody.ts +59 -0
- package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
- package/src/core/events/v3/producers/recorder.ts +94 -68
- package/src/core/governor/planning.ts +20 -8
- package/src/core/governor/service-read.ts +128 -20
- package/src/core/governor/service.ts +119 -42
- package/src/core/hooks/cli.ts +33 -5
- package/src/core/inbox/contract.ts +2 -1
- package/src/core/inbox/index.ts +2 -0
- package/src/core/inbox/peer-messages.ts +94 -0
- package/src/core/inbox/service.ts +88 -22
- package/src/core/inbox/targets.ts +148 -0
- package/src/core/inbox/watcher.ts +8 -9
- package/src/core/journal/index.ts +19 -2
- package/src/core/resources/contract.ts +4 -0
- package/src/core/resources/service-status.ts +35 -12
- package/src/core/resources/service.ts +71 -24
- package/src/core/resources/status.ts +57 -8
- package/src/core/servers/index.ts +177 -91
- package/src/core/servers/tunnels.ts +3 -11
- package/src/core/supervisor/contract.ts +13 -0
- package/src/core/supervisor/explanations.ts +4 -1
- package/src/core/supervisor/findings.ts +68 -10
- package/src/core/supervisor/service.ts +88 -26
- package/src/core/supervisor/services.ts +26 -8
- package/src/core/supervisor/status.ts +89 -12
- package/src/core/supervisor/timeline.ts +2 -1
- package/src/core/work/state.ts +7 -7
- package/src/core/workflow/engine.ts +131 -49
- package/src/core/workflow/failure-evidence.ts +141 -0
- package/src/core/workflow/index.ts +0 -1
- package/src/core/workflow/live-session-v3.ts +146 -5
- package/src/core/workflow/proof.ts +134 -26
- package/src/core/workflow/run-state.ts +31 -29
- package/src/core/workflow/spawn-claude.ts +18 -0
- package/src/core/workflow/spawn-codex.ts +13 -0
- package/src/core/workflow/spawn-cursor.ts +18 -0
- package/src/core/workflow/spawn-opencode.ts +14 -0
- package/src/core/workflow/types.ts +51 -17
- package/src/core/workflow/workspaces/index.ts +0 -1
- package/src/core/workflow/workspaces/leases.ts +48 -17
- package/src/core/workflow/workspaces/local-git.ts +315 -5
- package/src/core/workflow/workspaces/ownership.ts +232 -0
- package/src/core/workflow/workspaces/reclaim.ts +58 -0
- package/src/core/workflow/workspaces/recovery.ts +25 -13
- package/src/core/workflow/workspaces/state.ts +20 -0
- package/src/lib/browser/windows-chrome.ts +346 -86
- package/src/lib/docs-links.ts +49 -2
- package/src/lib/guarded-remove.ts +132 -14
- package/src/lib/identities/assume.ts +4 -0
- package/src/lib/zip/index.ts +483 -0
|
@@ -7,18 +7,19 @@ import type { Cookie } from "../cookies/client.ts";
|
|
|
7
7
|
/**
|
|
8
8
|
* Sign-in through the Windows host's Google Chrome when running under WSL.
|
|
9
9
|
*
|
|
10
|
-
* Some sign-in flows
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
10
|
+
* Some sign-in flows reject every browser running inside WSL, including a
|
|
11
|
+
* plain Linux Google Chrome with nothing attached: the sign-in request comes
|
|
12
|
+
* back "429 Too Many Requests" while the same account signs in from the
|
|
13
|
+
* Windows desktop browser without trouble. The Windows Chrome is the client
|
|
14
|
+
* those flows accept, so `browse --login --plain` under WSL opens it on a
|
|
15
|
+
* dedicated Windows-side profile. The person signs in there with nothing
|
|
16
|
+
* attached. Afterwards the profile is reopened headless for a moment with its
|
|
17
|
+
* last session restored, its cookies for the sign-in site (browser-session
|
|
18
|
+
* cookies included) and its open pages on that site are read through the
|
|
19
|
+
* DevTools protocol, and the cookies are merged into the shared cookie store
|
|
20
|
+
* that later `browse` runs attach. The Windows profile cannot be shared with
|
|
21
|
+
* Linux Chromium directly because Windows encrypts its cookie database with
|
|
22
|
+
* keys only Windows can read.
|
|
22
23
|
*
|
|
23
24
|
* Under WSL2's default NAT networking the Windows loopback port is not
|
|
24
25
|
* reachable from Linux, so the DevTools conversation runs inside a short
|
|
@@ -106,117 +107,376 @@ export function cookiesForDomain(cdpCookies: CdpCookie[], domain: string): Cooki
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
110
|
+
* Regular expression, valid in both JavaScript and .NET, that matches a Chrome
|
|
111
|
+
* command line running on `profileWindows`. It matches the `--user-data-dir`
|
|
112
|
+
* argument exactly, with or without quotes around the path, so `...\profile`
|
|
113
|
+
* never matches `...\profile-2`. Quotes are written as `\x22` so the pattern
|
|
114
|
+
* survives being passed to PowerShell as an argument. Case-insensitive use is
|
|
115
|
+
* intended, as Windows paths are.
|
|
114
116
|
*/
|
|
115
|
-
export function
|
|
117
|
+
export function profileCommandLinePattern(profileWindows: string): string {
|
|
118
|
+
const path = profileWindows.replace(/\\+$/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
119
|
+
return `--user-data-dir=\\x22?${path}\\\\?(\\x22|\\s|$)`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** PowerShell function listing the Chrome processes whose command line matches `$Pattern`. */
|
|
123
|
+
const PROFILE_PROCESS_FUNCTION =
|
|
124
|
+
"function Get-ProfileChrome { @(Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine -match $Pattern }) }";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* PowerShell that throws while any Chrome is still running on the profile: a
|
|
128
|
+
* second Chrome on the same profile hands its work to the first and exits.
|
|
129
|
+
*/
|
|
130
|
+
const REFUSE_OPEN_PROFILE_LINES = [
|
|
131
|
+
PROFILE_PROCESS_FUNCTION,
|
|
132
|
+
"$alive = Get-ProfileChrome",
|
|
133
|
+
"if ($alive.Count -gt 0) { throw \"Chrome is still running on the sign-in profile (process $(($alive | ForEach-Object { $_.ProcessId }) -join ', ')). Close that window and try again.\" }",
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* PowerShell that starts Chrome headless on `$Profile` with `extraArgs`, waits
|
|
138
|
+
* for the DevToolsActivePort file it writes, and connects to the browser
|
|
139
|
+
* endpoint, defining `Send`, `Recv`, and `Wait-Reply` for the conversation.
|
|
140
|
+
* It refuses to start while any Chrome is still running on the profile,
|
|
141
|
+
* which would otherwise surface as a confusing startup failure.
|
|
142
|
+
*/
|
|
143
|
+
function headlessSessionLines(extraArgs: string[]): string[] {
|
|
144
|
+
const quoted = extraArgs.map((arg) => `'${arg}'`).join(", ");
|
|
116
145
|
return [
|
|
117
|
-
|
|
118
|
-
"$ErrorActionPreference = 'Stop'",
|
|
146
|
+
...REFUSE_OPEN_PROFILE_LINES,
|
|
119
147
|
"$portFile = Join-Path $Profile 'DevToolsActivePort'",
|
|
120
148
|
"if (Test-Path $portFile) { Remove-Item $portFile -Force }",
|
|
121
|
-
|
|
122
|
-
"$proc = Start-Process -FilePath $Chrome -ArgumentList $
|
|
149
|
+
`$chromeArgs = @('--headless=new', '--no-first-run', '--no-default-browser-check', ${quoted ? `${quoted}, ` : ""}'--remote-debugging-port=0', "--user-data-dir=\`"$Profile\`"", 'about:blank')`,
|
|
150
|
+
"$proc = Start-Process -FilePath $Chrome -ArgumentList $chromeArgs -PassThru -WindowStyle Hidden",
|
|
123
151
|
"$deadline = (Get-Date).AddSeconds($StartupSeconds)",
|
|
124
|
-
"while (-not (Test-Path $portFile)) { if ((Get-Date) -gt $deadline) { throw 'Windows Chrome did not open its DevTools port in time.' }; if ($proc.HasExited) { throw 'Windows Chrome exited before opening its DevTools port.' }; Start-Sleep -Milliseconds 250 }",
|
|
152
|
+
"while (-not (Test-Path $portFile)) { if ((Get-Date) -gt $deadline) { throw 'Windows Chrome did not open its DevTools port in time.' }; if ($proc.HasExited) { throw 'Windows Chrome exited before opening its DevTools port; another Chrome may have opened on the sign-in profile.' }; Start-Sleep -Milliseconds 250 }",
|
|
125
153
|
"Start-Sleep -Milliseconds 200",
|
|
126
154
|
"$lines = Get-Content $portFile",
|
|
127
155
|
"$port = [int]$lines[0]; $path = $lines[1]",
|
|
128
156
|
"$ws = New-Object System.Net.WebSockets.ClientWebSocket",
|
|
129
|
-
"$cts = New-Object System.Threading.CancellationTokenSource
|
|
157
|
+
"$cts = New-Object System.Threading.CancellationTokenSource 30000",
|
|
130
158
|
'$ws.ConnectAsync([Uri]("ws://127.0.0.1:$port$path"), $cts.Token).Wait()',
|
|
131
|
-
"function Send($obj) { $b = [Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json -Compress)); $ws.SendAsync([ArraySegment[byte]]$b, 'Text', $true, $cts.Token).Wait() }",
|
|
159
|
+
"function Send($obj) { $b = [Text.Encoding]::UTF8.GetBytes(($obj | ConvertTo-Json -Compress -Depth 6)); $ws.SendAsync([ArraySegment[byte]]$b, 'Text', $true, $cts.Token).Wait() }",
|
|
132
160
|
"function Recv() { $buf = New-Object byte[] 65536; $ms = New-Object IO.MemoryStream; do { $r = $ws.ReceiveAsync([ArraySegment[byte]]$buf, $cts.Token).Result; $ms.Write($buf, 0, $r.Count) } while (-not $r.EndOfMessage); [Text.Encoding]::UTF8.GetString($ms.ToArray()) }",
|
|
161
|
+
"function Wait-Reply([int]$n) { while ($true) { $m = Recv; if ($m -match ('\"id\":' + $n + '[,}]')) { return $m } } }",
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** PowerShell that closes the headless browser started by `headlessSessionLines`. */
|
|
166
|
+
const CLOSE_HEADLESS_LINES = [
|
|
167
|
+
"try { Send @{ id = 0; method = 'Browser.close' } } catch {}",
|
|
168
|
+
"if (-not $proc.WaitForExit(10000)) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue }",
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* PowerShell that sets the profile's startup preference to "continue where
|
|
173
|
+
* you left off". Chrome deletes browser-session cookies (cookies with no
|
|
174
|
+
* expiry) when it exits normally unless that preference is set, and many
|
|
175
|
+
* sign-ins keep the signed-in state only in such cookies. With it set, the
|
|
176
|
+
* person can simply close the window and the sign-in survives for the
|
|
177
|
+
* harvest, and the next sign-in window reopens where they left off.
|
|
178
|
+
*
|
|
179
|
+
* The preference is protected against edits to the profile's files, so the
|
|
180
|
+
* script sets it the way the settings page does: it opens the profile
|
|
181
|
+
* headless, loads the settings page, and calls the settings API there. When
|
|
182
|
+
* the profile's preference files already show the value, Chrome is not
|
|
183
|
+
* started. The script prints `{"changed":bool,"value":n}`.
|
|
184
|
+
*/
|
|
185
|
+
export function keepSessionScript(): string {
|
|
186
|
+
return [
|
|
187
|
+
"param([string]$Chrome, [string]$Profile, [string]$Pattern, [int]$StartupSeconds = 30)",
|
|
188
|
+
"$ErrorActionPreference = 'Stop'",
|
|
189
|
+
...REFUSE_OPEN_PROFILE_LINES,
|
|
190
|
+
"foreach ($name in @('Secure Preferences', 'Preferences')) { $prefsFile = Join-Path (Join-Path $Profile 'Default') $name; if (Test-Path $prefsFile) { try { $prefs = Get-Content -Raw $prefsFile | ConvertFrom-Json; if ($prefs.session.restore_on_startup -eq 1) { [Console]::Out.Write('{\"changed\":false,\"value\":1}'); exit 0 } } catch {} } }",
|
|
191
|
+
...headlessSessionLines([]),
|
|
192
|
+
"Send @{ id = 1; method = 'Target.createTarget'; params = @{ url = 'chrome://settings/onStartup' } }",
|
|
193
|
+
"$targetId = (ConvertFrom-Json (Wait-Reply 1)).result.targetId",
|
|
194
|
+
"Send @{ id = 2; method = 'Target.attachToTarget'; params = @{ targetId = $targetId; flatten = $true } }",
|
|
195
|
+
"$sessionId = (ConvertFrom-Json (Wait-Reply 2)).result.sessionId",
|
|
196
|
+
"$expr = \"new Promise((resolve) => { const started = Date.now(); const attempt = () => { const api = globalThis.chrome && chrome.settingsPrivate; if (api) { api.setPref('session.restore_on_startup', 1, '', () => api.getPref('session.restore_on_startup', (pref) => resolve(JSON.stringify({ changed: true, value: pref ? pref.value : null })))); } else if (Date.now() - started > 10000) { resolve(JSON.stringify({ changed: false, value: null })); } else { setTimeout(attempt, 100); } }; attempt(); })\"",
|
|
197
|
+
"Send @{ id = 3; sessionId = $sessionId; method = 'Runtime.evaluate'; params = @{ expression = $expr; awaitPromise = $true; returnByValue = $true } }",
|
|
198
|
+
"$value = (ConvertFrom-Json (Wait-Reply 3)).result.result.value",
|
|
199
|
+
"Start-Sleep -Milliseconds 300",
|
|
200
|
+
...CLOSE_HEADLESS_LINES,
|
|
201
|
+
"[Console]::Out.Write($value)",
|
|
202
|
+
].join("\r\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* PowerShell that does the whole read on the Windows side: start Chrome
|
|
207
|
+
* headless on the profile with its last session restored, read every cookie
|
|
208
|
+
* with `Storage.getCookies`, list the open pages with `Target.getTargets`,
|
|
209
|
+
* write both raw responses to stdout as one JSON object, and close the
|
|
210
|
+
* browser. WSL never touches the profile folder, which it may not be able to
|
|
211
|
+
* list. Every wait carries a deadline.
|
|
212
|
+
*
|
|
213
|
+
* Restoring the session keeps browser-session cookies that a forced stop left
|
|
214
|
+
* on disk, and it reopens the tabs the person had open, so the harvest can
|
|
215
|
+
* report the last page they reached on the sign-in site. That page's URL may
|
|
216
|
+
* carry a per-session token that cookies alone do not replace. The page list
|
|
217
|
+
* is polled for up to `$PageSeconds` until a page on `$Domain` appears.
|
|
218
|
+
*/
|
|
219
|
+
export function harvestScript(): string {
|
|
220
|
+
return [
|
|
221
|
+
"param([string]$Chrome, [string]$Profile, [string]$Pattern, [string]$Domain = '', [int]$StartupSeconds = 30, [int]$PageSeconds = 5)",
|
|
222
|
+
"$ErrorActionPreference = 'Stop'",
|
|
223
|
+
...headlessSessionLines(["--restore-last-session"]),
|
|
224
|
+
"function Test-SiteUrl([string]$u) { if (-not $Domain) { return $false }; try { $h = ([Uri]$u).Host.ToLowerInvariant() } catch { return $false }; return ($h -eq $Domain -or $h.EndsWith('.' + $Domain)) }",
|
|
133
225
|
"Send @{ id = 1; method = 'Storage.getCookies' }",
|
|
134
|
-
"$
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
138
|
-
"
|
|
226
|
+
"$cookiesReply = Wait-Reply 1",
|
|
227
|
+
"$pageDeadline = (Get-Date).AddSeconds($PageSeconds)",
|
|
228
|
+
"$n = 1",
|
|
229
|
+
"while ($true) { $n++; Send @{ id = $n; method = 'Target.getTargets' }; $targetsReply = Wait-Reply $n; $sitePages = @((ConvertFrom-Json $targetsReply).result.targetInfos | Where-Object { $_.type -eq 'page' -and (Test-SiteUrl $_.url) }); if ($sitePages.Count -gt 0 -or (Get-Date) -gt $pageDeadline) { break }; Start-Sleep -Milliseconds 250 }",
|
|
230
|
+
"[Console]::Out.Write('{\"cookies\":' + $cookiesReply + ',\"targets\":' + $targetsReply + '}')",
|
|
231
|
+
...CLOSE_HEADLESS_LINES,
|
|
139
232
|
].join("\r\n");
|
|
140
233
|
}
|
|
141
234
|
|
|
235
|
+
/** Shape `Target.getTargets` returns; extra fields are ignored. */
|
|
236
|
+
interface CdpTargetInfo {
|
|
237
|
+
type: string;
|
|
238
|
+
url: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
142
241
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
242
|
+
* URLs of the open pages on the sign-in site, in the order Chrome lists them,
|
|
243
|
+
* without duplicates.
|
|
145
244
|
*/
|
|
146
|
-
export function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
245
|
+
export function sitePages(targets: CdpTargetInfo[], domain: string): string[] {
|
|
246
|
+
const pages: string[] = [];
|
|
247
|
+
for (const target of targets) {
|
|
248
|
+
if (target.type !== "page") continue;
|
|
249
|
+
let host: string;
|
|
250
|
+
try {
|
|
251
|
+
host = new URL(target.url).hostname;
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (host && cookieInDomain(host, domain) && !pages.includes(target.url)) pages.push(target.url);
|
|
256
|
+
}
|
|
257
|
+
return pages;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface WindowsChromeHarvest {
|
|
261
|
+
/** The sign-in site's cookies, browser-session cookies included. */
|
|
262
|
+
cookies: Cookie[];
|
|
263
|
+
/** Open pages on the sign-in site; the first is the one to resume from. */
|
|
264
|
+
pages: string[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Parse the harvest script's stdout. */
|
|
268
|
+
export function parseHarvestOutput(stdout: string, domain: string): WindowsChromeHarvest {
|
|
269
|
+
const parsed = JSON.parse(stdout) as {
|
|
270
|
+
cookies?: { result?: { cookies?: CdpCookie[] }; error?: { message?: string } };
|
|
271
|
+
targets?: { result?: { targetInfos?: CdpTargetInfo[] } };
|
|
272
|
+
};
|
|
273
|
+
const cookieList = parsed.cookies?.result?.cookies;
|
|
274
|
+
if (!cookieList)
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Windows Chrome returned no cookie list: ${parsed.cookies?.error?.message ?? "unknown response"}`,
|
|
277
|
+
);
|
|
278
|
+
return {
|
|
279
|
+
cookies: cookiesForDomain(cookieList, domain),
|
|
280
|
+
pages: sitePages(parsed.targets?.result?.targetInfos ?? [], domain),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Write a PowerShell script to a temp file and run it with `-File`. */
|
|
285
|
+
function runPowerShellScript(
|
|
286
|
+
name: string,
|
|
287
|
+
script: string,
|
|
288
|
+
args: string[],
|
|
289
|
+
timeoutMs: number,
|
|
290
|
+
): { status: number | null; stdout: string; stderr: string; error?: Error } {
|
|
152
291
|
const scriptDir = join(tmpdir(), "harnery-windows-chrome");
|
|
153
292
|
mkdirSync(scriptDir, { recursive: true });
|
|
154
|
-
const scriptLinux = join(scriptDir,
|
|
155
|
-
writeFileSync(scriptLinux,
|
|
293
|
+
const scriptLinux = join(scriptDir, `${name}-${process.pid}.ps1`);
|
|
294
|
+
writeFileSync(scriptLinux, script, { mode: 0o600 });
|
|
156
295
|
try {
|
|
157
296
|
const scriptWindows = spawnSync("wslpath", ["-w", scriptLinux], {
|
|
158
297
|
encoding: "utf8",
|
|
159
298
|
}).stdout.trim();
|
|
160
299
|
const result = spawnSync(
|
|
161
300
|
"powershell.exe",
|
|
162
|
-
[
|
|
163
|
-
|
|
164
|
-
"-ExecutionPolicy",
|
|
165
|
-
"Bypass",
|
|
166
|
-
"-File",
|
|
167
|
-
scriptWindows,
|
|
168
|
-
"-Chrome",
|
|
169
|
-
input.chromeWindows,
|
|
170
|
-
"-Profile",
|
|
171
|
-
input.profileWindows,
|
|
172
|
-
"-StartupSeconds",
|
|
173
|
-
String(input.startupTimeoutSeconds ?? 30),
|
|
174
|
-
],
|
|
175
|
-
{ encoding: "utf8", timeout: 90_000, maxBuffer: 64 * 1024 * 1024 },
|
|
301
|
+
["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptWindows, ...args],
|
|
302
|
+
{ encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
|
|
176
303
|
);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
183
|
-
result?: { cookies?: CdpCookie[] };
|
|
184
|
-
error?: { message?: string };
|
|
304
|
+
return {
|
|
305
|
+
status: result.status,
|
|
306
|
+
stdout: result.stdout ?? "",
|
|
307
|
+
stderr: result.stderr ?? "",
|
|
308
|
+
...(result.error ? { error: result.error } : {}),
|
|
185
309
|
};
|
|
186
|
-
if (!parsed.result?.cookies)
|
|
187
|
-
throw new Error(
|
|
188
|
-
`Windows Chrome returned no cookie list: ${parsed.error?.message ?? "unknown response"}`,
|
|
189
|
-
);
|
|
190
|
-
return cookiesForDomain(parsed.result.cookies, input.domain);
|
|
191
310
|
} finally {
|
|
192
311
|
rmSync(scriptLinux, { force: true });
|
|
193
312
|
}
|
|
194
313
|
}
|
|
195
314
|
|
|
196
315
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
316
|
+
* Reopen the Windows profile headless with its last session restored, read
|
|
317
|
+
* its cookies for `domain` and its open pages on that site, and close it.
|
|
318
|
+
* The profile must already be closed; see `stopWindowsChromeProfile`.
|
|
319
|
+
*/
|
|
320
|
+
export function harvestWindowsChromeCookies(input: {
|
|
321
|
+
chromeWindows: string;
|
|
322
|
+
profileWindows: string;
|
|
323
|
+
domain: string;
|
|
324
|
+
startupTimeoutSeconds?: number;
|
|
325
|
+
pageTimeoutSeconds?: number;
|
|
326
|
+
}): WindowsChromeHarvest {
|
|
327
|
+
const result = runPowerShellScript(
|
|
328
|
+
"harvest-cookies",
|
|
329
|
+
harvestScript(),
|
|
330
|
+
[
|
|
331
|
+
"-Chrome",
|
|
332
|
+
input.chromeWindows,
|
|
333
|
+
"-Profile",
|
|
334
|
+
input.profileWindows,
|
|
335
|
+
"-Pattern",
|
|
336
|
+
profileCommandLinePattern(input.profileWindows),
|
|
337
|
+
"-Domain",
|
|
338
|
+
input.domain,
|
|
339
|
+
"-StartupSeconds",
|
|
340
|
+
String(input.startupTimeoutSeconds ?? 30),
|
|
341
|
+
"-PageSeconds",
|
|
342
|
+
String(input.pageTimeoutSeconds ?? 5),
|
|
343
|
+
],
|
|
344
|
+
90_000,
|
|
345
|
+
);
|
|
346
|
+
if (result.status !== 0) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Reading cookies from Windows Chrome failed: ${(result.stderr || result.stdout || result.error?.message || "no output").trim().slice(0, 400)}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return parseHarvestOutput(result.stdout, input.domain);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface WindowsChromeKeepSession {
|
|
355
|
+
/** True when the preference was already set or is now set. */
|
|
356
|
+
kept: boolean;
|
|
357
|
+
/** True when this call changed the preference. */
|
|
358
|
+
changed: boolean;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Set the profile to keep browser-session cookies across a normal exit; see
|
|
363
|
+
* `keepSessionScript`. Throws when Chrome is already open on the profile,
|
|
364
|
+
* since a second sign-in window would hand off to it. Any other failure
|
|
365
|
+
* returns `kept: false` so the caller can warn and continue.
|
|
201
366
|
*/
|
|
202
|
-
export function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
367
|
+
export function keepWindowsChromeSession(input: {
|
|
368
|
+
chromeWindows: string;
|
|
369
|
+
profileWindows: string;
|
|
370
|
+
startupTimeoutSeconds?: number;
|
|
371
|
+
}): WindowsChromeKeepSession {
|
|
372
|
+
const result = runPowerShellScript(
|
|
373
|
+
"keep-session",
|
|
374
|
+
keepSessionScript(),
|
|
207
375
|
[
|
|
208
|
-
"-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
].join("; "),
|
|
376
|
+
"-Chrome",
|
|
377
|
+
input.chromeWindows,
|
|
378
|
+
"-Profile",
|
|
379
|
+
input.profileWindows,
|
|
380
|
+
"-Pattern",
|
|
381
|
+
profileCommandLinePattern(input.profileWindows),
|
|
382
|
+
"-StartupSeconds",
|
|
383
|
+
String(input.startupTimeoutSeconds ?? 30),
|
|
217
384
|
],
|
|
218
|
-
|
|
385
|
+
90_000,
|
|
386
|
+
);
|
|
387
|
+
const detail = (result.stderr || result.stdout || result.error?.message || "").trim();
|
|
388
|
+
if (result.status !== 0) {
|
|
389
|
+
if (detail.includes("Chrome is still running on the sign-in profile"))
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Chrome is already open on the sign-in profile ${input.profileWindows}. Close that window and run the sign-in again.`,
|
|
392
|
+
);
|
|
393
|
+
return { kept: false, changed: false };
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const parsed = JSON.parse(result.stdout) as { changed?: boolean; value?: unknown };
|
|
397
|
+
return { kept: parsed.value === 1, changed: parsed.changed === true };
|
|
398
|
+
} catch {
|
|
399
|
+
return { kept: false, changed: false };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* PowerShell that closes every Chrome whose command line matches `$Pattern`. It asks each window to
|
|
405
|
+
* close, repeating so a profile with several windows closes them all, which
|
|
406
|
+
* lets Chrome write its cookies and session to disk. Whatever is still
|
|
407
|
+
* running after `$CloseSeconds` is stopped by force. The script prints
|
|
408
|
+
* `{found, forced, remaining}` as JSON; `remaining` lists process ids that
|
|
409
|
+
* survived even the forced stop.
|
|
410
|
+
*/
|
|
411
|
+
export function stopScript(): string {
|
|
412
|
+
return [
|
|
413
|
+
"param([string]$Pattern, [int]$CloseSeconds = 15)",
|
|
414
|
+
"$ErrorActionPreference = 'Stop'",
|
|
415
|
+
PROFILE_PROCESS_FUNCTION,
|
|
416
|
+
"$found = (Get-ProfileChrome).Count",
|
|
417
|
+
"$deadline = (Get-Date).AddSeconds($CloseSeconds)",
|
|
418
|
+
"while ($true) { $procs = Get-ProfileChrome; if ($procs.Count -eq 0 -or (Get-Date) -gt $deadline) { break }; foreach ($c in $procs) { try { $p = Get-Process -Id $c.ProcessId -ErrorAction SilentlyContinue; if ($p -and $p.MainWindowHandle -ne 0) { [void]$p.CloseMainWindow() } } catch {} }; Start-Sleep -Milliseconds 500 }",
|
|
419
|
+
"$forced = 0",
|
|
420
|
+
"foreach ($c in $(Get-ProfileChrome)) { Stop-Process -Id $c.ProcessId -Force -ErrorAction SilentlyContinue; $forced++ }",
|
|
421
|
+
"if ($forced -gt 0) { $killDeadline = (Get-Date).AddSeconds(10); while ((Get-ProfileChrome).Count -gt 0 -and (Get-Date) -lt $killDeadline) { Start-Sleep -Milliseconds 250 } }",
|
|
422
|
+
"$remaining = @(Get-ProfileChrome | ForEach-Object { [int]$_.ProcessId })",
|
|
423
|
+
"[Console]::Out.Write((@{ found = $found; forced = $forced; remaining = $remaining } | ConvertTo-Json -Compress))",
|
|
424
|
+
].join("\r\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface WindowsChromeStop {
|
|
428
|
+
/** Chrome processes running on the profile when the stop began. */
|
|
429
|
+
found: number;
|
|
430
|
+
/** Processes that ignored the close request and were stopped by force. */
|
|
431
|
+
forced: number;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Parse the stop script's stdout; throws when a process survived. */
|
|
435
|
+
export function parseStopOutput(stdout: string, profileWindows: string): WindowsChromeStop {
|
|
436
|
+
const parsed = JSON.parse(stdout) as {
|
|
437
|
+
found?: number;
|
|
438
|
+
forced?: number;
|
|
439
|
+
remaining?: number | number[] | null;
|
|
440
|
+
};
|
|
441
|
+
const remaining =
|
|
442
|
+
parsed.remaining === undefined || parsed.remaining === null
|
|
443
|
+
? []
|
|
444
|
+
: Array.isArray(parsed.remaining)
|
|
445
|
+
? parsed.remaining
|
|
446
|
+
: [parsed.remaining];
|
|
447
|
+
if (remaining.length > 0) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`Chrome is still running on ${profileWindows} (process ${remaining.join(", ")}) after a forced stop; its cookies cannot be read until it exits.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return { found: Number(parsed.found ?? 0), forced: Number(parsed.forced ?? 0) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Close every Chrome still running on `profileWindows`, for when the person
|
|
457
|
+
* ends the sign-in from the terminal while the window is open. The profile
|
|
458
|
+
* must be closed before its cookies can be read. Closing the windows lets
|
|
459
|
+
* Chrome write recent cookies to disk; a forced stop is the fallback after
|
|
460
|
+
* `closeSeconds`, and it can lose cookies Chrome had not yet written. Throws
|
|
461
|
+
* when the stop cannot be confirmed, so the harvest never runs against a
|
|
462
|
+
* profile that is still open.
|
|
463
|
+
*/
|
|
464
|
+
export function stopWindowsChromeProfile(
|
|
465
|
+
profileWindows: string,
|
|
466
|
+
closeSeconds = 15,
|
|
467
|
+
): WindowsChromeStop {
|
|
468
|
+
const result = runPowerShellScript(
|
|
469
|
+
"stop-profile",
|
|
470
|
+
stopScript(),
|
|
471
|
+
["-Pattern", profileCommandLinePattern(profileWindows), "-CloseSeconds", String(closeSeconds)],
|
|
472
|
+
(closeSeconds + 60) * 1000,
|
|
219
473
|
);
|
|
474
|
+
if (result.status !== 0) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Closing Chrome on ${profileWindows} failed: ${(result.stderr || result.stdout || result.error?.message || "no output").trim().slice(0, 400)}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return parseStopOutput(result.stdout, profileWindows);
|
|
220
480
|
}
|
|
221
481
|
|
|
222
482
|
/** Windows form of a `/mnt/<drive>/...` path. */
|
package/src/lib/docs-links.ts
CHANGED
|
@@ -363,6 +363,52 @@ export function slugify(heading: string): string {
|
|
|
363
363
|
.replace(/\s/g, "-");
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/** Keep literal code text out of the heading's inline-markup replacements. */
|
|
367
|
+
function shieldHeadingCode(text: string): { text: string; restore: (text: string) => string } {
|
|
368
|
+
let marker = "\u0000";
|
|
369
|
+
while (text.includes(marker)) marker += "\u0000";
|
|
370
|
+
const spans: string[] = [];
|
|
371
|
+
const tokens = /<[^>]+>|`+/g;
|
|
372
|
+
let masked = "";
|
|
373
|
+
let copiedThrough = 0;
|
|
374
|
+
|
|
375
|
+
for (let opening = tokens.exec(text); opening; opening = tokens.exec(text)) {
|
|
376
|
+
// An HTML tag starts before its attribute values; their backticks are not
|
|
377
|
+
// code delimiters. Escaped opening backticks are literal punctuation too.
|
|
378
|
+
if (!opening[0].startsWith("`")) continue;
|
|
379
|
+
let backslashes = 0;
|
|
380
|
+
for (let i = opening.index - 1; i >= 0 && text[i] === "\\"; i--) backslashes++;
|
|
381
|
+
if (backslashes % 2 !== 0) continue;
|
|
382
|
+
|
|
383
|
+
const closingRuns = /`+/g;
|
|
384
|
+
closingRuns.lastIndex = opening.index + opening[0].length;
|
|
385
|
+
let closing = closingRuns.exec(text);
|
|
386
|
+
while (closing && closing[0].length !== opening[0].length) {
|
|
387
|
+
closing = closingRuns.exec(text);
|
|
388
|
+
}
|
|
389
|
+
if (!closing) continue;
|
|
390
|
+
|
|
391
|
+
let code = text.slice(opening.index + opening[0].length, closing.index);
|
|
392
|
+
// Code spans remove one padding space at each end, except all-space spans.
|
|
393
|
+
if (code.startsWith(" ") && code.endsWith(" ") && /[^ ]/.test(code)) {
|
|
394
|
+
code = code.slice(1, -1);
|
|
395
|
+
}
|
|
396
|
+
masked += text.slice(copiedThrough, opening.index) + marker + spans.length + marker;
|
|
397
|
+
spans.push(code);
|
|
398
|
+
copiedThrough = closing.index + closing[0].length;
|
|
399
|
+
tokens.lastIndex = copiedThrough;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
text: masked + text.slice(copiedThrough),
|
|
404
|
+
restore: (filtered) =>
|
|
405
|
+
filtered.replace(
|
|
406
|
+
new RegExp(`${marker}(\\d+)${marker}`, "g"),
|
|
407
|
+
(_, index: string) => spans[Number(index)]!,
|
|
408
|
+
),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
366
412
|
/**
|
|
367
413
|
* Every fragment a reader can legitimately target in one document: Markdown
|
|
368
414
|
* heading slugs (ATX and Setext), explicit `{#custom-id}` suffixes, and HTML
|
|
@@ -388,7 +434,8 @@ export function collectAnchors(content: string): Set<string> {
|
|
|
388
434
|
// so only word-boundary underscores are markup. `_` counts as a word
|
|
389
435
|
// character in JS regex, so \b sits exactly at the space-to-underscore
|
|
390
436
|
// seam these delimiters occupy.
|
|
391
|
-
|
|
437
|
+
const code = shieldHeadingCode(text);
|
|
438
|
+
text = code.text
|
|
392
439
|
// Backslash escapes render as the bare character, so `snake\_case`
|
|
393
440
|
// must anchor exactly like `snake_case`. Unescape before the emphasis
|
|
394
441
|
// strip or the escaped underscore gains a phantom word boundary.
|
|
@@ -397,7 +444,7 @@ export function collectAnchors(content: string): Set<string> {
|
|
|
397
444
|
.replace(/[*~`]/g, "")
|
|
398
445
|
.replace(/\b_+|_+\b/g, "")
|
|
399
446
|
.replace(/<[^>]+>/g, "");
|
|
400
|
-
const base = slugify(text);
|
|
447
|
+
const base = slugify(code.restore(text));
|
|
401
448
|
if (!base) return;
|
|
402
449
|
const n = seen.get(base) ?? 0;
|
|
403
450
|
seen.set(base, n + 1);
|