Compare commits
13 Commits
9f3d080471
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b9878e938c | |||
| b2e97a3036 | |||
| 809a3955e5 | |||
| fb868b9e5f | |||
| e2657f4850 | |||
| c30ad85e0d | |||
| ba5b0e4e67 | |||
| 9c7103770a | |||
| 57f6b132b2 | |||
| b3a59b5b45 | |||
| a796481875 | |||
| 8d2c162849 | |||
| 4919edcec1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
|||||||
| Skill | What it does | Path |
|
| Skill | What it does | Path |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
||||||
|
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Luke’s sender path. | `skills/flight-finder` |
|
||||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
||||||
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
||||||
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ This folder contains detailed docs for each skill in this repository.
|
|||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
||||||
|
- [`flight-finder`](flight-finder.md) — Typed flight-search intake, bounded source orchestration, PDF report rendering, and Luke-sender email delivery
|
||||||
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
||||||
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
|
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
|
||||||
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
||||||
|
|||||||
77
docs/flight-finder.md
Normal file
77
docs/flight-finder.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# flight-finder
|
||||||
|
|
||||||
|
Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery.
|
||||||
|
|
||||||
|
## Core behavior
|
||||||
|
|
||||||
|
`flight-finder` is designed to:
|
||||||
|
|
||||||
|
- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose
|
||||||
|
- support split returns, flexible dates, passenger groups, and exclusions
|
||||||
|
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
|
||||||
|
- normalize pricing to USD before ranking
|
||||||
|
- produce a report payload first, then render PDF/email only when the report is complete
|
||||||
|
- render booking links in the PDF for each recommended fare, including airline-direct links when they were actually captured
|
||||||
|
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
|
||||||
|
- require a fresh bounded search run for every report instead of reusing earlier captures
|
||||||
|
|
||||||
|
## Important rules
|
||||||
|
|
||||||
|
- Recipient email is a delivery gate, not a search gate.
|
||||||
|
- Cached flight-search data is forbidden as primary evidence for a new run.
|
||||||
|
- same-day workspace captures must not be reused
|
||||||
|
- previously rendered PDFs must not be treated as fresh search output
|
||||||
|
- if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts
|
||||||
|
- `marketCountry` is explicit-only in this implementation pass.
|
||||||
|
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
|
||||||
|
- If present, it activates VPN only for the bounded search phase.
|
||||||
|
- If omitted, no VPN change happens.
|
||||||
|
- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging.
|
||||||
|
- Direct-airline cross-checking is currently best-effort, not a hard blocker.
|
||||||
|
- The PDF should show:
|
||||||
|
- the captured booking/search link for every quoted fare
|
||||||
|
- the airline-direct link only when `directBookingUrl` was actually captured
|
||||||
|
- an explicit “not captured in this run” note when direct-airline booking was unavailable
|
||||||
|
|
||||||
|
## DFW ↔ BLQ daily automation
|
||||||
|
|
||||||
|
- The 6 AM DFW ↔ BLQ automation should invoke `flight-finder` directly with the structured JSON payload.
|
||||||
|
- The legacy file at `workspace/docs/prompts/dfw-blq-2026.md` should mirror that exact direct skill invocation, not old prose instructions.
|
||||||
|
|
||||||
|
## Helper package
|
||||||
|
|
||||||
|
From `skills/flight-finder/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run normalize-request -- --legacy-dfw-blq
|
||||||
|
npm run normalize-request -- --input "<request.json>"
|
||||||
|
npm run report-status -- --input "<report-payload.json>"
|
||||||
|
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||||
|
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected report-payload provenance fields:
|
||||||
|
|
||||||
|
- `searchExecution.freshSearch: true`
|
||||||
|
- `searchExecution.startedAt`
|
||||||
|
- `searchExecution.completedAt`
|
||||||
|
- `searchExecution.artifactsRoot`
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
- sender identity: `luke@fiorinis.com`
|
||||||
|
- send path:
|
||||||
|
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||||
|
- if the user already provided the destination email, that counts as delivery authorization once the report is ready
|
||||||
|
|
||||||
|
## Source viability for implementation pass 1
|
||||||
|
|
||||||
|
Bounded checks on Stefano's MacBook Air showed:
|
||||||
|
|
||||||
|
- `KAYAK`: viable
|
||||||
|
- `Skyscanner`: viable
|
||||||
|
- `Expedia`: viable
|
||||||
|
- airline direct-booking cross-check: degraded / best-effort
|
||||||
|
|
||||||
|
That means the first implementation pass should rely primarily on the three aggregator sources and treat direct-airline confirmation as additive evidence when it succeeds.
|
||||||
@@ -69,13 +69,19 @@ Current macOS backend:
|
|||||||
- NordLynx/WireGuard
|
- NordLynx/WireGuard
|
||||||
- `wireguard-go`
|
- `wireguard-go`
|
||||||
- `wireguard-tools`
|
- `wireguard-tools`
|
||||||
- NordVPN DNS in the generated WireGuard config:
|
- explicit macOS DNS management on eligible physical services:
|
||||||
- `103.86.96.100`
|
- `103.86.96.100`
|
||||||
- `103.86.99.100`
|
- `103.86.99.100`
|
||||||
|
|
||||||
Important behavior:
|
Important behavior:
|
||||||
|
|
||||||
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
|
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
|
||||||
|
- the generated WireGuard config intentionally stays free of `DNS = ...` so `wg-quick` does not rewrite every macOS network service behind the skill’s back.
|
||||||
|
- during `connect`, the skill first proves the tunnel is stable with a bounded persistence gate that reuses the allowed helper `probe` action and a verified public exit.
|
||||||
|
- during `connect`, the skill snapshots current DNS/search-domain settings on eligible physical services and then applies NordVPN DNS only after that stable gate, one last liveness check, and a post-DNS system-hostname-resolution check succeed.
|
||||||
|
- during `disconnect`, or after a failed/stale teardown, the skill restores the saved DNS/search-domain snapshot.
|
||||||
|
- if persistence, exit verification, or post-DNS hostname resolution fails, the skill rolls back before treating the connect as successful and resumes Tailscale if it stopped it.
|
||||||
|
- when the skill intentionally stops Tailscale for a VPN session, it writes a short-lived suppression marker so host watchdogs do not immediately run `tailscale up` and fight the VPN route change.
|
||||||
- The skill automatically suspends Tailscale before connect if Tailscale is active.
|
- The skill automatically suspends Tailscale before connect if Tailscale is active.
|
||||||
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
|
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
|
||||||
- The Homebrew NordVPN app does not need to be uninstalled.
|
- The Homebrew NordVPN app does not need to be uninstalled.
|
||||||
@@ -144,6 +150,8 @@ Add this exact rule:
|
|||||||
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Do not add extra helper actions just for persistence checks unless you are also updating host sudoers. The current implementation intentionally rides the persistence check on `probe` so the existing `probe/up/down` rule remains sufficient.
|
||||||
|
|
||||||
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
|
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
|
||||||
|
|
||||||
## Common Flows
|
## Common Flows
|
||||||
@@ -188,7 +196,9 @@ Expected macOS behavior:
|
|||||||
- stop Tailscale if active
|
- stop Tailscale if active
|
||||||
- select a NordVPN server for the target
|
- select a NordVPN server for the target
|
||||||
- bring up the WireGuard tunnel
|
- bring up the WireGuard tunnel
|
||||||
|
- prove persistence of the live `utun*` runtime via the helper `probe` path
|
||||||
- verify the public exit location
|
- verify the public exit location
|
||||||
|
- run one final liveness check before applying NordVPN DNS
|
||||||
- return JSON describing the chosen server and final verified location
|
- return JSON describing the chosen server and final verified location
|
||||||
|
|
||||||
### Verify
|
### Verify
|
||||||
@@ -209,6 +219,7 @@ Expected macOS behavior:
|
|||||||
|
|
||||||
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
|
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
|
||||||
- remove stale local NordVPN state files after teardown
|
- remove stale local NordVPN state files after teardown
|
||||||
|
- restore automatic DNS when the saved DNS snapshot is obviously just NordVPN-pinned leftovers
|
||||||
- resume Tailscale if the skill had suspended it
|
- resume Tailscale if the skill had suspended it
|
||||||
|
|
||||||
## Output Model
|
## Output Model
|
||||||
@@ -238,7 +249,9 @@ For deeper troubleshooting, use:
|
|||||||
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
|
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
`--debug` keeps the internal local paths and other low-level metadata in the JSON output.
|
`--debug` keeps the internal local paths, helper-hardening diagnostics, and other low-level metadata in the JSON output.
|
||||||
|
|
||||||
|
If you also run local watchdogs such as `healthwatch.sh`, they should honor the NordVPN Tailscale suppression marker at `~/.nordvpn-client/tailscale-suppressed` and skip automatic `tailscale up` while the marker is fresh or the NordVPN WireGuard tunnel is active.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ The current host-local OpenClaw config keeps the native `main` orchestrator and
|
|||||||
- `acp.maxConcurrentSessions = 2`
|
- `acp.maxConcurrentSessions = 2`
|
||||||
- `plugins.allow += acpx`
|
- `plugins.allow += acpx`
|
||||||
- `plugins.entries.acpx.enabled = true`
|
- `plugins.entries.acpx.enabled = true`
|
||||||
- `plugins.entries.acpx.config.command = /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/acpx/dist/cli.js`
|
|
||||||
- ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts
|
- ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts
|
||||||
|
|
||||||
The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture.
|
The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture.
|
||||||
@@ -89,17 +88,19 @@ Important health nuance:
|
|||||||
|
|
||||||
- `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"`
|
- `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"`
|
||||||
- the actual readiness signal is the gateway log line `acpx runtime backend ready`
|
- the actual readiness signal is the gateway log line `acpx runtime backend ready`
|
||||||
- during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths and the plugin command was pinned to the direct `acpx/dist/cli.js` entrypoint
|
- during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths
|
||||||
|
- a later startup bug showed that pinning a custom `command` path disables the plugin-local managed install path and can leave ACP unavailable after a restart if the local `acpx` artifact is absent at boot
|
||||||
|
- the current host fix is to leave `plugins.entries.acpx.config.command` unset so the bundled plugin can manage its own plugin-local `acpx` binary
|
||||||
|
|
||||||
Maintenance note:
|
Maintenance note:
|
||||||
|
|
||||||
- the pinned command path and `expectedVersion = "0.3.1"` are intentionally explicit because they were needed to get this host healthy
|
- the current host intentionally uses the managed plugin-local default command path rather than a custom override
|
||||||
- after any OpenClaw upgrade, re-run:
|
- after any OpenClaw upgrade, re-run:
|
||||||
- `openclaw config validate`
|
- `openclaw config validate`
|
||||||
- `openclaw plugins inspect acpx --json`
|
- `openclaw plugins inspect acpx --json`
|
||||||
- `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'`
|
- `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'`
|
||||||
- `node /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/acpx/dist/cli.js --version`
|
- `ls -l /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/.bin/acpx`
|
||||||
- if the pinned path or version no longer matches the bundled layout, update the config deliberately instead of assuming the old override remains valid
|
- if ACP comes up unavailable at startup, check whether a custom `plugins.entries.acpx.config.command` override was reintroduced before debugging deeper
|
||||||
|
|
||||||
## Security Review
|
## Security Review
|
||||||
|
|
||||||
|
|||||||
159
skills/flight-finder/SKILL.md
Normal file
159
skills/flight-finder/SKILL.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
---
|
||||||
|
name: flight-finder
|
||||||
|
description: Use when a user wants a flight-search report with explicit trip constraints, ranked options, and PDF/email delivery. Prefer when the request may include split returns, flexible dates, passenger groups, market-localized pricing, or a WhatsApp-safe workflow that should keep going after status nudges instead of hanging behind a long inline prompt.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flight Finder
|
||||||
|
|
||||||
|
Use this skill instead of the old `docs/prompts/dfw-blq-2026.md` prompt when the user wants a flight-search report.
|
||||||
|
|
||||||
|
The deliverable target is always a PDF report plus email delivery when the report is complete.
|
||||||
|
|
||||||
|
PDF output rule:
|
||||||
|
|
||||||
|
- Render aggregator booking links for every quoted fare.
|
||||||
|
- Render direct-airline booking links when they were actually captured.
|
||||||
|
- If a direct-airline link was not captured for a quote, say so explicitly in the report instead of implying one exists.
|
||||||
|
|
||||||
|
Freshness rule:
|
||||||
|
|
||||||
|
- Never reuse cached flight-search captures, prior same-day report payloads, earlier workspace artifacts, or previously rendered PDFs as the primary evidence for a new run.
|
||||||
|
- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery.
|
||||||
|
- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
Accept either:
|
||||||
|
|
||||||
|
- a natural-language trip request
|
||||||
|
- a structured helper payload
|
||||||
|
- a route plus partial constraints that still need follow-up questions
|
||||||
|
|
||||||
|
Collect these explicitly when missing:
|
||||||
|
|
||||||
|
- origin and destination
|
||||||
|
- outbound date window
|
||||||
|
- whether there are one or more return legs
|
||||||
|
- passenger groups
|
||||||
|
- which passenger groups travel on which legs
|
||||||
|
- flexibility rules
|
||||||
|
- cabin / stop / layover preferences when relevant
|
||||||
|
- exclusions such as airlines, countries, or airports when relevant
|
||||||
|
- recipient email before final render/send
|
||||||
|
|
||||||
|
Optional explicit input:
|
||||||
|
|
||||||
|
- `marketCountry`
|
||||||
|
- if present, it must be a validated ISO 3166-1 alpha-2 uppercase country code such as `TH` or `DE`
|
||||||
|
- if present, it activates the VPN / market-localized search path for the bounded search phase only
|
||||||
|
- if absent, do not change VPN in this implementation pass
|
||||||
|
|
||||||
|
Do not silently infer or reuse missing trip-shape details from earlier runs just because the route looks similar.
|
||||||
|
If the user already explicitly said where to email the finished PDF, that counts as delivery authorization once the report is complete. Do not ask for a second send confirmation unless the destination changed or the user sounded uncertain.
|
||||||
|
|
||||||
|
## Core workflow
|
||||||
|
|
||||||
|
1. Normalize the trip request into typed legs, passenger groups, assignments, and preferences.
|
||||||
|
2. Ask targeted follow-up questions for missing search-critical inputs.
|
||||||
|
3. Run the bounded search phase across the configured travel sources.
|
||||||
|
4. Rank the viable combinations in USD.
|
||||||
|
5. Assemble the final report payload.
|
||||||
|
6. Render the PDF.
|
||||||
|
7. Send the email from Luke to the user-specified recipient.
|
||||||
|
|
||||||
|
Completion rule:
|
||||||
|
|
||||||
|
- A partial source result is not completion.
|
||||||
|
- A partial helper result is not completion.
|
||||||
|
- The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted.
|
||||||
|
- An interim commentary/status line is not completion.
|
||||||
|
- For isolated cron-style runs, do not leave the run with only a phase-update summary. If PDF render or email send did not happen, the terminal result must say so explicitly as a failed or incomplete outcome.
|
||||||
|
|
||||||
|
## WhatsApp-safe behavior
|
||||||
|
|
||||||
|
Follow the same operational style as `property-assessor`:
|
||||||
|
|
||||||
|
- missing required input should trigger a direct question, not an invented assumption
|
||||||
|
- `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going”
|
||||||
|
- a silent helper/source path is a failed path and should be abandoned quickly
|
||||||
|
- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report
|
||||||
|
- keep progress state by phase so a dropped session can resume or at least report the last completed phase
|
||||||
|
- do not start email-tool exploration before the report payload is complete and the PDF is ready to render
|
||||||
|
- if PDF render fails, return the completed report summary in chat and report delivery failure separately
|
||||||
|
- if email send fails after render, say so clearly and keep the rendered PDF path
|
||||||
|
|
||||||
|
## Long-running command rule
|
||||||
|
|
||||||
|
- Do not fire a long-running local command and then abandon it.
|
||||||
|
- If an `exec` call returns a running session/process handle instead of a completed result, immediately follow it with the relevant process polling/log calls until the command finishes, fails, or times out.
|
||||||
|
- Treat VPN connect, fresh search sweeps, PDF render, and Gmail send as critical-path commands. They must be observed to completion; do not assume they succeeded just because the process started.
|
||||||
|
- If a critical-path command cannot be observed to completion, stop claiming progress and report the run as incomplete.
|
||||||
|
|
||||||
|
## Isolated cron rule
|
||||||
|
|
||||||
|
- In isolated cron runs, success means all of the following are true:
|
||||||
|
- fresh search artifacts exist for the current run
|
||||||
|
- final `report-payload.json` exists for the current run
|
||||||
|
- PDF render completed
|
||||||
|
- Gmail send completed to the requested recipient
|
||||||
|
- If the model/provider times out, the VPN step flakes, or the run ends before those artifacts exist, report a failure/incomplete outcome rather than an `ok`-sounding completion summary.
|
||||||
|
- Do not end an isolated cron run on the VPN/connect phase alone.
|
||||||
|
|
||||||
|
## Search-source contract
|
||||||
|
|
||||||
|
Use the proven travel-source order from the local viability findings:
|
||||||
|
|
||||||
|
1. KAYAK
|
||||||
|
2. Skyscanner
|
||||||
|
3. Expedia
|
||||||
|
4. airline direct-booking cross-check
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- treat source viability as evidence-based, not assumed
|
||||||
|
- if a source is blocked or degraded, continue with the remaining sources and record the issue in the report
|
||||||
|
- keep the bounded search phase observable with concise updates
|
||||||
|
- normalize compared prices to USD before ranking
|
||||||
|
- do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded
|
||||||
|
- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run
|
||||||
|
|
||||||
|
## VPN / market-country rules
|
||||||
|
|
||||||
|
- Use VPN only when `marketCountry` is explicitly provided in the typed request for this implementation pass.
|
||||||
|
- Connect VPN only for the bounded search phase.
|
||||||
|
- Bounded search phase means: from first travel-site navigation until the last search-result capture for the report.
|
||||||
|
- If VPN connect or verification fails within the configured timeout, continue on the default market and mark the report as degraded.
|
||||||
|
- Disconnect VPN immediately after the bounded search phase, before ranking/render/delivery.
|
||||||
|
- On rollback or failure, attempt disconnect immediately.
|
||||||
|
|
||||||
|
## Delivery rules
|
||||||
|
|
||||||
|
- Sender must be Luke: `luke@fiorinis.com`
|
||||||
|
- Delivery path must be Luke’s wrapper:
|
||||||
|
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||||
|
- If recipient email is missing, ask for it before the render/send phase.
|
||||||
|
- If the user already provided the recipient email, do not ask for a redundant “send it” confirmation.
|
||||||
|
|
||||||
|
## Helper commands
|
||||||
|
|
||||||
|
From `~/.openclaw/workspace/skills/flight-finder/` or the repo mirror copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run normalize-request -- --legacy-dfw-blq
|
||||||
|
npm run normalize-request -- --input "<request.json>"
|
||||||
|
npm run report-status -- --input "<report-payload.json>"
|
||||||
|
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||||
|
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `normalize-request` should report missing search inputs separately from delivery-only email gaps
|
||||||
|
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
|
||||||
|
- `render-report` must reject incomplete report payloads
|
||||||
|
- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run
|
||||||
|
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
|
||||||
|
|
||||||
|
## Prompt migration rule
|
||||||
|
|
||||||
|
The old DFW↔BLQ prompt is legacy input only. Do not treat it as freeform execution prose. Its file should contain a literal `use flight finder skill with the following json payload` request so manual runs and scheduled runs stay aligned.
|
||||||
168
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
168
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"tripName": "DFW ↔ BLQ flight report",
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"id": "outbound",
|
||||||
|
"origin": "DFW",
|
||||||
|
"destination": "BLQ",
|
||||||
|
"earliest": "2026-05-30",
|
||||||
|
"latest": "2026-06-07",
|
||||||
|
"label": "Outbound"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-pair",
|
||||||
|
"origin": "BLQ",
|
||||||
|
"destination": "DFW",
|
||||||
|
"relativeToLegId": "outbound",
|
||||||
|
"minDaysAfter": 6,
|
||||||
|
"maxDaysAfter": 10,
|
||||||
|
"label": "Return for 2 adults"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-solo",
|
||||||
|
"origin": "BLQ",
|
||||||
|
"destination": "DFW",
|
||||||
|
"earliest": "2026-06-28",
|
||||||
|
"latest": "2026-07-05",
|
||||||
|
"label": "Return for 1 adult"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passengerGroups": [
|
||||||
|
{
|
||||||
|
"id": "pair",
|
||||||
|
"adults": 2,
|
||||||
|
"label": "2 adults traveling together"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "solo",
|
||||||
|
"adults": 1,
|
||||||
|
"label": "1 adult returning separately"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"legAssignments": [
|
||||||
|
{
|
||||||
|
"legId": "outbound",
|
||||||
|
"passengerGroupIds": ["pair", "solo"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legId": "return-pair",
|
||||||
|
"passengerGroupIds": ["pair"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legId": "return-solo",
|
||||||
|
"passengerGroupIds": ["solo"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recipientEmail": "stefano@fiorinis.com",
|
||||||
|
"preferences": {
|
||||||
|
"preferOneStop": true,
|
||||||
|
"maxStops": 1,
|
||||||
|
"maxLayoverHours": 6,
|
||||||
|
"flexibleDates": true,
|
||||||
|
"excludeAirlines": ["Turkish Airlines"],
|
||||||
|
"excludeCountries": ["TR"],
|
||||||
|
"requireAirlineDirectCrossCheck": true,
|
||||||
|
"specialConstraints": [
|
||||||
|
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||||
|
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||||
|
"Start from a fresh browser/session profile for this search."
|
||||||
|
],
|
||||||
|
"geoPricingMarket": "Thailand",
|
||||||
|
"marketCountry": "TH",
|
||||||
|
"normalizeCurrencyTo": "USD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"searchExecution": {
|
||||||
|
"freshSearch": true,
|
||||||
|
"startedAt": "2026-03-30T21:00:00Z",
|
||||||
|
"completedAt": "2026-03-30T21:20:00Z",
|
||||||
|
"artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
|
||||||
|
"notes": [
|
||||||
|
"Fresh bounded Thailand-market search executed for this report payload.",
|
||||||
|
"Prior workspace captures must not be reused across flight-finder runs."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sourceFindings": [
|
||||||
|
{
|
||||||
|
"source": "kayak",
|
||||||
|
"status": "viable",
|
||||||
|
"checkedAt": "2026-03-30T21:00:00Z",
|
||||||
|
"notes": [
|
||||||
|
"KAYAK returned multiple one-stop DFW -> BLQ options and exposed direct-booking hints for British Airways."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "skyscanner",
|
||||||
|
"status": "viable",
|
||||||
|
"checkedAt": "2026-03-30T21:05:00Z",
|
||||||
|
"notes": [
|
||||||
|
"Skyscanner returned one-stop and multi-stop DFW -> BLQ results with total USD pricing."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "expedia",
|
||||||
|
"status": "viable",
|
||||||
|
"checkedAt": "2026-03-30T21:10:00Z",
|
||||||
|
"notes": [
|
||||||
|
"Expedia returned one-way DFW -> BLQ options with clear per-traveler pricing."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "airline-direct",
|
||||||
|
"status": "degraded",
|
||||||
|
"checkedAt": "2026-03-30T21:15:00Z",
|
||||||
|
"notes": [
|
||||||
|
"United's direct booking shell loaded with the route/date context but failed to complete the search."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
{
|
||||||
|
"id": "kayak-outbound-ba",
|
||||||
|
"source": "kayak",
|
||||||
|
"legId": "outbound",
|
||||||
|
"passengerGroupIds": ["pair", "solo"],
|
||||||
|
"bookingLink": "https://www.kayak.com/flights/DFW-BLQ/2026-05-30?adults=3&sort=bestflight_a&fs=stops=-2",
|
||||||
|
"itinerarySummary": "British Airways via London Heathrow",
|
||||||
|
"airlineName": "British Airways",
|
||||||
|
"departureTimeLocal": "10:59 PM",
|
||||||
|
"arrivalTimeLocal": "11:45 PM +1",
|
||||||
|
"stopsText": "1 stop",
|
||||||
|
"layoverText": "6h 15m in London Heathrow",
|
||||||
|
"totalDurationText": "17h 46m",
|
||||||
|
"totalPriceUsd": 2631,
|
||||||
|
"displayPriceUsd": "$2,631 total",
|
||||||
|
"directBookingUrl": "https://www.britishairways.com/",
|
||||||
|
"crossCheckStatus": "failed",
|
||||||
|
"notes": [
|
||||||
|
"Observed on KAYAK at $877 per traveler for 3 adults.",
|
||||||
|
"Airline-direct cross-check remains degraded in this implementation pass."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rankedOptions": [
|
||||||
|
{
|
||||||
|
"id": "primary-outbound",
|
||||||
|
"title": "Best observed outbound baseline",
|
||||||
|
"quoteIds": ["kayak-outbound-ba"],
|
||||||
|
"totalPriceUsd": 2631,
|
||||||
|
"rationale": "Lowest observed one-stop fare in the bounded spike while still meeting the layover constraint."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"executiveSummary": [
|
||||||
|
"The bounded implementation-pass smoke test produced a viable report payload from real DFW -> BLQ search evidence.",
|
||||||
|
"KAYAK, Skyscanner, and Expedia all returned usable results on this machine.",
|
||||||
|
"Direct-airline cross-checking is currently best-effort and should be reported honestly when it fails."
|
||||||
|
],
|
||||||
|
"reportWarnings": [
|
||||||
|
"This smoke-test payload captures the bounded implementation-pass workflow, not the full old prompt's multi-leg optimization."
|
||||||
|
],
|
||||||
|
"degradedReasons": [
|
||||||
|
"Airline direct-booking cross-check is still degraded and must be treated as best-effort in this pass."
|
||||||
|
],
|
||||||
|
"comparisonCurrency": "USD",
|
||||||
|
"marketCountryUsed": "TH",
|
||||||
|
"lastCompletedPhase": "ranking",
|
||||||
|
"generatedAt": "2026-03-30T21:20:00Z"
|
||||||
|
}
|
||||||
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"tripName": "DFW ↔ BLQ flight report",
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"id": "outbound",
|
||||||
|
"origin": "DFW",
|
||||||
|
"destination": "BLQ",
|
||||||
|
"earliest": "2026-05-30",
|
||||||
|
"latest": "2026-06-07",
|
||||||
|
"label": "Outbound"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-pair",
|
||||||
|
"origin": "BLQ",
|
||||||
|
"destination": "DFW",
|
||||||
|
"relativeToLegId": "outbound",
|
||||||
|
"minDaysAfter": 6,
|
||||||
|
"maxDaysAfter": 10,
|
||||||
|
"label": "Return for 2 adults"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "return-solo",
|
||||||
|
"origin": "BLQ",
|
||||||
|
"destination": "DFW",
|
||||||
|
"earliest": "2026-06-28",
|
||||||
|
"latest": "2026-07-05",
|
||||||
|
"label": "Return for 1 adult"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passengerGroups": [
|
||||||
|
{
|
||||||
|
"id": "pair",
|
||||||
|
"adults": 2,
|
||||||
|
"label": "2 adults traveling together"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "solo",
|
||||||
|
"adults": 1,
|
||||||
|
"label": "1 adult returning separately"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"legAssignments": [
|
||||||
|
{
|
||||||
|
"legId": "outbound",
|
||||||
|
"passengerGroupIds": ["pair", "solo"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legId": "return-pair",
|
||||||
|
"passengerGroupIds": ["pair"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"legId": "return-solo",
|
||||||
|
"passengerGroupIds": ["solo"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recipientEmail": "stefano@fiorinis.com",
|
||||||
|
"preferences": {
|
||||||
|
"preferOneStop": true,
|
||||||
|
"maxStops": 1,
|
||||||
|
"maxLayoverHours": 6,
|
||||||
|
"flexibleDates": true,
|
||||||
|
"excludeAirlines": ["Turkish Airlines"],
|
||||||
|
"excludeCountries": ["TR"],
|
||||||
|
"requireAirlineDirectCrossCheck": true,
|
||||||
|
"specialConstraints": [
|
||||||
|
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||||
|
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||||
|
"Start from a fresh browser/session profile for this search."
|
||||||
|
],
|
||||||
|
"geoPricingMarket": "Thailand",
|
||||||
|
"marketCountry": "TH",
|
||||||
|
"normalizeCurrencyTo": "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
791
skills/flight-finder/package-lock.json
generated
Normal file
791
skills/flight-finder/package-lock.json
generated
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
{
|
||||||
|
"name": "flight-finder-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "flight-finder-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"pdfkit": "^0.17.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
|
"@types/pdfkit": "^0.17.3",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||||
|
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/minimist": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||||
|
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/pdfkit": {
|
||||||
|
"version": "0.17.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||||
|
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||||
|
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jpeg-exif": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||||
|
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"jpeg-exif": "^1.1.4",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
skills/flight-finder/package.json
Normal file
23
skills/flight-finder/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "flight-finder-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"normalize-request": "tsx src/cli.ts normalize-request",
|
||||||
|
"report-status": "tsx src/cli.ts report-status",
|
||||||
|
"render-report": "tsx src/cli.ts render-report",
|
||||||
|
"delivery-plan": "tsx src/cli.ts delivery-plan",
|
||||||
|
"test": "node --import tsx --test tests/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"pdfkit": "^0.17.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
|
"@types/pdfkit": "^0.17.3",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
skills/flight-finder/references/source-viability.md
Normal file
75
skills/flight-finder/references/source-viability.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Flight Finder Source Viability
|
||||||
|
|
||||||
|
This note records bounded live checks for the first implementation pass.
|
||||||
|
|
||||||
|
It is intentionally operational, not aspirational: if a source is blocked here, the skill must treat it as blocked unless later evidence replaces this note.
|
||||||
|
|
||||||
|
## Required sources
|
||||||
|
|
||||||
|
- KAYAK
|
||||||
|
- Skyscanner
|
||||||
|
- Expedia
|
||||||
|
- airline direct-booking cross-check
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Checked on `2026-03-30` from Stefano's MacBook Air, with no VPN active, using the existing local `web-automation` / CloakBrowser probe scripts.
|
||||||
|
|
||||||
|
Route used for bounded checks:
|
||||||
|
|
||||||
|
- `DFW -> BLQ`
|
||||||
|
- outbound date: `2026-05-30`
|
||||||
|
- travelers: `3 adults`
|
||||||
|
|
||||||
|
### KAYAK
|
||||||
|
|
||||||
|
- Status: `viable`
|
||||||
|
- Probe: `node flight_kayak_sweep.mjs DFW BLQ 3 /tmp/flight-finder-kayak-dates.json ...`
|
||||||
|
- Evidence:
|
||||||
|
- title returned as `DFW to BLQ, 5/30`
|
||||||
|
- results included multiple one-stop itineraries with parsed USD fares such as `$877`, `$949`, `$955`
|
||||||
|
- direct-booking hints were visible for British Airways on at least some results
|
||||||
|
- Implementation note:
|
||||||
|
- KAYAK can be a primary source in this first implementation pass
|
||||||
|
- parsed text is workable, but still brittle enough that bounded retries and status fallback remain necessary
|
||||||
|
|
||||||
|
### Skyscanner
|
||||||
|
|
||||||
|
- Status: `viable`
|
||||||
|
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.skyscanner.com/transport/flights/dfw/blq/260530/?adultsv2=3&cabinclass=economy&rtn=0'`
|
||||||
|
- Evidence:
|
||||||
|
- title returned as `Cheap flights from Dallas to Bologna on Skyscanner`
|
||||||
|
- results page exposed concrete prices, total trip prices, stops, and itinerary text
|
||||||
|
- one-stop and multi-stop options were visible in the captured text
|
||||||
|
- Implementation note:
|
||||||
|
- Skyscanner is viable for bounded result capture in this first pass
|
||||||
|
- itinerary extraction should still be treated as text-scrape, not a stable API
|
||||||
|
|
||||||
|
### Expedia
|
||||||
|
|
||||||
|
- Status: `viable`
|
||||||
|
- Probe: `node tmp_expedia_probe.mjs 'https://www.expedia.com/Flights-Search?...'`
|
||||||
|
- Evidence:
|
||||||
|
- title returned as `DFW to BLQ flights`
|
||||||
|
- results page exposed current lowest price, airline/stops filters, and concrete per-traveler options such as `$877`, `$949`, `$961`
|
||||||
|
- Expedia text already surfaced some itinerary summaries in a report-friendly format
|
||||||
|
- Implementation note:
|
||||||
|
- Expedia is viable for bounded result capture in this first pass
|
||||||
|
- as with the other aggregators, source-specific timeouts and fallback rules are still required
|
||||||
|
|
||||||
|
### Airline direct-booking cross-check
|
||||||
|
|
||||||
|
- Status: `degraded`
|
||||||
|
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.united.com/en/us/fsr/choose-flights?...'`
|
||||||
|
- Evidence:
|
||||||
|
- United's booking shell loaded and recognized the route / date context
|
||||||
|
- the search then returned `united.com was unable to complete your request. Please try again later.`
|
||||||
|
- Implementation note:
|
||||||
|
- direct-airline cross-checking remains in scope, but it should be treated as best-effort in the first pass
|
||||||
|
- when a direct site fails or refuses completion, the skill should record the failure explicitly instead of hanging or pretending a clean cross-check happened
|
||||||
|
|
||||||
|
## Scope decision for implementation pass 1
|
||||||
|
|
||||||
|
- Primary bounded search sources: `KAYAK`, `Skyscanner`, `Expedia`
|
||||||
|
- Direct-airline cross-check: `best-effort / degraded`
|
||||||
|
- The skill should continue if the direct-airline step fails, but the report must say that the direct cross-check was not fully completed
|
||||||
86
skills/flight-finder/src/cli.ts
Normal file
86
skills/flight-finder/src/cli.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import minimist from "minimist";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "./request-normalizer.js";
|
||||||
|
import { getFlightReportStatus } from "./report-status.js";
|
||||||
|
import { renderFlightReportPdf } from "./report-pdf.js";
|
||||||
|
import { buildLukeDeliveryPlan } from "./report-delivery.js";
|
||||||
|
import type { FlightReportPayload, FlightReportRequestDraft } from "./types.js";
|
||||||
|
|
||||||
|
function usage(): never {
|
||||||
|
throw new Error(`Usage:
|
||||||
|
flight-finder normalize-request --input "<request.json>"
|
||||||
|
flight-finder normalize-request --legacy-dfw-blq
|
||||||
|
flight-finder report-status --input "<report-payload.json>"
|
||||||
|
flight-finder render-report --input "<report-payload.json>" --output "<report.pdf>"
|
||||||
|
flight-finder delivery-plan --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||||
|
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const argv = minimist(process.argv.slice(2), {
|
||||||
|
string: ["input", "output", "to", "subject", "body", "attach"],
|
||||||
|
boolean: ["legacy-dfw-blq"]
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = argv._[0];
|
||||||
|
if (!command) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "normalize-request") {
|
||||||
|
const draft = argv["legacy-dfw-blq"]
|
||||||
|
? DFW_BLQ_2026_PROMPT_DRAFT
|
||||||
|
: await readJsonFile<FlightReportRequestDraft>(argv.input || usage());
|
||||||
|
console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "report-status") {
|
||||||
|
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "render-report") {
|
||||||
|
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||||
|
const rendered = await renderFlightReportPdf(payload, argv.output || usage());
|
||||||
|
console.log(rendered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "delivery-plan") {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
buildLukeDeliveryPlan({
|
||||||
|
recipientEmail: argv.to,
|
||||||
|
subject: argv.subject || "",
|
||||||
|
body: argv.body || "",
|
||||||
|
attachmentPath: argv.attach || usage()
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
25
skills/flight-finder/src/input-validation.ts
Normal file
25
skills/flight-finder/src/input-validation.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export function normalizeMarketCountry(
|
||||||
|
value: string | null | undefined
|
||||||
|
): string | null | undefined {
|
||||||
|
const cleaned = typeof value === "string" ? value.trim() : undefined;
|
||||||
|
if (!cleaned) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = cleaned.toUpperCase();
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlausibleEmail(value: string | null | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||||
|
}
|
||||||
36
skills/flight-finder/src/price-normalization.ts
Normal file
36
skills/flight-finder/src/price-normalization.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type NormalizePriceInput = {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
exchangeRates?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedPrice = {
|
||||||
|
currency: "USD";
|
||||||
|
amountUsd: number;
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizePriceToUsd(
|
||||||
|
input: NormalizePriceInput
|
||||||
|
): NormalizedPrice {
|
||||||
|
const currency = input.currency.trim().toUpperCase();
|
||||||
|
if (currency === "USD") {
|
||||||
|
return {
|
||||||
|
currency: "USD",
|
||||||
|
amountUsd: input.amount,
|
||||||
|
notes: ["Price already in USD."]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = input.exchangeRates?.[currency];
|
||||||
|
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
|
||||||
|
throw new Error(`Missing usable exchange rate for ${currency}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountUsd = Math.round(input.amount * rate * 100) / 100;
|
||||||
|
return {
|
||||||
|
currency: "USD",
|
||||||
|
amountUsd,
|
||||||
|
notes: [`Converted ${currency} to USD using rate ${rate}.`]
|
||||||
|
};
|
||||||
|
}
|
||||||
50
skills/flight-finder/src/report-delivery.ts
Normal file
50
skills/flight-finder/src/report-delivery.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { isPlausibleEmail } from "./input-validation.js";
|
||||||
|
|
||||||
|
type DeliveryPlanInput = {
|
||||||
|
recipientEmail?: string | null;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
attachmentPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeliveryPlan = {
|
||||||
|
ready: boolean;
|
||||||
|
needsRecipientEmail: boolean;
|
||||||
|
command: string | null;
|
||||||
|
sender: "luke@fiorinis.com";
|
||||||
|
recipientEmail: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shellQuote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLukeDeliveryPlan(input: DeliveryPlanInput): DeliveryPlan {
|
||||||
|
const recipientEmail = input.recipientEmail?.trim() || null;
|
||||||
|
if (!recipientEmail || !isPlausibleEmail(recipientEmail)) {
|
||||||
|
return {
|
||||||
|
ready: false,
|
||||||
|
needsRecipientEmail: true,
|
||||||
|
command: null,
|
||||||
|
sender: "luke@fiorinis.com",
|
||||||
|
recipientEmail
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRecipientEmail: string = recipientEmail;
|
||||||
|
const command = [
|
||||||
|
"zsh ~/.openclaw/workspace/bin/gog-luke gmail send",
|
||||||
|
`--to ${shellQuote(validRecipientEmail)}`,
|
||||||
|
`--subject ${shellQuote(input.subject)}`,
|
||||||
|
`--body ${shellQuote(input.body)}`,
|
||||||
|
`--attach ${shellQuote(input.attachmentPath)}`
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
needsRecipientEmail: false,
|
||||||
|
command,
|
||||||
|
sender: "luke@fiorinis.com",
|
||||||
|
recipientEmail: validRecipientEmail
|
||||||
|
};
|
||||||
|
}
|
||||||
407
skills/flight-finder/src/report-pdf.ts
Normal file
407
skills/flight-finder/src/report-pdf.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import PDFDocument from "pdfkit";
|
||||||
|
|
||||||
|
import { getFlightReportStatus } from "./report-status.js";
|
||||||
|
import { normalizeFlightReportRequest } from "./request-normalizer.js";
|
||||||
|
import type {
|
||||||
|
FlightLegWindow,
|
||||||
|
FlightPassengerGroup,
|
||||||
|
FlightQuote,
|
||||||
|
FlightRecommendationOption,
|
||||||
|
FlightReportPayload
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export class ReportValidationError extends Error {}
|
||||||
|
|
||||||
|
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
|
||||||
|
return value?.length ? value : [fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
|
||||||
|
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
|
||||||
|
return new Map(
|
||||||
|
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsd(value: number): string {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateWindow(leg: FlightLegWindow): string {
|
||||||
|
if (leg.earliest && leg.latest) {
|
||||||
|
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
|
||||||
|
}
|
||||||
|
if (leg.relativeToLegId) {
|
||||||
|
const min = leg.minDaysAfter ?? "?";
|
||||||
|
const max = leg.maxDaysAfter ?? "?";
|
||||||
|
return `${min}-${max} days after ${leg.relativeToLegId}`;
|
||||||
|
}
|
||||||
|
return "Not provided";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
|
||||||
|
const bottomLimit = doc.page.height - doc.page.margins.bottom;
|
||||||
|
if (doc.y + requiredHeight > bottomLimit) {
|
||||||
|
doc.addPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
|
||||||
|
|
||||||
|
export function formatArtifactsRoot(value: string): string {
|
||||||
|
const trimmed = String(value || "").trim();
|
||||||
|
if (!trimmed) return "Not provided";
|
||||||
|
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
|
||||||
|
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
|
||||||
|
}
|
||||||
|
return path.basename(trimmed) || trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||||
|
ensurePageSpace(doc, 28);
|
||||||
|
const x = doc.page.margins.left;
|
||||||
|
const y = doc.y;
|
||||||
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const height = 18;
|
||||||
|
|
||||||
|
doc.save();
|
||||||
|
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
|
||||||
|
doc
|
||||||
|
.fillColor("white")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(12)
|
||||||
|
.text(title, x + 8, y + 4, { width: width - 16 });
|
||||||
|
doc.restore();
|
||||||
|
doc.moveDown(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
|
||||||
|
const lines = bulletLines(items, fallback);
|
||||||
|
for (const line of lines) {
|
||||||
|
const lineHeight = doc.heightOfString(line, {
|
||||||
|
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||||
|
lineGap: 2
|
||||||
|
});
|
||||||
|
ensurePageSpace(doc, lineHeight + 12);
|
||||||
|
const startY = doc.y;
|
||||||
|
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
|
||||||
|
doc
|
||||||
|
.fillColor("#1E2329")
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(10.5)
|
||||||
|
.text(line, doc.page.margins.left + 14, startY, {
|
||||||
|
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||||
|
lineGap: 2
|
||||||
|
});
|
||||||
|
doc.moveDown(0.35);
|
||||||
|
}
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuoteBookingLink = {
|
||||||
|
label: string;
|
||||||
|
text: string;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Search / book this fare",
|
||||||
|
text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`,
|
||||||
|
url: quote.bookingLink
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Direct airline booking",
|
||||||
|
text: quote.directBookingUrl
|
||||||
|
? `Direct airline booking: ${quote.airlineName || "Airline site"}`
|
||||||
|
: "Direct airline booking: not captured in this run",
|
||||||
|
url: quote.directBookingUrl || null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
|
||||||
|
for (const linkItem of links) {
|
||||||
|
const lineHeight = Math.max(
|
||||||
|
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
|
||||||
|
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
|
||||||
|
width: width - 132,
|
||||||
|
lineGap: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
ensurePageSpace(doc, lineHeight + 8);
|
||||||
|
const startY = doc.y;
|
||||||
|
doc
|
||||||
|
.fillColor("#1E2329")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.text(`${linkItem.label}:`, left, startY, { width: 130 });
|
||||||
|
doc
|
||||||
|
.fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66")
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, {
|
||||||
|
width: width - 132,
|
||||||
|
lineGap: 1,
|
||||||
|
...(linkItem.url ? { link: linkItem.url, underline: true } : {})
|
||||||
|
});
|
||||||
|
doc.moveDown(0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||||
|
const left = doc.page.margins.left;
|
||||||
|
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const keyWidth = 170;
|
||||||
|
const valueWidth = totalWidth - keyWidth;
|
||||||
|
|
||||||
|
for (const [key, value] of rows) {
|
||||||
|
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||||
|
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||||
|
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||||
|
ensurePageSpace(doc, rowHeight + 8);
|
||||||
|
const startY = doc.y;
|
||||||
|
|
||||||
|
doc.save();
|
||||||
|
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||||
|
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
||||||
|
doc
|
||||||
|
.lineWidth(0.5)
|
||||||
|
.strokeColor("#C7D0D9")
|
||||||
|
.rect(left, startY, totalWidth, rowHeight)
|
||||||
|
.stroke();
|
||||||
|
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
|
||||||
|
doc.restore();
|
||||||
|
|
||||||
|
doc
|
||||||
|
.fillColor("#1E2329")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
||||||
|
doc
|
||||||
|
.fillColor("#1E2329")
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(9.5)
|
||||||
|
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
||||||
|
doc.y = startY + rowHeight;
|
||||||
|
}
|
||||||
|
doc.moveDown(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFreshSearch(payload: FlightReportPayload): void {
|
||||||
|
const searchExecution = payload.searchExecution;
|
||||||
|
if (!searchExecution?.freshSearch) {
|
||||||
|
throw new ReportValidationError(
|
||||||
|
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = Date.parse(searchExecution.startedAt);
|
||||||
|
const completedAt = Date.parse(searchExecution.completedAt);
|
||||||
|
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
|
||||||
|
throw new ReportValidationError(
|
||||||
|
"The flight report payload must include a valid fresh-search time window."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(searchExecution.artifactsRoot || "").trim()) {
|
||||||
|
throw new ReportValidationError(
|
||||||
|
"The flight report payload must include the artifacts root for the fresh search run."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawItineraryCard(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
option: FlightRecommendationOption,
|
||||||
|
quotesById: Map<string, FlightQuote>,
|
||||||
|
legLabels: Map<string, string>,
|
||||||
|
passengerLabels: Map<string, string>
|
||||||
|
): void {
|
||||||
|
const left = doc.page.margins.left;
|
||||||
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const badge = option.backup ? "Backup option" : "Primary recommendation";
|
||||||
|
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
|
||||||
|
const quote = quotesById.get(quoteId);
|
||||||
|
if (!quote) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
heading: `Missing quote reference: ${quoteId}`,
|
||||||
|
details: [],
|
||||||
|
links: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const passengerLine = quote.passengerGroupIds
|
||||||
|
.map((groupId) => passengerLabels.get(groupId) || groupId)
|
||||||
|
.join(", ");
|
||||||
|
const legLine = legLabels.get(quote.legId) || quote.legId;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
|
||||||
|
details: [
|
||||||
|
`Passengers: ${passengerLine}`,
|
||||||
|
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`,
|
||||||
|
`Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`,
|
||||||
|
...(quote.notes || []).map((note) => `Note: ${note}`)
|
||||||
|
],
|
||||||
|
links: describeQuoteBookingLinks(quote)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
ensurePageSpace(doc, 28);
|
||||||
|
const top = doc.y;
|
||||||
|
doc.save();
|
||||||
|
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
||||||
|
doc
|
||||||
|
.fillColor("white")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(10.5)
|
||||||
|
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
|
||||||
|
doc.restore();
|
||||||
|
doc.y = top + 28;
|
||||||
|
|
||||||
|
drawKeyValueTable(doc, [
|
||||||
|
["Recommendation total", formatUsd(option.totalPriceUsd)],
|
||||||
|
["Why it ranked here", option.rationale]
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const block of quoteBlocks) {
|
||||||
|
ensurePageSpace(doc, 22);
|
||||||
|
doc
|
||||||
|
.fillColor("#10384E")
|
||||||
|
.font("Helvetica-Bold")
|
||||||
|
.fontSize(10)
|
||||||
|
.text(block.heading, left, doc.y, {
|
||||||
|
width,
|
||||||
|
lineGap: 2
|
||||||
|
});
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
drawBulletList(doc, block.details);
|
||||||
|
drawQuoteBookingLinks(doc, block.links, left, width);
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
}
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderFlightReportPdf(
|
||||||
|
payload: FlightReportPayload,
|
||||||
|
outputPath: string
|
||||||
|
): Promise<string> {
|
||||||
|
const normalizedRequest = normalizeFlightReportRequest(payload.request);
|
||||||
|
const status = getFlightReportStatus(normalizedRequest, payload);
|
||||||
|
|
||||||
|
if (!status.pdfReady) {
|
||||||
|
throw new ReportValidationError(
|
||||||
|
"The flight report payload is still incomplete. Finish the report before generating the PDF."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureFreshSearch(payload);
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: "LETTER",
|
||||||
|
margin: 50,
|
||||||
|
info: {
|
||||||
|
Title: payload.request.tripName || "Flight report",
|
||||||
|
Author: "OpenClaw flight-finder"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = fs.createWriteStream(outputPath);
|
||||||
|
doc.pipe(stream);
|
||||||
|
|
||||||
|
const request = normalizedRequest.request;
|
||||||
|
const legLabels = asLegLabelMap(request.legs);
|
||||||
|
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
|
||||||
|
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
doc
|
||||||
|
.fillColor("#4F5B66")
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(10)
|
||||||
|
.text(`Generated: ${payload.generatedAt}`);
|
||||||
|
doc
|
||||||
|
.fillColor("#4F5B66")
|
||||||
|
.font("Helvetica")
|
||||||
|
.fontSize(10)
|
||||||
|
.text(
|
||||||
|
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
|
||||||
|
);
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Trip Snapshot");
|
||||||
|
drawKeyValueTable(doc, [
|
||||||
|
["Recipient", request.recipientEmail || "Missing"],
|
||||||
|
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
|
||||||
|
["Preferences", [
|
||||||
|
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
|
||||||
|
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
|
||||||
|
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
|
||||||
|
].join(" | ")],
|
||||||
|
["Market-country mode", request.preferences.marketCountry || "Off"],
|
||||||
|
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
|
||||||
|
]);
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Legs And Travelers");
|
||||||
|
for (const leg of request.legs) {
|
||||||
|
const assignedGroups = request.legAssignments
|
||||||
|
.find((assignment) => assignment.legId === leg.id)
|
||||||
|
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
|
||||||
|
.join(", ") || "Not assigned";
|
||||||
|
drawKeyValueTable(doc, [
|
||||||
|
["Leg", leg.label || leg.id],
|
||||||
|
["Route", `${leg.origin} -> ${leg.destination}`],
|
||||||
|
["Window", formatDateWindow(leg)],
|
||||||
|
["Travelers", assignedGroups]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Executive Summary");
|
||||||
|
drawBulletList(doc, payload.executiveSummary);
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Recommended Options");
|
||||||
|
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Source Findings");
|
||||||
|
payload.sourceFindings.forEach((finding) => {
|
||||||
|
drawKeyValueTable(doc, [
|
||||||
|
["Source", finding.source],
|
||||||
|
["Status", finding.status],
|
||||||
|
["Checked", finding.checkedAt]
|
||||||
|
]);
|
||||||
|
drawBulletList(doc, finding.notes, "No source notes.");
|
||||||
|
});
|
||||||
|
|
||||||
|
drawSectionHeader(doc, "Warnings And Degraded Conditions");
|
||||||
|
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.on("finish", () => resolve());
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
stream.destroy();
|
||||||
|
await fs.promises.unlink(outputPath).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
83
skills/flight-finder/src/report-status.ts
Normal file
83
skills/flight-finder/src/report-status.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type {
|
||||||
|
FlightReportPayload,
|
||||||
|
FlightReportStatusResult,
|
||||||
|
NormalizedFlightReportRequest
|
||||||
|
} from "./types.js";
|
||||||
|
import { isPlausibleEmail } from "./input-validation.js";
|
||||||
|
|
||||||
|
export function getFlightReportStatus(
|
||||||
|
normalizedRequest: NormalizedFlightReportRequest,
|
||||||
|
payload?: FlightReportPayload | null
|
||||||
|
): FlightReportStatusResult {
|
||||||
|
if (normalizedRequest.missingSearchInputs.length) {
|
||||||
|
return {
|
||||||
|
needsMissingInputs: normalizedRequest.missingSearchInputs,
|
||||||
|
readyToSearch: false,
|
||||||
|
pdfReady: false,
|
||||||
|
emailReady: false,
|
||||||
|
chatSummaryReady: false,
|
||||||
|
terminalOutcome: "missing-inputs",
|
||||||
|
degraded: false,
|
||||||
|
degradedReasons: [],
|
||||||
|
blockingReason: "Missing search-critical trip inputs."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return {
|
||||||
|
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||||
|
readyToSearch: true,
|
||||||
|
pdfReady: false,
|
||||||
|
emailReady: false,
|
||||||
|
chatSummaryReady: false,
|
||||||
|
terminalOutcome: "report-incomplete",
|
||||||
|
degraded: false,
|
||||||
|
degradedReasons: [],
|
||||||
|
lastCompletedPhase: "intake",
|
||||||
|
blockingReason: "Search and report assembly have not completed yet."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSourcesFailed =
|
||||||
|
payload.sourceFindings.length > 0 &&
|
||||||
|
payload.sourceFindings.every((finding) => finding.status === "blocked") &&
|
||||||
|
payload.quotes.length === 0;
|
||||||
|
|
||||||
|
if (allSourcesFailed) {
|
||||||
|
return {
|
||||||
|
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||||
|
readyToSearch: true,
|
||||||
|
pdfReady: false,
|
||||||
|
emailReady: false,
|
||||||
|
chatSummaryReady: true,
|
||||||
|
terminalOutcome: "all-sources-failed",
|
||||||
|
degraded: true,
|
||||||
|
degradedReasons: payload.degradedReasons,
|
||||||
|
lastCompletedPhase: payload.lastCompletedPhase || "search",
|
||||||
|
blockingReason: "All configured travel sources failed or were blocked."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportComplete = Boolean(
|
||||||
|
payload.quotes.length &&
|
||||||
|
payload.rankedOptions.length &&
|
||||||
|
payload.executiveSummary.length
|
||||||
|
);
|
||||||
|
const validRecipient = isPlausibleEmail(normalizedRequest.request.recipientEmail);
|
||||||
|
|
||||||
|
return {
|
||||||
|
needsMissingInputs: validRecipient ? [] : normalizedRequest.missingDeliveryInputs,
|
||||||
|
readyToSearch: true,
|
||||||
|
pdfReady: reportComplete,
|
||||||
|
emailReady: reportComplete && validRecipient,
|
||||||
|
chatSummaryReady:
|
||||||
|
payload.quotes.length > 0 ||
|
||||||
|
payload.rankedOptions.length > 0 ||
|
||||||
|
payload.executiveSummary.length > 0,
|
||||||
|
terminalOutcome: reportComplete ? "ready" : "report-incomplete",
|
||||||
|
degraded: payload.degradedReasons.length > 0,
|
||||||
|
degradedReasons: payload.degradedReasons,
|
||||||
|
lastCompletedPhase: payload.lastCompletedPhase,
|
||||||
|
blockingReason: reportComplete ? undefined : "The report payload is still incomplete."
|
||||||
|
};
|
||||||
|
}
|
||||||
309
skills/flight-finder/src/request-normalizer.ts
Normal file
309
skills/flight-finder/src/request-normalizer.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import type {
|
||||||
|
FlightLegWindow,
|
||||||
|
FlightPassengerGroup,
|
||||||
|
FlightReportRequest,
|
||||||
|
FlightReportRequestDraft,
|
||||||
|
FlightSearchPreferences,
|
||||||
|
NormalizedFlightReportRequest
|
||||||
|
} from "./types.js";
|
||||||
|
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
||||||
|
preferOneStop: true,
|
||||||
|
maxLayoverHours: 6,
|
||||||
|
normalizeCurrencyTo: "USD"
|
||||||
|
};
|
||||||
|
|
||||||
|
function cleanString(value: string | null | undefined): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values: Array<string | null | undefined> | undefined): string[] | undefined {
|
||||||
|
if (!values?.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = values
|
||||||
|
.map((value) => cleanString(value))
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
|
||||||
|
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePassengerGroups(
|
||||||
|
groups: FlightReportRequestDraft["passengerGroups"]
|
||||||
|
): FlightPassengerGroup[] {
|
||||||
|
return (groups || [])
|
||||||
|
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||||
|
.map((group, index) => ({
|
||||||
|
id: cleanString(group.id) || `group-${index + 1}`,
|
||||||
|
adults: Number(group.adults || 0),
|
||||||
|
children: Number(group.children || 0) || undefined,
|
||||||
|
infants: Number(group.infants || 0) || undefined,
|
||||||
|
label: cleanString(group.label)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegs(legs: FlightReportRequestDraft["legs"]): FlightLegWindow[] {
|
||||||
|
return (legs || [])
|
||||||
|
.filter((leg): leg is NonNullable<typeof leg> => Boolean(leg))
|
||||||
|
.map((leg, index) => ({
|
||||||
|
id: cleanString(leg.id) || `leg-${index + 1}`,
|
||||||
|
origin: cleanString(leg.origin) || "",
|
||||||
|
destination: cleanString(leg.destination) || "",
|
||||||
|
earliest: cleanString(leg.earliest),
|
||||||
|
latest: cleanString(leg.latest),
|
||||||
|
relativeToLegId: cleanString(leg.relativeToLegId),
|
||||||
|
minDaysAfter:
|
||||||
|
typeof leg.minDaysAfter === "number" && Number.isFinite(leg.minDaysAfter)
|
||||||
|
? leg.minDaysAfter
|
||||||
|
: undefined,
|
||||||
|
maxDaysAfter:
|
||||||
|
typeof leg.maxDaysAfter === "number" && Number.isFinite(leg.maxDaysAfter)
|
||||||
|
? leg.maxDaysAfter
|
||||||
|
: undefined,
|
||||||
|
label: cleanString(leg.label)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssignments(
|
||||||
|
draft: FlightReportRequestDraft["legAssignments"]
|
||||||
|
): FlightReportRequest["legAssignments"] {
|
||||||
|
return (draft || [])
|
||||||
|
.filter((assignment): assignment is NonNullable<typeof assignment> => Boolean(assignment))
|
||||||
|
.map((assignment) => ({
|
||||||
|
legId: cleanString(assignment.legId) || "",
|
||||||
|
passengerGroupIds:
|
||||||
|
uniqueStrings(
|
||||||
|
(assignment.passengerGroupIds || []).map((value) =>
|
||||||
|
typeof value === "string" ? value : undefined
|
||||||
|
)
|
||||||
|
) || []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePreferences(
|
||||||
|
preferences: FlightReportRequestDraft["preferences"]
|
||||||
|
): FlightSearchPreferences {
|
||||||
|
const draft = preferences || {};
|
||||||
|
return {
|
||||||
|
...DEFAULT_PREFERENCES,
|
||||||
|
cabin: draft.cabin,
|
||||||
|
maxStops: typeof draft.maxStops === "number" ? draft.maxStops : undefined,
|
||||||
|
preferOneStop:
|
||||||
|
typeof draft.preferOneStop === "boolean"
|
||||||
|
? draft.preferOneStop
|
||||||
|
: DEFAULT_PREFERENCES.preferOneStop,
|
||||||
|
maxLayoverHours:
|
||||||
|
typeof draft.maxLayoverHours === "number"
|
||||||
|
? draft.maxLayoverHours
|
||||||
|
: DEFAULT_PREFERENCES.maxLayoverHours,
|
||||||
|
excludeAirlines: uniqueStrings(draft.excludeAirlines),
|
||||||
|
excludeCountries: uniqueStrings(draft.excludeCountries),
|
||||||
|
excludeAirports: uniqueStrings(draft.excludeAirports),
|
||||||
|
flexibleDates:
|
||||||
|
typeof draft.flexibleDates === "boolean" ? draft.flexibleDates : undefined,
|
||||||
|
requireAirlineDirectCrossCheck:
|
||||||
|
typeof draft.requireAirlineDirectCrossCheck === "boolean"
|
||||||
|
? draft.requireAirlineDirectCrossCheck
|
||||||
|
: undefined,
|
||||||
|
specialConstraints: uniqueStrings(draft.specialConstraints),
|
||||||
|
geoPricingMarket: cleanString(draft.geoPricingMarket),
|
||||||
|
marketCountry: normalizeMarketCountry(draft.marketCountry),
|
||||||
|
normalizeCurrencyTo: "USD"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMissingSearchInputs(request: FlightReportRequest): string[] {
|
||||||
|
const missing = new Set<string>();
|
||||||
|
|
||||||
|
if (!request.legs.length) {
|
||||||
|
missing.add("trip legs");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.passengerGroups.length) {
|
||||||
|
missing.add("passenger groups");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of request.passengerGroups) {
|
||||||
|
const total = group.adults + (group.children || 0) + (group.infants || 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
missing.add(`traveler count for passenger group ${group.label || group.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legIds = new Set(request.legs.map((leg) => leg.id));
|
||||||
|
const groupIds = new Set(request.passengerGroups.map((group) => group.id));
|
||||||
|
|
||||||
|
for (const leg of request.legs) {
|
||||||
|
const label = leg.label || leg.id;
|
||||||
|
if (!leg.origin || !leg.destination) {
|
||||||
|
missing.add(`origin and destination for ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAbsoluteWindow = Boolean(leg.earliest || leg.latest);
|
||||||
|
const hasRelativeWindow = Boolean(
|
||||||
|
leg.relativeToLegId &&
|
||||||
|
(typeof leg.minDaysAfter === "number" || typeof leg.maxDaysAfter === "number")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasAbsoluteWindow && !hasRelativeWindow) {
|
||||||
|
missing.add(`date window for ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leg.relativeToLegId && !legIds.has(leg.relativeToLegId)) {
|
||||||
|
missing.add(`valid reference leg for ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.legAssignments.length) {
|
||||||
|
missing.add("passenger assignments for every leg");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const leg of request.legs) {
|
||||||
|
const label = leg.label || leg.id;
|
||||||
|
const assignment = request.legAssignments.find((entry) => entry.legId === leg.id);
|
||||||
|
if (!assignment) {
|
||||||
|
missing.add(`passenger assignments for ${label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignment.passengerGroupIds.length) {
|
||||||
|
missing.add(`assigned travelers for ${label}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment.passengerGroupIds.some((groupId) => !groupIds.has(groupId))) {
|
||||||
|
missing.add(`valid passenger-group references for ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFlightReportRequest(
|
||||||
|
draft: FlightReportRequestDraft
|
||||||
|
): NormalizedFlightReportRequest {
|
||||||
|
const normalizedRecipientEmail = cleanString(draft.recipientEmail) || null;
|
||||||
|
const request: FlightReportRequest = {
|
||||||
|
tripName: cleanString(draft.tripName),
|
||||||
|
legs: normalizeLegs(draft.legs),
|
||||||
|
passengerGroups: normalizePassengerGroups(draft.passengerGroups),
|
||||||
|
legAssignments: normalizeAssignments(draft.legAssignments),
|
||||||
|
recipientEmail: normalizedRecipientEmail,
|
||||||
|
preferences: normalizePreferences(draft.preferences)
|
||||||
|
};
|
||||||
|
|
||||||
|
const missingSearchInputs = collectMissingSearchInputs(request);
|
||||||
|
const missingDeliveryInputs = !request.recipientEmail
|
||||||
|
? ["recipient email"]
|
||||||
|
: isPlausibleEmail(request.recipientEmail)
|
||||||
|
? []
|
||||||
|
: ["valid recipient email"];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!request.recipientEmail) {
|
||||||
|
warnings.push(
|
||||||
|
"Recipient email is still missing. Ask for it before rendering or sending the PDF report."
|
||||||
|
);
|
||||||
|
} else if (!isPlausibleEmail(request.recipientEmail)) {
|
||||||
|
warnings.push(
|
||||||
|
"Recipient email looks malformed. Ask for a corrected email address before rendering or sending the PDF report."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.preferences.marketCountry && !request.preferences.geoPricingMarket) {
|
||||||
|
warnings.push(
|
||||||
|
`Market-localized search is explicit for this run. Connect VPN to ${request.preferences.marketCountry} only for the bounded search phase, then disconnect before ranking/render/delivery.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
readyToSearch: missingSearchInputs.length === 0,
|
||||||
|
missingInputs: [...missingSearchInputs, ...missingDeliveryInputs],
|
||||||
|
missingSearchInputs,
|
||||||
|
missingDeliveryInputs,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DFW_BLQ_2026_PROMPT_DRAFT: FlightReportRequestDraft = {
|
||||||
|
tripName: "DFW ↔ BLQ flight report",
|
||||||
|
legs: [
|
||||||
|
{
|
||||||
|
id: "outbound",
|
||||||
|
origin: "DFW",
|
||||||
|
destination: "BLQ",
|
||||||
|
earliest: "2026-05-30",
|
||||||
|
latest: "2026-06-07",
|
||||||
|
label: "Outbound"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "return-pair",
|
||||||
|
origin: "BLQ",
|
||||||
|
destination: "DFW",
|
||||||
|
relativeToLegId: "outbound",
|
||||||
|
minDaysAfter: 6,
|
||||||
|
maxDaysAfter: 10,
|
||||||
|
label: "Return for 2 adults"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "return-solo",
|
||||||
|
origin: "BLQ",
|
||||||
|
destination: "DFW",
|
||||||
|
earliest: "2026-06-28",
|
||||||
|
latest: "2026-07-05",
|
||||||
|
label: "Return for 1 adult"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
passengerGroups: [
|
||||||
|
{
|
||||||
|
id: "pair",
|
||||||
|
adults: 2,
|
||||||
|
label: "2 adults traveling together"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "solo",
|
||||||
|
adults: 1,
|
||||||
|
label: "1 adult returning separately"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
legAssignments: [
|
||||||
|
{
|
||||||
|
legId: "outbound",
|
||||||
|
passengerGroupIds: ["pair", "solo"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legId: "return-pair",
|
||||||
|
passengerGroupIds: ["pair"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legId: "return-solo",
|
||||||
|
passengerGroupIds: ["solo"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
recipientEmail: "stefano@fiorinis.com",
|
||||||
|
preferences: {
|
||||||
|
preferOneStop: true,
|
||||||
|
maxStops: 1,
|
||||||
|
maxLayoverHours: 6,
|
||||||
|
flexibleDates: true,
|
||||||
|
excludeAirlines: ["Turkish Airlines"],
|
||||||
|
excludeCountries: ["TR"],
|
||||||
|
requireAirlineDirectCrossCheck: true,
|
||||||
|
specialConstraints: [
|
||||||
|
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||||
|
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||||
|
"Start from a fresh browser/session profile for this search."
|
||||||
|
],
|
||||||
|
geoPricingMarket: "Thailand",
|
||||||
|
marketCountry: "TH",
|
||||||
|
normalizeCurrencyTo: "USD"
|
||||||
|
}
|
||||||
|
};
|
||||||
59
skills/flight-finder/src/run-state.ts
Normal file
59
skills/flight-finder/src/run-state.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type { FlightRunState } from "./types.js";
|
||||||
|
|
||||||
|
const DEFAULT_STATE_DIR = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".openclaw",
|
||||||
|
"workspace",
|
||||||
|
"state",
|
||||||
|
"flight-finder"
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getFlightFinderStatePath(
|
||||||
|
baseDir = DEFAULT_STATE_DIR
|
||||||
|
): string {
|
||||||
|
return path.join(baseDir, "flight-finder-run.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFlightFinderRunState(
|
||||||
|
state: FlightRunState,
|
||||||
|
baseDir = DEFAULT_STATE_DIR
|
||||||
|
): Promise<string> {
|
||||||
|
await fs.mkdir(baseDir, { recursive: true });
|
||||||
|
const statePath = getFlightFinderStatePath(baseDir);
|
||||||
|
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
||||||
|
return statePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFlightFinderRunState(
|
||||||
|
baseDir = DEFAULT_STATE_DIR
|
||||||
|
): Promise<FlightRunState | null> {
|
||||||
|
const statePath = getFlightFinderStatePath(baseDir);
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(statePath, "utf8");
|
||||||
|
return JSON.parse(raw) as FlightRunState;
|
||||||
|
} catch (error) {
|
||||||
|
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||||
|
if (maybeNodeError.code === "ENOENT") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearFlightFinderRunState(
|
||||||
|
baseDir = DEFAULT_STATE_DIR
|
||||||
|
): Promise<void> {
|
||||||
|
const statePath = getFlightFinderStatePath(baseDir);
|
||||||
|
try {
|
||||||
|
await fs.unlink(statePath);
|
||||||
|
} catch (error) {
|
||||||
|
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||||
|
if (maybeNodeError.code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
skills/flight-finder/src/search-orchestration.ts
Normal file
81
skills/flight-finder/src/search-orchestration.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type {
|
||||||
|
FlightReportRequest,
|
||||||
|
FlightSearchPlan,
|
||||||
|
FlightSearchSourceFinding,
|
||||||
|
FlightSearchSourceName,
|
||||||
|
FlightSearchSourceViability
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const SOURCE_SILENT_TIMEOUT_MS = 45_000;
|
||||||
|
export const SOURCE_TOTAL_TIMEOUT_MS = 180_000;
|
||||||
|
export const RUN_TOTAL_TIMEOUT_MS = 900_000;
|
||||||
|
export const VPN_CONNECT_TIMEOUT_MS = 30_000;
|
||||||
|
export const VPN_DISCONNECT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
const DEFAULT_SOURCE_ORDER: FlightSearchSourceName[] = [
|
||||||
|
"kayak",
|
||||||
|
"skyscanner",
|
||||||
|
"expedia",
|
||||||
|
"airline-direct"
|
||||||
|
];
|
||||||
|
|
||||||
|
function findingFor(
|
||||||
|
source: FlightSearchSourceName,
|
||||||
|
findings: FlightSearchSourceFinding[]
|
||||||
|
): FlightSearchSourceFinding | undefined {
|
||||||
|
return findings.find((entry) => entry.source === source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceStatus(
|
||||||
|
source: FlightSearchSourceName,
|
||||||
|
findings: FlightSearchSourceFinding[]
|
||||||
|
): FlightSearchSourceViability {
|
||||||
|
return findingFor(source, findings)?.status || "viable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFlightSearchPlan(
|
||||||
|
request: FlightReportRequest,
|
||||||
|
findings: FlightSearchSourceFinding[] = []
|
||||||
|
): FlightSearchPlan {
|
||||||
|
const sourceOrder = DEFAULT_SOURCE_ORDER.map((source) => {
|
||||||
|
const status = sourceStatus(source, findings);
|
||||||
|
const finding = findingFor(source, findings);
|
||||||
|
const required = source !== "airline-direct";
|
||||||
|
const enabled = status !== "blocked";
|
||||||
|
const reason =
|
||||||
|
finding?.notes[0] ||
|
||||||
|
(source === "airline-direct"
|
||||||
|
? "Best-effort airline direct cross-check."
|
||||||
|
: "Primary bounded search source.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
enabled,
|
||||||
|
required,
|
||||||
|
status,
|
||||||
|
silentTimeoutMs: SOURCE_SILENT_TIMEOUT_MS,
|
||||||
|
totalTimeoutMs: SOURCE_TOTAL_TIMEOUT_MS,
|
||||||
|
reason
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const degradedReasons = sourceOrder
|
||||||
|
.filter((source) => source.status !== "viable")
|
||||||
|
.map((source) =>
|
||||||
|
source.enabled
|
||||||
|
? `${source.source} is degraded for this run: ${source.reason}`
|
||||||
|
: `${source.source} is blocked for this run: ${source.reason}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceOrder,
|
||||||
|
vpn: {
|
||||||
|
enabled: Boolean(request.preferences.marketCountry),
|
||||||
|
marketCountry: request.preferences.marketCountry || null,
|
||||||
|
connectTimeoutMs: VPN_CONNECT_TIMEOUT_MS,
|
||||||
|
disconnectTimeoutMs: VPN_DISCONNECT_TIMEOUT_MS,
|
||||||
|
fallbackMode: "default-market"
|
||||||
|
},
|
||||||
|
degradedReasons
|
||||||
|
};
|
||||||
|
}
|
||||||
194
skills/flight-finder/src/types.ts
Normal file
194
skills/flight-finder/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export type FlightPassengerGroup = {
|
||||||
|
id: string;
|
||||||
|
adults: number;
|
||||||
|
children?: number;
|
||||||
|
infants?: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightLegWindow = {
|
||||||
|
id: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
earliest?: string;
|
||||||
|
latest?: string;
|
||||||
|
relativeToLegId?: string;
|
||||||
|
minDaysAfter?: number;
|
||||||
|
maxDaysAfter?: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightSearchPreferences = {
|
||||||
|
cabin?: "economy" | "premium-economy" | "business" | "first";
|
||||||
|
maxStops?: number;
|
||||||
|
preferOneStop?: boolean;
|
||||||
|
maxLayoverHours?: number;
|
||||||
|
excludeAirlines?: string[];
|
||||||
|
excludeCountries?: string[];
|
||||||
|
excludeAirports?: string[];
|
||||||
|
flexibleDates?: boolean;
|
||||||
|
requireAirlineDirectCrossCheck?: boolean;
|
||||||
|
specialConstraints?: string[];
|
||||||
|
geoPricingMarket?: string | null;
|
||||||
|
marketCountry?: string | null;
|
||||||
|
normalizeCurrencyTo?: "USD";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightReportRequest = {
|
||||||
|
tripName?: string;
|
||||||
|
legs: FlightLegWindow[];
|
||||||
|
passengerGroups: FlightPassengerGroup[];
|
||||||
|
legAssignments: Array<{ legId: string; passengerGroupIds: string[] }>;
|
||||||
|
recipientEmail?: string | null;
|
||||||
|
preferences: FlightSearchPreferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightReportStatus = {
|
||||||
|
needsMissingInputs: string[];
|
||||||
|
readyToSearch: boolean;
|
||||||
|
pdfReady: boolean;
|
||||||
|
emailReady: boolean;
|
||||||
|
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightQuote = {
|
||||||
|
id: string;
|
||||||
|
source: FlightSearchSourceName;
|
||||||
|
legId: string;
|
||||||
|
passengerGroupIds: string[];
|
||||||
|
bookingLink: string;
|
||||||
|
itinerarySummary: string;
|
||||||
|
airlineName?: string;
|
||||||
|
departureTimeLocal?: string;
|
||||||
|
arrivalTimeLocal?: string;
|
||||||
|
stopsText?: string;
|
||||||
|
layoverText?: string;
|
||||||
|
totalDurationText?: string;
|
||||||
|
totalPriceUsd: number;
|
||||||
|
displayPriceUsd: string;
|
||||||
|
originalCurrency?: string;
|
||||||
|
originalTotalPrice?: number;
|
||||||
|
directBookingUrl?: string | null;
|
||||||
|
crossCheckStatus?: "verified" | "not-available" | "failed";
|
||||||
|
notes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightRecommendationOption = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
quoteIds: string[];
|
||||||
|
totalPriceUsd: number;
|
||||||
|
rationale: string;
|
||||||
|
backup?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightSearchSourceName =
|
||||||
|
| "kayak"
|
||||||
|
| "skyscanner"
|
||||||
|
| "expedia"
|
||||||
|
| "airline-direct";
|
||||||
|
|
||||||
|
export type FlightSearchSourceViability = "viable" | "degraded" | "blocked";
|
||||||
|
|
||||||
|
export type FlightSearchSourceFinding = {
|
||||||
|
source: FlightSearchSourceName;
|
||||||
|
status: FlightSearchSourceViability;
|
||||||
|
checkedAt: string;
|
||||||
|
notes: string[];
|
||||||
|
evidence?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightSearchPlanSource = {
|
||||||
|
source: FlightSearchSourceName;
|
||||||
|
enabled: boolean;
|
||||||
|
required: boolean;
|
||||||
|
status: FlightSearchSourceViability;
|
||||||
|
silentTimeoutMs: number;
|
||||||
|
totalTimeoutMs: number;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightSearchPlan = {
|
||||||
|
sourceOrder: FlightSearchPlanSource[];
|
||||||
|
vpn: {
|
||||||
|
enabled: boolean;
|
||||||
|
marketCountry: string | null;
|
||||||
|
connectTimeoutMs: number;
|
||||||
|
disconnectTimeoutMs: number;
|
||||||
|
fallbackMode: "default-market";
|
||||||
|
};
|
||||||
|
degradedReasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightReportRequestDraft = {
|
||||||
|
tripName?: string | null;
|
||||||
|
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
|
||||||
|
passengerGroups?: Array<Partial<FlightPassengerGroup> | null | undefined> | null;
|
||||||
|
legAssignments?:
|
||||||
|
| Array<
|
||||||
|
| {
|
||||||
|
legId?: string | null;
|
||||||
|
passengerGroupIds?: Array<string | null | undefined> | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
>
|
||||||
|
| null;
|
||||||
|
recipientEmail?: string | null;
|
||||||
|
preferences?: Partial<FlightSearchPreferences> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedFlightReportRequest = {
|
||||||
|
request: FlightReportRequest;
|
||||||
|
readyToSearch: boolean;
|
||||||
|
missingInputs: string[];
|
||||||
|
missingSearchInputs: string[];
|
||||||
|
missingDeliveryInputs: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightReportPayload = {
|
||||||
|
request: FlightReportRequest;
|
||||||
|
searchExecution: {
|
||||||
|
freshSearch: boolean;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string;
|
||||||
|
artifactsRoot: string;
|
||||||
|
notes?: string[];
|
||||||
|
};
|
||||||
|
sourceFindings: FlightSearchSourceFinding[];
|
||||||
|
quotes: FlightQuote[];
|
||||||
|
rankedOptions: FlightRecommendationOption[];
|
||||||
|
executiveSummary: string[];
|
||||||
|
reportWarnings: string[];
|
||||||
|
degradedReasons: string[];
|
||||||
|
comparisonCurrency: "USD";
|
||||||
|
marketCountryUsed?: string | null;
|
||||||
|
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||||
|
renderedPdfPath?: string | null;
|
||||||
|
deliveredTo?: string | null;
|
||||||
|
emailAuthorized?: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightReportStatusResult = FlightReportStatus & {
|
||||||
|
chatSummaryReady: boolean;
|
||||||
|
terminalOutcome:
|
||||||
|
| "ready"
|
||||||
|
| "missing-inputs"
|
||||||
|
| "all-sources-failed"
|
||||||
|
| "report-incomplete";
|
||||||
|
degraded: boolean;
|
||||||
|
degradedReasons: string[];
|
||||||
|
blockingReason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlightRunState = {
|
||||||
|
request: FlightReportRequest;
|
||||||
|
lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||||
|
updatedAt: string;
|
||||||
|
reportWarnings: string[];
|
||||||
|
degradedReasons: string[];
|
||||||
|
sourceFindings: FlightSearchSourceFinding[];
|
||||||
|
comparisonCurrency: "USD";
|
||||||
|
};
|
||||||
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { normalizePriceToUsd } from "../src/price-normalization.js";
|
||||||
|
|
||||||
|
test("normalizePriceToUsd passes through USD values", () => {
|
||||||
|
const result = normalizePriceToUsd({
|
||||||
|
amount: 2847,
|
||||||
|
currency: "USD"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.amountUsd, 2847);
|
||||||
|
assert.equal(result.currency, "USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
|
||||||
|
const result = normalizePriceToUsd({
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR",
|
||||||
|
exchangeRates: {
|
||||||
|
EUR: 1.08
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.amountUsd, 108);
|
||||||
|
assert.match(result.notes.join(" "), /EUR/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizePriceToUsd rejects missing exchange rates", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizePriceToUsd({
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR"
|
||||||
|
}),
|
||||||
|
/exchange rate/i
|
||||||
|
);
|
||||||
|
});
|
||||||
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
|
||||||
|
|
||||||
|
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
|
||||||
|
const plan = buildLukeDeliveryPlan({
|
||||||
|
recipientEmail: "invalid-email",
|
||||||
|
subject: "DFW ↔ BLQ flight report",
|
||||||
|
body: "Summary",
|
||||||
|
attachmentPath: "/tmp/report.pdf"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(plan.ready, false);
|
||||||
|
assert.equal(plan.needsRecipientEmail, true);
|
||||||
|
assert.equal(plan.command, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
|
||||||
|
const plan = buildLukeDeliveryPlan({
|
||||||
|
recipientEmail: "stefano@fiorinis.com",
|
||||||
|
subject: "DFW ↔ BLQ flight report",
|
||||||
|
body: "Summary",
|
||||||
|
attachmentPath: "/tmp/report.pdf"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(plan.ready, true);
|
||||||
|
assert.match(String(plan.command), /gog-luke gmail send/);
|
||||||
|
assert.match(String(plan.command), /stefano@fiorinis.com/);
|
||||||
|
assert.match(String(plan.command), /report\.pdf/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
|
||||||
|
const plan = buildLukeDeliveryPlan({
|
||||||
|
recipientEmail: "stefano@fiorinis.com",
|
||||||
|
subject: "Luke's flight report; rm -rf /",
|
||||||
|
body: "It's ready.",
|
||||||
|
attachmentPath: "/tmp/report's-final.pdf"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(plan.ready, true);
|
||||||
|
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
|
||||||
|
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
|
||||||
|
});
|
||||||
273
skills/flight-finder/tests/report-pdf.test.ts
Normal file
273
skills/flight-finder/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
describeQuoteBookingLinks,
|
||||||
|
formatArtifactsRoot,
|
||||||
|
renderFlightReportPdf,
|
||||||
|
ReportValidationError
|
||||||
|
} from "../src/report-pdf.js";
|
||||||
|
import type { FlightReportPayload } from "../src/types.js";
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "../src/request-normalizer.js";
|
||||||
|
|
||||||
|
function samplePayload(): FlightReportPayload {
|
||||||
|
return {
|
||||||
|
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||||
|
searchExecution: {
|
||||||
|
freshSearch: true,
|
||||||
|
startedAt: "2026-03-30T20:45:00Z",
|
||||||
|
completedAt: "2026-03-30T21:00:00Z",
|
||||||
|
artifactsRoot: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
|
||||||
|
notes: ["Fresh bounded search executed for this report."]
|
||||||
|
},
|
||||||
|
sourceFindings: [
|
||||||
|
{
|
||||||
|
source: "kayak",
|
||||||
|
status: "viable",
|
||||||
|
checkedAt: "2026-03-30T21:00:00Z",
|
||||||
|
notes: ["Returned usable options."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
quotes: [
|
||||||
|
{
|
||||||
|
id: "quote-1",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "outbound",
|
||||||
|
passengerGroupIds: ["pair", "solo"],
|
||||||
|
bookingLink: "https://example.com/quote-1",
|
||||||
|
itinerarySummary: "DFW -> BLQ via LHR",
|
||||||
|
totalPriceUsd: 2847,
|
||||||
|
displayPriceUsd: "$2,847"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rankedOptions: [
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
title: "Best overall itinerary",
|
||||||
|
quoteIds: ["quote-1"],
|
||||||
|
totalPriceUsd: 2847,
|
||||||
|
rationale: "Best price-to-convenience tradeoff."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||||
|
reportWarnings: [],
|
||||||
|
degradedReasons: [],
|
||||||
|
comparisonCurrency: "USD",
|
||||||
|
generatedAt: "2026-03-30T21:00:00Z",
|
||||||
|
lastCompletedPhase: "ranking"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPdfPages(outputPath: string): number {
|
||||||
|
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||||
|
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||||
|
assert.ok(fs.existsSync(outputPath));
|
||||||
|
assert.ok(fs.statSync(outputPath).size > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderFlightReportPdf embeds booking links and only includes direct-airline URLs when captured", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-links-${Date.now()}.pdf`);
|
||||||
|
const payload = samplePayload();
|
||||||
|
payload.quotes = [
|
||||||
|
{
|
||||||
|
...payload.quotes[0],
|
||||||
|
bookingLink: "https://example.com/quote-1",
|
||||||
|
directBookingUrl: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quote-2",
|
||||||
|
source: "airline-direct",
|
||||||
|
legId: "return-pair",
|
||||||
|
passengerGroupIds: ["pair"],
|
||||||
|
bookingLink: "https://example.com/quote-2",
|
||||||
|
directBookingUrl: "https://www.britishairways.com/",
|
||||||
|
airlineName: "British Airways",
|
||||||
|
itinerarySummary: "BLQ -> DFW via LHR",
|
||||||
|
totalPriceUsd: 1100,
|
||||||
|
displayPriceUsd: "$1,100"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
payload.rankedOptions = [
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
title: "Best overall itinerary",
|
||||||
|
quoteIds: ["quote-1", "quote-2"],
|
||||||
|
totalPriceUsd: 3947,
|
||||||
|
rationale: "Best price-to-convenience tradeoff."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderFlightReportPdf(payload, outputPath);
|
||||||
|
|
||||||
|
const pdfContents = fs.readFileSync(outputPath, "latin1");
|
||||||
|
assert.match(pdfContents, /https:\/\/example\.com\/quote-1/);
|
||||||
|
assert.match(pdfContents, /https:\/\/example\.com\/quote-2/);
|
||||||
|
assert.match(pdfContents, /https:\/\/www\.britishairways\.com\//);
|
||||||
|
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
|
||||||
|
const payload = samplePayload();
|
||||||
|
payload.searchExecution.artifactsRoot =
|
||||||
|
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
|
||||||
|
payload.quotes = [
|
||||||
|
{
|
||||||
|
...payload.quotes[0],
|
||||||
|
id: "quote-1",
|
||||||
|
notes: [
|
||||||
|
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
|
||||||
|
"Skyscanner TH/USD corroborated the same Madrid pattern.",
|
||||||
|
"Expedia also found a nearby fare band."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quote-2",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "return-pair",
|
||||||
|
passengerGroupIds: ["pair"],
|
||||||
|
bookingLink: "https://example.com/quote-2",
|
||||||
|
itinerarySummary: "BLQ -> DFW via LHR",
|
||||||
|
totalPriceUsd: 1316,
|
||||||
|
displayPriceUsd: "$1,316 total ($658 pp x 2)",
|
||||||
|
notes: [
|
||||||
|
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
|
||||||
|
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
|
||||||
|
"Expedia hit a bot challenge on the Jun 8 paired-return check."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quote-3",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "return-solo",
|
||||||
|
passengerGroupIds: ["solo"],
|
||||||
|
bookingLink: "https://example.com/quote-3",
|
||||||
|
itinerarySummary: "BLQ -> DFW via MAD",
|
||||||
|
totalPriceUsd: 722,
|
||||||
|
displayPriceUsd: "$722 total",
|
||||||
|
notes: [
|
||||||
|
"Lowest valid solo-return fare in the fresh Jul window capture.",
|
||||||
|
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
|
||||||
|
"Expedia surfaced a cheaper Turkish option, but it was excluded."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
payload.rankedOptions = [
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
title: "Best overall itinerary",
|
||||||
|
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||||
|
totalPriceUsd: 4420,
|
||||||
|
rationale: "Best price-to-convenience tradeoff."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "backup",
|
||||||
|
title: "Backup itinerary",
|
||||||
|
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||||
|
totalPriceUsd: 4515,
|
||||||
|
rationale: "Slightly higher price but still viable."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
payload.executiveSummary = [
|
||||||
|
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
|
||||||
|
"Lowest valid total observed was $4,420.",
|
||||||
|
"Cross-source corroboration is strongest for the outbound and solo return."
|
||||||
|
];
|
||||||
|
|
||||||
|
await renderFlightReportPdf(payload, outputPath);
|
||||||
|
|
||||||
|
const pdfContents = fs.readFileSync(outputPath, "latin1");
|
||||||
|
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
|
||||||
|
assert.ok(countPdfPages(outputPath) <= 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
renderFlightReportPdf(
|
||||||
|
{
|
||||||
|
...samplePayload(),
|
||||||
|
rankedOptions: [],
|
||||||
|
executiveSummary: []
|
||||||
|
},
|
||||||
|
outputPath
|
||||||
|
),
|
||||||
|
ReportValidationError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => {
|
||||||
|
const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`);
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
renderFlightReportPdf(
|
||||||
|
{
|
||||||
|
...samplePayload(),
|
||||||
|
searchExecution: {
|
||||||
|
...samplePayload().searchExecution,
|
||||||
|
freshSearch: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
outputPath
|
||||||
|
),
|
||||||
|
ReportValidationError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("describeQuoteBookingLinks includes both aggregator and airline-direct links when captured", () => {
|
||||||
|
const payload = samplePayload();
|
||||||
|
const quote = {
|
||||||
|
...payload.quotes[0],
|
||||||
|
airlineName: "British Airways",
|
||||||
|
directBookingUrl: "https://www.britishairways.com/"
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(describeQuoteBookingLinks(quote), [
|
||||||
|
{
|
||||||
|
label: "Search / book this fare",
|
||||||
|
text: "Search / book this fare: $2,847 via kayak",
|
||||||
|
url: "https://example.com/quote-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Direct airline booking",
|
||||||
|
text: "Direct airline booking: British Airways",
|
||||||
|
url: "https://www.britishairways.com/"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("describeQuoteBookingLinks calls out missing direct-airline links explicitly", () => {
|
||||||
|
const payload = samplePayload();
|
||||||
|
|
||||||
|
assert.deepEqual(describeQuoteBookingLinks(payload.quotes[0]), [
|
||||||
|
{
|
||||||
|
label: "Search / book this fare",
|
||||||
|
text: "Search / book this fare: $2,847 via kayak",
|
||||||
|
url: "https://example.com/quote-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Direct airline booking",
|
||||||
|
text: "Direct airline booking: not captured in this run",
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatArtifactsRoot only shortens known workspace report paths", () => {
|
||||||
|
assert.equal(
|
||||||
|
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
|
||||||
|
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
|
||||||
|
);
|
||||||
|
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
|
||||||
|
});
|
||||||
106
skills/flight-finder/tests/report-status.test.ts
Normal file
106
skills/flight-finder/tests/report-status.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "../src/request-normalizer.js";
|
||||||
|
import { getFlightReportStatus } from "../src/report-status.js";
|
||||||
|
import type { FlightReportPayload } from "../src/types.js";
|
||||||
|
|
||||||
|
function buildPayload(): FlightReportPayload {
|
||||||
|
return {
|
||||||
|
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||||
|
searchExecution: {
|
||||||
|
freshSearch: true,
|
||||||
|
startedAt: "2026-03-30T20:45:00Z",
|
||||||
|
completedAt: "2026-03-30T21:00:00Z",
|
||||||
|
artifactsRoot: "/tmp/flight-finder-report-status",
|
||||||
|
notes: ["Fresh bounded search executed for this report."]
|
||||||
|
},
|
||||||
|
sourceFindings: [
|
||||||
|
{
|
||||||
|
source: "kayak",
|
||||||
|
status: "viable",
|
||||||
|
checkedAt: "2026-03-30T21:00:00Z",
|
||||||
|
notes: ["Returned usable options."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
quotes: [
|
||||||
|
{
|
||||||
|
id: "quote-1",
|
||||||
|
source: "kayak",
|
||||||
|
legId: "outbound",
|
||||||
|
passengerGroupIds: ["pair", "solo"],
|
||||||
|
bookingLink: "https://example.com/quote-1",
|
||||||
|
itinerarySummary: "DFW -> BLQ via LHR",
|
||||||
|
totalPriceUsd: 2847,
|
||||||
|
displayPriceUsd: "$2,847",
|
||||||
|
crossCheckStatus: "not-available"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rankedOptions: [
|
||||||
|
{
|
||||||
|
id: "primary",
|
||||||
|
title: "Best overall itinerary",
|
||||||
|
quoteIds: ["quote-1"],
|
||||||
|
totalPriceUsd: 2847,
|
||||||
|
rationale: "Best price-to-convenience tradeoff."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||||
|
reportWarnings: [],
|
||||||
|
degradedReasons: [],
|
||||||
|
comparisonCurrency: "USD",
|
||||||
|
generatedAt: "2026-03-30T21:00:00Z",
|
||||||
|
lastCompletedPhase: "ranking"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
|
||||||
|
const normalized = normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
recipientEmail: null
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
...buildPayload(),
|
||||||
|
request: normalized.request
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = getFlightReportStatus(normalized, payload);
|
||||||
|
|
||||||
|
assert.equal(status.readyToSearch, true);
|
||||||
|
assert.equal(status.pdfReady, true);
|
||||||
|
assert.equal(status.emailReady, false);
|
||||||
|
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
|
||||||
|
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||||
|
const status = getFlightReportStatus(normalized, {
|
||||||
|
...buildPayload(),
|
||||||
|
sourceFindings: [
|
||||||
|
{
|
||||||
|
source: "kayak",
|
||||||
|
status: "blocked",
|
||||||
|
checkedAt: "2026-03-30T21:00:00Z",
|
||||||
|
notes: ["Timed out."]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "skyscanner",
|
||||||
|
status: "blocked",
|
||||||
|
checkedAt: "2026-03-30T21:00:00Z",
|
||||||
|
notes: ["Timed out."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
quotes: [],
|
||||||
|
rankedOptions: [],
|
||||||
|
executiveSummary: [],
|
||||||
|
degradedReasons: ["All configured travel sources failed."]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(status.terminalOutcome, "all-sources-failed");
|
||||||
|
assert.equal(status.chatSummaryReady, true);
|
||||||
|
assert.equal(status.pdfReady, false);
|
||||||
|
assert.equal(status.degraded, true);
|
||||||
|
});
|
||||||
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "../src/request-normalizer.js";
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest preserves the legacy DFW-BLQ split-return structure", () => {
|
||||||
|
const result = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||||
|
|
||||||
|
assert.equal(result.readyToSearch, true);
|
||||||
|
assert.deepEqual(result.missingSearchInputs, []);
|
||||||
|
assert.deepEqual(result.missingDeliveryInputs, []);
|
||||||
|
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||||
|
assert.equal(result.request.preferences.normalizeCurrencyTo, "USD");
|
||||||
|
assert.equal(result.request.legs.length, 3);
|
||||||
|
assert.equal(result.request.passengerGroups.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
result.request.legAssignments.find((entry) => entry.legId === "outbound")?.passengerGroupIds,
|
||||||
|
["pair", "solo"]
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.request.preferences.requireAirlineDirectCrossCheck,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest keeps email missing as a delivery gate, not a search blocker", () => {
|
||||||
|
const result = normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
recipientEmail: null
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.readyToSearch, true);
|
||||||
|
assert.deepEqual(result.missingSearchInputs, []);
|
||||||
|
assert.deepEqual(result.missingDeliveryInputs, ["recipient email"]);
|
||||||
|
assert.match(result.warnings.join(" "), /recipient email/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest reports missing search-critical inputs explicitly", () => {
|
||||||
|
const result = normalizeFlightReportRequest({
|
||||||
|
tripName: "Incomplete flight request",
|
||||||
|
legs: [
|
||||||
|
{
|
||||||
|
id: "outbound",
|
||||||
|
origin: "DFW",
|
||||||
|
destination: "",
|
||||||
|
label: "Outbound"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
passengerGroups: [],
|
||||||
|
legAssignments: []
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.readyToSearch, false);
|
||||||
|
assert.match(result.missingSearchInputs.join(" | "), /origin and destination/i);
|
||||||
|
assert.match(result.missingSearchInputs.join(" | "), /date window/i);
|
||||||
|
assert.match(result.missingSearchInputs.join(" | "), /passenger groups/i);
|
||||||
|
assert.match(result.missingSearchInputs.join(" | "), /assignments/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest validates marketCountry as an ISO alpha-2 code", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
preferences: {
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||||
|
marketCountry: "Germany"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/ISO 3166-1 alpha-2/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest uppercases a lowercase marketCountry code", () => {
|
||||||
|
const result = normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
preferences: {
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||||
|
marketCountry: "th"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeFlightReportRequest flags a malformed recipient email without blocking search readiness", () => {
|
||||||
|
const result = normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
recipientEmail: "stefano-at-example"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.readyToSearch, true);
|
||||||
|
assert.deepEqual(result.missingDeliveryInputs, ["valid recipient email"]);
|
||||||
|
assert.match(result.warnings.join(" "), /malformed/i);
|
||||||
|
});
|
||||||
42
skills/flight-finder/tests/run-state.test.ts
Normal file
42
skills/flight-finder/tests/run-state.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearFlightFinderRunState,
|
||||||
|
loadFlightFinderRunState,
|
||||||
|
saveFlightFinderRunState
|
||||||
|
} from "../src/run-state.js";
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "../src/request-normalizer.js";
|
||||||
|
|
||||||
|
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
|
||||||
|
const baseDir = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "flight-finder-run-state-")
|
||||||
|
);
|
||||||
|
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||||
|
|
||||||
|
await saveFlightFinderRunState(
|
||||||
|
{
|
||||||
|
request,
|
||||||
|
lastCompletedPhase: "search",
|
||||||
|
updatedAt: "2026-03-30T21:00:00Z",
|
||||||
|
reportWarnings: [],
|
||||||
|
degradedReasons: [],
|
||||||
|
sourceFindings: [],
|
||||||
|
comparisonCurrency: "USD"
|
||||||
|
},
|
||||||
|
baseDir
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = await loadFlightFinderRunState(baseDir);
|
||||||
|
assert.equal(loaded?.lastCompletedPhase, "search");
|
||||||
|
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
|
||||||
|
|
||||||
|
await clearFlightFinderRunState(baseDir);
|
||||||
|
assert.equal(await loadFlightFinderRunState(baseDir), null);
|
||||||
|
});
|
||||||
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
normalizeFlightReportRequest
|
||||||
|
} from "../src/request-normalizer.js";
|
||||||
|
import {
|
||||||
|
VPN_CONNECT_TIMEOUT_MS,
|
||||||
|
VPN_DISCONNECT_TIMEOUT_MS,
|
||||||
|
buildFlightSearchPlan
|
||||||
|
} from "../src/search-orchestration.js";
|
||||||
|
|
||||||
|
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
|
||||||
|
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||||
|
const plan = buildFlightSearchPlan(request);
|
||||||
|
|
||||||
|
assert.equal(plan.vpn.enabled, true);
|
||||||
|
assert.equal(plan.vpn.marketCountry, "TH");
|
||||||
|
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
|
||||||
|
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
|
||||||
|
const request = normalizeFlightReportRequest({
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||||
|
preferences: {
|
||||||
|
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||||
|
marketCountry: null
|
||||||
|
}
|
||||||
|
}).request;
|
||||||
|
const plan = buildFlightSearchPlan(request, [
|
||||||
|
{
|
||||||
|
source: "airline-direct",
|
||||||
|
status: "degraded",
|
||||||
|
checkedAt: "2026-03-30T21:00:00Z",
|
||||||
|
notes: ["Direct booking shell loads but search completion is unreliable."]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(plan.vpn.enabled, false);
|
||||||
|
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
|
||||||
|
assert.equal(directSource?.enabled, true);
|
||||||
|
assert.equal(directSource?.required, false);
|
||||||
|
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
|
||||||
|
});
|
||||||
17
skills/flight-finder/tsconfig.json
Normal file
17
skills/flight-finder/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -41,7 +41,13 @@ node scripts/nordvpn-client.js status --debug
|
|||||||
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
|
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
|
||||||
- `install` bootstraps them with Homebrew
|
- `install` bootstraps them with Homebrew
|
||||||
- `login` validates the token for the WireGuard backend
|
- `login` validates the token for the WireGuard backend
|
||||||
|
- the generated WireGuard config stays free of `DNS = ...`
|
||||||
|
- `connect` now requires a bounded persistence gate plus a verified exit before success is declared
|
||||||
|
- the skill snapshots and applies NordVPN DNS only to eligible physical services while connected
|
||||||
|
- NordVPN DNS is applied only after the tunnel remains up, the final liveness check still shows the requested exit, and system hostname resolution still works afterward
|
||||||
|
- `disconnect` restores the saved DNS/search-domain state even if the tunnel state is stale
|
||||||
- Tailscale is suspended before connect and resumed after disconnect or failed connect
|
- Tailscale is suspended before connect and resumed after disconnect or failed connect
|
||||||
|
- the skill writes a short-lived Tailscale suppression marker during VPN connect so host watchdogs do not immediately re-run `tailscale up`
|
||||||
- `NordVPN.app` may remain installed but is only the manual fallback
|
- `NordVPN.app` may remain installed but is only the manual fallback
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
@@ -75,6 +81,10 @@ Exact `visudo` rule for the installed OpenClaw skill:
|
|||||||
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Operational note:
|
||||||
|
|
||||||
|
- the persistence gate reuses the already-allowed `probe` action to confirm the live `utun*` WireGuard runtime and does not require extra sudoers actions beyond `probe`, `up`, and `down`
|
||||||
|
|
||||||
## Agent Guidance
|
## Agent Guidance
|
||||||
|
|
||||||
- run `status` first when the machine state is unclear
|
- run `status` first when the machine state is unclear
|
||||||
@@ -83,10 +93,11 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
|
|||||||
- use `connect` before location-sensitive skills such as `web-automation`
|
- use `connect` before location-sensitive skills such as `web-automation`
|
||||||
- use `verify` after connect when you need an explicit location check
|
- use `verify` after connect when you need an explicit location check
|
||||||
- use `disconnect` after the follow-up task
|
- use `disconnect` after the follow-up task
|
||||||
|
- if `connect` fails its persistence or final verification gate, treat that as a safe rollback, not a partial success
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
|
|
||||||
- normal JSON output redacts local path metadata
|
- normal JSON output redacts local path metadata and helper-hardening diagnostics
|
||||||
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
|
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
|
||||||
|
|
||||||
## Troubleshooting Cues
|
## Troubleshooting Cues
|
||||||
@@ -98,6 +109,7 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
|
|||||||
- connect succeeds but final state looks inconsistent:
|
- connect succeeds but final state looks inconsistent:
|
||||||
- rely on the verified public IP/location first
|
- rely on the verified public IP/location first
|
||||||
- then inspect `status --debug`
|
- then inspect `status --debug`
|
||||||
|
- `verified: true` but `persistence.stable: false` should not happen anymore; if it does, the skill should roll back instead of pinning DNS
|
||||||
- disconnect should leave:
|
- disconnect should leave:
|
||||||
- normal public IP restored
|
- normal public IP restored
|
||||||
- no active WireGuard state
|
- no active WireGuard state
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ const WG_STATE_DIR = path.join(STATE_DIR, "wireguard");
|
|||||||
const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`);
|
const WG_CONFIG_PATH = path.join(WG_STATE_DIR, `${MAC_WG_INTERFACE}.conf`);
|
||||||
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
const AUTH_CACHE_PATH = path.join(STATE_DIR, "auth.json");
|
||||||
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
|
const LAST_CONNECTION_PATH = path.join(STATE_DIR, "last-connection.json");
|
||||||
|
const MAC_DNS_STATE_PATH = path.join(STATE_DIR, "dns.json");
|
||||||
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
|
const MAC_TAILSCALE_STATE_PATH = path.join(STATE_DIR, "tailscale.json");
|
||||||
|
const MAC_TAILSCALE_SUPPRESS_PATH = path.join(STATE_DIR, "tailscale-suppressed");
|
||||||
|
const OPERATION_LOCK_PATH = path.join(STATE_DIR, "operation.lock");
|
||||||
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
const OPENCLAW_NORDVPN_CREDENTIALS_DIR = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
".openclaw",
|
".openclaw",
|
||||||
@@ -37,7 +40,13 @@ const MAC_WG_HELPER_PATH = path.join(
|
|||||||
const CLIENT_IPV4 = "10.5.0.2";
|
const CLIENT_IPV4 = "10.5.0.2";
|
||||||
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
|
const DNS_FALLBACK_RESOLVERS = ["1.1.1.1", "8.8.8.8"];
|
||||||
const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"];
|
const NORDVPN_MAC_DNS_SERVERS = ["103.86.96.100", "103.86.99.100"];
|
||||||
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource"]);
|
const CONNECT_PERSISTENCE_ATTEMPTS = 6;
|
||||||
|
const CONNECT_PERSISTENCE_DELAY_MS = 2000;
|
||||||
|
const CONNECT_TOTAL_TIMEOUT_MS = 90000;
|
||||||
|
const POST_DNS_RESOLUTION_HOSTNAMES = ["www.google.com", "api.openai.com", "docs.openclaw.ai"];
|
||||||
|
const POST_DNS_RESOLUTION_TIMEOUT_MS = 4000;
|
||||||
|
const POST_DNS_SETTLE_DELAY_MS = 1500;
|
||||||
|
const REDACTED_PATH_KEYS = new Set(["cliPath", "appPath", "configPath", "helperPath", "tokenSource", "helperSecurity"]);
|
||||||
|
|
||||||
function sanitizeOutputPayload(payload) {
|
function sanitizeOutputPayload(payload) {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
@@ -252,6 +261,78 @@ function sleep(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processExists(pid) {
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return error && error.code === "EPERM";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOperationLockStale(lockRecord, options = {}) {
|
||||||
|
if (!lockRecord || typeof lockRecord !== "object") return true;
|
||||||
|
const now = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
||||||
|
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
|
||||||
|
const startedAtMs = Number.isFinite(lockRecord.startedAtMs)
|
||||||
|
? lockRecord.startedAtMs
|
||||||
|
: Date.parse(lockRecord.startedAt || "");
|
||||||
|
|
||||||
|
if (!processExists(lockRecord.pid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(startedAtMs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return now - startedAtMs > maxAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupOperationLock(lockPath = OPERATION_LOCK_PATH) {
|
||||||
|
try {
|
||||||
|
if (lockPath && fs.existsSync(lockPath)) {
|
||||||
|
fs.unlinkSync(lockPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireOperationLock(action, options = {}) {
|
||||||
|
const lockPath = options.lockPath || OPERATION_LOCK_PATH;
|
||||||
|
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
||||||
|
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : CONNECT_TOTAL_TIMEOUT_MS;
|
||||||
|
const existing = readJsonFile(lockPath);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (isOperationLockStale(existing, { nowMs, maxAgeMs })) {
|
||||||
|
cleanupOperationLock(lockPath);
|
||||||
|
} else {
|
||||||
|
const activeAction = existing.action || "unknown";
|
||||||
|
throw new Error(`Another nordvpn-client ${activeAction} operation is already running.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
action,
|
||||||
|
pid: process.pid,
|
||||||
|
startedAt: new Date(nowMs).toISOString(),
|
||||||
|
startedAtMs: nowMs,
|
||||||
|
};
|
||||||
|
writeJsonFile(lockPath, record);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lockPath,
|
||||||
|
record,
|
||||||
|
release() {
|
||||||
|
const current = readJsonFile(lockPath);
|
||||||
|
if (current && current.pid === record.pid && current.startedAtMs === record.startedAtMs) {
|
||||||
|
cleanupOperationLock(lockPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
function detectMacWireguardActiveFromIfconfig(ifconfigOutput) {
|
||||||
let inUtunBlock = false;
|
let inUtunBlock = false;
|
||||||
const lines = `${ifconfigOutput || ""}`.split("\n");
|
const lines = `${ifconfigOutput || ""}`.split("\n");
|
||||||
@@ -276,6 +357,281 @@ function buildMacTailscaleState(tailscaleWasActive) {
|
|||||||
return { tailscaleWasActive: Boolean(tailscaleWasActive) };
|
return { tailscaleWasActive: Boolean(tailscaleWasActive) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markMacTailscaleRecoverySuppressed() {
|
||||||
|
ensureDir(STATE_DIR);
|
||||||
|
writeTextFile(MAC_TAILSCALE_SUPPRESS_PATH, `${new Date().toISOString()}\n`, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMacTailscaleRecoverySuppressed() {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(MAC_TAILSCALE_SUPPRESS_PATH);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectMacWireguardHelperSecurity(helperPath, deps = {}) {
|
||||||
|
const fileExistsFn = deps.fileExists || fileExists;
|
||||||
|
const statSyncFn = deps.statSync || fs.statSync;
|
||||||
|
if (!helperPath || !fileExistsFn(helperPath)) {
|
||||||
|
return {
|
||||||
|
exists: false,
|
||||||
|
hardened: false,
|
||||||
|
ownerUid: null,
|
||||||
|
groupGid: null,
|
||||||
|
mode: null,
|
||||||
|
reason: "Helper is missing.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = statSyncFn(helperPath);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hardened: false,
|
||||||
|
ownerUid: null,
|
||||||
|
groupGid: null,
|
||||||
|
mode: null,
|
||||||
|
reason: error.message || "Unable to inspect helper security.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = stat.mode & 0o777;
|
||||||
|
if (stat.uid !== 0) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hardened: false,
|
||||||
|
ownerUid: stat.uid,
|
||||||
|
groupGid: stat.gid,
|
||||||
|
mode,
|
||||||
|
reason: "Helper must be root-owned before privileged actions are trusted.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ((mode & 0o022) !== 0) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hardened: false,
|
||||||
|
ownerUid: stat.uid,
|
||||||
|
groupGid: stat.gid,
|
||||||
|
mode,
|
||||||
|
reason: "Helper must not be group- or world-writable.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
hardened: true,
|
||||||
|
ownerUid: stat.uid,
|
||||||
|
groupGid: stat.gid,
|
||||||
|
mode,
|
||||||
|
reason: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimDiagnosticOutput(value, maxChars = 4000) {
|
||||||
|
const text = `${value || ""}`.trim();
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.length <= maxChars) return text;
|
||||||
|
return `${text.slice(0, maxChars)}\n...[truncated]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeMacWireguardDiagnostics(diagnostics) {
|
||||||
|
if (!diagnostics) return null;
|
||||||
|
return {
|
||||||
|
interfaceName: diagnostics.interfaceName || MAC_WG_INTERFACE,
|
||||||
|
wgShowCaptured: Boolean(diagnostics.wgShow),
|
||||||
|
ifconfigCaptured: Boolean(diagnostics.ifconfig),
|
||||||
|
routesCaptured: Boolean(diagnostics.routes),
|
||||||
|
processesCaptured: Boolean(diagnostics.processes),
|
||||||
|
helperSecurity:
|
||||||
|
diagnostics.helperSecurity && typeof diagnostics.helperSecurity === "object"
|
||||||
|
? {
|
||||||
|
hardened: Boolean(diagnostics.helperSecurity.hardened),
|
||||||
|
reason: diagnostics.helperSecurity.reason || "",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMacWireguardDiagnostics(options = {}) {
|
||||||
|
const runExecFn = options.runExec || runExec;
|
||||||
|
const interfaceName = options.interfaceName || MAC_WG_INTERFACE;
|
||||||
|
const wgPath = options.wgPath || commandExists("wg") || "/opt/homebrew/bin/wg";
|
||||||
|
const helperSecurity =
|
||||||
|
options.helperSecurity ||
|
||||||
|
inspectMacWireguardHelperSecurity(options.helperPath || MAC_WG_HELPER_PATH, options.securityDeps || {});
|
||||||
|
|
||||||
|
const [wgShow, ifconfig, routes, processes] = await Promise.all([
|
||||||
|
runExecFn(wgPath, ["show", interfaceName]),
|
||||||
|
runExecFn("ifconfig", [interfaceName]),
|
||||||
|
runExecFn("route", ["-n", "get", "default"]),
|
||||||
|
runExecFn("pgrep", ["-fl", "wireguard-go|wg-quick|nordvpnctl"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
interfaceName,
|
||||||
|
helperSecurity,
|
||||||
|
wgShow: trimDiagnosticOutput(wgShow.stdout || wgShow.stderr || wgShow.error),
|
||||||
|
ifconfig: trimDiagnosticOutput(ifconfig.stdout || ifconfig.stderr || ifconfig.error),
|
||||||
|
routes: trimDiagnosticOutput(routes.stdout || routes.stderr || routes.error),
|
||||||
|
processes: trimDiagnosticOutput(processes.stdout || processes.stderr || processes.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldResumeMacTailscale(state, currentlyActive) {
|
||||||
|
return Boolean(state && state.tailscaleWasActive && !currentlyActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMacDnsState(services) {
|
||||||
|
return {
|
||||||
|
services: (services || []).map((service) => ({
|
||||||
|
name: service.name,
|
||||||
|
dnsServers: Array.isArray(service.dnsServers) ? service.dnsServers : [],
|
||||||
|
searchDomains: Array.isArray(service.searchDomains) ? service.searchDomains : [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldManageMacDnsService(serviceName) {
|
||||||
|
const normalized = `${serviceName || ""}`.trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return !["tailscale", "bridge", "thunderbolt bridge", "loopback", "vpn", "utun"].some((token) => normalized.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMacNetworksetupList(output) {
|
||||||
|
return `${output || ""}`
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((line) => !line.startsWith("An asterisk"))
|
||||||
|
.map((line) => (line.startsWith("*") ? line.slice(1).trim() : line))
|
||||||
|
.filter((line) => shouldManageMacDnsService(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMacDnsCommandOutput(output) {
|
||||||
|
const text = `${output || ""}`.trim();
|
||||||
|
if (!text || text.includes("aren't any") || text.includes("There aren't any")) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNordDnsServerList(dnsServers) {
|
||||||
|
if (!Array.isArray(dnsServers) || !dnsServers.length) return false;
|
||||||
|
const normalized = dnsServers.map((value) => `${value}`.trim()).filter(Boolean);
|
||||||
|
if (!normalized.length) return false;
|
||||||
|
return normalized.every((value) => NORDVPN_MAC_DNS_SERVERS.includes(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAutomaticMacDnsState(serviceNames) {
|
||||||
|
return {
|
||||||
|
services: (serviceNames || []).map((name) => ({
|
||||||
|
name,
|
||||||
|
dnsServers: [],
|
||||||
|
searchDomains: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRejectMacDnsBaseline(state) {
|
||||||
|
if (!state || !Array.isArray(state.services) || !state.services.length) return false;
|
||||||
|
return state.services.every((service) => isNordDnsServerList(service.dnsServers || []));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMacDnsServices() {
|
||||||
|
const result = await runExec("networksetup", ["-listallnetworkservices"]);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error((result.stderr || result.stdout || result.error).trim() || "networksetup -listallnetworkservices failed");
|
||||||
|
}
|
||||||
|
return normalizeMacNetworksetupList(result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMacDnsStateForService(serviceName) {
|
||||||
|
const dnsResult = await runExec("networksetup", ["-getdnsservers", serviceName]);
|
||||||
|
if (!dnsResult.ok) {
|
||||||
|
throw new Error((dnsResult.stderr || dnsResult.stdout || dnsResult.error).trim() || `networksetup -getdnsservers failed for ${serviceName}`);
|
||||||
|
}
|
||||||
|
const searchResult = await runExec("networksetup", ["-getsearchdomains", serviceName]);
|
||||||
|
if (!searchResult.ok) {
|
||||||
|
throw new Error((searchResult.stderr || searchResult.stdout || searchResult.error).trim() || `networksetup -getsearchdomains failed for ${serviceName}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: serviceName,
|
||||||
|
dnsServers: normalizeMacDnsCommandOutput(dnsResult.stdout),
|
||||||
|
searchDomains: normalizeMacDnsCommandOutput(searchResult.stdout),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotMacDnsState() {
|
||||||
|
const services = await listMacDnsServices();
|
||||||
|
const snapshot = [];
|
||||||
|
for (const serviceName of services) {
|
||||||
|
snapshot.push(await readMacDnsStateForService(serviceName));
|
||||||
|
}
|
||||||
|
const state = buildMacDnsState(snapshot);
|
||||||
|
writeJsonFile(MAC_DNS_STATE_PATH, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMacDnsServers(serviceName, dnsServers) {
|
||||||
|
const args = ["-setdnsservers", serviceName];
|
||||||
|
args.push(...(dnsServers && dnsServers.length ? dnsServers : ["Empty"]));
|
||||||
|
const result = await runExec("networksetup", args);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setdnsservers failed for ${serviceName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setMacSearchDomains(serviceName, searchDomains) {
|
||||||
|
const args = ["-setsearchdomains", serviceName];
|
||||||
|
args.push(...(searchDomains && searchDomains.length ? searchDomains : ["Empty"]));
|
||||||
|
const result = await runExec("networksetup", args);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error((result.stderr || result.stdout || result.error).trim() || `networksetup -setsearchdomains failed for ${serviceName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMacNordDns() {
|
||||||
|
let snapshot = readJsonFile(MAC_DNS_STATE_PATH);
|
||||||
|
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
|
||||||
|
snapshot = await snapshotMacDnsState();
|
||||||
|
} else if (shouldRejectMacDnsBaseline(snapshot)) {
|
||||||
|
const services = await listMacDnsServices();
|
||||||
|
snapshot = buildAutomaticMacDnsState(services);
|
||||||
|
writeJsonFile(MAC_DNS_STATE_PATH, snapshot);
|
||||||
|
}
|
||||||
|
for (const service of snapshot.services || []) {
|
||||||
|
await setMacDnsServers(service.name, NORDVPN_MAC_DNS_SERVERS);
|
||||||
|
await setMacSearchDomains(service.name, []);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreMacDnsIfNeeded() {
|
||||||
|
const snapshot = readJsonFile(MAC_DNS_STATE_PATH);
|
||||||
|
if (!snapshot || !Array.isArray(snapshot.services) || !snapshot.services.length) {
|
||||||
|
return { restored: false };
|
||||||
|
}
|
||||||
|
const servicesToRestore = shouldRejectMacDnsBaseline(snapshot)
|
||||||
|
? buildAutomaticMacDnsState(snapshot.services.map((service) => service.name)).services
|
||||||
|
: snapshot.services;
|
||||||
|
for (const service of servicesToRestore) {
|
||||||
|
await setMacDnsServers(service.name, service.dnsServers || []);
|
||||||
|
await setMacSearchDomains(service.name, service.searchDomains || []);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(MAC_DNS_STATE_PATH);
|
||||||
|
} catch {
|
||||||
|
// Ignore unlink errors.
|
||||||
|
}
|
||||||
|
return { restored: true };
|
||||||
|
}
|
||||||
|
|
||||||
function getMacTailscalePath(deps = {}) {
|
function getMacTailscalePath(deps = {}) {
|
||||||
const commandExistsFn = deps.commandExists || commandExists;
|
const commandExistsFn = deps.commandExists || commandExists;
|
||||||
const fileExistsFn = deps.fileExists || fileExists;
|
const fileExistsFn = deps.fileExists || fileExists;
|
||||||
@@ -308,12 +664,15 @@ async function getMacTailscaleStatus() {
|
|||||||
async function stopMacTailscaleIfActive() {
|
async function stopMacTailscaleIfActive() {
|
||||||
const status = await getMacTailscaleStatus();
|
const status = await getMacTailscaleStatus();
|
||||||
if (!status.active) {
|
if (!status.active) {
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false));
|
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(false));
|
||||||
return { tailscaleWasActive: false };
|
return { tailscaleWasActive: false };
|
||||||
}
|
}
|
||||||
|
markMacTailscaleRecoverySuppressed();
|
||||||
const tailscale = getMacTailscalePath();
|
const tailscale = getMacTailscalePath();
|
||||||
const result = await runExec(tailscale, ["down"]);
|
const result = await runExec(tailscale, ["down"]);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed");
|
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale down failed");
|
||||||
}
|
}
|
||||||
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true));
|
writeJsonFile(MAC_TAILSCALE_STATE_PATH, buildMacTailscaleState(true));
|
||||||
@@ -322,7 +681,19 @@ async function stopMacTailscaleIfActive() {
|
|||||||
|
|
||||||
async function resumeMacTailscaleIfNeeded() {
|
async function resumeMacTailscaleIfNeeded() {
|
||||||
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
|
const state = readJsonFile(MAC_TAILSCALE_STATE_PATH);
|
||||||
if (!state || !state.tailscaleWasActive) {
|
let currentStatus = null;
|
||||||
|
try {
|
||||||
|
currentStatus = await getMacTailscaleStatus();
|
||||||
|
} catch {
|
||||||
|
currentStatus = null;
|
||||||
|
}
|
||||||
|
if (!shouldResumeMacTailscale(state, currentStatus && currentStatus.active)) {
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
||||||
|
} catch {
|
||||||
|
// Ignore unlink errors.
|
||||||
|
}
|
||||||
return { restored: false };
|
return { restored: false };
|
||||||
}
|
}
|
||||||
const tailscale = getMacTailscalePath();
|
const tailscale = getMacTailscalePath();
|
||||||
@@ -330,6 +701,7 @@ async function resumeMacTailscaleIfNeeded() {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed");
|
throw new Error((result.stderr || result.stdout || result.error).trim() || "tailscale up failed");
|
||||||
}
|
}
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
fs.unlinkSync(MAC_TAILSCALE_STATE_PATH);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -362,6 +734,52 @@ async function resolveHostnameWithFallback(hostname, options = {}) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifySystemHostnameResolution(hostnames = POST_DNS_RESOLUTION_HOSTNAMES, options = {}) {
|
||||||
|
const lookup =
|
||||||
|
options.lookup ||
|
||||||
|
((hostname) =>
|
||||||
|
dns.promises.lookup(hostname, {
|
||||||
|
family: 4,
|
||||||
|
}));
|
||||||
|
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : POST_DNS_RESOLUTION_TIMEOUT_MS;
|
||||||
|
const settleDelayMs =
|
||||||
|
Number.isFinite(options.settleDelayMs) && options.settleDelayMs >= 0 ? options.settleDelayMs : POST_DNS_SETTLE_DELAY_MS;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (settleDelayMs > 0) {
|
||||||
|
await sleep(settleDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hostname of hostnames) {
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
Promise.resolve().then(() => lookup(hostname)),
|
||||||
|
sleep(timeoutMs).then(() => {
|
||||||
|
throw new Error(`timeout after ${timeoutMs}ms`);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const address = Array.isArray(result) ? result[0] && result[0].address : result && result.address;
|
||||||
|
if (address) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
hostname,
|
||||||
|
address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
errors.push(`${hostname}: no address returned`);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${hostname}: ${error.message || String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
hostname: "",
|
||||||
|
address: "",
|
||||||
|
error: errors.join("; "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildLookupResult(address, options = {}) {
|
function buildLookupResult(address, options = {}) {
|
||||||
if (options && options.all) {
|
if (options && options.all) {
|
||||||
return [{ address, family: 4 }];
|
return [{ address, family: 4 }];
|
||||||
@@ -385,12 +803,134 @@ function cleanupMacWireguardState(paths = {}) {
|
|||||||
return { cleaned };
|
return { cleaned };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupMacWireguardAndDnsState(paths = {}) {
|
||||||
|
const cleaned = cleanupMacWireguardState(paths).cleaned;
|
||||||
|
const dnsStatePath = paths.dnsStatePath || MAC_DNS_STATE_PATH;
|
||||||
|
let dnsCleaned = false;
|
||||||
|
try {
|
||||||
|
if (dnsStatePath && fs.existsSync(dnsStatePath)) {
|
||||||
|
fs.unlinkSync(dnsStatePath);
|
||||||
|
dnsCleaned = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors; caller will rely on current runtime state.
|
||||||
|
}
|
||||||
|
return { cleaned: cleaned || dnsCleaned };
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAttemptMacWireguardDisconnect(wireguardState) {
|
function shouldAttemptMacWireguardDisconnect(wireguardState) {
|
||||||
if (!wireguardState) return false;
|
if (!wireguardState) return false;
|
||||||
if (wireguardState.active) return true;
|
if (wireguardState.active) return true;
|
||||||
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
|
return Boolean(wireguardState.configPath || wireguardState.endpoint || wireguardState.lastConnection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBenignMacWireguardAbsentError(message) {
|
||||||
|
const normalized = `${message || ""}`.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes("is not a wireguard interface") ||
|
||||||
|
normalized.includes("is not a known interface") ||
|
||||||
|
normalized.includes("unable to access interface") ||
|
||||||
|
normalized.includes("not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMacWireguardHelperStatus(output) {
|
||||||
|
const parsed = {};
|
||||||
|
for (const line of `${output || ""}`.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const separator = trimmed.indexOf("=");
|
||||||
|
if (separator <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, separator).trim();
|
||||||
|
const value = trimmed.slice(separator + 1).trim();
|
||||||
|
parsed[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: ["1", "true", "yes", "on"].includes(`${parsed.active || ""}`.toLowerCase()),
|
||||||
|
interfaceName: parsed.interfaceName || MAC_WG_INTERFACE,
|
||||||
|
wireguardInterface: parsed.wireguardInterface || null,
|
||||||
|
configPath: parsed.configPath || null,
|
||||||
|
raw: `${output || ""}`.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMacWireguardHelperStatus(installProbe, options = {}) {
|
||||||
|
const runSudoWireguardFn = options.runSudoWireguard || runSudoWireguard;
|
||||||
|
const result = await runSudoWireguardFn(installProbe, "probe");
|
||||||
|
const parsed = parseMacWireguardHelperStatus(result.stdout || result.stderr || "");
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
ok: result.ok,
|
||||||
|
error: result.ok ? "" : (result.stderr || result.stdout || result.error).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkMacWireguardPersistence(target, options = {}) {
|
||||||
|
const attempts = Math.max(1, Number(options.attempts || CONNECT_PERSISTENCE_ATTEMPTS));
|
||||||
|
const delayMs = Math.max(0, Number(options.delayMs || CONNECT_PERSISTENCE_DELAY_MS));
|
||||||
|
const getHelperStatus = options.getHelperStatus || (async () => ({ active: false, interfaceName: MAC_WG_INTERFACE }));
|
||||||
|
const verifyConnectionFn = options.verifyConnection || verifyConnection;
|
||||||
|
const sleepFn = options.sleep || sleep;
|
||||||
|
let lastHelperStatus = { active: false, interfaceName: MAC_WG_INTERFACE };
|
||||||
|
let lastVerified = { ok: false, ipInfo: null };
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
lastHelperStatus = await getHelperStatus();
|
||||||
|
} catch (error) {
|
||||||
|
lastHelperStatus = {
|
||||||
|
active: false,
|
||||||
|
interfaceName: MAC_WG_INTERFACE,
|
||||||
|
error: error.message || String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
lastVerified = await verifyConnectionFn(target);
|
||||||
|
} catch (error) {
|
||||||
|
lastVerified = {
|
||||||
|
ok: false,
|
||||||
|
ipInfo: {
|
||||||
|
ok: false,
|
||||||
|
error: error.message || String(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastHelperStatus.active && lastVerified.ok) {
|
||||||
|
return {
|
||||||
|
stable: true,
|
||||||
|
attempts: attempt,
|
||||||
|
helperStatus: lastHelperStatus,
|
||||||
|
verified: lastVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < attempts) {
|
||||||
|
await sleepFn(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stable: false,
|
||||||
|
attempts,
|
||||||
|
helperStatus: lastHelperStatus,
|
||||||
|
verified: lastVerified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFinalizeMacWireguardConnect(connectResult, verified, persistence) {
|
||||||
|
return Boolean(
|
||||||
|
connectResult &&
|
||||||
|
connectResult.backend === "wireguard" &&
|
||||||
|
verified &&
|
||||||
|
verified.ok &&
|
||||||
|
persistence &&
|
||||||
|
persistence.stable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSuccessfulConnectState(state, connectResult, verified) {
|
function normalizeSuccessfulConnectState(state, connectResult, verified) {
|
||||||
if (
|
if (
|
||||||
!state ||
|
!state ||
|
||||||
@@ -539,15 +1079,21 @@ async function probeMacWireguard() {
|
|||||||
const wgQuickPath = commandExists("wg-quick");
|
const wgQuickPath = commandExists("wg-quick");
|
||||||
const wireguardGoPath = commandExists("wireguard-go");
|
const wireguardGoPath = commandExists("wireguard-go");
|
||||||
const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
|
const helperPath = fileExists(MAC_WG_HELPER_PATH) ? MAC_WG_HELPER_PATH : null;
|
||||||
|
const helperSecurity = inspectMacWireguardHelperSecurity(helperPath);
|
||||||
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
const sudoProbe = helperPath ? await runExec("sudo", ["-n", helperPath, "probe"]) : { ok: false };
|
||||||
|
const helperStatus = helperPath && sudoProbe.ok ? parseMacWireguardHelperStatus(sudoProbe.stdout || sudoProbe.stderr || "") : null;
|
||||||
let active = false;
|
let active = false;
|
||||||
let showRaw = "";
|
let showRaw = "";
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
let ifconfigRaw = "";
|
let ifconfigRaw = "";
|
||||||
|
|
||||||
|
if (helperStatus) {
|
||||||
|
active = helperStatus.active;
|
||||||
|
}
|
||||||
|
|
||||||
if (wgPath) {
|
if (wgPath) {
|
||||||
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
const show = await runExec(wgPath, ["show", MAC_WG_INTERFACE]);
|
||||||
active = show.ok;
|
active = active || show.ok;
|
||||||
showRaw = (show.stdout || show.stderr).trim();
|
showRaw = (show.stdout || show.stderr).trim();
|
||||||
if (show.ok) {
|
if (show.ok) {
|
||||||
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
const endpointResult = await runExec(wgPath, ["show", MAC_WG_INTERFACE, "endpoints"]);
|
||||||
@@ -571,10 +1117,11 @@ async function probeMacWireguard() {
|
|||||||
wgQuickPath: wgQuickPath || null,
|
wgQuickPath: wgQuickPath || null,
|
||||||
wireguardGoPath: wireguardGoPath || null,
|
wireguardGoPath: wireguardGoPath || null,
|
||||||
helperPath,
|
helperPath,
|
||||||
|
helperSecurity,
|
||||||
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
dependenciesReady: Boolean(wgPath && wgQuickPath && wireguardGoPath),
|
||||||
sudoReady: sudoProbe.ok,
|
sudoReady: sudoProbe.ok,
|
||||||
interfaceName: MAC_WG_INTERFACE,
|
interfaceName: helperStatus && helperStatus.interfaceName ? helperStatus.interfaceName : MAC_WG_INTERFACE,
|
||||||
configPath: fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null,
|
configPath: (helperStatus && helperStatus.configPath) || (fileExists(WG_CONFIG_PATH) ? WG_CONFIG_PATH : null),
|
||||||
active,
|
active,
|
||||||
endpoint: endpoint || null,
|
endpoint: endpoint || null,
|
||||||
showRaw,
|
showRaw,
|
||||||
@@ -716,6 +1263,7 @@ function buildStateSummary(installProbe, ipInfo) {
|
|||||||
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
wgQuickPath: installProbe.wireguard.wgQuickPath,
|
||||||
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
wireguardGoPath: installProbe.wireguard.wireguardGoPath,
|
||||||
helperPath: installProbe.wireguard.helperPath,
|
helperPath: installProbe.wireguard.helperPath,
|
||||||
|
helperSecurity: installProbe.wireguard.helperSecurity,
|
||||||
authCache: installProbe.wireguard.authCache,
|
authCache: installProbe.wireguard.authCache,
|
||||||
lastConnection: installProbe.wireguard.lastConnection,
|
lastConnection: installProbe.wireguard.lastConnection,
|
||||||
}
|
}
|
||||||
@@ -996,7 +1544,6 @@ function buildWireguardConfig(server, privateKey) {
|
|||||||
"[Interface]",
|
"[Interface]",
|
||||||
`PrivateKey = ${privateKey}`,
|
`PrivateKey = ${privateKey}`,
|
||||||
`Address = ${addresses.join(", ")}`,
|
`Address = ${addresses.join(", ")}`,
|
||||||
`DNS = ${NORDVPN_MAC_DNS_SERVERS.join(", ")}`,
|
|
||||||
"",
|
"",
|
||||||
"[Peer]",
|
"[Peer]",
|
||||||
`PublicKey = ${publicKey}`,
|
`PublicKey = ${publicKey}`,
|
||||||
@@ -1123,10 +1670,7 @@ async function connectViaMacWireguard(installProbe, target) {
|
|||||||
|
|
||||||
const down = await runSudoWireguard(installProbe, "down");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
const message = `${down.stderr || down.stdout || down.error}`.toLowerCase();
|
// Ignore the common no-active-interface case before reconnecting.
|
||||||
if (!message.includes("is not a known interface") && !message.includes("unable to access interface") && !message.includes("not found")) {
|
|
||||||
// Ignore only the common no-active-interface case.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const up = await runSudoWireguard(installProbe, "up");
|
const up = await runSudoWireguard(installProbe, "up");
|
||||||
@@ -1137,23 +1681,6 @@ async function connectViaMacWireguard(installProbe, target) {
|
|||||||
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
|
throw new Error((up.stderr || up.stdout || up.error).trim() || "wg-quick up failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJsonFile(LAST_CONNECTION_PATH, {
|
|
||||||
backend: "wireguard",
|
|
||||||
interfaceName: MAC_WG_INTERFACE,
|
|
||||||
requestedTarget: target,
|
|
||||||
resolvedTarget: {
|
|
||||||
country: targetMeta.country.name,
|
|
||||||
city: targetMeta.city ? targetMeta.city.name : getServerCityName(selectedServer),
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
hostname: selectedServer.hostname,
|
|
||||||
city: getServerCityName(selectedServer),
|
|
||||||
country: getServerCountryName(selectedServer),
|
|
||||||
load: selectedServer.load,
|
|
||||||
},
|
|
||||||
connectedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
server: {
|
server: {
|
||||||
@@ -1193,10 +1720,12 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
|
|
||||||
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
if (installProbe.platform === "darwin" && installProbe.wireguard && installProbe.wireguard.dependenciesReady) {
|
||||||
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
|
if (!shouldAttemptMacWireguardDisconnect(installProbe.wireguard)) {
|
||||||
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: false,
|
changed: false,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "No active macOS WireGuard NordVPN connection found.",
|
message: "No active macOS WireGuard NordVPN connection found.",
|
||||||
};
|
};
|
||||||
@@ -1204,30 +1733,29 @@ async function disconnectNordvpn(installProbe) {
|
|||||||
const down = await runSudoWireguard(installProbe, "down");
|
const down = await runSudoWireguard(installProbe, "down");
|
||||||
if (!down.ok) {
|
if (!down.ok) {
|
||||||
const message = (down.stderr || down.stdout || down.error).trim();
|
const message = (down.stderr || down.stdout || down.error).trim();
|
||||||
const normalized = message.toLowerCase();
|
if (isBenignMacWireguardAbsentError(message)) {
|
||||||
if (
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
normalized.includes("is not a known interface") ||
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
normalized.includes("unable to access interface") ||
|
|
||||||
normalized.includes("not found")
|
|
||||||
) {
|
|
||||||
const cleaned = cleanupMacWireguardState();
|
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: false,
|
changed: false,
|
||||||
stateCleaned: cleaned.cleaned,
|
stateCleaned: cleaned.cleaned,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "No active macOS WireGuard NordVPN connection found.",
|
message: "No active macOS WireGuard NordVPN connection found.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(message || "wg-quick down failed");
|
throw new Error(message || "wg-quick down failed");
|
||||||
}
|
}
|
||||||
const cleaned = cleanupMacWireguardState();
|
const dnsState = await restoreMacDnsIfNeeded();
|
||||||
|
const cleaned = cleanupMacWireguardAndDnsState();
|
||||||
const tailscale = await resumeMacTailscaleIfNeeded();
|
const tailscale = await resumeMacTailscaleIfNeeded();
|
||||||
return {
|
return {
|
||||||
backend: "wireguard",
|
backend: "wireguard",
|
||||||
changed: true,
|
changed: true,
|
||||||
stateCleaned: cleaned.cleaned,
|
stateCleaned: cleaned.cleaned,
|
||||||
|
dnsRestored: dnsState.restored,
|
||||||
tailscaleRestored: tailscale.restored,
|
tailscaleRestored: tailscale.restored,
|
||||||
message: "Disconnected the macOS NordLynx/WireGuard session.",
|
message: "Disconnected the macOS NordLynx/WireGuard session.",
|
||||||
};
|
};
|
||||||
@@ -1367,8 +1895,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "connect") {
|
if (action === "connect") {
|
||||||
|
const lock = acquireOperationLock(action);
|
||||||
|
try {
|
||||||
const target = buildConnectTarget(args);
|
const target = buildConnectTarget(args);
|
||||||
let connectResult;
|
let connectResult;
|
||||||
|
let verified;
|
||||||
|
let persistence = null;
|
||||||
|
|
||||||
if (installProbe.cliPath) {
|
if (installProbe.cliPath) {
|
||||||
connectResult = await connectViaCli(installProbe.cliPath, target);
|
connectResult = await connectViaCli(installProbe.cliPath, target);
|
||||||
@@ -1387,17 +1919,175 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connectResult.manualActionRequired) {
|
if (connectResult.manualActionRequired) {
|
||||||
|
lock.release();
|
||||||
emitJson({ action, requestedTarget: target, ...connectResult });
|
emitJson({ action, requestedTarget: target, ...connectResult });
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = await verifyConnectionWithRetry(target, { attempts: 6, delayMs: 2000 });
|
if (connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
|
||||||
|
persistence = await checkMacWireguardPersistence(target, {
|
||||||
|
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
|
||||||
|
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
|
||||||
|
getHelperStatus: async () => getMacWireguardHelperStatus(installProbe),
|
||||||
|
verifyConnection: verifyConnection,
|
||||||
|
});
|
||||||
|
verified = persistence.verified;
|
||||||
|
|
||||||
|
if (!persistence.stable) {
|
||||||
const refreshed = await probeInstallation(platform);
|
const refreshed = await probeInstallation(platform);
|
||||||
const connectState = normalizeSuccessfulConnectState(buildStateSummary(refreshed, verified.ipInfo), connectResult, verified);
|
const diagnostics = await collectMacWireguardDiagnostics({
|
||||||
|
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
|
||||||
|
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
|
||||||
|
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
|
||||||
|
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
||||||
|
});
|
||||||
|
const rollback = await disconnectNordvpn(refreshed);
|
||||||
|
lock.release();
|
||||||
emitJson(
|
emitJson(
|
||||||
{
|
{
|
||||||
action,
|
action,
|
||||||
requestedTarget: target,
|
requestedTarget: target,
|
||||||
connectResult,
|
connectResult,
|
||||||
|
persistence,
|
||||||
|
verified: false,
|
||||||
|
verification: verified && verified.ipInfo ? verified.ipInfo : null,
|
||||||
|
diagnostics: debugOutput ? diagnostics : undefined,
|
||||||
|
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
|
||||||
|
rollback,
|
||||||
|
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verified = await verifyConnection(target);
|
||||||
|
} else {
|
||||||
|
verified = await verifyConnectionWithRetry(target, {
|
||||||
|
attempts: CONNECT_PERSISTENCE_ATTEMPTS,
|
||||||
|
delayMs: CONNECT_PERSISTENCE_DELAY_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await probeInstallation(platform);
|
||||||
|
if (!verified.ok && connectResult && connectResult.backend === "wireguard" && platform === "darwin") {
|
||||||
|
const diagnostics = await collectMacWireguardDiagnostics({
|
||||||
|
interfaceName: connectResult.interfaceName || MAC_WG_INTERFACE,
|
||||||
|
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
|
||||||
|
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
|
||||||
|
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
||||||
|
});
|
||||||
|
const rollback = await disconnectNordvpn(refreshed);
|
||||||
|
lock.release();
|
||||||
|
emitJson(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
requestedTarget: target,
|
||||||
|
connectResult,
|
||||||
|
persistence,
|
||||||
|
verified: false,
|
||||||
|
verification: verified.ipInfo,
|
||||||
|
diagnostics: debugOutput ? diagnostics : undefined,
|
||||||
|
diagnosticsSummary: summarizeMacWireguardDiagnostics(diagnostics),
|
||||||
|
rollback,
|
||||||
|
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === "darwin" && shouldFinalizeMacWireguardConnect(connectResult, verified, persistence)) {
|
||||||
|
try {
|
||||||
|
await snapshotMacDnsState();
|
||||||
|
const liveness = await verifyConnection(target);
|
||||||
|
if (!liveness.ok) {
|
||||||
|
const rollback = await disconnectNordvpn(await probeInstallation(platform));
|
||||||
|
lock.release();
|
||||||
|
emitJson(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
requestedTarget: target,
|
||||||
|
connectResult,
|
||||||
|
persistence,
|
||||||
|
verified: false,
|
||||||
|
verification: liveness.ipInfo,
|
||||||
|
rollback,
|
||||||
|
error: "Connected but failed the final liveness check before DNS finalization.",
|
||||||
|
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
verified = liveness;
|
||||||
|
await applyMacNordDns();
|
||||||
|
const dnsResolution = await verifySystemHostnameResolution();
|
||||||
|
if (!dnsResolution.ok) {
|
||||||
|
const rollback = await disconnectNordvpn(await probeInstallation(platform));
|
||||||
|
lock.release();
|
||||||
|
emitJson(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
requestedTarget: target,
|
||||||
|
connectResult,
|
||||||
|
persistence,
|
||||||
|
verified: false,
|
||||||
|
verification: verified.ipInfo,
|
||||||
|
dnsResolution,
|
||||||
|
rollback,
|
||||||
|
error: `Connected but system DNS resolution failed after DNS finalization: ${dnsResolution.error}`,
|
||||||
|
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeJsonFile(LAST_CONNECTION_PATH, {
|
||||||
|
backend: "wireguard",
|
||||||
|
interfaceName: MAC_WG_INTERFACE,
|
||||||
|
requestedTarget: connectResult.requestedTarget,
|
||||||
|
resolvedTarget: connectResult.resolvedTarget,
|
||||||
|
server: {
|
||||||
|
hostname: connectResult.server.hostname,
|
||||||
|
city: connectResult.server.city,
|
||||||
|
country: connectResult.server.country,
|
||||||
|
load: connectResult.server.load,
|
||||||
|
},
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const rollback = await disconnectNordvpn(await probeInstallation(platform));
|
||||||
|
lock.release();
|
||||||
|
emitJson(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
requestedTarget: target,
|
||||||
|
connectResult,
|
||||||
|
persistence,
|
||||||
|
verified: true,
|
||||||
|
verification: verified.ipInfo,
|
||||||
|
rollback,
|
||||||
|
error: `Connected but failed to finalize macOS DNS/state: ${error.message || String(error)}`,
|
||||||
|
state: buildStateSummary(await probeInstallation(platform), await getPublicIpInfo()),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectState = normalizeSuccessfulConnectState(
|
||||||
|
buildStateSummary(await probeInstallation(platform), verified.ipInfo),
|
||||||
|
connectResult,
|
||||||
|
verified
|
||||||
|
);
|
||||||
|
lock.release();
|
||||||
|
emitJson(
|
||||||
|
{
|
||||||
|
action,
|
||||||
|
requestedTarget: target,
|
||||||
|
connectResult,
|
||||||
|
persistence,
|
||||||
verified: verified.ok,
|
verified: verified.ok,
|
||||||
verification: verified.ipInfo,
|
verification: verified.ipInfo,
|
||||||
state: connectState,
|
state: connectState,
|
||||||
@@ -1405,21 +2095,47 @@ async function main() {
|
|||||||
verified.ok ? 0 : 1,
|
verified.ok ? 0 : 1,
|
||||||
!verified.ok
|
!verified.ok
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "disconnect") {
|
if (action === "disconnect") {
|
||||||
|
const lock = acquireOperationLock(action);
|
||||||
|
try {
|
||||||
const result = await disconnectNordvpn(installProbe);
|
const result = await disconnectNordvpn(installProbe);
|
||||||
const refreshed = await probeInstallation(platform);
|
const refreshed = await probeInstallation(platform);
|
||||||
|
lock.release();
|
||||||
emitJson({
|
emitJson({
|
||||||
action,
|
action,
|
||||||
...result,
|
...result,
|
||||||
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
state: buildStateSummary(refreshed, await getPublicIpInfo()),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
lock.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
emitJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitJson({ error: error.message || String(error), action }, 1, true);
|
const payload = { error: error.message || String(error), action };
|
||||||
|
if (action === "connect" && platform === "darwin" && installProbe && installProbe.wireguard) {
|
||||||
|
try {
|
||||||
|
const refreshed = await probeInstallation(platform);
|
||||||
|
const diagnostics = await collectMacWireguardDiagnostics({
|
||||||
|
interfaceName: MAC_WG_INTERFACE,
|
||||||
|
wgPath: refreshed.wireguard && refreshed.wireguard.wgPath,
|
||||||
|
helperPath: refreshed.wireguard && refreshed.wireguard.helperPath,
|
||||||
|
helperSecurity: refreshed.wireguard && refreshed.wireguard.helperSecurity,
|
||||||
|
});
|
||||||
|
payload.state = buildStateSummary(refreshed, await getPublicIpInfo());
|
||||||
|
payload.diagnostics = debugOutput ? diagnostics : undefined;
|
||||||
|
payload.diagnosticsSummary = summarizeMacWireguardDiagnostics(diagnostics);
|
||||||
|
} catch {
|
||||||
|
// Fall back to the base error payload if diagnostic capture also fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitJson(payload, 1, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,28 +11,58 @@ function loadInternals() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildMacTailscaleState:
|
buildMacTailscaleState:
|
||||||
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
||||||
|
markMacTailscaleRecoverySuppressed:
|
||||||
|
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
|
||||||
|
clearMacTailscaleRecoverySuppressed:
|
||||||
|
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : undefined,
|
||||||
|
buildMacDnsState:
|
||||||
|
typeof buildMacDnsState === "function" ? buildMacDnsState : undefined,
|
||||||
buildWireguardConfig:
|
buildWireguardConfig:
|
||||||
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
||||||
buildLookupResult:
|
buildLookupResult:
|
||||||
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
||||||
cleanupMacWireguardState:
|
cleanupMacWireguardState:
|
||||||
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
||||||
|
cleanupMacWireguardAndDnsState:
|
||||||
|
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
|
||||||
|
collectMacWireguardDiagnostics:
|
||||||
|
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
|
||||||
|
acquireOperationLock:
|
||||||
|
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
|
||||||
|
inspectMacWireguardHelperSecurity:
|
||||||
|
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
|
||||||
getMacTailscalePath:
|
getMacTailscalePath:
|
||||||
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
||||||
|
isBenignMacWireguardAbsentError:
|
||||||
|
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
||||||
isMacTailscaleActive:
|
isMacTailscaleActive:
|
||||||
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
||||||
|
checkMacWireguardPersistence:
|
||||||
|
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
|
||||||
normalizeSuccessfulConnectState:
|
normalizeSuccessfulConnectState:
|
||||||
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
||||||
normalizeStatusState:
|
normalizeStatusState:
|
||||||
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
||||||
|
parseMacWireguardHelperStatus:
|
||||||
|
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
|
||||||
|
shouldRejectMacDnsBaseline:
|
||||||
|
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
|
||||||
|
shouldManageMacDnsService:
|
||||||
|
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
||||||
sanitizeOutputPayload:
|
sanitizeOutputPayload:
|
||||||
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
||||||
|
shouldFinalizeMacWireguardConnect:
|
||||||
|
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
|
||||||
|
shouldResumeMacTailscale:
|
||||||
|
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
|
||||||
shouldAttemptMacWireguardDisconnect:
|
shouldAttemptMacWireguardDisconnect:
|
||||||
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
||||||
detectMacWireguardActiveFromIfconfig:
|
detectMacWireguardActiveFromIfconfig:
|
||||||
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
||||||
resolveHostnameWithFallback:
|
resolveHostnameWithFallback:
|
||||||
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
||||||
|
verifySystemHostnameResolution:
|
||||||
|
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
|
||||||
verifyConnectionWithRetry:
|
verifyConnectionWithRetry:
|
||||||
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
|
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
|
||||||
};`;
|
};`;
|
||||||
@@ -78,7 +108,7 @@ test("buildLookupResult supports lookup all=true mode", () => {
|
|||||||
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
|
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => {
|
test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => {
|
||||||
const { buildWireguardConfig } = loadInternals();
|
const { buildWireguardConfig } = loadInternals();
|
||||||
assert.equal(typeof buildWireguardConfig, "function");
|
assert.equal(typeof buildWireguardConfig, "function");
|
||||||
|
|
||||||
@@ -91,10 +121,71 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat
|
|||||||
"PRIVATEKEY"
|
"PRIVATEKEY"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true);
|
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false);
|
||||||
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => {
|
||||||
|
const { shouldManageMacDnsService } = loadInternals();
|
||||||
|
assert.equal(typeof shouldManageMacDnsService, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldManageMacDnsService("Wi-Fi"), true);
|
||||||
|
assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true);
|
||||||
|
assert.equal(shouldManageMacDnsService("Tailscale"), false);
|
||||||
|
assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false);
|
||||||
|
assert.equal(shouldManageMacDnsService("Acme VPN"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildMacDnsState records DNS and search domains per service", () => {
|
||||||
|
const { buildMacDnsState } = loadInternals();
|
||||||
|
assert.equal(typeof buildMacDnsState, "function");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(
|
||||||
|
buildMacDnsState([
|
||||||
|
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||||
|
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
JSON.stringify({
|
||||||
|
services: [
|
||||||
|
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||||
|
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
|
||||||
|
const { shouldRejectMacDnsBaseline } = loadInternals();
|
||||||
|
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
shouldRejectMacDnsBaseline({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "Wi-Fi",
|
||||||
|
dnsServers: ["103.86.96.100", "103.86.99.100"],
|
||||||
|
searchDomains: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldRejectMacDnsBaseline({
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
name: "Wi-Fi",
|
||||||
|
dnsServers: ["1.1.1.1"],
|
||||||
|
searchDomains: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
||||||
const { getMacTailscalePath } = loadInternals();
|
const { getMacTailscalePath } = loadInternals();
|
||||||
assert.equal(typeof getMacTailscalePath, "function");
|
assert.equal(typeof getMacTailscalePath, "function");
|
||||||
@@ -118,6 +209,29 @@ test("buildMacTailscaleState records whether tailscale was active", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("tailscale recovery suppression marker can be created and cleared", () => {
|
||||||
|
const { markMacTailscaleRecoverySuppressed, clearMacTailscaleRecoverySuppressed } = loadInternals();
|
||||||
|
assert.equal(typeof markMacTailscaleRecoverySuppressed, "function");
|
||||||
|
assert.equal(typeof clearMacTailscaleRecoverySuppressed, "function");
|
||||||
|
|
||||||
|
const markerPath = path.join(process.env.HOME || "", ".nordvpn-client", "tailscale-suppressed");
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
|
markMacTailscaleRecoverySuppressed();
|
||||||
|
assert.equal(fs.existsSync(markerPath), true);
|
||||||
|
clearMacTailscaleRecoverySuppressed();
|
||||||
|
assert.equal(fs.existsSync(markerPath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldResumeMacTailscale only resumes when previously active and not already running", () => {
|
||||||
|
const { shouldResumeMacTailscale } = loadInternals();
|
||||||
|
assert.equal(typeof shouldResumeMacTailscale, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true);
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false);
|
||||||
|
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false);
|
||||||
|
assert.equal(shouldResumeMacTailscale(null, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
||||||
const { cleanupMacWireguardState } = loadInternals();
|
const { cleanupMacWireguardState } = loadInternals();
|
||||||
assert.equal(typeof cleanupMacWireguardState, "function");
|
assert.equal(typeof cleanupMacWireguardState, "function");
|
||||||
@@ -138,6 +252,190 @@ test("cleanupMacWireguardState removes stale config and last-connection files",
|
|||||||
assert.equal(fs.existsSync(lastConnectionPath), false);
|
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => {
|
||||||
|
const { cleanupMacWireguardAndDnsState } = loadInternals();
|
||||||
|
assert.equal(typeof cleanupMacWireguardAndDnsState, "function");
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
|
||||||
|
const configPath = path.join(tmpDir, "nordvpnctl.conf");
|
||||||
|
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
|
||||||
|
const dnsStatePath = path.join(tmpDir, "dns.json");
|
||||||
|
fs.writeFileSync(configPath, "wireguard-config");
|
||||||
|
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
|
||||||
|
fs.writeFileSync(dnsStatePath, "{\"services\":[]}");
|
||||||
|
|
||||||
|
const result = cleanupMacWireguardAndDnsState({
|
||||||
|
configPath,
|
||||||
|
lastConnectionPath,
|
||||||
|
dnsStatePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.cleaned, true);
|
||||||
|
assert.equal(fs.existsSync(configPath), false);
|
||||||
|
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||||
|
assert.equal(fs.existsSync(dnsStatePath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => {
|
||||||
|
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||||
|
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||||
|
|
||||||
|
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||||
|
fileExists: () => true,
|
||||||
|
statSync: () => ({
|
||||||
|
uid: 501,
|
||||||
|
gid: 20,
|
||||||
|
mode: 0o100755,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.exists, true);
|
||||||
|
assert.equal(result.hardened, false);
|
||||||
|
assert.match(result.reason, /root-owned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => {
|
||||||
|
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||||
|
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||||
|
|
||||||
|
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||||
|
fileExists: () => true,
|
||||||
|
statSync: () => ({
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
mode: 0o100755,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.exists, true);
|
||||||
|
assert.equal(result.hardened, true);
|
||||||
|
assert.equal(result.reason, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => {
|
||||||
|
const { collectMacWireguardDiagnostics } = loadInternals();
|
||||||
|
assert.equal(typeof collectMacWireguardDiagnostics, "function");
|
||||||
|
|
||||||
|
const seen = [];
|
||||||
|
const result = await collectMacWireguardDiagnostics({
|
||||||
|
interfaceName: "nordvpnctl",
|
||||||
|
runExec: async (command, args) => {
|
||||||
|
seen.push(`${command} ${args.join(" ")}`);
|
||||||
|
if (command === "/opt/homebrew/bin/wg") {
|
||||||
|
return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "ifconfig") {
|
||||||
|
return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "route") {
|
||||||
|
return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
if (command === "pgrep") {
|
||||||
|
return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected command: ${command}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(seen, [
|
||||||
|
"/opt/homebrew/bin/wg show nordvpnctl",
|
||||||
|
"ifconfig nordvpnctl",
|
||||||
|
"route -n get default",
|
||||||
|
"pgrep -fl wireguard-go|wg-quick|nordvpnctl",
|
||||||
|
]);
|
||||||
|
assert.equal(result.interfaceName, "nordvpnctl");
|
||||||
|
assert.equal(result.wgShow.includes("peer: abc123"), true);
|
||||||
|
assert.equal(result.ifconfig.includes("10.5.0.2"), true);
|
||||||
|
assert.equal(result.routes.includes("default 10.5.0.2"), true);
|
||||||
|
assert.equal(result.processes.includes("wireguard-go"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseMacWireguardHelperStatus reads active helper key-value output", () => {
|
||||||
|
const { parseMacWireguardHelperStatus } = loadInternals();
|
||||||
|
assert.equal(typeof parseMacWireguardHelperStatus, "function");
|
||||||
|
|
||||||
|
const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n");
|
||||||
|
|
||||||
|
assert.equal(result.active, true);
|
||||||
|
assert.equal(result.interfaceName, "nordvpnctl");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => {
|
||||||
|
const { checkMacWireguardPersistence } = loadInternals();
|
||||||
|
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||||
|
|
||||||
|
const helperStatuses = [
|
||||||
|
{ active: false, interfaceName: "nordvpnctl" },
|
||||||
|
{ active: true, interfaceName: "nordvpnctl" },
|
||||||
|
];
|
||||||
|
const verifications = [
|
||||||
|
{ ok: false, ipInfo: { ok: false, error: "timeout" } },
|
||||||
|
{ ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await checkMacWireguardPersistence(
|
||||||
|
{ country: "Germany", city: "" },
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
delayMs: 1,
|
||||||
|
getHelperStatus: async () => helperStatuses.shift(),
|
||||||
|
verifyConnection: async () => verifications.shift(),
|
||||||
|
sleep: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.stable, true);
|
||||||
|
assert.equal(result.attempts, 2);
|
||||||
|
assert.equal(result.helperStatus.active, true);
|
||||||
|
assert.equal(result.verified.ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => {
|
||||||
|
const { checkMacWireguardPersistence } = loadInternals();
|
||||||
|
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||||
|
|
||||||
|
const result = await checkMacWireguardPersistence(
|
||||||
|
{ country: "Germany", city: "" },
|
||||||
|
{
|
||||||
|
attempts: 2,
|
||||||
|
delayMs: 1,
|
||||||
|
getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }),
|
||||||
|
verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }),
|
||||||
|
sleep: async () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.stable, false);
|
||||||
|
assert.equal(result.attempts, 2);
|
||||||
|
assert.equal(result.helperStatus.active, false);
|
||||||
|
assert.equal(result.verified.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => {
|
||||||
|
const { acquireOperationLock } = loadInternals();
|
||||||
|
assert.equal(typeof acquireOperationLock, "function");
|
||||||
|
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-"));
|
||||||
|
const lockPath = path.join(tmpDir, "operation.lock");
|
||||||
|
fs.writeFileSync(
|
||||||
|
lockPath,
|
||||||
|
JSON.stringify({
|
||||||
|
action: "connect",
|
||||||
|
pid: 0,
|
||||||
|
startedAt: new Date(0).toISOString(),
|
||||||
|
startedAtMs: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const lock = acquireOperationLock("disconnect", { lockPath });
|
||||||
|
const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||||
|
|
||||||
|
assert.equal(lockFile.action, "disconnect");
|
||||||
|
assert.equal(lockFile.pid, process.pid);
|
||||||
|
lock.release();
|
||||||
|
assert.equal(fs.existsSync(lockPath), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
||||||
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
||||||
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
||||||
@@ -173,6 +471,15 @@ test("shouldAttemptMacWireguardDisconnect does not trust active=false when resid
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => {
|
||||||
|
const { isBenignMacWireguardAbsentError } = loadInternals();
|
||||||
|
assert.equal(typeof isBenignMacWireguardAbsentError, "function");
|
||||||
|
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true);
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true);
|
||||||
|
assert.equal(isBenignMacWireguardAbsentError("permission denied"), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
||||||
const { normalizeSuccessfulConnectState } = loadInternals();
|
const { normalizeSuccessfulConnectState } = loadInternals();
|
||||||
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
||||||
@@ -206,6 +513,17 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve
|
|||||||
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
|
||||||
|
const { shouldFinalizeMacWireguardConnect } = loadInternals();
|
||||||
|
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
|
||||||
|
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false);
|
||||||
|
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
|
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
|
||||||
const { normalizeStatusState } = loadInternals();
|
const { normalizeStatusState } = loadInternals();
|
||||||
assert.equal(typeof normalizeStatusState, "function");
|
assert.equal(typeof normalizeStatusState, "function");
|
||||||
@@ -243,6 +561,10 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
|
|||||||
wireguard: {
|
wireguard: {
|
||||||
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
|
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
|
||||||
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
|
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
|
||||||
|
helperSecurity: {
|
||||||
|
hardened: false,
|
||||||
|
reason: "Helper must be root-owned before privileged actions are trusted.",
|
||||||
|
},
|
||||||
authCache: {
|
authCache: {
|
||||||
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
|
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
|
||||||
},
|
},
|
||||||
@@ -254,6 +576,7 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
|
|||||||
assert.equal(sanitized.appPath, null);
|
assert.equal(sanitized.appPath, null);
|
||||||
assert.equal(sanitized.wireguard.configPath, null);
|
assert.equal(sanitized.wireguard.configPath, null);
|
||||||
assert.equal(sanitized.wireguard.helperPath, null);
|
assert.equal(sanitized.wireguard.helperPath, null);
|
||||||
|
assert.equal(sanitized.wireguard.helperSecurity, null);
|
||||||
assert.equal(sanitized.wireguard.authCache.tokenSource, null);
|
assert.equal(sanitized.wireguard.authCache.tokenSource, null);
|
||||||
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
|
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
|
||||||
});
|
});
|
||||||
@@ -307,3 +630,42 @@ test("resolveHostnameWithFallback uses fallback resolvers when system lookup fai
|
|||||||
assert.equal(address, "104.26.9.44");
|
assert.equal(address, "104.26.9.44");
|
||||||
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
|
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("verifySystemHostnameResolution succeeds when any system lookup resolves", async () => {
|
||||||
|
const { verifySystemHostnameResolution } = loadInternals();
|
||||||
|
assert.equal(typeof verifySystemHostnameResolution, "function");
|
||||||
|
|
||||||
|
const calls = [];
|
||||||
|
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
|
||||||
|
timeoutMs: 5,
|
||||||
|
lookup: async (hostname) => {
|
||||||
|
calls.push(hostname);
|
||||||
|
if (hostname === "www.google.com") {
|
||||||
|
throw new Error("ENOTFOUND");
|
||||||
|
}
|
||||||
|
return { address: "104.18.33.45", family: 4 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.hostname, "api.openai.com");
|
||||||
|
assert.equal(result.address, "104.18.33.45");
|
||||||
|
assert.deepEqual(calls, ["www.google.com", "api.openai.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifySystemHostnameResolution fails when all hostnames fail system lookup", async () => {
|
||||||
|
const { verifySystemHostnameResolution } = loadInternals();
|
||||||
|
assert.equal(typeof verifySystemHostnameResolution, "function");
|
||||||
|
|
||||||
|
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
|
||||||
|
timeoutMs: 5,
|
||||||
|
lookup: async (hostname) => {
|
||||||
|
throw new Error(`${hostname}: timeout`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.hostname, "");
|
||||||
|
assert.match(result.error, /www\.google\.com/);
|
||||||
|
assert.match(result.error, /api\.openai\.com/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,21 +3,53 @@ set -eu
|
|||||||
|
|
||||||
ACTION="${1:-}"
|
ACTION="${1:-}"
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
probe|up|down)
|
probe|up|down|status|cleanup)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down]" >&2
|
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2
|
||||||
exit 2
|
exit 2
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
||||||
|
WG="/opt/homebrew/bin/wg"
|
||||||
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
||||||
|
WG_INTERFACE="nordvpnctl"
|
||||||
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||||
export PATH
|
export PATH
|
||||||
|
|
||||||
if [ "$ACTION" = "probe" ]; then
|
if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
|
||||||
test -x "$WG_QUICK"
|
test -x "$WG_QUICK"
|
||||||
|
ACTIVE=0
|
||||||
|
RUNTIME_INTERFACE=""
|
||||||
|
if [ -x "$WG" ]; then
|
||||||
|
RUNTIME_INTERFACE=$("$WG" show interfaces 2>/dev/null | awk 'NF { print $1; exit }')
|
||||||
|
fi
|
||||||
|
if [ -n "$RUNTIME_INTERFACE" ]; then
|
||||||
|
ACTIVE=1
|
||||||
|
elif [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
elif pgrep -f "wg-quick up $WG_CONFIG" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
elif pgrep -f "wireguard-go utun" >/dev/null 2>&1; then
|
||||||
|
ACTIVE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "active=$ACTIVE"
|
||||||
|
echo "interfaceName=$WG_INTERFACE"
|
||||||
|
if [ -n "$RUNTIME_INTERFACE" ]; then
|
||||||
|
echo "wireguardInterface=$RUNTIME_INTERFACE"
|
||||||
|
fi
|
||||||
|
if [ -f "$WG_CONFIG" ]; then
|
||||||
|
echo "configPath=$WG_CONFIG"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" = "cleanup" ]; then
|
||||||
|
"$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user