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,197 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { createCatalog } from "../catalog/index.js";
|
|
5
|
+
import { loadConfig, resolveGisoolHome, resolveHome } from "../config.js";
|
|
6
|
+
import { PACKS } from "../packs/registry.js";
|
|
7
|
+
import { selfCheck } from "../store/db.js";
|
|
8
|
+
import { listBackupSets } from "./init.js";
|
|
9
|
+
import { currentServicePlatform, dbPath, packageVersion, serviceLoaded, servicePath } from "./serve.js";
|
|
10
|
+
const HOOK_LIMIT_S = 5;
|
|
11
|
+
const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
function readJson(path) {
|
|
13
|
+
try {
|
|
14
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
return isObj(v) ? v : undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function gisoolHookTimeouts(cfg, unitSeconds) {
|
|
22
|
+
const out = [];
|
|
23
|
+
const hooks = cfg && isObj(cfg.hooks) ? cfg.hooks : {};
|
|
24
|
+
const visit = (event, h) => {
|
|
25
|
+
if (!isObj(h) || typeof h.command !== "string" || !h.command.includes("hook-post.mjs"))
|
|
26
|
+
return;
|
|
27
|
+
if (typeof h.timeout === "number")
|
|
28
|
+
out.push({ event, seconds: h.timeout * unitSeconds });
|
|
29
|
+
if (Array.isArray(h.hooks))
|
|
30
|
+
for (const inner of h.hooks)
|
|
31
|
+
visit(event, inner);
|
|
32
|
+
};
|
|
33
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
34
|
+
if (!Array.isArray(groups))
|
|
35
|
+
continue;
|
|
36
|
+
for (const g of groups) {
|
|
37
|
+
if (isObj(g) && Array.isArray(g.hooks))
|
|
38
|
+
for (const h of g.hooks)
|
|
39
|
+
visit(event, h);
|
|
40
|
+
else
|
|
41
|
+
visit(event, g);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
function hookTimeoutCheck(home, env) {
|
|
47
|
+
const sources = [
|
|
48
|
+
[join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "settings.json"), 1],
|
|
49
|
+
[join(home, ".gemini", "settings.json"), 0.001],
|
|
50
|
+
[join(home, ".cursor", "hooks.json"), 1],
|
|
51
|
+
];
|
|
52
|
+
const tooLong = [];
|
|
53
|
+
for (const [path, unit] of sources) {
|
|
54
|
+
for (const t of gisoolHookTimeouts(readJson(path), unit))
|
|
55
|
+
if (t.seconds > HOOK_LIMIT_S)
|
|
56
|
+
tooLong.push(`${path} ${t.event} ${t.seconds}s`);
|
|
57
|
+
}
|
|
58
|
+
return tooLong.length
|
|
59
|
+
? { name: "hook_timeouts", status: "fail", code: "hook_timeout_too_long", detail: `gisool hooks must time out within ${HOOK_LIMIT_S}s: ${tooLong.join("; ")}` }
|
|
60
|
+
: { name: "hook_timeouts", status: "ok", detail: `all gisool hook timeouts <= ${HOOK_LIMIT_S}s` };
|
|
61
|
+
}
|
|
62
|
+
async function healthCheck(port, gisoolHome) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1500) });
|
|
65
|
+
const body = (await res.json().catch(() => undefined));
|
|
66
|
+
if (res.status !== 200 || body?.ok !== true || typeof body.version !== "string") {
|
|
67
|
+
return { name: "daemon", status: "fail", code: "not_gisool", detail: `port ${port} answered /healthz with ${res.status} but it is not gisool` };
|
|
68
|
+
}
|
|
69
|
+
const mine = packageVersion();
|
|
70
|
+
if (body.version !== mine)
|
|
71
|
+
return { name: "daemon", status: "warn", code: "version_mismatch", detail: `daemon ${body.version}, CLI ${mine}` };
|
|
72
|
+
const tokenPath = join(gisoolHome, "token");
|
|
73
|
+
if (existsSync(tokenPath)) {
|
|
74
|
+
const stats = await fetch(`http://127.0.0.1:${port}/api/stats`, {
|
|
75
|
+
headers: { authorization: `Bearer ${readFileSync(tokenPath, "utf8").trim()}` },
|
|
76
|
+
signal: AbortSignal.timeout(1500),
|
|
77
|
+
});
|
|
78
|
+
if (stats.status === 401) {
|
|
79
|
+
return { name: "daemon", status: "fail", code: "token_mismatch", detail: `gisool ${body.version} on 127.0.0.1:${port} rejected this install's token: the port is held by another gisool instance, or the token was rotated; restart the daemon` };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { name: "daemon", status: "ok", detail: `gisool ${body.version} on 127.0.0.1:${port}` };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
return { name: "daemon", status: "fail", code: "daemon_unreachable", detail: `no response from http://127.0.0.1:${port}/healthz (${error.message})` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function serviceCheck(home, env) {
|
|
89
|
+
if (env.GISOOL_NO_CLI)
|
|
90
|
+
return { name: "service", status: "skip", detail: "GISOOL_NO_CLI set" };
|
|
91
|
+
const platform = currentServicePlatform();
|
|
92
|
+
const path = servicePath(home, platform);
|
|
93
|
+
if (!existsSync(path))
|
|
94
|
+
return { name: "service", status: "warn", code: "service_not_installed", detail: `${path} missing; run gisool service install` };
|
|
95
|
+
return serviceLoaded(platform, process.getuid?.() ?? 0, env)
|
|
96
|
+
? { name: "service", status: "ok", detail: `${path} loaded` }
|
|
97
|
+
: { name: "service", status: "fail", code: "service_not_loaded", detail: `${path} exists but the service is not loaded` };
|
|
98
|
+
}
|
|
99
|
+
function tokenCheck(gisoolHome) {
|
|
100
|
+
const path = join(gisoolHome, "token");
|
|
101
|
+
if (!existsSync(path))
|
|
102
|
+
return { name: "token_mode", status: "fail", code: "token_missing", detail: path };
|
|
103
|
+
const mode = statSync(path).mode & 0o777;
|
|
104
|
+
return mode === 0o600
|
|
105
|
+
? { name: "token_mode", status: "ok", detail: `${path} 0600` }
|
|
106
|
+
: { name: "token_mode", status: "fail", code: "token_mode_insecure", detail: `${path} is 0${mode.toString(8)}; expected 0600` };
|
|
107
|
+
}
|
|
108
|
+
function ftsCheck() {
|
|
109
|
+
try {
|
|
110
|
+
selfCheck();
|
|
111
|
+
return { name: "fts5", status: "ok", detail: "FTS5 trigram tokenizer works" };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { name: "fts5", status: "fail", code: "fts5_trigram_unavailable", detail: error.message };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function catalogChecks(config, gisoolHome) {
|
|
118
|
+
const catalog = createCatalog({ roots: config.roots, log: () => undefined });
|
|
119
|
+
try {
|
|
120
|
+
await catalog.refresh();
|
|
121
|
+
const report = catalog.report();
|
|
122
|
+
const unreadable = report.roots.filter((r) => !r.readable);
|
|
123
|
+
const nameByUri = new Map(catalog.list().map((e) => [e.uri, e.name]));
|
|
124
|
+
const families = report.families.map((f) => ({ id: f.id, members: [...new Set(f.uris.map((u) => nameByUri.get(u) ?? u))].sort() }));
|
|
125
|
+
const checks = [
|
|
126
|
+
unreadable.length
|
|
127
|
+
? { name: "roots", status: "warn", code: "root_unreadable", detail: `unreadable or missing: ${unreadable.map((r) => r.path).join(", ")}` }
|
|
128
|
+
: { name: "roots", status: "ok", detail: `${report.roots.length} root(s) readable` },
|
|
129
|
+
{ name: "duplicates", status: report.duplicates.length ? "warn" : "ok", detail: `${report.duplicates.length} duplicate skill name(s)${report.duplicates.length ? `: ${report.duplicates.map((d) => d.name).join(", ")}` : ""}` },
|
|
130
|
+
{ name: "skill_families", status: "ok", detail: families.length ? families.map((f) => `${f.id}: ${f.members.join(", ")}`).join("; ") : "none" },
|
|
131
|
+
];
|
|
132
|
+
const path = dbPath(gisoolHome);
|
|
133
|
+
if (!existsSync(path)) {
|
|
134
|
+
checks.push({ name: "index_freshness", status: "warn", code: "index_missing", detail: `${path} not created yet; start the daemon` });
|
|
135
|
+
return { checks, families };
|
|
136
|
+
}
|
|
137
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
138
|
+
try {
|
|
139
|
+
const rows = db.prepare("SELECT uri, manifest_digest FROM skills WHERE removed_at IS NULL").all();
|
|
140
|
+
const indexed = new Map(rows.map((r) => [r.uri, r.manifest_digest]));
|
|
141
|
+
const live = catalog.list();
|
|
142
|
+
const stale = live.filter((e) => indexed.get(e.uri) !== e.manifestDigest).length + rows.filter((r) => !live.some((e) => e.uri === r.uri)).length;
|
|
143
|
+
checks.push(stale === 0
|
|
144
|
+
? { name: "index_freshness", status: "ok", detail: `${live.length} skill(s) indexed and current` }
|
|
145
|
+
: { name: "index_freshness", status: "fail", code: "index_stale", detail: `${stale} skill(s) differ between the catalog and ${path}; restart the daemon` });
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
db.close();
|
|
149
|
+
}
|
|
150
|
+
return { checks, families };
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
catalog.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function lastBackupCheck(gisoolHome, now) {
|
|
157
|
+
const latest = listBackupSets(gisoolHome)[0];
|
|
158
|
+
if (!latest)
|
|
159
|
+
return { name: "last_backup", status: "ok", detail: "none" };
|
|
160
|
+
const ageH = Math.round((now - Date.parse(latest.manifest.createdAt)) / 3_600_000);
|
|
161
|
+
return { name: "last_backup", status: "ok", detail: `${latest.dir} (${ageH} h ago)` };
|
|
162
|
+
}
|
|
163
|
+
export async function collectDoctor(env) {
|
|
164
|
+
const home = resolveHome(env);
|
|
165
|
+
const gisoolHome = resolveGisoolHome(env);
|
|
166
|
+
const config = loadConfig(env);
|
|
167
|
+
const checks = [serviceCheck(home, env), await healthCheck(config.port, gisoolHome)];
|
|
168
|
+
const remote = [];
|
|
169
|
+
const packChecks = [];
|
|
170
|
+
for (const pack of PACKS) {
|
|
171
|
+
const d = await pack.detect({ home, env });
|
|
172
|
+
if (d.remoteGisulRegistered)
|
|
173
|
+
remote.push(pack.id);
|
|
174
|
+
if (!d.installed || config.runtimes[pack.id]?.enabled === false)
|
|
175
|
+
continue;
|
|
176
|
+
for (const c of await pack.doctorChecks({ home, env, port: config.port })) {
|
|
177
|
+
packChecks.push({ name: `${pack.id}: ${c.name}`, status: c.ok ? "ok" : "fail", detail: c.detail });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
checks.push(remote.length
|
|
181
|
+
? { name: "remote_gisul", status: "warn", code: "remote_gisul_registered", detail: `remote gisul is also registered in: ${remote.join(", ")} (gisool init --disable-remote turns it off)` }
|
|
182
|
+
: { name: "remote_gisul", status: "ok", detail: "no remote gisul registration found" });
|
|
183
|
+
const catalogResult = await catalogChecks(config, gisoolHome);
|
|
184
|
+
checks.push(tokenCheck(gisoolHome), ftsCheck(), ...catalogResult.checks, ...packChecks, hookTimeoutCheck(home, env), lastBackupCheck(gisoolHome, Date.now()));
|
|
185
|
+
return { ok: checks.every((c) => c.status !== "fail"), version: packageVersion(), checks, families: catalogResult.families };
|
|
186
|
+
}
|
|
187
|
+
export async function runDoctor(opts, env, io) {
|
|
188
|
+
const report = await collectDoctor(env);
|
|
189
|
+
if (opts.json)
|
|
190
|
+
io.out(JSON.stringify(report, null, 2) + "\n");
|
|
191
|
+
else {
|
|
192
|
+
for (const c of report.checks)
|
|
193
|
+
io.out(`[${c.status}] ${c.name}: ${c.detail}${c.code ? ` (${c.code})` : ""}\n`);
|
|
194
|
+
io.out(report.ok ? "doctor: all checks passed\n" : "doctor: some checks failed\n");
|
|
195
|
+
}
|
|
196
|
+
return report.ok ? 0 : 1;
|
|
197
|
+
}
|
package/dist/cli/eval.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createCatalog } from "../catalog/index.js";
|
|
3
|
+
import { createSearchIndex } from "../search/index.js";
|
|
4
|
+
import { openDb } from "../store/db.js";
|
|
5
|
+
export async function runEval(opts, deps) {
|
|
6
|
+
const store = deps.openStore();
|
|
7
|
+
const catalog = createCatalog({ roots: deps.config.roots, log: () => { } });
|
|
8
|
+
const db = openDb(":memory:");
|
|
9
|
+
try {
|
|
10
|
+
let cases = store.evalCases();
|
|
11
|
+
if (opts.cases) {
|
|
12
|
+
const extra = JSON.parse(readFileSync(opts.cases, "utf8"));
|
|
13
|
+
if (!Array.isArray(extra))
|
|
14
|
+
throw new Error("cases file must be a JSON array of { query, expectedUri? }");
|
|
15
|
+
cases = [...cases, ...extra];
|
|
16
|
+
}
|
|
17
|
+
const index = createSearchIndex(db);
|
|
18
|
+
await catalog.refresh();
|
|
19
|
+
index.rebuild(catalog.list());
|
|
20
|
+
let judged = 0, h1 = 0, h3 = 0, rr = 0, abstained = 0;
|
|
21
|
+
const misses = [];
|
|
22
|
+
for (const c of cases) {
|
|
23
|
+
const out = index.search(c.query, { mode: "discovery", limit: 10 });
|
|
24
|
+
if (out.abstained)
|
|
25
|
+
abstained++;
|
|
26
|
+
if (!c.expectedUri)
|
|
27
|
+
continue;
|
|
28
|
+
judged++;
|
|
29
|
+
const pos = out.candidates.findIndex((x) => x.uri === c.expectedUri);
|
|
30
|
+
if (pos === 0)
|
|
31
|
+
h1++;
|
|
32
|
+
if (pos >= 0 && pos < 3)
|
|
33
|
+
h3++;
|
|
34
|
+
if (pos >= 0)
|
|
35
|
+
rr += 1 / (pos + 1);
|
|
36
|
+
if (pos !== 0)
|
|
37
|
+
misses.push({ query: c.query, expectedUri: c.expectedUri, rank: pos >= 0 ? pos + 1 : null });
|
|
38
|
+
}
|
|
39
|
+
const ratio = (n, d) => (d === 0 ? 0 : n / d);
|
|
40
|
+
const report = { cases: cases.length, judged, hitAt1: ratio(h1, judged), hitAt3: ratio(h3, judged), mrr: ratio(rr, judged),
|
|
41
|
+
abstainRate: ratio(abstained, cases.length), misses };
|
|
42
|
+
if (opts.json)
|
|
43
|
+
console.log(JSON.stringify(report, null, 2));
|
|
44
|
+
else {
|
|
45
|
+
console.log(`cases: ${report.cases} (with expectation: ${judged})`);
|
|
46
|
+
console.log(`hit@1: ${report.hitAt1.toFixed(3)}`);
|
|
47
|
+
console.log(`hit@3: ${report.hitAt3.toFixed(3)}`);
|
|
48
|
+
console.log(`MRR: ${report.mrr.toFixed(3)}`);
|
|
49
|
+
console.log(`abstain rate: ${report.abstainRate.toFixed(3)}`);
|
|
50
|
+
for (const m of misses)
|
|
51
|
+
console.log(`miss: "${m.query}" expected ${m.expectedUri} rank ${m.rank ?? "none"}`);
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
catalog.close();
|
|
61
|
+
db.close();
|
|
62
|
+
store.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { claudeCodePack } from "../packs/claude-code.js";
|
|
2
|
+
import { codexPack } from "../packs/codex.js";
|
|
3
|
+
import { cursorPack } from "../packs/cursor.js";
|
|
4
|
+
import { gajaePack } from "../packs/gajae.js";
|
|
5
|
+
import { geminiPack } from "../packs/gemini.js";
|
|
6
|
+
import { omoPack } from "../packs/omo.js";
|
|
7
|
+
import { openclawPack } from "../packs/openclaw.js";
|
|
8
|
+
const PACKS = [claudeCodePack, codexPack, geminiPack, cursorPack, omoPack, gajaePack, openclawPack];
|
|
9
|
+
function fail(error) {
|
|
10
|
+
const code = error?.code;
|
|
11
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12
|
+
console.error(typeof code === "string" ? `${code}: ${message}` : message);
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
export const runExport = async (dir, opts, deps) => {
|
|
16
|
+
const store = deps.openStore();
|
|
17
|
+
try {
|
|
18
|
+
store.exportTo(dir, { withHistory: opts.withHistory });
|
|
19
|
+
console.log(`exported to ${dir}${opts.withHistory ? " (with history)" : ""}`);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return fail(error);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
store.close();
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function list(label, items) {
|
|
30
|
+
return ` ${label}: ${items.length > 0 ? items.join(", ") : "none"}`;
|
|
31
|
+
}
|
|
32
|
+
async function printDetection(deps) {
|
|
33
|
+
console.log("runtime detection:");
|
|
34
|
+
for (const pack of PACKS) {
|
|
35
|
+
const d = await pack.detect({ home: deps.home, env: deps.env });
|
|
36
|
+
console.log(` ${pack.id}: ${d.installed ? "detected" : "not detected"}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const runImport = async (dir, opts, deps) => {
|
|
40
|
+
const store = deps.openStore();
|
|
41
|
+
try {
|
|
42
|
+
const preview = store.importFrom(dir, { merge: false });
|
|
43
|
+
const d = preview.details;
|
|
44
|
+
console.log(opts.dryRun ? "import preview (dry run, nothing written):" : "import preview:");
|
|
45
|
+
console.log(list("roots to add", d.roots.added));
|
|
46
|
+
console.log(` eval cases to add: ${d.evalCases.added} (already present: ${d.evalCases.skipped})`);
|
|
47
|
+
console.log(list("config keys to add", d.configKeys.added));
|
|
48
|
+
console.log(list("config keys already present (kept)", d.configKeys.kept));
|
|
49
|
+
console.log(list("aliases to add", d.aliases.added));
|
|
50
|
+
if (d.history)
|
|
51
|
+
console.log(` history to add: ${d.history.sessions} sessions, ${d.history.events} events, ${d.history.loads} loads`);
|
|
52
|
+
if (opts.dryRun)
|
|
53
|
+
return 0;
|
|
54
|
+
store.importFrom(dir, { merge: true });
|
|
55
|
+
console.log("import applied");
|
|
56
|
+
await printDetection(deps);
|
|
57
|
+
console.log("next: run `gisool init`");
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return fail(error);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
store.close();
|
|
65
|
+
}
|
|
66
|
+
};
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { DEFAULT_PORT, defaultConfigFile, ensureGisoolHome, expandHome, loadConfig, resolveGisoolHome, resolveHome } from "../config.js";
|
|
6
|
+
import { PACKS, getPack } from "../packs/registry.js";
|
|
7
|
+
import { assertPlatform } from "../version.js";
|
|
8
|
+
import { WriterError, applyFiles, changedFiles, maskSecrets, removeEmptyDirs, restoreFiles, unifiedDiff, unparseable } from "./config-writer.js";
|
|
9
|
+
import { currentServicePlatform, daemonLogPath, nodePath, packageRoot, planService, servicePath } from "./serve.js";
|
|
10
|
+
// Planning never writes: a first run plans against default config and an in-memory token, and materialize() persists
|
|
11
|
+
// both only once the apply starts, so dry runs and aborted runs leave HOME byte-identical.
|
|
12
|
+
function readConfigForPlan(env) {
|
|
13
|
+
const gisoolHome = resolveGisoolHome(env);
|
|
14
|
+
const tokenPath = join(gisoolHome, "token");
|
|
15
|
+
const configPath = join(gisoolHome, "config.json");
|
|
16
|
+
if (existsSync(configPath) && existsSync(tokenPath)) {
|
|
17
|
+
const config = loadConfig(env);
|
|
18
|
+
return { config, token: readFileSync(tokenPath, "utf8").trim(), materialize: () => undefined };
|
|
19
|
+
}
|
|
20
|
+
const home = resolveHome(env);
|
|
21
|
+
const d = defaultConfigFile();
|
|
22
|
+
let port = DEFAULT_PORT;
|
|
23
|
+
if (existsSync(configPath)) {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
25
|
+
if (typeof parsed.port === "number")
|
|
26
|
+
port = parsed.port;
|
|
27
|
+
}
|
|
28
|
+
const config = { port, captureLevel: d.captureLevel, retentionDays: d.retentionDays, roots: d.roots.map((r) => ({ id: r.id, path: expandHome(r.path, home) })), runtimes: {} };
|
|
29
|
+
const token = existsSync(tokenPath) ? readFileSync(tokenPath, "utf8").trim() : randomBytes(32).toString("hex");
|
|
30
|
+
return {
|
|
31
|
+
config,
|
|
32
|
+
token,
|
|
33
|
+
materialize() {
|
|
34
|
+
ensureGisoolHome(env);
|
|
35
|
+
if (!existsSync(tokenPath)) {
|
|
36
|
+
writeFileSync(tokenPath, token + "\n", { mode: 0o600, flag: "wx" });
|
|
37
|
+
chmodSync(tokenPath, 0o600);
|
|
38
|
+
}
|
|
39
|
+
loadConfig(env);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function backupsDir(gisoolHome) {
|
|
44
|
+
return join(gisoolHome, "backups");
|
|
45
|
+
}
|
|
46
|
+
function newBackupDir(gisoolHome) {
|
|
47
|
+
const root = backupsDir(gisoolHome);
|
|
48
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
49
|
+
const stamp = new Date().toISOString().replace(/[-:.]/g, "");
|
|
50
|
+
let dir = join(root, stamp);
|
|
51
|
+
for (let i = 1; existsSync(dir); i++)
|
|
52
|
+
dir = join(root, `${stamp}-${String(i).padStart(3, "0")}`);
|
|
53
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
export function listBackupSets(gisoolHome) {
|
|
57
|
+
const root = backupsDir(gisoolHome);
|
|
58
|
+
if (!existsSync(root))
|
|
59
|
+
return [];
|
|
60
|
+
return readdirSync(root)
|
|
61
|
+
.sort()
|
|
62
|
+
.reverse()
|
|
63
|
+
.flatMap((name) => {
|
|
64
|
+
const path = join(root, name, "manifest.json");
|
|
65
|
+
if (!existsSync(path))
|
|
66
|
+
return [];
|
|
67
|
+
return [{ dir: join(root, name), manifest: JSON.parse(readFileSync(path, "utf8")) }];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function writeManifest(dir, manifest) {
|
|
71
|
+
const path = join(dir, "manifest.json");
|
|
72
|
+
writeFileSync(path, JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
|
|
73
|
+
chmodSync(path, 0o600);
|
|
74
|
+
}
|
|
75
|
+
function run(argv, env, io) {
|
|
76
|
+
const r = spawnSync(argv[0], argv.slice(1), { env, encoding: "utf8" });
|
|
77
|
+
if (r.stdout)
|
|
78
|
+
io.out(r.stdout);
|
|
79
|
+
if (r.stderr)
|
|
80
|
+
io.err(r.stderr);
|
|
81
|
+
if (r.error)
|
|
82
|
+
io.err(`failed to run ${argv[0]}: ${r.error.message}\n`);
|
|
83
|
+
return r.status;
|
|
84
|
+
}
|
|
85
|
+
function parseRuntimeIds(ids) {
|
|
86
|
+
const ok = [];
|
|
87
|
+
const unknown = [];
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const pack = getPack(id);
|
|
90
|
+
if (pack)
|
|
91
|
+
ok.push(pack);
|
|
92
|
+
else
|
|
93
|
+
unknown.push(id);
|
|
94
|
+
}
|
|
95
|
+
return { ok, unknown };
|
|
96
|
+
}
|
|
97
|
+
export async function runInit(opts, env, io, hooks = {}) {
|
|
98
|
+
assertPlatform();
|
|
99
|
+
if (opts.rollback)
|
|
100
|
+
return runRollback({ all: opts.all ?? false }, env, io);
|
|
101
|
+
const home = resolveHome(env);
|
|
102
|
+
const gisoolHome = resolveGisoolHome(env);
|
|
103
|
+
const { config, token, materialize } = readConfigForPlan(env);
|
|
104
|
+
const mask = (t) => maskSecrets(t, [token]);
|
|
105
|
+
const ctx = { home, env, port: config.port, token, nodePath: nodePath(), packageRoot: packageRoot(), disableRemote: opts.disableRemote ?? false };
|
|
106
|
+
const selected = [];
|
|
107
|
+
if (!opts.serviceOnly) {
|
|
108
|
+
const requested = parseRuntimeIds(opts.runtimes ?? []);
|
|
109
|
+
if (requested.unknown.length) {
|
|
110
|
+
io.err(`unknown_runtime: ${requested.unknown.join(", ")} (known: ${PACKS.map((p) => p.id).join(", ")})\n`);
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
for (const pack of PACKS) {
|
|
114
|
+
const d = await pack.detect({ home, env });
|
|
115
|
+
io.out(`runtime ${pack.id}: ${d.installed ? `detected (${d.evidence.join(", ")})` : "not detected"}; remoteGisulRegistered=${d.remoteGisulRegistered}\n`);
|
|
116
|
+
const wanted = requested.ok.length ? requested.ok.includes(pack) : d.installed && config.runtimes[pack.id]?.enabled !== false;
|
|
117
|
+
if (wanted)
|
|
118
|
+
selected.push(pack);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const plans = [];
|
|
122
|
+
for (const pack of selected) {
|
|
123
|
+
try {
|
|
124
|
+
plans.push({ id: pack.id, plan: await pack.plan(ctx) });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
plans.push({ id: pack.id, plan: { files: [], commands: [], notes: [], conflicts: [{ path: pack.id, code: "plan_failed", detail: error.message }] } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let serviceFile = null;
|
|
131
|
+
if (!opts.noService) {
|
|
132
|
+
const svc = planService({ home, gisoolHome, env, nodePath: ctx.nodePath, packageRoot: ctx.packageRoot, platform: currentServicePlatform(), uid: process.getuid?.() ?? 0 });
|
|
133
|
+
plans.push({ id: "service", plan: { files: [svc.file], commands: svc.commands, notes: svc.notes, conflicts: [] } });
|
|
134
|
+
serviceFile = svc.file.path;
|
|
135
|
+
}
|
|
136
|
+
const files = plans.flatMap((p) => p.plan.files);
|
|
137
|
+
const commands = plans.flatMap((p) => p.plan.commands);
|
|
138
|
+
const conflicts = plans.flatMap((p) => p.plan.conflicts.map((c) => ({ ...c, runtime: p.id })));
|
|
139
|
+
for (const f of changedFiles(files)) {
|
|
140
|
+
const why = unparseable(f);
|
|
141
|
+
if (why)
|
|
142
|
+
conflicts.push({ path: f.path, code: "unparseable_config", detail: why, runtime: "writer" });
|
|
143
|
+
}
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const f of changedFiles(files)) {
|
|
146
|
+
if (seen.has(f.path))
|
|
147
|
+
conflicts.push({ path: f.path, code: "duplicate_target", detail: "planned by more than one pack", runtime: "writer" });
|
|
148
|
+
seen.add(f.path);
|
|
149
|
+
}
|
|
150
|
+
if (conflicts.length) {
|
|
151
|
+
for (const c of conflicts)
|
|
152
|
+
io.err(`conflict [${c.runtime}] ${c.path}: ${c.code}: ${c.detail}\n`);
|
|
153
|
+
io.err(`aborting: ${conflicts.length} conflict(s) in ${[...new Set(conflicts.map((c) => c.path))].join(", ")}; no files were changed\n`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
for (const p of plans) {
|
|
157
|
+
for (const f of changedFiles(p.plan.files))
|
|
158
|
+
io.out(mask(unifiedDiff(f.path, f.before, f.after)));
|
|
159
|
+
for (const c of p.plan.commands)
|
|
160
|
+
io.out(mask(`command [${p.id}]: ${c.argv.join(" ")}${c.undo ? ` (undo: ${c.undo.argv.join(" ")})` : ""}\n`));
|
|
161
|
+
for (const n of p.plan.notes)
|
|
162
|
+
io.out(mask(`note [${p.id}]: ${n}\n`));
|
|
163
|
+
}
|
|
164
|
+
const toWrite = changedFiles(files);
|
|
165
|
+
if (opts.dryRun) {
|
|
166
|
+
io.out(`dry run: ${toWrite.length} file(s) and ${commands.length} command(s) planned; nothing written\n`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
await hooks.afterPlan?.();
|
|
170
|
+
materialize();
|
|
171
|
+
const backupDir = newBackupDir(gisoolHome);
|
|
172
|
+
if (serviceFile)
|
|
173
|
+
mkdirSync(join(gisoolHome, "logs"), { recursive: true, mode: 0o700 });
|
|
174
|
+
let applied;
|
|
175
|
+
try {
|
|
176
|
+
applied = applyFiles(files, { backupDir, home });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
180
|
+
if (error instanceof WriterError) {
|
|
181
|
+
io.err(`${error.message}\naborting: no files were changed (${error.paths.join(", ")})\n`);
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const manifest = { version: 1, createdAt: new Date().toISOString(), status: "applied", home, runtimes: selected.map((p) => p.id),
|
|
187
|
+
service: serviceFile, files: applied.files, createdDirs: applied.createdDirs, commands: [] };
|
|
188
|
+
for (const c of commands) {
|
|
189
|
+
const exitCode = run(c.argv, { ...env, ...c.env }, io);
|
|
190
|
+
manifest.commands.push({ argv: c.argv, description: c.description, ...(c.undo ? { undo: c.undo } : {}), exitCode, ...(c.allowFailure ? { allowFailure: true } : {}) });
|
|
191
|
+
if (exitCode !== 0 && !c.allowFailure) {
|
|
192
|
+
io.err(mask(`command failed (exit ${exitCode}): ${c.argv.join(" ")}; rolling back this run\n`));
|
|
193
|
+
manifest.status = "failed";
|
|
194
|
+
manifest.rolledBackAt = new Date().toISOString();
|
|
195
|
+
manifest.rollback = undoRun(manifest, backupDir, env, io);
|
|
196
|
+
writeManifest(backupDir, manifest);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
writeManifest(backupDir, manifest);
|
|
201
|
+
if (applied.files.some((f) => f.path === serviceFile))
|
|
202
|
+
io.out(`service log: ${daemonLogPath(gisoolHome)}\n`);
|
|
203
|
+
io.out(`applied ${applied.files.length} file(s) and ${manifest.commands.length} command(s); backup set ${backupDir}\n`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
function undoRun(manifest, dir, env, io) {
|
|
207
|
+
const commands = [];
|
|
208
|
+
for (const c of [...manifest.commands].reverse()) {
|
|
209
|
+
if (c.exitCode !== 0 || !c.undo)
|
|
210
|
+
continue;
|
|
211
|
+
if (env.GISOOL_NO_CLI) {
|
|
212
|
+
io.err(`GISOOL_NO_CLI set: run manually: ${c.undo.argv.join(" ")}\n`);
|
|
213
|
+
commands.push({ argv: c.undo.argv, description: c.undo.description, exitCode: null });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
commands.push({ argv: c.undo.argv, description: c.undo.description, exitCode: run(c.undo.argv, env, io) });
|
|
217
|
+
}
|
|
218
|
+
const files = restoreFiles(manifest.files, dir);
|
|
219
|
+
removeEmptyDirs(manifest.createdDirs);
|
|
220
|
+
return { files, commands };
|
|
221
|
+
}
|
|
222
|
+
export async function runRollback(opts, env, io) {
|
|
223
|
+
const gisoolHome = resolveGisoolHome(env);
|
|
224
|
+
const sets = listBackupSets(gisoolHome).filter((s) => s.manifest.status === "applied" && !s.manifest.rolledBackAt && (s.manifest.files.length > 0 || s.manifest.commands.length > 0));
|
|
225
|
+
if (sets.length === 0) {
|
|
226
|
+
io.out("nothing to roll back\n");
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
let failed = false;
|
|
230
|
+
for (const s of opts.all ? sets : sets.slice(0, 1)) {
|
|
231
|
+
const result = undoRun(s.manifest, s.dir, env, io);
|
|
232
|
+
for (const c of result.commands) {
|
|
233
|
+
io.out(`undo: ${c.argv.join(" ")} -> ${c.exitCode === null ? "skipped" : `exit ${c.exitCode}`}\n`);
|
|
234
|
+
if (c.exitCode !== 0)
|
|
235
|
+
failed = true;
|
|
236
|
+
}
|
|
237
|
+
for (const f of result.files) {
|
|
238
|
+
io.out(`${f.action}: ${f.path}\n`);
|
|
239
|
+
if (f.action === "skipped_modified")
|
|
240
|
+
failed = true;
|
|
241
|
+
}
|
|
242
|
+
s.manifest.rolledBackAt = new Date().toISOString();
|
|
243
|
+
s.manifest.rollback = result;
|
|
244
|
+
writeManifest(s.dir, s.manifest);
|
|
245
|
+
io.out(`rolled back ${s.dir}\n`);
|
|
246
|
+
}
|
|
247
|
+
return failed ? 1 : 0;
|
|
248
|
+
}
|
|
249
|
+
export async function serviceInstall(opts, env, io) {
|
|
250
|
+
return runInit({ dryRun: opts.dryRun ?? false, serviceOnly: true }, env, io);
|
|
251
|
+
}
|
|
252
|
+
export async function serviceRemove(env, io) {
|
|
253
|
+
assertPlatform();
|
|
254
|
+
const home = resolveHome(env);
|
|
255
|
+
const platform = currentServicePlatform();
|
|
256
|
+
const path = servicePath(home, platform);
|
|
257
|
+
if (!existsSync(path)) {
|
|
258
|
+
io.out("service not installed\n");
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const argv = platform === "darwin"
|
|
262
|
+
? ["launchctl", "bootout", `gui/${process.getuid?.() ?? 0}/dev.gisool.daemon`]
|
|
263
|
+
: ["systemctl", "--user", "disable", "--now", "gisool.service"];
|
|
264
|
+
if (env.GISOOL_NO_CLI)
|
|
265
|
+
io.out(`GISOOL_NO_CLI set: run manually: ${argv.join(" ")}\n`);
|
|
266
|
+
else
|
|
267
|
+
run(argv, env, io);
|
|
268
|
+
rmSync(path, { force: true });
|
|
269
|
+
io.out(`removed ${path}\n`);
|
|
270
|
+
return 0;
|
|
271
|
+
}
|