akm-cli 0.9.0-rc.13 → 0.9.0-rc.14

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 (100) hide show
  1. package/CHANGELOG.md +117 -23
  2. package/dist/akm-migrate +3 -1
  3. package/dist/assets/hints/cli-hints-full.md +3 -4
  4. package/dist/assets/hints/cli-hints-short.md +5 -5
  5. package/dist/assets/workflows/workflow-template.md +4 -3
  6. package/dist/cli/invocation.js +3 -2
  7. package/dist/cli/retired-commands.js +3 -0
  8. package/dist/cli/unknown-flags.js +226 -0
  9. package/dist/cli.js +19 -35
  10. package/dist/commands/agent/contribute-cli.js +15 -3
  11. package/dist/commands/feedback-cli.js +6 -3
  12. package/dist/commands/improve/collapse-detector.js +2 -3
  13. package/dist/commands/lint/base-linter.js +4 -16
  14. package/dist/commands/lint/index.js +13 -13
  15. package/dist/commands/log.js +6 -1
  16. package/dist/commands/migration-tool.js +4 -5
  17. package/dist/commands/observability-cli.js +1 -1
  18. package/dist/commands/proposal/repository.js +5 -5
  19. package/dist/commands/read/knowledge.js +2 -0
  20. package/dist/commands/registry-cli.js +5 -3
  21. package/dist/commands/sources/add-cli.js +6 -6
  22. package/dist/commands/sources/self-update.js +30 -7
  23. package/dist/commands/sources/source-add.js +17 -2
  24. package/dist/commands/tasks/tasks.js +8 -3
  25. package/dist/commands/workflow-cli.js +142 -119
  26. package/dist/core/adapter/adapters/akm-lint.js +17 -13
  27. package/dist/core/adapter/adapters/akm-task-adapter.js +14 -11
  28. package/dist/core/asset/akm-markdown.js +41 -8
  29. package/dist/core/asset/frontmatter.js +22 -0
  30. package/dist/core/asset/resolve-ref.js +23 -3
  31. package/dist/core/common.js +45 -2
  32. package/dist/core/config/config-schema.js +8 -0
  33. package/dist/core/config/schema/experimental.js +5 -13
  34. package/dist/core/config/schema/sources-bundles.js +11 -0
  35. package/dist/core/config/schema/workflow.js +3 -1
  36. package/dist/core/errors.js +5 -0
  37. package/dist/core/logs-db.js +2 -1
  38. package/dist/core/parse.js +4 -1
  39. package/dist/core/state/migrations.js +11 -14
  40. package/dist/core/state-db.js +4 -6
  41. package/dist/core/subprocess.js +6 -4
  42. package/dist/core/type-presentation.js +1 -1
  43. package/dist/indexer/indexer.js +16 -1
  44. package/dist/indexer/search/search-source.js +1 -1
  45. package/dist/indexer/walk/matchers.js +3 -1
  46. package/dist/integrations/agent/config.js +2 -2
  47. package/dist/integrations/agent/detect.js +49 -19
  48. package/dist/integrations/agent/profiles.js +10 -0
  49. package/dist/integrations/agent/spawn.js +1 -2
  50. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +7 -3
  51. package/dist/integrations/lockfile.js +11 -56
  52. package/dist/output/shapes/passthrough.js +0 -4
  53. package/dist/output/text/command-format.js +3 -3
  54. package/dist/output/text/helpers.js +1 -1
  55. package/dist/output/text/show-directives.js +4 -6
  56. package/dist/output/text/workflow-format.js +7 -62
  57. package/dist/output/text/workflow.js +1 -5
  58. package/dist/scripts/akm-migrate-node.js +58773 -0
  59. package/dist/scripts/akm-migrate.js +33976 -11391
  60. package/dist/setup/detect.js +40 -15
  61. package/dist/setup/setup.js +1 -1
  62. package/dist/sources/providers/git-stash.js +4 -2
  63. package/dist/sources/providers/website.js +5 -0
  64. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  65. package/dist/sources/snapshot-fetchers/content-extract.js +370 -0
  66. package/dist/sources/snapshot-fetchers/fetcher-util.js +40 -0
  67. package/dist/sources/snapshot-fetchers/host-guard.js +199 -0
  68. package/dist/sources/snapshot-fetchers/registry.js +10 -1
  69. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  70. package/dist/sources/snapshot-fetchers/rss.js +279 -0
  71. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  72. package/dist/sources/snapshot-fetchers/website-ingest.js +488 -257
  73. package/dist/sources/snapshot-fetchers/x.js +193 -0
  74. package/dist/storage/engines/sqlite-migrations.js +22 -107
  75. package/dist/tasks/runner.js +19 -16
  76. package/dist/tasks/schema.js +24 -1
  77. package/dist/workflows/exec/brief.js +1 -1
  78. package/dist/workflows/exec/frozen-judge.js +28 -2
  79. package/dist/workflows/exec/native-executor.js +18 -1
  80. package/dist/workflows/exec/report.js +11 -4
  81. package/dist/workflows/exec/run-workflow.js +103 -44
  82. package/dist/workflows/exec/step-work.js +19 -18
  83. package/dist/workflows/exec/unit-dispatch.js +4 -0
  84. package/dist/workflows/exec/workflow-engine-gate.js +11 -13
  85. package/dist/workflows/ir/compile.js +2 -2
  86. package/dist/workflows/ir/freeze.js +16 -8
  87. package/dist/workflows/ir/params.js +134 -10
  88. package/dist/workflows/ir/plan-hash.js +1 -1
  89. package/dist/workflows/ir/schema.js +6 -2
  90. package/dist/workflows/renderer.js +2 -2
  91. package/dist/workflows/runtime/checkin.js +3 -3
  92. package/dist/workflows/runtime/runs.js +50 -29
  93. package/dist/workflows/validate-summary.js +30 -14
  94. package/docs/migration/release-notes/0.9.0.md +31 -4
  95. package/docs/migration/v0.8-to-v0.9.md +69 -100
  96. package/docs/reference/data-and-telemetry.md +5 -5
  97. package/package.json +6 -2
  98. package/schemas/akm-config.json +24 -0
  99. package/schemas/akm-workflow.json +1 -1
  100. package/dist/workflows/cli.js +0 -33
@@ -12,7 +12,8 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { runManagedSubprocess } from "../core/subprocess.js";
14
14
  import { defaultWhich } from "../integrations/agent/detect.js";
15
- import { SESSION_LOG_HARNESSES } from "../integrations/harnesses/index.js";
15
+ import { getBuiltinAgentProfile, OPENCODE_SDK_SERVER_BIN } from "../integrations/agent/profiles.js";
16
+ import { AGENT_DISPATCH_HARNESSES, SESSION_LOG_HARNESSES } from "../integrations/harnesses/index.js";
16
17
  import { detectHarnessConfigs } from "./harness-config-import.js";
17
18
  let detectOverrides;
18
19
  /** TEST-ONLY. Swap the network/host probes; pass undefined to restore. */
@@ -332,26 +333,50 @@ function findGitRepoRoot(start) {
332
333
  }
333
334
  }
334
335
  /**
335
- * Detect the best agent harness in priority order:
336
- * 1. OpenCode SDK resolvable via `import('@opencode-ai/sdk')`.
337
- * 2. `opencode` binary on PATH.
338
- * 3. `claude` binary on PATH.
339
- * 4. none.
336
+ * Detect the best agent harness, preferring in-process/SDK dispatch over CLI
337
+ * subprocess dispatch:
338
+ * 1. `opencode-sdk` — the embedded SDK, when the server binary it spawns is
339
+ * on PATH.
340
+ * 2. the first dispatch-capable CLI harness whose binary is on PATH, in
341
+ * registry order (`opencode`, `claude`, `codex`, ...).
342
+ * 3. none.
343
+ *
344
+ * The SDK import resolving is NOT evidence the SDK path can run: `@opencode-ai/sdk`
345
+ * is a hard dependency of akm-cli itself, so `import()` always succeeds and an
346
+ * import-only check would report `opencode-sdk` on every machine — including
347
+ * ones where the first agentic command then dies with spawn ENOENT. The SDK
348
+ * runner spawns `opencode serve` (see `harnesses/opencode-sdk/sdk-runner.ts`),
349
+ * so the binary probe is what actually decides.
340
350
  *
341
351
  * Pure aside from the dynamic import resolution (which performs no network).
342
352
  */
343
353
  export async function detectHarness(whichFn = defaultWhich) {
344
- try {
345
- await import("@opencode-ai/sdk");
346
- return "opencode-sdk";
354
+ // Probed once; reused below so the `opencode` CLI fallback doesn't repeat
355
+ // the identical full-PATH walk this just performed.
356
+ const opencodePath = whichFn(OPENCODE_SDK_SERVER_BIN);
357
+ if (opencodePath) {
358
+ try {
359
+ await import("@opencode-ai/sdk");
360
+ return "opencode-sdk";
361
+ }
362
+ catch {
363
+ // SDK genuinely unavailable (e.g. externalized in a standalone build).
364
+ // Fall through — the `opencode` CLI harness is probed below.
365
+ }
347
366
  }
348
- catch {
349
- // SDK not installed — fall through to bin probes.
367
+ for (const harness of AGENT_DISPATCH_HARNESSES) {
368
+ // The SDK harness dispatches without a CLI profile and was decided above;
369
+ // an explicit skip so a future BUILTINS entry for it can't silently
370
+ // reintroduce it here after the import guard already rejected it.
371
+ if (harness.id === "opencode-sdk")
372
+ continue;
373
+ const bin = getBuiltinAgentProfile(harness.id)?.bin;
374
+ if (!bin)
375
+ continue;
376
+ const found = bin === OPENCODE_SDK_SERVER_BIN ? opencodePath : whichFn(bin);
377
+ if (found)
378
+ return harness.id;
350
379
  }
351
- if (whichFn("opencode"))
352
- return "opencode";
353
- if (whichFn("claude"))
354
- return "claude";
355
380
  return "none";
356
381
  }
357
382
  /**
@@ -852,7 +852,7 @@ export async function runSetupFromConfig(opts) {
852
852
  parsed = JSON.parse(opts.configJson);
853
853
  }
854
854
  catch (e) {
855
- throw new Error(`Invalid JSON in --config: ${e.message}`);
855
+ throw new UsageError(`Invalid JSON in --config: ${e.message}`, "INVALID_FLAG_VALUE");
856
856
  }
857
857
  if (!isPlainRecord(parsed)) {
858
858
  throw new ConfigError("Setup config must contain a top-level object.", "INVALID_CONFIG_FILE");
@@ -8,7 +8,7 @@ import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js";
8
8
  import { stashDirNames } from "../../core/asset/asset-placement.js";
9
9
  import { resolveStashDir } from "../../core/common.js";
10
10
  import { getSources, loadConfig, resolveConfiguredSources } from "../../core/config/config.js";
11
- import { UsageError } from "../../core/errors.js";
11
+ import { NotFoundError, UsageError } from "../../core/errors.js";
12
12
  import { sanitizeCommitMessage } from "../../core/git-message.js";
13
13
  import { lockContentRootFor } from "../../integrations/lockfile.js";
14
14
  import { runGit } from "./git-install.js";
@@ -106,8 +106,10 @@ export function saveGitStash(name, message, writableOverride, options) {
106
106
  if (name) {
107
107
  const config = loadConfig();
108
108
  const stash = findGitStashByTarget(getSources(config), name);
109
+ // NotFoundError (exit 1), not UsageError (exit 2): the argument is
110
+ // well-formed, the bundle just isn't configured.
109
111
  if (!stash)
110
- throw new UsageError(`No git stash found with name "${name}"`);
112
+ throw new NotFoundError(`No git bundle found with name "${name}"`, "SOURCE_NOT_FOUND");
111
113
  if (stash.type !== "git") {
112
114
  throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
113
115
  }
@@ -20,6 +20,11 @@ registerSourceProvider("website", (config) => {
20
20
  await ensureWebsiteMirror(config, {
21
21
  requireStashDir: true,
22
22
  force: options?.force,
23
+ // Feed the resolver into the EXISTING website-ingest plumbing that
24
+ // already threads `resolveSecret` down to `FetcherContext`. This is the
25
+ // line that closes the bundle-update / sync() gap: previously the X
26
+ // fetcher on this path saw only `X_BEARER_TOKEN`, never the store.
27
+ ...(options?.secrets ? { resolveSecret: options.secrets.resolveSecret } : {}),
23
28
  ...(allowPrivateHosts ? { allowPrivateHosts: true } : {}),
24
29
  });
25
30
  },
@@ -0,0 +1,146 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { isSafeLinkUrl } from "./content-extract.js";
5
+ import { coerceString, escapeMarkdownStructure } from "./fetcher-util.js";
6
+ import { fetchPinnedJson } from "./host-guard.js";
7
+ /**
8
+ * Bluesky profile fetcher, using the public AT Protocol XRPC endpoints.
9
+ *
10
+ * No authentication: `public.api.bsky.app` serves public profiles unauthed.
11
+ * The fetcher contract takes a URL, so bare-handle input (`@user.bsky.social`)
12
+ * is out of scope — that is `akm bundle add` sugar, not an addressable source.
13
+ */
14
+ const BSKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
15
+ const PUBLIC_API_BASE = "https://public.api.bsky.app";
16
+ /** The XRPC endpoint caps `limit` at 100; asking for more is an error. */
17
+ const MAX_FEED_LIMIT = 100;
18
+ const DEFAULT_FEED_LIMIT = 50;
19
+ /** Bound the JSON read: fetch timeouts cover only the header phase. */
20
+ const BSKY_BYTE_CAP = 4 * 1024 * 1024;
21
+ const BSKY_BODY_TIMEOUT_MS = 30_000;
22
+ /** Extract a handle from `https://bsky.app/profile/<handle>`. */
23
+ export function extractBlueskyHandle(url) {
24
+ if (!BSKY_HOSTS.has(url.hostname.toLowerCase()))
25
+ return null;
26
+ const segments = url.pathname.split("/").filter(Boolean);
27
+ if (segments.length < 2 || segments[0] !== "profile")
28
+ return null;
29
+ const handle = segments[1]?.trim();
30
+ if (!handle)
31
+ return null;
32
+ // Only the profile root is a snapshot target; /post/<id> and other
33
+ // sub-routes are individual records, not a profile feed.
34
+ if (segments.length > 2)
35
+ return null;
36
+ return handle.replace(/^@/, "");
37
+ }
38
+ /** Emit a link only when it is a safe absolute http(s) URL. */
39
+ function safeLink(value) {
40
+ if (!value)
41
+ return "";
42
+ try {
43
+ return isSafeLinkUrl(new URL(value)) ? value : "";
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ function num(value) {
50
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
51
+ }
52
+ function xrpcJson(url, timeoutMs, signal) {
53
+ return fetchPinnedJson(url, {
54
+ headers: { Accept: "application/json", "User-Agent": "akm-cli bluesky fetcher" },
55
+ byteCap: BSKY_BYTE_CAP,
56
+ bodyTimeoutMs: BSKY_BODY_TIMEOUT_MS,
57
+ timeoutMs,
58
+ signal,
59
+ });
60
+ }
61
+ /** Resolve a handle to its DID. Returns null when the handle does not exist. */
62
+ async function resolveHandle(handle, timeoutMs, signal) {
63
+ const url = `${PUBLIC_API_BASE}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
64
+ const json = (await xrpcJson(url, timeoutMs, signal));
65
+ const did = coerceString(json?.did);
66
+ return did || null;
67
+ }
68
+ function readPosts(feed, limit) {
69
+ if (!Array.isArray(feed))
70
+ return [];
71
+ const posts = [];
72
+ for (const entry of feed.slice(0, limit)) {
73
+ const post = entry?.post;
74
+ if (!post)
75
+ continue;
76
+ const record = (post.record ?? {});
77
+ const embed = (post.embed ?? {});
78
+ const external = (embed.external ?? {});
79
+ posts.push({
80
+ text: coerceString(record.text),
81
+ createdAt: coerceString(record.createdAt),
82
+ uri: coerceString(post.uri),
83
+ likeCount: num(post.likeCount),
84
+ repostCount: num(post.repostCount),
85
+ externalUri: safeLink(coerceString(external.uri)),
86
+ });
87
+ }
88
+ return posts;
89
+ }
90
+ /** Convert an `at://did/app.bsky.feed.post/<rkey>` URI to a bsky.app permalink. */
91
+ function permalink(handle, uri) {
92
+ const rkey = uri.split("/").pop();
93
+ return rkey ? `https://bsky.app/profile/${handle}/post/${rkey}` : "";
94
+ }
95
+ function renderMarkdown(handle, posts) {
96
+ const sections = [];
97
+ for (const post of posts) {
98
+ if (!post.text && !post.externalUri)
99
+ continue;
100
+ const when = post.createdAt ? new Date(post.createdAt) : null;
101
+ const iso = when && !Number.isNaN(when.getTime()) ? when.toISOString() : "";
102
+ sections.push(`## ${iso || "(undated)"}`, "");
103
+ if (post.text)
104
+ sections.push(escapeMarkdownStructure(post.text), "");
105
+ const meta = [];
106
+ const link = permalink(handle, post.uri);
107
+ if (link)
108
+ meta.push(link);
109
+ if (post.externalUri)
110
+ meta.push(`link: ${post.externalUri}`);
111
+ meta.push(`${post.likeCount} likes, ${post.repostCount} reposts`);
112
+ sections.push(meta.join(" — "), "");
113
+ }
114
+ return sections.join("\n").trimEnd();
115
+ }
116
+ const blueskyFetcher = {
117
+ name: "bluesky-profile",
118
+ matches(url) {
119
+ return extractBlueskyHandle(url) !== null;
120
+ },
121
+ async fetch(url, context) {
122
+ const handle = extractBlueskyHandle(url);
123
+ if (!handle)
124
+ return null;
125
+ const did = await resolveHandle(handle, context.timeoutMs, context.signal);
126
+ if (!did)
127
+ return null;
128
+ const feedUrl = `${PUBLIC_API_BASE}/xrpc/app.bsky.feed.getAuthorFeed` +
129
+ `?actor=${encodeURIComponent(did)}&limit=${Math.min(DEFAULT_FEED_LIMIT, MAX_FEED_LIMIT)}`;
130
+ const json = (await xrpcJson(feedUrl, context.timeoutMs, context.signal));
131
+ if (!json)
132
+ return null;
133
+ const posts = readPosts(json.feed, DEFAULT_FEED_LIMIT);
134
+ const markdown = renderMarkdown(handle, posts);
135
+ if (!markdown)
136
+ return null;
137
+ return {
138
+ url: `https://bsky.app/profile/${handle}`,
139
+ title: `Bluesky — @${handle}`,
140
+ markdown,
141
+ preferredName: `bluesky/${handle}`,
142
+ tags: ["bluesky", "social"],
143
+ };
144
+ },
145
+ };
146
+ export default blueskyFetcher;
@@ -0,0 +1,370 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { parse } from "node-html-parser";
5
+ import TurndownService from "turndown";
6
+ /**
7
+ * Main-content extraction and HTML -> Markdown conversion for website snapshots.
8
+ *
9
+ * Snapshots are read back by agents as trusted knowledge, so anything a hostile
10
+ * page can smuggle into the markdown is a prompt-injection vector. Three
11
+ * invariants are load-bearing and each is enforced defensively:
12
+ *
13
+ * 1. `<script>` / `<style>` / `<noscript>` / `<template>` bodies never reach
14
+ * the markdown.
15
+ * 2. Only `http:` / `https:` URLs are emitted as links or images; anything
16
+ * else degrades to plain text.
17
+ * 3. A page cannot forge document structure — code fences are sized so their
18
+ * content cannot close them early.
19
+ */
20
+ const DANGEROUS_TAGS = ["script", "style", "noscript", "template"];
21
+ /**
22
+ * Scrub patterns for {@link scrubDangerousMarkup}, compiled once at module
23
+ * load rather than per call — this runs on every crawled page.
24
+ */
25
+ const DANGEROUS_TAG_PATTERNS = DANGEROUS_TAGS.map((tag) => ({
26
+ // A full block with any end-tag spelling a browser would accept.
27
+ full: new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/\\s*${tag}\\b[^>]*>`, "gi"),
28
+ // Unterminated open tag: everything to EOF is inside the raw-text element.
29
+ unterminated: new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*$`, "i"),
30
+ // Any stray leftover tag of this kind.
31
+ stray: new RegExp(`<\\/?\\s*${tag}\\b[^>]*>`, "gi"),
32
+ }));
33
+ /**
34
+ * Chrome stripped only when falling back to `<body>`. Not applied when a
35
+ * semantic content region matched: a `<nav>` nested inside `<article>` is
36
+ * usually in-article navigation worth keeping.
37
+ */
38
+ const CHROME_TAGS = ["nav", "header", "footer", "aside"];
39
+ /** Content-region selectors in priority order; semantic HTML beats classes. */
40
+ const CONTENT_SELECTORS = [
41
+ "main",
42
+ "article",
43
+ '[role="main"]',
44
+ "#content",
45
+ "#main-content",
46
+ "#docs-content",
47
+ ".markdown-body",
48
+ ".main-content",
49
+ ".docs-content",
50
+ ".content",
51
+ ];
52
+ /**
53
+ * Remove raw-text blocks textually, BEFORE the DOM parse.
54
+ *
55
+ * This is the security boundary, not `querySelectorAll("script").remove()`.
56
+ * `node-html-parser` only closes a raw-text element on an exactly-matching
57
+ * lowercase `</script>`; browsers additionally accept `</SCRIPT>`,
58
+ * `</script >`, `</script/>`, `</script foo>` and `</ script>`. On any of
59
+ * those the parser never creates a `script` element at all and serializes the
60
+ * script *body* as ordinary text — so DOM-level removal silently does nothing
61
+ * and the payload lands in the snapshot while the page renders normally in a
62
+ * browser. An unterminated `<script>` at EOF has the same effect.
63
+ */
64
+ function scrubDangerousMarkup(html) {
65
+ let out = html;
66
+ for (const pattern of DANGEROUS_TAG_PATTERNS) {
67
+ out = out.replace(pattern.full, " ").replace(pattern.unterminated, " ").replace(pattern.stray, " ");
68
+ }
69
+ return out;
70
+ }
71
+ function stripDangerousNodes(root) {
72
+ for (const tag of DANGEROUS_TAGS) {
73
+ for (const node of root.querySelectorAll(tag))
74
+ node.remove();
75
+ }
76
+ }
77
+ /** True for the only link schemes we will emit into agent-facing markdown. */
78
+ export function isSafeLinkUrl(url) {
79
+ return url.protocol === "http:" || url.protocol === "https:";
80
+ }
81
+ /**
82
+ * Resolve an href/src for emission: absolute, http(s) only, credentials
83
+ * stripped. Returns null when the URL must not be emitted.
84
+ */
85
+ function resolveEmittableUrl(raw, pageUrl) {
86
+ try {
87
+ const resolved = new URL(raw, pageUrl);
88
+ if (!isSafeLinkUrl(resolved))
89
+ return null;
90
+ // `validateWebsiteUrl` rejects credentials on input URLs; do not let them
91
+ // re-enter through a link inside page content.
92
+ resolved.username = "";
93
+ resolved.password = "";
94
+ return resolved.toString();
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Escape characters that would let attacker-controlled text break out of a
102
+ * markdown link/image and start a new one. `alt` and link labels are raw page
103
+ * strings; an alt of `x](javascript:alert(1))` otherwise emits a second,
104
+ * unvalidated destination ahead of the validated `src`.
105
+ */
106
+ function escapeMarkdownLabel(value) {
107
+ return value
108
+ .replace(/([\\[\]])/g, "\\$1")
109
+ .replace(/\s+/g, " ")
110
+ .trim();
111
+ }
112
+ /**
113
+ * Percent-encode parens in a link destination. Unescaped parens truncate the
114
+ * link and spill the remainder into the document as live markup. Encoding
115
+ * (rather than the `<...>` destination form) keeps the result free of `<`, so
116
+ * {@link escapeResidualMarkup} cannot corrupt it afterwards.
117
+ */
118
+ function markdownDestination(url) {
119
+ return url.replaceAll("(", "%28").replaceAll(")", "%29");
120
+ }
121
+ /**
122
+ * Guard against pathological nesting. `node-html-parser` and Turndown both
123
+ * recurse, and ~20k nested elements (well under the page byte cap) overflows
124
+ * the stack — which would otherwise abort an entire crawl.
125
+ */
126
+ const MAX_NESTING_DEPTH = 2_000;
127
+ function exceedsNestingBudget(html) {
128
+ let depth = 0;
129
+ let max = 0;
130
+ const tagPattern = /<(\/?)([a-zA-Z][\w:-]*)[^>]*?(\/?)>/g;
131
+ for (const match of html.matchAll(tagPattern)) {
132
+ const closing = match[1] === "/";
133
+ const selfClosing = match[3] === "/";
134
+ if (selfClosing)
135
+ continue;
136
+ if (closing)
137
+ depth = Math.max(0, depth - 1);
138
+ else {
139
+ depth += 1;
140
+ if (depth > max)
141
+ max = depth;
142
+ if (max > MAX_NESTING_DEPTH)
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+ /** Last-resort conversion when the document is too deep to parse safely. */
149
+ function plainTextFallback(html) {
150
+ return scrubDangerousMarkup(html)
151
+ .replace(/<[^>]+>/g, " ")
152
+ .replace(/\s+/g, " ")
153
+ .trim();
154
+ }
155
+ /**
156
+ * Select the main-content region of a document and return its HTML.
157
+ *
158
+ * Falls back to `<body>` minus page chrome, then to the whole document, so a
159
+ * page with no semantic markup still yields content rather than nothing.
160
+ */
161
+ /**
162
+ * Select the content region from an already-parsed, already-scrubbed root
163
+ * (dangerous nodes removed). Mutates the root when it falls back to `<body>`.
164
+ */
165
+ function selectMainContentFromRoot(root) {
166
+ for (const selector of CONTENT_SELECTORS) {
167
+ const match = root.querySelector(selector);
168
+ // Ignore a region that matched structurally but carries no prose — a
169
+ // decorative wrapper should not shadow the real content below it.
170
+ if (match && match.textContent.trim())
171
+ return match.toString();
172
+ }
173
+ const body = root.querySelector("body");
174
+ if (body) {
175
+ for (const tag of CHROME_TAGS) {
176
+ for (const node of body.querySelectorAll(tag))
177
+ node.remove();
178
+ }
179
+ if (body.textContent.trim())
180
+ return body.toString();
181
+ }
182
+ return root.toString();
183
+ }
184
+ export function extractMainContentHtml(html) {
185
+ const root = parse(scrubDangerousMarkup(html), { comment: false });
186
+ stripDangerousNodes(root);
187
+ return selectMainContentFromRoot(root);
188
+ }
189
+ /** Read a fence language off `class="language-ts"` / `class="lang-ts"`. */
190
+ function fenceLanguage(className) {
191
+ return /(?:language|lang)-([\w+#-]+)/.exec(className)?.[1] ?? "";
192
+ }
193
+ /**
194
+ * Size a fence so its own content cannot close it. Turndown's default rule
195
+ * does this; the custom rule below must not regress it, or a page can end the
196
+ * fence early and inject forged headings and prose into the snapshot.
197
+ */
198
+ function fenceFor(code) {
199
+ let longest = 0;
200
+ for (const run of code.matchAll(/`+/g))
201
+ longest = Math.max(longest, run[0].length);
202
+ return "`".repeat(Math.max(3, longest + 1));
203
+ }
204
+ function createTurndown(pageUrl) {
205
+ const service = new TurndownService({
206
+ headingStyle: "atx",
207
+ codeBlockStyle: "fenced",
208
+ emDelimiter: "_",
209
+ bulletListMarker: "-",
210
+ });
211
+ // Defense in depth: these are already scrubbed textually and removed from
212
+ // the DOM. A caller converting raw HTML must not be able to leak them either.
213
+ service.remove([...DANGEROUS_TAGS]);
214
+ service.addRule("fencedCodeBlock", {
215
+ filter: (node) => node.nodeName === "PRE" && Boolean(node.firstChild) && node.firstChild?.nodeName === "CODE",
216
+ replacement: (_content, node) => {
217
+ const code = node.firstChild;
218
+ const language = fenceLanguage(code?.getAttribute?.("class") ?? "");
219
+ const text = (code?.textContent ?? "").replace(/\n+$/, "");
220
+ if (!text.trim())
221
+ return "\n\n";
222
+ const fence = fenceFor(text);
223
+ return `\n\n${fence}${language}\n${text}\n${fence}\n\n`;
224
+ },
225
+ });
226
+ service.addRule("safeLink", {
227
+ filter: (node) => node.nodeName === "A",
228
+ replacement: (content, node) => {
229
+ const label = content.trim();
230
+ if (!label)
231
+ return "";
232
+ const href = node.getAttribute("href");
233
+ if (!href)
234
+ return label;
235
+ const resolved = resolveEmittableUrl(href, pageUrl);
236
+ if (!resolved)
237
+ return label;
238
+ return `[${escapeMarkdownLabel(label)}](${markdownDestination(resolved)})`;
239
+ },
240
+ });
241
+ // Turndown's built-in image rule emits `src` verbatim — no scheme check and
242
+ // no base resolution — so `javascript:` / `data:` srcs would slip past the
243
+ // anchor policy entirely.
244
+ service.addRule("safeImage", {
245
+ filter: (node) => node.nodeName === "IMG",
246
+ replacement: (_content, node) => {
247
+ const element = node;
248
+ const alt = escapeMarkdownLabel(element.getAttribute("alt") ?? "");
249
+ const src = element.getAttribute("src");
250
+ if (!src)
251
+ return alt;
252
+ const resolved = resolveEmittableUrl(src, pageUrl);
253
+ return resolved ? `![${alt}](${markdownDestination(resolved)})` : alt;
254
+ },
255
+ });
256
+ return service;
257
+ }
258
+ /**
259
+ * Escape markup that survives conversion as literal text.
260
+ *
261
+ * Turndown does not escape `<`, so page text written as `&lt;script&gt;`
262
+ * (harmless, visible text on the page) would otherwise become a live
263
+ * `<script>` tag in the markdown and execute in any renderer that passes raw
264
+ * HTML through.
265
+ */
266
+ function escapeResidualMarkup(markdown) {
267
+ // Only `<` that begins a tag-like construct; a bare `a < b` stays readable.
268
+ return markdown.replace(/<(?=[a-zA-Z/!?])/g, "&lt;");
269
+ }
270
+ /** Escape residual markup, then normalize whitespace, into the final snapshot. */
271
+ function finalizeMarkdown(markdown) {
272
+ return escapeResidualMarkup(markdown)
273
+ .replace(/\r/g, "")
274
+ .replace(/[ \t]+\n/g, "\n")
275
+ .replace(/\n{3,}/g, "\n\n")
276
+ .trim();
277
+ }
278
+ /** Turn an already-scrubbed root into snapshot Markdown (mutates the root). */
279
+ function markdownFromRoot(root, html, pageUrl) {
280
+ if (exceedsNestingBudget(html))
281
+ return plainTextFallback(html);
282
+ try {
283
+ stripDangerousNodes(root);
284
+ return createTurndown(pageUrl).turndown(selectMainContentFromRoot(root));
285
+ }
286
+ catch {
287
+ // Depth budget is a heuristic; a parser or converter blow-up must
288
+ // degrade this page, never abort the surrounding crawl.
289
+ return plainTextFallback(html);
290
+ }
291
+ }
292
+ /**
293
+ * Collect http(s) links from an already-parsed, already-scrubbed root. Read
294
+ * BEFORE any content-region mutation so nav/header/footer links survive.
295
+ */
296
+ function collectLinksFromRoot(root, pageUrl) {
297
+ const links = [];
298
+ for (const anchor of root.querySelectorAll("a")) {
299
+ const href = anchor.getAttribute("href")?.trim();
300
+ if (!href || href.startsWith("#"))
301
+ continue;
302
+ try {
303
+ const resolved = new URL(href, pageUrl);
304
+ if (isSafeLinkUrl(resolved))
305
+ links.push(resolved);
306
+ }
307
+ catch {
308
+ /* ignore malformed hrefs */
309
+ }
310
+ }
311
+ return links;
312
+ }
313
+ /**
314
+ * Convert a page to Markdown, scoped to its main-content region.
315
+ *
316
+ * `pageUrl` resolves relative hrefs; it is not fetched.
317
+ */
318
+ export function htmlToMarkdown(html, pageUrl) {
319
+ let markdown;
320
+ if (exceedsNestingBudget(html)) {
321
+ markdown = plainTextFallback(html);
322
+ }
323
+ else {
324
+ try {
325
+ markdown = createTurndown(pageUrl).turndown(extractMainContentHtml(html));
326
+ }
327
+ catch {
328
+ markdown = plainTextFallback(html);
329
+ }
330
+ }
331
+ return finalizeMarkdown(markdown);
332
+ }
333
+ /**
334
+ * Crawl-path entry point: convert a page AND collect its links from a SINGLE
335
+ * parse. The content region and the whole-document link set both derive from
336
+ * the same DOM, so a crawled page is scrubbed and parsed once, not twice.
337
+ *
338
+ * Links are collected from the whole document (nav/header/footer included —
339
+ * that is how a crawl discovers pages) and read before the content-region
340
+ * selection mutates the tree.
341
+ */
342
+ export function htmlToMarkdownAndLinks(html, pageUrl) {
343
+ let root = null;
344
+ try {
345
+ root = parse(scrubDangerousMarkup(html), { comment: false });
346
+ }
347
+ catch {
348
+ root = null;
349
+ }
350
+ const links = root ? collectLinksFromRoot(root, pageUrl) : [];
351
+ const markdown = root ? markdownFromRoot(root, html, pageUrl) : plainTextFallback(html);
352
+ return { markdown: finalizeMarkdown(markdown), links };
353
+ }
354
+ /**
355
+ * Collect links from the WHOLE document, not the extracted content region.
356
+ *
357
+ * Deliberate and load-bearing: nav/header/footer links are how a crawl
358
+ * discovers pages. Narrowing this to the content region would silently shrink
359
+ * every crawl to whatever the first page happens to link inline.
360
+ */
361
+ export function extractDocumentLinks(html, pageUrl) {
362
+ let root;
363
+ try {
364
+ root = parse(scrubDangerousMarkup(html), { comment: false });
365
+ }
366
+ catch {
367
+ return [];
368
+ }
369
+ return collectLinksFromRoot(root, pageUrl);
370
+ }
@@ -0,0 +1,40 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Small shared helpers for the snapshot fetchers.
6
+ *
7
+ * These live in a leaf module (no fetcher or `website-ingest` imports) so the
8
+ * individual fetchers can share them without cycling through the fetcher
9
+ * registry — the same reason the SSRF guards were pulled into `host-guard.ts`.
10
+ */
11
+ /**
12
+ * Neutralize markdown structure in attacker-controlled prose. Without this a
13
+ * post/tweet body containing a line starting with `##` forges a section
14
+ * boundary in the snapshot, letting it impersonate content the fetcher
15
+ * vouched for.
16
+ */
17
+ export function escapeMarkdownStructure(value) {
18
+ return value
19
+ .split("\n")
20
+ .map((line) => line.replace(/^(\s*)([#>\-*+=]|\d+\.)/, "$1\\$2"))
21
+ .join("\n");
22
+ }
23
+ /** Coerce an unknown JSON value to a string, defaulting to "". */
24
+ export function coerceString(value) {
25
+ return typeof value === "string" ? value : "";
26
+ }
27
+ /**
28
+ * `index` and `log` are OKF reserved structural basenames at every depth — an
29
+ * adapter never indexes them, so an asset written there imports but can never
30
+ * be found by search or show. Remap the trailing segment (`x/index` ->
31
+ * `x/index-content`). Idempotent: a `-content`-suffixed name is left alone.
32
+ */
33
+ export function avoidReservedBasename(relPath) {
34
+ const segments = relPath.split("/");
35
+ const last = segments[segments.length - 1] ?? "";
36
+ if (last === "index" || last === "log") {
37
+ segments[segments.length - 1] = `${last}-content`;
38
+ }
39
+ return segments.join("/");
40
+ }