omakit 0.1.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/LICENSE +21 -0
- package/README.md +229 -0
- package/bin/omakit +3 -0
- package/package.json +40 -0
- package/skills/omarchy-plugin-submit/SKILL.md +87 -0
- package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
- package/tests/parity/corpus.mjs +62 -0
- package/tests/parity/run.mjs +194 -0
- package/tools/marketplace/README.md +64 -0
- package/tools/marketplace/agent-control.mjs +72 -0
- package/tools/marketplace/banner.mjs +307 -0
- package/tools/marketplace/cli.mjs +283 -0
- package/tools/marketplace/completion.mjs +263 -0
- package/tools/marketplace/doctor.mjs +156 -0
- package/tools/marketplace/effect.mjs +115 -0
- package/tools/marketplace/form.mjs +209 -0
- package/tools/marketplace/github.mjs +206 -0
- package/tools/marketplace/issue.mjs +80 -0
- package/tools/marketplace/local-transport.mjs +162 -0
- package/tools/marketplace/parity-output.mjs +26 -0
- package/tools/marketplace/paths.mjs +10 -0
- package/tools/marketplace/pin.mjs +203 -0
- package/tools/marketplace/plugin.mjs +64 -0
- package/tools/marketplace/preflight.mjs +164 -0
- package/tools/marketplace/progress.mjs +96 -0
- package/tools/marketplace/registry.mjs +216 -0
- package/tools/marketplace/report.mjs +192 -0
- package/tools/marketplace/run-baseline.mjs +72 -0
- package/tools/marketplace/setup.mjs +136 -0
- package/tools/marketplace/style.mjs +443 -0
- package/tools/marketplace/submit.mjs +341 -0
- package/tools/marketplace/tree.mjs +50 -0
- package/tools/marketplace/upgrade.mjs +179 -0
- package/tools/marketplace/usage.mjs +191 -0
- package/tools/marketplace/verify.mjs +70 -0
- package/tools/marketplace/watch.mjs +262 -0
- package/tools/marketplace/yaml.mjs +164 -0
- package/tools/subject/resolve.mjs +124 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Read-only GitHub access. GET only, no mutation of any kind, and the
|
|
2
|
+
// credential is borrowed, used for GET, and never written anywhere.
|
|
3
|
+
//
|
|
4
|
+
// Omakit is read-only against omacom/omarchy-plugin-marketplace at all times.
|
|
5
|
+
// There is no code path in this repository that issues a POST, PATCH, PUT or
|
|
6
|
+
// DELETE, and no code path that creates an issue, comment, label or pull
|
|
7
|
+
// request. `omakit submit` prints a body for a person to post; it never posts.
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process"
|
|
10
|
+
|
|
11
|
+
export class GitHubError extends Error {
|
|
12
|
+
constructor(code, message, status) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = "GitHubError"
|
|
15
|
+
this.code = code
|
|
16
|
+
this.status = status
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const USER_AGENT = "omakit-marketplace-submit (read-only; https://github.com/mtolhuys/omakit)"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the read-only credential comes from, in order.
|
|
24
|
+
*
|
|
25
|
+
* `gh` is first among the things a person actually has. The audience for this
|
|
26
|
+
* tool is people who submit plugins to a GitHub-hosted marketplace by opening
|
|
27
|
+
* an issue; the overlap between them and people with `gh auth login` already
|
|
28
|
+
* done is most of them. Asking them instead to mint a personal access token,
|
|
29
|
+
* for a tool that only ever issues GET, is a bad trade: it is friction for the
|
|
30
|
+
* honest case and a new long-lived secret on disk for the dishonest one. And
|
|
31
|
+
* `gh` is the only source: it honours GH_TOKEN and GITHUB_TOKEN itself
|
|
32
|
+
* (measured: `gh auth token` prints an environment token straight back), so
|
|
33
|
+
* an agent with a token in its environment is covered through the same one
|
|
34
|
+
* call, and omakit reads no variable of its own.
|
|
35
|
+
*
|
|
36
|
+
* Borrowing `gh`'s credential means borrowing whatever scopes that login has,
|
|
37
|
+
* which is usually enough to write. This repository keeps that safe the only
|
|
38
|
+
* way worth trusting, which is structurally rather than by intention: there is
|
|
39
|
+
* exactly one `fetch` call site in the whole tool, it is in this file, its
|
|
40
|
+
* method is the literal "GET", and tests/unit/read-only.test.mjs fails the
|
|
41
|
+
* suite if a second one appears anywhere, if any file spawns `gh` with
|
|
42
|
+
* arguments other than the four below, or if a credential is ever written to
|
|
43
|
+
* disk.
|
|
44
|
+
*/
|
|
45
|
+
export const GH_ARGS = Object.freeze(["auth", "token", "--hostname", "github.com"])
|
|
46
|
+
|
|
47
|
+
/** GitHub's unauthenticated REST allowance, per hour, per IP. */
|
|
48
|
+
export const UNAUTHENTICATED_LIMIT = 60
|
|
49
|
+
|
|
50
|
+
// Long enough for a cold `gh` on a slow disk, short enough that a broken `gh`
|
|
51
|
+
// cannot hold up a command that does not need a credential at all.
|
|
52
|
+
const GH_TIMEOUT_MS = 4000
|
|
53
|
+
|
|
54
|
+
// A token shape, not a token: enough to tell a credential from `gh`'s own
|
|
55
|
+
// "not logged in" chatter, and it is never logged either way.
|
|
56
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9_.-]{20,255}$/
|
|
57
|
+
|
|
58
|
+
/** The credential `gh` holds for github.com, or null if there is not one. */
|
|
59
|
+
export function ghCredential({ run = execFileSync } = {}) {
|
|
60
|
+
let printed
|
|
61
|
+
try {
|
|
62
|
+
printed = run("gh", [...GH_ARGS], {
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
65
|
+
timeout: GH_TIMEOUT_MS,
|
|
66
|
+
})
|
|
67
|
+
} catch {
|
|
68
|
+
// Not installed, not signed in, or too slow to wait for. All three mean the
|
|
69
|
+
// same thing here, and none of them is an error worth reporting: every
|
|
70
|
+
// command that needs a credential works without one, just rate-limited.
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
const value = String(printed || "").trim()
|
|
74
|
+
return TOKEN_SHAPE.test(value) ? value : null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {{ gh?: () => string|null }} [options]
|
|
79
|
+
* @returns {{ value: string|null, source: "gh"|null, detail: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function resolveCredential({ gh = ghCredential } = {}) {
|
|
82
|
+
const borrowed = gh()
|
|
83
|
+
if (borrowed) return { value: borrowed, source: "gh", detail: "read from your `gh` login" }
|
|
84
|
+
return {
|
|
85
|
+
value: null,
|
|
86
|
+
source: null,
|
|
87
|
+
detail: `no GitHub login found; GitHub allows ${UNAUTHENTICATED_LIMIT} unauthenticated requests an hour`,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let resolved = null
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The resolved credential, looked up once per process.
|
|
95
|
+
*
|
|
96
|
+
* Cached because resolving it may spawn `gh`, and `watch` asks for it on every
|
|
97
|
+
* request. `refresh` is for the two commands that report the answer to a person
|
|
98
|
+
* rather than use it.
|
|
99
|
+
*/
|
|
100
|
+
export function credential({ refresh = false } = {}) {
|
|
101
|
+
if (refresh || !resolved) resolved = resolveCredential()
|
|
102
|
+
return resolved
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function token() {
|
|
106
|
+
return credential().value
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function get(url, { accept = "application/vnd.github+json" } = {}) {
|
|
110
|
+
const headers = { accept, "user-agent": USER_AGENT }
|
|
111
|
+
const auth = token()
|
|
112
|
+
if (auth) headers.authorization = `Bearer ${auth}`
|
|
113
|
+
let response
|
|
114
|
+
try {
|
|
115
|
+
response = await fetch(url, { method: "GET", headers, redirect: "follow" })
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// Node reports every transport failure as "fetch failed" with the real
|
|
118
|
+
// reason in `cause`. A person needs the reason, and the CLI keys its
|
|
119
|
+
// remedy on the code, so both are carried out of here.
|
|
120
|
+
const cause = error?.cause?.code || error?.cause?.message || error?.message || "fetch failed"
|
|
121
|
+
const { host, pathname } = new URL(url)
|
|
122
|
+
throw new GitHubError("network-unavailable", `${host} did not answer (${cause}) while reading ${pathname}`)
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
const remaining = response.headers.get("x-ratelimit-remaining")
|
|
126
|
+
const hint = response.status === 403 && remaining === "0"
|
|
127
|
+
? (auth
|
|
128
|
+
? " (GitHub rate limit exhausted even authenticated; it resets within the hour)"
|
|
129
|
+
: ` (GitHub rate limit exhausted at ${UNAUTHENTICATED_LIMIT} requests an hour; \`gh auth login\` raises it, and omakit reads that login read-only)`)
|
|
130
|
+
: ""
|
|
131
|
+
throw new GitHubError(
|
|
132
|
+
response.status === 404 ? "not-found" : "github-unavailable",
|
|
133
|
+
`GET ${url} returned ${response.status}${hint}`,
|
|
134
|
+
response.status,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return response
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function getJson(url) {
|
|
141
|
+
return (await get(url)).json()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function getText(url, accept) {
|
|
145
|
+
return (await get(url, { accept })).text()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse a marketplace issue URL into its parts. */
|
|
149
|
+
export function parseIssueUrl(value) {
|
|
150
|
+
const match = String(value || "").trim().match(
|
|
151
|
+
/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/issues\/(\d+)(?:[?#].*)?$/,
|
|
152
|
+
)
|
|
153
|
+
if (!match) throw new GitHubError("usage", `not a GitHub issue URL: ${value}`)
|
|
154
|
+
return { owner: match[1], repository: match[2], number: Number(match[3]) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function issue(owner, repository, number) {
|
|
158
|
+
return getJson(`https://api.github.com/repos/${owner}/${repository}/issues/${number}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function issueComments(owner, repository, number, maxPages = 10) {
|
|
162
|
+
const all = []
|
|
163
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
164
|
+
const batch = await getJson(
|
|
165
|
+
`https://api.github.com/repos/${owner}/${repository}/issues/${number}/comments?per_page=100&page=${page}`,
|
|
166
|
+
)
|
|
167
|
+
if (!Array.isArray(batch) || !batch.length) break
|
|
168
|
+
all.push(...batch)
|
|
169
|
+
if (batch.length < 100) break
|
|
170
|
+
}
|
|
171
|
+
return all
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The current default-branch HEAD of a repository.
|
|
176
|
+
*
|
|
177
|
+
* With a token this uses the REST API, which names the default branch
|
|
178
|
+
* explicitly. Without one it falls back to the repository's commit feed, the
|
|
179
|
+
* same unauthenticated source the underlying measurement used, because that
|
|
180
|
+
* feed does not consume the 60-requests-per-hour API allowance.
|
|
181
|
+
*/
|
|
182
|
+
export async function defaultBranchHead(repositoryUrl) {
|
|
183
|
+
const match = String(repositoryUrl).match(/^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
|
|
184
|
+
if (!match) throw new GitHubError("usage", `not a GitHub repository URL: ${repositoryUrl}`)
|
|
185
|
+
const [, owner, repository] = match
|
|
186
|
+
if (token()) {
|
|
187
|
+
const meta = await getJson(`https://api.github.com/repos/${owner}/${repository}`)
|
|
188
|
+
const branch = meta.default_branch
|
|
189
|
+
const head = await getJson(`https://api.github.com/repos/${owner}/${repository}/commits/${encodeURIComponent(branch)}`)
|
|
190
|
+
return {
|
|
191
|
+
source: "api",
|
|
192
|
+
branch,
|
|
193
|
+
commit: String(head.sha).toLowerCase(),
|
|
194
|
+
committedAt: head.commit?.committer?.date || head.commit?.author?.date || null,
|
|
195
|
+
archived: meta.archived === true,
|
|
196
|
+
private: meta.private === true,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const feed = await getText(`https://github.com/${owner}/${repository}/commits.atom`, "application/atom+xml")
|
|
200
|
+
const commit = feed.match(/<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i)?.[1]
|
|
201
|
+
|| feed.match(/\/commit\/([0-9a-f]{40})/i)?.[1]
|
|
202
|
+
if (!commit) throw new GitHubError("head-unreadable", `cannot read a commit from the commit feed of ${repositoryUrl}`)
|
|
203
|
+
const branch = feed.match(/<title>Recent Commits to [^:]+:(.+?)<\/title>/)?.[1] || null
|
|
204
|
+
const committedAt = feed.match(/<updated>([^<]+)<\/updated>/)?.[1] || null
|
|
205
|
+
return { source: "commits.atom", branch, commit: commit.toLowerCase(), committedAt, archived: null, private: null }
|
|
206
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Render the submission issue exactly as GitHub's own issue form would render
|
|
2
|
+
// it, from the contract read at the pin, and then prove the result by running
|
|
3
|
+
// the marketplace's own parser over it.
|
|
4
|
+
//
|
|
5
|
+
// Measured reason the rendering is generated rather than hand-written
|
|
6
|
+
// (docs/MEASUREMENTS.md M2): 39 submissions failed on the title prefix alone
|
|
7
|
+
// and 11 open submissions are malformed in the body, one of them by the single
|
|
8
|
+
// word "Suggested" instead of "Suggest". Hand-typing six headings is how that
|
|
9
|
+
// happens. Generating them from the form and then parsing the result with
|
|
10
|
+
// `parseCurrentSubmission` from the same commit removes the class entirely: if
|
|
11
|
+
// the marketplace's parser accepts the body here, it accepts it there.
|
|
12
|
+
|
|
13
|
+
export const NO_RESPONSE = "_No response_"
|
|
14
|
+
|
|
15
|
+
export class IssueRenderError extends Error {
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = "IssueRenderError"
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function heading(text) {
|
|
24
|
+
return `### ${text}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} contract from submissionContract()
|
|
29
|
+
* @param {{ pluginName: string, repositoryUrl: string, category: string,
|
|
30
|
+
* tags: string[], suggestedTag?: string, notes?: string }} input
|
|
31
|
+
*/
|
|
32
|
+
export function renderIssue(contract, input) {
|
|
33
|
+
const name = String(input.pluginName || "").trim()
|
|
34
|
+
if (!name) throw new IssueRenderError("plugin-name-missing", "the issue title needs the plugin name")
|
|
35
|
+
const title = `${contract.titleTemplate}${name}`
|
|
36
|
+
|
|
37
|
+
const values = new Map([
|
|
38
|
+
[contract.headingFor.repository, String(input.repositoryUrl || "").trim()],
|
|
39
|
+
[contract.headingFor.category, String(input.category || "").trim()],
|
|
40
|
+
[contract.headingFor.tags, (input.tags || []).join(", ")],
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
const checklistBlock = contract.checklist.map((item) => `- [X] ${item.label}`).join("\n")
|
|
44
|
+
|
|
45
|
+
// Optional free-text fields keyed by heading, so a renamed or reordered form
|
|
46
|
+
// field cannot silently drop the author's text.
|
|
47
|
+
const optional = new Map()
|
|
48
|
+
for (const field of contract.fields) {
|
|
49
|
+
if (values.has(field.label) || field.label === contract.headingFor.checklist) continue
|
|
50
|
+
if (field.type === "input") optional.set(field.label, String(input.suggestedTag || "").trim())
|
|
51
|
+
else optional.set(field.label, String(input.notes || "").trim())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sections = []
|
|
55
|
+
for (const label of contract.headings) {
|
|
56
|
+
let body
|
|
57
|
+
if (label === contract.headingFor.checklist) body = checklistBlock
|
|
58
|
+
else if (values.has(label)) body = values.get(label)
|
|
59
|
+
else body = optional.get(label) ?? ""
|
|
60
|
+
sections.push(`${heading(label)}\n\n${body || NO_RESPONSE}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { title, body: `${sections.join("\n\n")}\n` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the marketplace's own submission parser over a rendered issue. The
|
|
68
|
+
* parser comes from the pinned checkout, so this is the marketplace accepting
|
|
69
|
+
* or refusing the body, not Omakit's opinion of it.
|
|
70
|
+
*
|
|
71
|
+
* @returns {{ ok: true, submission: object } | { ok: false, code: string, message: string }}
|
|
72
|
+
*/
|
|
73
|
+
export function verifyAgainstOfficialParser(contract, issue) {
|
|
74
|
+
try {
|
|
75
|
+
const submission = contract.parseCurrentSubmission({ title: issue.title, body: issue.body })
|
|
76
|
+
return { ok: true, submission }
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { ok: false, code: error.code || "submission-invalid", message: error.message }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Local Git transport for the official Omarchy marketplace security baseline.
|
|
2
|
+
//
|
|
3
|
+
// The official snapshot resolver (scripts/security-baseline-scope.mjs at the
|
|
4
|
+
// pinned marketplace commit) takes an injectable `fetchImpl` and asks for:
|
|
5
|
+
//
|
|
6
|
+
// GET https://api.github.com/repos/{owner}/{repo}
|
|
7
|
+
// GET https://api.github.com/repos/{owner}/{repo}/commits/{sha}
|
|
8
|
+
// GET https://api.github.com/repos/{owner}/{repo}/git/trees/{treeSha}?recursive=1
|
|
9
|
+
// GET https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path} (also ranged)
|
|
10
|
+
//
|
|
11
|
+
// This transport answers exactly those requests from a local clone at the exact
|
|
12
|
+
// commit. Nothing is invented beyond repository metadata that cannot be known
|
|
13
|
+
// locally, which the caller records as `assumedByAdapter`. No network, no
|
|
14
|
+
// credentials, no writes.
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process"
|
|
17
|
+
|
|
18
|
+
export const ADAPTER_VERSION = 1
|
|
19
|
+
|
|
20
|
+
export const ASSUMED_BY_ADAPTER = Object.freeze([
|
|
21
|
+
"repository.private=false",
|
|
22
|
+
"repository.disabled=false",
|
|
23
|
+
"repository.archived=false",
|
|
24
|
+
"tree.truncated=false (the local tree is always complete)",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
function git(repoDir, args, encoding = "utf8") {
|
|
28
|
+
return execFileSync("git", ["-C", repoDir, ...args], {
|
|
29
|
+
encoding,
|
|
30
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
31
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseRepoUrl(repoUrl) {
|
|
36
|
+
const url = new URL(repoUrl)
|
|
37
|
+
const [owner, repository] = url.pathname.replace(/^\/|\/$/g, "").split("/")
|
|
38
|
+
return { owner, repository: repository.replace(/\.git$/, "") }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readTree(repoDir, commitSha) {
|
|
42
|
+
const raw = git(repoDir, ["ls-tree", "-r", "-t", "-l", commitSha])
|
|
43
|
+
const entries = []
|
|
44
|
+
for (const line of raw.split("\n")) {
|
|
45
|
+
if (!line) continue
|
|
46
|
+
const tab = line.indexOf("\t")
|
|
47
|
+
const [mode, type, sha, size] = line.slice(0, tab).split(/\s+/)
|
|
48
|
+
const path = line.slice(tab + 1)
|
|
49
|
+
const entry = { path, mode, type, sha }
|
|
50
|
+
if (type === "blob") entry.size = Number(size)
|
|
51
|
+
entries.push(entry)
|
|
52
|
+
}
|
|
53
|
+
return entries
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function jsonResponse(body) {
|
|
57
|
+
return new Response(JSON.stringify(body), {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function notFound(what) {
|
|
64
|
+
return new Response(JSON.stringify({ message: `local transport: ${what}` }), {
|
|
65
|
+
status: 404,
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fileResponse(buffer, range) {
|
|
71
|
+
if (!range) {
|
|
72
|
+
return new Response(buffer, {
|
|
73
|
+
status: 200,
|
|
74
|
+
headers: {
|
|
75
|
+
"content-type": "text/plain; charset=utf-8",
|
|
76
|
+
"content-length": String(buffer.length),
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
const match = String(range).match(/^bytes=0-(\d+)$/)
|
|
81
|
+
if (!match) return new Response("", { status: 416 })
|
|
82
|
+
const end = Math.min(Number(match[1]), buffer.length - 1)
|
|
83
|
+
const slice = buffer.subarray(0, end + 1)
|
|
84
|
+
return new Response(slice, {
|
|
85
|
+
status: 206,
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "text/plain; charset=utf-8",
|
|
88
|
+
"content-length": String(slice.length),
|
|
89
|
+
"content-range": `bytes 0-${end}/${buffer.length}`,
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {{ repoDir: string, repoUrl: string, commitSha: string, defaultBranch?: string }} options
|
|
96
|
+
* @returns {{ fetchImpl: Function, stats: { api: number, raw: number } }}
|
|
97
|
+
*/
|
|
98
|
+
export function createLocalTransport({ repoDir, repoUrl, commitSha, defaultBranch }) {
|
|
99
|
+
const { owner, repository } = parseRepoUrl(repoUrl)
|
|
100
|
+
let commit = ""
|
|
101
|
+
try {
|
|
102
|
+
commit = git(repoDir, ["rev-parse", "--verify", "-q", `${commitSha}^{commit}`]).trim()
|
|
103
|
+
} catch {
|
|
104
|
+
commit = ""
|
|
105
|
+
}
|
|
106
|
+
if (commit.toLowerCase() !== String(commitSha).toLowerCase()) {
|
|
107
|
+
throw new Error(`local transport: ${repoDir} does not contain commit ${commitSha}`)
|
|
108
|
+
}
|
|
109
|
+
const treeSha = git(repoDir, ["rev-parse", `${commit}^{tree}`]).trim()
|
|
110
|
+
const tree = readTree(repoDir, commit)
|
|
111
|
+
const byPath = new Map(tree.map((entry) => [entry.path, entry]))
|
|
112
|
+
const branch = defaultBranch
|
|
113
|
+
|| (() => {
|
|
114
|
+
try {
|
|
115
|
+
return git(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim()
|
|
116
|
+
} catch {
|
|
117
|
+
return "main"
|
|
118
|
+
}
|
|
119
|
+
})()
|
|
120
|
+
|
|
121
|
+
const stats = { api: 0, raw: 0 }
|
|
122
|
+
const apiBase = `https://api.github.com/repos/${owner}/${repository}`
|
|
123
|
+
const rawBase = `https://raw.githubusercontent.com/${owner}/${repository}/${commit}/`
|
|
124
|
+
|
|
125
|
+
async function fetchImpl(url, options = {}) {
|
|
126
|
+
const target = String(url)
|
|
127
|
+
if (target.startsWith("https://api.github.com/")) {
|
|
128
|
+
stats.api += 1
|
|
129
|
+
if (target === apiBase) {
|
|
130
|
+
return jsonResponse({
|
|
131
|
+
full_name: `${owner}/${repository}`,
|
|
132
|
+
private: false,
|
|
133
|
+
disabled: false,
|
|
134
|
+
archived: false,
|
|
135
|
+
default_branch: branch,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
if (target === `${apiBase}/commits/${commit}`) {
|
|
139
|
+
return jsonResponse({ sha: commit, commit: { tree: { sha: treeSha } } })
|
|
140
|
+
}
|
|
141
|
+
if (target === `${apiBase}/git/trees/${treeSha}?recursive=1`) {
|
|
142
|
+
return jsonResponse({ sha: treeSha, truncated: false, tree })
|
|
143
|
+
}
|
|
144
|
+
return notFound(`unexpected API request ${target}`)
|
|
145
|
+
}
|
|
146
|
+
if (target.startsWith(rawBase)) {
|
|
147
|
+
stats.raw += 1
|
|
148
|
+
const path = target
|
|
149
|
+
.slice(rawBase.length)
|
|
150
|
+
.split("/")
|
|
151
|
+
.map(decodeURIComponent)
|
|
152
|
+
.join("/")
|
|
153
|
+
const entry = byPath.get(path)
|
|
154
|
+
if (!entry || entry.type !== "blob") return notFound(`no blob at ${path}`)
|
|
155
|
+
const buffer = git(repoDir, ["cat-file", "blob", entry.sha], "buffer")
|
|
156
|
+
return fileResponse(buffer, options.headers?.Range || options.headers?.range)
|
|
157
|
+
}
|
|
158
|
+
return notFound(`unsupported host for ${target}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { fetchImpl, stats, commit, treeSha, tree }
|
|
162
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync } from "node:fs"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
export class ParityOutputError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = "ParityOutputError"
|
|
8
|
+
this.code = "parity-output-required"
|
|
9
|
+
this.remedy = "omakit parity --out <file>"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve parity evidence before any fetch, refusing a packaged read-only default. */
|
|
14
|
+
export function parityOutput({ repoRoot, out = null }) {
|
|
15
|
+
if (out) return resolve(out)
|
|
16
|
+
const evidenceRoot = join(resolve(repoRoot), "docs/evidence")
|
|
17
|
+
if (!existsSync(evidenceRoot)) {
|
|
18
|
+
throw new ParityOutputError(`this install has no source-tree evidence directory at ${evidenceRoot}; pass --out <file> for the parity evidence.`)
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
accessSync(evidenceRoot, constants.W_OK)
|
|
22
|
+
} catch {
|
|
23
|
+
throw new ParityOutputError(`the measured evidence directory is not writable at ${evidenceRoot}; pass --out <file> for the parity evidence.`)
|
|
24
|
+
}
|
|
25
|
+
return join(evidenceRoot, "parity")
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** Omakit's user-writable cache, following XDG with the usual ~/.cache fallback. */
|
|
5
|
+
export function omakitCacheDir(name = "", env = process.env) {
|
|
6
|
+
const base = env.XDG_CACHE_HOME
|
|
7
|
+
? resolve(env.XDG_CACHE_HOME)
|
|
8
|
+
: join(env.HOME || homedir(), ".cache")
|
|
9
|
+
return join(base, "omakit", name)
|
|
10
|
+
}
|