gisool 0.0.0-stage → 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 +22 -0
- package/README.md +136 -2
- package/dist/api/routes.js +166 -0
- package/dist/api/security.js +86 -0
- package/dist/api/sse.js +55 -0
- package/dist/api/static.js +32 -0
- package/dist/catalog/fields.js +146 -0
- package/dist/catalog/index.js +133 -0
- package/dist/catalog/scanner.js +185 -0
- package/dist/catalog/uri.js +41 -0
- package/dist/catalog/watcher.js +59 -0
- package/dist/cli/config-writer.js +255 -0
- package/dist/cli/doctor.js +197 -0
- package/dist/cli/eval.js +64 -0
- package/dist/cli/export-import.js +66 -0
- package/dist/cli/init.js +271 -0
- package/dist/cli/main.js +165 -0
- package/dist/cli/open.js +24 -0
- package/dist/cli/serve.js +144 -0
- package/dist/config.js +78 -0
- package/dist/dashboard/assets/app.js +256 -0
- package/dist/dashboard/assets/chunk-4U4GNPH4.js +17 -0
- package/dist/dashboard/assets/chunk-5DFWNJC2.js +1 -0
- package/dist/dashboard/assets/chunk-JPIEF5XO.js +17 -0
- package/dist/dashboard/assets/chunk-ZBUKVJOI.js +1 -0
- package/dist/dashboard/assets/styles.css +487 -0
- package/dist/dashboard/index.html +15 -0
- package/dist/ingest/attribution.js +55 -0
- package/dist/ingest/hooks.js +168 -0
- package/dist/ingest/mcp-recorder.js +41 -0
- package/dist/ingest/otlp.js +160 -0
- package/dist/ingest/pipeline.js +100 -0
- package/dist/ingest/redact.js +36 -0
- package/dist/ingest/runtimes.js +25 -0
- package/dist/mcp/http.js +84 -0
- package/dist/mcp/skills-extension.js +35 -0
- package/dist/mcp/stdio-proxy.js +53 -0
- package/dist/mcp/tools.js +164 -0
- package/dist/packs/claude-code.js +157 -0
- package/dist/packs/codex.js +148 -0
- package/dist/packs/cursor.js +95 -0
- package/dist/packs/gajae.js +7 -0
- package/dist/packs/gemini.js +88 -0
- package/dist/packs/json-config.js +51 -0
- package/dist/packs/omo.js +149 -0
- package/dist/packs/openclaw.js +119 -0
- package/dist/packs/registry.js +13 -0
- package/dist/search/fts.js +61 -0
- package/dist/search/fusion.js +50 -0
- package/dist/search/guidance.js +7 -0
- package/dist/search/index.js +74 -0
- package/dist/search/lexical.js +69 -0
- package/dist/server.js +216 -0
- package/dist/store/db.js +74 -0
- package/dist/store/export.js +199 -0
- package/dist/store/index.js +13 -0
- package/dist/store/queries.js +458 -0
- package/dist/store/schema.sql +96 -0
- package/dist/store/writes.js +172 -0
- package/dist/types.js +1 -0
- package/dist/version.js +19 -0
- package/package.json +46 -4
- package/packs/claude-code/README.md +5 -0
- package/packs/claude-code/settings.template.json +24 -0
- package/packs/codex/README.md +5 -0
- package/packs/codex/config.template.toml +11 -0
- package/packs/cursor/hooks.example.json +8 -0
- package/packs/cursor/mcp.example.json +8 -0
- package/packs/gajae/extension/discovery.md +5 -0
- package/packs/gajae/extension/index.js +44 -0
- package/packs/gemini/settings.example.json +16 -0
- package/packs/hook-post.mjs +29 -0
- package/packs/omo/extension/discovery.md +5 -0
- package/packs/omo/extension/index.js +44 -0
- package/packs/openclaw/.codex-plugin/plugin.json +11 -0
- package/packs/openclaw/.mcp.json +12 -0
- package/packs/openclaw/gisool.json +1 -0
- package/packs/openclaw/hooks/gisool-discovery/HOOK.md +8 -0
- package/packs/openclaw/hooks/gisool-discovery/handler.js +27 -0
- package/packs/openclaw/index.mjs +47 -0
- package/packs/openclaw/openclaw.plugin.json +22 -0
- package/packs/openclaw/package.json +11 -0
- package/src/store/schema.sql +96 -0
- package/src/store/task-types.json +15 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { applyFamilies } from "./fields.js";
|
|
3
|
+
import { assertRegularSkillPath, CatalogError, scanRoot, sha256 } from "./scanner.js";
|
|
4
|
+
import { parseSkillUri, skillUri } from "./uri.js";
|
|
5
|
+
import { watchRoots } from "./watcher.js";
|
|
6
|
+
export { CatalogError } from "./scanner.js";
|
|
7
|
+
export { parseSkillUri, skillUri } from "./uri.js";
|
|
8
|
+
function deltaIsEmpty(d) {
|
|
9
|
+
return d.added.length === 0 && d.changed.length === 0 && d.removed.length === 0;
|
|
10
|
+
}
|
|
11
|
+
/** Indexes the configured skill roots in place. Nothing is scanned until refresh(); nothing is ever written. */
|
|
12
|
+
export function createCatalog(config) {
|
|
13
|
+
const roots = config.roots.map((r) => ({ id: r.id, path: r.path }));
|
|
14
|
+
const log = config.log ?? console.error;
|
|
15
|
+
let entries = new Map();
|
|
16
|
+
let resourceIndex = new Map();
|
|
17
|
+
let families = [];
|
|
18
|
+
let rootStatus = roots.map((r) => ({ id: r.id, path: r.path, readable: false, count: 0 }));
|
|
19
|
+
const listeners = new Set();
|
|
20
|
+
let chain = Promise.resolve();
|
|
21
|
+
async function scan() {
|
|
22
|
+
const next = [];
|
|
23
|
+
const explicit = new Map();
|
|
24
|
+
const status = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const root of roots) {
|
|
27
|
+
const { readable, skills } = await scanRoot(root, log);
|
|
28
|
+
let count = 0;
|
|
29
|
+
for (const { entry, explicitGroup } of skills) {
|
|
30
|
+
if (seen.has(entry.uri))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(entry.uri);
|
|
33
|
+
next.push(entry);
|
|
34
|
+
explicit.set(entry.uri, explicitGroup);
|
|
35
|
+
count += 1;
|
|
36
|
+
}
|
|
37
|
+
status.push({ id: root.id, path: root.path, readable, count });
|
|
38
|
+
}
|
|
39
|
+
next.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
40
|
+
const nextFamilies = applyFamilies(next, explicit);
|
|
41
|
+
const delta = { added: [], changed: [], removed: [] };
|
|
42
|
+
const nextMap = new Map(next.map((e) => [e.uri, e]));
|
|
43
|
+
for (const entry of next) {
|
|
44
|
+
const prev = entries.get(entry.uri);
|
|
45
|
+
if (!prev)
|
|
46
|
+
delta.added.push(entry);
|
|
47
|
+
else if (prev.manifestDigest !== entry.manifestDigest || JSON.stringify(prev.group) !== JSON.stringify(entry.group))
|
|
48
|
+
delta.changed.push(entry);
|
|
49
|
+
}
|
|
50
|
+
for (const uri of entries.keys())
|
|
51
|
+
if (!nextMap.has(uri))
|
|
52
|
+
delta.removed.push(uri);
|
|
53
|
+
const nextResources = new Map();
|
|
54
|
+
for (const entry of next)
|
|
55
|
+
for (const r of entry.resources)
|
|
56
|
+
nextResources.set(r.uri, { entry, digest: r.digest, size: r.size });
|
|
57
|
+
entries = nextMap;
|
|
58
|
+
resourceIndex = nextResources;
|
|
59
|
+
families = nextFamilies;
|
|
60
|
+
rootStatus = status;
|
|
61
|
+
if (!deltaIsEmpty(delta)) {
|
|
62
|
+
for (const cb of listeners) {
|
|
63
|
+
try {
|
|
64
|
+
cb(delta);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
log(`catalog onDelta listener failed: ${error.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return delta;
|
|
72
|
+
}
|
|
73
|
+
function refresh() {
|
|
74
|
+
const run = chain.then(scan, scan);
|
|
75
|
+
chain = run.catch(() => undefined);
|
|
76
|
+
return run;
|
|
77
|
+
}
|
|
78
|
+
const watcher = config.watch
|
|
79
|
+
? watchRoots(roots, () => {
|
|
80
|
+
refresh().catch((error) => log(`catalog refresh failed: ${error.message}`));
|
|
81
|
+
}, { debounceMs: config.watchDebounceMs, log })
|
|
82
|
+
: undefined;
|
|
83
|
+
return {
|
|
84
|
+
list: () => [...entries.values()],
|
|
85
|
+
get: (uri) => entries.get(uri),
|
|
86
|
+
async readResource(uri) {
|
|
87
|
+
const parsed = parseSkillUri(uri);
|
|
88
|
+
if (!parsed)
|
|
89
|
+
throw new CatalogError("invalid_uri", "not a valid skill://local URI");
|
|
90
|
+
const root = roots.find((r) => r.id === parsed.rootId);
|
|
91
|
+
if (!root)
|
|
92
|
+
throw new CatalogError("unknown_root", `unknown skill root: ${parsed.rootId}`);
|
|
93
|
+
const canonical = skillUri(parsed.rootId, ...parsed.segments);
|
|
94
|
+
const filePath = await assertRegularSkillPath(root.path, parsed.segments);
|
|
95
|
+
const known = resourceIndex.get(canonical);
|
|
96
|
+
if (!known)
|
|
97
|
+
throw new CatalogError("unknown_resource", "resource is not in any skill manifest");
|
|
98
|
+
const bytes = await readFile(filePath);
|
|
99
|
+
const digest = sha256(bytes);
|
|
100
|
+
if (digest !== known.digest)
|
|
101
|
+
throw new CatalogError("digest_mismatch", "resource changed since the last catalog refresh");
|
|
102
|
+
return { text: bytes.toString("utf8"), digest, size: bytes.byteLength };
|
|
103
|
+
},
|
|
104
|
+
refresh,
|
|
105
|
+
onDelta(cb) {
|
|
106
|
+
listeners.add(cb);
|
|
107
|
+
return () => listeners.delete(cb);
|
|
108
|
+
},
|
|
109
|
+
report() {
|
|
110
|
+
const byName = new Map();
|
|
111
|
+
for (const entry of entries.values()) {
|
|
112
|
+
if (!byName.has(entry.name))
|
|
113
|
+
byName.set(entry.name, { roots: new Set(), uris: [] });
|
|
114
|
+
const slot = byName.get(entry.name);
|
|
115
|
+
slot.roots.add(entry.rootId);
|
|
116
|
+
slot.uris.push(entry.uri);
|
|
117
|
+
}
|
|
118
|
+
const duplicates = [...byName.entries()]
|
|
119
|
+
.filter(([, v]) => v.roots.size > 1)
|
|
120
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
121
|
+
.map(([name, v]) => ({ name, uris: v.uris.sort() }));
|
|
122
|
+
return {
|
|
123
|
+
duplicates,
|
|
124
|
+
families: families.map((f) => ({ id: f.id, uris: [...f.uris] })),
|
|
125
|
+
roots: rootStatus.map((r) => ({ ...r })),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
close() {
|
|
129
|
+
watcher?.close();
|
|
130
|
+
listeners.clear();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Ported from server/src/index.ts @48e7955
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstat, readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { extractFields, splitSkillMarkdown } from "./fields.js";
|
|
6
|
+
import { skillUri } from "./uri.js";
|
|
7
|
+
/** SEP-2640 limits per skill. */
|
|
8
|
+
export const MAX_SKILL_RESOURCE_COUNT = 512;
|
|
9
|
+
export const MAX_SKILL_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
export class CatalogError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(`${code}: ${message}`);
|
|
14
|
+
this.name = "CatalogError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function sha256(bytes) {
|
|
19
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
20
|
+
}
|
|
21
|
+
export function manifestDigest(resources) {
|
|
22
|
+
const sorted = resources.map(({ uri, digest, size }) => ({ uri, digest, size })).sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0));
|
|
23
|
+
return sha256(JSON.stringify(sorted));
|
|
24
|
+
}
|
|
25
|
+
/** Regular files under baseDir (relative posix paths), skipping dotfiles, node_modules and symlinks. */
|
|
26
|
+
export async function collectSkillRelativeFiles(baseDir) {
|
|
27
|
+
const output = [];
|
|
28
|
+
async function walk(dir, prefix) {
|
|
29
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
32
|
+
continue;
|
|
33
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
await walk(path.join(dir, entry.name), relative);
|
|
36
|
+
}
|
|
37
|
+
else if (entry.isFile()) {
|
|
38
|
+
const info = await lstat(path.join(dir, entry.name));
|
|
39
|
+
if (info.isFile())
|
|
40
|
+
output.push({ relative, size: info.size });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await walk(baseDir, "");
|
|
45
|
+
return output.sort((a, b) => (a.relative < b.relative ? -1 : a.relative > b.relative ? 1 : 0));
|
|
46
|
+
}
|
|
47
|
+
/** Directories (relative to rootDir, never through symlinked dirs) that contain a SKILL.md. */
|
|
48
|
+
export async function listSkillEntryDirs(rootDir) {
|
|
49
|
+
const output = [];
|
|
50
|
+
async function walk(dir, prefix) {
|
|
51
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules")
|
|
54
|
+
continue;
|
|
55
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
56
|
+
try {
|
|
57
|
+
const info = await stat(path.join(dir, entry.name, "SKILL.md"));
|
|
58
|
+
if (info.isFile())
|
|
59
|
+
output.push(relative);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// not a skill directory; descendants may still contain one
|
|
63
|
+
}
|
|
64
|
+
await walk(path.join(dir, entry.name), relative);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await walk(rootDir, "");
|
|
68
|
+
return output.sort();
|
|
69
|
+
}
|
|
70
|
+
/** Builds one entry; returns null (with a stderr note) for anything that must not be served. Reads but never writes. */
|
|
71
|
+
export async function buildSkillEntry(rootId, rootDir, dir, log = console.error) {
|
|
72
|
+
const skillDir = path.join(rootDir, dir);
|
|
73
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
74
|
+
let mtimeMs;
|
|
75
|
+
try {
|
|
76
|
+
const info = await lstat(skillPath);
|
|
77
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
78
|
+
log(`Skipping skill ${rootId}/${dir}: SKILL.md must be a regular file`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
mtimeMs = info.mtimeMs;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const files = await collectSkillRelativeFiles(skillDir);
|
|
87
|
+
const declaredBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
88
|
+
if (files.length > MAX_SKILL_RESOURCE_COUNT || declaredBytes > MAX_SKILL_TOTAL_BYTES) {
|
|
89
|
+
log(`Skipping skill ${rootId}/${dir}: exceeds SEP-2640 limits (${files.length} resources, ${declaredBytes} bytes)`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let fields = null;
|
|
93
|
+
let skillDigest;
|
|
94
|
+
const resources = [];
|
|
95
|
+
let totalBytes = 0;
|
|
96
|
+
for (const { relative } of files) {
|
|
97
|
+
const bytes = await readFile(path.join(skillDir, relative));
|
|
98
|
+
totalBytes += bytes.byteLength;
|
|
99
|
+
const digest = sha256(bytes);
|
|
100
|
+
resources.push({ uri: skillUri(rootId, dir, relative), digest, size: bytes.byteLength });
|
|
101
|
+
if (relative === "SKILL.md") {
|
|
102
|
+
skillDigest = digest;
|
|
103
|
+
const { frontmatter, body } = splitSkillMarkdown(bytes.toString("utf8"));
|
|
104
|
+
fields = frontmatter ? extractFields(frontmatter, body) : null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (resources.length > MAX_SKILL_RESOURCE_COUNT || totalBytes > MAX_SKILL_TOTAL_BYTES) {
|
|
108
|
+
log(`Skipping skill ${rootId}/${dir}: exceeds SEP-2640 limits (${resources.length} resources, ${totalBytes} bytes)`);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (!skillDigest)
|
|
112
|
+
return null;
|
|
113
|
+
if (!fields) {
|
|
114
|
+
log(`Skipping skill ${rootId}/${dir}: SKILL.md frontmatter requires name and description`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (fields.name !== dir.split("/").pop()) {
|
|
118
|
+
log(`Skipping skill ${rootId}/${dir}: frontmatter name "${fields.name}" does not match directory name`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const { group, ...rest } = fields;
|
|
122
|
+
const entry = {
|
|
123
|
+
uri: skillUri(rootId, dir, "SKILL.md"),
|
|
124
|
+
rootId,
|
|
125
|
+
dir,
|
|
126
|
+
...rest,
|
|
127
|
+
digest: skillDigest,
|
|
128
|
+
manifestDigest: manifestDigest(resources),
|
|
129
|
+
resources,
|
|
130
|
+
mtimeMs,
|
|
131
|
+
};
|
|
132
|
+
return group ? { entry, explicitGroup: group } : { entry };
|
|
133
|
+
}
|
|
134
|
+
/** Scans one root; a missing or unreadable root is reported as readable: false, not thrown. */
|
|
135
|
+
export async function scanRoot(root, log = console.error) {
|
|
136
|
+
let dirs;
|
|
137
|
+
try {
|
|
138
|
+
const info = await stat(root.path);
|
|
139
|
+
if (!info.isDirectory())
|
|
140
|
+
return { readable: false, skills: [] };
|
|
141
|
+
dirs = await listSkillEntryDirs(root.path);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { readable: false, skills: [] };
|
|
145
|
+
}
|
|
146
|
+
const skills = [];
|
|
147
|
+
for (const dir of dirs) {
|
|
148
|
+
try {
|
|
149
|
+
const skill = await buildSkillEntry(root.id, root.path, dir, log);
|
|
150
|
+
if (skill)
|
|
151
|
+
skills.push(skill);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
log(`Failed to build skill entry for ${root.id}/${dir}: ${error.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { readable: true, skills };
|
|
158
|
+
}
|
|
159
|
+
/** Resolves segments under rootDir, refusing traversal outside the root and any symlink along the way. */
|
|
160
|
+
export async function assertRegularSkillPath(rootDir, segments) {
|
|
161
|
+
const relativePath = path.posix.normalize(segments.join("/"));
|
|
162
|
+
const filePath = path.resolve(rootDir, relativePath);
|
|
163
|
+
const relativeToRoot = path.relative(rootDir, filePath);
|
|
164
|
+
if (!relativeToRoot || relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
|
|
165
|
+
throw new CatalogError("path_outside_root", "resource path escapes its skill root");
|
|
166
|
+
}
|
|
167
|
+
let current = rootDir;
|
|
168
|
+
const parts = relativeToRoot.split(path.sep);
|
|
169
|
+
for (const [index, segment] of parts.entries()) {
|
|
170
|
+
current = path.join(current, segment);
|
|
171
|
+
let info;
|
|
172
|
+
try {
|
|
173
|
+
info = await lstat(current);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new CatalogError("unknown_resource", "unknown or inaccessible skill resource");
|
|
177
|
+
}
|
|
178
|
+
if (info.isSymbolicLink())
|
|
179
|
+
throw new CatalogError("symlink_refused", "symbolic links inside skill roots are not served");
|
|
180
|
+
if (index === parts.length - 1 ? !info.isFile() : !info.isDirectory()) {
|
|
181
|
+
throw new CatalogError("unknown_resource", "skill resources require regular files and directories");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return current;
|
|
185
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const SKILL_URI_AUTHORITY = "local";
|
|
2
|
+
/** skill://local/<rootId>/<dir...>/<relative...>, each path segment URI-encoded. */
|
|
3
|
+
export function skillUri(...segments) {
|
|
4
|
+
const parts = segments.flatMap((segment) => segment.split("/")).filter(Boolean);
|
|
5
|
+
return `skill://${SKILL_URI_AUTHORITY}/${parts.map((part) => encodeURIComponent(part)).join("/")}`;
|
|
6
|
+
}
|
|
7
|
+
/** Parses a skill URI into its root id and path segments; returns null for anything malformed or traversing. */
|
|
8
|
+
export function parseSkillUri(uri) {
|
|
9
|
+
if (typeof uri !== "string" || /%2e/i.test(uri) || /%2f/i.test(uri) || uri.includes("\\"))
|
|
10
|
+
return null;
|
|
11
|
+
const authorityIndex = uri.indexOf("://");
|
|
12
|
+
const rawPath = authorityIndex === -1 ? uri : uri.slice(authorityIndex + 3);
|
|
13
|
+
if (rawPath.split("/").some((segment) => segment === ".." || segment === "."))
|
|
14
|
+
return null;
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(uri);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== "skill:" || parsed.host !== SKILL_URI_AUTHORITY || parsed.username || parsed.password || parsed.search || parsed.hash || !parsed.pathname || parsed.pathname === "/")
|
|
23
|
+
return null;
|
|
24
|
+
let segments;
|
|
25
|
+
try {
|
|
26
|
+
segments = parsed.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (segments.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
const rootId = segments[0];
|
|
34
|
+
if (!/^[A-Za-z0-9._-]+$/.test(rootId))
|
|
35
|
+
return null;
|
|
36
|
+
for (const segment of segments.slice(1)) {
|
|
37
|
+
if (segment === ".." || segment === "." || segment.includes("/") || segment.includes("\0"))
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return { rootId, segments: segments.slice(1) };
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { watch } from "node:fs";
|
|
2
|
+
export const WATCH_DEBOUNCE_MS = 500;
|
|
3
|
+
const WATCH_FALLBACK_MS = 2000;
|
|
4
|
+
/**
|
|
5
|
+
* Watches each root recursively and calls onChange once per quiet period of debounceMs.
|
|
6
|
+
* Roots that do not exist (or cannot be watched) are skipped with one log line; the next refresh still reports them unreadable.
|
|
7
|
+
* A watcher that errors later (EMFILE, EACCES) is logged once and closed; the periodic refresh keeps detecting changes.
|
|
8
|
+
*/
|
|
9
|
+
export function watchRoots(roots, onChange, opts) {
|
|
10
|
+
const debounceMs = opts.debounceMs ?? WATCH_DEBOUNCE_MS;
|
|
11
|
+
const watchers = [];
|
|
12
|
+
let timer;
|
|
13
|
+
let closed = false;
|
|
14
|
+
const schedule = () => {
|
|
15
|
+
if (closed)
|
|
16
|
+
return;
|
|
17
|
+
if (timer)
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
timer = setTimeout(() => {
|
|
20
|
+
timer = undefined;
|
|
21
|
+
if (!closed)
|
|
22
|
+
onChange();
|
|
23
|
+
}, debounceMs);
|
|
24
|
+
};
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
try {
|
|
27
|
+
const watcher = watch(root.path, { recursive: true, persistent: false }, schedule);
|
|
28
|
+
let failed = false;
|
|
29
|
+
watcher.on("error", (error) => {
|
|
30
|
+
if (failed)
|
|
31
|
+
return;
|
|
32
|
+
failed = true;
|
|
33
|
+
opts.log(`catalog watcher for root ${root.id} (${root.path}) failed: ${error.message}`);
|
|
34
|
+
watcher.close();
|
|
35
|
+
});
|
|
36
|
+
watchers.push(watcher);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
opts.log(`catalog watcher cannot watch root ${root.id} (${root.path}): ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// fs.watch can drop events under load; a periodic refresh (no-op when nothing changed) bounds detection latency.
|
|
43
|
+
let poll;
|
|
44
|
+
if (watchers.length > 0) {
|
|
45
|
+
poll = setInterval(schedule, WATCH_FALLBACK_MS);
|
|
46
|
+
poll.unref();
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
close() {
|
|
50
|
+
closed = true;
|
|
51
|
+
if (poll)
|
|
52
|
+
clearInterval(poll);
|
|
53
|
+
if (timer)
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
for (const watcher of watchers)
|
|
56
|
+
watcher.close();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
export class WriterError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
paths;
|
|
7
|
+
constructor(code, message, paths = []) {
|
|
8
|
+
super(`${code}: ${message}`);
|
|
9
|
+
this.name = "WriterError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.paths = paths;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
15
|
+
export function readTarget(path) {
|
|
16
|
+
let st;
|
|
17
|
+
try {
|
|
18
|
+
st = lstatSync(path);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === "ENOENT")
|
|
22
|
+
return null;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (!st.isFile())
|
|
26
|
+
throw new WriterError("not_a_regular_file", `expected a regular file: ${path}`, [path]);
|
|
27
|
+
return readFileSync(path, "utf8");
|
|
28
|
+
}
|
|
29
|
+
export const changedFiles = (files) => files.filter((f) => f.before !== f.after);
|
|
30
|
+
export function maskSecrets(text, secrets) {
|
|
31
|
+
let out = text;
|
|
32
|
+
for (const s of secrets)
|
|
33
|
+
if (s.length >= 8)
|
|
34
|
+
out = out.replaceAll(s, "<gisool-token>");
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function splitLines(text) {
|
|
38
|
+
if (text === null || text === "")
|
|
39
|
+
return [];
|
|
40
|
+
const lines = text.split("\n");
|
|
41
|
+
if (lines[lines.length - 1] === "")
|
|
42
|
+
lines.pop();
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
function diffOps(a, b) {
|
|
46
|
+
const n = a.length;
|
|
47
|
+
const m = b.length;
|
|
48
|
+
if (n * m > 25_000_000)
|
|
49
|
+
return [...a.map((line) => ({ tag: "-", line })), ...b.map((line) => ({ tag: "+", line }))];
|
|
50
|
+
// LCS table over suffixes; lcs[i][j] = LCS length of a[i..] and b[j..].
|
|
51
|
+
const w = m + 1;
|
|
52
|
+
const lcs = new Uint32Array((n + 1) * w);
|
|
53
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
54
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
55
|
+
lcs[i * w + j] = a[i] === b[j] ? lcs[(i + 1) * w + j + 1] + 1 : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const ops = [];
|
|
59
|
+
let i = 0;
|
|
60
|
+
let j = 0;
|
|
61
|
+
while (i < n && j < m) {
|
|
62
|
+
if (a[i] === b[j]) {
|
|
63
|
+
ops.push({ tag: " ", line: a[i] });
|
|
64
|
+
i++;
|
|
65
|
+
j++;
|
|
66
|
+
}
|
|
67
|
+
else if (lcs[(i + 1) * w + j] >= lcs[i * w + j + 1])
|
|
68
|
+
ops.push({ tag: "-", line: a[i++] });
|
|
69
|
+
else
|
|
70
|
+
ops.push({ tag: "+", line: b[j++] });
|
|
71
|
+
}
|
|
72
|
+
while (i < n)
|
|
73
|
+
ops.push({ tag: "-", line: a[i++] });
|
|
74
|
+
while (j < m)
|
|
75
|
+
ops.push({ tag: "+", line: b[j++] });
|
|
76
|
+
return ops;
|
|
77
|
+
}
|
|
78
|
+
export function unifiedDiff(path, before, after, context = 3) {
|
|
79
|
+
if (before === after)
|
|
80
|
+
return "";
|
|
81
|
+
const ops = diffOps(splitLines(before), splitLines(after));
|
|
82
|
+
const header = [`--- ${before === null ? "/dev/null" : `a${path}`}`, `+++ ${after === null ? "/dev/null" : `b${path}`}`];
|
|
83
|
+
const changes = ops.map((op, idx) => (op.tag === " " ? -1 : idx)).filter((idx) => idx >= 0);
|
|
84
|
+
if (changes.length === 0)
|
|
85
|
+
return [...header, "@@ whitespace or trailing newline only @@"].join("\n") + "\n";
|
|
86
|
+
const hunks = [];
|
|
87
|
+
for (const idx of changes) {
|
|
88
|
+
const start = Math.max(0, idx - context);
|
|
89
|
+
const end = Math.min(ops.length, idx + context + 1);
|
|
90
|
+
const last = hunks[hunks.length - 1];
|
|
91
|
+
if (last && start <= last[1])
|
|
92
|
+
last[1] = Math.max(last[1], end);
|
|
93
|
+
else
|
|
94
|
+
hunks.push([start, end]);
|
|
95
|
+
}
|
|
96
|
+
const out = [...header];
|
|
97
|
+
for (const [start, end] of hunks) {
|
|
98
|
+
let aLine = 1;
|
|
99
|
+
let bLine = 1;
|
|
100
|
+
for (let k = 0; k < start; k++) {
|
|
101
|
+
if (ops[k].tag !== "+")
|
|
102
|
+
aLine++;
|
|
103
|
+
if (ops[k].tag !== "-")
|
|
104
|
+
bLine++;
|
|
105
|
+
}
|
|
106
|
+
const slice = ops.slice(start, end);
|
|
107
|
+
const aLen = slice.filter((o) => o.tag !== "+").length;
|
|
108
|
+
const bLen = slice.filter((o) => o.tag !== "-").length;
|
|
109
|
+
out.push(`@@ -${aLen === 0 ? aLine - 1 : aLine},${aLen} +${bLen === 0 ? bLine - 1 : bLine},${bLen} @@`);
|
|
110
|
+
for (const o of slice)
|
|
111
|
+
out.push(`${o.tag}${o.line}`);
|
|
112
|
+
}
|
|
113
|
+
return out.join("\n") + "\n";
|
|
114
|
+
}
|
|
115
|
+
/** Unparseable targets are refused: only JSON is checked structurally (TOML and other formats are validated by their pack). */
|
|
116
|
+
export function unparseable(file) {
|
|
117
|
+
if (!file.path.endsWith(".json") || file.before === null || file.before.trim() === "")
|
|
118
|
+
return undefined;
|
|
119
|
+
try {
|
|
120
|
+
JSON.parse(file.before);
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return error.message;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export function backupPathFor(backupDir, home, path) {
|
|
128
|
+
const rel = relative(home, path);
|
|
129
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? join(backupDir, "files", rel) : join(backupDir, "files", "_root", path);
|
|
130
|
+
}
|
|
131
|
+
function snapshot(path) {
|
|
132
|
+
const text = readTarget(path);
|
|
133
|
+
return { exists: text !== null, mtimeMs: text === null ? 0 : statSync(path).mtimeMs, digest: text === null ? null : sha256(text) };
|
|
134
|
+
}
|
|
135
|
+
function mkdirTracked(dir, created) {
|
|
136
|
+
const missing = [];
|
|
137
|
+
let cur = dir;
|
|
138
|
+
while (!existsSync(cur)) {
|
|
139
|
+
missing.unshift(cur);
|
|
140
|
+
const parent = dirname(cur);
|
|
141
|
+
if (parent === cur)
|
|
142
|
+
break;
|
|
143
|
+
cur = parent;
|
|
144
|
+
}
|
|
145
|
+
for (const d of missing) {
|
|
146
|
+
mkdirSync(d, { mode: 0o700 });
|
|
147
|
+
created.push(d);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function atomicWrite(path, content, mode, expected) {
|
|
151
|
+
const tmp = `${path}.gisool-${randomUUID()}.tmp`;
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(tmp, content, { flag: "wx", mode });
|
|
154
|
+
chmodSync(tmp, mode);
|
|
155
|
+
if (expected) {
|
|
156
|
+
// Re-read mtime + digest immediately before the rename so a concurrent editor's change is never clobbered.
|
|
157
|
+
const now = snapshot(path);
|
|
158
|
+
if (now.exists !== expected.exists || now.digest !== expected.digest || now.mtimeMs !== expected.mtimeMs) {
|
|
159
|
+
throw new WriterError("concurrent_modification", `file changed during apply: ${path}`, [path]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
renameSync(tmp, path);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
rmSync(tmp, { force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Applies planned files atomically. Validates the whole plan first (unparseable or changed-since-plan files abort before
|
|
170
|
+
* any write), backs up originals under backupDir mirroring their path relative to home (0600), writes via temp + rename
|
|
171
|
+
* preserving an existing file's mode, and on any failure restores every file already written.
|
|
172
|
+
*/
|
|
173
|
+
export function applyFiles(files, opts) {
|
|
174
|
+
const todo = changedFiles(files);
|
|
175
|
+
const bad = todo.filter((f) => unparseable(f) !== undefined);
|
|
176
|
+
if (bad.length)
|
|
177
|
+
throw new WriterError("unparseable_config", `refusing to edit unparseable file(s): ${bad.map((f) => f.path).join(", ")}`, bad.map((f) => f.path));
|
|
178
|
+
const moved = todo.filter((f) => readTarget(f.path) !== f.before);
|
|
179
|
+
if (moved.length)
|
|
180
|
+
throw new WriterError("modified_since_plan", `file(s) changed after the plan was made: ${moved.map((f) => f.path).join(", ")}`, moved.map((f) => f.path));
|
|
181
|
+
const applied = [];
|
|
182
|
+
const createdDirs = [];
|
|
183
|
+
const written = [];
|
|
184
|
+
try {
|
|
185
|
+
for (const f of todo) {
|
|
186
|
+
const expected = snapshot(f.path);
|
|
187
|
+
if (expected.digest !== (f.before === null ? null : sha256(f.before))) {
|
|
188
|
+
throw new WriterError("modified_since_plan", `file changed after the plan was made: ${f.path}`, [f.path]);
|
|
189
|
+
}
|
|
190
|
+
let backup = null;
|
|
191
|
+
if (f.before !== null) {
|
|
192
|
+
backup = backupPathFor(opts.backupDir, opts.home, f.path);
|
|
193
|
+
mkdirSync(dirname(backup), { recursive: true, mode: 0o700 });
|
|
194
|
+
writeFileSync(backup, f.before, { flag: "wx", mode: 0o600 });
|
|
195
|
+
chmodSync(backup, 0o600);
|
|
196
|
+
}
|
|
197
|
+
const mode = f.before !== null ? statSync(f.path).mode & 0o777 : f.mode;
|
|
198
|
+
mkdirTracked(dirname(f.path), createdDirs);
|
|
199
|
+
atomicWrite(f.path, f.after, mode, expected);
|
|
200
|
+
written.push(f);
|
|
201
|
+
applied.push({ path: f.path, backup: backup === null ? null : relative(opts.backupDir, backup), existed: f.before !== null,
|
|
202
|
+
beforeDigest: f.before === null ? null : sha256(f.before), afterDigest: sha256(f.after), mode });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
restoreWritten(written);
|
|
207
|
+
removeEmptyDirs(createdDirs);
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
return { files: applied, createdDirs };
|
|
211
|
+
}
|
|
212
|
+
function restoreWritten(written) {
|
|
213
|
+
for (const f of [...written].reverse()) {
|
|
214
|
+
if (readTarget(f.path) !== f.after)
|
|
215
|
+
continue;
|
|
216
|
+
if (f.before === null)
|
|
217
|
+
rmSync(f.path, { force: true });
|
|
218
|
+
else
|
|
219
|
+
atomicWrite(f.path, f.before, statSync(f.path).mode & 0o777, null);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export function removeEmptyDirs(dirs) {
|
|
223
|
+
for (const d of [...dirs].reverse()) {
|
|
224
|
+
try {
|
|
225
|
+
rmdirSync(d);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Non-empty (someone else wrote there) or already gone: keep it.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Restores files recorded by applyFiles from backupDir; files edited since gisool wrote them are left alone. */
|
|
233
|
+
export function restoreFiles(files, backupDir) {
|
|
234
|
+
const out = [];
|
|
235
|
+
for (const f of [...files].reverse()) {
|
|
236
|
+
const current = readTarget(f.path);
|
|
237
|
+
if (current === null && !f.existed) {
|
|
238
|
+
out.push({ path: f.path, action: "unchanged" });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (current === null || sha256(current) !== f.afterDigest) {
|
|
242
|
+
out.push({ path: f.path, action: "skipped_modified" });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!f.existed || f.backup === null) {
|
|
246
|
+
rmSync(f.path, { force: true });
|
|
247
|
+
out.push({ path: f.path, action: "removed" });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const original = readFileSync(join(backupDir, f.backup), "utf8");
|
|
251
|
+
atomicWrite(f.path, original, f.mode, null);
|
|
252
|
+
out.push({ path: f.path, action: "restored" });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|