heyspex 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/hs.mjs +349 -38
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +40 -3
- package/dist/cli/hs-defer.mjs +3817 -3741
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +228 -37
- package/dist/cli/hs-index-reconcile.mjs +231 -40
- package/dist/cli/hs-init.mjs +22 -3
- package/dist/cli/hs-install.mjs +22 -3
- package/dist/cli/hs-join.mjs +40 -3
- package/dist/cli/hs-next-launch.mjs +2777 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12403 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +233 -42
- package/dist/cli/hs-vault-push.mjs +231 -40
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +9 -2
- package/docs/mcp-client-config.md +6 -0
- package/package.json +266 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +41 -3
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +53 -3
- package/scripts/lib/hs-companion-settings.mjs +26 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/scripts/record-grounding.mjs +38 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
- package/bin/postinstall.mjs +0 -88
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* firecrawl-cli.mjs — CLI wrapper around firecrawl-adapter.mjs so a Claude subagent (Bash tool
|
|
4
|
+
* only, no JS imports) can use self-hosted Firecrawl for search/scrape.
|
|
5
|
+
*
|
|
6
|
+
* THE GAP THIS CLOSES: `.heyspex/config.yaml` (agent_behaviors.research.default_source) and
|
|
7
|
+
* specs/protocols/research-capture-protocol.md declare self-hosted Firecrawl on Night Action the
|
|
8
|
+
* default search/scrape backend for every rung of the confidence ladder. But the ladder workflows
|
|
9
|
+
* (.claude/workflows/deep-research-{quick,medium,thorough}.js) dispatch Claude *subagents* whose
|
|
10
|
+
* prompts said "use WebSearch" / "use WebFetch" — subagents can't `import` firecrawl-adapter.mjs,
|
|
11
|
+
* so the claimed default was silently bypassed by every real run. This CLI is the bridge: the
|
|
12
|
+
* subagent prompts now say "try this CLI first"; this file does the actual Firecrawl call.
|
|
13
|
+
*
|
|
14
|
+
* SHIPPED IN THE `heyspex` NPM PACKAGE (owner ruling 2026-10-17): in a customer repo
|
|
15
|
+
* `node node_modules/heyspex/dev-factory/spexcrawl/firecrawl-cli.mjs search|scrape …` finds the
|
|
16
|
+
* self-hosted Firecrawl in the user's LOCAL Docker with zero config — see loadPlatform() below and
|
|
17
|
+
* dev-factory/lib/local-firecrawl.mjs. No env var, no API key, never hosted.
|
|
18
|
+
*
|
|
19
|
+
* HARD RULES (see [[firecrawl-routing-self-hosted-night]] — do not relax these):
|
|
20
|
+
* 1. NEVER spend hosted api.firecrawl.dev credits from this CLI. If the endpoint
|
|
21
|
+
* firecrawl-adapter.mjs's getBaseUrl() resolves is the hosted host, REFUSE outright — exit
|
|
22
|
+
* non-zero before any network call. Only the owner decides hosted spend, interactively.
|
|
23
|
+
* 2. Ollama has priority on the Night Action mode lease — Firecrawl work WAITS, never evicts.
|
|
24
|
+
* Before its first call this CLI asks for the lease via requestFirecrawlMode() — the SAME
|
|
25
|
+
* function every other caller uses. That function already refuses to touch anything when
|
|
26
|
+
* Ollama holds the box and only starts the stack over SSH when the box is genuinely idle.
|
|
27
|
+
* This CLI does not duplicate or bypass that logic.
|
|
28
|
+
* 3. On refusal/unreachable, print a single machine-readable line `FIRECRAWL_UNAVAILABLE: <reason>`
|
|
29
|
+
* and exit non-zero, so a calling ladder-workflow prompt ("if it prints FIRECRAWL_UNAVAILABLE
|
|
30
|
+
* or fails, fall back to WebSearch/WebFetch") can react deterministically instead of hanging
|
|
31
|
+
* or silently returning nothing. Every path is caught — never a raw stack trace.
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* node dev-factory/spexcrawl/firecrawl-cli.mjs search "<query>" [--limit N] [--json]
|
|
35
|
+
* node dev-factory/spexcrawl/firecrawl-cli.mjs scrape "<url>" [--json]
|
|
36
|
+
* node dev-factory/spexcrawl/firecrawl-cli.mjs --self-test
|
|
37
|
+
*
|
|
38
|
+
* Exit codes: 0 ok · 1 unexpected error (clean message, no stack) · 2 refused (hosted endpoint,
|
|
39
|
+
* policy violation) · 3 FIRECRAWL_UNAVAILABLE (lease refused / unreachable / network
|
|
40
|
+
* failure — the fallback-to-WebSearch/WebFetch signal) · 64 usage error (bad args).
|
|
41
|
+
*/
|
|
42
|
+
import { readFileSync } from "node:fs";
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
import { OLLAMA } from "../lib/model-catalog.mjs";
|
|
46
|
+
import {
|
|
47
|
+
detectLocalFirecrawl,
|
|
48
|
+
isHostedFirecrawlUrl as isHostedFirecrawl,
|
|
49
|
+
localFirecrawlScrape,
|
|
50
|
+
localFirecrawlSearch,
|
|
51
|
+
LOCAL_FIRECRAWL_SETUP_HINT,
|
|
52
|
+
} from "../lib/local-firecrawl.mjs";
|
|
53
|
+
|
|
54
|
+
// ── two modes: platform checkout vs. the `heyspex` npm package ───────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* This CLI ships in the `heyspex` npm package (owner ruling 2026-10-17: customer research finds
|
|
57
|
+
* URLs through a self-hosted Firecrawl in the user's LOCAL Docker, zero config). The SpexFleet
|
|
58
|
+
* routing it uses in a platform checkout — firecrawl-adapter.mjs (fleet candidates, cost ledger)
|
|
59
|
+
* and night-action-mode.mjs (the Ollama/Firecrawl mode lease) — is NOT in the package, so it is
|
|
60
|
+
* loaded with a dynamic import that a published install answers with "absent":
|
|
61
|
+
*
|
|
62
|
+
* platform both modules load → fleet candidates + lease, then this machine's Docker, then
|
|
63
|
+
* the on-demand/remote autostarts
|
|
64
|
+
* local either is missing → detectLocalFirecrawl() only (optional override env,
|
|
65
|
+
* `docker ps`, localhost:3002), plain HTTP, no API key, never hosted
|
|
66
|
+
*
|
|
67
|
+
* Only a genuinely missing module selects local mode; any other load error is rethrown.
|
|
68
|
+
*/
|
|
69
|
+
let platformPromise = null;
|
|
70
|
+
let loadedPlatform = null;
|
|
71
|
+
export function loadPlatform() {
|
|
72
|
+
if (!platformPromise) {
|
|
73
|
+
platformPromise = Promise.all([import("./firecrawl-adapter.mjs"), import("../lib/night-action-mode.mjs")])
|
|
74
|
+
.then(([adapter, mode]) => (loadedPlatform = { adapter, mode }))
|
|
75
|
+
.catch((e) => {
|
|
76
|
+
if (e?.code === "ERR_MODULE_NOT_FOUND") return null;
|
|
77
|
+
throw e;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return platformPromise;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const MAX_SCRAPE_CHARS = 20000;
|
|
84
|
+
|
|
85
|
+
export const HOSTED_REFUSAL_MESSAGE =
|
|
86
|
+
"REFUSED: resolved Firecrawl endpoint is the hosted api.firecrawl.dev API. Agents must NEVER " +
|
|
87
|
+
"spend hosted credits — only the owner decides, and only interactively (see " +
|
|
88
|
+
"specs/memory/decisions/firecrawl-routing-self-hosted-night.md). This CLI only ever talks to a " +
|
|
89
|
+
"self-hosted Firecrawl endpoint.";
|
|
90
|
+
|
|
91
|
+
// ── on-demand local stack (fleet-down fallback) ────────────────────────────────────────────────
|
|
92
|
+
/**
|
|
93
|
+
* Does THIS machine declare an ON-DEMAND self-hosted Firecrawl? Returns its base URL, or null.
|
|
94
|
+
*
|
|
95
|
+
* On-demand hosts are normally STOPPED ([[workstation-firecrawl-is-the-fleet-down]]) — the owner
|
|
96
|
+
* works on that box and the stack idles ~6 containers. So a probe failing against it is the
|
|
97
|
+
* EXPECTED resting state, not a broken install, and it is worth starting before giving up.
|
|
98
|
+
* Opt-in is explicit: `services.firecrawl.onDemand === true` on the machine named by
|
|
99
|
+
* HEYSPEX_FLEET_MACHINE_KEY. Any other host is left alone.
|
|
100
|
+
*/
|
|
101
|
+
export function localOnDemandFirecrawl(env = process.env) {
|
|
102
|
+
try {
|
|
103
|
+
const key = String(env.HEYSPEX_FLEET_MACHINE_KEY || "").trim();
|
|
104
|
+
if (!key) return null;
|
|
105
|
+
const table = JSON.parse(readFileSync(fileURLToPath(new URL("../routing-table.json", import.meta.url)), "utf8"));
|
|
106
|
+
const svc = table?.machines?.[key]?.services?.firecrawl;
|
|
107
|
+
if (!svc || svc.onDemand !== true) return null;
|
|
108
|
+
const baseUrl = String(svc.localUrl || svc.url || "").trim().replace(/\/+$/, "");
|
|
109
|
+
if (!baseUrl || isHostedFirecrawl(baseUrl)) return null;
|
|
110
|
+
return baseUrl;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Start the local on-demand stack and return its base URL, or null if there is nothing to start
|
|
118
|
+
* or the start failed. NEVER throws — a failure here must degrade to the original
|
|
119
|
+
* FIRECRAWL_UNAVAILABLE, never to a crash.
|
|
120
|
+
*/
|
|
121
|
+
export async function autostartLocalFirecrawl({ env = process.env, spawn = spawnSync } = {}) {
|
|
122
|
+
const baseUrl = localOnDemandFirecrawl(env);
|
|
123
|
+
if (!baseUrl) return null;
|
|
124
|
+
try {
|
|
125
|
+
const script = fileURLToPath(new URL("../scripts/firecrawl-local.sh", import.meta.url));
|
|
126
|
+
const res = spawn("bash", [script, "up"], { timeout: 300000, stdio: "ignore", encoding: "utf8" });
|
|
127
|
+
return res && res.status === 0 ? baseUrl : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── remote start (fleet machines with a declared day-2 SSH script) ────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Fleet machines other than "this one" that declare `services.firecrawl.remoteStart.script` —
|
|
136
|
+
* a repo-relative day-2 script (mirroring firecrawl-local.sh's {status|up|down} contract) that
|
|
137
|
+
* SSHes out and brings the stack up on THAT machine, e.g. dev-factory/scripts/firecrawl-specops.sh.
|
|
138
|
+
*
|
|
139
|
+
* WHY THIS EXISTS (HS-DEC firecrawl-any-machine-docker-autostart, 2026-08-25): before this,
|
|
140
|
+
* `ensureAvailable()`'s only autostart path was `autostartLocalFirecrawl()`, which only ever acts
|
|
141
|
+
* on the machine THIS process is running on (`HEYSPEX_FLEET_MACHINE_KEY`). A remote fleet host
|
|
142
|
+
* with its own working day-2 script (Spec-ops) still read as FIRECRAWL_UNAVAILABLE and required a
|
|
143
|
+
* human to notice the script existed and run it by hand — exactly the gap this closes.
|
|
144
|
+
*
|
|
145
|
+
* Ordered by FLEET_FIRECRAWL_PREFERENCE so Spec-ops (the fleet default, owner ruling 2026-08-25)
|
|
146
|
+
* is tried before Basement Geek. Basement Geek has no verified remote-start script yet (its SSH
|
|
147
|
+
* shell mixed bash/cmd.exe output on a first probe — do not fabricate one untested); it simply
|
|
148
|
+
* has no `remoteStart` entry, so it is skipped here, same as any machine without one.
|
|
149
|
+
*/
|
|
150
|
+
export function remoteStartCandidates(env = process.env, preference = loadedPlatform?.adapter?.FLEET_FIRECRAWL_PREFERENCE ?? []) {
|
|
151
|
+
const out = [];
|
|
152
|
+
try {
|
|
153
|
+
const key = String(env.HEYSPEX_FLEET_MACHINE_KEY || "").trim();
|
|
154
|
+
const table = JSON.parse(readFileSync(fileURLToPath(new URL("../routing-table.json", import.meta.url)), "utf8"));
|
|
155
|
+
const machines = table?.machines || {};
|
|
156
|
+
const seenScripts = new Set();
|
|
157
|
+
const order = [...preference, ...Object.keys(machines).filter((k) => k !== "_comment" && !preference.includes(k))];
|
|
158
|
+
for (const machineKey of order) {
|
|
159
|
+
if (machineKey === key) continue; // this machine uses autostartLocalFirecrawl instead
|
|
160
|
+
const svc = machines[machineKey]?.services?.firecrawl;
|
|
161
|
+
const scriptRel = svc?.remoteStart?.script;
|
|
162
|
+
if (!scriptRel || typeof scriptRel !== "string") continue;
|
|
163
|
+
if (seenScripts.has(scriptRel)) continue; // specops-gpu0/gpu1 share one script/stack
|
|
164
|
+
seenScripts.add(scriptRel);
|
|
165
|
+
const baseUrl = String(svc.url || "").trim().replace(/\/+$/, "");
|
|
166
|
+
if (!baseUrl || isHostedFirecrawl(baseUrl)) continue;
|
|
167
|
+
out.push({ machineKey, scriptPath: scriptRel, baseUrl });
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Try each declared remote-start script in turn (repo-relative, resolved from this file's
|
|
177
|
+
* location); return the first one that starts successfully, or null. NEVER throws — same
|
|
178
|
+
* fail-safe contract as autostartLocalFirecrawl.
|
|
179
|
+
*/
|
|
180
|
+
export async function autostartRemoteFirecrawl({ env = process.env, spawn = spawnSync, candidatesFn = remoteStartCandidates } = {}) {
|
|
181
|
+
const candidates = typeof candidatesFn === "function" ? candidatesFn(env) : [];
|
|
182
|
+
for (const { scriptPath, baseUrl } of candidates) {
|
|
183
|
+
try {
|
|
184
|
+
const script = fileURLToPath(new URL(`../../${scriptPath}`, import.meta.url));
|
|
185
|
+
const res = spawn("bash", [script, "up"], { timeout: 300000, stdio: "ignore", encoding: "utf8" });
|
|
186
|
+
if (res && res.status === 0) return baseUrl;
|
|
187
|
+
} catch {
|
|
188
|
+
// try the next candidate — never throw
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── the single availability gate every subcommand runs first — never throws ─────────────────────
|
|
195
|
+
/**
|
|
196
|
+
* @returns {Promise<
|
|
197
|
+
* {ok:true, baseUrl:string} |
|
|
198
|
+
* {ok:false, refusedHosted:true, message:string} |
|
|
199
|
+
* {ok:false, unavailable:true, reason:string}
|
|
200
|
+
* >}
|
|
201
|
+
*/
|
|
202
|
+
export async function ensureAvailable(opts = {}) {
|
|
203
|
+
const platform = opts.platform !== undefined ? opts.platform : await loadPlatform();
|
|
204
|
+
return platform ? ensurePlatformAvailable(platform, opts) : ensureLocalAvailable(opts);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* npm-package mode: the local Docker Firecrawl or nothing. No env var required, no API key, never
|
|
209
|
+
* hosted. Unavailable carries the plain setup steps (start Docker Desktop + the Firecrawl stack).
|
|
210
|
+
*/
|
|
211
|
+
export async function ensureLocalAvailable({ localDetectFn = detectLocalFirecrawl, modeOpts = {}, env = process.env } = {}) {
|
|
212
|
+
let det;
|
|
213
|
+
try {
|
|
214
|
+
det = await localDetectFn({ env, ...(modeOpts.httpFetch ? { httpFetch: modeOpts.httpFetch } : {}) });
|
|
215
|
+
} catch (e) {
|
|
216
|
+
det = { ok: false, reason: `local Firecrawl detection failed: ${e?.message || e}` };
|
|
217
|
+
}
|
|
218
|
+
if (det?.ok && det.baseUrl && !isHostedFirecrawl(det.baseUrl)) {
|
|
219
|
+
return { ok: true, baseUrl: det.baseUrl, mode: "local", source: det.source };
|
|
220
|
+
}
|
|
221
|
+
return { ok: false, unavailable: true, mode: "local", reason: `${det?.reason || "no local Firecrawl found"}. ${LOCAL_FIRECRAWL_SETUP_HINT}` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Platform-mode lookup of this machine's own Docker; null when nothing local answers. Never throws. */
|
|
225
|
+
async function tryLocalDocker(localDetectFn, modeOpts) {
|
|
226
|
+
if (typeof localDetectFn !== "function") return null;
|
|
227
|
+
try {
|
|
228
|
+
const det = await localDetectFn(modeOpts?.httpFetch ? { httpFetch: modeOpts.httpFetch } : {});
|
|
229
|
+
return det?.ok && det.baseUrl && !isHostedFirecrawl(det.baseUrl) ? det.baseUrl : null;
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function ensurePlatformAvailable(platform, opts = {}) {
|
|
236
|
+
const {
|
|
237
|
+
getBaseUrlFn = platform.adapter.getBaseUrl,
|
|
238
|
+
modeOpts = {},
|
|
239
|
+
candidatesFn = platform.adapter.listSelfHostedFirecrawlCandidates,
|
|
240
|
+
autostartFn = autostartLocalFirecrawl,
|
|
241
|
+
remoteStartFn = autostartRemoteFirecrawl,
|
|
242
|
+
localDetectFn = detectLocalFirecrawl,
|
|
243
|
+
} = opts;
|
|
244
|
+
const { requestFirecrawlMode, resolveLocalFirecrawlProbeUrl } = platform.mode;
|
|
245
|
+
let primary;
|
|
246
|
+
try {
|
|
247
|
+
primary = getBaseUrlFn();
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { ok: false, unavailable: true, reason: `could not resolve Firecrawl base URL: ${e?.message || e}` };
|
|
250
|
+
}
|
|
251
|
+
if (isHostedFirecrawl(primary)) {
|
|
252
|
+
// Prefer a fleet self-hosted duplicate before refusing — Basement / Spec-ops may be up
|
|
253
|
+
// even when FIRECRAWL_BASE_URL still points at the hosted default.
|
|
254
|
+
const fleet = typeof candidatesFn === "function" ? candidatesFn() : [];
|
|
255
|
+
if (!fleet.length) {
|
|
256
|
+
// A Firecrawl in this machine's own Docker is self-hosted too — use it before refusing.
|
|
257
|
+
const local = await tryLocalDocker(localDetectFn, modeOpts);
|
|
258
|
+
if (local) return { ok: true, baseUrl: local, source: "local-docker" };
|
|
259
|
+
return { ok: false, refusedHosted: true, message: HOSTED_REFUSAL_MESSAGE };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const candidates = [];
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
const push = (url) => {
|
|
266
|
+
const u = typeof url === "string" ? url.trim().replace(/\/+$/, "") : "";
|
|
267
|
+
if (!u || isHostedFirecrawl(u) || seen.has(u)) return;
|
|
268
|
+
seen.add(u);
|
|
269
|
+
candidates.push(u);
|
|
270
|
+
};
|
|
271
|
+
push(primary);
|
|
272
|
+
if (typeof candidatesFn === "function") {
|
|
273
|
+
for (const u of candidatesFn()) push(u);
|
|
274
|
+
}
|
|
275
|
+
if (!candidates.length) {
|
|
276
|
+
return { ok: false, refusedHosted: true, message: HOSTED_REFUSAL_MESSAGE };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Try each self-hosted host (Basement OR Spec-ops OR Night Action). Unleased hosts skip the
|
|
280
|
+
// XOR lease and just probe; leased Night Action keeps the SSH/lease path.
|
|
281
|
+
const reasons = [];
|
|
282
|
+
for (const baseUrl of candidates) {
|
|
283
|
+
let lease;
|
|
284
|
+
try {
|
|
285
|
+
lease = await requestFirecrawlMode({
|
|
286
|
+
localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: baseUrl }),
|
|
287
|
+
...modeOpts,
|
|
288
|
+
});
|
|
289
|
+
} catch (e) {
|
|
290
|
+
reasons.push(`${baseUrl}: mode-lease request failed: ${e?.message || e}`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (lease.granted) return { ok: true, baseUrl };
|
|
294
|
+
reasons.push(`${baseUrl}: ${lease.reason}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Every declared candidate is down — a Firecrawl in THIS machine's Docker (any compose project,
|
|
298
|
+
// any published port) is still self-hosted. Found by `docker ps` + probe, no config needed.
|
|
299
|
+
{
|
|
300
|
+
const local = await tryLocalDocker(localDetectFn, modeOpts);
|
|
301
|
+
if (local) return { ok: true, baseUrl: local, source: "local-docker" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// LAST RESort — every candidate is down (the fleet is powered off). If THIS machine declares an
|
|
305
|
+
// on-demand stack, start it and retry once. Deliberately placed after the loop so it can only
|
|
306
|
+
// ever turn a failure into a success: on any error we fall through to the original unavailable
|
|
307
|
+
// result below, and the "stop, never substitute" contract is preserved (a started local stack is
|
|
308
|
+
// still a self-hosted endpoint — not a hosted-credit spend and not a WebSearch fallback).
|
|
309
|
+
if (typeof autostartFn === "function") {
|
|
310
|
+
let started = null;
|
|
311
|
+
try {
|
|
312
|
+
started = await autostartFn();
|
|
313
|
+
} catch {
|
|
314
|
+
started = null;
|
|
315
|
+
}
|
|
316
|
+
if (started) {
|
|
317
|
+
try {
|
|
318
|
+
const lease = await requestFirecrawlMode({
|
|
319
|
+
localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: started }),
|
|
320
|
+
...modeOpts,
|
|
321
|
+
});
|
|
322
|
+
if (lease.granted) return { ok: true, baseUrl: started, autostarted: true };
|
|
323
|
+
reasons.push(`${started}: autostarted but lease refused — ${lease.reason}`);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
reasons.push(`${started}: autostarted but probe failed: ${e?.message || e}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// SECOND LAST RESORT — this machine has nothing on-demand of its own, but a fleet machine
|
|
331
|
+
// (Spec-ops) may declare a working day-2 SSH script to start ITS stack remotely. Same fail-safe
|
|
332
|
+
// shape as the local autostart above: only ever turns a failure into a success.
|
|
333
|
+
if (typeof remoteStartFn === "function") {
|
|
334
|
+
let started = null;
|
|
335
|
+
try {
|
|
336
|
+
started = await remoteStartFn();
|
|
337
|
+
} catch {
|
|
338
|
+
started = null;
|
|
339
|
+
}
|
|
340
|
+
if (started) {
|
|
341
|
+
try {
|
|
342
|
+
const lease = await requestFirecrawlMode({
|
|
343
|
+
localProbeUrl: resolveLocalFirecrawlProbeUrl({ FIRECRAWL_BASE_URL: started }),
|
|
344
|
+
...modeOpts,
|
|
345
|
+
});
|
|
346
|
+
if (lease.granted) return { ok: true, baseUrl: started, autostarted: true };
|
|
347
|
+
reasons.push(`${started}: remote-started but lease refused — ${lease.reason}`);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
reasons.push(`${started}: remote-started but probe failed: ${e?.message || e}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { ok: false, unavailable: true, reason: reasons.join(" | ") || "no self-hosted Firecrawl endpoint available" };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── subcommand implementations (testable independent of the CLI/exit layer) ─────────────────────
|
|
358
|
+
export async function runSearch(query, { limit = 6, httpFetch, availabilityOpts = {} } = {}) {
|
|
359
|
+
const platform = availabilityOpts.platform !== undefined ? availabilityOpts.platform : await loadPlatform();
|
|
360
|
+
const avail = await ensureAvailable({ ...availabilityOpts, platform });
|
|
361
|
+
if (!avail.ok) return avail;
|
|
362
|
+
if (!platform) {
|
|
363
|
+
const lr = await localFirecrawlSearch(avail.baseUrl, query, { limit, ...(httpFetch ? { httpFetch } : {}) });
|
|
364
|
+
if (!lr.ok) return { ok: false, unavailable: true, reason: `search request failed on ${avail.baseUrl}: ${lr.error}` };
|
|
365
|
+
return { ok: true, results: lr.results, servedBy: avail.baseUrl, autostarted: false };
|
|
366
|
+
}
|
|
367
|
+
let r;
|
|
368
|
+
try {
|
|
369
|
+
r = await platform.adapter.firecrawlSearch(query, { limit, baseUrl: avail.baseUrl, httpFetch });
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return { ok: false, unavailable: true, reason: `search request failed: ${e?.message || e}` };
|
|
372
|
+
}
|
|
373
|
+
if (r.blocked) return { ok: false, unavailable: true, reason: r.reason };
|
|
374
|
+
if (r.error) return { ok: false, unavailable: true, reason: r.error };
|
|
375
|
+
// servedBy: which candidate ACTUALLY answered. A research doc's Method block should record the
|
|
376
|
+
// real backend, not the intended one — the failover walks several hosts and an on-demand local
|
|
377
|
+
// stack may be the one that served.
|
|
378
|
+
return { ok: true, results: r.results, servedBy: avail.baseUrl, autostarted: avail.autostarted === true };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function runScrape(url, { httpFetch, availabilityOpts = {}, maxChars = MAX_SCRAPE_CHARS } = {}) {
|
|
382
|
+
const platform = availabilityOpts.platform !== undefined ? availabilityOpts.platform : await loadPlatform();
|
|
383
|
+
const avail = await ensureAvailable({ ...availabilityOpts, platform });
|
|
384
|
+
if (!avail.ok) return avail;
|
|
385
|
+
let r;
|
|
386
|
+
try {
|
|
387
|
+
r = platform
|
|
388
|
+
? await platform.adapter.firecrawlScrape(url, { baseUrl: avail.baseUrl, httpFetch })
|
|
389
|
+
: await localFirecrawlScrape(avail.baseUrl, url, httpFetch ? { httpFetch } : {});
|
|
390
|
+
} catch (e) {
|
|
391
|
+
return { ok: false, unavailable: true, reason: `scrape request failed: ${e?.message || e}` };
|
|
392
|
+
}
|
|
393
|
+
if (r.blocked) return { ok: false, unavailable: true, reason: r.reason };
|
|
394
|
+
if (!r.ok) return { ok: false, unavailable: true, reason: r.error || `HTTP ${r.status || 0}` };
|
|
395
|
+
const full = r.markdown || "";
|
|
396
|
+
const truncated = full.length > maxChars;
|
|
397
|
+
const markdown = truncated ? full.slice(0, maxChars) : full;
|
|
398
|
+
return { ok: true, url, title: r.title || "", markdown, truncated, fullLength: full.length, servedBy: avail.baseUrl, autostarted: avail.autostarted === true };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── output formatting ─────────────────────────────────────────────────────────────────────────
|
|
402
|
+
function formatSearchList(results) {
|
|
403
|
+
if (results.length === 0) return "(no results)";
|
|
404
|
+
return results
|
|
405
|
+
.map((r, i) => {
|
|
406
|
+
const lines = [`${i + 1}. ${r.title || "(untitled)"}`, ` ${r.url}`];
|
|
407
|
+
if (r.snippet) lines.push(` ${r.snippet}`);
|
|
408
|
+
return lines.join("\n");
|
|
409
|
+
})
|
|
410
|
+
.join("\n\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatScrapeText(result) {
|
|
414
|
+
let out = result.markdown;
|
|
415
|
+
if (result.truncated) {
|
|
416
|
+
out += `\n\n[...truncated — showing first ${MAX_SCRAPE_CHARS} of ${result.fullLength} chars...]`;
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── CLI entry ─────────────────────────────────────────────────────────────────────────────────
|
|
422
|
+
function printUsage() {
|
|
423
|
+
console.error('usage: firecrawl-cli.mjs search "<query>" [--limit N] [--json]');
|
|
424
|
+
console.error(' firecrawl-cli.mjs scrape "<url>" [--json]');
|
|
425
|
+
console.error(" firecrawl-cli.mjs --self-test");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Set the exit code and RETURN — never call process.exit() after doing network work.
|
|
430
|
+
*
|
|
431
|
+
* Forcing an exit while the HTTP client still has live handles aborts the process on Windows with
|
|
432
|
+
* `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\\win\\async.c:94` (uv_async_send on
|
|
433
|
+
* an already-closing handle). stdout had already flushed, so callers saw perfect JSON alongside
|
|
434
|
+
* exit code 127: harmless when a human reads stdout, fatal for any pipeline that checks exit
|
|
435
|
+
* status — and it poisoned callers that correctly treat non-zero as failure. Letting the event
|
|
436
|
+
* loop drain on its own exits cleanly and promptly (verified: the same work run to completion
|
|
437
|
+
* without process.exit() returns 0 with no assertion).
|
|
438
|
+
*/
|
|
439
|
+
function finish(code) {
|
|
440
|
+
process.exitCode = code;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function exitOnResult(result, { kind, json }) {
|
|
444
|
+
if (!result.ok) {
|
|
445
|
+
if (result.refusedHosted) {
|
|
446
|
+
console.log(result.message);
|
|
447
|
+
return finish(2);
|
|
448
|
+
}
|
|
449
|
+
// Machine-readable, single-line, exact prefix the calling workflow prompts grep for.
|
|
450
|
+
console.log(`FIRECRAWL_UNAVAILABLE: ${result.reason}`);
|
|
451
|
+
return finish(3);
|
|
452
|
+
}
|
|
453
|
+
if (kind === "search") {
|
|
454
|
+
console.log(json ? JSON.stringify({ results: result.results, servedBy: result.servedBy, autostarted: result.autostarted }) : formatSearchList(result.results));
|
|
455
|
+
} else {
|
|
456
|
+
console.log(
|
|
457
|
+
json
|
|
458
|
+
? JSON.stringify({ url: result.url, title: result.title, markdown: result.markdown, truncated: result.truncated, fullLength: result.fullLength, servedBy: result.servedBy, autostarted: result.autostarted })
|
|
459
|
+
: formatScrapeText(result),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return finish(0);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function main() {
|
|
466
|
+
const argv = process.argv.slice(2);
|
|
467
|
+
const cmd = argv[0];
|
|
468
|
+
const target = argv[1];
|
|
469
|
+
const json = argv.includes("--json");
|
|
470
|
+
const limitIdx = argv.indexOf("--limit");
|
|
471
|
+
const limit = limitIdx !== -1 ? Number(argv[limitIdx + 1]) || 6 : 6;
|
|
472
|
+
|
|
473
|
+
if (cmd !== "search" && cmd !== "scrape") {
|
|
474
|
+
printUsage();
|
|
475
|
+
process.exit(64);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (!target) {
|
|
479
|
+
console.error(`usage error: missing ${cmd === "search" ? "<query>" : "<url>"} argument`);
|
|
480
|
+
printUsage();
|
|
481
|
+
process.exit(64);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const result = cmd === "search" ? await runSearch(target, { limit }) : await runScrape(target, {});
|
|
487
|
+
await exitOnResult(result, { kind: cmd, json });
|
|
488
|
+
} catch (e) {
|
|
489
|
+
// Last-resort catch — every internal path above already returns a clean shape, but this
|
|
490
|
+
// guarantees the CLI NEVER surfaces a raw stack trace, per the hard requirement.
|
|
491
|
+
console.log(`FIRECRAWL_UNAVAILABLE: unexpected error — ${e?.message || String(e)}`);
|
|
492
|
+
return finish(3);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const isMain = import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""));
|
|
497
|
+
|
|
498
|
+
if (isMain && !process.argv.includes("--self-test")) {
|
|
499
|
+
main();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── self-test (mocked — no real network, no real SSH) — MAIN-entry guarded ──────────────────────
|
|
503
|
+
if (isMain && process.argv.includes("--self-test")) {
|
|
504
|
+
(async () => {
|
|
505
|
+
let ok = true;
|
|
506
|
+
const check = (n, c) => {
|
|
507
|
+
console.log(` ${c ? "✓" : "✗"} ${n}`);
|
|
508
|
+
if (!c) ok = false;
|
|
509
|
+
};
|
|
510
|
+
console.log("== firecrawl-cli.mjs self-test (mock-only, no real network/SSH) ==\n");
|
|
511
|
+
const P = await loadPlatform();
|
|
512
|
+
if (!P) {
|
|
513
|
+
console.log(" (platform-mode self-test needs a HeySpex checkout; local mode: firecrawl-cli.local.test.mjs)");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const { HOSTED_FIRECRAWL_BASE_URL } = P.adapter;
|
|
517
|
+
|
|
518
|
+
// A single mock httpFetch that branches on URL shape — reused for BOTH the mode-lease probes
|
|
519
|
+
// (":11434"/":3002" GETs) and the actual Firecrawl POST calls (/search, /scrape).
|
|
520
|
+
const makeMockFetch = ({ ollamaModels = [], firecrawlUp = true, searchResults = [{ title: "T", url: "http://example.com/x", description: "snip" }], scrapeMarkdown = "Hello **world**" } = {}) =>
|
|
521
|
+
async (url) => {
|
|
522
|
+
const u = String(url);
|
|
523
|
+
if (u.includes(":11434")) {
|
|
524
|
+
return { ok: true, status: 200, json: async () => ({ models: ollamaModels }) };
|
|
525
|
+
}
|
|
526
|
+
if (u.endsWith("/search")) {
|
|
527
|
+
return { ok: true, status: 200, json: async () => ({ success: true, data: { web: searchResults } }) };
|
|
528
|
+
}
|
|
529
|
+
if (u.endsWith("/scrape")) {
|
|
530
|
+
return { ok: true, status: 200, json: async () => ({ success: true, data: { markdown: scrapeMarkdown, metadata: { title: "Page Title", statusCode: 200 } } }) };
|
|
531
|
+
}
|
|
532
|
+
// bare-root Firecrawl reachability probe
|
|
533
|
+
if (firecrawlUp) return { ok: true, status: 200, json: async () => ({}) };
|
|
534
|
+
throw new Error("ECONNREFUSED");
|
|
535
|
+
};
|
|
536
|
+
const noSsh = async () => { throw new Error("self-test: SSH must never be called by these cases"); };
|
|
537
|
+
|
|
538
|
+
// Isolate lease cases from fleet failover (Basement / Spec-ops candidates) —
|
|
539
|
+
// each case under test pins a single endpoint via getBaseUrlFn.
|
|
540
|
+
// autostartFn is stubbed OFF for every case below: the real one shells out to
|
|
541
|
+
// firecrawl-local.sh (docker compose), which must never run from a self-test. Cases that
|
|
542
|
+
// expect `unavailable` DO reach the autostart step, so this stub is load-bearing.
|
|
543
|
+
const noFleet = { candidatesFn: () => [], autostartFn: async () => null, remoteStartFn: async () => null, localDetectFn: null };
|
|
544
|
+
|
|
545
|
+
// ── 1. hosted endpoint is refused BEFORE any network call ──
|
|
546
|
+
let networkCalled = false;
|
|
547
|
+
const trackedFetch = async (...a) => { networkCalled = true; throw new Error("must not be called"); };
|
|
548
|
+
const hostedAvail = await ensureAvailable({ getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL, modeOpts: { httpFetch: trackedFetch, runCmd: noSsh }, ...noFleet });
|
|
549
|
+
check("1 hosted endpoint -> refusedHosted:true", hostedAvail.ok === false && hostedAvail.refusedHosted === true);
|
|
550
|
+
check("1b hosted refusal never calls the network", networkCalled === false);
|
|
551
|
+
|
|
552
|
+
// ── 2. self-hosted + Ollama busy -> unavailable, no SSH call (never evicts Ollama) ──
|
|
553
|
+
let sshCalled = false;
|
|
554
|
+
const ollamaBusyFetch = makeMockFetch({ ollamaModels: [{ name: OLLAMA.SMALL }], firecrawlUp: false });
|
|
555
|
+
const busyAvail = await ensureAvailable({
|
|
556
|
+
getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
|
|
557
|
+
modeOpts: { httpFetch: ollamaBusyFetch, runCmd: async () => { sshCalled = true; } },
|
|
558
|
+
...noFleet,
|
|
559
|
+
});
|
|
560
|
+
check("2 Ollama-busy -> unavailable:true", busyAvail.ok === false && busyAvail.unavailable === true);
|
|
561
|
+
check("2b reason names the Ollama lease", /ollama/i.test(busyAvail.reason));
|
|
562
|
+
check("2c never calls SSH (no eviction)", sshCalled === false);
|
|
563
|
+
|
|
564
|
+
// ── 3. self-hosted + box offline -> unavailable ──
|
|
565
|
+
const offlineFetch = async () => { throw new Error("ECONNREFUSED"); };
|
|
566
|
+
const offlineAvail = await ensureAvailable({ getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: offlineFetch, runCmd: noSsh }, ...noFleet });
|
|
567
|
+
check("3 offline box -> unavailable:true", offlineAvail.ok === false && offlineAvail.unavailable === true);
|
|
568
|
+
|
|
569
|
+
// ── 4. self-hosted + box idle -> lease requested and granted (compose up mocked, no real SSH) ──
|
|
570
|
+
sshCalled = false;
|
|
571
|
+
const idleFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: false });
|
|
572
|
+
const idleAvail = await ensureAvailable({
|
|
573
|
+
getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
|
|
574
|
+
modeOpts: { httpFetch: idleFetch, runCmd: async () => { sshCalled = true; } },
|
|
575
|
+
...noFleet,
|
|
576
|
+
});
|
|
577
|
+
check("4 idle box -> lease granted", idleAvail.ok === true);
|
|
578
|
+
check("4b idle grant DOES start the stack (mocked SSH call made)", sshCalled === true);
|
|
579
|
+
|
|
580
|
+
// ── 5. self-hosted + Firecrawl already up -> granted directly, no SSH ──
|
|
581
|
+
sshCalled = false;
|
|
582
|
+
const upFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
|
|
583
|
+
const upAvail = await ensureAvailable({
|
|
584
|
+
getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
|
|
585
|
+
modeOpts: { httpFetch: upFetch, runCmd: async () => { sshCalled = true; } },
|
|
586
|
+
...noFleet,
|
|
587
|
+
});
|
|
588
|
+
check("5 already-up -> lease granted with no SSH call", upAvail.ok === true && sshCalled === false);
|
|
589
|
+
|
|
590
|
+
// ── 5d. hosted primary + Spec-ops/Basement candidate up -> failover grant (duplicate option) ──
|
|
591
|
+
const fleetUpFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
|
|
592
|
+
const failoverAvail = await ensureAvailable({
|
|
593
|
+
getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL,
|
|
594
|
+
candidatesFn: () => ["http://192.168.1.98:3002/v2"],
|
|
595
|
+
modeOpts: { httpFetch: fleetUpFetch, runCmd: noSsh },
|
|
596
|
+
});
|
|
597
|
+
check("5d hosted primary fails over to Spec-ops candidate", failoverAvail.ok === true && failoverAvail.baseUrl === "http://192.168.1.98:3002/v2");
|
|
598
|
+
|
|
599
|
+
// ── 6. runSearch end-to-end (mocked): returns normalized {title,url,snippet}[] ──
|
|
600
|
+
const searchFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
|
|
601
|
+
const searchResult = await runSearch("test query", {
|
|
602
|
+
httpFetch: searchFetch,
|
|
603
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: searchFetch, runCmd: noSsh }, ...noFleet },
|
|
604
|
+
});
|
|
605
|
+
check("6 runSearch resolves ok:true with results", searchResult.ok === true && searchResult.results.length === 1 && searchResult.results[0].url === "http://example.com/x");
|
|
606
|
+
check("6b formatSearchList renders a readable list", formatSearchList(searchResult.results).includes("http://example.com/x"));
|
|
607
|
+
|
|
608
|
+
// ── 7. runSearch when unavailable (Ollama busy) -> clean unavailable shape, not a throw ──
|
|
609
|
+
const busySearchResult = await runSearch("q", {
|
|
610
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: ollamaBusyFetch, runCmd: noSsh }, ...noFleet },
|
|
611
|
+
});
|
|
612
|
+
check("7 runSearch under Ollama lease -> unavailable:true (not a throw)", busySearchResult.ok === false && busySearchResult.unavailable === true);
|
|
613
|
+
|
|
614
|
+
// ── 8. runScrape end-to-end (mocked): returns markdown + title ──
|
|
615
|
+
const scrapeFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true, scrapeMarkdown: "Hello **world**" });
|
|
616
|
+
const scrapeResult = await runScrape("http://example.com/x", {
|
|
617
|
+
httpFetch: scrapeFetch,
|
|
618
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: scrapeFetch, runCmd: noSsh }, ...noFleet },
|
|
619
|
+
});
|
|
620
|
+
check("8 runScrape resolves ok:true", scrapeResult.ok === true && scrapeResult.title === "Page Title");
|
|
621
|
+
check("8b scrape markdown content present", /Hello \*\*world\*\*/.test(scrapeResult.markdown));
|
|
622
|
+
check("8c not truncated (short body)", scrapeResult.truncated === false);
|
|
623
|
+
|
|
624
|
+
// ── 9. runScrape truncates at MAX_SCRAPE_CHARS and notes it ──
|
|
625
|
+
const longMarkdown = "x".repeat(MAX_SCRAPE_CHARS + 500);
|
|
626
|
+
const longFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true, scrapeMarkdown: longMarkdown });
|
|
627
|
+
const longResult = await runScrape("http://example.com/long", {
|
|
628
|
+
httpFetch: longFetch,
|
|
629
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: longFetch, runCmd: noSsh }, ...noFleet },
|
|
630
|
+
});
|
|
631
|
+
check("9 long page truncated to MAX_SCRAPE_CHARS", longResult.ok === true && longResult.markdown.length === MAX_SCRAPE_CHARS && longResult.truncated === true);
|
|
632
|
+
check("9b formatScrapeText appends a truncation note", formatScrapeText(longResult).includes("truncated"));
|
|
633
|
+
|
|
634
|
+
// ── 10. network/parse failure inside runSearch/runScrape never throws ──
|
|
635
|
+
const throwingFetch = async (url) => {
|
|
636
|
+
if (String(url).includes(":11434") || String(url).endsWith("/") === false && !String(url).endsWith("/search") && !String(url).endsWith("/scrape")) {
|
|
637
|
+
return { ok: true, status: 200, json: async () => ({ models: [] }) };
|
|
638
|
+
}
|
|
639
|
+
throw new Error("ECONNRESET");
|
|
640
|
+
};
|
|
641
|
+
const errSearch = await runSearch("q", {
|
|
642
|
+
httpFetch: throwingFetch,
|
|
643
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: makeMockFetch({ ollamaModels: [], firecrawlUp: true }), runCmd: noSsh }, ...noFleet },
|
|
644
|
+
});
|
|
645
|
+
check("10 network failure inside search -> unavailable:true, not a throw", errSearch.ok === false && errSearch.unavailable === true);
|
|
646
|
+
|
|
647
|
+
// ── 11. on-demand local autostart: fires ONLY after every candidate failed, and is fail-safe ──
|
|
648
|
+
// The workstation stack is normally stopped ([[workstation-firecrawl-is-the-fleet-down]]), so a
|
|
649
|
+
// fleet-down run must be able to start it and retry — without ever weakening the refusal path.
|
|
650
|
+
const localUpFetch = async (url) => {
|
|
651
|
+
if (String(url).includes("127.0.0.1")) return { ok: true, status: 200, json: async () => ({}) };
|
|
652
|
+
throw new Error("ECONNREFUSED");
|
|
653
|
+
};
|
|
654
|
+
const autoAvail = await ensureAvailable({
|
|
655
|
+
getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
|
|
656
|
+
modeOpts: { httpFetch: localUpFetch, runCmd: noSsh },
|
|
657
|
+
candidatesFn: () => [],
|
|
658
|
+
localDetectFn: null,
|
|
659
|
+
autostartFn: async () => "http://127.0.0.1:3002/v2",
|
|
660
|
+
remoteStartFn: async () => null,
|
|
661
|
+
});
|
|
662
|
+
check("11 all candidates down -> autostart rescues the run", autoAvail.ok === true && autoAvail.baseUrl === "http://127.0.0.1:3002/v2");
|
|
663
|
+
check("11b rescued result is flagged autostarted:true", autoAvail.autostarted === true);
|
|
664
|
+
|
|
665
|
+
// a failed start must degrade to the ORIGINAL unavailable, never to a crash
|
|
666
|
+
const autoFail = await ensureAvailable({
|
|
667
|
+
getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
|
|
668
|
+
modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
|
|
669
|
+
candidatesFn: () => [],
|
|
670
|
+
localDetectFn: null,
|
|
671
|
+
autostartFn: async () => null,
|
|
672
|
+
remoteStartFn: async () => null,
|
|
673
|
+
});
|
|
674
|
+
check("11c failed autostart -> still unavailable:true", autoFail.ok === false && autoFail.unavailable === true);
|
|
675
|
+
|
|
676
|
+
const autoThrow = await ensureAvailable({
|
|
677
|
+
getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
|
|
678
|
+
modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
|
|
679
|
+
candidatesFn: () => [],
|
|
680
|
+
localDetectFn: null,
|
|
681
|
+
autostartFn: async () => { throw new Error("docker exploded"); },
|
|
682
|
+
remoteStartFn: async () => null,
|
|
683
|
+
});
|
|
684
|
+
check("11d throwing autostart -> unavailable:true, not a throw", autoThrow.ok === false && autoThrow.unavailable === true);
|
|
685
|
+
|
|
686
|
+
// never start anything when a candidate already answered
|
|
687
|
+
let autostartCalled = false;
|
|
688
|
+
const noStartNeeded = await ensureAvailable({
|
|
689
|
+
getBaseUrlFn: () => "http://192.168.1.252:3002/v2",
|
|
690
|
+
modeOpts: { httpFetch: upFetch, runCmd: async () => {} },
|
|
691
|
+
candidatesFn: () => [],
|
|
692
|
+
localDetectFn: null,
|
|
693
|
+
autostartFn: async () => { autostartCalled = true; return null; },
|
|
694
|
+
remoteStartFn: async () => null,
|
|
695
|
+
});
|
|
696
|
+
check("11e a healthy candidate never triggers autostart", noStartNeeded.ok === true && autostartCalled === false);
|
|
697
|
+
|
|
698
|
+
// the hosted refusal must still short-circuit BEFORE any autostart attempt
|
|
699
|
+
let hostedAutostartCalled = false;
|
|
700
|
+
const hostedStill = await ensureAvailable({
|
|
701
|
+
getBaseUrlFn: () => HOSTED_FIRECRAWL_BASE_URL,
|
|
702
|
+
modeOpts: { httpFetch: trackedFetch, runCmd: noSsh },
|
|
703
|
+
candidatesFn: () => [],
|
|
704
|
+
localDetectFn: null,
|
|
705
|
+
autostartFn: async () => { hostedAutostartCalled = true; return "http://127.0.0.1:3002/v2"; },
|
|
706
|
+
remoteStartFn: async () => null,
|
|
707
|
+
});
|
|
708
|
+
check("11f hosted refusal still short-circuits, no autostart", hostedStill.refusedHosted === true && hostedAutostartCalled === false);
|
|
709
|
+
|
|
710
|
+
// ── 11g-11i. remote-start (Spec-ops SSH script) fires as the THIRD fallback, after local
|
|
711
|
+
// autostart also fails — and is itself fail-safe (never throws, degrades to unavailable). ──
|
|
712
|
+
let remoteStartCalled = false;
|
|
713
|
+
const remoteRescue = await ensureAvailable({
|
|
714
|
+
getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
|
|
715
|
+
modeOpts: { httpFetch: localUpFetch, runCmd: noSsh },
|
|
716
|
+
candidatesFn: () => [],
|
|
717
|
+
localDetectFn: null,
|
|
718
|
+
autostartFn: async () => null,
|
|
719
|
+
remoteStartFn: async () => { remoteStartCalled = true; return "http://127.0.0.1:3002/v2"; },
|
|
720
|
+
});
|
|
721
|
+
check("11g local autostart failing falls through to remoteStartFn", remoteStartCalled === true);
|
|
722
|
+
check("11h remote-started result is flagged autostarted:true", remoteRescue.ok === true && remoteRescue.autostarted === true);
|
|
723
|
+
|
|
724
|
+
const remoteThrow = await ensureAvailable({
|
|
725
|
+
getBaseUrlFn: () => "http://192.168.1.98:3002/v2",
|
|
726
|
+
modeOpts: { httpFetch: offlineFetch, runCmd: noSsh },
|
|
727
|
+
candidatesFn: () => [],
|
|
728
|
+
localDetectFn: null,
|
|
729
|
+
autostartFn: async () => null,
|
|
730
|
+
remoteStartFn: async () => { throw new Error("ssh exploded"); },
|
|
731
|
+
});
|
|
732
|
+
check("11i throwing remoteStartFn -> unavailable:true, not a throw", remoteThrow.ok === false && remoteThrow.unavailable === true);
|
|
733
|
+
|
|
734
|
+
// ── 11j-11l. remoteStartCandidates(): excludes THIS machine, dedupes shared-stack GPUs,
|
|
735
|
+
// skips machines with no declared script. Reads the real routing-table.json by path (mirrors
|
|
736
|
+
// localOnDemandFirecrawl's shape, no table-injection param) — exercises the LIVE table's
|
|
737
|
+
// actual declarations (specops-gpu0/gpu1 -> firecrawl-specops.sh, basement-geek -> none). ──
|
|
738
|
+
const liveRemote = remoteStartCandidates({ HEYSPEX_FLEET_MACHINE_KEY: "coffee-geek" });
|
|
739
|
+
check("11j live routing-table declares Spec-ops' remote-start script, deduped to one entry", liveRemote.filter((c) => c.machineKey.startsWith("specops")).length === 1);
|
|
740
|
+
check("11k live routing-table excludes basement-geek (no remoteStart declared)", !liveRemote.some((c) => c.machineKey === "basement-geek"));
|
|
741
|
+
const selfExcluded = remoteStartCandidates({ HEYSPEX_FLEET_MACHINE_KEY: "specops-gpu0" });
|
|
742
|
+
check("11l remoteStartCandidates excludes the machine THIS process is on", !selfExcluded.some((c) => c.machineKey === "specops-gpu0"));
|
|
743
|
+
|
|
744
|
+
// ── 12. servedBy reports the candidate that ACTUALLY answered ──
|
|
745
|
+
const servedFetch = makeMockFetch({ ollamaModels: [], firecrawlUp: true });
|
|
746
|
+
const servedResult = await runSearch("q", {
|
|
747
|
+
httpFetch: servedFetch,
|
|
748
|
+
availabilityOpts: { getBaseUrlFn: () => "http://192.168.1.252:3002/v2", modeOpts: { httpFetch: servedFetch, runCmd: noSsh }, ...noFleet },
|
|
749
|
+
});
|
|
750
|
+
check("12 runSearch reports servedBy", servedResult.ok === true && servedResult.servedBy === "http://192.168.1.252:3002/v2");
|
|
751
|
+
|
|
752
|
+
console.log(`\n${ok ? "✓ self-test PASS" : "✗ self-test FAIL"}`);
|
|
753
|
+
process.exit(ok ? 0 : 1);
|
|
754
|
+
})();
|
|
755
|
+
}
|