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.
- package/CHANGELOG.md +117 -23
- package/dist/akm-migrate +3 -1
- package/dist/assets/hints/cli-hints-full.md +3 -4
- package/dist/assets/hints/cli-hints-short.md +5 -5
- package/dist/assets/workflows/workflow-template.md +4 -3
- package/dist/cli/invocation.js +3 -2
- package/dist/cli/retired-commands.js +3 -0
- package/dist/cli/unknown-flags.js +226 -0
- package/dist/cli.js +19 -35
- package/dist/commands/agent/contribute-cli.js +15 -3
- package/dist/commands/feedback-cli.js +6 -3
- package/dist/commands/improve/collapse-detector.js +2 -3
- package/dist/commands/lint/base-linter.js +4 -16
- package/dist/commands/lint/index.js +13 -13
- package/dist/commands/log.js +6 -1
- package/dist/commands/migration-tool.js +4 -5
- package/dist/commands/observability-cli.js +1 -1
- package/dist/commands/proposal/repository.js +5 -5
- package/dist/commands/read/knowledge.js +2 -0
- package/dist/commands/registry-cli.js +5 -3
- package/dist/commands/sources/add-cli.js +6 -6
- package/dist/commands/sources/self-update.js +30 -7
- package/dist/commands/sources/source-add.js +17 -2
- package/dist/commands/tasks/tasks.js +8 -3
- package/dist/commands/workflow-cli.js +142 -119
- package/dist/core/adapter/adapters/akm-lint.js +17 -13
- package/dist/core/adapter/adapters/akm-task-adapter.js +14 -11
- package/dist/core/asset/akm-markdown.js +41 -8
- package/dist/core/asset/frontmatter.js +22 -0
- package/dist/core/asset/resolve-ref.js +23 -3
- package/dist/core/common.js +45 -2
- package/dist/core/config/config-schema.js +8 -0
- package/dist/core/config/schema/experimental.js +5 -13
- package/dist/core/config/schema/sources-bundles.js +11 -0
- package/dist/core/config/schema/workflow.js +3 -1
- package/dist/core/errors.js +5 -0
- package/dist/core/logs-db.js +2 -1
- package/dist/core/parse.js +4 -1
- package/dist/core/state/migrations.js +11 -14
- package/dist/core/state-db.js +4 -6
- package/dist/core/subprocess.js +6 -4
- package/dist/core/type-presentation.js +1 -1
- package/dist/indexer/indexer.js +16 -1
- package/dist/indexer/search/search-source.js +1 -1
- package/dist/indexer/walk/matchers.js +3 -1
- package/dist/integrations/agent/config.js +2 -2
- package/dist/integrations/agent/detect.js +49 -19
- package/dist/integrations/agent/profiles.js +10 -0
- package/dist/integrations/agent/spawn.js +1 -2
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +7 -3
- package/dist/integrations/lockfile.js +11 -56
- package/dist/output/shapes/passthrough.js +0 -4
- package/dist/output/text/command-format.js +3 -3
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/show-directives.js +4 -6
- package/dist/output/text/workflow-format.js +7 -62
- package/dist/output/text/workflow.js +1 -5
- package/dist/scripts/akm-migrate-node.js +58773 -0
- package/dist/scripts/akm-migrate.js +33976 -11391
- package/dist/setup/detect.js +40 -15
- package/dist/setup/setup.js +1 -1
- package/dist/sources/providers/git-stash.js +4 -2
- package/dist/sources/providers/website.js +5 -0
- package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
- package/dist/sources/snapshot-fetchers/content-extract.js +370 -0
- package/dist/sources/snapshot-fetchers/fetcher-util.js +40 -0
- package/dist/sources/snapshot-fetchers/host-guard.js +199 -0
- package/dist/sources/snapshot-fetchers/registry.js +10 -1
- package/dist/sources/snapshot-fetchers/robots.js +348 -0
- package/dist/sources/snapshot-fetchers/rss.js +279 -0
- package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
- package/dist/sources/snapshot-fetchers/website-ingest.js +488 -257
- package/dist/sources/snapshot-fetchers/x.js +193 -0
- package/dist/storage/engines/sqlite-migrations.js +22 -107
- package/dist/tasks/runner.js +19 -16
- package/dist/tasks/schema.js +24 -1
- package/dist/workflows/exec/brief.js +1 -1
- package/dist/workflows/exec/frozen-judge.js +28 -2
- package/dist/workflows/exec/native-executor.js +18 -1
- package/dist/workflows/exec/report.js +11 -4
- package/dist/workflows/exec/run-workflow.js +103 -44
- package/dist/workflows/exec/step-work.js +19 -18
- package/dist/workflows/exec/unit-dispatch.js +4 -0
- package/dist/workflows/exec/workflow-engine-gate.js +11 -13
- package/dist/workflows/ir/compile.js +2 -2
- package/dist/workflows/ir/freeze.js +16 -8
- package/dist/workflows/ir/params.js +134 -10
- package/dist/workflows/ir/plan-hash.js +1 -1
- package/dist/workflows/ir/schema.js +6 -2
- package/dist/workflows/renderer.js +2 -2
- package/dist/workflows/runtime/checkin.js +3 -3
- package/dist/workflows/runtime/runs.js +50 -29
- package/dist/workflows/validate-summary.js +30 -14
- package/docs/migration/release-notes/0.9.0.md +31 -4
- package/docs/migration/v0.8-to-v0.9.md +69 -100
- package/docs/reference/data-and-telemetry.md +5 -5
- package/package.json +6 -2
- package/schemas/akm-config.json +24 -0
- package/schemas/akm-workflow.json +1 -1
- package/dist/workflows/cli.js +0 -33
package/dist/setup/detect.js
CHANGED
|
@@ -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 {
|
|
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
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
349
|
-
// SDK
|
|
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
|
/**
|
package/dist/setup/setup.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
|
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 `<script>`
|
|
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, "<");
|
|
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
|
+
}
|