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.
Files changed (85) hide show
  1. package/bin/hs.mjs +85 -25
  2. package/bin/spexcode.mjs +31 -0
  3. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  4. package/dev-factory/lib/seat-context.mjs +20 -4
  5. package/dev-factory/model-catalog.json +222 -222
  6. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  7. package/dist/cli/hs-connect.mjs +20 -2
  8. package/dist/cli/hs-defer.mjs +19 -1
  9. package/dist/cli/hs-index-rebuild.mjs +19 -1
  10. package/dist/cli/hs-index-reconcile.mjs +19 -1
  11. package/dist/cli/hs-init.mjs +2 -2
  12. package/dist/cli/hs-install.mjs +2 -2
  13. package/dist/cli/hs-join.mjs +20 -2
  14. package/dist/cli/hs-next-launch.mjs +89 -10
  15. package/dist/cli/hs-spawn.mjs +27 -1
  16. package/dist/cli/hs-vault-pull.mjs +19 -1
  17. package/dist/cli/hs-vault-push.mjs +19 -1
  18. package/dist/cli/hs-vault.mjs +21 -7
  19. package/dist/cli/hs-workspace.mjs +19 -1
  20. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  21. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  22. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  23. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  24. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  25. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  26. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  27. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  28. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  29. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  30. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  31. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  32. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  33. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  34. package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
  35. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  36. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  37. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  38. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  39. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  40. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  41. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  42. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  43. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  44. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  45. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  46. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  47. package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
  48. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  49. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  50. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  51. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  52. package/docs/copilot-plugin-install.md +7 -0
  53. package/docs/mcp-client-config.md +6 -0
  54. package/package.json +7 -2
  55. package/plugins/heyspex/README.md +6 -0
  56. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  57. package/plugins/heyspex/hooks/hooks.json +418 -418
  58. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  59. package/scripts/hs-companion.mjs +38 -8
  60. package/scripts/lib/companion-install.mjs +768 -0
  61. package/scripts/lib/customer-kit.mjs +5 -3
  62. package/scripts/lib/hs-companion-settings.mjs +1 -0
  63. package/scripts/lib/hs-source-push.mjs +386 -386
  64. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  65. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  66. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  67. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  68. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  69. package/spexharness/src/seat-context.mjs +10 -5
  70. package/spexharness/src/session-bootstrap.mjs +6 -3
  71. package/vendor/heyspex-mcp/cli.mjs +6 -1
  72. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  73. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  74. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  75. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  76. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  77. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  78. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  79. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  80. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  81. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  82. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  83. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  84. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  85. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
@@ -0,0 +1,768 @@
1
+ /**
2
+ * `npx heyspex companion install` and the installed-mode Companion
3
+ * ([[HS-056P-EH9T-EPIC-npx-heyspex-companion-install-front-door]], research
4
+ * [[HS-AUY9-EH9T-RES-spexcompanion-windows-installer]]).
5
+ *
6
+ * `npm install heyspex` stays install-script-free (HS-6UWZ). This module is the
7
+ * explicit, user-run step after it:
8
+ *
9
+ * 1. platform gate Windows x64 only; anywhere else a notice, exit 0, no fetch
10
+ * 2. resolve latest the published manifest (schema owned by HS-CA6B:
11
+ * {version,url,sha256,size,signed,gpuPackage}), read tolerantly
12
+ * 3. download SpexCompanionSetup.exe to a temp folder, hashing as it streams
13
+ * 4. verify SHA-256 (+ size) against the manifest, THEN the Authenticode
14
+ * signature (Get-AuthenticodeSignature via powershell
15
+ * -NoProfile): status Valid and the expected publisher. A hash
16
+ * from the same server only catches corruption; the signature
17
+ * is what catches a tampered manifest.
18
+ * 5. launch interactive wizard by default; --quiet passes the silent
19
+ * flags; waits and passes on the setup's exit code
20
+ *
21
+ * Installed mode (no companion/ source beside the package): detect a Companion
22
+ * the setup installed (HKCU uninstall entry, else %LOCALAPPDATA%\HeySpex\Companion)
23
+ * and drive that exe for `hs companion start|stop|status`; when none is
24
+ * installed, offer the install instead of a dead end.
25
+ *
26
+ * Every side effect (platform, env, fetch, registry, fs probes, powershell,
27
+ * spawn) is injectable, so the tests run a local HTTP fixture, a fake exe and a
28
+ * mocked signature check -- nothing here ever needs a real installer.
29
+ */
30
+ import { spawn, spawnSync } from "node:child_process";
31
+ import { createHash } from "node:crypto";
32
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { Readable } from "node:stream";
36
+ import { pipeline } from "node:stream/promises";
37
+ import { fileURLToPath, pathToFileURL } from "node:url";
38
+
39
+ /** Where `hs` looks for the latest release when nothing overrides it. */
40
+ export const DEFAULT_MANIFEST_URL = "https://app.heyspex.com/downloads/heyspex-companion-latest.json";
41
+
42
+ /**
43
+ * The Authenticode publisher (certificate subject CN) a setup must be signed
44
+ * by. HS-CA6B owns the signing identity; until it names the validated
45
+ * organisation this is the product name, and HEYSPEX_COMPANION_PUBLISHER
46
+ * overrides it on a machine (never the manifest -- a tampered manifest must not
47
+ * be able to name its own publisher).
48
+ */
49
+ export const DEFAULT_EXPECTED_PUBLISHER = "HeySpex";
50
+
51
+ /**
52
+ * Silent flags for --quiet. A WiX Burn bundle takes the standard /quiet and
53
+ * /norestart switches; the setup's own public properties (HS-G9YI) go after
54
+ * `--` on the command line, or come from the manifest's `quietArgs`.
55
+ */
56
+ export const DEFAULT_QUIET_ARGS = Object.freeze(["/quiet", "/norestart"]);
57
+
58
+ /** Exe names an installed Companion may carry (HS-JAX3 owns the layout). */
59
+ export const INSTALLED_EXE_NAMES = Object.freeze(["HeySpex.Companion.Host.exe", "SpexCompanion.exe"]);
60
+
61
+ export const UNINSTALL_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall";
62
+ const DISPLAY_NAME_RE = /spex\s*companion|heyspex\s+companion/i;
63
+
64
+ export const INSTALL_COMMAND = "npx heyspex companion install";
65
+
66
+ /**
67
+ * What `hs companion install` says when there is no published installer yet:
68
+ * the release manifest is missing (HTTP 404) or names no SpexCompanionSetup.exe.
69
+ * Exit 1, never a crash ([[HS-5HM6-EH9T-DIAG-companion-install-dry-run-native]]).
70
+ */
71
+ export const NO_INSTALLER_PUBLISHED_MESSAGE =
72
+ "hs companion install: no SpexCompanion installer has been published yet -- the Windows installer is coming (HS-4GXH).";
73
+
74
+ const SHA256_RE = /^[0-9a-f]{64}$/;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Text
78
+
79
+ /** The notice every non-Windows (or non-x64) platform gets instead of a download. */
80
+ export function renderWindowsOnlyNotice() {
81
+ return [
82
+ "SpexCompanion is Windows-only (x64). The heyspex CLI, MCP and hooks work on this OS without it.",
83
+ "Without it: run `npx hs pull --apply` to bring cloud-written specs down (the Companion's",
84
+ "automatic vault mirror), and SpexVoice / SpexCrawl research gather are not available.",
85
+ ].join("\n");
86
+ }
87
+
88
+ /**
89
+ * What `hs companion <sub>` prints from an installed package when no Companion
90
+ * is installed -- the install offer that replaced "not distributed through the
91
+ * npm package yet".
92
+ * @param {string} sub
93
+ * @param {{ platform?: string, arch?: string }} [opts]
94
+ */
95
+ export function renderCompanionInstallOffer(sub, { platform = process.platform, arch = process.arch } = {}) {
96
+ if (!isSupportedPlatform(platform, arch)) {
97
+ return `hs companion ${sub}: ${renderWindowsOnlyNotice()}`;
98
+ }
99
+ return [
100
+ `hs companion ${sub}: SpexCompanion (the desktop tray app) is not installed on this machine.`,
101
+ "",
102
+ "Install it with:",
103
+ ` ${INSTALL_COMMAND} (opens the Windows setup wizard)`,
104
+ ` ${INSTALL_COMMAND} --quiet (silent install with the default choices)`,
105
+ "",
106
+ "It downloads the signed SpexCompanionSetup.exe, checks its SHA-256 and signature, then",
107
+ "runs it. No admin rights are needed. Until then: `npx hs pull --apply` brings cloud-written",
108
+ "specs down; `hs companion settings ...` (console-backed) works without it.",
109
+ ].join("\n");
110
+ }
111
+
112
+ export function renderInstallHelp() {
113
+ return [
114
+ "Usage:",
115
+ ` ${INSTALL_COMMAND} [--quiet] [--dry-run] [--force] [--manifest <url|path>] [--keep] [-- <setup args>]`,
116
+ "",
117
+ "Downloads the signed SpexCompanionSetup.exe named by the latest-release manifest, checks",
118
+ "its SHA-256 and its Authenticode signature (status Valid, expected publisher), then runs",
119
+ "it. Windows x64 only; on other platforms it prints a notice and downloads nothing.",
120
+ "",
121
+ " --quiet Silent install with the default choices (passes /quiet /norestart)",
122
+ " --dry-run Resolve, download and verify, but never launch the setup",
123
+ " --force Reinstall even when the installed version is current",
124
+ " --manifest <url|path> Read this manifest instead of the published one",
125
+ " (env: HEYSPEX_COMPANION_MANIFEST_URL)",
126
+ " --keep Keep the downloaded setup file afterwards",
127
+ " -- <setup args> Passed to the setup unchanged (e.g. its public properties)",
128
+ "",
129
+ "Expected publisher: HEYSPEX_COMPANION_PUBLISHER overrides the default certificate CN.",
130
+ ].join("\n");
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Pure helpers
135
+
136
+ export function isSupportedPlatform(platform = process.platform, arch = process.arch) {
137
+ return platform === "win32" && arch === "x64";
138
+ }
139
+
140
+ /** Numeric dotted-version compare: <0, 0, >0. Non-numeric parts compare as 0. */
141
+ export function compareVersions(a, b) {
142
+ const pa = String(a ?? "").replace(/^v/i, "").split(/[.+-]/).map((x) => Number.parseInt(x, 10) || 0);
143
+ const pb = String(b ?? "").replace(/^v/i, "").split(/[.+-]/).map((x) => Number.parseInt(x, 10) || 0);
144
+ for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
145
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
146
+ if (d) return d;
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ /**
152
+ * Parse `hs companion install` argv.
153
+ * @returns {{ quiet:boolean, dryRun:boolean, force:boolean, keep:boolean, help:boolean, manifest:string|null, setupArgs:string[], error?:string }}
154
+ */
155
+ export function parseInstallArgs(argv) {
156
+ const out = { quiet: false, dryRun: false, force: false, keep: false, help: false, manifest: null, setupArgs: [] };
157
+ for (let i = 0; i < argv.length; i++) {
158
+ const a = argv[i];
159
+ if (a === "--") {
160
+ out.setupArgs = argv.slice(i + 1);
161
+ break;
162
+ }
163
+ if (a === "--quiet" || a === "-q" || a === "--silent") out.quiet = true;
164
+ else if (a === "--dry-run") out.dryRun = true;
165
+ else if (a === "--force") out.force = true;
166
+ else if (a === "--keep") out.keep = true;
167
+ else if (a === "--help" || a === "-h" || a === "help") out.help = true;
168
+ else if (a === "--manifest") {
169
+ const v = argv[++i];
170
+ if (!v) return { ...out, error: "--manifest needs a url or path" };
171
+ out.manifest = v;
172
+ } else if (a.startsWith("--manifest=")) out.manifest = a.slice("--manifest=".length);
173
+ else return { ...out, error: `unknown argument ${a}` };
174
+ }
175
+ return out;
176
+ }
177
+
178
+ function pick(obj, ...keys) {
179
+ for (const k of keys) {
180
+ if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ function looksLikeExeUrl(u) {
186
+ if (typeof u !== "string" || !u) return false;
187
+ try {
188
+ return /\.exe$/i.test(new URL(u, "http://x/").pathname);
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /** The manifest object that describes the installer, wherever HS-CA6B put it. */
195
+ function findInstallerEntry(m) {
196
+ if (!m || typeof m !== "object") return null;
197
+ const candidates = [
198
+ m.installer,
199
+ m.windows_installer,
200
+ m.windowsInstaller,
201
+ m.companion_installer,
202
+ m.setup,
203
+ m.windows_companion?.installer,
204
+ m.windows_companion?.setup,
205
+ m.windows_companion,
206
+ m.windows,
207
+ m,
208
+ ];
209
+ for (const c of candidates) {
210
+ if (c && typeof c === "object" && looksLikeExeUrl(pick(c, "url", "download_url", "downloadUrl"))) return c;
211
+ }
212
+ return null;
213
+ }
214
+
215
+ function normalizeGpuPackage(g, baseUrl) {
216
+ if (!g || typeof g !== "object") return null;
217
+ const url = pick(g, "url", "download_url", "downloadUrl");
218
+ const sha = pick(g, "sha256", "sha_256", "checksum");
219
+ return {
220
+ version: pick(g, "version") ?? null,
221
+ url: typeof url === "string" ? safeResolve(url, baseUrl) : null,
222
+ sha256: typeof sha === "string" ? sha.toLowerCase() : null,
223
+ size: Number(pick(g, "size", "bytes", "size_bytes")) || null,
224
+ };
225
+ }
226
+
227
+ function safeResolve(u, base) {
228
+ try {
229
+ return new URL(u, base).href;
230
+ } catch {
231
+ return u;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Normalise the published manifest into one installer description. Tolerant
237
+ * of where the entry sits and of key spellings (size|bytes, sha256|sha_256,
238
+ * url|download_url); strict about what makes it safe to run.
239
+ * @param {unknown} manifest parsed JSON
240
+ * @param {string} manifestUrl the URL it came from (relative urls resolve against it)
241
+ * @returns {{ ok: true, installer: { version: string|null, url: string, sha256: string, size: number|null, signed: boolean|null, filename: string, quietArgs: string[]|null, gpuPackage: object|null } } | { ok: false, error: string }}
242
+ */
243
+ export function parseInstallerManifest(manifest, manifestUrl) {
244
+ const e = findInstallerEntry(manifest);
245
+ if (!e) {
246
+ return {
247
+ ok: false,
248
+ error:
249
+ "the release manifest has no SpexCompanionSetup.exe entry yet -- the signed Windows setup " +
250
+ "has not been published (release pipeline HS-CA6B).",
251
+ };
252
+ }
253
+ const rawUrl = pick(e, "url", "download_url", "downloadUrl");
254
+ const url = safeResolve(rawUrl, manifestUrl);
255
+ let proto;
256
+ try {
257
+ proto = new URL(url).protocol;
258
+ } catch {
259
+ return { ok: false, error: `installer url is not a valid URL: ${rawUrl}` };
260
+ }
261
+ const allowHttp = proto === "http:" && isLoopbackUrl(url);
262
+ if (proto !== "https:" && proto !== "file:" && !allowHttp) {
263
+ return { ok: false, error: `installer url must be https (got ${proto}//): ${url}` };
264
+ }
265
+ const sha = String(pick(e, "sha256", "sha_256", "sha256sum", "checksum") ?? "").trim().toLowerCase().replace(/^sha256:/, "");
266
+ if (!SHA256_RE.test(sha)) {
267
+ return { ok: false, error: "installer entry has no valid sha256 (64 hex characters); refusing to download." };
268
+ }
269
+ const sizeRaw = pick(e, "size", "bytes", "size_bytes", "sizeBytes");
270
+ const size = sizeRaw === undefined ? null : Number(sizeRaw);
271
+ const signedRaw = pick(e, "signed", "authenticode");
272
+ const quietRaw = pick(e, "quietArgs", "quiet_args", "silentArgs", "silent_args");
273
+ const quietArgs = Array.isArray(quietRaw) && quietRaw.every((x) => typeof x === "string") ? [...quietRaw] : null;
274
+ let filename = pick(e, "filename", "file");
275
+ if (typeof filename !== "string" || !filename) {
276
+ filename = decodeURIComponent(new URL(url).pathname.split("/").pop() || "SpexCompanionSetup.exe");
277
+ }
278
+ filename = path.basename(filename.replace(/\\/g, "/"));
279
+ if (!/\.exe$/i.test(filename)) filename = "SpexCompanionSetup.exe";
280
+ return {
281
+ ok: true,
282
+ installer: {
283
+ version: pick(e, "version") != null ? String(pick(e, "version")) : manifest?.version != null && e === manifest ? String(manifest.version) : null,
284
+ url,
285
+ sha256: sha,
286
+ size: Number.isFinite(size) && size > 0 ? size : null,
287
+ signed: typeof signedRaw === "boolean" ? signedRaw : signedRaw && typeof signedRaw === "object" ? true : null,
288
+ filename,
289
+ quietArgs,
290
+ gpuPackage: normalizeGpuPackage(pick(e, "gpuPackage", "gpu_package", "gpu"), url),
291
+ },
292
+ };
293
+ }
294
+
295
+ function isLoopbackUrl(u) {
296
+ try {
297
+ const h = new URL(u).hostname;
298
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]";
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /** The CN out of an X.500 subject ("CN=HeySpex, O=HeySpex, C=US"). */
305
+ export function subjectCommonName(subject) {
306
+ if (typeof subject !== "string") return null;
307
+ const m = subject.match(/(?:^|,\s*)CN=(?:"((?:[^"]|"")*)"|([^,]*))/i);
308
+ if (!m) return null;
309
+ return (m[1] !== undefined ? m[1].replace(/""/g, '"') : m[2]).trim();
310
+ }
311
+
312
+ /**
313
+ * Decide a signature verdict. Pure.
314
+ * @param {{ status?: string, subject?: string|null, message?: string }|null} sig
315
+ * @param {string} expectedPublisher compared case-insensitively against the subject CN
316
+ */
317
+ export function judgeSignature(sig, expectedPublisher) {
318
+ if (!sig || typeof sig !== "object") return { ok: false, reason: "signature check returned nothing" };
319
+ if (sig.status !== "Valid") {
320
+ return { ok: false, reason: `Authenticode status is ${sig.status || "unknown"}${sig.message ? ` (${sig.message})` : ""}` };
321
+ }
322
+ const cn = subjectCommonName(sig.subject);
323
+ if (!cn || cn.toLowerCase() !== String(expectedPublisher).trim().toLowerCase()) {
324
+ return { ok: false, reason: `signed by "${cn ?? sig.subject ?? "unknown"}", expected publisher "${expectedPublisher}"` };
325
+ }
326
+ return { ok: true, publisher: cn, subject: sig.subject };
327
+ }
328
+
329
+ /** Setup argv for a launch. */
330
+ export function buildSetupArgs({ quiet = false, quietArgs = null, setupArgs = [] } = {}) {
331
+ const base = quiet ? [...(quietArgs ?? DEFAULT_QUIET_ARGS)] : [];
332
+ return [...base, ...setupArgs];
333
+ }
334
+
335
+ /** Map a Burn / Windows Installer exit code to an outcome. */
336
+ export function describeSetupExit(code) {
337
+ if (code === 0) return { ok: true, text: "SpexCompanion setup finished." };
338
+ if (code === 3010 || code === 1641) return { ok: true, text: "SpexCompanion setup finished; Windows asks for a restart to complete it." };
339
+ if (code === 1602 || code === 1223) return { ok: false, text: "SpexCompanion setup was cancelled." };
340
+ return { ok: false, text: `SpexCompanion setup exited with code ${code}.` };
341
+ }
342
+
343
+ /** Parse `reg query <UNINSTALL_KEY> /s` output into the SpexCompanion entry, if any. */
344
+ export function parseUninstallRegQuery(text) {
345
+ if (typeof text !== "string" || !text) return null;
346
+ const blocks = text.split(/\r?\n(?=HKEY_)/);
347
+ for (const block of blocks) {
348
+ const values = {};
349
+ for (const line of block.split(/\r?\n/)) {
350
+ const m = line.match(/^\s+(\S[^\t]*?)\s{2,}(REG_\w+)\s{2,}(.*)$/) || line.match(/^\s+(\S+)\s+(REG_\w+)\s+(.*)$/);
351
+ if (m) values[m[1].trim()] = m[3].trim();
352
+ }
353
+ if (values.DisplayName && DISPLAY_NAME_RE.test(values.DisplayName)) {
354
+ return {
355
+ key: block.split(/\r?\n/)[0].trim(),
356
+ displayName: values.DisplayName,
357
+ version: values.DisplayVersion || null,
358
+ installLocation: values.InstallLocation ? values.InstallLocation.replace(/^"|"$/g, "") : null,
359
+ displayIcon: values.DisplayIcon ? values.DisplayIcon.replace(/^"|"$/g, "").replace(/,\d+$/, "") : null,
360
+ };
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Installed-mode detection
368
+
369
+ function defaultRegQuery() {
370
+ const r = spawnSync("reg", ["query", UNINSTALL_KEY, "/s"], { encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
371
+ return r.status === 0 ? r.stdout : "";
372
+ }
373
+
374
+ /** %LOCALAPPDATA%\HeySpex\Companion (null when LOCALAPPDATA is unknown). */
375
+ export function defaultInstallDir(env = process.env) {
376
+ const base = env.LOCALAPPDATA || (env.USERPROFILE ? path.win32.join(env.USERPROFILE, "AppData", "Local") : null);
377
+ return base ? path.win32.join(base, "HeySpex", "Companion") : null;
378
+ }
379
+
380
+ /**
381
+ * Find a SpexCompanion installed by the setup. Registry first (it carries the
382
+ * version), then the default install folder.
383
+ * @param {{ platform?: string, env?: NodeJS.ProcessEnv, regQuery?: () => string, exists?: (p: string) => boolean }} [deps]
384
+ * @returns {{ installed: boolean, exePath?: string, installDir?: string, version?: string|null, source?: "registry"|"folder" }}
385
+ */
386
+ export function detectInstalledCompanion({ platform = process.platform, env = process.env, regQuery = defaultRegQuery, exists = existsSync } = {}) {
387
+ if (platform !== "win32") return { installed: false };
388
+ let reg = null;
389
+ try {
390
+ reg = parseUninstallRegQuery(regQuery());
391
+ } catch {
392
+ reg = null;
393
+ }
394
+ const dirs = [];
395
+ if (reg?.installLocation) dirs.push(reg.installLocation);
396
+ const def = defaultInstallDir(env);
397
+ if (def) dirs.push(def);
398
+ if (reg?.displayIcon && /\.exe$/i.test(reg.displayIcon) && exists(reg.displayIcon) && INSTALLED_EXE_NAMES.some((n) => n.toLowerCase() === path.win32.basename(reg.displayIcon).toLowerCase())) {
399
+ return { installed: true, exePath: reg.displayIcon, installDir: path.win32.dirname(reg.displayIcon), version: reg.version, source: "registry" };
400
+ }
401
+ for (const dir of dirs) {
402
+ for (const name of INSTALLED_EXE_NAMES) {
403
+ const exePath = path.win32.join(dir, name);
404
+ if (exists(exePath)) {
405
+ return { installed: true, exePath, installDir: dir, version: reg?.version ?? null, source: reg ? "registry" : "folder" };
406
+ }
407
+ }
408
+ }
409
+ return { installed: false, registryEntry: reg ?? undefined };
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Effects (default implementations; all injectable)
414
+
415
+ /** Load the manifest from an http(s) URL, a file: URL or a local path. */
416
+ export async function loadManifest(source, { fetchImpl = globalThis.fetch } = {}) {
417
+ if (/^https?:\/\//i.test(source)) {
418
+ const res = await fetchImpl(source, { headers: { accept: "application/json" } });
419
+ if (!res.ok) {
420
+ // Release the unread body so the pooled socket closes now, not at exit.
421
+ try {
422
+ await res.body?.cancel?.();
423
+ } catch {
424
+ /* already closed */
425
+ }
426
+ const e = new Error(`manifest fetch failed: HTTP ${res.status} from ${source}`);
427
+ e.status = res.status;
428
+ throw e;
429
+ }
430
+ return { json: await res.json(), url: source };
431
+ }
432
+ const file = /^file:/i.test(source) ? fileURLToPath(source) : path.resolve(source);
433
+ return { json: JSON.parse(readFileSync(file, "utf8")), url: pathToFileURL(file).href };
434
+ }
435
+
436
+ /**
437
+ * Stream `url` to `dest`, hashing as it goes. Returns { sha256, bytes }.
438
+ * @param {(p: { received: number, total: number|null }) => void} [onProgress]
439
+ */
440
+ export async function downloadWithHash(url, dest, { fetchImpl = globalThis.fetch, onProgress } = {}) {
441
+ mkdirSync(path.dirname(dest), { recursive: true });
442
+ const hash = createHash("sha256");
443
+ let received = 0;
444
+ let source;
445
+ let total = null;
446
+ if (/^file:/i.test(url)) {
447
+ const file = fileURLToPath(url);
448
+ total = statSync(file).size;
449
+ source = createReadStream(file);
450
+ } else {
451
+ const res = await fetchImpl(url);
452
+ if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status} from ${url}`);
453
+ total = Number(res.headers?.get?.("content-length")) || null;
454
+ source = Readable.fromWeb(res.body);
455
+ }
456
+ source.on("data", (chunk) => {
457
+ hash.update(chunk);
458
+ received += chunk.length;
459
+ onProgress?.({ received, total });
460
+ });
461
+ await pipeline(source, createWriteStream(dest));
462
+ return { sha256: hash.digest("hex"), bytes: received };
463
+ }
464
+
465
+ export const SIGNATURE_PS =
466
+ "$ErrorActionPreference = 'Stop'; " +
467
+ "$s = Get-AuthenticodeSignature -LiteralPath $env:HEYSPEX_SIG_PATH; " +
468
+ "[pscustomobject]@{ " +
469
+ "status = $s.Status.ToString(); " +
470
+ "subject = $(if ($s.SignerCertificate) { $s.SignerCertificate.Subject } else { $null }); " +
471
+ "message = $s.StatusMessage; " +
472
+ "timestamped = [bool]$s.TimeStamperCertificate " +
473
+ "} | ConvertTo-Json -Compress";
474
+
475
+ /** process.env minus PSModulePath (any casing). */
476
+ export function withoutPsModulePath(env) {
477
+ const copy = { ...env };
478
+ for (const k of Object.keys(copy)) if (k.toLowerCase() === "psmodulepath") delete copy[k];
479
+ return copy;
480
+ }
481
+
482
+ /**
483
+ * Get-AuthenticodeSignature via Windows PowerShell. The path travels in an env
484
+ * var, never interpolated into the command.
485
+ * @returns {{ status: string, subject: string|null, message?: string, timestamped?: boolean }}
486
+ */
487
+ export function readAuthenticodeSignature(file, { spawnSyncImpl = spawnSync, powershell = "powershell.exe" } = {}) {
488
+ const r = spawnSyncImpl(powershell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", SIGNATURE_PS], {
489
+ encoding: "utf8",
490
+ windowsHide: true,
491
+ // A PSModulePath inherited from PowerShell 7 makes Windows PowerShell 5.1
492
+ // load the wrong Microsoft.PowerShell.Security and the cmdlet fails to load.
493
+ env: { ...withoutPsModulePath(process.env), HEYSPEX_SIG_PATH: file },
494
+ });
495
+ if (r.error) return { status: "CheckFailed", subject: null, message: r.error.message };
496
+ const line = String(r.stdout || "").trim().split(/\r?\n/).filter(Boolean).pop();
497
+ try {
498
+ return JSON.parse(line);
499
+ } catch {
500
+ return { status: "CheckFailed", subject: null, message: (r.stderr || "no output").trim().slice(0, 300) };
501
+ }
502
+ }
503
+
504
+ /** Run the setup, wait, resolve its exit code. */
505
+ export function launchSetup(exe, args, { spawnImpl = spawn } = {}) {
506
+ return new Promise((resolve, reject) => {
507
+ const child = spawnImpl(exe, args, { stdio: "inherit", windowsHide: false });
508
+ child.on("error", reject);
509
+ child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 0)));
510
+ });
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Orchestrator
515
+
516
+ /**
517
+ * `hs companion install`. Returns the process exit code; prints via out/err.
518
+ * @param {string[]} argv
519
+ * @param {object} [deps]
520
+ */
521
+ export async function runCompanionInstall(argv, deps = {}) {
522
+ const {
523
+ platform = process.platform,
524
+ arch = process.arch,
525
+ env = process.env,
526
+ out = (s) => process.stdout.write(`${s}\n`),
527
+ err = (s) => process.stderr.write(`${s}\n`),
528
+ fetchImpl = globalThis.fetch,
529
+ tmpDir = os.tmpdir(),
530
+ detect = () => detectInstalledCompanion({ platform, env }),
531
+ readSignature = (file) => readAuthenticodeSignature(file),
532
+ launch = (exe, args) => launchSetup(exe, args),
533
+ progress = null,
534
+ } = deps;
535
+
536
+ const args = parseInstallArgs(argv);
537
+ if (args.error) {
538
+ err(`hs companion install: ${args.error}\n\n${renderInstallHelp()}`);
539
+ return 2;
540
+ }
541
+ if (args.help) {
542
+ out(renderInstallHelp());
543
+ return 0;
544
+ }
545
+ if (!isSupportedPlatform(platform, arch)) {
546
+ out(renderWindowsOnlyNotice());
547
+ return 0;
548
+ }
549
+
550
+ const installed = detect();
551
+ if (installed.installed) {
552
+ out(`Installed: SpexCompanion ${installed.version ?? "(version unknown)"} at ${installed.exePath}`);
553
+ }
554
+
555
+ const manifestSource = args.manifest || env.HEYSPEX_COMPANION_MANIFEST_URL || DEFAULT_MANIFEST_URL;
556
+ let manifest;
557
+ try {
558
+ manifest = await loadManifest(manifestSource, { fetchImpl });
559
+ } catch (e) {
560
+ if (e?.status === 404 || e?.code === "ENOENT") {
561
+ err(NO_INSTALLER_PUBLISHED_MESSAGE);
562
+ err(` (release manifest not found: ${manifestSource})`);
563
+ return 1;
564
+ }
565
+ err(`hs companion install: could not read the release manifest (${manifestSource}): ${e.message}`);
566
+ return 1;
567
+ }
568
+ if (!findInstallerEntry(manifest.json)) {
569
+ err(NO_INSTALLER_PUBLISHED_MESSAGE);
570
+ err(` (the release manifest names no SpexCompanionSetup.exe: ${manifestSource})`);
571
+ return 1;
572
+ }
573
+ const parsed = parseInstallerManifest(manifest.json, manifest.url);
574
+ if (!parsed.ok) {
575
+ err(`hs companion install: ${parsed.error}`);
576
+ return 1;
577
+ }
578
+ const inst = parsed.installer;
579
+ if (inst.signed === false) {
580
+ err("hs companion install: the manifest marks this setup as unsigned; refusing to run an unsigned installer.");
581
+ return 1;
582
+ }
583
+ out(`Latest: SpexCompanion ${inst.version ?? "(version unknown)"} -- ${inst.url}`);
584
+
585
+ if (installed.installed && installed.version && inst.version && !args.force && !args.dryRun && compareVersions(installed.version, inst.version) >= 0) {
586
+ out(`SpexCompanion is up to date (${installed.version}). Use --force to reinstall.`);
587
+ return 0;
588
+ }
589
+ if (installed.installed && installed.version && inst.version && compareVersions(installed.version, inst.version) < 0) {
590
+ out(`Upgrade available: ${installed.version} -> ${inst.version}.`);
591
+ }
592
+
593
+ const dir = path.join(tmpDir, `heyspex-companion-setup-${inst.version ?? "latest"}-${process.pid}`);
594
+ const dest = path.join(dir, inst.filename);
595
+ const cleanup = () => {
596
+ if (!args.keep) rmSync(dir, { recursive: true, force: true });
597
+ };
598
+
599
+ let got;
600
+ try {
601
+ out(`Downloading ${inst.filename}${inst.size ? ` (${(inst.size / 1048576).toFixed(1)} MB)` : ""} ...`);
602
+ got = await downloadWithHash(inst.url, dest, { fetchImpl, onProgress: progress ?? undefined });
603
+ } catch (e) {
604
+ cleanup();
605
+ err(`hs companion install: ${e.message}`);
606
+ return 1;
607
+ }
608
+ if (got.sha256 !== inst.sha256) {
609
+ rmSync(dir, { recursive: true, force: true });
610
+ err(`hs companion install: SHA-256 mismatch -- expected ${inst.sha256}, got ${got.sha256}. The download was deleted; nothing was run.`);
611
+ return 1;
612
+ }
613
+ if (inst.size && got.bytes !== inst.size) {
614
+ rmSync(dir, { recursive: true, force: true });
615
+ err(`hs companion install: size mismatch -- expected ${inst.size} bytes, got ${got.bytes}. The download was deleted; nothing was run.`);
616
+ return 1;
617
+ }
618
+ out(`SHA-256 ok (${got.sha256}).`);
619
+
620
+ const expected = (env.HEYSPEX_COMPANION_PUBLISHER || DEFAULT_EXPECTED_PUBLISHER).trim();
621
+ let sig;
622
+ try {
623
+ sig = await readSignature(dest);
624
+ } catch (e) {
625
+ sig = { status: "CheckFailed", subject: null, message: e.message };
626
+ }
627
+ const verdict = judgeSignature(sig, expected);
628
+ if (!verdict.ok) {
629
+ rmSync(dir, { recursive: true, force: true });
630
+ err(`hs companion install: signature check failed -- ${verdict.reason}. The download was deleted; nothing was run.`);
631
+ return 1;
632
+ }
633
+ out(`Signature ok: Valid, publisher "${verdict.publisher}".`);
634
+
635
+ const setupArgs = buildSetupArgs({ quiet: args.quiet, quietArgs: inst.quietArgs, setupArgs: args.setupArgs });
636
+ if (args.dryRun) {
637
+ out(`Dry run: would run ${dest}${setupArgs.length ? ` ${setupArgs.join(" ")}` : ""} -- not launched.`);
638
+ if (args.keep) out(`Kept: ${dest}`);
639
+ cleanup();
640
+ return 0;
641
+ }
642
+
643
+ out(args.quiet ? "Installing SpexCompanion silently ..." : "Opening the SpexCompanion setup wizard ...");
644
+ let code;
645
+ try {
646
+ code = await launch(dest, setupArgs);
647
+ } catch (e) {
648
+ cleanup();
649
+ err(`hs companion install: could not start the setup: ${e.message}`);
650
+ return 1;
651
+ }
652
+ cleanup();
653
+ const d = describeSetupExit(code);
654
+ (d.ok ? out : err)(d.text);
655
+ if (d.ok) out("Start it any time with `npx hs companion start`.");
656
+ return d.ok ? 0 : code || 1;
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Installed-mode host verbs
661
+
662
+ function defaultIsRunning(imageName) {
663
+ const r = spawnSync("tasklist", ["/FI", `IMAGENAME eq ${imageName}`, "/NH", "/FO", "CSV"], { encoding: "utf8", windowsHide: true });
664
+ return r.status === 0 && r.stdout.toLowerCase().includes(`"${imageName.toLowerCase()}"`);
665
+ }
666
+
667
+ function defaultStartDetached(exe) {
668
+ const child = spawn(exe, [], { detached: true, stdio: "ignore", cwd: path.win32.dirname(exe), windowsHide: false });
669
+ child.unref();
670
+ return child.pid;
671
+ }
672
+
673
+ function defaultStop(imageName) {
674
+ const r = spawnSync("taskkill", ["/IM", imageName], { encoding: "utf8", windowsHide: true });
675
+ return r.status === 0;
676
+ }
677
+
678
+ /**
679
+ * `hs companion start|stop|status|build` when the package has no companion/
680
+ * source. Returns the exit code.
681
+ */
682
+ export function runInstalledCompanionHost(sub, deps = {}) {
683
+ const {
684
+ platform = process.platform,
685
+ arch = process.arch,
686
+ env = process.env,
687
+ out = (s) => process.stdout.write(`${s}\n`),
688
+ err = (s) => process.stderr.write(`${s}\n`),
689
+ detect = () => detectInstalledCompanion({ platform, env }),
690
+ isRunning = defaultIsRunning,
691
+ startDetached = defaultStartDetached,
692
+ stop = defaultStop,
693
+ } = deps;
694
+
695
+ if (!isSupportedPlatform(platform, arch)) {
696
+ err(`hs companion ${sub}: ${renderWindowsOnlyNotice()}`);
697
+ return 1;
698
+ }
699
+ if (sub === "build") {
700
+ err("hs companion build: building the Companion needs a HeySpex source checkout. From the npm package, install the released build with `npx heyspex companion install`.");
701
+ return 1;
702
+ }
703
+ const found = detect();
704
+ if (!found.installed) {
705
+ err(renderCompanionInstallOffer(sub, { platform, arch }));
706
+ return 1;
707
+ }
708
+ const image = path.win32.basename(found.exePath);
709
+ const label = `SpexCompanion ${found.version ?? "(version unknown)"} at ${found.exePath}`;
710
+ if (sub === "status") {
711
+ out(`${label}: ${isRunning(image) ? "running" : "not running"}.`);
712
+ return 0;
713
+ }
714
+ if (sub === "stop") {
715
+ if (!isRunning(image)) {
716
+ out("SpexCompanion is not running.");
717
+ return 0;
718
+ }
719
+ if (stop(image)) {
720
+ out("SpexCompanion asked to exit.");
721
+ return 0;
722
+ }
723
+ err(`hs companion stop: could not stop ${image}; quit it from the tray icon.`);
724
+ return 1;
725
+ }
726
+ // start
727
+ if (isRunning(image)) {
728
+ out(`${label}: already running.`);
729
+ return 0;
730
+ }
731
+ try {
732
+ const pid = startDetached(found.exePath);
733
+ out(`Started ${label}${pid ? ` (pid ${pid})` : ""}.`);
734
+ return 0;
735
+ } catch (e) {
736
+ err(`hs companion start: could not start ${found.exePath}: ${e.message}`);
737
+ return 1;
738
+ }
739
+ }
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Doctor row
743
+
744
+ /**
745
+ * The published-install `hs doctor` Companion row. Never `fail`: the
746
+ * Companion is optional, so missing is a warn with the install offer.
747
+ */
748
+ export function companionDoctorRow({ platform = process.platform, arch = process.arch, detect } = {}) {
749
+ if (!isSupportedPlatform(platform, arch)) {
750
+ return { id: "companion", label: "SpexCompanion", status: "ok", detail: "skipped -- SpexCompanion is Windows-only (x64); not needed on this OS" };
751
+ }
752
+ let found;
753
+ try {
754
+ found = (detect ?? (() => detectInstalledCompanion({ platform })))();
755
+ } catch (e) {
756
+ return { id: "companion", label: "SpexCompanion", status: "warn", detail: `could not check for an installed Companion: ${e.message}` };
757
+ }
758
+ if (found.installed) {
759
+ return { id: "companion", label: "SpexCompanion", status: "ok", detail: `installed ${found.version ?? "(version unknown)"} at ${found.exePath}` };
760
+ }
761
+ return {
762
+ id: "companion",
763
+ label: "SpexCompanion",
764
+ status: "warn",
765
+ detail: "not installed (optional: vault mirror, SpexVoice, SpexCrawl gather)",
766
+ human: `Install it with \`${INSTALL_COMMAND}\` (signed Windows setup, no admin needed).`,
767
+ };
768
+ }