heyspex 0.2.6 → 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/bin/hs.mjs +85 -25
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -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/dist/cli/hs-connect.mjs +20 -2
- package/dist/cli/hs-defer.mjs +19 -1
- package/dist/cli/hs-index-rebuild.mjs +19 -1
- package/dist/cli/hs-index-reconcile.mjs +19 -1
- package/dist/cli/hs-init.mjs +2 -2
- package/dist/cli/hs-install.mjs +2 -2
- package/dist/cli/hs-join.mjs +20 -2
- package/dist/cli/hs-next-launch.mjs +89 -10
- package/dist/cli/hs-spawn.mjs +27 -1
- package/dist/cli/hs-vault-pull.mjs +19 -1
- package/dist/cli/hs-vault-push.mjs +19 -1
- 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 +20 -8
- 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 +20 -8
- 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 +7 -0
- package/docs/mcp-client-config.md +6 -0
- package/package.json +7 -2
- 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/hs-companion.mjs +38 -8
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +5 -3
- package/scripts/lib/hs-companion-settings.mjs +1 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- 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/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/vault-io.mjs +720 -720
package/bin/hs.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { BINDING_VERBS, runVaultBindingCommand } from "./hs-vault-binding.mjs";
|
|
15
15
|
import { findProjectRoot } from "../scripts/lib/customer-kit.mjs";
|
|
16
|
+
import { companionDoctorRow } from "../scripts/lib/companion-install.mjs";
|
|
16
17
|
import { spawn } from "node:child_process";
|
|
17
18
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
18
19
|
import path from "node:path";
|
|
@@ -86,15 +87,19 @@ function helpForThisInstall(lines) {
|
|
|
86
87
|
if (!IS_INSTALLED_PACKAGE) return lines;
|
|
87
88
|
const out = [];
|
|
88
89
|
for (const line of lines) {
|
|
90
|
+
if (line.startsWith(" hs seat set|reset|assignments")) {
|
|
91
|
+
out.push(" hs seat list|set|reset [--harness <h>] Your own model per SpexFleet seat in this vault (Claude Code / Copilot)");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
89
94
|
if (DEV_ONLY_HELP_PREFIXES.some((p) => line.startsWith(p))) continue;
|
|
90
95
|
if (line.startsWith(" hs companion start|stop|status|build")) {
|
|
91
|
-
out.push(" hs companion start|stop|status Desktop companion tray app (
|
|
96
|
+
out.push(" hs companion start|stop|status Desktop companion tray app (installed by `hs companion install`)");
|
|
92
97
|
continue;
|
|
93
98
|
}
|
|
94
99
|
out.push(line);
|
|
95
100
|
}
|
|
96
101
|
out.push(
|
|
97
|
-
"HeySpex developer-only verbs (hs server, hs factory, hs seat, hs admin, hs queue,",
|
|
102
|
+
"HeySpex developer-only verbs (hs server, hs factory, hs seat add|update|disable, hs admin, hs queue,",
|
|
98
103
|
"hs doctrine, hs companion build) need a HeySpex source checkout and are not",
|
|
99
104
|
"listed here. Neither are verbs whose implementation the npm package does not",
|
|
100
105
|
"carry (hs agent, hs chat, hs code-watcher, hs harness, hs hooks, hs import,",
|
|
@@ -678,13 +683,12 @@ function usageLines() {
|
|
|
678
683
|
" hs worktree list|alloc|release|reap [--json] [--dry-run] Session/build worktree registry (HS-03DO)",
|
|
679
684
|
" hs view <surface> [spec-id] [--width N --height N --browser edge|chrome] Open one surface in a chromeless app-mode window",
|
|
680
685
|
" hs server stop [--json] Stop the spex-console dev server (any terminal)",
|
|
681
|
-
" hs source status|preflight|commit|push SpexSource local git operations",
|
|
682
|
-
" hs source identity [show|set|clear] Git author identity (set/clear are human-only; see hs source --help)",
|
|
683
686
|
" hs defer <id> [--until <when>] [--json] Time-shift a spec (defer_until)",
|
|
684
687
|
" hs undefer <id> [--json] Clear a spec's defer_until",
|
|
685
688
|
" hs spawn [<id>] [--dry-run] [--json] Launch a fresh /hs-next agent in a new terminal",
|
|
686
689
|
' hs capture "<text>"|--clipboard|--file <p> [--add] Capture → console drafts (--add chains a group)',
|
|
687
690
|
" hs companion start|stop|status|build Desktop companion tray app",
|
|
691
|
+
" hs companion install [--quiet] [--dry-run] Download, verify and run the signed SpexCompanion setup (Windows x64)",
|
|
688
692
|
" hs companion settings [list|get <k>|set <k> <v>] [--json] Every Companion setting (console-backed)",
|
|
689
693
|
" hs companion mute|unmute|volume|orb|wake|activation|hotkey [<v>] Setting shortcuts (omit <v> to read)",
|
|
690
694
|
" hs registry list [--json] List launch registry entries",
|
|
@@ -702,6 +706,7 @@ function usageLines() {
|
|
|
702
706
|
" hs next assist|auto <milestone...> --launch Arm a dispatcher run on one or more milestones",
|
|
703
707
|
" hs model [<seat>] [--launch|--fleet] Every seat and the model set for it",
|
|
704
708
|
" hs seat list|add|update|disable [--json] Read/add/update/disable a seat in the registry (dev-factory/seats.json)",
|
|
709
|
+
" hs seat set|reset|assignments [--harness] Your own model per SpexFleet seat, this vault (customers: hs seat list|set|reset)",
|
|
705
710
|
" hs voice add|build|show|set|test|refresh|export Build and manage a coded voice profile",
|
|
706
711
|
" hs trash [list|restore|empty] The bin spec deletes go into",
|
|
707
712
|
" hs auto [filename] [--json] Resolve /hs-auto dispatch plan",
|
|
@@ -731,6 +736,9 @@ function usageLines() {
|
|
|
731
736
|
" search/scrape/crawl CLI (standalone script,",
|
|
732
737
|
" not an `hs` verb; also reachable from the",
|
|
733
738
|
" Companion)",
|
|
739
|
+
" node dev-factory/spexcrawl/firecrawl-cli.mjs search|scrape Firecrawl — finds the",
|
|
740
|
+
" self-hosted Firecrawl in this machine's",
|
|
741
|
+
" Docker (no env var, no API key)",
|
|
734
742
|
"",
|
|
735
743
|
"Control plane (Neon — requires a reachable console):",
|
|
736
744
|
" hs pin get|set <id>|clear Read/set/clear the on-deck pin",
|
|
@@ -916,6 +924,11 @@ function runStandaloneDoctor(args, vaultRoot) {
|
|
|
916
924
|
if (templateRow.status === "fail") report.ok = false;
|
|
917
925
|
report.checks.push(templateRow);
|
|
918
926
|
|
|
927
|
+
// `companion` (HS-056P): is SpexCompanion installed? Optional, so it is never
|
|
928
|
+
// a failure -- missing on Windows is a warn that offers
|
|
929
|
+
// `npx heyspex companion install`; other platforms get a skipped note.
|
|
930
|
+
report.checks.push(companionDoctorRow());
|
|
931
|
+
|
|
919
932
|
// `connection` ([[HS-W73V-WW9R-TASK-make-both-doctor-shapes-fail-on-cloud]],
|
|
920
933
|
// invariants 8 and 10): the BUNDLED `hs connect --check --json` from the
|
|
921
934
|
// invoked cwd, folded by the SAME function the full doctor uses
|
|
@@ -1096,8 +1109,22 @@ export const NOT_IN_PACKAGE_VERBS = Object.freeze({
|
|
|
1096
1109
|
factory: "SpexFleet/dev-factory internals are platform-only.",
|
|
1097
1110
|
doctrine: "SpexCode Doctrine synthesis internals are platform-only.",
|
|
1098
1111
|
bench: "SpexCode bench internals are platform-only.",
|
|
1112
|
+
server: "the spex-console dev server is part of the HeySpex source checkout; the hosted console is app.heyspex.com.",
|
|
1099
1113
|
});
|
|
1100
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* Is this `hs <verb> ... --help|-h` (a help flag after the verb, before any
|
|
1117
|
+
* `--` end-of-options marker)? Bare `hs --help` is handled before dispatch.
|
|
1118
|
+
* @param {string[]} argv the argv after the global --vault flag is removed
|
|
1119
|
+
*/
|
|
1120
|
+
export function isHelpInvocation(argv) {
|
|
1121
|
+
for (const a of argv.slice(1)) {
|
|
1122
|
+
if (a === "--") return false;
|
|
1123
|
+
if (a === "--help" || a === "-h") return true;
|
|
1124
|
+
}
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1101
1128
|
/**
|
|
1102
1129
|
* The NOT_IN_PACKAGE_VERBS key this invocation hits from an installed
|
|
1103
1130
|
* package, or null. Pure apart from `installed`.
|
|
@@ -1121,6 +1148,21 @@ export function notInPackageMessage(key) {
|
|
|
1121
1148
|
);
|
|
1122
1149
|
}
|
|
1123
1150
|
|
|
1151
|
+
/**
|
|
1152
|
+
* Does `hs seat <command>` go to the customer seat-assignment CLI
|
|
1153
|
+
* (vendor/heyspex-mcp/seat-assign-cli.mjs, epic HS-K9L7)? Installed package:
|
|
1154
|
+
* list|assignments|set|reset|defaults|report-fallback|help (and a bare `hs seat`).
|
|
1155
|
+
* Source checkout: the same minus `list`/bare, which stay the dev-factory
|
|
1156
|
+
* registry (`hs-seat.ts`).
|
|
1157
|
+
*/
|
|
1158
|
+
export function routesToSeatAssignCli(namespace, command, { installed = IS_INSTALLED_PACKAGE } = {}) {
|
|
1159
|
+
if (namespace !== "seat") return false;
|
|
1160
|
+
if (installed) {
|
|
1161
|
+
return command === undefined || ["list", "assignments", "set", "reset", "defaults", "report-fallback", "help", "--help", "-h"].includes(command);
|
|
1162
|
+
}
|
|
1163
|
+
return ["assignments", "set", "reset", "defaults", "report-fallback"].includes(command);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1124
1166
|
function main(argv) {
|
|
1125
1167
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
1126
1168
|
usage();
|
|
@@ -1152,6 +1194,16 @@ function main(argv) {
|
|
|
1152
1194
|
const command = argv[1];
|
|
1153
1195
|
const rest = argv.slice(2);
|
|
1154
1196
|
|
|
1197
|
+
// `hs seat list|set|reset|defaults|report-fallback` — the signed-in user's OWN
|
|
1198
|
+
// seat models (HS-K9L7). Installed package: these subcommands. Source checkout:
|
|
1199
|
+
// set|reset|defaults|assignments|report-fallback (list stays the registry).
|
|
1200
|
+
if (routesToSeatAssignCli(namespace, command)) {
|
|
1201
|
+
import(pathToFileURL(path.join(PKG_ROOT, "vendor", "heyspex-mcp", "seat-assign-cli.mjs")).href)
|
|
1202
|
+
.then((m) => m.runSeatAssignCli(argv.slice(1)))
|
|
1203
|
+
.then((code) => process.exit(code));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1155
1207
|
// A source-checkout-only verb from an installed package: say so before vault
|
|
1156
1208
|
// resolution can refuse with an unrelated binding error.
|
|
1157
1209
|
const missingVerb = notInPackageVerb(namespace, command);
|
|
@@ -1170,31 +1222,38 @@ function main(argv) {
|
|
|
1170
1222
|
}
|
|
1171
1223
|
}
|
|
1172
1224
|
|
|
1173
|
-
// `hs companion start|stop|status|build` from an installed package
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1225
|
+
// `hs companion start|stop|status|build` from an installed package (no
|
|
1226
|
+
// companion/ source in the tarball) and `hs companion install` anywhere are
|
|
1227
|
+
// machine-local and need no vault: run them BEFORE vault resolution can
|
|
1228
|
+
// refuse with "not bound to a vault", which sends a customer to fix a binding
|
|
1229
|
+
// that would not help. scripts/hs-companion.mjs drives an installed
|
|
1230
|
+
// SpexCompanion, or offers `npx heyspex companion install` when there is none
|
|
1231
|
+
// (HS-056P; this replaced the "not distributed through the npm package yet" answer).
|
|
1177
1232
|
const companionSub = companionHostWithoutSource(namespace, command);
|
|
1178
|
-
if (companionSub) {
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
);
|
|
1233
|
+
if (companionSub || (namespace === "companion" && command === "install")) {
|
|
1234
|
+
const companionArgs = command ? [command, ...rest] : ["start", ...rest];
|
|
1235
|
+
const child = spawn(process.execPath, [spawnTargetPath("companion"), ...companionArgs], {
|
|
1236
|
+
cwd: INVOKED_CWD,
|
|
1237
|
+
stdio: "inherit",
|
|
1238
|
+
});
|
|
1239
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
1240
|
+
child.on("error", (err) => {
|
|
1241
|
+
process.stderr.write(`hs companion: ${err.message}\n`);
|
|
1242
|
+
process.exit(1);
|
|
1243
|
+
});
|
|
1189
1244
|
return;
|
|
1190
1245
|
}
|
|
1191
1246
|
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1247
|
+
const resolved = resolveVaultRootForMain(process.cwd(), process.env);
|
|
1248
|
+
const { usedRegistryFallback, error: vaultError } = resolved;
|
|
1249
|
+
// `hs <verb> --help` must always print help and exit 0, bound vault or not
|
|
1250
|
+
// (install QC: ~28 verbs refused with VAULT_UNBOUND from an unbound folder).
|
|
1251
|
+
// Help never reads or writes a vault, so an unresolved vault falls back to the
|
|
1252
|
+
// invocation folder and the vault refusals below (broken pin, no vault,
|
|
1253
|
+
// uncloned satellite) are skipped; each verb's own handler prints its help.
|
|
1254
|
+
const helpRequested = isHelpInvocation(argv);
|
|
1255
|
+
const vaultRoot = resolved.vaultRoot || (helpRequested ? INVOKED_CWD : resolved.vaultRoot);
|
|
1256
|
+
const brokenPin = helpRequested ? null : resolved.brokenPin;
|
|
1198
1257
|
// The registry pointer is never consulted ([[HS-TYJA]]), so the only P1
|
|
1199
1258
|
// refusal left is a satellite pin that cannot be honoured; onboarding /
|
|
1200
1259
|
// registry verbs and `hs doctor` still run so the pin can be repaired.
|
|
@@ -1588,6 +1647,7 @@ function main(argv) {
|
|
|
1588
1647
|
// vault's files to disk must never scaffold specs/ into a code repo.
|
|
1589
1648
|
if (
|
|
1590
1649
|
["pull", "push", "index"].includes(namespace) &&
|
|
1650
|
+
!helpRequested &&
|
|
1591
1651
|
readMarkerSatellite(vaultRoot) &&
|
|
1592
1652
|
!existsSync(path.join(vaultRoot, "specs"))
|
|
1593
1653
|
) {
|
package/bin/spexcode.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { readFileSync, realpathSync } from "node:fs";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import process from "node:process";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
|
+
import { PRODUCT_SEATS, canonicalSeatId, defaultModelForSeat } from "../vendor/heyspex-mcp/seat-defaults.mjs";
|
|
37
|
+
import { detectHarness } from "../spexharness/src/adapters/harness-detect.mjs";
|
|
36
38
|
|
|
37
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
40
|
|
|
@@ -200,6 +202,29 @@ export function resolveSeatGuard(argv, seatsRegistry) {
|
|
|
200
202
|
};
|
|
201
203
|
}
|
|
202
204
|
|
|
205
|
+
/**
|
|
206
|
+
* HS-MDZB (epic HS-C65J): a customer install ships no dev-factory/seats.json (that file is the
|
|
207
|
+
* owner's own fleet). When it is ABSENT, a product seat id is allowed and runs on the harness
|
|
208
|
+
* default (Claude tiers under Claude Code, `auto` under Copilot); an unknown id is still refused.
|
|
209
|
+
* Pure: env in, a guard verdict plus a one-line notice out.
|
|
210
|
+
* @returns {{allowed: boolean, message?: string, notice?: string}}
|
|
211
|
+
*/
|
|
212
|
+
export function resolveSeatGuardWithoutRegistry(seatId, env = {}) {
|
|
213
|
+
const id = canonicalSeatId(seatId);
|
|
214
|
+
const known = PRODUCT_SEATS.some((s) => s.id === id);
|
|
215
|
+
if (!known) {
|
|
216
|
+
return {
|
|
217
|
+
allowed: false,
|
|
218
|
+
message: `spexcode: refusing --seat "${seatId}" — not a HeySpex seat (known: ${PRODUCT_SEATS.map((s) => s.id).join(", ")}).`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const d = defaultModelForSeat(id, detectHarness(env).harness);
|
|
222
|
+
const notice = d.model
|
|
223
|
+
? `spexcode: seat "${id}" uses the ${d.harness} default model "${d.model}".`
|
|
224
|
+
: `spexcode: seat "${id}" has no default model (no harness detected; set HEYSPEX_HARNESS=claude-code or copilot).`;
|
|
225
|
+
return { allowed: true, notice };
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
/** Load + JSON.parse `dev-factory/seats.json`. Isolated so tests can stub it. */
|
|
204
229
|
export function loadSeatsRegistry(seatsJsonPath = SEATS_JSON_PATH) {
|
|
205
230
|
return JSON.parse(readFileSync(seatsJsonPath, "utf8"));
|
|
@@ -273,6 +298,12 @@ export async function runSpexcode(argv, deps = {}) {
|
|
|
273
298
|
try {
|
|
274
299
|
registry = loadRegistry();
|
|
275
300
|
} catch (err) {
|
|
301
|
+
if (err && err.code === "ENOENT") {
|
|
302
|
+
const guard = resolveSeatGuardWithoutRegistry(seatId, deps.env ?? process.env);
|
|
303
|
+
stderr.write((guard.allowed ? guard.notice : guard.message) + "\n");
|
|
304
|
+
if (!guard.allowed) return 2;
|
|
305
|
+
return runForward(argv, { hsPath, spawnFn });
|
|
306
|
+
}
|
|
276
307
|
stderr.write(
|
|
277
308
|
`spexcode: could not read dev-factory/seats.json to check --seat "${seatId}": ` +
|
|
278
309
|
`${err && err.message ? err.message : err}\n`,
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-firecrawl.mjs — zero-config discovery of a self-hosted Firecrawl running in THIS machine's
|
|
3
|
+
* Docker, plus a minimal search/scrape client for it.
|
|
4
|
+
*
|
|
5
|
+
* Owner ruling 2026-10-17: research in a customer repo (the `heyspex` npm package) finds URLs
|
|
6
|
+
* through a self-hosted Firecrawl in the user's LOCAL Docker. "No ENV variable required. It will
|
|
7
|
+
* just see the Docker>Firecrawl on the local and it will utilize it." So this module never needs
|
|
8
|
+
* configuration:
|
|
9
|
+
*
|
|
10
|
+
* 1. optional override — HEYSPEX_FIRECRAWL_URL, then FIRECRAWL_BASE_URL (ignored when it points
|
|
11
|
+
* at the hosted api.firecrawl.dev: hosted credits are ask-before-spend, never automatic);
|
|
12
|
+
* 2. `docker ps` — the Firecrawl API container and the host port it publishes;
|
|
13
|
+
* 3. the self-hosted default, http://localhost:3002 (SELF_HOST.md in github.com/firecrawl/firecrawl).
|
|
14
|
+
*
|
|
15
|
+
* Every candidate is probed with a GET on its origin before use. Hosted Firecrawl is filtered out
|
|
16
|
+
* of every list, no API key is ever sent (a local stack runs with USE_DB_AUTHENTICATION=false),
|
|
17
|
+
* and nothing here throws: failures come back as `{ ok: false, reason }`.
|
|
18
|
+
*
|
|
19
|
+
* Node built-ins only — this file ships in the npm package and must not import SpexFleet code.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_LOCAL_FIRECRAWL_PORT = 3002;
|
|
24
|
+
export const DEFAULT_LOCAL_FIRECRAWL_BASE_URLS = Object.freeze([
|
|
25
|
+
`http://127.0.0.1:${DEFAULT_LOCAL_FIRECRAWL_PORT}/v2`,
|
|
26
|
+
`http://localhost:${DEFAULT_LOCAL_FIRECRAWL_PORT}/v2`,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** One-line setup instructions printed after every FIRECRAWL_UNAVAILABLE reason. */
|
|
30
|
+
export const LOCAL_FIRECRAWL_SETUP_HINT =
|
|
31
|
+
'Research needs a self-hosted Firecrawl running in your local Docker. Start Docker Desktop, then ' +
|
|
32
|
+
'start the Firecrawl stack: `git clone https://github.com/firecrawl/firecrawl.git && cd firecrawl`, ' +
|
|
33
|
+
'create `.env` in that folder with `PORT=3002` and `USE_DB_AUTHENTICATION=false`, then ' +
|
|
34
|
+
'`docker compose build && docker compose up -d` (official steps: SELF_HOST.md in ' +
|
|
35
|
+
'github.com/firecrawl/firecrawl). No API key or environment variable is needed: it is found ' +
|
|
36
|
+
'automatically on localhost:3002 or from `docker ps`.';
|
|
37
|
+
|
|
38
|
+
/** True for the hosted Firecrawl API (the thing that spends real credits). */
|
|
39
|
+
export function isHostedFirecrawlUrl(url) {
|
|
40
|
+
try {
|
|
41
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
42
|
+
return host === 'firecrawl.dev' || host.endsWith('.firecrawl.dev');
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Trim, drop trailing slashes, and add the `/v2` API path when the URL has no version path. */
|
|
49
|
+
export function normalizeFirecrawlBaseUrl(url) {
|
|
50
|
+
const raw = String(url ?? '').trim().replace(/\/+$/, '');
|
|
51
|
+
if (!raw) return '';
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(raw);
|
|
54
|
+
if (!/^https?:$/.test(u.protocol)) return '';
|
|
55
|
+
if (!/\/v\d+$/.test(u.pathname.replace(/\/+$/, ''))) u.pathname = `${u.pathname.replace(/\/+$/, '')}/v2`;
|
|
56
|
+
return u.toString().replace(/\/+$/, '');
|
|
57
|
+
} catch {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function originOf(baseUrl) {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(baseUrl).origin;
|
|
65
|
+
} catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── docker ps ───────────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const NON_API_SERVICE_RE = /playwright|postgres|redis|rabbit|searxng|worker/i;
|
|
73
|
+
|
|
74
|
+
/** Is this `docker ps` row the Firecrawl API container (not its playwright/db/queue siblings)? */
|
|
75
|
+
export function isFirecrawlApiContainer({ name = '', image = '' } = {}) {
|
|
76
|
+
const imageRepo = String(image).split('@')[0];
|
|
77
|
+
const lastSeg = imageRepo.split('/').pop().split(':')[0].toLowerCase();
|
|
78
|
+
const n = String(name).toLowerCase();
|
|
79
|
+
if (NON_API_SERVICE_RE.test(lastSeg)) return false;
|
|
80
|
+
if (lastSeg === 'firecrawl' || lastSeg === 'firecrawl-api' || lastSeg === 'firecrawl_api') return true;
|
|
81
|
+
// `docker compose build` from a checkout tags the image after the project; the container name
|
|
82
|
+
// (firecrawl-api-1 / firecrawl_api_1) still says what it is.
|
|
83
|
+
return /firecrawl/.test(n) && /(^|[-_])api([-_]\d+)?$/.test(n) && !NON_API_SERVICE_RE.test(n);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a `docker ps` Ports column, e.g. `0.0.0.0:3002->3002/tcp, [::]:3002->3002/tcp`, into
|
|
88
|
+
* published TCP mappings `{ hostIp, hostPort, containerPort }`. Unpublished ports (`6379/tcp`) are
|
|
89
|
+
* dropped. Ranges map their first port.
|
|
90
|
+
*/
|
|
91
|
+
export function parseDockerPorts(portsText) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const part of String(portsText ?? '').split(',')) {
|
|
94
|
+
const m = part.trim().match(/^(?:(\[[^\]]*\]|[^:\s]+):)?(\d+)(?:-\d+)?->(\d+)(?:-\d+)?\/tcp$/);
|
|
95
|
+
if (!m) continue;
|
|
96
|
+
out.push({ hostIp: m[1] ?? '', hostPort: Number(m[2]), containerPort: Number(m[3]) });
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hostForBinding(hostIp) {
|
|
102
|
+
const ip = String(hostIp).replace(/^\[|\]$/g, '');
|
|
103
|
+
if (!ip || ip === '0.0.0.0' || ip === '127.0.0.1') return '127.0.0.1';
|
|
104
|
+
if (ip === '::' || ip === '::1') return '[::1]';
|
|
105
|
+
return ip.includes(':') ? `[${ip}]` : ip;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse `docker ps --format '{{.Names}}\t{{.Image}}\t{{.Ports}}'` output into the Firecrawl API
|
|
110
|
+
* containers it lists, each with the base URLs its published ports reach.
|
|
111
|
+
*/
|
|
112
|
+
export function parseDockerPsFirecrawl(text) {
|
|
113
|
+
const rows = [];
|
|
114
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
115
|
+
if (!line.trim()) continue;
|
|
116
|
+
const [name = '', image = '', ports = ''] = line.split('\t');
|
|
117
|
+
if (!isFirecrawlApiContainer({ name, image })) continue;
|
|
118
|
+
const maps = parseDockerPorts(ports);
|
|
119
|
+
// The API listens on PORT (3002 by default) inside the container; prefer that mapping.
|
|
120
|
+
const preferred = maps.filter((p) => p.containerPort === DEFAULT_LOCAL_FIRECRAWL_PORT);
|
|
121
|
+
const ordered = [...preferred, ...maps.filter((p) => p.containerPort !== DEFAULT_LOCAL_FIRECRAWL_PORT)];
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const baseUrls = [];
|
|
124
|
+
for (const p of ordered) {
|
|
125
|
+
const u = `http://${hostForBinding(p.hostIp)}:${p.hostPort}/v2`;
|
|
126
|
+
if (seen.has(u)) continue;
|
|
127
|
+
seen.add(u);
|
|
128
|
+
baseUrls.push(u);
|
|
129
|
+
}
|
|
130
|
+
rows.push({ name: name.trim(), image: image.trim(), baseUrls });
|
|
131
|
+
}
|
|
132
|
+
return rows;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Default docker runner. Returns `{ status, stdout, stderr, error }` like spawnSync; never throws. */
|
|
136
|
+
export function runDockerPs() {
|
|
137
|
+
try {
|
|
138
|
+
const r = spawnSync('docker', ['ps', '--format', '{{.Names}}\t{{.Image}}\t{{.Ports}}'], {
|
|
139
|
+
encoding: 'utf8',
|
|
140
|
+
timeout: 8000,
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
});
|
|
143
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '', error: r.error ?? null };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { status: null, stdout: '', stderr: '', error: e };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ask Docker which Firecrawl API containers are running.
|
|
151
|
+
* @returns {{ state: 'missing'|'down'|'running', containers: Array<{name,image,baseUrls}>, detail?: string }}
|
|
152
|
+
*/
|
|
153
|
+
export function dockerFirecrawlContainers({ runDocker = runDockerPs } = {}) {
|
|
154
|
+
let r;
|
|
155
|
+
try {
|
|
156
|
+
r = runDocker();
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { state: 'missing', containers: [], detail: e?.message || String(e) };
|
|
159
|
+
}
|
|
160
|
+
if (r?.error) {
|
|
161
|
+
const code = r.error.code;
|
|
162
|
+
return { state: code === 'ENOENT' ? 'missing' : 'down', containers: [], detail: r.error.message || String(code) };
|
|
163
|
+
}
|
|
164
|
+
if (r?.status !== 0) {
|
|
165
|
+
return { state: 'down', containers: [], detail: String(r?.stderr || '').trim().split(/\r?\n/)[0] || `docker ps exited ${r?.status}` };
|
|
166
|
+
}
|
|
167
|
+
return { state: 'running', containers: parseDockerPsFirecrawl(r.stdout) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── probing ─────────────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* GET the origin of a candidate. `requireSignature` (used for the unconfirmed localhost:3002 guess)
|
|
174
|
+
* also requires the body to look like Firecrawl, so an unrelated service on that port is not used.
|
|
175
|
+
*/
|
|
176
|
+
export async function probeFirecrawlBase(baseUrl, { httpFetch = globalThis.fetch, timeoutMs = 3000, requireSignature = false } = {}) {
|
|
177
|
+
const origin = originOf(baseUrl);
|
|
178
|
+
if (!origin) return { up: false, reason: `invalid URL ${baseUrl}` };
|
|
179
|
+
try {
|
|
180
|
+
const res = await httpFetch(`${origin}/`, { method: 'GET', signal: AbortSignal.timeout(timeoutMs) });
|
|
181
|
+
if (!requireSignature) return { up: true, status: res?.status ?? 0 };
|
|
182
|
+
let body = '';
|
|
183
|
+
try {
|
|
184
|
+
body = typeof res?.text === 'function' ? await res.text() : JSON.stringify(await res.json());
|
|
185
|
+
} catch {
|
|
186
|
+
body = '';
|
|
187
|
+
}
|
|
188
|
+
if (/firecrawl|SCRAPERS-JS/i.test(body)) return { up: true, status: res?.status ?? 0 };
|
|
189
|
+
return { up: false, reason: `${origin} answered but is not a Firecrawl API` };
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return { up: false, reason: `${origin} not reachable (${e?.cause?.code || e?.name || e?.message || e})` };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Find a local self-hosted Firecrawl. Never throws, never returns a hosted URL.
|
|
197
|
+
* @returns {Promise<{ok:true, baseUrl:string, source:'override'|'docker'|'default', container?:string}
|
|
198
|
+
* | {ok:false, reason:string, docker:string}>}
|
|
199
|
+
*/
|
|
200
|
+
export async function detectLocalFirecrawl({ env = process.env, runDocker = runDockerPs, httpFetch = globalThis.fetch, timeoutMs = 3000 } = {}) {
|
|
201
|
+
const candidates = [];
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const notes = [];
|
|
204
|
+
const push = (url, source, extra = {}) => {
|
|
205
|
+
const u = normalizeFirecrawlBaseUrl(url);
|
|
206
|
+
if (!u || isHostedFirecrawlUrl(u) || seen.has(u)) return;
|
|
207
|
+
seen.add(u);
|
|
208
|
+
candidates.push({ baseUrl: u, source, ...extra });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
for (const key of ['HEYSPEX_FIRECRAWL_URL', 'FIRECRAWL_BASE_URL']) {
|
|
212
|
+
const v = String(env?.[key] ?? '').trim();
|
|
213
|
+
if (!v) continue;
|
|
214
|
+
if (isHostedFirecrawlUrl(v)) notes.push(`${key} points at hosted Firecrawl and was ignored`);
|
|
215
|
+
else push(v, 'override');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const docker = dockerFirecrawlContainers({ runDocker });
|
|
219
|
+
for (const c of docker.containers) {
|
|
220
|
+
for (const u of c.baseUrls) push(u, 'docker', { container: c.name });
|
|
221
|
+
}
|
|
222
|
+
for (const u of DEFAULT_LOCAL_FIRECRAWL_BASE_URLS) push(u, 'default');
|
|
223
|
+
|
|
224
|
+
const reasons = [];
|
|
225
|
+
for (const c of candidates) {
|
|
226
|
+
const probe = await probeFirecrawlBase(c.baseUrl, { httpFetch, timeoutMs, requireSignature: c.source === 'default' });
|
|
227
|
+
if (probe.up) return { ok: true, baseUrl: c.baseUrl, source: c.source, ...(c.container ? { container: c.container } : {}) };
|
|
228
|
+
reasons.push(probe.reason);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let headline;
|
|
232
|
+
if (docker.state === 'missing') headline = 'Docker is not installed or not on PATH';
|
|
233
|
+
else if (docker.state === 'down') headline = 'Docker is installed but not running';
|
|
234
|
+
else if (!docker.containers.length) headline = 'Docker is running but no Firecrawl API container is up';
|
|
235
|
+
else if (!docker.containers.some((c) => c.baseUrls.length)) headline = `Firecrawl container ${docker.containers[0].name} publishes no host port`;
|
|
236
|
+
else headline = `Firecrawl container ${docker.containers[0].name} is up but not answering yet (still starting? retry in a minute)`;
|
|
237
|
+
const detail = [...notes, ...reasons].join('; ');
|
|
238
|
+
return { ok: false, docker: docker.state, reason: `${headline}${detail ? ` (${detail})` : ''}` };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── search / scrape against a local instance (no API key, no credits) ─────────────────────────────
|
|
242
|
+
|
|
243
|
+
async function postJson(baseUrl, path, body, { httpFetch, timeoutMs }) {
|
|
244
|
+
const res = await httpFetch(`${baseUrl}${path}`, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify(body),
|
|
248
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
249
|
+
});
|
|
250
|
+
let json = null;
|
|
251
|
+
try {
|
|
252
|
+
json = await res.json();
|
|
253
|
+
} catch {
|
|
254
|
+
json = null;
|
|
255
|
+
}
|
|
256
|
+
return { res, json };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @returns {Promise<{ok:true, results:Array<{title,url,snippet}>} | {ok:false, error:string}>} */
|
|
260
|
+
export async function localFirecrawlSearch(baseUrl, query, { limit = 6, httpFetch = globalThis.fetch, timeoutMs = 45000 } = {}) {
|
|
261
|
+
if (isHostedFirecrawlUrl(baseUrl)) return { ok: false, error: 'refused: hosted Firecrawl endpoint' };
|
|
262
|
+
try {
|
|
263
|
+
const { res, json } = await postJson(baseUrl, '/search', { query, limit, sources: [{ type: 'web' }] }, { httpFetch, timeoutMs });
|
|
264
|
+
if (!res?.ok || json?.success === false) return { ok: false, error: json?.error || `HTTP ${res?.status ?? 0}` };
|
|
265
|
+
const rows = json?.data?.web ?? (Array.isArray(json?.data) ? json.data : []);
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
results: rows.map((r) => ({
|
|
269
|
+
title: r.title ?? '',
|
|
270
|
+
url: r.url ?? '',
|
|
271
|
+
snippet: r.description ?? (typeof r.markdown === 'string' ? r.markdown.slice(0, 300) : ''),
|
|
272
|
+
})),
|
|
273
|
+
};
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return { ok: false, error: e?.message || String(e) };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** @returns {Promise<{ok:true, title:string, markdown:string, status:number} | {ok:false, error:string}>} */
|
|
280
|
+
export async function localFirecrawlScrape(baseUrl, url, { httpFetch = globalThis.fetch, timeoutMs = 90000 } = {}) {
|
|
281
|
+
if (isHostedFirecrawlUrl(baseUrl)) return { ok: false, error: 'refused: hosted Firecrawl endpoint' };
|
|
282
|
+
try {
|
|
283
|
+
const { res, json } = await postJson(baseUrl, '/scrape', { url, formats: ['markdown'], onlyMainContent: true }, { httpFetch, timeoutMs });
|
|
284
|
+
if (!res?.ok || json?.success === false) return { ok: false, error: json?.error || `HTTP ${res?.status ?? 0}` };
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
title: json?.data?.metadata?.title ?? '',
|
|
288
|
+
markdown: json?.data?.markdown ?? '',
|
|
289
|
+
status: json?.data?.metadata?.statusCode ?? res?.status ?? 0,
|
|
290
|
+
};
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return { ok: false, error: e?.message || String(e) };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -45,6 +45,7 @@ import { readFileSync } from 'node:fs';
|
|
|
45
45
|
import path from 'node:path';
|
|
46
46
|
import { fileURLToPath } from 'node:url';
|
|
47
47
|
import { resolveCooModeEnabled } from './fleet-readiness.mjs';
|
|
48
|
+
import { isDevFactoryOverlayOpen } from '../../vendor/heyspex-mcp/dev-factory-overlay.mjs';
|
|
48
49
|
|
|
49
50
|
/** Repo root: honor CLAUDE_PROJECT_DIR, else two dirs up from dev-factory/lib. */
|
|
50
51
|
export function repoRoot() {
|
|
@@ -52,6 +53,19 @@ export function repoRoot() {
|
|
|
52
53
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The AMBIENT repo root — the one a caller gets when it passes no root — or null when the
|
|
58
|
+
* owner dev-factory overlay is closed there ([[HS-R475-C86Z-TASK-load-the-owner-dev-factory-overlay-only]]).
|
|
59
|
+
* repoRoot() honours CLAUDE_PROJECT_DIR, so in another developer's repo (or an installed
|
|
60
|
+
* package) this is null and seats.json is never read: the owner's seats never inject into a
|
|
61
|
+
* customer session. An EXPLICIT root is the caller's own choice (fixtures, sync-seat-context)
|
|
62
|
+
* and is not re-gated.
|
|
63
|
+
*/
|
|
64
|
+
export function ambientOverlayRoot(env = process.env) {
|
|
65
|
+
const root = repoRoot();
|
|
66
|
+
return isDevFactoryOverlayOpen(root, { env }) ? root : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
/** The single registry file every front reads. Relative to the repo root. */
|
|
56
70
|
export const SEATS_REGISTRY_RELATIVE_PATH = path.join('dev-factory', 'seats.json');
|
|
57
71
|
|
|
@@ -80,9 +94,11 @@ export function seatsRegistryPath(root = repoRoot()) {
|
|
|
80
94
|
* @param {string} [root]
|
|
81
95
|
* @returns {object|null}
|
|
82
96
|
*/
|
|
83
|
-
export function loadSeatRegistry(root =
|
|
97
|
+
export function loadSeatRegistry(root = undefined) {
|
|
98
|
+
const r = root ?? ambientOverlayRoot();
|
|
99
|
+
if (!r) return null;
|
|
84
100
|
try {
|
|
85
|
-
return JSON.parse(readFileSync(seatsRegistryPath(
|
|
101
|
+
return JSON.parse(readFileSync(seatsRegistryPath(r), 'utf8'));
|
|
86
102
|
} catch {
|
|
87
103
|
return null;
|
|
88
104
|
}
|
|
@@ -144,7 +160,7 @@ export function seatReferenceLine(seats) {
|
|
|
144
160
|
* text: string|null,
|
|
145
161
|
* }}
|
|
146
162
|
*/
|
|
147
|
-
export function buildSeatContextModel({ root
|
|
163
|
+
export function buildSeatContextModel({ root, registry } = {}) {
|
|
148
164
|
const reg = registry === undefined ? loadSeatRegistry(root) : registry;
|
|
149
165
|
if (!reg) {
|
|
150
166
|
return { registry: null, enabled: false, reason: 'registry missing or corrupt', inject: [], reference: [], text: null };
|
|
@@ -194,6 +210,6 @@ export function buildSeatContextModel({ root = repoRoot(), registry } = {}) {
|
|
|
194
210
|
* @param {string} [root] repo root (defaults to repoRoot())
|
|
195
211
|
* @returns {string|null}
|
|
196
212
|
*/
|
|
197
|
-
export function buildSeatContext(root =
|
|
213
|
+
export function buildSeatContext(root = undefined) {
|
|
198
214
|
return buildSeatContextModel({ root }).text;
|
|
199
215
|
}
|