@fraylabs/possible 0.1.9 → 0.2.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.
@@ -0,0 +1,201 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { basename, join, relative, resolve } from "node:path";
3
+
4
+ const SAFE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
5
+ const EXACT_REVISION = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
6
+ const PRODUCT_ID = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
7
+ const GITHUB_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8
+ const SAFE_REPOSITORY_PATH = /^(?:\.|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/;
9
+ const MODEL_ROLES = new Set(["authorship", "execution", "review"]);
10
+ const FILE_TYPES = new Set(["image", "video", "audio", "cad", "document", "data", "source", "archive", "other"]);
11
+ const MANIFEST_KEYS = new Set(["schemaVersion", "slug", "files", "authoredAt", "author", "models", "requirements", "products", "skills", "inputs", "artifacts", "preview"]);
12
+
13
+ const asObject = (value, context) => {
14
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${context} must be a JSON object`);
15
+ return value;
16
+ };
17
+
18
+ const string = (value, context) => {
19
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${context} must be a non-empty string`);
20
+ return value;
21
+ };
22
+
23
+ const exactKeys = (value, allowed, context) => {
24
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${context}.${key} is unsupported`);
25
+ };
26
+
27
+ const httpsUrl = (value, context) => {
28
+ const candidate = string(value, context);
29
+ let parsed;
30
+ try { parsed = new URL(candidate); } catch { throw new Error(`${context} must be an HTTPS URL`); }
31
+ if (parsed.protocol !== "https:") throw new Error(`${context} must be an HTTPS URL`);
32
+ return candidate;
33
+ };
34
+
35
+ const safeRelativePath = (value, context) => {
36
+ const candidate = string(value, context);
37
+ if (candidate.includes("\\") || candidate.startsWith("/") || candidate.split("/").some((part) => part === "" || part === "." || part === "..")) {
38
+ throw new Error(`${context} must be a safe repository-relative path`);
39
+ }
40
+ return candidate;
41
+ };
42
+
43
+ const validateFiles = (value, context) => {
44
+ if (value === undefined) return;
45
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${context} must be omitted or a non-empty array`);
46
+ const seen = new Set();
47
+ value.forEach((entry, index) => {
48
+ const file = asObject(entry, `${context}[${index}]`);
49
+ exactKeys(file, new Set(["type", "src", "label", "format"]), `${context}[${index}]`);
50
+ if (!FILE_TYPES.has(file.type)) throw new Error(`${context}[${index}].type is unsupported`);
51
+ const source = file.src?.startsWith("https://") ? httpsUrl(file.src, `${context}[${index}].src`) : safeRelativePath(file.src, `${context}[${index}].src`);
52
+ string(file.label, `${context}[${index}].label`);
53
+ if (file.format !== undefined) string(file.format, `${context}[${index}].format`);
54
+ if (seen.has(source)) throw new Error(`${context} contains duplicate source ${source}`);
55
+ seen.add(source);
56
+ });
57
+ };
58
+
59
+ export function validateOutcomeManifest(value, context = "outcome.json") {
60
+ const manifest = asObject(value, context);
61
+ exactKeys(manifest, MANIFEST_KEYS, context);
62
+ if (manifest.schemaVersion !== 3) throw new Error(`${context}.schemaVersion must be 3`);
63
+ if (!SAFE_SLUG.test(string(manifest.slug, `${context}.slug`))) throw new Error(`${context}.slug must be lowercase and hyphenated`);
64
+
65
+ const files = asObject(manifest.files, `${context}.files`);
66
+ exactKeys(files, new Set(["about", "prompt"]), `${context}.files`);
67
+ if (files.about !== "outcome.md" || files.prompt !== "prompt.md") throw new Error(`${context}.files must reference outcome.md and prompt.md`);
68
+
69
+ if (manifest.authoredAt !== null) {
70
+ const authoredAt = string(manifest.authoredAt, `${context}.authoredAt`);
71
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || Number.isNaN(Date.parse(authoredAt))) {
72
+ throw new Error(`${context}.authoredAt must be null or an ISO 8601 timestamp with a timezone`);
73
+ }
74
+ }
75
+
76
+ const author = asObject(manifest.author, `${context}.author`);
77
+ exactKeys(author, new Set(["name", "url"]), `${context}.author`);
78
+ string(author.name, `${context}.author.name`);
79
+ httpsUrl(author.url, `${context}.author.url`);
80
+
81
+ if (!Array.isArray(manifest.models) || manifest.models.length === 0) throw new Error(`${context}.models must be a non-empty array`);
82
+ manifest.models.forEach((entry, index) => {
83
+ const model = asObject(entry, `${context}.models[${index}]`);
84
+ exactKeys(model, new Set(["provider", "model", "agent", "role"]), `${context}.models[${index}]`);
85
+ string(model.provider, `${context}.models[${index}].provider`);
86
+ string(model.model, `${context}.models[${index}].model`);
87
+ if (model.agent !== undefined) string(model.agent, `${context}.models[${index}].agent`);
88
+ if (!MODEL_ROLES.has(model.role)) throw new Error(`${context}.models[${index}].role is unsupported`);
89
+ });
90
+
91
+ if (!Array.isArray(manifest.requirements)) throw new Error(`${context}.requirements must be an array`);
92
+ const requirements = manifest.requirements.map((entry, index) => string(entry, `${context}.requirements[${index}]`));
93
+ if (new Set(requirements).size !== requirements.length) throw new Error(`${context}.requirements contains duplicates`);
94
+
95
+ if (manifest.products !== undefined) {
96
+ if (!Array.isArray(manifest.products) || manifest.products.length === 0) throw new Error(`${context}.products must be omitted or a non-empty array`);
97
+ for (const [index, product] of manifest.products.entries()) if (!PRODUCT_ID.test(string(product, `${context}.products[${index}]`))) throw new Error(`${context}.products[${index}] is invalid`);
98
+ if (new Set(manifest.products).size !== manifest.products.length) throw new Error(`${context}.products contains duplicates`);
99
+ }
100
+
101
+ if (manifest.skills !== undefined) {
102
+ if (!Array.isArray(manifest.skills) || manifest.skills.length === 0) throw new Error(`${context}.skills must be omitted or a non-empty array`);
103
+ for (const [index, entry] of manifest.skills.entries()) {
104
+ const skill = asObject(entry, `${context}.skills[${index}]`);
105
+ exactKeys(skill, new Set(["repository", "lastReviewedCommit", "directory"]), `${context}.skills[${index}]`);
106
+ if (!GITHUB_REPOSITORY.test(string(skill.repository, `${context}.skills[${index}].repository`))) throw new Error(`${context}.skills[${index}].repository is invalid`);
107
+ if (!EXACT_REVISION.test(string(skill.lastReviewedCommit, `${context}.skills[${index}].lastReviewedCommit`))) throw new Error(`${context}.skills[${index}].lastReviewedCommit must be an exact commit`);
108
+ const directory = string(skill.directory, `${context}.skills[${index}].directory`);
109
+ if (!SAFE_REPOSITORY_PATH.test(directory) || (directory !== "." && directory.split("/").some((part) => part === "." || part === ".."))) throw new Error(`${context}.skills[${index}].directory is invalid`);
110
+ }
111
+ }
112
+
113
+ validateFiles(manifest.inputs, `${context}.inputs`);
114
+ validateFiles(manifest.artifacts, `${context}.artifacts`);
115
+ if (manifest.preview !== undefined) asObject(manifest.preview, `${context}.preview`);
116
+ return manifest;
117
+ }
118
+
119
+ const plainInlineMarkdown = (value) => value
120
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
121
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
122
+ .replace(/[*_~`]/g, "")
123
+ .replace(/\s+/g, " ")
124
+ .trim();
125
+
126
+ export function parseOutcomeMarkdown(value, context = "outcome.md") {
127
+ const markdown = string(value, context).trim();
128
+ const lines = markdown.split(/\r?\n/);
129
+ const titleLine = lines[0] ?? "";
130
+ if (!titleLine.startsWith("# ") || titleLine.slice(2).trim().length === 0) throw new Error(`${context} must begin with one # title`);
131
+ if (lines.slice(1).some((line) => line.startsWith("# "))) throw new Error(`${context} must contain exactly one # title`);
132
+ let index = 1;
133
+ while (index < lines.length && lines[index]?.trim() === "") index += 1;
134
+ const summaryLines = [];
135
+ while (index < lines.length && lines[index]?.trim() !== "" && !lines[index]?.startsWith("## ")) {
136
+ summaryLines.push(lines[index]);
137
+ index += 1;
138
+ }
139
+ const summaryMarkdown = summaryLines.join("\n").trim();
140
+ if (!summaryMarkdown) throw new Error(`${context} must contain an opening summary after its title`);
141
+
142
+ const originalHeading = lines.findIndex((line) => line.trim().toLowerCase() === "## original request");
143
+ let originalPrompt;
144
+ if (originalHeading >= 0) {
145
+ const quoted = [];
146
+ for (const line of lines.slice(originalHeading + 1)) {
147
+ if (line.startsWith("## ")) break;
148
+ if (line.startsWith("> ")) quoted.push(line.slice(2));
149
+ else if (line === ">") quoted.push("");
150
+ }
151
+ const joined = quoted.join("\n").trim();
152
+ if (joined) originalPrompt = joined;
153
+ }
154
+ return {
155
+ title: plainInlineMarkdown(titleLine.slice(2)),
156
+ summary: plainInlineMarkdown(summaryMarkdown),
157
+ summaryMarkdown,
158
+ markdown,
159
+ ...(originalPrompt ? { originalPrompt } : {}),
160
+ };
161
+ }
162
+
163
+ export async function readOutcomeFolder(folder) {
164
+ const [manifestText, aboutText, promptText] = await Promise.all([
165
+ readFile(join(folder, "outcome.json"), "utf8"),
166
+ readFile(join(folder, "outcome.md"), "utf8"),
167
+ readFile(join(folder, "prompt.md"), "utf8"),
168
+ ]);
169
+ const manifest = validateOutcomeManifest(JSON.parse(manifestText), `${relative(process.cwd(), join(folder, "outcome.json")) || "outcome.json"}`);
170
+ if (basename(folder) !== manifest.slug) throw new Error(`${manifest.slug}: folder name must match outcome.json slug`);
171
+ const about = parseOutcomeMarkdown(aboutText, `${manifest.slug}/outcome.md`);
172
+ const executionPrompt = string(promptText, `${manifest.slug}/prompt.md`).trim();
173
+ return { slug: manifest.slug, folder, manifest, about, executionPrompt };
174
+ }
175
+
176
+ export async function discoverLocalOutcomes(repositoryRoot) {
177
+ const found = [];
178
+ async function walk(directory) {
179
+ const entries = await readdir(directory, { withFileTypes: true });
180
+ if (entries.some((entry) => entry.isFile() && entry.name === "outcome.json")) {
181
+ found.push(await readOutcomeFolder(directory));
182
+ return;
183
+ }
184
+ for (const entry of entries) {
185
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === ".git" || entry.name === "node_modules") continue;
186
+ await walk(join(directory, entry.name));
187
+ }
188
+ }
189
+ await walk(resolve(repositoryRoot));
190
+ if (found.length === 0) throw new Error(`No Outcome folders found under ${repositoryRoot}`);
191
+ const identities = new Set();
192
+ for (const outcome of found) {
193
+ if (identities.has(outcome.slug)) throw new Error(`Duplicate Outcome slug: ${outcome.slug}`);
194
+ identities.add(outcome.slug);
195
+ }
196
+ return found.sort((left, right) => left.about.title.localeCompare(right.about.title));
197
+ }
198
+
199
+ export function relativeOutcomePath(repositoryRoot, folder) {
200
+ return relative(resolve(repositoryRoot), resolve(folder)).split("\\").join("/");
201
+ }
@@ -0,0 +1,174 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isIP } from "node:net";
3
+ import { parseOutcomeMarkdown, validateOutcomeManifest } from "./outcome-format.mjs";
4
+
5
+ const MAX_OUTCOMES = 100;
6
+ const MAX_DOCUMENT_BYTES = 1024 * 1024;
7
+ const GITHUB_SHORTHAND = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/;
8
+
9
+ function privateHostname(value) {
10
+ const hostname = value.toLowerCase().replace(/^\[|\]$/g, "");
11
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) return true;
12
+ if (isIP(hostname) === 4) {
13
+ const [first, second] = hostname.split(".").map(Number);
14
+ return first === 0 || first === 10 || first === 127 || first >= 224
15
+ || (first === 100 && second >= 64 && second <= 127)
16
+ || (first === 169 && second === 254)
17
+ || (first === 172 && second >= 16 && second <= 31)
18
+ || (first === 192 && second === 168)
19
+ || (first === 198 && (second === 18 || second === 19));
20
+ }
21
+ if (isIP(hostname) === 6) return hostname === "::" || hostname === "::1" || /^(?:fc|fd|fe[89ab])/i.test(hostname) || /^::ffff:(?:0\.|10\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
22
+ return false;
23
+ }
24
+
25
+ function normalizedBaseUrl(value) {
26
+ const url = new URL(value);
27
+ if (url.protocol !== "https:") throw new Error("Publisher domains must use HTTPS");
28
+ url.hash = "";
29
+ url.search = "";
30
+ url.pathname = url.pathname.replace(/\/+$/, "");
31
+ return url;
32
+ }
33
+
34
+ export function parseOutcomeSource(value) {
35
+ const source = String(value ?? "").trim();
36
+ const shorthand = source.match(GITHUB_SHORTHAND);
37
+ if (shorthand) {
38
+ const owner = shorthand[1];
39
+ const repository = shorthand[2].replace(/\.git$/, "");
40
+ return { type: "github", locator: `${owner}/${repository}`, installUrl: `https://github.com/${owner}/${repository}` };
41
+ }
42
+ let url;
43
+ try { url = new URL(source); } catch { throw new Error("Source must be a GitHub owner/repository or an HTTPS publisher URL"); }
44
+ if (url.hostname.toLowerCase() === "github.com") {
45
+ const [owner, repository] = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
46
+ if (!owner || !repository) throw new Error("GitHub sources must identify an owner and repository");
47
+ const cleanRepository = repository.replace(/\.git$/, "");
48
+ return { type: "github", locator: `${owner}/${cleanRepository}`, installUrl: `https://github.com/${owner}/${cleanRepository}` };
49
+ }
50
+ if (privateHostname(url.hostname)) throw new Error("Publisher source cannot use a local or private network address");
51
+ const base = normalizedBaseUrl(url.toString());
52
+ return { type: "well-known", locator: base.origin, installUrl: base.toString() };
53
+ }
54
+
55
+ async function responseText(response, context) {
56
+ if (!response.ok) throw new Error(`${context} returned HTTP ${response.status}`);
57
+ const declared = Number(response.headers.get("content-length") ?? 0);
58
+ if (declared > MAX_DOCUMENT_BYTES) throw new Error(`${context} exceeds the 1 MiB document limit`);
59
+ const text = await response.text();
60
+ if (Buffer.byteLength(text) > MAX_DOCUMENT_BYTES) throw new Error(`${context} exceeds the 1 MiB document limit`);
61
+ return text;
62
+ }
63
+
64
+ async function fetchJson(url, context, headers = {}) {
65
+ const response = await fetch(url, { headers: { accept: "application/json", ...headers }, redirect: "error" });
66
+ return JSON.parse(await responseText(response, context));
67
+ }
68
+
69
+ async function fetchText(url, context, headers = {}) {
70
+ const response = await fetch(url, { headers: { accept: "text/markdown,text/plain;q=0.9", ...headers }, redirect: "error" });
71
+ return responseText(response, context);
72
+ }
73
+
74
+ const digestDocuments = ({ manifestText, aboutText, promptText }) => `sha256:${createHash("sha256").update(manifestText).update("\0").update(aboutText).update("\0").update(promptText).digest("hex")}`;
75
+
76
+ function resolvedRemoteOutcome({ manifestText, aboutText, promptText, manifestUrl, aboutUrl, promptUrl, repositoryPath }) {
77
+ const manifest = validateOutcomeManifest(JSON.parse(manifestText), `${manifestUrl}`);
78
+ const about = parseOutcomeMarkdown(aboutText, `${aboutUrl}`);
79
+ const prompt = promptText.trim();
80
+ if (!prompt) throw new Error(`${promptUrl} must contain the exact execution prompt`);
81
+ return {
82
+ slug: manifest.slug,
83
+ title: about.title,
84
+ summary: about.summary,
85
+ aboutMarkdown: about.markdown,
86
+ prompt,
87
+ manifest,
88
+ manifestUrl,
89
+ aboutUrl,
90
+ promptUrl,
91
+ repositoryPath,
92
+ contentHash: digestDocuments({ manifestText, aboutText, promptText }),
93
+ };
94
+ }
95
+
96
+ async function discoverGitHub(source, options) {
97
+ const headers = { "user-agent": "possible-cli" };
98
+ const token = options.githubToken ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
99
+ if (token) headers.authorization = `Bearer ${token}`;
100
+ const repository = await fetchJson(`https://api.github.com/repos/${source.locator}`, `GitHub repository ${source.locator}`, headers);
101
+ if (repository.private !== false) throw new Error(`${source.locator} is not a public GitHub repository`);
102
+ const revisionRecord = await fetchJson(`https://api.github.com/repos/${source.locator}/commits/${encodeURIComponent(repository.default_branch)}`, `GitHub revision ${source.locator}`, headers);
103
+ const revision = String(revisionRecord.sha ?? "");
104
+ if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error(`GitHub did not return an exact revision for ${source.locator}`);
105
+ const rawBase = `https://raw.githubusercontent.com/${source.locator}/${revision}/`;
106
+ const indexUrl = new URL("outcomes.json", rawBase).toString();
107
+ const index = await fetchJson(indexUrl, `${source.locator}/outcomes.json`, headers);
108
+ if (index?.schemaVersion !== 1 || !Array.isArray(index.outcomes)) throw new Error(`${source.locator}/outcomes.json must be a Possible publisher index with schemaVersion 1`);
109
+ if (index.outcomes.length === 0 || index.outcomes.length > MAX_OUTCOMES) throw new Error(`${source.locator}/outcomes.json must list between 1 and ${MAX_OUTCOMES} Outcomes`);
110
+ const outcomes = [];
111
+ for (const [indexPosition, entry] of index.outcomes.entries()) {
112
+ if (!entry || typeof entry !== "object" || typeof entry.url !== "string") throw new Error(`${source.locator}/outcomes.json outcomes[${indexPosition}] must contain a URL`);
113
+ const manifestUrl = new URL(entry.url, indexUrl);
114
+ if (manifestUrl.origin !== new URL(rawBase).origin || !manifestUrl.pathname.startsWith(new URL(rawBase).pathname)) throw new Error(`${source.locator}/outcomes.json Outcome URLs must stay inside the exact repository revision`);
115
+ const folderUrl = new URL("./", manifestUrl);
116
+ const aboutUrl = new URL("outcome.md", folderUrl).toString();
117
+ const promptUrl = new URL("prompt.md", folderUrl).toString();
118
+ const [manifestText, aboutText, promptText] = await Promise.all([
119
+ fetchText(manifestUrl.toString(), `Outcome manifest ${manifestUrl}`, headers),
120
+ fetchText(aboutUrl, `Outcome page ${aboutUrl}`, headers),
121
+ fetchText(promptUrl, `Outcome prompt ${promptUrl}`, headers),
122
+ ]);
123
+ const repositoryPath = decodeURIComponent(manifestUrl.pathname.slice(new URL(rawBase).pathname.length)).replace(/\/outcome\.json$/, "");
124
+ const outcome = resolvedRemoteOutcome({ manifestText, aboutText, promptText, manifestUrl: manifestUrl.toString(), aboutUrl, promptUrl, repositoryPath });
125
+ if (entry.slug !== undefined && entry.slug !== outcome.slug) throw new Error(`${source.locator}/outcomes.json slug does not match ${manifestUrl}`);
126
+ if (outcome.slug !== repositoryPath.split("/").filter(Boolean).at(-1)) throw new Error(`${manifestUrl} slug must match its folder name`);
127
+ outcomes.push(outcome);
128
+ }
129
+ if (new Set(outcomes.map(({ slug }) => slug)).size !== outcomes.length) throw new Error(`${source.locator}/outcomes.json contains duplicate Outcome slugs`);
130
+ return { ...source, revision, publisherName: index.publisher?.name ?? source.locator.split("/")[0], outcomes };
131
+ }
132
+
133
+ async function discoverWellKnown(source) {
134
+ const indexUrl = new URL("/.well-known/possible/outcomes.json", source.locator).toString();
135
+ const index = await fetchJson(indexUrl, `Possible index ${indexUrl}`);
136
+ if (index?.schemaVersion !== 1 || !Array.isArray(index.outcomes)) throw new Error(`${indexUrl} must be a Possible publisher index with schemaVersion 1`);
137
+ if (index.outcomes.length === 0 || index.outcomes.length > MAX_OUTCOMES) throw new Error(`${indexUrl} must list between 1 and ${MAX_OUTCOMES} Outcomes`);
138
+ const outcomes = [];
139
+ for (const [indexPosition, entry] of index.outcomes.entries()) {
140
+ if (!entry || typeof entry !== "object" || typeof entry.url !== "string") throw new Error(`${indexUrl} outcomes[${indexPosition}] must contain a URL`);
141
+ const manifestUrl = new URL(entry.url, indexUrl);
142
+ if (manifestUrl.protocol !== "https:" || manifestUrl.origin !== new URL(source.locator).origin) throw new Error(`${indexUrl} Outcome URLs must stay on the publisher origin`);
143
+ const folderUrl = new URL("./", manifestUrl);
144
+ const aboutUrl = new URL("outcome.md", folderUrl).toString();
145
+ const promptUrl = new URL("prompt.md", folderUrl).toString();
146
+ const [manifestText, aboutText, promptText] = await Promise.all([
147
+ fetchText(manifestUrl.toString(), `Outcome manifest ${manifestUrl}`),
148
+ fetchText(aboutUrl, `Outcome page ${aboutUrl}`),
149
+ fetchText(promptUrl, `Outcome prompt ${promptUrl}`),
150
+ ]);
151
+ const outcome = resolvedRemoteOutcome({ manifestText, aboutText, promptText, manifestUrl: manifestUrl.toString(), aboutUrl, promptUrl });
152
+ if (entry.slug !== undefined && entry.slug !== outcome.slug) throw new Error(`${indexUrl} slug does not match ${manifestUrl}`);
153
+ outcomes.push(outcome);
154
+ }
155
+ if (new Set(outcomes.map(({ slug }) => slug)).size !== outcomes.length) throw new Error(`${indexUrl} contains duplicate Outcome slugs`);
156
+ const revision = `sha256:${createHash("sha256").update(outcomes.map(({ contentHash }) => contentHash).sort().join("\n")).digest("hex")}`;
157
+ return { ...source, revision, publisherName: index.publisher?.name ?? new URL(source.locator).hostname, outcomes };
158
+ }
159
+
160
+ export async function discoverOutcomeSource(value, options = {}) {
161
+ const source = typeof value === "string" ? parseOutcomeSource(value) : value;
162
+ return source.type === "github" ? discoverGitHub(source, options) : discoverWellKnown(source);
163
+ }
164
+
165
+ export function publicSnapshot(discovery) {
166
+ return {
167
+ schemaVersion: 1,
168
+ source: { type: discovery.type, locator: discovery.locator, installUrl: discovery.installUrl, revision: discovery.revision },
169
+ publisherName: discovery.publisherName,
170
+ outcomes: discovery.outcomes.map(({ slug, title, summary, aboutMarkdown, prompt, manifest, manifestUrl, aboutUrl, promptUrl, repositoryPath, contentHash }) => ({
171
+ slug, title, summary, aboutMarkdown, prompt, manifest, manifestUrl, aboutUrl, promptUrl, repositoryPath, contentHash,
172
+ })),
173
+ };
174
+ }