@ninemind/agentgem 0.1.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.
- package/README.md +26 -0
- package/dist/gem/acpRecommender.js +259 -0
- package/dist/gem/acpRun.js +156 -0
- package/dist/gem/acpSession.js +79 -0
- package/dist/gem/analysisCache.js +55 -0
- package/dist/gem/archive.js +17 -0
- package/dist/gem/binPath.js +9 -0
- package/dist/gem/buildGem.js +4 -1
- package/dist/gem/channels.js +29 -0
- package/dist/gem/credentials.js +3 -2
- package/dist/gem/distill.js +162 -0
- package/dist/gem/draftStage.js +77 -0
- package/dist/gem/gemVerify.js +35 -0
- package/dist/gem/inputError.js +21 -0
- package/dist/gem/registry.js +23 -4
- package/dist/gem/runGem.js +161 -0
- package/dist/gem/safeFetch.js +112 -0
- package/dist/gem/sandbox.js +37 -0
- package/dist/gem/sandboxLaunch.js +55 -0
- package/dist/gem/scrub.js +108 -0
- package/dist/gem/search.js +34 -0
- package/dist/gem/share.js +21 -0
- package/dist/gem/targets.js +280 -16
- package/dist/gem/testbedFlavors.js +1 -0
- package/dist/gem/workflowScan.js +0 -0
- package/dist/gem/workspaces.js +4 -3
- package/dist/gem.controller.js +151 -16
- package/dist/gem.tools.js +53 -5
- package/dist/gemRunStream.js +67 -0
- package/dist/index.js +15 -0
- package/dist/originGuard.js +36 -0
- package/dist/public/index.html +444 -10
- package/dist/schemas.js +180 -7
- package/dist/workflowStream.js +78 -0
- package/package.json +7 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/gem/safeFetch.ts
|
|
2
|
+
// SSRF guard for installing a .gem from a URL. A localhost-bound dev server is still
|
|
3
|
+
// reachable by a malicious page via CSRF, so an unguarded server-side fetch lets an
|
|
4
|
+
// attacker reach cloud metadata (169.254.169.254) or internal hosts. We resolve the
|
|
5
|
+
// host and refuse any non-public address, re-validate every redirect hop, AND pin the
|
|
6
|
+
// socket to the validated IP via an undici dispatcher so a DNS rebind between the
|
|
7
|
+
// validation and the connect cannot swing the request onto a blocked address.
|
|
8
|
+
import { lookup } from "node:dns/promises";
|
|
9
|
+
import { Agent } from "undici";
|
|
10
|
+
import { InvalidInputError } from "./inputError.js";
|
|
11
|
+
// True for loopback, RFC1918, CGNAT, link-local/metadata, and IPv6 loopback/link-local/ULA.
|
|
12
|
+
export function isBlockedAddress(ip) {
|
|
13
|
+
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
14
|
+
if (v4) {
|
|
15
|
+
const a = Number(v4[1]), b = Number(v4[2]);
|
|
16
|
+
if (a === 0 || a === 127)
|
|
17
|
+
return true; // 0.0.0.0/8, loopback
|
|
18
|
+
if (a === 10)
|
|
19
|
+
return true; // RFC1918
|
|
20
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
21
|
+
return true; // RFC1918
|
|
22
|
+
if (a === 192 && b === 168)
|
|
23
|
+
return true; // RFC1918
|
|
24
|
+
if (a === 169 && b === 254)
|
|
25
|
+
return true; // link-local + cloud metadata
|
|
26
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
27
|
+
return true; // CGNAT 100.64/10
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const v6 = ip.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
31
|
+
if (v6 === "::1" || v6 === "::")
|
|
32
|
+
return true; // loopback / unspecified
|
|
33
|
+
if (v6.startsWith("fe80"))
|
|
34
|
+
return true; // link-local
|
|
35
|
+
if (v6.startsWith("fc") || v6.startsWith("fd"))
|
|
36
|
+
return true; // unique-local fc00::/7
|
|
37
|
+
const mapped = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); // IPv4-mapped
|
|
38
|
+
if (mapped)
|
|
39
|
+
return isBlockedAddress(mapped[1]);
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// Parse + scheme-check + DNS-resolve a URL, rejecting any non-public address.
|
|
43
|
+
// Returns the URL plus the validated addresses (null when allowPrivate skips resolution).
|
|
44
|
+
async function validatePublic(raw, opts) {
|
|
45
|
+
let u;
|
|
46
|
+
try {
|
|
47
|
+
u = new URL(raw);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new InvalidInputError(`invalid gem URL: ${raw}`);
|
|
51
|
+
}
|
|
52
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
53
|
+
throw new InvalidInputError(`gem URL must be http(s), got ${u.protocol}`);
|
|
54
|
+
}
|
|
55
|
+
if (opts.allowPrivate)
|
|
56
|
+
return { url: u, validated: null };
|
|
57
|
+
const host = u.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
58
|
+
const results = await lookup(host, { all: true });
|
|
59
|
+
if (results.length === 0)
|
|
60
|
+
throw new InvalidInputError(`could not resolve gem URL host: ${host}`);
|
|
61
|
+
for (const r of results) {
|
|
62
|
+
if (isBlockedAddress(r.address))
|
|
63
|
+
throw new InvalidInputError(`refusing to fetch gem from non-public address ${r.address} (${host})`);
|
|
64
|
+
}
|
|
65
|
+
return { url: u, validated: results };
|
|
66
|
+
}
|
|
67
|
+
export async function assertPublicUrl(raw, opts = {}) {
|
|
68
|
+
return (await validatePublic(raw, opts)).url;
|
|
69
|
+
}
|
|
70
|
+
// A Node lookup function pinned to the validated addresses — it ignores the hostname it is
|
|
71
|
+
// asked to resolve, so a host that rebinds to a blocked IP after validation cannot redirect
|
|
72
|
+
// the socket. This is the piece that actually closes the validate→connect race.
|
|
73
|
+
export function makePinnedLookup(validated) {
|
|
74
|
+
return (_hostname, options, callback) => {
|
|
75
|
+
const cb = (typeof options === "function" ? options : callback);
|
|
76
|
+
const all = typeof options === "object" && options.all;
|
|
77
|
+
if (all)
|
|
78
|
+
cb(null, validated);
|
|
79
|
+
else
|
|
80
|
+
cb(null, validated[0].address, validated[0].family);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Fetch a .gem over http(s): validate the URL + every redirect hop, and pin each connection
|
|
84
|
+
// to the validated IP set via an undici dispatcher. Size-capped.
|
|
85
|
+
export async function fetchGemBytes(raw, opts = {}) {
|
|
86
|
+
const maxRedirects = opts.maxRedirects ?? 3;
|
|
87
|
+
const maxBytes = opts.maxBytes ?? 50 * 1024 * 1024;
|
|
88
|
+
let target = raw;
|
|
89
|
+
for (let hop = 0;; hop++) {
|
|
90
|
+
const { url, validated } = await validatePublic(target, opts);
|
|
91
|
+
const dispatcher = validated ? new Agent({ connect: { lookup: makePinnedLookup(validated) } }) : undefined;
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(url.toString(), { redirect: "manual", ...(dispatcher ? { dispatcher } : {}) });
|
|
94
|
+
const loc = res.headers.get("location");
|
|
95
|
+
if (res.status >= 300 && res.status < 400 && loc) {
|
|
96
|
+
if (hop >= maxRedirects)
|
|
97
|
+
throw new Error("too many redirects fetching gem");
|
|
98
|
+
target = new URL(loc, url).toString();
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`gem fetch failed: HTTP ${res.status}`);
|
|
103
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
104
|
+
if (buf.length > maxBytes)
|
|
105
|
+
throw new Error(`gem exceeds max size (${buf.length} > ${maxBytes} bytes)`);
|
|
106
|
+
return buf;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await dispatcher?.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/gem/sandbox.ts
|
|
2
|
+
// Pluggable sandbox-backend registry in front of the RunConnectFn seam. Each backend
|
|
3
|
+
// produces a RunConnectFn pre-scoped to a run dir. Auto-allow is capability-gated:
|
|
4
|
+
// isolated backends run permission:"allow" (the FS boundary bounds blast radius);
|
|
5
|
+
// the child-spawn fallback stays deny unless AGENTGEM_GEM_RUN_AUTOALLOW=1.
|
|
6
|
+
import { connectRunSession } from "./acpRun.js"; // value used at call-time (safe ESM cycle)
|
|
7
|
+
import { wrapWithSandbox } from "./sandboxLaunch.js";
|
|
8
|
+
import { binOnPath } from "./binPath.js";
|
|
9
|
+
export function envPermission(env = process.env) {
|
|
10
|
+
return env.AGENTGEM_GEM_RUN_AUTOALLOW === "1" ? "allow" : "deny";
|
|
11
|
+
}
|
|
12
|
+
// An isolated backend: wrap the agent command with the OS sandbox launcher (so the
|
|
13
|
+
// agent AND its child shells inherit the jail) and auto-allow tool calls. `bin` is the
|
|
14
|
+
// launcher resolved on PATH (not a hard-coded absolute path — distros place bwrap in
|
|
15
|
+
// /usr/bin or /usr/local/bin), matching the bare name `wrapWithSandbox` actually spawns.
|
|
16
|
+
function isolatedBackend(id, kind, bin, supported) {
|
|
17
|
+
return {
|
|
18
|
+
id, isolated: true,
|
|
19
|
+
available: () => supported() && binOnPath(bin),
|
|
20
|
+
connectFn: (runDir) => (descriptor, app) => connectRunSession({ ...descriptor, command: wrapWithSandbox(kind, runDir, descriptor.command) }, "allow", app),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const childSpawnBackend = {
|
|
24
|
+
id: "child-spawn",
|
|
25
|
+
isolated: false,
|
|
26
|
+
available: () => true,
|
|
27
|
+
connectFn: () => (descriptor, app) => connectRunSession(descriptor, envPermission(), app),
|
|
28
|
+
};
|
|
29
|
+
export const RUN_BACKENDS = [
|
|
30
|
+
isolatedBackend("macos-seatbelt", "macos-seatbelt", "sandbox-exec", () => process.platform === "darwin"),
|
|
31
|
+
isolatedBackend("linux-bubblewrap", "linux-bubblewrap", "bwrap", () => process.platform === "linux"),
|
|
32
|
+
childSpawnBackend,
|
|
33
|
+
];
|
|
34
|
+
export function selectRunBackend(runDir, registry = RUN_BACKENDS) {
|
|
35
|
+
const backend = registry.find((b) => b.isolated && b.available()) ?? registry[registry.length - 1] ?? childSpawnBackend;
|
|
36
|
+
return { backend, connectFn: backend.connectFn(runDir) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/gem/sandboxLaunch.ts
|
|
2
|
+
// Pure generators for the OS-native sandbox launchers. The v1 boundary contains
|
|
3
|
+
// FILESYSTEM WRITES to the run dir (+ temp); reads, exec, and network stay open. This
|
|
4
|
+
// "write-deny" shape (allow-all, then deny writes, then re-allow under runDir) avoids
|
|
5
|
+
// the deny-default trap that kills the agent's own runtime before it can start.
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { realpathSync } from "node:fs";
|
|
8
|
+
// Resolve symlinks when the path exists; fall back to the original string otherwise.
|
|
9
|
+
function tryRealpath(p) {
|
|
10
|
+
try {
|
|
11
|
+
return realpathSync(p);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function seatbeltPolicy(runDir, tmpDir = tmpdir()) {
|
|
18
|
+
// Resolve symlinks so the SBPL subpath clause matches the kernel's canonical path.
|
|
19
|
+
// On macOS, tmpdir() returns /var/folders/... but the kernel sees /private/var/folders/...
|
|
20
|
+
// Fall back to the original path if the directory doesn't exist yet.
|
|
21
|
+
const realRun = tryRealpath(runDir);
|
|
22
|
+
const realTmp = tryRealpath(tmpDir);
|
|
23
|
+
return [
|
|
24
|
+
"(version 1)",
|
|
25
|
+
"(allow default)",
|
|
26
|
+
"(deny file-write*)",
|
|
27
|
+
"(allow file-write*",
|
|
28
|
+
` (subpath ${q(realRun)})`,
|
|
29
|
+
` (subpath ${q(realTmp)})`,
|
|
30
|
+
' (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr")',
|
|
31
|
+
' (subpath "/dev/tty") (regex #"^/dev/fd/"))',
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
// SBPL string literal: wrap in double quotes (paths under our control have no quotes).
|
|
35
|
+
function q(p) { return `"${p}"`; }
|
|
36
|
+
export function bwrapArgs(runDir, tmpDir = tmpdir()) {
|
|
37
|
+
// Resolve symlinks so the writable bind matches the kernel's canonical path
|
|
38
|
+
// (mirrors seatbeltPolicy); fall back to the original if the dir doesn't exist yet.
|
|
39
|
+
const realRun = tryRealpath(runDir);
|
|
40
|
+
const realTmp = tryRealpath(tmpDir);
|
|
41
|
+
return [
|
|
42
|
+
"--ro-bind", "/", "/", // everything readable, nothing writable…
|
|
43
|
+
"--bind", realRun, realRun, // …except the run dir…
|
|
44
|
+
"--bind", realTmp, realTmp, // …and temp.
|
|
45
|
+
"--dev", "/dev",
|
|
46
|
+
"--unshare-pid", // own PID namespace: the agent can't see/signal host processes
|
|
47
|
+
"--proc", "/proc", // fresh procfs for that namespace (must follow --unshare-pid)
|
|
48
|
+
"--die-with-parent",
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
export function wrapWithSandbox(kind, runDir, command) {
|
|
52
|
+
if (kind === "macos-seatbelt")
|
|
53
|
+
return ["sandbox-exec", "-p", seatbeltPolicy(runDir), ...command];
|
|
54
|
+
return ["bwrap", ...bwrapArgs(runDir), "--", ...command];
|
|
55
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/gem/scrub.ts
|
|
2
|
+
//
|
|
3
|
+
// Field-aware, default-deny scrubber for builtin tool_use inputs captured during
|
|
4
|
+
// transcript distillation (see docs/proposals/skill-distillation-from-transcripts.md
|
|
5
|
+
// §3a). Unlike redact.ts (which redacts secret VALUES in a structured config),
|
|
6
|
+
// this keeps only an allowlisted structural slice per builtin and DROPS everything
|
|
7
|
+
// else — removing the file-content/PII class by construction rather than blocklist.
|
|
8
|
+
//
|
|
9
|
+
// Output per step: { verb, arg } — a coarse low-cardinality `verb` for procedure
|
|
10
|
+
// recurrence (§3c) and a minimal scrubbed `arg` for the agent.
|
|
11
|
+
// Token-level secret detection. redact.ts redacts the WHOLE value and can lean on
|
|
12
|
+
// bare keywords because it has key/value structure; here we scrub free-text command
|
|
13
|
+
// tokens, where bare words like "secret"/"token"/"password" legitimately appear in
|
|
14
|
+
// filenames and commit messages (`cat secret.env`). So the rule is intentionally
|
|
15
|
+
// narrower: a token is secret only if it is HIGH-ENTROPY or carries a known secret
|
|
16
|
+
// PREFIX — never a plain dictionary keyword. (Short keyword-less secrets survive;
|
|
17
|
+
// that residual risk is accepted under the draft-only review gate — proposal §3a.)
|
|
18
|
+
const SECRET_PREFIX_RE = /^(sk-|ghp_|gho_|ghu_|ghs_|github_pat_|xox[a-z]-|AKIA|ASIA|glpat-)/;
|
|
19
|
+
function looksLikeSecretToken(t) {
|
|
20
|
+
if (t.length >= 32 && /^[A-Za-z0-9_-]+$/.test(t))
|
|
21
|
+
return true; // high entropy
|
|
22
|
+
if (t.length >= 8 && SECRET_PREFIX_RE.test(t))
|
|
23
|
+
return true; // prefixed token
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// Rewrite $HOME-absolute prefixes to ~ and any /Users/<name>/ to ~/, so paths
|
|
27
|
+
// never carry a username. Applied before token scrub.
|
|
28
|
+
function dehomePaths(s) {
|
|
29
|
+
const home = process.env.HOME;
|
|
30
|
+
let out = home ? s.split(home).join("~") : s;
|
|
31
|
+
out = out.replace(/\/Users\/[^/\s]+\//g, "~/");
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
// Token-scrub a free-text arg: de-home paths, then replace any secret-looking
|
|
35
|
+
// whitespace token with <redacted>, leaving the rest of the command intact.
|
|
36
|
+
function scrubText(s) {
|
|
37
|
+
return dehomePaths(s)
|
|
38
|
+
.split(/(\s+)/) // keep separators so spacing is preserved
|
|
39
|
+
.map((tok) => (/\s/.test(tok) ? tok : redactToken(tok)))
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
// A token may carry a secret embedded in surrounding syntax (https://SECRET@host).
|
|
43
|
+
// Redact the embedded secret, not the whole token, so structure survives.
|
|
44
|
+
function redactToken(tok) {
|
|
45
|
+
if (!tok)
|
|
46
|
+
return tok;
|
|
47
|
+
return tok
|
|
48
|
+
.split(/([^A-Za-z0-9_-]+)/) // split on non-identifier runs, keep them
|
|
49
|
+
.map((part) => (/^[A-Za-z0-9_-]+$/.test(part) && looksLikeSecretToken(part) ? "<redacted>" : part))
|
|
50
|
+
.join("");
|
|
51
|
+
}
|
|
52
|
+
// Scrub free-text prose (mission-hint task/outcome). Same token scrub + de-home as
|
|
53
|
+
// command args, plus a hard length cap — this is the one place free text is kept,
|
|
54
|
+
// so it is deliberately short and low-detail (proposal §3b).
|
|
55
|
+
export function scrubProse(s, maxLen = 280) {
|
|
56
|
+
const scrubbed = scrubText(s).trim();
|
|
57
|
+
if (scrubbed.length <= maxLen)
|
|
58
|
+
return scrubbed;
|
|
59
|
+
return scrubbed.slice(0, maxLen) + "…";
|
|
60
|
+
}
|
|
61
|
+
// Coarse procedure verb: "git commit -m fix" -> "Bash:git commit", "cd /x" ->
|
|
62
|
+
// "Bash:cd", "/usr/bin/npx vitest" -> "Bash:npx vitest". argv0 is basenamed; the
|
|
63
|
+
// 2nd token counts as a subcommand ONLY if it's a clean lowercase word — a path,
|
|
64
|
+
// filename, flag, or quoted arg is NOT a subcommand, so it never inflates the verb.
|
|
65
|
+
function bashVerb(command) {
|
|
66
|
+
const toks = command.trim().split(/\s+/).filter(Boolean);
|
|
67
|
+
if (!toks.length)
|
|
68
|
+
return "Bash";
|
|
69
|
+
const argv0 = (toks[0].split("/").pop() || toks[0]).replace(/[;|&]+$/, "");
|
|
70
|
+
if (!argv0)
|
|
71
|
+
return "Bash";
|
|
72
|
+
const sub = toks[1] && /^[a-z][a-z0-9-]*$/.test(toks[1]) ? ` ${toks[1]}` : "";
|
|
73
|
+
return `Bash:${argv0}${sub}`;
|
|
74
|
+
}
|
|
75
|
+
function str(input, key) {
|
|
76
|
+
const v = input?.[key];
|
|
77
|
+
return typeof v === "string" ? v : "";
|
|
78
|
+
}
|
|
79
|
+
// Default-deny: each builtin keeps only an allowlisted structural slice; every
|
|
80
|
+
// other field (file contents, agent prompts, tool output, unknown fields) is
|
|
81
|
+
// dropped, not scrubbed — so the file-content/PII class is removed by construction.
|
|
82
|
+
export function scrubStep(tool, input) {
|
|
83
|
+
switch (tool) {
|
|
84
|
+
case "Bash": {
|
|
85
|
+
const command = str(input, "command");
|
|
86
|
+
return { verb: bashVerb(command), arg: scrubText(command) };
|
|
87
|
+
}
|
|
88
|
+
// Edit/Write/NotebookEdit: keep the path; DROP old_string/new_string/content.
|
|
89
|
+
case "Edit":
|
|
90
|
+
case "Write":
|
|
91
|
+
case "NotebookEdit":
|
|
92
|
+
return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "notebook_path")) };
|
|
93
|
+
// Read/Grep/Glob: keep the path/pattern; DROP file contents and match output.
|
|
94
|
+
case "Read":
|
|
95
|
+
case "Grep":
|
|
96
|
+
case "Glob":
|
|
97
|
+
return { verb: tool, arg: scrubText(str(input, "file_path") || str(input, "path") || str(input, "pattern")) };
|
|
98
|
+
// Task/agent spawns: keep the short description; DROP the prompt.
|
|
99
|
+
case "Task":
|
|
100
|
+
case "Agent": {
|
|
101
|
+
const sub = str(input, "subagent_type");
|
|
102
|
+
return { verb: sub ? `${tool}:${sub}` : tool, arg: scrubText(str(input, "description")) };
|
|
103
|
+
}
|
|
104
|
+
// Unknown tool: verb only, entire input dropped.
|
|
105
|
+
default:
|
|
106
|
+
return { verb: tool, arg: "" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Weighted field match: name >> tags > description. An empty query (with optional
|
|
2
|
+
// kind/tag filter) browses the catalog — every gem returns, score 0.
|
|
3
|
+
export function searchIndex(index, query, opts = {}) {
|
|
4
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
5
|
+
const hits = [];
|
|
6
|
+
for (const [key, item] of Object.entries(index.items)) {
|
|
7
|
+
const d = item.discovery ?? {};
|
|
8
|
+
if (opts.kind && !(d.artifactKinds ?? []).includes(opts.kind))
|
|
9
|
+
continue;
|
|
10
|
+
if (opts.tag && !(d.tags ?? []).includes(opts.tag))
|
|
11
|
+
continue;
|
|
12
|
+
const name = key.toLowerCase();
|
|
13
|
+
const tags = (d.tags ?? []).join(" ").toLowerCase();
|
|
14
|
+
const desc = (d.description ?? "").toLowerCase();
|
|
15
|
+
let score = 0;
|
|
16
|
+
for (const t of terms) {
|
|
17
|
+
if (name === t)
|
|
18
|
+
score += 100;
|
|
19
|
+
else if (name.includes(t))
|
|
20
|
+
score += 10;
|
|
21
|
+
if (tags.split(/\s+/).includes(t))
|
|
22
|
+
score += 5;
|
|
23
|
+
else if (tags.includes(t))
|
|
24
|
+
score += 3;
|
|
25
|
+
if (desc.includes(t))
|
|
26
|
+
score += 1;
|
|
27
|
+
}
|
|
28
|
+
if (terms.length && score === 0)
|
|
29
|
+
continue; // query given but nothing matched
|
|
30
|
+
hits.push({ key, latest: item.latest, score, description: d.description, tags: d.tags, author: d.author, artifactKinds: d.artifactKinds, updatedAt: d.updatedAt });
|
|
31
|
+
}
|
|
32
|
+
hits.sort((a, b) => b.score - a.score || a.key.localeCompare(b.key));
|
|
33
|
+
return hits.slice(0, opts.limit ?? 25);
|
|
34
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/gem/share.ts
|
|
2
|
+
// The registry-optional "easy share" loop: turn a Gem into one portable .gem file
|
|
3
|
+
// and install one back. Pure + in-process — no disk/network — so it composes with
|
|
4
|
+
// any transport (file, URL, gist, paste). Integrity is inherited from readGemArchive,
|
|
5
|
+
// which verifies gem.lock and throws on any mismatch, so a tampered .gem never installs.
|
|
6
|
+
import { writeGemArchive, readGemArchive, readGemMeta } from "./archive.js";
|
|
7
|
+
import { packTar, unpackTar } from "./archiveTar.js";
|
|
8
|
+
import { safePathSegment } from "./targets.js";
|
|
9
|
+
// Gem -> a single self-verifying .gem (gzipped tar of the archive file tree).
|
|
10
|
+
export function exportGem(gem, opts = {}) {
|
|
11
|
+
const { files, skipped } = writeGemArchive(gem, opts);
|
|
12
|
+
const version = opts.version ?? "0.1.0";
|
|
13
|
+
return { filename: `${safePathSegment(gem.name)}-${version}.gem`, bytes: packTar(files), skipped };
|
|
14
|
+
}
|
|
15
|
+
// A .gem's bytes -> the verified Gem. Throws if the bytes aren't a valid archive
|
|
16
|
+
// or if gem.lock verification fails (tampering / corruption).
|
|
17
|
+
export function importGem(bytes) {
|
|
18
|
+
const files = unpackTar(bytes);
|
|
19
|
+
const gem = readGemArchive(files); // verifies gem.lock; throws on mismatch
|
|
20
|
+
return { gem, meta: readGemMeta(files) };
|
|
21
|
+
}
|