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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +229 -0
  3. package/bin/omakit +3 -0
  4. package/package.json +40 -0
  5. package/skills/omarchy-plugin-submit/SKILL.md +87 -0
  6. package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
  7. package/tests/parity/corpus.mjs +62 -0
  8. package/tests/parity/run.mjs +194 -0
  9. package/tools/marketplace/README.md +64 -0
  10. package/tools/marketplace/agent-control.mjs +72 -0
  11. package/tools/marketplace/banner.mjs +307 -0
  12. package/tools/marketplace/cli.mjs +283 -0
  13. package/tools/marketplace/completion.mjs +263 -0
  14. package/tools/marketplace/doctor.mjs +156 -0
  15. package/tools/marketplace/effect.mjs +115 -0
  16. package/tools/marketplace/form.mjs +209 -0
  17. package/tools/marketplace/github.mjs +206 -0
  18. package/tools/marketplace/issue.mjs +80 -0
  19. package/tools/marketplace/local-transport.mjs +162 -0
  20. package/tools/marketplace/parity-output.mjs +26 -0
  21. package/tools/marketplace/paths.mjs +10 -0
  22. package/tools/marketplace/pin.mjs +203 -0
  23. package/tools/marketplace/plugin.mjs +64 -0
  24. package/tools/marketplace/preflight.mjs +164 -0
  25. package/tools/marketplace/progress.mjs +96 -0
  26. package/tools/marketplace/registry.mjs +216 -0
  27. package/tools/marketplace/report.mjs +192 -0
  28. package/tools/marketplace/run-baseline.mjs +72 -0
  29. package/tools/marketplace/setup.mjs +136 -0
  30. package/tools/marketplace/style.mjs +443 -0
  31. package/tools/marketplace/submit.mjs +341 -0
  32. package/tools/marketplace/tree.mjs +50 -0
  33. package/tools/marketplace/upgrade.mjs +179 -0
  34. package/tools/marketplace/usage.mjs +191 -0
  35. package/tools/marketplace/verify.mjs +70 -0
  36. package/tools/marketplace/watch.mjs +262 -0
  37. package/tools/marketplace/yaml.mjs +164 -0
  38. 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
+ }