harnery 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"commander.d.ts","sourceRoot":"","sources":["../src/commander.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA+BpC,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAEhC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;IAEnB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,0FAA0F;IAC1F,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE;QACvB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,KAAK,MAAM,GAAG,SAAS,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACvE;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACjE;AAED;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,IAAI,CAAC;IAC5C,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC3D,KAAK,CAAC,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,CAAC;IACrF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IACpE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,eAAO,MAAM,WAAW,EAAE,WAkCzB,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,kBAAuB,GAAG,OAAO,CA2C3E"}
1
+ {"version":3,"file":"commander.d.ts","sourceRoot":"","sources":["../src/commander.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AA+BpC,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAEhC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,WAAW,CAAC;IAEnB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,0FAA0F;IAC1F,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACvC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE;QACvB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,UAAU,CAAC,EAAE,MAAM,CAAC;KACrB,KAAK,MAAM,GAAG,SAAS,CAAC;IACzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACvE;;;;;OAKG;IACH,yBAAyB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACjE;AAED;;;;;;GAMG;AACH,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,IAAI,CAAC;IAC5C,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC3D,KAAK,CAAC,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,GAAG,OAAO,GAAG,IAAI,CAAC;IACrF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC;IACpE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,eAAO,MAAM,WAAW,EAAE,WAkCzB,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,kBAAuB,GAAG,OAAO,CA2C3E"}
package/dist/commander.js CHANGED
@@ -13,6 +13,9 @@
13
13
  * Each subdirectory under src/commands/ exports its `register…` function
14
14
  * (Commander pattern).
15
15
  */
16
+ import { readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
16
19
  import { Command } from "commander";
17
20
  import { registerAgentsCommand } from "./commands/agents.js";
18
21
  import { registerBackupCommand } from "./commands/backup.js";
@@ -120,7 +123,29 @@ export function createHarneryProgram(opts = {}) {
120
123
  return program;
121
124
  }
122
125
  function readVersion() {
123
- // Static for the empty scaffold. Will be replaced by a build-time substitution
124
- // (or a JSON import) once we have any command shipping.
125
- return "0.1.0";
126
+ // Resolve package.json by walking up from this module. Works under Bun (this
127
+ // file runs from src/) and Node (from dist/): package.json sits at the
128
+ // package root above both. Falls back to "0.0.0" if it can't be found rather
129
+ // than crashing `--version`.
130
+ try {
131
+ let dir = dirname(fileURLToPath(import.meta.url));
132
+ for (let i = 0; i < 6; i++) {
133
+ try {
134
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
135
+ if (pkg.name === "harnery" && typeof pkg.version === "string")
136
+ return pkg.version;
137
+ }
138
+ catch {
139
+ // no package.json at this level, or not ours; keep walking up
140
+ }
141
+ const parent = dirname(dir);
142
+ if (parent === dir)
143
+ break;
144
+ dir = parent;
145
+ }
146
+ }
147
+ catch {
148
+ // import.meta.url unavailable or fs error; fall through
149
+ }
150
+ return "0.0.0";
126
151
  }
@@ -0,0 +1,10 @@
1
+ import type { EmitContext } from "../commander.js";
2
+ /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
3
+ export declare function webRunner(): "bun" | "npm";
4
+ /**
5
+ * Ensure a cached harnery checkout's `web/` exists for this version and return
6
+ * its path, fetching (clone + install) on first use. Returns null on failure
7
+ * (after emitting an actionable error).
8
+ */
9
+ export declare function lazyFetchWebRoot(emit: EmitContext): string | null;
10
+ //# sourceMappingURL=web-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-fetch.d.ts","sourceRoot":"","sources":["../../src/commands/web-fetch.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAiDnD,2GAA2G;AAC3G,wBAAgB,SAAS,IAAI,KAAK,GAAG,KAAK,CAGzC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAmDjE"}
@@ -0,0 +1,100 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /**
7
+ * Lazy-fetch of the web dashboard for npm consumers.
8
+ *
9
+ * The published npm package ships the CLI + coord engine but deliberately
10
+ * excludes `web/` (ADR 0001/0003: keep the core lean for CLI-only users). The
11
+ * dashboard also imports harnery's `src/` directly (see `web/next.config.ts`),
12
+ * so it can't be lifted out as a standalone folder. Instead, the first time
13
+ * `harn web up` runs from an install without a local `web/`, we clone the
14
+ * harnery repo at the *matching version tag* into a cache dir and install the
15
+ * web app's deps. Subsequent runs reuse the cache.
16
+ *
17
+ * The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
18
+ * dependency-free module), so the install is `web/` deps only: no root install,
19
+ * no Playwright browser download.
20
+ */
21
+ const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
22
+ /** harnery's own package root: src/commands/ -> ../.. ; valid in both src/ and dist/. */
23
+ function packageRoot() {
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ return path.resolve(here, "..", "..");
26
+ }
27
+ function readPkg() {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
30
+ const raw = typeof pkg.repository === "string" ? pkg.repository : (pkg.repository?.url ?? "");
31
+ const repoUrl = raw.replace(/^git\+/, "") || DEFAULT_REPO_URL;
32
+ return { version: typeof pkg.version === "string" ? pkg.version : "0.0.0", repoUrl };
33
+ }
34
+ catch {
35
+ return { version: "0.0.0", repoUrl: DEFAULT_REPO_URL };
36
+ }
37
+ }
38
+ /** The git ref to fetch: HARNERY_WEB_REF override, else the version tag (`v<version>`). */
39
+ function resolveRef(version) {
40
+ return process.env.HARNERY_WEB_REF || `v${version}`;
41
+ }
42
+ /** Per-ref cache dir under the XDG cache (matches harnery's `~/.cache/harnery/...`). */
43
+ function webCacheRoot(ref) {
44
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
45
+ const safeRef = ref.replace(/[^\w.-]/g, "_");
46
+ return path.join(base, "harnery", "web", safeRef);
47
+ }
48
+ /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
49
+ export function webRunner() {
50
+ const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
51
+ return r.status === 0 ? "bun" : "npm";
52
+ }
53
+ /**
54
+ * Ensure a cached harnery checkout's `web/` exists for this version and return
55
+ * its path, fetching (clone + install) on first use. Returns null on failure
56
+ * (after emitting an actionable error).
57
+ */
58
+ export function lazyFetchWebRoot(emit) {
59
+ const { version, repoUrl } = readPkg();
60
+ const ref = resolveRef(version);
61
+ const cacheRoot = webCacheRoot(ref);
62
+ const webDir = path.join(cacheRoot, "web");
63
+ // node_modules in the cached web/ is the "fetched + installed" marker.
64
+ if (existsSync(path.join(webDir, "node_modules"))) {
65
+ emit.log(`harn web · using cached dashboard (${ref})`, "info");
66
+ return webDir;
67
+ }
68
+ emit.log(`harn web · dashboard is not bundled in the npm package; fetching harnery ${ref} (one-time)`, "info");
69
+ emit.log(` into ${cacheRoot} · clone + web/ deps; pass --no-fetch to skip`, "info");
70
+ // Clear any partial/stale state so a retry is clean.
71
+ if (existsSync(cacheRoot))
72
+ rmSync(cacheRoot, { recursive: true, force: true });
73
+ mkdirSync(path.dirname(cacheRoot), { recursive: true });
74
+ const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
75
+ stdio: "inherit",
76
+ });
77
+ if (clone.status !== 0) {
78
+ emit.error({
79
+ code: "web_fetch_failed",
80
+ message: `git clone of ${repoUrl} @ ${ref} failed${clone.error ? `: ${clone.error.message}` : ` (status ${clone.status})`}`,
81
+ hint: "Fetching the dashboard needs git + network. Do it manually: " +
82
+ `git clone ${repoUrl} && cd harnery/web && ${webRunner()} install, then run it pointed at your project via HARNERY_COORD_ROOT. ` +
83
+ "See https://harnery.com/cli/web/.",
84
+ });
85
+ return null;
86
+ }
87
+ const runner = webRunner();
88
+ emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
89
+ const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
90
+ if (install.status !== 0) {
91
+ emit.error({
92
+ code: "web_fetch_failed",
93
+ message: `${runner} install in ${webDir} failed (status ${install.status})`,
94
+ hint: `Try manually: cd ${webDir} && ${runner} install`,
95
+ });
96
+ return null;
97
+ }
98
+ emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
99
+ return webDir;
100
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAsDnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAqI5E"}
1
+ {"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/commands/web.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAwDnD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAwI5E"}
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { lazyFetchWebRoot, webRunner } from "./web-fetch.js";
5
6
  /**
6
7
  * `harn web`: Next.js dashboard for harnery's coord state.
7
8
  *
@@ -15,6 +16,9 @@ import { fileURLToPath } from "node:url";
15
16
  * passing cwd here lets the dashboard show the right project regardless
16
17
  * of where the user invokes `harn web up` from.
17
18
  *
19
+ * The dashboard is not bundled in the npm package (ADR 0001/0003), so on an
20
+ * npm install `resolveWebRoot` lazy-fetches it on first use (see web-fetch.ts).
21
+ *
18
22
  * Localhost-only by default. Network exposure is intentionally out of
19
23
  * scope for v1.
20
24
  */
@@ -23,25 +27,25 @@ function webRoot() {
23
27
  const here = path.dirname(fileURLToPath(import.meta.url));
24
28
  return path.resolve(here, "..", "..", "web");
25
29
  }
26
- function checkWebPresent(emit) {
27
- const root = webRoot();
28
- if (!existsSync(path.join(root, "package.json"))) {
29
- emit.error({
30
- code: "web_missing",
31
- message: `harnery/web/ not found at ${root}`,
32
- hint: "The dashboard ships with the harnery git repo, not the npm package (which is the CLI + " +
33
- "coord engine only). Clone https://github.com/ryanjkelly/harnery, run `bun install`, then " +
34
- "`harn web up --coord-root <your-project>` (or run it from inside that project). " +
35
- "See https://harnery.com/cli/web/.",
36
- });
37
- return null;
38
- }
39
- return root;
40
- }
41
- function runner() {
42
- // Prefer bun when available; fall back to npx/npm for plain Node installs.
43
- const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
44
- return r.status === 0 ? "bun" : "npx";
30
+ /**
31
+ * Resolve the web app root. A local `web/` (source checkout / submodule) wins.
32
+ * Otherwise, on an npm install, lazy-fetch it (clone + install) unless the
33
+ * caller passed --no-fetch, in which case emit the honest manual hint.
34
+ */
35
+ function resolveWebRoot(emit, allowFetch) {
36
+ const local = webRoot();
37
+ if (existsSync(path.join(local, "package.json")))
38
+ return local;
39
+ if (allowFetch)
40
+ return lazyFetchWebRoot(emit);
41
+ emit.error({
42
+ code: "web_missing",
43
+ message: `harnery/web/ not found at ${local}`,
44
+ hint: "The dashboard is not bundled in the npm package. Re-run without --no-fetch to fetch it " +
45
+ "automatically, or clone https://github.com/ryanjkelly/harnery and run it from there. " +
46
+ "See https://harnery.com/cli/web/.",
47
+ });
48
+ return null;
45
49
  }
46
50
  export function registerWebCommand(program, emit) {
47
51
  const web = program
@@ -54,8 +58,9 @@ export function registerWebCommand(program, emit) {
54
58
  .option("-p, --port <port>", "Listen port", "9000")
55
59
  .option("--coord-root <dir>", "Override the coord root (default: cwd; web walks up looking for .harnery/)")
56
60
  .option("--prod", "Use next start instead of next dev (requires prior build)")
61
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
57
62
  .action(async (opts) => {
58
- const root = checkWebPresent(emit);
63
+ const root = resolveWebRoot(emit, opts.fetch !== false);
59
64
  if (!root) {
60
65
  process.exitCode = 1;
61
66
  return;
@@ -77,7 +82,7 @@ export function registerWebCommand(program, emit) {
77
82
  }
78
83
  emit.log(`harn web · http://localhost:${port} (${mode})`, "info");
79
84
  emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
80
- const child = spawn(runner(), ["run", mode], {
85
+ const child = spawn(webRunner(), ["run", mode], {
81
86
  cwd: root,
82
87
  env: {
83
88
  ...process.env,
@@ -105,14 +110,15 @@ export function registerWebCommand(program, emit) {
105
110
  web
106
111
  .command("build")
107
112
  .description("Build the production bundle (next build).")
108
- .action(() => {
109
- const root = checkWebPresent(emit);
113
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
114
+ .action((opts) => {
115
+ const root = resolveWebRoot(emit, opts.fetch !== false);
110
116
  if (!root) {
111
117
  process.exitCode = 1;
112
118
  return;
113
119
  }
114
120
  emit.log("running next build…", "info");
115
- const r = spawnSync(runner(), ["run", "build"], {
121
+ const r = spawnSync(webRunner(), ["run", "build"], {
116
122
  cwd: root,
117
123
  stdio: "inherit",
118
124
  });
@@ -123,8 +129,9 @@ export function registerWebCommand(program, emit) {
123
129
  .description("Start the production server (next start). Requires prior `harn web build`.")
124
130
  .option("-p, --port <port>", "Listen port", "9000")
125
131
  .option("--coord-root <dir>", "Override the coord root")
132
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
126
133
  .action((opts) => {
127
- const root = checkWebPresent(emit);
134
+ const root = resolveWebRoot(emit, opts.fetch !== false);
128
135
  if (!root) {
129
136
  process.exitCode = 1;
130
137
  return;
@@ -143,7 +150,7 @@ export function registerWebCommand(program, emit) {
143
150
  const port = String(opts.port);
144
151
  emit.log(`harn web · http://localhost:${port} (start)`, "info");
145
152
  emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
146
- const child = spawn(runner(), ["run", "start"], {
153
+ const child = spawn(webRunner(), ["run", "start"], {
147
154
  cwd: root,
148
155
  env: {
149
156
  ...process.env,
@@ -4,12 +4,14 @@
4
4
  *
5
5
  * The library backing the `read` command.
6
6
  *
7
- * jsdom, @mozilla/readability, and turndown are heavy and (jsdom's tree)
8
- * ESM-fragile, so they are loaded lazily inside `htmlToMarkdown` rather than
9
- * at module top level. A static import would pull them into CLI startup (every
10
- * command is registered eagerly), and jsdom's transitive `html-encoding-sniffer`
11
- * crashes a plain `node` process via ERR_REQUIRE_ESM. Lazy-loading keeps the
12
- * CLI booting for users who never touch `read`/`browse`.
7
+ * linkedom, @mozilla/readability, and turndown are heavy, so they are loaded
8
+ * lazily inside `htmlToMarkdown` rather than at module top level. A static
9
+ * import would pull them into CLI startup (every command is registered
10
+ * eagerly). linkedom replaced jsdom here: jsdom's transitive dependency tree
11
+ * (`html-encoding-sniffer`, `whatwg-url`) does a CommonJS `require()` of the
12
+ * ESM-only `@exodus/bytes`, which crashes a plain `node` process via
13
+ * ERR_REQUIRE_ESM. linkedom is a lighter, CJS-friendly DOM with no such chain.
14
+ * See ADR 0002.
13
15
  */
14
16
  export interface ReadabilityOptions {
15
17
  /** Base URL for resolving relative links. Default `http://local/`. */
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/lib/readability/client.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAID;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CA8D5B"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/lib/readability/client.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,MAAM,WAAW,iBAAiB;IAChC,+DAA+D;IAC/D,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAID;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CAwE5B"}
@@ -10,14 +10,23 @@ export async function htmlToMarkdown(html, opts = {}) {
10
10
  if (!html.trim()) {
11
11
  throw new Error("Empty HTML input");
12
12
  }
13
- const [{ Readability }, { JSDOM }, { default: TurndownService }, { tables }] = await Promise.all([
13
+ const [{ Readability }, { parseHTML }, { default: TurndownService }, { tables }] = await Promise.all([
14
14
  import("@mozilla/readability"),
15
- import("jsdom"),
15
+ import("linkedom"),
16
16
  import("turndown"),
17
17
  import("turndown-plugin-gfm"),
18
18
  ]);
19
- const dom = new JSDOM(html, { url: opts.url ?? "http://local/" });
20
- const doc = dom.window.document;
19
+ // jsdom took a `url` option to set the document base so Readability resolves
20
+ // relative links to absolute. linkedom's parseHTML has no such option, so we
21
+ // inject a <base> tag, which sets doc.baseURI to the same effect. Only when
22
+ // the HTML doesn't already carry its own <base>.
23
+ const baseUrl = opts.url ?? "http://local/";
24
+ const htmlWithBase = /<base\b/i.test(html)
25
+ ? html
26
+ : html.replace(/<head\b[^>]*>/i, (m) => `${m}<base href="${baseUrl}">`);
27
+ // If there's no <head> to anchor the <base> into, fall back to the raw HTML
28
+ // (relative links stay relative, matching a document with no base).
29
+ const { document: doc } = parseHTML(htmlWithBase.includes("<base") ? htmlWithBase : html);
21
30
  preprocessDom(doc);
22
31
  let contentHtml;
23
32
  let title = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Multi-agent coordination + harness adapters + portable CLI utilities for Claude Code / Cursor / Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -132,6 +132,7 @@
132
132
  "test": "bun test src tests",
133
133
  "test:web": "cd web && bun install && bun test",
134
134
  "test:integration": "bash tests/integration/run.sh",
135
+ "smoke": "node scripts/smoke-test.mjs",
135
136
  "docs:dev": "bun --cwd docs run dev",
136
137
  "docs:build": "bun --cwd docs run build",
137
138
  "brand:showcase": "node scripts/build-brand-showcase.mjs"
@@ -141,7 +142,7 @@
141
142
  "commander": "^15.0.0",
142
143
  "gpt-tokenizer": "^3.4.0",
143
144
  "js-yaml": "^4.1.1",
144
- "jsdom": "^29.0.2",
145
+ "linkedom": "^0.18.0",
145
146
  "mailparser": "^3.9.8",
146
147
  "pixelmatch": "^7.2.0",
147
148
  "playwright": "^1.59.1",
@@ -154,7 +155,6 @@
154
155
  "@changesets/cli": "^2.27.0",
155
156
  "@types/bun": "^1.2.0",
156
157
  "@types/js-yaml": "^4.0.9",
157
- "@types/jsdom": "^28.0.1",
158
158
  "@types/mailparser": "^3.4.6",
159
159
  "@types/node": "^26.0.0",
160
160
  "@types/pixelmatch": "^5.2.6",
package/src/commander.ts CHANGED
@@ -14,6 +14,9 @@
14
14
  * (Commander pattern).
15
15
  */
16
16
 
17
+ import { readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
17
20
  import { Command } from "commander";
18
21
  import { registerAgentsCommand } from "./commands/agents.ts";
19
22
  import { registerBackupCommand } from "./commands/backup.ts";
@@ -236,7 +239,25 @@ export function createHarneryProgram(opts: HarneryContextOpts = {}): Command {
236
239
  }
237
240
 
238
241
  function readVersion(): string {
239
- // Static for the empty scaffold. Will be replaced by a build-time substitution
240
- // (or a JSON import) once we have any command shipping.
241
- return "0.1.0";
242
+ // Resolve package.json by walking up from this module. Works under Bun (this
243
+ // file runs from src/) and Node (from dist/): package.json sits at the
244
+ // package root above both. Falls back to "0.0.0" if it can't be found rather
245
+ // than crashing `--version`.
246
+ try {
247
+ let dir = dirname(fileURLToPath(import.meta.url));
248
+ for (let i = 0; i < 6; i++) {
249
+ try {
250
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
251
+ if (pkg.name === "harnery" && typeof pkg.version === "string") return pkg.version;
252
+ } catch {
253
+ // no package.json at this level, or not ours; keep walking up
254
+ }
255
+ const parent = dirname(dir);
256
+ if (parent === dir) break;
257
+ dir = parent;
258
+ }
259
+ } catch {
260
+ // import.meta.url unavailable or fs error; fall through
261
+ }
262
+ return "0.0.0";
242
263
  }
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { EmitContext } from "../commander.ts";
7
+
8
+ /**
9
+ * Lazy-fetch of the web dashboard for npm consumers.
10
+ *
11
+ * The published npm package ships the CLI + coord engine but deliberately
12
+ * excludes `web/` (ADR 0001/0003: keep the core lean for CLI-only users). The
13
+ * dashboard also imports harnery's `src/` directly (see `web/next.config.ts`),
14
+ * so it can't be lifted out as a standalone folder. Instead, the first time
15
+ * `harn web up` runs from an install without a local `web/`, we clone the
16
+ * harnery repo at the *matching version tag* into a cache dir and install the
17
+ * web app's deps. Subsequent runs reuse the cache.
18
+ *
19
+ * The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
20
+ * dependency-free module), so the install is `web/` deps only: no root install,
21
+ * no Playwright browser download.
22
+ */
23
+
24
+ const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
25
+
26
+ /** harnery's own package root: src/commands/ -> ../.. ; valid in both src/ and dist/. */
27
+ function packageRoot(): string {
28
+ const here = path.dirname(fileURLToPath(import.meta.url));
29
+ return path.resolve(here, "..", "..");
30
+ }
31
+
32
+ function readPkg(): { version: string; repoUrl: string } {
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
35
+ const raw = typeof pkg.repository === "string" ? pkg.repository : (pkg.repository?.url ?? "");
36
+ const repoUrl = raw.replace(/^git\+/, "") || DEFAULT_REPO_URL;
37
+ return { version: typeof pkg.version === "string" ? pkg.version : "0.0.0", repoUrl };
38
+ } catch {
39
+ return { version: "0.0.0", repoUrl: DEFAULT_REPO_URL };
40
+ }
41
+ }
42
+
43
+ /** The git ref to fetch: HARNERY_WEB_REF override, else the version tag (`v<version>`). */
44
+ function resolveRef(version: string): string {
45
+ return process.env.HARNERY_WEB_REF || `v${version}`;
46
+ }
47
+
48
+ /** Per-ref cache dir under the XDG cache (matches harnery's `~/.cache/harnery/...`). */
49
+ function webCacheRoot(ref: string): string {
50
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
51
+ const safeRef = ref.replace(/[^\w.-]/g, "_");
52
+ return path.join(base, "harnery", "web", safeRef);
53
+ }
54
+
55
+ /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
56
+ export function webRunner(): "bun" | "npm" {
57
+ const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
58
+ return r.status === 0 ? "bun" : "npm";
59
+ }
60
+
61
+ /**
62
+ * Ensure a cached harnery checkout's `web/` exists for this version and return
63
+ * its path, fetching (clone + install) on first use. Returns null on failure
64
+ * (after emitting an actionable error).
65
+ */
66
+ export function lazyFetchWebRoot(emit: EmitContext): string | null {
67
+ const { version, repoUrl } = readPkg();
68
+ const ref = resolveRef(version);
69
+ const cacheRoot = webCacheRoot(ref);
70
+ const webDir = path.join(cacheRoot, "web");
71
+
72
+ // node_modules in the cached web/ is the "fetched + installed" marker.
73
+ if (existsSync(path.join(webDir, "node_modules"))) {
74
+ emit.log(`harn web · using cached dashboard (${ref})`, "info");
75
+ return webDir;
76
+ }
77
+
78
+ emit.log(
79
+ `harn web · dashboard is not bundled in the npm package; fetching harnery ${ref} (one-time)`,
80
+ "info",
81
+ );
82
+ emit.log(` into ${cacheRoot} · clone + web/ deps; pass --no-fetch to skip`, "info");
83
+
84
+ // Clear any partial/stale state so a retry is clean.
85
+ if (existsSync(cacheRoot)) rmSync(cacheRoot, { recursive: true, force: true });
86
+ mkdirSync(path.dirname(cacheRoot), { recursive: true });
87
+
88
+ const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
89
+ stdio: "inherit",
90
+ });
91
+ if (clone.status !== 0) {
92
+ emit.error({
93
+ code: "web_fetch_failed",
94
+ message: `git clone of ${repoUrl} @ ${ref} failed${clone.error ? `: ${clone.error.message}` : ` (status ${clone.status})`}`,
95
+ hint:
96
+ "Fetching the dashboard needs git + network. Do it manually: " +
97
+ `git clone ${repoUrl} && cd harnery/web && ${webRunner()} install, then run it pointed at your project via HARNERY_COORD_ROOT. ` +
98
+ "See https://harnery.com/cli/web/.",
99
+ });
100
+ return null;
101
+ }
102
+
103
+ const runner = webRunner();
104
+ emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
105
+ const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
106
+ if (install.status !== 0) {
107
+ emit.error({
108
+ code: "web_fetch_failed",
109
+ message: `${runner} install in ${webDir} failed (status ${install.status})`,
110
+ hint: `Try manually: cd ${webDir} && ${runner} install`,
111
+ });
112
+ return null;
113
+ }
114
+
115
+ emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
116
+ return webDir;
117
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { Command } from "commander";
6
6
  import type { EmitContext } from "../commander.ts";
7
+ import { lazyFetchWebRoot, webRunner } from "./web-fetch.ts";
7
8
 
8
9
  /**
9
10
  * `harn web`: Next.js dashboard for harnery's coord state.
@@ -18,6 +19,9 @@ import type { EmitContext } from "../commander.ts";
18
19
  * passing cwd here lets the dashboard show the right project regardless
19
20
  * of where the user invokes `harn web up` from.
20
21
  *
22
+ * The dashboard is not bundled in the npm package (ADR 0001/0003), so on an
23
+ * npm install `resolveWebRoot` lazy-fetches it on first use (see web-fetch.ts).
24
+ *
21
25
  * Localhost-only by default. Network exposure is intentionally out of
22
26
  * scope for v1.
23
27
  */
@@ -28,33 +32,31 @@ function webRoot(): string {
28
32
  return path.resolve(here, "..", "..", "web");
29
33
  }
30
34
 
31
- function checkWebPresent(emit: EmitContext): string | null {
32
- const root = webRoot();
33
- if (!existsSync(path.join(root, "package.json"))) {
34
- emit.error({
35
- code: "web_missing",
36
- message: `harnery/web/ not found at ${root}`,
37
- hint:
38
- "The dashboard ships with the harnery git repo, not the npm package (which is the CLI + " +
39
- "coord engine only). Clone https://github.com/ryanjkelly/harnery, run `bun install`, then " +
40
- "`harn web up --coord-root <your-project>` (or run it from inside that project). " +
41
- "See https://harnery.com/cli/web/.",
42
- });
43
- return null;
44
- }
45
- return root;
46
- }
47
-
48
- function runner(): string {
49
- // Prefer bun when available; fall back to npx/npm for plain Node installs.
50
- const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
51
- return r.status === 0 ? "bun" : "npx";
35
+ /**
36
+ * Resolve the web app root. A local `web/` (source checkout / submodule) wins.
37
+ * Otherwise, on an npm install, lazy-fetch it (clone + install) unless the
38
+ * caller passed --no-fetch, in which case emit the honest manual hint.
39
+ */
40
+ function resolveWebRoot(emit: EmitContext, allowFetch: boolean): string | null {
41
+ const local = webRoot();
42
+ if (existsSync(path.join(local, "package.json"))) return local;
43
+ if (allowFetch) return lazyFetchWebRoot(emit);
44
+ emit.error({
45
+ code: "web_missing",
46
+ message: `harnery/web/ not found at ${local}`,
47
+ hint:
48
+ "The dashboard is not bundled in the npm package. Re-run without --no-fetch to fetch it " +
49
+ "automatically, or clone https://github.com/ryanjkelly/harnery and run it from there. " +
50
+ "See https://harnery.com/cli/web/.",
51
+ });
52
+ return null;
52
53
  }
53
54
 
54
55
  interface UpOpts {
55
56
  port: string;
56
57
  coordRoot?: string;
57
58
  prod?: boolean;
59
+ fetch?: boolean;
58
60
  }
59
61
 
60
62
  export function registerWebCommand(program: Command, emit: EmitContext): void {
@@ -74,8 +76,9 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
74
76
  "Override the coord root (default: cwd; web walks up looking for .harnery/)",
75
77
  )
76
78
  .option("--prod", "Use next start instead of next dev (requires prior build)")
79
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
77
80
  .action(async (opts: UpOpts) => {
78
- const root = checkWebPresent(emit);
81
+ const root = resolveWebRoot(emit, opts.fetch !== false);
79
82
  if (!root) {
80
83
  process.exitCode = 1;
81
84
  return;
@@ -101,7 +104,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
101
104
  emit.log(`harn web · http://localhost:${port} (${mode})`, "info");
102
105
  emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
103
106
 
104
- const child = spawn(runner(), ["run", mode], {
107
+ const child = spawn(webRunner(), ["run", mode], {
105
108
  cwd: root,
106
109
  env: {
107
110
  ...process.env,
@@ -130,14 +133,15 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
130
133
  web
131
134
  .command("build")
132
135
  .description("Build the production bundle (next build).")
133
- .action(() => {
134
- const root = checkWebPresent(emit);
136
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
137
+ .action((opts: { fetch?: boolean }) => {
138
+ const root = resolveWebRoot(emit, opts.fetch !== false);
135
139
  if (!root) {
136
140
  process.exitCode = 1;
137
141
  return;
138
142
  }
139
143
  emit.log("running next build…", "info");
140
- const r = spawnSync(runner(), ["run", "build"], {
144
+ const r = spawnSync(webRunner(), ["run", "build"], {
141
145
  cwd: root,
142
146
  stdio: "inherit",
143
147
  });
@@ -149,8 +153,9 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
149
153
  .description("Start the production server (next start). Requires prior `harn web build`.")
150
154
  .option("-p, --port <port>", "Listen port", "9000")
151
155
  .option("--coord-root <dir>", "Override the coord root")
152
- .action((opts: { port: string; coordRoot?: string }) => {
153
- const root = checkWebPresent(emit);
156
+ .option("--no-fetch", "Don't auto-fetch the dashboard if it's missing (npm installs)")
157
+ .action((opts: { port: string; coordRoot?: string; fetch?: boolean }) => {
158
+ const root = resolveWebRoot(emit, opts.fetch !== false);
154
159
  if (!root) {
155
160
  process.exitCode = 1;
156
161
  return;
@@ -170,7 +175,7 @@ export function registerWebCommand(program: Command, emit: EmitContext): void {
170
175
  emit.log(`harn web · http://localhost:${port} (start)`, "info");
171
176
  emit.log(`reading .harnery/ from: ${coordRoot}`, "info");
172
177
 
173
- const child = spawn(runner(), ["run", "start"], {
178
+ const child = spawn(webRunner(), ["run", "start"], {
174
179
  cwd: root,
175
180
  env: {
176
181
  ...process.env,
@@ -6,12 +6,14 @@
6
6
  *
7
7
  * The library backing the `read` command.
8
8
  *
9
- * jsdom, @mozilla/readability, and turndown are heavy and (jsdom's tree)
10
- * ESM-fragile, so they are loaded lazily inside `htmlToMarkdown` rather than
11
- * at module top level. A static import would pull them into CLI startup (every
12
- * command is registered eagerly), and jsdom's transitive `html-encoding-sniffer`
13
- * crashes a plain `node` process via ERR_REQUIRE_ESM. Lazy-loading keeps the
14
- * CLI booting for users who never touch `read`/`browse`.
9
+ * linkedom, @mozilla/readability, and turndown are heavy, so they are loaded
10
+ * lazily inside `htmlToMarkdown` rather than at module top level. A static
11
+ * import would pull them into CLI startup (every command is registered
12
+ * eagerly). linkedom replaced jsdom here: jsdom's transitive dependency tree
13
+ * (`html-encoding-sniffer`, `whatwg-url`) does a CommonJS `require()` of the
14
+ * ESM-only `@exodus/bytes`, which crashes a plain `node` process via
15
+ * ERR_REQUIRE_ESM. linkedom is a lighter, CJS-friendly DOM with no such chain.
16
+ * See ADR 0002.
15
17
  */
16
18
 
17
19
  export interface ReadabilityOptions {
@@ -50,15 +52,25 @@ export async function htmlToMarkdown(
50
52
  throw new Error("Empty HTML input");
51
53
  }
52
54
 
53
- const [{ Readability }, { JSDOM }, { default: TurndownService }, { tables }] = await Promise.all([
54
- import("@mozilla/readability"),
55
- import("jsdom"),
56
- import("turndown"),
57
- import("turndown-plugin-gfm"),
58
- ]);
55
+ const [{ Readability }, { parseHTML }, { default: TurndownService }, { tables }] =
56
+ await Promise.all([
57
+ import("@mozilla/readability"),
58
+ import("linkedom"),
59
+ import("turndown"),
60
+ import("turndown-plugin-gfm"),
61
+ ]);
59
62
 
60
- const dom = new JSDOM(html, { url: opts.url ?? "http://local/" });
61
- const doc = dom.window.document;
63
+ // jsdom took a `url` option to set the document base so Readability resolves
64
+ // relative links to absolute. linkedom's parseHTML has no such option, so we
65
+ // inject a <base> tag, which sets doc.baseURI to the same effect. Only when
66
+ // the HTML doesn't already carry its own <base>.
67
+ const baseUrl = opts.url ?? "http://local/";
68
+ const htmlWithBase = /<base\b/i.test(html)
69
+ ? html
70
+ : html.replace(/<head\b[^>]*>/i, (m) => `${m}<base href="${baseUrl}">`);
71
+ // If there's no <head> to anchor the <base> into, fall back to the raw HTML
72
+ // (relative links stay relative, matching a document with no base).
73
+ const { document: doc } = parseHTML(htmlWithBase.includes("<base") ? htmlWithBase : html);
62
74
  preprocessDom(doc);
63
75
 
64
76
  let contentHtml: string;