@nema-project/nema 0.1.4 → 0.1.6
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/dist/chunks/{chunk-HEIQINX3.js → chunk-W4DUBATU.js} +231 -91
- package/dist/daemon/connection.js +1 -1
- package/dist/direct/tools.js +1 -1
- package/dist/extensions/handlers.d.ts +16 -1
- package/dist/extensions/handlers.js +77 -2
- package/dist/extensions/install.d.ts +70 -0
- package/dist/extensions/install.js +241 -0
- package/dist/extensions/source.d.ts +81 -0
- package/dist/extensions/source.js +203 -0
- package/dist/extensions/store.d.ts +7 -0
- package/dist/extensions/store.js +72 -5
- package/dist/github/client.js +1 -1
- package/dist/github/gh.js +1 -1
- package/dist/report/commands.js +1 -1
- package/dist/report/desktop.js +11 -4
- package/dist/repos/git.js +1 -1
- package/dist/repos/handlers.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseSkillSource,
|
|
3
|
+
safeRepoPath
|
|
4
|
+
} from "../chunks/chunk-W4DUBATU.js";
|
|
5
|
+
|
|
6
|
+
// extensions/install.js
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { RAW, commitTree, encodePath, pinnedSkillRecord, resolveSkillSource } from "./source.js";
|
|
11
|
+
var COMMIT = /^[0-9a-f]{40}$/;
|
|
12
|
+
var MAX_FILES = 500;
|
|
13
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
var MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
var RESOLVE_TIMEOUT_MS = 45e3;
|
|
16
|
+
var DOWNLOAD_TIMEOUT_MS = 10 * 6e4;
|
|
17
|
+
var INTERRUPTED = "interrupted: the daemon stopped before the install finished";
|
|
18
|
+
function message(error) {
|
|
19
|
+
return error instanceof Error ? error.message : String(error);
|
|
20
|
+
}
|
|
21
|
+
function gitBlobSha(bytes) {
|
|
22
|
+
return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
function skillFiles(entries, folder) {
|
|
25
|
+
const prefix = folder === "." ? "" : `${folder}/`;
|
|
26
|
+
const inside = entries.filter((entry) => entry.path.startsWith(prefix) && entry.path !== folder);
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const entry of inside) {
|
|
29
|
+
if (entry.type === "commit" || entry.mode === "160000")
|
|
30
|
+
throw new Error(`${entry.path} is a submodule`);
|
|
31
|
+
if (entry.type !== "blob")
|
|
32
|
+
continue;
|
|
33
|
+
if (entry.mode === "120000")
|
|
34
|
+
throw new Error(`${entry.path} is a symbolic link`);
|
|
35
|
+
const relative = entry.path.slice(prefix.length);
|
|
36
|
+
if (!safeRepoPath(relative))
|
|
37
|
+
throw new Error(`unsafe file name in ${entry.path}`);
|
|
38
|
+
files.push({ ...entry, relative });
|
|
39
|
+
}
|
|
40
|
+
if (files.length > MAX_FILES)
|
|
41
|
+
throw new Error(`${folder} has more than ${MAX_FILES} files`);
|
|
42
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
43
|
+
const seen = /* @__PURE__ */ new Set();
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const folded = file.relative.toLowerCase();
|
|
47
|
+
if (seen.has(folded))
|
|
48
|
+
throw new Error(`${file.path} differs from another file only in case`);
|
|
49
|
+
seen.add(folded);
|
|
50
|
+
if (file.size !== null && file.size > MAX_FILE_BYTES)
|
|
51
|
+
throw new Error(`${file.path} is larger than 5 MiB`);
|
|
52
|
+
total += file.size ?? 0;
|
|
53
|
+
}
|
|
54
|
+
if (total > MAX_TOTAL_BYTES)
|
|
55
|
+
throw new Error(`${folder} is larger than 20 MiB in total`);
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
var SkillInstaller = class {
|
|
59
|
+
o;
|
|
60
|
+
jobs = /* @__PURE__ */ new Map();
|
|
61
|
+
/** Ids a retry has claimed while it re-reads the recorded commit, before its job exists. */
|
|
62
|
+
reserved = /* @__PURE__ */ new Set();
|
|
63
|
+
staging;
|
|
64
|
+
constructor(o) {
|
|
65
|
+
this.o = o;
|
|
66
|
+
this.staging = path.join(o.root, "staging");
|
|
67
|
+
}
|
|
68
|
+
/** Called once at daemon start: no install can be running yet, so every `installing` record is left over. */
|
|
69
|
+
recover() {
|
|
70
|
+
rmSync(this.staging, { recursive: true, force: true });
|
|
71
|
+
mkdirSync(this.staging, { recursive: true });
|
|
72
|
+
const interrupted = this.o.store.list().filter((row) => row.provision === "managed" && row.installState === "installing").map((row) => row.id);
|
|
73
|
+
for (const id of interrupted)
|
|
74
|
+
this.o.store.failInstall(id, INTERRUPTED);
|
|
75
|
+
return interrupted;
|
|
76
|
+
}
|
|
77
|
+
/** True from the moment an install or retry is accepted until its cleanup has finished. */
|
|
78
|
+
running(id) {
|
|
79
|
+
return this.reserved.has(id) || this.jobs.get(id)?.active === true;
|
|
80
|
+
}
|
|
81
|
+
/** Called after a removal, so a finished job is not reported for an extension that is gone. */
|
|
82
|
+
forget(id) {
|
|
83
|
+
if (!this.running(id))
|
|
84
|
+
this.jobs.delete(id);
|
|
85
|
+
}
|
|
86
|
+
async start(request) {
|
|
87
|
+
return this.begin(request, null);
|
|
88
|
+
}
|
|
89
|
+
async begin(request, reservedFor) {
|
|
90
|
+
const { source, commit, path: folder } = request;
|
|
91
|
+
if (typeof source !== "string")
|
|
92
|
+
throw new Error("install needs a string source");
|
|
93
|
+
if (typeof commit !== "string" || !COMMIT.test(commit))
|
|
94
|
+
throw new Error("install needs a 40-character commit");
|
|
95
|
+
if (typeof folder !== "string" || folder !== "." && !safeRepoPath(folder))
|
|
96
|
+
throw new Error("invalid skill path");
|
|
97
|
+
const parsed = parseSkillSource(source);
|
|
98
|
+
const pinned = {
|
|
99
|
+
via: "github",
|
|
100
|
+
owner: parsed.owner,
|
|
101
|
+
repo: parsed.repo,
|
|
102
|
+
ref: commit,
|
|
103
|
+
path: folder === "." ? null : folder,
|
|
104
|
+
skill: null,
|
|
105
|
+
refPath: null,
|
|
106
|
+
exact: true
|
|
107
|
+
};
|
|
108
|
+
const resolution = await resolveSkillSource(pinned, { fetch: this.o.fetch, signal: AbortSignal.timeout(RESOLVE_TIMEOUT_MS) });
|
|
109
|
+
if (resolution.commit !== commit)
|
|
110
|
+
throw new Error(`${commit} resolved to a different commit`);
|
|
111
|
+
const pinnedRecord = pinnedSkillRecord(resolution, resolution.skills[0]);
|
|
112
|
+
if (reservedFor !== null && pinnedRecord.id !== reservedFor)
|
|
113
|
+
throw new Error(`${reservedFor} now resolves to a different skill (${pinnedRecord.id})`);
|
|
114
|
+
this.claim(pinnedRecord.id, reservedFor);
|
|
115
|
+
const previous = this.o.store.list().find((row) => row.id === pinnedRecord.id);
|
|
116
|
+
const record = previous ? { ...pinnedRecord, useState: previous.useState } : pinnedRecord;
|
|
117
|
+
this.o.store.putManaged(record);
|
|
118
|
+
return this.launch(record, resolution.owner, resolution.repo, commit, folder);
|
|
119
|
+
}
|
|
120
|
+
/** Start again from the commit and folder on the record, for a failed, cancelled or interrupted install. */
|
|
121
|
+
async retry(id) {
|
|
122
|
+
const record = this.o.store.list().find((row) => row.id === id);
|
|
123
|
+
if (!record || record.provision !== "managed" || !record.provenance)
|
|
124
|
+
throw new Error(`${id} has no install to retry`);
|
|
125
|
+
this.claim(id, null);
|
|
126
|
+
this.reserved.add(id);
|
|
127
|
+
try {
|
|
128
|
+
return await this.begin({ source: record.provenance.repository, commit: record.provenance.commit, path: record.provenance.path }, id);
|
|
129
|
+
} finally {
|
|
130
|
+
this.reserved.delete(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
cancel(id) {
|
|
134
|
+
const job = this.jobs.get(id);
|
|
135
|
+
if (!job || job.state !== "running")
|
|
136
|
+
throw new Error(`${id} is not installing`);
|
|
137
|
+
job.controller.abort(new Error("cancelled"));
|
|
138
|
+
job.state = "cancelled";
|
|
139
|
+
return this.snapshot(job);
|
|
140
|
+
}
|
|
141
|
+
status(id) {
|
|
142
|
+
const record = this.o.store.list().find((row) => row.id === id);
|
|
143
|
+
const job = this.jobs.get(id);
|
|
144
|
+
if (job && (job.active || record))
|
|
145
|
+
return this.snapshot(job);
|
|
146
|
+
if (job)
|
|
147
|
+
this.jobs.delete(id);
|
|
148
|
+
if (!record || record.provision !== "managed")
|
|
149
|
+
throw new Error(`${id} has no install`);
|
|
150
|
+
const state = record.installState === "installed" ? "installed" : record.installState === "failed" ? "failed" : record.installState === "absent" ? "cancelled" : "running";
|
|
151
|
+
return { id, state, done: 0, total: 0, error: record.error, commit: record.provenance?.commit ?? null, path: record.provenance?.path ?? null };
|
|
152
|
+
}
|
|
153
|
+
/** Resolves once the current install of `id` has stopped, whichever way. For tests and shutdown. */
|
|
154
|
+
async settled(id) {
|
|
155
|
+
await this.jobs.get(id)?.promise;
|
|
156
|
+
}
|
|
157
|
+
/** One install per id at a time; an installed copy is not replaced (updating is its own operation). */
|
|
158
|
+
claim(id, reservedFor) {
|
|
159
|
+
if (reservedFor !== id && this.running(id))
|
|
160
|
+
throw new Error(`${id} is already being installed`);
|
|
161
|
+
const current = this.o.store.list().find((row) => row.id === id);
|
|
162
|
+
if (!current)
|
|
163
|
+
return;
|
|
164
|
+
if (current.provision !== "managed")
|
|
165
|
+
throw new Error(`${id} is taken by another extension`);
|
|
166
|
+
if (current.installState === "installed")
|
|
167
|
+
throw new Error(`${id} is already installed`);
|
|
168
|
+
if (current.installState === "installing")
|
|
169
|
+
throw new Error(`${id} is already being installed`);
|
|
170
|
+
}
|
|
171
|
+
snapshot(job) {
|
|
172
|
+
return { id: job.id, state: job.state, done: job.done, total: job.total, error: job.error, commit: job.commit, path: job.path };
|
|
173
|
+
}
|
|
174
|
+
launch(record, owner, repo, commit, folder) {
|
|
175
|
+
const controller = new AbortController();
|
|
176
|
+
const job = { id: record.id, state: "running", done: 0, total: 0, error: null, commit, path: folder, controller, promise: Promise.resolve(), active: true };
|
|
177
|
+
this.jobs.set(record.id, job);
|
|
178
|
+
job.promise = this.run(job, record, owner, repo, commit, folder);
|
|
179
|
+
return this.snapshot(job);
|
|
180
|
+
}
|
|
181
|
+
async run(job, record, owner, repo, commit, folder) {
|
|
182
|
+
const timeout = this.o.downloadTimeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
183
|
+
const signal = AbortSignal.any([job.controller.signal, AbortSignal.timeout(timeout)]);
|
|
184
|
+
let stage = null;
|
|
185
|
+
try {
|
|
186
|
+
mkdirSync(this.staging, { recursive: true });
|
|
187
|
+
stage = mkdtempSync(path.join(this.staging, `${record.id}-`));
|
|
188
|
+
const { entries, truncated } = await commitTree(owner, repo, commit, { fetch: this.o.fetch, signal });
|
|
189
|
+
if (truncated)
|
|
190
|
+
throw new Error(`GitHub cut the file list of ${owner}/${repo} short; it is too large to install from`);
|
|
191
|
+
const files = skillFiles(entries, folder);
|
|
192
|
+
job.total = files.length;
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
const response = await this.o.fetch(`${RAW}/${owner}/${repo}/${commit}/${encodePath(file.path)}`, {
|
|
195
|
+
headers: { "User-Agent": "nema-extension-installer" },
|
|
196
|
+
signal
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok)
|
|
199
|
+
throw new Error(`could not download ${file.path} (${response.status})`);
|
|
200
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
201
|
+
if (bytes.length > MAX_FILE_BYTES)
|
|
202
|
+
throw new Error(`${file.path} is larger than 5 MiB`);
|
|
203
|
+
if (gitBlobSha(bytes) !== file.sha)
|
|
204
|
+
throw new Error(`${file.path} does not match its Git blob hash`);
|
|
205
|
+
const target = path.resolve(stage, ...file.relative.split("/"));
|
|
206
|
+
if (!target.startsWith(stage + path.sep))
|
|
207
|
+
throw new Error(`unsafe file name in ${file.path}`);
|
|
208
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
209
|
+
const mode = file.mode === "100755" ? 493 : 420;
|
|
210
|
+
writeFileSync(target, bytes, { flag: "wx", mode });
|
|
211
|
+
chmodSync(target, mode);
|
|
212
|
+
job.done += 1;
|
|
213
|
+
}
|
|
214
|
+
signal.throwIfAborted();
|
|
215
|
+
const current = this.o.store.list().find((row) => row.id === record.id);
|
|
216
|
+
this.o.store.installManaged({ ...record, useState: current?.useState ?? record.useState, installState: "installed", error: null }, stage);
|
|
217
|
+
job.state = "installed";
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const cancelled = job.controller.signal.aborted;
|
|
220
|
+
const reason = cancelled ? null : error instanceof Error && error.name === "TimeoutError" ? `download did not finish within ${Math.round(timeout / 6e4)} min` : message(error);
|
|
221
|
+
job.state = cancelled ? "cancelled" : "failed";
|
|
222
|
+
job.error = reason;
|
|
223
|
+
try {
|
|
224
|
+
if (cancelled)
|
|
225
|
+
this.o.store.cancelInstall(record.id);
|
|
226
|
+
else
|
|
227
|
+
this.o.store.failInstall(record.id, reason);
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
if (stage)
|
|
232
|
+
rmSync(stage, { recursive: true, force: true });
|
|
233
|
+
job.active = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
export {
|
|
238
|
+
INTERRUPTED,
|
|
239
|
+
SkillInstaller,
|
|
240
|
+
skillFiles
|
|
241
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type ExtensionRecord, type SkillProblem, type SkillSource } from "@nema/shared";
|
|
2
|
+
/**
|
|
3
|
+
* The slice of `fetch` the resolver needs. Production passes the global `fetch`; tests pass a fake
|
|
4
|
+
* GitHub, so no test depends on the network.
|
|
5
|
+
*/
|
|
6
|
+
export type SourceFetch = (url: string, init: {
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}) => Promise<{
|
|
10
|
+
ok: boolean;
|
|
11
|
+
status: number;
|
|
12
|
+
headers: {
|
|
13
|
+
get(name: string): string | null;
|
|
14
|
+
};
|
|
15
|
+
json(): Promise<unknown>;
|
|
16
|
+
text(): Promise<string>;
|
|
17
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
18
|
+
}>;
|
|
19
|
+
export type { SkillProblem } from "@nema/shared";
|
|
20
|
+
export type SkillCandidate = {
|
|
21
|
+
/** Managed id the skill would install under; null when it cannot be installed (see `problem`). */
|
|
22
|
+
id: string | null;
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
/** Folder of the skill inside the repository; `.` is the repository root. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** Git blob SHA-1 of the SKILL.md at the pinned commit. */
|
|
28
|
+
digest: string;
|
|
29
|
+
problem: SkillProblem | null;
|
|
30
|
+
};
|
|
31
|
+
export type SkillResolution = {
|
|
32
|
+
repository: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
repo: string;
|
|
35
|
+
/** The ref that was asked for: the link's ref, or the repository's default branch. */
|
|
36
|
+
ref: string;
|
|
37
|
+
/** What that ref pointed to when it was resolved. Every later read uses this, never the ref. */
|
|
38
|
+
commit: string;
|
|
39
|
+
/** SPDX id GitHub reports for the repository, or null when it reports none. */
|
|
40
|
+
license: string | null;
|
|
41
|
+
path: string | null;
|
|
42
|
+
skills: SkillCandidate[];
|
|
43
|
+
/** GitHub cut the tree listing short, so skills deeper in a very large repository may be missing. */
|
|
44
|
+
truncated: boolean;
|
|
45
|
+
};
|
|
46
|
+
export declare const RAW = "https://raw.githubusercontent.com";
|
|
47
|
+
export type Options = {
|
|
48
|
+
fetch: SourceFetch;
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
};
|
|
51
|
+
export declare function encodePath(path: string): string;
|
|
52
|
+
export type TreeEntry = {
|
|
53
|
+
path: string;
|
|
54
|
+
type: string;
|
|
55
|
+
mode: string;
|
|
56
|
+
sha: string;
|
|
57
|
+
size: number | null;
|
|
58
|
+
};
|
|
59
|
+
/** Every entry of a repository at one commit, as GitHub's recursive tree listing reports it. */
|
|
60
|
+
export declare function commitTree(owner: string, repo: string, commit: string, o: Options): Promise<{
|
|
61
|
+
entries: TreeEntry[];
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/** `name` and `description` from a SKILL.md front matter; plain and folded/literal scalars only. */
|
|
65
|
+
export declare function skillFrontMatter(text: string): {
|
|
66
|
+
name: string;
|
|
67
|
+
description: string;
|
|
68
|
+
} | null;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a parsed source to one commit and the skills it contains.
|
|
71
|
+
*
|
|
72
|
+
* The ref is read once, turned into a 40-character commit, and every later read (tree listing,
|
|
73
|
+
* SKILL.md contents) names that commit. A branch that moves while this runs, or between preview and
|
|
74
|
+
* install, cannot change what was previewed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveSkillSource(source: SkillSource, o: Options): Promise<SkillResolution>;
|
|
77
|
+
/**
|
|
78
|
+
* The record a chosen skill is installed under. `revision` is the commit and `source` links to that
|
|
79
|
+
* commit, so the record keeps saying what was installed after the branch moves on.
|
|
80
|
+
*/
|
|
81
|
+
export declare function pinnedSkillRecord(resolution: SkillResolution, skill: SkillCandidate): ExtensionRecord;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bundledExtensions
|
|
3
|
+
} from "../chunks/chunk-W4DUBATU.js";
|
|
4
|
+
|
|
5
|
+
// extensions/source.js
|
|
6
|
+
var API = "https://api.github.com";
|
|
7
|
+
var RAW = "https://raw.githubusercontent.com";
|
|
8
|
+
var COMMIT = /^[0-9a-f]{40}$/;
|
|
9
|
+
var ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
10
|
+
var MAX_SKILLS = 50;
|
|
11
|
+
var MAX_SCAN = 200;
|
|
12
|
+
var MAX_SKILL_BYTES = 256 * 1024;
|
|
13
|
+
var BUNDLED_IDS = new Set(bundledExtensions().map((row) => row.id));
|
|
14
|
+
var HEADERS = { Accept: "application/vnd.github+json", "User-Agent": "nema-extension-resolver", "X-GitHub-Api-Version": "2022-11-28" };
|
|
15
|
+
async function getJson(url, slug, o, notFound) {
|
|
16
|
+
const response = await o.fetch(url, { headers: HEADERS, signal: o.signal });
|
|
17
|
+
if (response.ok)
|
|
18
|
+
return response.json();
|
|
19
|
+
if ((response.status === 403 || response.status === 429) && response.headers.get("x-ratelimit-remaining") === "0") {
|
|
20
|
+
throw new Error("GitHub API rate limit reached; try again later");
|
|
21
|
+
}
|
|
22
|
+
if (response.status === 404 || response.status === 422)
|
|
23
|
+
throw new Error(notFound);
|
|
24
|
+
throw new Error(`GitHub returned ${response.status} for ${slug}`);
|
|
25
|
+
}
|
|
26
|
+
function field(value, key) {
|
|
27
|
+
return value && typeof value === "object" ? value[key] : void 0;
|
|
28
|
+
}
|
|
29
|
+
function scalar(value) {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
const quote = trimmed[0];
|
|
32
|
+
if (quote === '"' || quote === "'") {
|
|
33
|
+
const end = trimmed.indexOf(quote, 1);
|
|
34
|
+
if (end > 0)
|
|
35
|
+
return trimmed.slice(1, end);
|
|
36
|
+
}
|
|
37
|
+
return trimmed.replace(/(^|\s+)#.*$/, "").trim();
|
|
38
|
+
}
|
|
39
|
+
function encodePath(path) {
|
|
40
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
41
|
+
}
|
|
42
|
+
async function commitTree(owner, repo, commit, o) {
|
|
43
|
+
const slug = `${owner}/${repo}`;
|
|
44
|
+
const body = await getJson(`${API}/repos/${owner}/${repo}/git/trees/${commit}?recursive=1`, slug, o, `commit not found in ${slug}: ${commit}`);
|
|
45
|
+
const tree = field(body, "tree");
|
|
46
|
+
if (!Array.isArray(tree))
|
|
47
|
+
throw new Error(`GitHub returned no file list for ${slug} at ${commit}`);
|
|
48
|
+
const entries = tree.filter((entry) => typeof field(entry, "path") === "string" && typeof field(entry, "type") === "string" && typeof field(entry, "sha") === "string").map((entry) => ({
|
|
49
|
+
path: field(entry, "path"),
|
|
50
|
+
type: field(entry, "type"),
|
|
51
|
+
mode: typeof field(entry, "mode") === "string" ? field(entry, "mode") : "",
|
|
52
|
+
sha: field(entry, "sha"),
|
|
53
|
+
size: typeof field(entry, "size") === "number" ? field(entry, "size") : null
|
|
54
|
+
}));
|
|
55
|
+
return { entries, truncated: field(body, "truncated") === true };
|
|
56
|
+
}
|
|
57
|
+
function skillFrontMatter(text) {
|
|
58
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text.slice(0, 64 * 1024));
|
|
59
|
+
if (!match)
|
|
60
|
+
return null;
|
|
61
|
+
const lines = match[1].split(/\r?\n/);
|
|
62
|
+
const values = {};
|
|
63
|
+
for (let i = 0; i < lines.length; i++) {
|
|
64
|
+
const top = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
65
|
+
if (!top)
|
|
66
|
+
continue;
|
|
67
|
+
const [, key, rest] = top;
|
|
68
|
+
if (rest === ">" || rest === "|" || rest === ">-" || rest === "|-") {
|
|
69
|
+
const block = [];
|
|
70
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
|
|
71
|
+
block.push(lines[++i].trim());
|
|
72
|
+
values[key] = block.join(rest.startsWith(">") ? " " : "\n");
|
|
73
|
+
} else
|
|
74
|
+
values[key] = scalar(rest);
|
|
75
|
+
}
|
|
76
|
+
const name = values.name?.trim();
|
|
77
|
+
const description = values.description?.trim();
|
|
78
|
+
return name && description ? { name, description } : null;
|
|
79
|
+
}
|
|
80
|
+
function skillId(name) {
|
|
81
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
82
|
+
}
|
|
83
|
+
async function resolveSkillSource(source, o) {
|
|
84
|
+
const slug = `${source.owner}/${source.repo}`;
|
|
85
|
+
const meta = await getJson(`${API}/repos/${source.owner}/${source.repo}`, slug, o, `GitHub repository not found: ${slug}`);
|
|
86
|
+
const fullName = field(meta, "full_name");
|
|
87
|
+
const [owner, repo] = typeof fullName === "string" && fullName.includes("/") ? fullName.split("/") : [source.owner, source.repo];
|
|
88
|
+
const defaultBranch = field(meta, "default_branch");
|
|
89
|
+
const ref = source.ref ?? (typeof defaultBranch === "string" && defaultBranch ? defaultBranch : null);
|
|
90
|
+
if (!ref)
|
|
91
|
+
throw new Error(`GitHub reported no default branch for ${slug}`);
|
|
92
|
+
const spdx = field(field(meta, "license"), "spdx_id");
|
|
93
|
+
const license = typeof spdx === "string" && spdx && spdx !== "NOASSERTION" ? spdx : null;
|
|
94
|
+
const segments = source.refPath ? source.refPath.split("/") : [];
|
|
95
|
+
const splits = segments.length ? segments.map((_, index) => ({ ref: segments.slice(0, index + 1).join("/"), path: segments.slice(index + 1).join("/") || null })) : [{ ref, path: source.path }];
|
|
96
|
+
let commit;
|
|
97
|
+
let chosenRef = ref;
|
|
98
|
+
let folderPath = source.path;
|
|
99
|
+
for (const split of splits) {
|
|
100
|
+
const notFound = `ref not found in ${slug}: ${ref}`;
|
|
101
|
+
try {
|
|
102
|
+
const body = await getJson(`${API}/repos/${source.owner}/${source.repo}/commits/${encodePath(split.ref)}`, slug, o, notFound);
|
|
103
|
+
commit = field(body, "sha");
|
|
104
|
+
chosenRef = split.ref;
|
|
105
|
+
folderPath = split.path;
|
|
106
|
+
break;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!(error instanceof Error && error.message === notFound) || split === splits.at(-1))
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (typeof commit !== "string" || !COMMIT.test(commit))
|
|
113
|
+
throw new Error(`${chosenRef} in ${slug} did not resolve to a commit`);
|
|
114
|
+
const treeBody = await getJson(`${API}/repos/${source.owner}/${source.repo}/git/trees/${commit}?recursive=1`, slug, o, `commit not found in ${slug}: ${commit}`);
|
|
115
|
+
const tree = field(treeBody, "tree");
|
|
116
|
+
if (!Array.isArray(tree))
|
|
117
|
+
throw new Error(`GitHub returned no file list for ${slug} at ${commit}`);
|
|
118
|
+
const prefix = folderPath ? `${folderPath}/` : "";
|
|
119
|
+
const exactFile = `${prefix}SKILL.md`;
|
|
120
|
+
const files = tree.filter((entry) => typeof field(entry, "path") === "string" && field(entry, "type") === "blob" && typeof field(entry, "sha") === "string").filter((entry) => source.exact ? entry.path === exactFile : (entry.path === "SKILL.md" || entry.path.endsWith("/SKILL.md")) && entry.path.startsWith(prefix)).sort((a, b) => a.path.localeCompare(b.path));
|
|
121
|
+
if (files.length === 0)
|
|
122
|
+
throw new Error(`no SKILL.md under ${folderPath ?? "the repository root"} in ${slug} at ${commit}`);
|
|
123
|
+
const read = async (file) => {
|
|
124
|
+
const folder = file.path === "SKILL.md" ? "." : file.path.slice(0, -"/SKILL.md".length);
|
|
125
|
+
const fallback = folder === "." ? repo : folder.split("/").at(-1);
|
|
126
|
+
if (typeof file.size === "number" && file.size > MAX_SKILL_BYTES) {
|
|
127
|
+
return { id: null, name: fallback, description: "", path: folder, digest: file.sha, problem: "too-large" };
|
|
128
|
+
}
|
|
129
|
+
const response = await o.fetch(`${RAW}/${owner}/${repo}/${commit}/${encodePath(file.path)}`, { headers: { "User-Agent": HEADERS["User-Agent"] }, signal: o.signal });
|
|
130
|
+
if (!response.ok)
|
|
131
|
+
throw new Error(`could not read ${file.path} in ${slug} at ${commit} (${response.status})`);
|
|
132
|
+
const meta2 = skillFrontMatter(await response.text());
|
|
133
|
+
if (!meta2)
|
|
134
|
+
return { id: null, name: fallback, description: "", path: folder, digest: file.sha, problem: "missing-frontmatter" };
|
|
135
|
+
const id = skillId(meta2.name);
|
|
136
|
+
const problem = !ID.test(id) ? "invalid-id" : BUNDLED_IDS.has(id) ? "bundled-id" : null;
|
|
137
|
+
return { id: problem ? null : id, name: meta2.name, description: meta2.description, path: folder, digest: file.sha, problem };
|
|
138
|
+
};
|
|
139
|
+
let chosen = [];
|
|
140
|
+
const limit = source.skill ? MAX_SCAN : MAX_SKILLS;
|
|
141
|
+
for (const file of files.slice(0, limit))
|
|
142
|
+
chosen.push(await read(file));
|
|
143
|
+
if (source.skill) {
|
|
144
|
+
const wanted = source.skill;
|
|
145
|
+
const ranks = [
|
|
146
|
+
(skill) => skill.name === wanted,
|
|
147
|
+
(skill) => skill.id === wanted,
|
|
148
|
+
(skill) => skill.path.split("/").at(-1) === wanted
|
|
149
|
+
];
|
|
150
|
+
const hits = ranks.map((rank) => chosen.filter(rank)).find((list) => list.length > 0) ?? [];
|
|
151
|
+
if (hits.length === 0)
|
|
152
|
+
throw new Error(`skill ${wanted} not found in ${slug} at ${commit}`);
|
|
153
|
+
if (hits.length > 1)
|
|
154
|
+
throw new Error(`skill ${wanted} is ambiguous in ${slug} at ${commit}: ${hits.map((skill) => skill.path).join(", ")}`);
|
|
155
|
+
chosen = hits;
|
|
156
|
+
}
|
|
157
|
+
const counts = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const skill of chosen)
|
|
159
|
+
if (skill.id)
|
|
160
|
+
counts.set(skill.id, (counts.get(skill.id) ?? 0) + 1);
|
|
161
|
+
chosen = chosen.map((skill) => skill.id && counts.get(skill.id) > 1 ? { ...skill, id: null, problem: "duplicate-id" } : skill);
|
|
162
|
+
return {
|
|
163
|
+
repository: `https://github.com/${owner}/${repo}`,
|
|
164
|
+
owner,
|
|
165
|
+
repo,
|
|
166
|
+
ref: chosenRef,
|
|
167
|
+
commit,
|
|
168
|
+
license,
|
|
169
|
+
path: folderPath,
|
|
170
|
+
skills: chosen,
|
|
171
|
+
truncated: field(treeBody, "truncated") === true || files.length > limit
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function pinnedSkillRecord(resolution, skill) {
|
|
175
|
+
if (!skill.id || skill.problem)
|
|
176
|
+
throw new Error(`skill ${skill.name} cannot be installed: ${skill.problem ?? "invalid-id"}`);
|
|
177
|
+
if (!COMMIT.test(resolution.commit))
|
|
178
|
+
throw new Error("a skill record needs a 40-character commit");
|
|
179
|
+
const folder = skill.path === "." ? "" : `/${encodePath(skill.path)}`;
|
|
180
|
+
return {
|
|
181
|
+
id: skill.id,
|
|
182
|
+
kind: "skill",
|
|
183
|
+
name: skill.name,
|
|
184
|
+
summary: skill.description,
|
|
185
|
+
source: `${resolution.repository}/tree/${resolution.commit}${folder}`,
|
|
186
|
+
revision: resolution.commit,
|
|
187
|
+
provision: "managed",
|
|
188
|
+
installState: "installing",
|
|
189
|
+
useState: "enabled",
|
|
190
|
+
availability: "ready",
|
|
191
|
+
externalPath: null,
|
|
192
|
+
error: null,
|
|
193
|
+
provenance: { repository: resolution.repository, commit: resolution.commit, path: skill.path, digest: skill.digest, license: resolution.license }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export {
|
|
197
|
+
RAW,
|
|
198
|
+
commitTree,
|
|
199
|
+
encodePath,
|
|
200
|
+
pinnedSkillRecord,
|
|
201
|
+
resolveSkillSource,
|
|
202
|
+
skillFrontMatter
|
|
203
|
+
};
|
|
@@ -7,5 +7,12 @@ export declare function openExtensionStore(root: string): {
|
|
|
7
7
|
removeManaged(id: string): void;
|
|
8
8
|
detach(id: string): void;
|
|
9
9
|
installManaged(record: ExtensionRecord, stagedDir: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* Flip the use state of a record. Bundled extensions live in `bundledExtensions()`, so their
|
|
12
|
+
* choice is stored as an override row rather than by editing the seed.
|
|
13
|
+
*/
|
|
14
|
+
setUseState(id: string, useState: ExtensionRecord["useState"]): ExtensionRecord;
|
|
15
|
+
/** End an install that did not finish: drop whatever reached the managed folder and keep the reason. */
|
|
16
|
+
failInstall(id: string, error: string): void;
|
|
10
17
|
cancelInstall(id: string): void;
|
|
11
18
|
};
|
package/dist/extensions/store.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
bundledExtensions
|
|
3
|
-
} from "../chunks/chunk-
|
|
3
|
+
} from "../chunks/chunk-W4DUBATU.js";
|
|
4
4
|
|
|
5
5
|
// extensions/store.js
|
|
6
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync,
|
|
6
|
+
import { closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
var ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
9
9
|
var BUNDLED_IDS = new Set(bundledExtensions().map((row) => row.id));
|
|
@@ -11,6 +11,17 @@ function assertId(id) {
|
|
|
11
11
|
if (!ID.test(id) || BUNDLED_IDS.has(id))
|
|
12
12
|
throw new Error(`invalid extension id: ${id}`);
|
|
13
13
|
}
|
|
14
|
+
var COMMIT = /^[0-9a-f]{40}$/;
|
|
15
|
+
function assertPinned(record) {
|
|
16
|
+
if (record.provision !== "managed")
|
|
17
|
+
return;
|
|
18
|
+
if (!/^https:\/\/(www\.)?github\.com\//i.test(record.source))
|
|
19
|
+
return;
|
|
20
|
+
const linked = /^https:\/\/(?:www\.)?github\.com\/[^/]+\/[^/]+\/tree\/([0-9a-f]{40})(?:\/|$)/i.exec(record.source)?.[1];
|
|
21
|
+
if (!COMMIT.test(record.revision) || linked !== record.revision) {
|
|
22
|
+
throw new Error("a GitHub source must be pinned to a 40-character commit");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
function indexPath(root) {
|
|
15
26
|
return path.join(root, "index.json");
|
|
16
27
|
}
|
|
@@ -25,7 +36,21 @@ function readExtra(root) {
|
|
|
25
36
|
}
|
|
26
37
|
function writeExtra(root, rows) {
|
|
27
38
|
mkdirSync(root, { recursive: true });
|
|
28
|
-
|
|
39
|
+
const target = indexPath(root);
|
|
40
|
+
const temp = path.join(root, `index.json.${process.pid}.${Date.now()}.tmp`);
|
|
41
|
+
const fd = openSync(temp, "w", 384);
|
|
42
|
+
try {
|
|
43
|
+
writeSync(fd, JSON.stringify(rows));
|
|
44
|
+
fsyncSync(fd);
|
|
45
|
+
} finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
renameSync(temp, target);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
rmSync(temp, { force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
29
54
|
}
|
|
30
55
|
function managedPath(root, id) {
|
|
31
56
|
const extensions = path.resolve(root, "extensions");
|
|
@@ -37,6 +62,8 @@ function managedPath(root, id) {
|
|
|
37
62
|
function ownedDirectory(root, id) {
|
|
38
63
|
const { extensions, target } = managedPath(root, id);
|
|
39
64
|
mkdirSync(extensions, { recursive: true });
|
|
65
|
+
if (lstatSync(extensions).isSymbolicLink())
|
|
66
|
+
throw new Error("managed extensions root is a symbolic link");
|
|
40
67
|
const realExtensions = realpathSync(extensions);
|
|
41
68
|
if (!existsSync(target))
|
|
42
69
|
return target;
|
|
@@ -51,11 +78,17 @@ function ownedDirectory(root, id) {
|
|
|
51
78
|
function openExtensionStore(root) {
|
|
52
79
|
return {
|
|
53
80
|
list() {
|
|
54
|
-
const
|
|
55
|
-
|
|
81
|
+
const rows = readExtra(root);
|
|
82
|
+
const overrides = new Map(rows.filter((row) => BUNDLED_IDS.has(row.id)).map((row) => [row.id, row]));
|
|
83
|
+
const seeds = bundledExtensions().map((row) => {
|
|
84
|
+
const override = overrides.get(row.id);
|
|
85
|
+
return override ? { ...row, useState: override.useState } : row;
|
|
86
|
+
});
|
|
87
|
+
return [...seeds, ...rows.filter((row) => !BUNDLED_IDS.has(row.id))];
|
|
56
88
|
},
|
|
57
89
|
putManaged(record) {
|
|
58
90
|
assertId(record.id);
|
|
91
|
+
assertPinned(record);
|
|
59
92
|
if (record.provision === "managed") {
|
|
60
93
|
const target = ownedDirectory(root, record.id);
|
|
61
94
|
if (!existsSync(target))
|
|
@@ -92,6 +125,7 @@ function openExtensionStore(root) {
|
|
|
92
125
|
assertId(record.id);
|
|
93
126
|
if (record.provision !== "managed")
|
|
94
127
|
throw new Error("only a managed extension can be installed");
|
|
128
|
+
assertPinned(record);
|
|
95
129
|
if (!existsSync(stagedDir) || !lstatSync(stagedDir).isDirectory()) {
|
|
96
130
|
throw new Error("staged extension directory is missing");
|
|
97
131
|
}
|
|
@@ -114,6 +148,39 @@ function openExtensionStore(root) {
|
|
|
114
148
|
rows.push({ ...record, provision: "managed", installState: "installed", error: null });
|
|
115
149
|
writeExtra(root, rows);
|
|
116
150
|
},
|
|
151
|
+
/**
|
|
152
|
+
* Flip the use state of a record. Bundled extensions live in `bundledExtensions()`, so their
|
|
153
|
+
* choice is stored as an override row rather than by editing the seed.
|
|
154
|
+
*/
|
|
155
|
+
setUseState(id, useState) {
|
|
156
|
+
if (useState !== "enabled" && useState !== "disabled")
|
|
157
|
+
throw new Error(`invalid use state: ${useState}`);
|
|
158
|
+
if (BUNDLED_IDS.has(id)) {
|
|
159
|
+
const rows = readExtra(root).filter((row) => row.id !== id);
|
|
160
|
+
const seed = bundledExtensions().find((row) => row.id === id);
|
|
161
|
+
rows.push({ ...seed, useState });
|
|
162
|
+
writeExtra(root, rows);
|
|
163
|
+
return { ...seed, useState };
|
|
164
|
+
}
|
|
165
|
+
const current = readExtra(root).find((row) => row.id === id);
|
|
166
|
+
if (!current)
|
|
167
|
+
throw new Error("extension is not installed here");
|
|
168
|
+
const next = { ...current, useState };
|
|
169
|
+
writeExtra(root, readExtra(root).map((row) => row.id === id ? next : row));
|
|
170
|
+
return next;
|
|
171
|
+
},
|
|
172
|
+
/** End an install that did not finish: drop whatever reached the managed folder and keep the reason. */
|
|
173
|
+
failInstall(id, error) {
|
|
174
|
+
assertId(id);
|
|
175
|
+
const current = readExtra(root).find((row) => row.id === id);
|
|
176
|
+
if (!current || current.provision !== "managed" || current.installState !== "installing")
|
|
177
|
+
throw new Error("extension is not installing");
|
|
178
|
+
const target = ownedDirectory(root, id);
|
|
179
|
+
if (existsSync(target))
|
|
180
|
+
rmSync(target, { recursive: true, force: true });
|
|
181
|
+
rmSync(path.join(managedPath(root, id).extensions, `.${id}.incoming`), { recursive: true, force: true });
|
|
182
|
+
writeExtra(root, readExtra(root).map((row) => row.id === id ? { ...row, installState: "failed", error } : row));
|
|
183
|
+
},
|
|
117
184
|
cancelInstall(id) {
|
|
118
185
|
assertId(id);
|
|
119
186
|
const current = readExtra(root).find((row) => row.id === id);
|