@ghaniy_fadhil/skill-manager 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 +202 -0
- package/README.md +182 -2
- package/dist/cli.js +4846 -0
- package/dist/server.js +4490 -0
- package/dist/web/assets/index-BYO8-w4P.js +11 -0
- package/dist/web/assets/index-CBdM5A7q.css +2 -0
- package/dist/web/index.html +13 -0
- package/package.json +66 -4
package/dist/server.js
ADDED
|
@@ -0,0 +1,4490 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual, randomUUID, createHash } from 'crypto';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { createServer as createServer$1 } from 'http';
|
|
4
|
+
import path2, { dirname, join, extname } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { getRequestListener } from '@hono/node-server';
|
|
7
|
+
import * as fsp from 'fs/promises';
|
|
8
|
+
import { readFile } from 'fs/promises';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
11
|
+
import { Hono } from 'hono';
|
|
12
|
+
import { setCookie, getCookie } from 'hono/cookie';
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { crc32, inflateRawSync } from 'zlib';
|
|
16
|
+
|
|
17
|
+
// src/server/index.ts
|
|
18
|
+
|
|
19
|
+
// package.json
|
|
20
|
+
var package_default = {
|
|
21
|
+
version: "0.1.0"};
|
|
22
|
+
|
|
23
|
+
// src/core/errors.ts
|
|
24
|
+
var SkillManagerError = class extends Error {
|
|
25
|
+
code;
|
|
26
|
+
details;
|
|
27
|
+
constructor({ code, message, details }) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "SkillManagerError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
if (details !== void 0) {
|
|
32
|
+
this.details = details;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var DATA_ROOT_ENV = "SKILL_MANAGER_DATA_ROOT";
|
|
37
|
+
function platformPath(platform) {
|
|
38
|
+
return platform === "win32" ? path2.win32 : path2.posix;
|
|
39
|
+
}
|
|
40
|
+
function resolveDataRoot(options = {}) {
|
|
41
|
+
const platform = options.platform ?? process.platform;
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const nodePath = platformPath(platform);
|
|
44
|
+
const override = env[DATA_ROOT_ENV]?.trim();
|
|
45
|
+
if (override) {
|
|
46
|
+
return nodePath.resolve(override);
|
|
47
|
+
}
|
|
48
|
+
const home = options.homedir ?? homedir();
|
|
49
|
+
if (platform === "win32") {
|
|
50
|
+
const localAppData = env.LOCALAPPDATA?.trim() || nodePath.join(home, "AppData", "Local");
|
|
51
|
+
return nodePath.join(localAppData, "SkillManager");
|
|
52
|
+
}
|
|
53
|
+
const dataHome = env.XDG_DATA_HOME?.trim() || nodePath.join(home, ".local", "share");
|
|
54
|
+
return nodePath.join(dataHome, "skillmanager");
|
|
55
|
+
}
|
|
56
|
+
function libraryRoot(dataRoot, platform = process.platform) {
|
|
57
|
+
return platformPath(platform).join(dataRoot, "library");
|
|
58
|
+
}
|
|
59
|
+
function adaptersRoot(dataRoot, platform = process.platform) {
|
|
60
|
+
return platformPath(platform).join(dataRoot, "adapters");
|
|
61
|
+
}
|
|
62
|
+
function depsRoot(dataRoot, platform = process.platform) {
|
|
63
|
+
return platformPath(platform).join(dataRoot, "deps");
|
|
64
|
+
}
|
|
65
|
+
function statePath(dataRoot, platform = process.platform) {
|
|
66
|
+
return platformPath(platform).join(dataRoot, "state.json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/fs/types.ts
|
|
70
|
+
function fsOk(value) {
|
|
71
|
+
return { ok: true, value };
|
|
72
|
+
}
|
|
73
|
+
function fsError(code, path23, message, cause) {
|
|
74
|
+
if (cause === void 0) {
|
|
75
|
+
return { code, path: path23, message };
|
|
76
|
+
}
|
|
77
|
+
return { code, path: path23, message, cause };
|
|
78
|
+
}
|
|
79
|
+
function fsErr(code, path23, message, cause) {
|
|
80
|
+
return { ok: false, error: fsError(code, path23, message, cause) };
|
|
81
|
+
}
|
|
82
|
+
function isExecutable(stat2) {
|
|
83
|
+
return (stat2.mode & 73) !== 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/fs/guard.ts
|
|
87
|
+
function isPathInside(parent, child) {
|
|
88
|
+
const relative = path2.relative(path2.resolve(parent), path2.resolve(child));
|
|
89
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative);
|
|
90
|
+
}
|
|
91
|
+
function isSamePath(left, right) {
|
|
92
|
+
return path2.relative(path2.resolve(left), path2.resolve(right)) === "";
|
|
93
|
+
}
|
|
94
|
+
async function guardStorePath(fs, storeRoot, targetPath, options = {}) {
|
|
95
|
+
if (!path2.isAbsolute(storeRoot) || !path2.isAbsolute(targetPath)) {
|
|
96
|
+
return fsErr("invalid_path", targetPath, "Path Store dan target harus absolut.");
|
|
97
|
+
}
|
|
98
|
+
const storeLexical = path2.resolve(storeRoot);
|
|
99
|
+
const targetLexical = path2.resolve(targetPath);
|
|
100
|
+
if (!isPathInside(storeLexical, targetLexical)) {
|
|
101
|
+
return fsErr("unsafe_path", targetPath, "Target berada di luar Store yang diharapkan.");
|
|
102
|
+
}
|
|
103
|
+
const storeReal = await fs.realpath(storeLexical);
|
|
104
|
+
if (!storeReal.ok) {
|
|
105
|
+
return storeReal;
|
|
106
|
+
}
|
|
107
|
+
const targetLstat = await fs.lstat(targetLexical);
|
|
108
|
+
if (targetLstat.ok) {
|
|
109
|
+
const targetReal = await fs.realpath(targetLexical);
|
|
110
|
+
if (!targetReal.ok) {
|
|
111
|
+
if (targetReal.error.code === "not_found") {
|
|
112
|
+
return fsErr(
|
|
113
|
+
"unsafe_symlink",
|
|
114
|
+
targetPath,
|
|
115
|
+
"Target adalah tautan menggantung; operasi ditolak.",
|
|
116
|
+
targetReal.error
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return targetReal;
|
|
120
|
+
}
|
|
121
|
+
if (!isPathInside(storeReal.value, targetReal.value)) {
|
|
122
|
+
return fsErr("unsafe_path", targetPath, "Target menunjuk keluar dari Store melalui tautan.");
|
|
123
|
+
}
|
|
124
|
+
return fsOk({
|
|
125
|
+
path: targetLexical,
|
|
126
|
+
realPath: targetReal.value,
|
|
127
|
+
symlink: targetLstat.value.kind === "symlink"
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (targetLstat.error.code !== "not_found") {
|
|
131
|
+
return targetLstat;
|
|
132
|
+
}
|
|
133
|
+
if (options.mustExist === true) {
|
|
134
|
+
return targetLstat;
|
|
135
|
+
}
|
|
136
|
+
const missing = [];
|
|
137
|
+
let ancestor = targetLexical;
|
|
138
|
+
while (true) {
|
|
139
|
+
const ancestorLstat = await fs.lstat(ancestor);
|
|
140
|
+
if (ancestorLstat.ok) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
if (ancestorLstat.error.code !== "not_found") {
|
|
144
|
+
return ancestorLstat;
|
|
145
|
+
}
|
|
146
|
+
const parent = path2.dirname(ancestor);
|
|
147
|
+
if (parent === ancestor) {
|
|
148
|
+
return fsErr("not_found", targetPath, "Tidak ada leluhur yang bisa diverifikasi.");
|
|
149
|
+
}
|
|
150
|
+
missing.unshift(path2.basename(ancestor));
|
|
151
|
+
ancestor = parent;
|
|
152
|
+
}
|
|
153
|
+
const ancestorReal = await fs.realpath(ancestor);
|
|
154
|
+
if (!ancestorReal.ok) {
|
|
155
|
+
return fsErr(
|
|
156
|
+
"unsafe_symlink",
|
|
157
|
+
targetPath,
|
|
158
|
+
"Leluhur target adalah tautan menggantung; operasi ditolak.",
|
|
159
|
+
ancestorReal.error
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (!isPathInside(storeReal.value, ancestorReal.value)) {
|
|
163
|
+
return fsErr(
|
|
164
|
+
"unsafe_path",
|
|
165
|
+
targetPath,
|
|
166
|
+
"Leluhur target menunjuk keluar dari Store melalui tautan."
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return fsOk({
|
|
170
|
+
path: targetLexical,
|
|
171
|
+
realPath: path2.join(ancestorReal.value, ...missing),
|
|
172
|
+
symlink: false
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function removeWithinStore(fs, storeRoot, targetPath) {
|
|
176
|
+
if (!path2.isAbsolute(storeRoot) || !path2.isAbsolute(targetPath)) {
|
|
177
|
+
return fsErr("invalid_path", targetPath, "Path Store dan target harus absolut.");
|
|
178
|
+
}
|
|
179
|
+
if (isSamePath(storeRoot, targetPath)) {
|
|
180
|
+
return fsErr("invalid_path", targetPath, "Store itu sendiri tidak boleh dihapus.");
|
|
181
|
+
}
|
|
182
|
+
const guarded = await guardStorePath(fs, storeRoot, targetPath, { mustExist: true });
|
|
183
|
+
if (!guarded.ok) {
|
|
184
|
+
return guarded;
|
|
185
|
+
}
|
|
186
|
+
return fs.remove(guarded.value.path, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/fs/node.ts
|
|
190
|
+
var ERROR_CODES = {
|
|
191
|
+
EACCES: "permission_denied",
|
|
192
|
+
EPERM: "permission_denied",
|
|
193
|
+
EEXIST: "already_exists",
|
|
194
|
+
EISDIR: "is_a_directory",
|
|
195
|
+
ELOOP: "symlink_loop",
|
|
196
|
+
ENAMETOOLONG: "invalid_path",
|
|
197
|
+
ENOENT: "not_found",
|
|
198
|
+
ENOTDIR: "not_a_directory",
|
|
199
|
+
ENOTEMPTY: "not_empty",
|
|
200
|
+
EINVAL: "invalid_path",
|
|
201
|
+
EXDEV: "io_error"
|
|
202
|
+
};
|
|
203
|
+
function nodeErrno(error) {
|
|
204
|
+
return error?.code;
|
|
205
|
+
}
|
|
206
|
+
function describe(error) {
|
|
207
|
+
return error instanceof Error ? error.message : String(error);
|
|
208
|
+
}
|
|
209
|
+
function toFsError(error, targetPath, message) {
|
|
210
|
+
const code = ERROR_CODES[nodeErrno(error) ?? ""] ?? "io_error";
|
|
211
|
+
return {
|
|
212
|
+
code,
|
|
213
|
+
path: targetPath,
|
|
214
|
+
message: message ?? `Operasi filesystem gagal: ${describe(error)}`,
|
|
215
|
+
cause: error
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function kindOf(stats) {
|
|
219
|
+
if (stats.isSymbolicLink()) {
|
|
220
|
+
return "symlink";
|
|
221
|
+
}
|
|
222
|
+
if (stats.isDirectory()) {
|
|
223
|
+
return "directory";
|
|
224
|
+
}
|
|
225
|
+
if (stats.isFile()) {
|
|
226
|
+
return "file";
|
|
227
|
+
}
|
|
228
|
+
return "other";
|
|
229
|
+
}
|
|
230
|
+
function statOf(stats) {
|
|
231
|
+
return {
|
|
232
|
+
kind: kindOf(stats),
|
|
233
|
+
size: stats.size,
|
|
234
|
+
mode: stats.mode,
|
|
235
|
+
mtimeMs: stats.mtimeMs
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function entryKindOf(stats) {
|
|
239
|
+
const kind = kindOf(stats);
|
|
240
|
+
return kind === "file" || kind === "directory" ? kind : "other";
|
|
241
|
+
}
|
|
242
|
+
var NodeFileSystem = class {
|
|
243
|
+
async read(targetPath) {
|
|
244
|
+
try {
|
|
245
|
+
const data = await fsp.readFile(targetPath);
|
|
246
|
+
return fsOk(new Uint8Array(data));
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async readText(targetPath) {
|
|
252
|
+
const bytes = await this.read(targetPath);
|
|
253
|
+
if (!bytes.ok) {
|
|
254
|
+
return bytes;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
return fsOk(new TextDecoder("utf-8", { fatal: true }).decode(bytes.value));
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return fsErr("invalid_utf8", targetPath, "Isi file bukan UTF-8 yang valid.", error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async write(targetPath, data, options) {
|
|
263
|
+
try {
|
|
264
|
+
await fsp.writeFile(targetPath, data, { mode: options?.mode ?? 420 });
|
|
265
|
+
return fsOk(void 0);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async copy(source, destination) {
|
|
271
|
+
const sourceAbsolute = path2.resolve(source);
|
|
272
|
+
const destinationAbsolute = path2.resolve(destination);
|
|
273
|
+
const sourceReal = await this.realpath(sourceAbsolute);
|
|
274
|
+
if (!sourceReal.ok) {
|
|
275
|
+
return sourceReal;
|
|
276
|
+
}
|
|
277
|
+
const destinationParent = await this.realpath(path2.dirname(destinationAbsolute));
|
|
278
|
+
if (!destinationParent.ok) {
|
|
279
|
+
return destinationParent;
|
|
280
|
+
}
|
|
281
|
+
const destinationKey = path2.join(destinationParent.value, path2.basename(destinationAbsolute));
|
|
282
|
+
if (isPathInside(sourceReal.value, destinationParent.value)) {
|
|
283
|
+
return fsErr("invalid_path", destination, "Tidak bisa menyalin direktori ke dalam dirinya.");
|
|
284
|
+
}
|
|
285
|
+
if (await this.exists(destinationKey)) {
|
|
286
|
+
return fsErr("already_exists", destination, "Tujuan copy sudah ada.");
|
|
287
|
+
}
|
|
288
|
+
return this.copyEntry(sourceReal.value, destinationKey, sourceReal.value, /* @__PURE__ */ new Set());
|
|
289
|
+
}
|
|
290
|
+
async remove(targetPath, options) {
|
|
291
|
+
return this.removeAt(path2.resolve(targetPath), options?.recursive === true);
|
|
292
|
+
}
|
|
293
|
+
async realpath(targetPath) {
|
|
294
|
+
try {
|
|
295
|
+
return fsOk(await fsp.realpath(targetPath));
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async stat(targetPath) {
|
|
301
|
+
try {
|
|
302
|
+
return fsOk(statOf(await fsp.stat(targetPath)));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async lstat(targetPath) {
|
|
308
|
+
try {
|
|
309
|
+
return fsOk(statOf(await fsp.lstat(targetPath)));
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async readdir(targetPath) {
|
|
315
|
+
try {
|
|
316
|
+
const names = await fsp.readdir(targetPath);
|
|
317
|
+
const entries = [];
|
|
318
|
+
for (const name of names) {
|
|
319
|
+
const entryPath = path2.join(targetPath, name);
|
|
320
|
+
const entryStat = await fsp.lstat(entryPath);
|
|
321
|
+
const isSymlink = entryStat.isSymbolicLink();
|
|
322
|
+
if (!isSymlink) {
|
|
323
|
+
entries.push({ name, kind: entryKindOf(entryStat), isSymlink });
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const resolved = await fsp.stat(entryPath);
|
|
328
|
+
entries.push({ name, kind: entryKindOf(resolved), isSymlink });
|
|
329
|
+
} catch (error) {
|
|
330
|
+
const code = nodeErrno(error);
|
|
331
|
+
if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") {
|
|
332
|
+
entries.push({ name, kind: "dangling", isSymlink });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
339
|
+
return fsOk(entries);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async mkdir(targetPath, options) {
|
|
345
|
+
try {
|
|
346
|
+
await fsp.mkdir(targetPath, { recursive: options?.recursive ?? true });
|
|
347
|
+
return fsOk(void 0);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async rename(from, to) {
|
|
353
|
+
try {
|
|
354
|
+
await fsp.rename(from, to);
|
|
355
|
+
return fsOk(void 0);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { ok: false, error: toFsError(error, to) };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async exists(targetPath) {
|
|
361
|
+
try {
|
|
362
|
+
await fsp.lstat(targetPath);
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async readlink(targetPath) {
|
|
369
|
+
try {
|
|
370
|
+
return fsOk(await fsp.readlink(targetPath));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async symlink(target, linkPath, options) {
|
|
376
|
+
try {
|
|
377
|
+
let type = options?.type;
|
|
378
|
+
if (type === void 0 && process.platform === "win32") {
|
|
379
|
+
try {
|
|
380
|
+
const stats = await fsp.stat(target);
|
|
381
|
+
type = stats.isDirectory() ? "junction" : "file";
|
|
382
|
+
} catch {
|
|
383
|
+
type = "file";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
await fsp.symlink(target, linkPath, type);
|
|
387
|
+
return fsOk(void 0);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
return { ok: false, error: toFsError(error, linkPath) };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async copyEntry(source, destination, sourceRoot, activeDirectories) {
|
|
393
|
+
let sourceStat;
|
|
394
|
+
try {
|
|
395
|
+
sourceStat = await fsp.lstat(source);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
return { ok: false, error: toFsError(error, source) };
|
|
398
|
+
}
|
|
399
|
+
if (sourceStat.isSymbolicLink()) {
|
|
400
|
+
const resolved = await this.realpath(source);
|
|
401
|
+
if (!resolved.ok) {
|
|
402
|
+
return fsErr(
|
|
403
|
+
"unsafe_symlink",
|
|
404
|
+
source,
|
|
405
|
+
"Tautan di dalam sumber tidak bisa disalin karena targetnya tidak ada.",
|
|
406
|
+
resolved.error
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (!isPathInside(sourceRoot, resolved.value)) {
|
|
410
|
+
return fsErr(
|
|
411
|
+
"unsafe_symlink",
|
|
412
|
+
source,
|
|
413
|
+
"Tautan di dalam sumber menunjuk keluar dari folder sumber."
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return this.copyEntry(resolved.value, destination, sourceRoot, activeDirectories);
|
|
417
|
+
}
|
|
418
|
+
if (sourceStat.isFile()) {
|
|
419
|
+
try {
|
|
420
|
+
await fsp.copyFile(source, destination);
|
|
421
|
+
await fsp.chmod(destination, sourceStat.mode & 511);
|
|
422
|
+
return fsOk(void 0);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
return { ok: false, error: toFsError(error, destination) };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!sourceStat.isDirectory()) {
|
|
428
|
+
return fsErr("io_error", source, "Jenis entri tidak didukung untuk copy.");
|
|
429
|
+
}
|
|
430
|
+
const canonical = await this.realpath(source);
|
|
431
|
+
if (!canonical.ok) {
|
|
432
|
+
return canonical;
|
|
433
|
+
}
|
|
434
|
+
if (activeDirectories.has(canonical.value)) {
|
|
435
|
+
return fsErr("symlink_loop", source, "Siklus direktori saat copy.");
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
await fsp.mkdir(destination);
|
|
439
|
+
activeDirectories.add(canonical.value);
|
|
440
|
+
const entries = await fsp.readdir(canonical.value);
|
|
441
|
+
for (const name of entries) {
|
|
442
|
+
const copied = await this.copyEntry(
|
|
443
|
+
path2.join(canonical.value, name),
|
|
444
|
+
path2.join(destination, name),
|
|
445
|
+
sourceRoot,
|
|
446
|
+
activeDirectories
|
|
447
|
+
);
|
|
448
|
+
if (!copied.ok) {
|
|
449
|
+
activeDirectories.delete(canonical.value);
|
|
450
|
+
return copied;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
activeDirectories.delete(canonical.value);
|
|
454
|
+
return fsOk(void 0);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
return { ok: false, error: toFsError(error, destination) };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async removeAt(targetPath, recursive) {
|
|
460
|
+
let stats;
|
|
461
|
+
try {
|
|
462
|
+
stats = await fsp.lstat(targetPath);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
465
|
+
}
|
|
466
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
467
|
+
try {
|
|
468
|
+
await fsp.unlink(targetPath);
|
|
469
|
+
return fsOk(void 0);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!recursive) {
|
|
475
|
+
try {
|
|
476
|
+
await fsp.rmdir(targetPath);
|
|
477
|
+
return fsOk(void 0);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
const entries = await fsp.readdir(targetPath);
|
|
484
|
+
for (const name of entries) {
|
|
485
|
+
const childPath = path2.join(targetPath, name);
|
|
486
|
+
const childStats = await fsp.lstat(childPath);
|
|
487
|
+
if (childStats.isSymbolicLink()) {
|
|
488
|
+
await fsp.unlink(childPath);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const removed = await this.removeAt(childPath, true);
|
|
492
|
+
if (!removed.ok) {
|
|
493
|
+
return removed;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await fsp.rmdir(targetPath);
|
|
497
|
+
return fsOk(void 0);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return { ok: false, error: toFsError(error, targetPath) };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// src/fs/process.ts
|
|
505
|
+
var DEFAULT_PROCESS_TIMEOUT_MS = 5e3;
|
|
506
|
+
var DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024;
|
|
507
|
+
|
|
508
|
+
// src/fs/process-node.ts
|
|
509
|
+
var PROCESS_ERROR_CODES = {
|
|
510
|
+
ENOENT: "process_not_found",
|
|
511
|
+
EACCES: "permission_denied",
|
|
512
|
+
EPERM: "permission_denied",
|
|
513
|
+
// Windows menolak spawn berkas `.cmd`/`.bat` tanpa shell (CVE-2024-27980).
|
|
514
|
+
// Itu bukan kegagalan I/O, jadi dipetakan ke galat proses biasa, bukan crash.
|
|
515
|
+
EINVAL: "process_failed"
|
|
516
|
+
};
|
|
517
|
+
function errnoOf(error) {
|
|
518
|
+
return error?.code?.toString();
|
|
519
|
+
}
|
|
520
|
+
function describe2(error) {
|
|
521
|
+
return error instanceof Error ? error.message : String(error);
|
|
522
|
+
}
|
|
523
|
+
function toProcessError(error, command) {
|
|
524
|
+
const errno = errnoOf(error);
|
|
525
|
+
const code = PROCESS_ERROR_CODES[errno ?? ""] ?? "process_failed";
|
|
526
|
+
const message = code === "process_not_found" ? `Perintah "${command}" tidak ditemukan di PATH.` : `Perintah "${command}" gagal dijalankan: ${describe2(error)}`;
|
|
527
|
+
return { code, path: command, message, cause: error };
|
|
528
|
+
}
|
|
529
|
+
var NodeProcessRunner = class {
|
|
530
|
+
async run(command, args, options = {}) {
|
|
531
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS;
|
|
532
|
+
const maxBuffer = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
533
|
+
return await new Promise((resolve) => {
|
|
534
|
+
try {
|
|
535
|
+
execFile(
|
|
536
|
+
command,
|
|
537
|
+
[...args],
|
|
538
|
+
{
|
|
539
|
+
cwd: options.cwd,
|
|
540
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
541
|
+
timeout: timeoutMs,
|
|
542
|
+
killSignal: "SIGKILL",
|
|
543
|
+
maxBuffer,
|
|
544
|
+
windowsHide: true,
|
|
545
|
+
shell: false
|
|
546
|
+
},
|
|
547
|
+
(error, stdout, stderr) => {
|
|
548
|
+
if (error === null) {
|
|
549
|
+
resolve(fsOk({ code: 0, stdout, stderr, timedOut: false }));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const failure = error;
|
|
553
|
+
const errno = errnoOf(failure);
|
|
554
|
+
if (errno === "ENOENT" || errno === "EACCES" || errno === "EPERM") {
|
|
555
|
+
resolve({ ok: false, error: toProcessError(failure, command) });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const killed = failure.killed === true || failure.signal != null;
|
|
559
|
+
const exitCode = typeof failure.code === "number" ? failure.code : null;
|
|
560
|
+
resolve(
|
|
561
|
+
fsOk({
|
|
562
|
+
code: killed ? null : exitCode,
|
|
563
|
+
stdout,
|
|
564
|
+
stderr,
|
|
565
|
+
timedOut: killed
|
|
566
|
+
})
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
resolve({ ok: false, error: toProcessError(error, command) });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// src/adapters/antigravity.json
|
|
578
|
+
var antigravity_default = {
|
|
579
|
+
id: "antigravity",
|
|
580
|
+
name: "Google Antigravity",
|
|
581
|
+
tier: "managed",
|
|
582
|
+
detect: {
|
|
583
|
+
paths: ["~/.gemini/config"],
|
|
584
|
+
binaries: ["agy"],
|
|
585
|
+
versionArgs: ["--version"]
|
|
586
|
+
},
|
|
587
|
+
stores: [
|
|
588
|
+
{
|
|
589
|
+
scope: "global",
|
|
590
|
+
path: "~/.gemini/config/skills",
|
|
591
|
+
exclusive: true
|
|
592
|
+
}
|
|
593
|
+
],
|
|
594
|
+
readsShared: false,
|
|
595
|
+
connect: {
|
|
596
|
+
installUrl: "https://antigravity.google/download",
|
|
597
|
+
docsUrl: "https://antigravity.google/docs/skills"
|
|
598
|
+
},
|
|
599
|
+
verified: {
|
|
600
|
+
at: "2026-11-05",
|
|
601
|
+
source: "https://antigravity.google/docs/skills"
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/adapters/claude-code.json
|
|
606
|
+
var claude_code_default = {
|
|
607
|
+
id: "claude-code",
|
|
608
|
+
name: "[CC]",
|
|
609
|
+
tier: "managed",
|
|
610
|
+
detect: {
|
|
611
|
+
paths: ["~/.claude"],
|
|
612
|
+
binaries: ["claude"],
|
|
613
|
+
versionArgs: ["--version"]
|
|
614
|
+
},
|
|
615
|
+
stores: [
|
|
616
|
+
{
|
|
617
|
+
scope: "global",
|
|
618
|
+
path: "~/.claude/skills",
|
|
619
|
+
exclusive: true
|
|
620
|
+
}
|
|
621
|
+
],
|
|
622
|
+
readsShared: false,
|
|
623
|
+
connect: {
|
|
624
|
+
installUrl: "https://code.claude.com/docs/en/skills",
|
|
625
|
+
docsUrl: "https://code.claude.com/docs/en/skills"
|
|
626
|
+
},
|
|
627
|
+
verified: {
|
|
628
|
+
at: "2026-11-05",
|
|
629
|
+
source: "https://code.claude.com/docs/en/skills"
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// src/adapters/crush.json
|
|
634
|
+
var crush_default = {
|
|
635
|
+
id: "crush",
|
|
636
|
+
name: "Crush",
|
|
637
|
+
tier: "detected-only",
|
|
638
|
+
detect: {
|
|
639
|
+
paths: ["~/.config/crush"],
|
|
640
|
+
binaries: ["crush"],
|
|
641
|
+
versionArgs: ["--version"]
|
|
642
|
+
},
|
|
643
|
+
stores: [],
|
|
644
|
+
readsShared: true,
|
|
645
|
+
connect: {
|
|
646
|
+
installUrl: "https://github.com/charmbracelet/crush",
|
|
647
|
+
docsUrl: "https://github.com/charmbracelet/crush"
|
|
648
|
+
},
|
|
649
|
+
verified: {
|
|
650
|
+
at: "2026-11-05",
|
|
651
|
+
source: "https://github.com/charmbracelet/crush"
|
|
652
|
+
},
|
|
653
|
+
notes: "Detected-only (roadmap v0.2). Store Crush nanti adalah %LOCALAPPDATA%\\crush\\skills; ~/.config/crush/skills hanya dipakai untuk deteksi."
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/adapters/devin.json
|
|
657
|
+
var devin_default = {
|
|
658
|
+
id: "devin",
|
|
659
|
+
name: "Devin",
|
|
660
|
+
tier: "detected-only",
|
|
661
|
+
detect: {
|
|
662
|
+
paths: ["~/.config/devin"],
|
|
663
|
+
binaries: ["devin"],
|
|
664
|
+
versionArgs: ["--version"]
|
|
665
|
+
},
|
|
666
|
+
stores: [],
|
|
667
|
+
readsShared: true,
|
|
668
|
+
connect: {
|
|
669
|
+
installUrl: "https://docs.devin.ai/desktop/cascade/skills",
|
|
670
|
+
docsUrl: "https://docs.devin.ai/desktop/cascade/skills"
|
|
671
|
+
},
|
|
672
|
+
verified: {
|
|
673
|
+
at: "2026-11-05",
|
|
674
|
+
source: "https://docs.devin.ai/desktop/cascade/skills"
|
|
675
|
+
},
|
|
676
|
+
notes: "BERTENTANGAN: docs Desktop menyebut ~/.config/devin/skills, docs CLI menyebut %APPDATA%\\devin\\skills. Deteksi memakai bentuk POSIX dari docs Desktop; belum ada Store managed sampai pertentangan ini teruji di mesin dengan Devin terpasang."
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/adapters/goose.json
|
|
680
|
+
var goose_default = {
|
|
681
|
+
id: "goose",
|
|
682
|
+
name: "Goose",
|
|
683
|
+
tier: "detected-only",
|
|
684
|
+
detect: {
|
|
685
|
+
paths: ["~/.config/goose"],
|
|
686
|
+
binaries: ["goose"],
|
|
687
|
+
versionArgs: ["--version"]
|
|
688
|
+
},
|
|
689
|
+
stores: [],
|
|
690
|
+
readsShared: true,
|
|
691
|
+
connect: {
|
|
692
|
+
installUrl: "https://github.com/aaif-goose/goose",
|
|
693
|
+
docsUrl: "https://github.com/aaif-goose/goose/blob/main/documentation/docs/guides/context-engineering/using-skills.md"
|
|
694
|
+
},
|
|
695
|
+
verified: {
|
|
696
|
+
at: "2026-11-05",
|
|
697
|
+
source: "https://github.com/aaif-goose/goose/blob/main/documentation/docs/guides/context-engineering/using-skills.md"
|
|
698
|
+
},
|
|
699
|
+
notes: "Detected-only (roadmap v0.2). Root kanonik Goose adalah ~/.agents/skills (Store shared); ~/.config/goose/skills bukan path kanonik di Windows, hanya dipakai untuk deteksi."
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// src/adapters/hermes.json
|
|
703
|
+
var hermes_default = {
|
|
704
|
+
id: "hermes",
|
|
705
|
+
name: "Hermes",
|
|
706
|
+
tier: "detected-only",
|
|
707
|
+
detect: {
|
|
708
|
+
paths: ["~/.hermes"],
|
|
709
|
+
binaries: ["hermes"],
|
|
710
|
+
versionArgs: ["--version"]
|
|
711
|
+
},
|
|
712
|
+
stores: [],
|
|
713
|
+
readsShared: false,
|
|
714
|
+
connect: {
|
|
715
|
+
installUrl: "https://github.com/NousResearch/hermes-agent",
|
|
716
|
+
docsUrl: "https://github.com/NousResearch/hermes-agent"
|
|
717
|
+
},
|
|
718
|
+
verified: {
|
|
719
|
+
at: "2026-11-05",
|
|
720
|
+
source: "https://github.com/NousResearch/hermes-agent/blob/ca705db/hermes_constants.py#L51-L58"
|
|
721
|
+
},
|
|
722
|
+
notes: "Detected-only (roadmap v0.2). Home aktif Hermes di Windows adalah %LOCALAPPDATA%\\hermes\\skills; ~/.hermes/skills hanya dipakai untuk deteksi, bukan Store."
|
|
723
|
+
};
|
|
724
|
+
var httpUrl = z.string().regex(/^https?:\/\/\S+$/, "harus URL http(s)");
|
|
725
|
+
var storeSchema = z.object({
|
|
726
|
+
scope: z.enum(["global", "project"]),
|
|
727
|
+
path: z.string().min(1),
|
|
728
|
+
exclusive: z.boolean(),
|
|
729
|
+
members: z.array(z.string().min(1)).optional()
|
|
730
|
+
});
|
|
731
|
+
var detectSchema = z.object({
|
|
732
|
+
paths: z.array(z.string().min(1)),
|
|
733
|
+
binaries: z.array(z.string().min(1)),
|
|
734
|
+
versionArgs: z.array(z.string())
|
|
735
|
+
});
|
|
736
|
+
var adapterSchema = z.object({
|
|
737
|
+
id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "id hanya huruf kecil, angka, dan tanda hubung"),
|
|
738
|
+
name: z.string().min(1),
|
|
739
|
+
tier: z.enum(["managed", "detected-only"]),
|
|
740
|
+
detect: detectSchema,
|
|
741
|
+
stores: z.array(storeSchema),
|
|
742
|
+
readsShared: z.boolean(),
|
|
743
|
+
connect: z.object({ installUrl: httpUrl, docsUrl: httpUrl.optional() }).optional(),
|
|
744
|
+
verified: z.object({
|
|
745
|
+
at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "format tanggal harus YYYY-MM-DD"),
|
|
746
|
+
source: httpUrl
|
|
747
|
+
}).optional(),
|
|
748
|
+
notes: z.string().min(1).optional()
|
|
749
|
+
});
|
|
750
|
+
function adapterCapability(adapter) {
|
|
751
|
+
return adapter.tier === "managed" && adapter.verified !== void 0 ? "managed" : "detected";
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/adapters/opencode.json
|
|
755
|
+
var opencode_default = {
|
|
756
|
+
id: "opencode",
|
|
757
|
+
name: "opencode",
|
|
758
|
+
tier: "managed",
|
|
759
|
+
detect: {
|
|
760
|
+
paths: ["~/.config/opencode"],
|
|
761
|
+
binaries: ["opencode"],
|
|
762
|
+
versionArgs: ["--version"]
|
|
763
|
+
},
|
|
764
|
+
stores: [
|
|
765
|
+
{
|
|
766
|
+
scope: "global",
|
|
767
|
+
path: "~/.config/opencode/skills",
|
|
768
|
+
exclusive: true
|
|
769
|
+
}
|
|
770
|
+
],
|
|
771
|
+
readsShared: true,
|
|
772
|
+
connect: {
|
|
773
|
+
installUrl: "https://opencode.ai/docs",
|
|
774
|
+
docsUrl: "https://opencode.ai/docs/skills/"
|
|
775
|
+
},
|
|
776
|
+
verified: {
|
|
777
|
+
at: "2026-11-05",
|
|
778
|
+
source: "https://opencode.ai/docs/skills/"
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// src/adapters/shared.json
|
|
783
|
+
var shared_default = {
|
|
784
|
+
id: "shared",
|
|
785
|
+
name: "Shared (.agents)",
|
|
786
|
+
tier: "managed",
|
|
787
|
+
detect: {
|
|
788
|
+
paths: ["~/.agents"],
|
|
789
|
+
binaries: [],
|
|
790
|
+
versionArgs: []
|
|
791
|
+
},
|
|
792
|
+
stores: [
|
|
793
|
+
{
|
|
794
|
+
scope: "global",
|
|
795
|
+
path: "~/.agents/skills",
|
|
796
|
+
exclusive: false,
|
|
797
|
+
members: [
|
|
798
|
+
"codex",
|
|
799
|
+
"gemini",
|
|
800
|
+
"cursor",
|
|
801
|
+
"copilot",
|
|
802
|
+
"amp",
|
|
803
|
+
"zed",
|
|
804
|
+
"roo",
|
|
805
|
+
"kilo",
|
|
806
|
+
"factory",
|
|
807
|
+
"dsh"
|
|
808
|
+
]
|
|
809
|
+
}
|
|
810
|
+
],
|
|
811
|
+
readsShared: true,
|
|
812
|
+
connect: {
|
|
813
|
+
installUrl: "https://agentskills.io",
|
|
814
|
+
docsUrl: "https://agentskills.io/specification"
|
|
815
|
+
},
|
|
816
|
+
verified: {
|
|
817
|
+
at: "2026-11-05",
|
|
818
|
+
source: "https://learn.chatgpt.com/docs/build-skills"
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// src/adapters/bundled.ts
|
|
823
|
+
var BUNDLED_ADAPTERS = [
|
|
824
|
+
claude_code_default,
|
|
825
|
+
opencode_default,
|
|
826
|
+
antigravity_default,
|
|
827
|
+
shared_default,
|
|
828
|
+
hermes_default,
|
|
829
|
+
goose_default,
|
|
830
|
+
crush_default,
|
|
831
|
+
devin_default
|
|
832
|
+
].map((raw) => adapterSchema.parse(raw));
|
|
833
|
+
|
|
834
|
+
// src/adapters/home.ts
|
|
835
|
+
function expandHome(raw, home, platform) {
|
|
836
|
+
const nodePath = platformPath(platform);
|
|
837
|
+
if (raw === "~") {
|
|
838
|
+
return nodePath.resolve(home);
|
|
839
|
+
}
|
|
840
|
+
if (raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
841
|
+
return nodePath.resolve(home, raw.slice(2));
|
|
842
|
+
}
|
|
843
|
+
return raw;
|
|
844
|
+
}
|
|
845
|
+
function platformPath2(platform) {
|
|
846
|
+
return platform === "win32" ? path2.win32 : path2.posix;
|
|
847
|
+
}
|
|
848
|
+
function formatIssues(error) {
|
|
849
|
+
return error.issues.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "(root)"}: ${issue.message}`).join("; ");
|
|
850
|
+
}
|
|
851
|
+
function describe3(error) {
|
|
852
|
+
return error instanceof Error ? error.message : String(error);
|
|
853
|
+
}
|
|
854
|
+
async function loadAdapters(options) {
|
|
855
|
+
const platform = options.platform ?? process.platform;
|
|
856
|
+
const nodePath = platformPath2(platform);
|
|
857
|
+
const dir = adaptersRoot(options.dataRoot, platform);
|
|
858
|
+
const problems = [];
|
|
859
|
+
const adapters = [...BUNDLED_ADAPTERS];
|
|
860
|
+
const listing = await options.fs.readdir(dir);
|
|
861
|
+
if (!listing.ok) {
|
|
862
|
+
if (listing.error.code !== "not_found") {
|
|
863
|
+
problems.push({ file: dir, message: listing.error.message });
|
|
864
|
+
}
|
|
865
|
+
return { adapters, problems };
|
|
866
|
+
}
|
|
867
|
+
const files = listing.value.filter((entry) => entry.kind === "file" && entry.name.endsWith(".json")).map((entry) => entry.name).sort();
|
|
868
|
+
const overridden = /* @__PURE__ */ new Set();
|
|
869
|
+
for (const name of files) {
|
|
870
|
+
const file = nodePath.join(dir, name);
|
|
871
|
+
const text = await options.fs.readText(file);
|
|
872
|
+
if (!text.ok) {
|
|
873
|
+
problems.push({ file, message: text.error.message });
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
let raw;
|
|
877
|
+
try {
|
|
878
|
+
raw = JSON.parse(text.value);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
problems.push({ file, message: `JSON tidak valid: ${describe3(error)}` });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const parsed = adapterSchema.safeParse(raw);
|
|
884
|
+
if (!parsed.success) {
|
|
885
|
+
problems.push({ file, message: formatIssues(parsed.error) });
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const adapter = parsed.data;
|
|
889
|
+
if (overridden.has(adapter.id)) {
|
|
890
|
+
problems.push({ file, message: `id "${adapter.id}" duplikat; manifest terakhir menang` });
|
|
891
|
+
}
|
|
892
|
+
overridden.add(adapter.id);
|
|
893
|
+
const index = adapters.findIndex((existing) => existing.id === adapter.id);
|
|
894
|
+
if (index >= 0) {
|
|
895
|
+
adapters[index] = adapter;
|
|
896
|
+
} else {
|
|
897
|
+
adapters.push(adapter);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return { adapters, problems };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/adapters/detect.ts
|
|
904
|
+
var POSIX_CANDIDATES = ["/usr/local/bin", "/usr/bin", "/bin"];
|
|
905
|
+
function splitList(value, separator) {
|
|
906
|
+
return value.split(separator).map((entry) => entry.trim().replace(/^"|"$/g, "")).filter((entry) => entry.length > 0);
|
|
907
|
+
}
|
|
908
|
+
function candidateDirs(context) {
|
|
909
|
+
const { env, home, nodePath, platform } = context;
|
|
910
|
+
if (platform === "win32") {
|
|
911
|
+
const appData = env.APPDATA?.trim() || nodePath.join(home, "AppData", "Roaming");
|
|
912
|
+
const localAppData = env.LOCALAPPDATA?.trim() || nodePath.join(home, "AppData", "Local");
|
|
913
|
+
return [
|
|
914
|
+
nodePath.join(appData, "npm"),
|
|
915
|
+
nodePath.join(localAppData, "Programs"),
|
|
916
|
+
nodePath.join(home, ".local", "bin")
|
|
917
|
+
];
|
|
918
|
+
}
|
|
919
|
+
return [...POSIX_CANDIDATES, nodePath.join(home, ".local", "bin"), nodePath.join(home, "bin")];
|
|
920
|
+
}
|
|
921
|
+
function createContext(options) {
|
|
922
|
+
const platform = options.platform ?? process.platform;
|
|
923
|
+
const base = {
|
|
924
|
+
fs: options.fs,
|
|
925
|
+
runner: options.runner,
|
|
926
|
+
home: options.home ?? homedir(),
|
|
927
|
+
env: options.env ?? process.env,
|
|
928
|
+
platform,
|
|
929
|
+
nodePath: platformPath(platform),
|
|
930
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
|
|
931
|
+
};
|
|
932
|
+
const rawPath = base.env.PATH ?? base.env.Path ?? base.env.path ?? "";
|
|
933
|
+
const fromPath = splitList(rawPath, platform === "win32" ? ";" : ":");
|
|
934
|
+
const seen = /* @__PURE__ */ new Set();
|
|
935
|
+
const searchDirs = [];
|
|
936
|
+
for (const dir of [...fromPath, ...candidateDirs(base)]) {
|
|
937
|
+
const resolved = base.nodePath.resolve(dir);
|
|
938
|
+
if (seen.has(resolved)) {
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
seen.add(resolved);
|
|
942
|
+
searchDirs.push(resolved);
|
|
943
|
+
}
|
|
944
|
+
return { ...base, searchDirs };
|
|
945
|
+
}
|
|
946
|
+
async function isFile(fs, target) {
|
|
947
|
+
const stat2 = await fs.stat(target);
|
|
948
|
+
return stat2.ok && stat2.value.kind === "file";
|
|
949
|
+
}
|
|
950
|
+
function binaryNames(binary, platform) {
|
|
951
|
+
if (platform !== "win32") {
|
|
952
|
+
return [binary];
|
|
953
|
+
}
|
|
954
|
+
return [`${binary}.exe`, `${binary}.cmd`, `${binary}.bat`, binary, `${binary}.ps1`];
|
|
955
|
+
}
|
|
956
|
+
async function findBinary(context, binary) {
|
|
957
|
+
for (const dir of context.searchDirs) {
|
|
958
|
+
for (const name of binaryNames(binary, context.platform)) {
|
|
959
|
+
const candidate = context.nodePath.join(dir, name);
|
|
960
|
+
if (await isFile(context.fs, candidate)) {
|
|
961
|
+
return candidate;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return void 0;
|
|
966
|
+
}
|
|
967
|
+
async function verifyBinary(context, binary, command, versionArgs) {
|
|
968
|
+
if (versionArgs.length === 0) {
|
|
969
|
+
return `${binary} ditemukan`;
|
|
970
|
+
}
|
|
971
|
+
if (context.platform === "win32" && /\.(cmd|bat|ps1)$/i.test(command)) {
|
|
972
|
+
return `${binary} ditemukan (skrip ${path2.win32.extname(command).slice(1)}, versi tidak diverifikasi)`;
|
|
973
|
+
}
|
|
974
|
+
const label = `${binary} ${versionArgs.join(" ")}`;
|
|
975
|
+
const result = await context.runner.run(command, versionArgs, { timeoutMs: context.timeoutMs });
|
|
976
|
+
if (!result.ok) {
|
|
977
|
+
return `${label} gagal dijalankan (${result.error.code})`;
|
|
978
|
+
}
|
|
979
|
+
if (result.value.timedOut) {
|
|
980
|
+
return `${label} tidak menjawab dalam ${context.timeoutMs} ms`;
|
|
981
|
+
}
|
|
982
|
+
if (result.value.code === 0) {
|
|
983
|
+
return `${label} menjawab`;
|
|
984
|
+
}
|
|
985
|
+
return `${label} keluar dengan kode ${result.value.code ?? "?"}`;
|
|
986
|
+
}
|
|
987
|
+
async function detectOne(adapter, context) {
|
|
988
|
+
const reasons = [];
|
|
989
|
+
let detected = false;
|
|
990
|
+
for (const raw of adapter.detect.paths) {
|
|
991
|
+
const resolved = expandHome(raw, context.home, context.platform);
|
|
992
|
+
const stat2 = await context.fs.stat(resolved);
|
|
993
|
+
if (stat2.ok) {
|
|
994
|
+
detected = true;
|
|
995
|
+
reasons.push(`ditemukan ${raw}`);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
reasons.push(`${raw} tidak ada`);
|
|
999
|
+
}
|
|
1000
|
+
for (const binary of adapter.detect.binaries) {
|
|
1001
|
+
const command = await findBinary(context, binary);
|
|
1002
|
+
if (command === void 0) {
|
|
1003
|
+
reasons.push(`${binary} tidak ditemukan di PATH`);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
detected = true;
|
|
1007
|
+
reasons.push(await verifyBinary(context, binary, command, adapter.detect.versionArgs));
|
|
1008
|
+
}
|
|
1009
|
+
const capability = adapterCapability(adapter);
|
|
1010
|
+
if (adapter.tier === "detected-only") {
|
|
1011
|
+
reasons.push(
|
|
1012
|
+
"adapter berstatus detected-only: terlihat di UI, tidak bisa dipilih sebagai Target"
|
|
1013
|
+
);
|
|
1014
|
+
} else if (adapter.verified === void 0) {
|
|
1015
|
+
reasons.push("manifest tidak punya verified.at: status diturunkan ke detected");
|
|
1016
|
+
}
|
|
1017
|
+
if (adapter.notes !== void 0) {
|
|
1018
|
+
reasons.push(adapter.notes);
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
id: adapter.id,
|
|
1022
|
+
name: adapter.name,
|
|
1023
|
+
capability,
|
|
1024
|
+
detected,
|
|
1025
|
+
detectionReasons: reasons,
|
|
1026
|
+
storeIds: capability === "managed" ? adapter.stores.map((store) => store.path) : [],
|
|
1027
|
+
connect: adapter.connect === void 0 ? void 0 : { installUrl: adapter.connect.installUrl }
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
async function detectHarnesses(adapters, options) {
|
|
1031
|
+
const context = createContext(options);
|
|
1032
|
+
const harnesses = [];
|
|
1033
|
+
for (const adapter of adapters) {
|
|
1034
|
+
harnesses.push(await detectOne(adapter, context));
|
|
1035
|
+
}
|
|
1036
|
+
return harnesses;
|
|
1037
|
+
}
|
|
1038
|
+
async function recheck(options) {
|
|
1039
|
+
const catalog = await loadAdapters(options);
|
|
1040
|
+
const harnesses = await detectHarnesses(catalog.adapters, options);
|
|
1041
|
+
return { adapters: catalog.adapters, harnesses, problems: catalog.problems };
|
|
1042
|
+
}
|
|
1043
|
+
function parseTargetId(targetId) {
|
|
1044
|
+
const [harnessId, scope, ...rest] = targetId.split("/");
|
|
1045
|
+
if (harnessId === void 0 || harnessId.length === 0 || rest.length > 0 || scope !== "global" && scope !== "project") {
|
|
1046
|
+
throw new SkillManagerError({
|
|
1047
|
+
code: "invalid_target",
|
|
1048
|
+
message: `Target "${targetId}" tidak valid; format yang benar mis. "opencode/global".`
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
return { harnessId, scope };
|
|
1052
|
+
}
|
|
1053
|
+
function sharedStore(adapters, scope) {
|
|
1054
|
+
for (const adapter of adapters) {
|
|
1055
|
+
if (adapterCapability(adapter) !== "managed") {
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const store = adapter.stores.find((entry) => entry.scope === scope && !entry.exclusive);
|
|
1059
|
+
if (store !== void 0) {
|
|
1060
|
+
return store;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return void 0;
|
|
1064
|
+
}
|
|
1065
|
+
function resolveTarget(adapters, target, options = {}) {
|
|
1066
|
+
const { harnessId, scope } = typeof target === "string" ? parseTargetId(target) : target;
|
|
1067
|
+
const adapter = adapters.find((entry) => entry.id === harnessId);
|
|
1068
|
+
if (adapter === void 0) {
|
|
1069
|
+
throw new SkillManagerError({
|
|
1070
|
+
code: "unknown_target",
|
|
1071
|
+
message: `Harness "${harnessId}" tidak dikenal.`
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
if (adapterCapability(adapter) !== "managed") {
|
|
1075
|
+
throw new SkillManagerError({
|
|
1076
|
+
code: "target_not_managed",
|
|
1077
|
+
message: `Harness "${harnessId}" berstatus detected-only sehingga tidak bisa dipilih sebagai Target.`
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if (options.shared === true) {
|
|
1081
|
+
const shared = sharedStore(adapters, scope);
|
|
1082
|
+
if (!adapter.readsShared || shared === void 0) {
|
|
1083
|
+
throw new SkillManagerError({
|
|
1084
|
+
code: "shared_not_supported",
|
|
1085
|
+
message: `Harness "${harnessId}" tidak membaca Store shared.`
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return { harnessId, scope, storeId: shared.path };
|
|
1089
|
+
}
|
|
1090
|
+
const store = adapter.stores.find((entry) => entry.scope === scope);
|
|
1091
|
+
if (store === void 0) {
|
|
1092
|
+
throw new SkillManagerError({
|
|
1093
|
+
code: "target_not_available",
|
|
1094
|
+
message: `Target "${harnessId}/${scope}" belum tersedia di v0.1.`
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
return { harnessId, scope, storeId: store.path };
|
|
1098
|
+
}
|
|
1099
|
+
function listStores(adapters, options = {}) {
|
|
1100
|
+
const platform = options.platform ?? process.platform;
|
|
1101
|
+
const home = options.home ?? homedir();
|
|
1102
|
+
const stores = [];
|
|
1103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1104
|
+
for (const adapter of adapters) {
|
|
1105
|
+
if (adapterCapability(adapter) !== "managed") {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
for (const store of adapter.stores) {
|
|
1109
|
+
if (seen.has(store.path)) {
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
seen.add(store.path);
|
|
1113
|
+
stores.push({
|
|
1114
|
+
id: store.path,
|
|
1115
|
+
path: expandHome(store.path, home, platform),
|
|
1116
|
+
members: store.members === void 0 ? [adapter.id] : [...store.members],
|
|
1117
|
+
exclusive: store.exclusive
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return stores;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// src/server/routes/connect.ts
|
|
1125
|
+
function registerConnectRoutes(app, deps) {
|
|
1126
|
+
app.get("/api/connect/:harnessId", async (c) => {
|
|
1127
|
+
const harnessId = c.req.param("harnessId");
|
|
1128
|
+
const { adapters } = await loadAdapters({ fs: deps.fs, dataRoot: deps.dataRoot });
|
|
1129
|
+
const adapter = adapters.find((entry) => entry.id === harnessId);
|
|
1130
|
+
if (adapter?.connect === void 0) {
|
|
1131
|
+
throw new SkillManagerError({
|
|
1132
|
+
code: "harness_not_found",
|
|
1133
|
+
message: `Harness "${harnessId}" tidak dikenal atau tidak punya URL installer.`
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
return c.json({ url: adapter.connect.installUrl });
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/library/frontmatter.ts
|
|
1141
|
+
var OFFICIAL_FRONTMATTER_FIELDS = [
|
|
1142
|
+
"name",
|
|
1143
|
+
"description",
|
|
1144
|
+
"license",
|
|
1145
|
+
"compatibility",
|
|
1146
|
+
"metadata",
|
|
1147
|
+
"allowed-tools"
|
|
1148
|
+
];
|
|
1149
|
+
var FRONTMATTER_INVALID_CODE = "frontmatter_invalid";
|
|
1150
|
+
var KEY_PATTERN = /^([A-Za-z0-9_-]+)[ \t]*:[ \t]*(.*)$/;
|
|
1151
|
+
var INDENTED_PATTERN = /^[ \t]+\S/;
|
|
1152
|
+
function invalid(message) {
|
|
1153
|
+
return new SkillManagerError({ code: FRONTMATTER_INVALID_CODE, message });
|
|
1154
|
+
}
|
|
1155
|
+
function parseScalar(raw, lineNumber) {
|
|
1156
|
+
const value = raw.trim();
|
|
1157
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
1158
|
+
const quote = value.charAt(0);
|
|
1159
|
+
if (value.length < 2 || !value.endsWith(quote)) {
|
|
1160
|
+
throw invalid(`baris ${lineNumber}: string berkutip tidak ditutup.`);
|
|
1161
|
+
}
|
|
1162
|
+
return value.slice(1, -1).replaceAll(`\\${quote}`, quote);
|
|
1163
|
+
}
|
|
1164
|
+
return value;
|
|
1165
|
+
}
|
|
1166
|
+
function parseFrontmatter(text) {
|
|
1167
|
+
const lines = text.split("\n").map((line) => line.replace(/\r$/, ""));
|
|
1168
|
+
const start = lines.findIndex((line) => line.trim() !== "");
|
|
1169
|
+
if (start === -1 || lines[start]?.trim() !== "---") {
|
|
1170
|
+
throw invalid("SKILL.md tidak memiliki blok frontmatter (---) di awal file.");
|
|
1171
|
+
}
|
|
1172
|
+
const end = lines.findIndex((line, index) => index > start && line.trim() === "---");
|
|
1173
|
+
if (end === -1) {
|
|
1174
|
+
throw invalid("Blok frontmatter tidak ditutup dengan ---.");
|
|
1175
|
+
}
|
|
1176
|
+
const data = {};
|
|
1177
|
+
let nested;
|
|
1178
|
+
for (let index = start + 1; index < end; index += 1) {
|
|
1179
|
+
const line = lines[index] ?? "";
|
|
1180
|
+
const trimmed = line.trim();
|
|
1181
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const indented = INDENTED_PATTERN.test(line);
|
|
1185
|
+
const match = KEY_PATTERN.exec(indented ? trimmed : line);
|
|
1186
|
+
if (!match) {
|
|
1187
|
+
throw invalid(`baris ${index + 1}: bukan pasangan key: value.`);
|
|
1188
|
+
}
|
|
1189
|
+
const key = match[1] ?? "";
|
|
1190
|
+
const rawValue = match[2] ?? "";
|
|
1191
|
+
if (indented) {
|
|
1192
|
+
if (!nested) {
|
|
1193
|
+
throw invalid(`baris ${index + 1}: baris menjorok tanpa induk key.`);
|
|
1194
|
+
}
|
|
1195
|
+
nested.map[key] = parseScalar(rawValue, index + 1);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (rawValue.trim() === "") {
|
|
1199
|
+
nested = { key, map: {} };
|
|
1200
|
+
data[key] = nested.map;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
nested = void 0;
|
|
1204
|
+
data[key] = parseScalar(rawValue, index + 1);
|
|
1205
|
+
}
|
|
1206
|
+
return { data, body: lines.slice(end + 1).join("\n") };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/deps/detect.ts
|
|
1210
|
+
var PACKAGE_JSON = "package.json";
|
|
1211
|
+
var REQUIREMENTS_TXT = "requirements.txt";
|
|
1212
|
+
var PYPROJECT_TOML = "pyproject.toml";
|
|
1213
|
+
var SKILL_MD = "SKILL.md";
|
|
1214
|
+
var IGNORE_SCRIPTS_FLAG = "--ignore-scripts";
|
|
1215
|
+
var UV_REQUIREMENTS_ARGS = ["pip", "install", "-r", REQUIREMENTS_TXT];
|
|
1216
|
+
var UV_PYPROJECT_ARGS = ["pip", "install", "-r", PYPROJECT_TOML];
|
|
1217
|
+
function validateSkillId(skillId) {
|
|
1218
|
+
if (skillId.length === 0) {
|
|
1219
|
+
return "skillId kosong.";
|
|
1220
|
+
}
|
|
1221
|
+
if (skillId === "." || skillId === ".." || /[\\/]/.test(skillId)) {
|
|
1222
|
+
return "skillId tidak boleh berisi pemisah path atau menunjuk ke direktori induk.";
|
|
1223
|
+
}
|
|
1224
|
+
return void 0;
|
|
1225
|
+
}
|
|
1226
|
+
async function readManifest(fs, directory, name) {
|
|
1227
|
+
const filePath = path2.join(directory, name);
|
|
1228
|
+
const stat2 = await fs.stat(filePath);
|
|
1229
|
+
if (!stat2.ok || stat2.value.kind !== "file") {
|
|
1230
|
+
return fsOk(void 0);
|
|
1231
|
+
}
|
|
1232
|
+
const text = await fs.readText(filePath);
|
|
1233
|
+
return text.ok ? fsOk({ text: text.value, filePath }) : text;
|
|
1234
|
+
}
|
|
1235
|
+
function splitList2(value) {
|
|
1236
|
+
return value.replace(/^\[|\]$/g, "").split(/[,\s]+/).map((token) => token.trim().replace(/^["']|["']$/g, "")).filter((token) => token.length > 0);
|
|
1237
|
+
}
|
|
1238
|
+
function metadataRequires(text) {
|
|
1239
|
+
let parsed;
|
|
1240
|
+
try {
|
|
1241
|
+
parsed = parseFrontmatter(text);
|
|
1242
|
+
} catch {
|
|
1243
|
+
return [];
|
|
1244
|
+
}
|
|
1245
|
+
const metadata = parsed.data.metadata;
|
|
1246
|
+
if (metadata === void 0 || typeof metadata === "string") {
|
|
1247
|
+
return [];
|
|
1248
|
+
}
|
|
1249
|
+
const requires = metadata.requires;
|
|
1250
|
+
return requires === void 0 ? [] : splitList2(requires);
|
|
1251
|
+
}
|
|
1252
|
+
function hintKinds(tokens) {
|
|
1253
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
1254
|
+
for (const token of tokens) {
|
|
1255
|
+
const hint = token.toLowerCase();
|
|
1256
|
+
if (hint === "npm" || hint === "node" || hint.startsWith("npm:")) {
|
|
1257
|
+
kinds.add("npm");
|
|
1258
|
+
}
|
|
1259
|
+
if (hint === "uv" || hint === "pip" || hint === "python" || hint.startsWith("pip:")) {
|
|
1260
|
+
kinds.add("uv");
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return kinds;
|
|
1264
|
+
}
|
|
1265
|
+
function withHint(reason, kind, hints) {
|
|
1266
|
+
return hints.has(kind) ? `${reason}; metadata.requires menyebut ${kind}` : reason;
|
|
1267
|
+
}
|
|
1268
|
+
function dependencyNames(value) {
|
|
1269
|
+
return typeof value === "object" && value !== null ? Object.keys(value) : [];
|
|
1270
|
+
}
|
|
1271
|
+
function parsePackageJson(manifest) {
|
|
1272
|
+
let parsed;
|
|
1273
|
+
try {
|
|
1274
|
+
parsed = JSON.parse(manifest.text);
|
|
1275
|
+
} catch {
|
|
1276
|
+
return fsErr("io_error", manifest.filePath, "package.json bukan JSON yang valid.");
|
|
1277
|
+
}
|
|
1278
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1279
|
+
return fsErr("io_error", manifest.filePath, "package.json harus berupa objek JSON.");
|
|
1280
|
+
}
|
|
1281
|
+
const record = parsed;
|
|
1282
|
+
return fsOk({
|
|
1283
|
+
dependencies: [
|
|
1284
|
+
...dependencyNames(record.dependencies),
|
|
1285
|
+
...dependencyNames(record.devDependencies)
|
|
1286
|
+
]
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
function requirementNames(text) {
|
|
1290
|
+
return text.split(/\r?\n/).map((line) => line.replace(/#.*$/, "").trim()).filter((line) => line.length > 0 && !line.startsWith("-")).map((line) => line.split(/\s+/)[0] ?? "").filter((name) => name.length > 0);
|
|
1291
|
+
}
|
|
1292
|
+
async function detectDependencies(fs, skillDir, options) {
|
|
1293
|
+
const invalid3 = validateSkillId(options.skillId);
|
|
1294
|
+
if (invalid3) {
|
|
1295
|
+
return fsErr("invalid_path", options.skillId, invalid3);
|
|
1296
|
+
}
|
|
1297
|
+
const cwd = path2.join(depsRoot(options.dataRoot, options.platform), options.skillId);
|
|
1298
|
+
const hints = /* @__PURE__ */ new Set();
|
|
1299
|
+
const skillFile = await readManifest(fs, skillDir, SKILL_MD);
|
|
1300
|
+
if (skillFile.ok && skillFile.value) {
|
|
1301
|
+
for (const kind of hintKinds(metadataRequires(skillFile.value.text))) {
|
|
1302
|
+
hints.add(kind);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
const commands = [];
|
|
1306
|
+
const packageFile = await readManifest(fs, skillDir, PACKAGE_JSON);
|
|
1307
|
+
if (!packageFile.ok) {
|
|
1308
|
+
return packageFile;
|
|
1309
|
+
}
|
|
1310
|
+
if (packageFile.value) {
|
|
1311
|
+
const manifest = parsePackageJson(packageFile.value);
|
|
1312
|
+
if (!manifest.ok) {
|
|
1313
|
+
return manifest;
|
|
1314
|
+
}
|
|
1315
|
+
commands.push({
|
|
1316
|
+
kind: "npm",
|
|
1317
|
+
command: "npm",
|
|
1318
|
+
args: ["install", IGNORE_SCRIPTS_FLAG],
|
|
1319
|
+
cwd,
|
|
1320
|
+
reason: withHint(
|
|
1321
|
+
`package.json: ${manifest.value.dependencies.length} dependensi`,
|
|
1322
|
+
"npm",
|
|
1323
|
+
hints
|
|
1324
|
+
),
|
|
1325
|
+
dependencies: manifest.value.dependencies
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const requirements = await readManifest(fs, skillDir, REQUIREMENTS_TXT);
|
|
1329
|
+
if (!requirements.ok) {
|
|
1330
|
+
return requirements;
|
|
1331
|
+
}
|
|
1332
|
+
if (requirements.value) {
|
|
1333
|
+
const dependencies = requirementNames(requirements.value.text);
|
|
1334
|
+
commands.push({
|
|
1335
|
+
kind: "uv",
|
|
1336
|
+
command: "uv",
|
|
1337
|
+
args: [...UV_REQUIREMENTS_ARGS],
|
|
1338
|
+
cwd,
|
|
1339
|
+
reason: withHint(`requirements.txt: ${dependencies.length} dependensi`, "uv", hints),
|
|
1340
|
+
dependencies
|
|
1341
|
+
});
|
|
1342
|
+
return fsOk(commands);
|
|
1343
|
+
}
|
|
1344
|
+
const pyproject = await readManifest(fs, skillDir, PYPROJECT_TOML);
|
|
1345
|
+
if (!pyproject.ok) {
|
|
1346
|
+
return pyproject;
|
|
1347
|
+
}
|
|
1348
|
+
if (pyproject.value && /^\s*\[(project|tool\.poetry)\]/m.test(pyproject.value.text)) {
|
|
1349
|
+
commands.push({
|
|
1350
|
+
kind: "uv",
|
|
1351
|
+
command: "uv",
|
|
1352
|
+
args: [...UV_PYPROJECT_ARGS],
|
|
1353
|
+
cwd,
|
|
1354
|
+
reason: withHint("pyproject.toml: proyek Python terdeteksi", "uv", hints),
|
|
1355
|
+
dependencies: []
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
return fsOk(commands);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/deps/plan.ts
|
|
1362
|
+
async function planDependencies(fs, skillDir, options) {
|
|
1363
|
+
const detected = await detectDependencies(fs, skillDir, options);
|
|
1364
|
+
if (!detected.ok || options.allowScripts !== true) {
|
|
1365
|
+
return detected;
|
|
1366
|
+
}
|
|
1367
|
+
return fsOk(
|
|
1368
|
+
detected.value.map((command) => {
|
|
1369
|
+
if (command.kind !== "npm") {
|
|
1370
|
+
return command;
|
|
1371
|
+
}
|
|
1372
|
+
return {
|
|
1373
|
+
...command,
|
|
1374
|
+
args: command.args.filter((arg) => arg !== IGNORE_SCRIPTS_FLAG),
|
|
1375
|
+
reason: `${command.reason}; script lifecycle diizinkan`
|
|
1376
|
+
};
|
|
1377
|
+
})
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/deps/availability.ts
|
|
1382
|
+
async function checkDependencyTool(runner, kind, options = {}) {
|
|
1383
|
+
const result = await runner.run(kind, ["--version"], {
|
|
1384
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS
|
|
1385
|
+
});
|
|
1386
|
+
if (!result.ok) {
|
|
1387
|
+
return { kind, available: false, message: result.error.message };
|
|
1388
|
+
}
|
|
1389
|
+
if (result.value.timedOut) {
|
|
1390
|
+
return { kind, available: false, message: `"${kind} --version" melewati batas waktu.` };
|
|
1391
|
+
}
|
|
1392
|
+
if (result.value.code !== 0) {
|
|
1393
|
+
return {
|
|
1394
|
+
kind,
|
|
1395
|
+
available: false,
|
|
1396
|
+
message: `"${kind} --version" keluar dengan kode ${result.value.code ?? "null"}.`
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const version = (result.value.stdout || result.value.stderr).trim();
|
|
1400
|
+
return version.length > 0 ? { kind, available: true, version } : { kind, available: true };
|
|
1401
|
+
}
|
|
1402
|
+
async function assertDependencyTools(runner, kinds, options = {}) {
|
|
1403
|
+
const unique = [...new Set(kinds)];
|
|
1404
|
+
const statuses = await Promise.all(
|
|
1405
|
+
unique.map((kind) => checkDependencyTool(runner, kind, options))
|
|
1406
|
+
);
|
|
1407
|
+
const missing = statuses.filter((status) => !status.available);
|
|
1408
|
+
if (missing.length > 0) {
|
|
1409
|
+
const names = missing.map((status) => status.kind).join(" dan ");
|
|
1410
|
+
throw new SkillManagerError({
|
|
1411
|
+
code: "dependency_tool_missing",
|
|
1412
|
+
message: `${names} tidak tersedia. Pasang sendiri lebih dulu; Skill Manager tidak pernah memasang package manager.`,
|
|
1413
|
+
details: { missing: missing.map(({ kind, message }) => ({ kind, message })) }
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
return statuses;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// src/deps/run.ts
|
|
1420
|
+
var DEFAULT_INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
1421
|
+
var MANIFESTS = {
|
|
1422
|
+
npm: ["package.json"],
|
|
1423
|
+
uv: ["requirements.txt", "pyproject.toml"]
|
|
1424
|
+
};
|
|
1425
|
+
async function stageManifests(fs, skillDir, cwd, kind) {
|
|
1426
|
+
const created = await fs.mkdir(cwd, { recursive: true });
|
|
1427
|
+
if (!created.ok) {
|
|
1428
|
+
return created;
|
|
1429
|
+
}
|
|
1430
|
+
for (const name of MANIFESTS[kind]) {
|
|
1431
|
+
const source = path2.join(skillDir, name);
|
|
1432
|
+
const stat2 = await fs.stat(source);
|
|
1433
|
+
if (!stat2.ok || stat2.value.kind !== "file") {
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
const text = await fs.readText(source);
|
|
1437
|
+
if (!text.ok) {
|
|
1438
|
+
return text;
|
|
1439
|
+
}
|
|
1440
|
+
const written = await fs.write(path2.join(cwd, name), text.value);
|
|
1441
|
+
if (!written.ok) {
|
|
1442
|
+
return written;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return fsOk(void 0);
|
|
1446
|
+
}
|
|
1447
|
+
function wrap(error, details) {
|
|
1448
|
+
return new SkillManagerError({ code: error.code, message: error.message, details });
|
|
1449
|
+
}
|
|
1450
|
+
async function runDependencies(options) {
|
|
1451
|
+
const planned = await planDependencies(options.fs, options.skillDir, options);
|
|
1452
|
+
if (!planned.ok) {
|
|
1453
|
+
throw wrap(planned.error, planned.error);
|
|
1454
|
+
}
|
|
1455
|
+
const commands = planned.value;
|
|
1456
|
+
if (commands.length === 0) {
|
|
1457
|
+
return { ok: true, results: [] };
|
|
1458
|
+
}
|
|
1459
|
+
await assertDependencyTools(
|
|
1460
|
+
options.runner,
|
|
1461
|
+
commands.map((command) => command.kind)
|
|
1462
|
+
);
|
|
1463
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
1464
|
+
const results = [];
|
|
1465
|
+
for (const command of commands) {
|
|
1466
|
+
const staged = await stageManifests(options.fs, options.skillDir, command.cwd, command.kind);
|
|
1467
|
+
if (!staged.ok) {
|
|
1468
|
+
throw wrap(staged.error, staged.error);
|
|
1469
|
+
}
|
|
1470
|
+
const execution = await options.runner.run(command.command, command.args, {
|
|
1471
|
+
cwd: command.cwd,
|
|
1472
|
+
timeoutMs
|
|
1473
|
+
});
|
|
1474
|
+
if (!execution.ok) {
|
|
1475
|
+
throw new SkillManagerError({
|
|
1476
|
+
code: "dependency_run_failed",
|
|
1477
|
+
message: execution.error.message,
|
|
1478
|
+
details: execution.error
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
results.push({
|
|
1482
|
+
kind: command.kind,
|
|
1483
|
+
command: command.command,
|
|
1484
|
+
args: command.args,
|
|
1485
|
+
cwd: command.cwd,
|
|
1486
|
+
code: execution.value.code,
|
|
1487
|
+
stdout: execution.value.stdout,
|
|
1488
|
+
stderr: execution.value.stderr,
|
|
1489
|
+
timedOut: execution.value.timedOut
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
return { ok: results.every((result) => result.code === 0 && !result.timedOut), results };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// src/server/routes/context.ts
|
|
1496
|
+
function describeIssues(error) {
|
|
1497
|
+
return error.issues.map((issue) => ({
|
|
1498
|
+
path: issue.path.map(String).join("."),
|
|
1499
|
+
message: issue.message
|
|
1500
|
+
}));
|
|
1501
|
+
}
|
|
1502
|
+
async function readJson(c, schema) {
|
|
1503
|
+
let payload;
|
|
1504
|
+
try {
|
|
1505
|
+
payload = await c.req.json();
|
|
1506
|
+
} catch {
|
|
1507
|
+
throw new SkillManagerError({
|
|
1508
|
+
code: "bad_request",
|
|
1509
|
+
message: "Body request harus JSON yang valid."
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
const parsed = schema.safeParse(payload);
|
|
1513
|
+
if (!parsed.success) {
|
|
1514
|
+
throw new SkillManagerError({
|
|
1515
|
+
code: "bad_request",
|
|
1516
|
+
message: "Body request tidak sesuai bentuk yang diharapkan.",
|
|
1517
|
+
details: { issues: describeIssues(parsed.error) }
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return parsed.data;
|
|
1521
|
+
}
|
|
1522
|
+
function fsFailure(error) {
|
|
1523
|
+
return new SkillManagerError({
|
|
1524
|
+
code: error.code,
|
|
1525
|
+
message: error.message,
|
|
1526
|
+
details: { path: error.path }
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
function unwrap(result) {
|
|
1530
|
+
if (!result.ok) {
|
|
1531
|
+
throw fsFailure(result.error);
|
|
1532
|
+
}
|
|
1533
|
+
return result.value;
|
|
1534
|
+
}
|
|
1535
|
+
var DOMAIN_CODE_PATTERN = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
1536
|
+
function asDomainError(error) {
|
|
1537
|
+
if (error instanceof SkillManagerError) {
|
|
1538
|
+
return error;
|
|
1539
|
+
}
|
|
1540
|
+
if (typeof error === "object" && error !== null) {
|
|
1541
|
+
const candidate = error;
|
|
1542
|
+
if (typeof candidate.code === "string" && DOMAIN_CODE_PATTERN.test(candidate.code) && typeof candidate.message === "string") {
|
|
1543
|
+
return new SkillManagerError({
|
|
1544
|
+
code: candidate.code,
|
|
1545
|
+
message: candidate.message,
|
|
1546
|
+
...typeof candidate.path === "string" ? { details: { path: candidate.path } } : {}
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return error;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// src/core/types.ts
|
|
1554
|
+
var STATE_SCHEMA_VERSION = 1;
|
|
1555
|
+
|
|
1556
|
+
// src/core/state.ts
|
|
1557
|
+
var STATE_FILE_NAME = "state.json";
|
|
1558
|
+
var contentHashSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/, "harus berformat sha256:<64 hex lowercase>");
|
|
1559
|
+
var sourceInfoSchema = z.discriminatedUnion("type", [
|
|
1560
|
+
z.object({ type: z.literal("folder"), path: z.string().min(1) }),
|
|
1561
|
+
z.object({
|
|
1562
|
+
type: z.literal("git"),
|
|
1563
|
+
url: z.string().min(1),
|
|
1564
|
+
ref: z.string().min(1),
|
|
1565
|
+
commit: z.string().min(1)
|
|
1566
|
+
}),
|
|
1567
|
+
z.object({ type: z.literal("zip"), path: z.string().min(1) })
|
|
1568
|
+
]);
|
|
1569
|
+
var skillSchema = z.object({
|
|
1570
|
+
id: z.string().min(1),
|
|
1571
|
+
name: z.string().min(1),
|
|
1572
|
+
dirName: z.string().min(1),
|
|
1573
|
+
contentHash: contentHashSchema,
|
|
1574
|
+
source: sourceInfoSchema,
|
|
1575
|
+
license: z.string().optional(),
|
|
1576
|
+
importedAt: z.string().min(1)
|
|
1577
|
+
});
|
|
1578
|
+
var installationSchema = z.object({
|
|
1579
|
+
skillId: z.string().min(1),
|
|
1580
|
+
storeId: z.string().min(1),
|
|
1581
|
+
mode: z.enum(["copy", "symlink"]),
|
|
1582
|
+
installedHash: contentHashSchema,
|
|
1583
|
+
installedAt: z.string().min(1)
|
|
1584
|
+
});
|
|
1585
|
+
var projectSchema = z.object({
|
|
1586
|
+
path: z.string().min(1)
|
|
1587
|
+
});
|
|
1588
|
+
var stateFileSchema = z.object({
|
|
1589
|
+
schemaVersion: z.literal(STATE_SCHEMA_VERSION),
|
|
1590
|
+
library: z.array(skillSchema),
|
|
1591
|
+
installations: z.array(installationSchema),
|
|
1592
|
+
projects: z.array(projectSchema),
|
|
1593
|
+
settings: z.object({
|
|
1594
|
+
sharedGroupMembers: z.array(z.string().min(1))
|
|
1595
|
+
})
|
|
1596
|
+
});
|
|
1597
|
+
function emptyState() {
|
|
1598
|
+
return {
|
|
1599
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
1600
|
+
library: [],
|
|
1601
|
+
installations: [],
|
|
1602
|
+
projects: [],
|
|
1603
|
+
settings: { sharedGroupMembers: [] }
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
function recovered(reason) {
|
|
1607
|
+
return { state: emptyState(), status: "recovered", reason };
|
|
1608
|
+
}
|
|
1609
|
+
function formatIssues2(error) {
|
|
1610
|
+
return error.issues.map((issue) => {
|
|
1611
|
+
const location = issue.path.length > 0 ? issue.path.join(".") : "(akar)";
|
|
1612
|
+
return `${location}: ${issue.message}`;
|
|
1613
|
+
}).join("; ");
|
|
1614
|
+
}
|
|
1615
|
+
async function loadState(fs, dataRoot) {
|
|
1616
|
+
const filePath = statePath(dataRoot);
|
|
1617
|
+
const text = await fs.readText(filePath);
|
|
1618
|
+
if (!text.ok) {
|
|
1619
|
+
if (text.error.code === "not_found") {
|
|
1620
|
+
return { state: emptyState(), status: "missing" };
|
|
1621
|
+
}
|
|
1622
|
+
return recovered(
|
|
1623
|
+
`${STATE_FILE_NAME} tidak bisa dibaca (${text.error.code}): ${text.error.message}`
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
let parsedJson;
|
|
1627
|
+
try {
|
|
1628
|
+
parsedJson = JSON.parse(text.value);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1631
|
+
return recovered(`${STATE_FILE_NAME} bukan JSON valid: ${message}`);
|
|
1632
|
+
}
|
|
1633
|
+
const version = parsedJson?.schemaVersion;
|
|
1634
|
+
if (typeof version === "number" && version > STATE_SCHEMA_VERSION) {
|
|
1635
|
+
return {
|
|
1636
|
+
state: emptyState(),
|
|
1637
|
+
status: "unsupported_version",
|
|
1638
|
+
reason: `${STATE_FILE_NAME} memakai schemaVersion ${version}, sedangkan aplikasi ini hanya mendukung ${STATE_SCHEMA_VERSION}.`
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
const parsed = stateFileSchema.safeParse(parsedJson);
|
|
1642
|
+
if (!parsed.success) {
|
|
1643
|
+
return recovered(`${STATE_FILE_NAME} tidak sesuai schema: ${formatIssues2(parsed.error)}`);
|
|
1644
|
+
}
|
|
1645
|
+
return { state: parsed.data, status: "loaded" };
|
|
1646
|
+
}
|
|
1647
|
+
async function quarantineCorruptState(fs, targetPath) {
|
|
1648
|
+
const existing = await fs.readText(targetPath);
|
|
1649
|
+
if (!existing.ok) {
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
let parsed;
|
|
1653
|
+
try {
|
|
1654
|
+
parsed = JSON.parse(existing.value);
|
|
1655
|
+
} catch {
|
|
1656
|
+
parsed = void 0;
|
|
1657
|
+
}
|
|
1658
|
+
if (parsed !== void 0 && stateFileSchema.safeParse(parsed).success) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const corruptPath = `${targetPath}.corrupt`;
|
|
1662
|
+
await fs.remove(corruptPath);
|
|
1663
|
+
await fs.rename(targetPath, corruptPath);
|
|
1664
|
+
}
|
|
1665
|
+
async function saveState(fs, dataRoot, state) {
|
|
1666
|
+
const targetPath = statePath(dataRoot);
|
|
1667
|
+
const tempPath = `${targetPath}.tmp`;
|
|
1668
|
+
const directory = await fs.mkdir(dataRoot, { recursive: true });
|
|
1669
|
+
if (!directory.ok) {
|
|
1670
|
+
return directory;
|
|
1671
|
+
}
|
|
1672
|
+
const serialized = `${JSON.stringify(state, null, 2)}
|
|
1673
|
+
`;
|
|
1674
|
+
const written = await fs.write(tempPath, serialized);
|
|
1675
|
+
if (!written.ok) {
|
|
1676
|
+
await fs.remove(tempPath);
|
|
1677
|
+
return written;
|
|
1678
|
+
}
|
|
1679
|
+
await quarantineCorruptState(fs, targetPath);
|
|
1680
|
+
const renamed = await fs.rename(tempPath, targetPath);
|
|
1681
|
+
if (!renamed.ok) {
|
|
1682
|
+
await fs.remove(tempPath);
|
|
1683
|
+
return renamed;
|
|
1684
|
+
}
|
|
1685
|
+
return fsOk(void 0);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// src/core/skill-lookup.ts
|
|
1689
|
+
async function findLibrarySkill(fs, dataRoot, name) {
|
|
1690
|
+
const { state } = await loadState(fs, dataRoot);
|
|
1691
|
+
const skill = state.library.find((entry) => entry.name === name || entry.id === name);
|
|
1692
|
+
if (skill === void 0) {
|
|
1693
|
+
throw new SkillManagerError({
|
|
1694
|
+
code: "skill_not_found",
|
|
1695
|
+
message: `Skill "${name}" tidak ada di Library.`
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
return { skill, state };
|
|
1699
|
+
}
|
|
1700
|
+
async function resolveLibrarySkill(fs, dataRoot, name) {
|
|
1701
|
+
const { skill, state } = await findLibrarySkill(fs, dataRoot, name);
|
|
1702
|
+
const source = path2.join(libraryRoot(dataRoot), skill.dirName);
|
|
1703
|
+
const stat2 = await fs.stat(source);
|
|
1704
|
+
if (!stat2.ok || stat2.value.kind !== "directory") {
|
|
1705
|
+
throw new SkillManagerError({
|
|
1706
|
+
code: "skill_not_found",
|
|
1707
|
+
message: `Isi skill "${skill.name}" tidak ditemukan di Library (${source}).`,
|
|
1708
|
+
details: { path: source }
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return { skill, source, state };
|
|
1712
|
+
}
|
|
1713
|
+
function toPosix(relativePath) {
|
|
1714
|
+
return relativePath.split(path2.sep).join("/");
|
|
1715
|
+
}
|
|
1716
|
+
async function walkTree(fs, root, options = {}) {
|
|
1717
|
+
const rootStat = await fs.stat(root);
|
|
1718
|
+
if (!rootStat.ok) {
|
|
1719
|
+
return rootStat;
|
|
1720
|
+
}
|
|
1721
|
+
if (rootStat.value.kind !== "directory") {
|
|
1722
|
+
return fsErr("not_a_directory", root, "walkTree membutuhkan direktori.");
|
|
1723
|
+
}
|
|
1724
|
+
const entries = [];
|
|
1725
|
+
const state = { count: 0, bytes: 0 };
|
|
1726
|
+
const walked = await visit(fs, root, root, "", options, entries, state);
|
|
1727
|
+
if (!walked.ok) {
|
|
1728
|
+
return walked;
|
|
1729
|
+
}
|
|
1730
|
+
return fsOk(entries);
|
|
1731
|
+
}
|
|
1732
|
+
async function visit(fs, rootPath, directory, relative, options, entries, state) {
|
|
1733
|
+
const listing = await fs.readdir(directory);
|
|
1734
|
+
if (!listing.ok) {
|
|
1735
|
+
return listing;
|
|
1736
|
+
}
|
|
1737
|
+
for (const entry of listing.value) {
|
|
1738
|
+
const entryPath = path2.join(directory, entry.name);
|
|
1739
|
+
const relativePath = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
1740
|
+
const canonical = toPosix(relativePath);
|
|
1741
|
+
state.count += 1;
|
|
1742
|
+
if (options.maxEntries !== void 0 && state.count > options.maxEntries) {
|
|
1743
|
+
return fsErr("io_error", rootPath, `Jumlah entri melebihi batas ${options.maxEntries}.`);
|
|
1744
|
+
}
|
|
1745
|
+
const isSymlink = entry.isSymlink;
|
|
1746
|
+
const kind = entry.kind === "dangling" ? "symlink" : entry.kind;
|
|
1747
|
+
let size = 0;
|
|
1748
|
+
if (kind === "file") {
|
|
1749
|
+
const stat2 = await fs.stat(entryPath);
|
|
1750
|
+
if (!stat2.ok) {
|
|
1751
|
+
return stat2;
|
|
1752
|
+
}
|
|
1753
|
+
size = stat2.value.size;
|
|
1754
|
+
state.bytes += size;
|
|
1755
|
+
if (options.maxBytes !== void 0 && state.bytes > options.maxBytes) {
|
|
1756
|
+
return fsErr(
|
|
1757
|
+
"io_error",
|
|
1758
|
+
entryPath,
|
|
1759
|
+
`Ukuran total melebihi batas ${options.maxBytes} byte.`
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
entries.push({ path: entryPath, relativePath: canonical, kind, size, isSymlink });
|
|
1764
|
+
if (entry.kind === "directory" && !isSymlink) {
|
|
1765
|
+
const nested = await visit(fs, rootPath, entryPath, relativePath, options, entries, state);
|
|
1766
|
+
if (!nested.ok) {
|
|
1767
|
+
return nested;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
return fsOk(void 0);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/core/hash.ts
|
|
1775
|
+
var CONTENT_HASH_PREFIX = "sha256:";
|
|
1776
|
+
function sha256(data) {
|
|
1777
|
+
return createHash("sha256").update(data).digest("hex");
|
|
1778
|
+
}
|
|
1779
|
+
async function contentHash(fs, root) {
|
|
1780
|
+
const walked = await walkTree(fs, root);
|
|
1781
|
+
if (!walked.ok) {
|
|
1782
|
+
return walked;
|
|
1783
|
+
}
|
|
1784
|
+
const entries = [];
|
|
1785
|
+
for (const entry of walked.value) {
|
|
1786
|
+
if (entry.isSymlink) {
|
|
1787
|
+
const target = await fs.readlink(entry.path);
|
|
1788
|
+
if (!target.ok) {
|
|
1789
|
+
return fsErr(
|
|
1790
|
+
"unsafe_symlink",
|
|
1791
|
+
entry.path,
|
|
1792
|
+
"Tautan di dalam skill tidak bisa dibaca.",
|
|
1793
|
+
target.error
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
entries.push({ type: "symlink", path: entry.relativePath, target: target.value });
|
|
1797
|
+
continue;
|
|
1798
|
+
}
|
|
1799
|
+
if (entry.kind === "directory") {
|
|
1800
|
+
entries.push({ type: "directory", path: entry.relativePath });
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (entry.kind !== "file") {
|
|
1804
|
+
return fsErr("io_error", entry.path, "Jenis entri tidak didukung contentHash.");
|
|
1805
|
+
}
|
|
1806
|
+
const stat2 = await fs.stat(entry.path);
|
|
1807
|
+
if (!stat2.ok) {
|
|
1808
|
+
return stat2;
|
|
1809
|
+
}
|
|
1810
|
+
const data = await fs.read(entry.path);
|
|
1811
|
+
if (!data.ok) {
|
|
1812
|
+
return data;
|
|
1813
|
+
}
|
|
1814
|
+
entries.push({
|
|
1815
|
+
type: "file",
|
|
1816
|
+
path: entry.relativePath,
|
|
1817
|
+
executable: isExecutable(stat2.value),
|
|
1818
|
+
digest: sha256(data.value)
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1822
|
+
const document = JSON.stringify({ version: 1, entries });
|
|
1823
|
+
return fsOk(`${CONTENT_HASH_PREFIX}${sha256(document)}`);
|
|
1824
|
+
}
|
|
1825
|
+
var TEMP_PREFIX = ".skill-manager-tmp-";
|
|
1826
|
+
async function cleanup(fs, targetPath) {
|
|
1827
|
+
await fs.remove(targetPath, { recursive: true });
|
|
1828
|
+
}
|
|
1829
|
+
async function writeDirectoryAtomic(fs, targetPath, populate, options = {}) {
|
|
1830
|
+
const absoluteTarget = path2.resolve(targetPath);
|
|
1831
|
+
const parent = path2.dirname(absoluteTarget);
|
|
1832
|
+
const parentStat = await fs.stat(parent);
|
|
1833
|
+
if (!parentStat.ok) {
|
|
1834
|
+
return parentStat;
|
|
1835
|
+
}
|
|
1836
|
+
if (parentStat.value.kind !== "directory") {
|
|
1837
|
+
return fsErr("not_a_directory", parent, "Induk target atomic bukan direktori.");
|
|
1838
|
+
}
|
|
1839
|
+
if (await fs.exists(absoluteTarget)) {
|
|
1840
|
+
return fsErr("already_exists", absoluteTarget, "Target atomic sudah ada.");
|
|
1841
|
+
}
|
|
1842
|
+
const suffix = options.randomSuffix?.() ?? randomUUID();
|
|
1843
|
+
const tempDir = path2.join(parent, `${options.tempPrefix ?? TEMP_PREFIX}${suffix}`);
|
|
1844
|
+
const created = await fs.mkdir(tempDir);
|
|
1845
|
+
if (!created.ok) {
|
|
1846
|
+
return created;
|
|
1847
|
+
}
|
|
1848
|
+
let populated;
|
|
1849
|
+
try {
|
|
1850
|
+
populated = await populate(tempDir);
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
await cleanup(fs, tempDir);
|
|
1853
|
+
return fsErr("io_error", tempDir, "Gagal mengisi direktori sementara.", error);
|
|
1854
|
+
}
|
|
1855
|
+
if (!populated.ok) {
|
|
1856
|
+
await cleanup(fs, tempDir);
|
|
1857
|
+
return populated;
|
|
1858
|
+
}
|
|
1859
|
+
const renamed = await fs.rename(tempDir, absoluteTarget);
|
|
1860
|
+
if (!renamed.ok) {
|
|
1861
|
+
await cleanup(fs, tempDir);
|
|
1862
|
+
return renamed;
|
|
1863
|
+
}
|
|
1864
|
+
return fsOk(void 0);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// src/library/import-folder.ts
|
|
1868
|
+
var MAX_SOURCE_BYTES = 10 * 1024 * 1024;
|
|
1869
|
+
var MAX_SOURCE_ENTRIES = 1e3;
|
|
1870
|
+
var SKILL_FILE_NAME = "SKILL.md";
|
|
1871
|
+
var SKILL_ID_PREFIX = "sk_";
|
|
1872
|
+
var SAFE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
1873
|
+
var LICENSE_FILE_PATTERN = /^licen[cs]e(\.(md|txt))?$/i;
|
|
1874
|
+
var MAX_NAME_LENGTH = 100;
|
|
1875
|
+
function importError(code, message, details) {
|
|
1876
|
+
return new SkillManagerError({
|
|
1877
|
+
code,
|
|
1878
|
+
message,
|
|
1879
|
+
...details === void 0 ? {} : { details }
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
function createSkillId(nowMs = Date.now()) {
|
|
1883
|
+
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1884
|
+
let time = "";
|
|
1885
|
+
let remaining = nowMs;
|
|
1886
|
+
for (let index = 0; index < 10; index += 1) {
|
|
1887
|
+
time = `${crockford.charAt(remaining % 32)}${time}`;
|
|
1888
|
+
remaining = Math.floor(remaining / 32);
|
|
1889
|
+
}
|
|
1890
|
+
let randomness = "";
|
|
1891
|
+
for (const byte of randomBytes(16)) {
|
|
1892
|
+
randomness += crockford.charAt(byte % 32);
|
|
1893
|
+
}
|
|
1894
|
+
return `${SKILL_ID_PREFIX}${time}${randomness}`;
|
|
1895
|
+
}
|
|
1896
|
+
async function importFolderSkill(fs, dataRoot, options) {
|
|
1897
|
+
return importFromDirectory(fs, dataRoot, {
|
|
1898
|
+
sourceDir: options.sourceDir,
|
|
1899
|
+
provenance: { type: "folder", path: path2.resolve(options.sourceDir) },
|
|
1900
|
+
...options.newSkillId === void 0 ? {} : { newSkillId: options.newSkillId },
|
|
1901
|
+
...options.now === void 0 ? {} : { now: options.now }
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
async function importFromDirectory(fs, dataRoot, options) {
|
|
1905
|
+
const sourceDir = await resolveSourceDir(fs, options.sourceDir);
|
|
1906
|
+
const entries = await scanSource(fs, sourceDir);
|
|
1907
|
+
const frontmatter = await readFrontmatter(fs, sourceDir);
|
|
1908
|
+
const expectedName = options.name ?? path2.basename(sourceDir);
|
|
1909
|
+
const name = requireName(frontmatter, expectedName);
|
|
1910
|
+
const warnings = collectWarnings(frontmatter);
|
|
1911
|
+
const license = await detectLicense(fs, sourceDir, entries, frontmatter);
|
|
1912
|
+
const state = await readState(fs, dataRoot);
|
|
1913
|
+
assertNoConflict(state.library, name);
|
|
1914
|
+
const libraryDir = libraryRoot(dataRoot);
|
|
1915
|
+
const destination = path2.join(libraryDir, name);
|
|
1916
|
+
const contentHashValue = await copyIntoLibrary(fs, libraryDir, sourceDir, destination);
|
|
1917
|
+
const skill = {
|
|
1918
|
+
id: options.newSkillId?.() ?? createSkillId(),
|
|
1919
|
+
name,
|
|
1920
|
+
dirName: name,
|
|
1921
|
+
contentHash: contentHashValue,
|
|
1922
|
+
source: options.provenance,
|
|
1923
|
+
importedAt: options.now?.() ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1924
|
+
...license === void 0 ? {} : { license }
|
|
1925
|
+
};
|
|
1926
|
+
const saved = await saveState(fs, dataRoot, {
|
|
1927
|
+
...state,
|
|
1928
|
+
library: [...state.library, skill]
|
|
1929
|
+
});
|
|
1930
|
+
if (!saved.ok) {
|
|
1931
|
+
await fs.remove(destination, { recursive: true });
|
|
1932
|
+
throw importError(
|
|
1933
|
+
"import_failed",
|
|
1934
|
+
`Gagal menyimpan state setelah menyalin skill: ${saved.error.message}`,
|
|
1935
|
+
saved.error
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
return { skill, warnings };
|
|
1939
|
+
}
|
|
1940
|
+
async function resolveSourceDir(fs, sourceDir) {
|
|
1941
|
+
const real = await fs.realpath(sourceDir);
|
|
1942
|
+
if (!real.ok) {
|
|
1943
|
+
throw importError(
|
|
1944
|
+
"source_not_found",
|
|
1945
|
+
`Folder sumber tidak bisa dibaca: ${sourceDir} (${real.error.code}).`,
|
|
1946
|
+
real.error
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
const stat2 = await fs.stat(real.value);
|
|
1950
|
+
if (!stat2.ok) {
|
|
1951
|
+
throw importError(
|
|
1952
|
+
"source_not_found",
|
|
1953
|
+
`Folder sumber tidak bisa dibaca: ${sourceDir}.`,
|
|
1954
|
+
stat2.error
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
if (stat2.value.kind !== "directory") {
|
|
1958
|
+
throw importError("source_not_directory", `Sumber impor bukan folder: ${sourceDir}.`);
|
|
1959
|
+
}
|
|
1960
|
+
return real.value;
|
|
1961
|
+
}
|
|
1962
|
+
async function scanSource(fs, sourceDir) {
|
|
1963
|
+
const walked = await walkTree(fs, sourceDir, {
|
|
1964
|
+
maxEntries: MAX_SOURCE_ENTRIES,
|
|
1965
|
+
maxBytes: MAX_SOURCE_BYTES
|
|
1966
|
+
});
|
|
1967
|
+
if (walked.ok) {
|
|
1968
|
+
return walked.value;
|
|
1969
|
+
}
|
|
1970
|
+
if (walked.error.code !== "io_error") {
|
|
1971
|
+
throw importError(
|
|
1972
|
+
"source_unreadable",
|
|
1973
|
+
`Folder sumber tidak bisa ditelusuri: ${walked.error.message}`,
|
|
1974
|
+
walked.error
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
const counted = await walkTree(fs, sourceDir, { maxEntries: MAX_SOURCE_ENTRIES });
|
|
1978
|
+
if (!counted.ok) {
|
|
1979
|
+
throw importError(
|
|
1980
|
+
"source_too_many_files",
|
|
1981
|
+
`Folder sumber melebihi batas ${MAX_SOURCE_ENTRIES} entri. Impor ditolak.`
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
throw importError(
|
|
1985
|
+
"source_too_large",
|
|
1986
|
+
`Ukuran folder sumber melebihi batas ${MAX_SOURCE_BYTES} byte (10 MiB). Impor ditolak.`
|
|
1987
|
+
);
|
|
1988
|
+
}
|
|
1989
|
+
async function readFrontmatter(fs, sourceDir) {
|
|
1990
|
+
const skillFilePath = path2.join(sourceDir, SKILL_FILE_NAME);
|
|
1991
|
+
const text = await fs.readText(skillFilePath);
|
|
1992
|
+
if (!text.ok) {
|
|
1993
|
+
if (text.error.code === "not_found") {
|
|
1994
|
+
throw importError(
|
|
1995
|
+
"skill_md_missing",
|
|
1996
|
+
`Folder sumber tidak berisi ${SKILL_FILE_NAME}: ${skillFilePath}.`
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
throw importError(
|
|
2000
|
+
"source_unreadable",
|
|
2001
|
+
`${SKILL_FILE_NAME} tidak bisa dibaca: ${text.error.message}`,
|
|
2002
|
+
text.error
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
return parseFrontmatter(text.value);
|
|
2006
|
+
}
|
|
2007
|
+
function requireName(frontmatter, expectedName) {
|
|
2008
|
+
const raw = frontmatter.data.name;
|
|
2009
|
+
if (raw === void 0) {
|
|
2010
|
+
throw importError(
|
|
2011
|
+
"name_missing",
|
|
2012
|
+
`Frontmatter ${SKILL_FILE_NAME} tidak memiliki field "name".`
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
if (typeof raw !== "string") {
|
|
2016
|
+
throw importError(
|
|
2017
|
+
"frontmatter_invalid",
|
|
2018
|
+
`Field "name" pada ${SKILL_FILE_NAME} harus berupa teks, bukan blok bertingkat.`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
const name = raw.trim();
|
|
2022
|
+
if (name === "") {
|
|
2023
|
+
throw importError("name_missing", `Field "name" pada ${SKILL_FILE_NAME} kosong.`);
|
|
2024
|
+
}
|
|
2025
|
+
if (name.length > MAX_NAME_LENGTH || !SAFE_NAME_PATTERN.test(name) || name === "." || name === "..") {
|
|
2026
|
+
throw importError(
|
|
2027
|
+
"invalid_name",
|
|
2028
|
+
`Nama skill "${name}" tidak aman dipakai sebagai nama folder Library (hanya huruf, angka, ".", "_", "-").`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
if (name !== expectedName) {
|
|
2032
|
+
throw importError(
|
|
2033
|
+
"name_mismatch",
|
|
2034
|
+
`Nama skill di frontmatter ("${name}") tidak sama dengan nama folder sumber ("${expectedName}").`
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
return name;
|
|
2038
|
+
}
|
|
2039
|
+
function collectWarnings(frontmatter) {
|
|
2040
|
+
const official = OFFICIAL_FRONTMATTER_FIELDS;
|
|
2041
|
+
return Object.keys(frontmatter.data).filter((field) => !official.includes(field)).map(
|
|
2042
|
+
(field) => `Field frontmatter "${field}" di luar 6 field resmi; diterima, tetapi tidak dipakai v0.1.`
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
async function detectLicense(fs, sourceDir, entries, frontmatter) {
|
|
2046
|
+
const declared = frontmatter.data.license;
|
|
2047
|
+
if (typeof declared === "string" && declared.trim() !== "") {
|
|
2048
|
+
return declared.trim();
|
|
2049
|
+
}
|
|
2050
|
+
const licenseEntry = entries.find(
|
|
2051
|
+
(entry) => entry.kind === "file" && !entry.isSymlink && !entry.relativePath.includes("/") && LICENSE_FILE_PATTERN.test(entry.relativePath)
|
|
2052
|
+
);
|
|
2053
|
+
if (licenseEntry === void 0) {
|
|
2054
|
+
return void 0;
|
|
2055
|
+
}
|
|
2056
|
+
const text = await fs.readText(path2.join(sourceDir, licenseEntry.relativePath));
|
|
2057
|
+
if (!text.ok) {
|
|
2058
|
+
return void 0;
|
|
2059
|
+
}
|
|
2060
|
+
const firstLine = text.value.split(/\r?\n/).map((line) => line.trim()).find((line) => line !== "");
|
|
2061
|
+
return firstLine === void 0 || firstLine === "" ? void 0 : firstLine.slice(0, 200);
|
|
2062
|
+
}
|
|
2063
|
+
async function readState(fs, dataRoot) {
|
|
2064
|
+
const loaded = await loadState(fs, dataRoot);
|
|
2065
|
+
if (loaded.status === "unsupported_version") {
|
|
2066
|
+
throw importError(
|
|
2067
|
+
"state_unsupported_version",
|
|
2068
|
+
loaded.reason ?? "state.json dibuat versi aplikasi yang lebih baru."
|
|
2069
|
+
);
|
|
2070
|
+
}
|
|
2071
|
+
return loaded.state;
|
|
2072
|
+
}
|
|
2073
|
+
function conflictError(name, existingId) {
|
|
2074
|
+
return importError(
|
|
2075
|
+
"name_conflict",
|
|
2076
|
+
`Skill "${name}" sudah ada di Library. Impor ditolak; tidak ada penimpaan.`,
|
|
2077
|
+
existingId === void 0 ? void 0 : { existing: name, existingId }
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
function assertNoConflict(library, name) {
|
|
2081
|
+
const existing = library.find((skill) => skill.name === name || skill.dirName === name);
|
|
2082
|
+
if (existing !== void 0) {
|
|
2083
|
+
throw conflictError(name, existing.id);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
async function copyIntoLibrary(fs, libraryDir, sourceDir, destination) {
|
|
2087
|
+
const created = await fs.mkdir(libraryDir, { recursive: true });
|
|
2088
|
+
if (!created.ok) {
|
|
2089
|
+
throw importError(
|
|
2090
|
+
"import_failed",
|
|
2091
|
+
`Tidak bisa menyiapkan folder Library: ${created.error.message}`,
|
|
2092
|
+
created.error
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
const guarded = await guardStorePath(fs, libraryDir, destination);
|
|
2096
|
+
if (!guarded.ok) {
|
|
2097
|
+
throw importError(
|
|
2098
|
+
"import_failed",
|
|
2099
|
+
`Tujuan impor tidak aman: ${guarded.error.message}`,
|
|
2100
|
+
guarded.error
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
if (await fs.exists(destination)) {
|
|
2104
|
+
throw conflictError(path2.basename(destination));
|
|
2105
|
+
}
|
|
2106
|
+
const tempDir = path2.join(libraryDir, `${TEMP_PREFIX}${randomUUID()}`);
|
|
2107
|
+
const copied = await fs.copy(sourceDir, tempDir);
|
|
2108
|
+
if (!copied.ok) {
|
|
2109
|
+
await fs.remove(tempDir, { recursive: true });
|
|
2110
|
+
throw copyError(copied.error);
|
|
2111
|
+
}
|
|
2112
|
+
const hashed = await contentHash(fs, tempDir);
|
|
2113
|
+
if (!hashed.ok) {
|
|
2114
|
+
await fs.remove(tempDir, { recursive: true });
|
|
2115
|
+
throw importError(
|
|
2116
|
+
"import_failed",
|
|
2117
|
+
`Gagal menghitung hash skill: ${hashed.error.message}`,
|
|
2118
|
+
hashed.error
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
const renamed = await fs.rename(tempDir, destination);
|
|
2122
|
+
if (!renamed.ok) {
|
|
2123
|
+
await fs.remove(tempDir, { recursive: true });
|
|
2124
|
+
throw importError(
|
|
2125
|
+
"import_failed",
|
|
2126
|
+
`Gagal memindahkan skill ke Library: ${renamed.error.message}`,
|
|
2127
|
+
renamed.error
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
return hashed.value;
|
|
2131
|
+
}
|
|
2132
|
+
function copyError(error) {
|
|
2133
|
+
if (error.code === "unsafe_symlink") {
|
|
2134
|
+
return importError(
|
|
2135
|
+
"unsafe_symlink",
|
|
2136
|
+
`Tautan di dalam folder sumber menunjuk keluar folder sumber: ${error.path}. Impor ditolak.`,
|
|
2137
|
+
error
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
if (error.code === "already_exists") {
|
|
2141
|
+
return importError("name_conflict", "Tujuan impor sudah ada di Library; impor ditolak.", error);
|
|
2142
|
+
}
|
|
2143
|
+
return importError("import_failed", `Gagal menyalin skill ke Library: ${error.message}`, error);
|
|
2144
|
+
}
|
|
2145
|
+
var LICENSE_FILE_PATTERN2 = /^licen[cs]e(\.(md|txt))?$/i;
|
|
2146
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2147
|
+
"a",
|
|
2148
|
+
"bin",
|
|
2149
|
+
"class",
|
|
2150
|
+
"dll",
|
|
2151
|
+
"dylib",
|
|
2152
|
+
"exe",
|
|
2153
|
+
"gif",
|
|
2154
|
+
"gz",
|
|
2155
|
+
"ico",
|
|
2156
|
+
"jar",
|
|
2157
|
+
"jpeg",
|
|
2158
|
+
"jpg",
|
|
2159
|
+
"mov",
|
|
2160
|
+
"mp3",
|
|
2161
|
+
"mp4",
|
|
2162
|
+
"node",
|
|
2163
|
+
"o",
|
|
2164
|
+
"otf",
|
|
2165
|
+
"pdf",
|
|
2166
|
+
"png",
|
|
2167
|
+
"so",
|
|
2168
|
+
"tar",
|
|
2169
|
+
"ttf",
|
|
2170
|
+
"wasm",
|
|
2171
|
+
"webp",
|
|
2172
|
+
"woff",
|
|
2173
|
+
"woff2",
|
|
2174
|
+
"zip",
|
|
2175
|
+
"7z"
|
|
2176
|
+
]);
|
|
2177
|
+
function inspectError(code, message, details) {
|
|
2178
|
+
return new SkillManagerError({ code, message, ...details === void 0 ? {} : { details } });
|
|
2179
|
+
}
|
|
2180
|
+
async function scanSource2(fs, sourceDir) {
|
|
2181
|
+
const walked = await walkTree(fs, sourceDir, {
|
|
2182
|
+
maxEntries: MAX_SOURCE_ENTRIES,
|
|
2183
|
+
maxBytes: MAX_SOURCE_BYTES
|
|
2184
|
+
});
|
|
2185
|
+
if (walked.ok) {
|
|
2186
|
+
return walked.value;
|
|
2187
|
+
}
|
|
2188
|
+
if (walked.error.code !== "io_error") {
|
|
2189
|
+
throw inspectError(
|
|
2190
|
+
"source_unreadable",
|
|
2191
|
+
`Folder sumber tidak bisa ditelusuri: ${walked.error.message}`,
|
|
2192
|
+
walked.error
|
|
2193
|
+
);
|
|
2194
|
+
}
|
|
2195
|
+
const counted = await walkTree(fs, sourceDir, { maxEntries: MAX_SOURCE_ENTRIES });
|
|
2196
|
+
if (!counted.ok) {
|
|
2197
|
+
throw inspectError(
|
|
2198
|
+
"source_too_many_files",
|
|
2199
|
+
`Folder sumber melebihi batas ${MAX_SOURCE_ENTRIES} entri. Impor akan ditolak.`
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
throw inspectError(
|
|
2203
|
+
"source_too_large",
|
|
2204
|
+
`Ukuran folder sumber melebihi batas ${MAX_SOURCE_BYTES} byte (10 MiB). Impor akan ditolak.`
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
function buildTree(entries) {
|
|
2208
|
+
const root = [];
|
|
2209
|
+
const childrenByPath = /* @__PURE__ */ new Map([["", root]]);
|
|
2210
|
+
for (const entry of entries) {
|
|
2211
|
+
const separator = entry.relativePath.lastIndexOf("/");
|
|
2212
|
+
const parentPath = separator === -1 ? "" : entry.relativePath.slice(0, separator);
|
|
2213
|
+
const name = separator === -1 ? entry.relativePath : entry.relativePath.slice(separator + 1);
|
|
2214
|
+
const children = entry.kind === "directory" ? [] : void 0;
|
|
2215
|
+
const node = {
|
|
2216
|
+
name,
|
|
2217
|
+
path: entry.relativePath,
|
|
2218
|
+
// readdir mengikuti tautan, jadi tautan ke file/direktori tetap dilaporkan
|
|
2219
|
+
// sebagai symlink agar UI tidak menampilkan targetnya sebagai isi skill.
|
|
2220
|
+
kind: entry.isSymlink ? "symlink" : entry.kind,
|
|
2221
|
+
size: entry.size,
|
|
2222
|
+
...children === void 0 ? {} : { children }
|
|
2223
|
+
};
|
|
2224
|
+
const siblings = childrenByPath.get(parentPath) ?? root;
|
|
2225
|
+
siblings.push(node);
|
|
2226
|
+
if (children !== void 0) {
|
|
2227
|
+
childrenByPath.set(entry.relativePath, children);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return root;
|
|
2231
|
+
}
|
|
2232
|
+
function isBinaryFile(entry) {
|
|
2233
|
+
if (entry.kind !== "file") {
|
|
2234
|
+
return false;
|
|
2235
|
+
}
|
|
2236
|
+
const extension = path2.posix.extname(entry.relativePath).slice(1).toLowerCase();
|
|
2237
|
+
return BINARY_EXTENSIONS.has(extension);
|
|
2238
|
+
}
|
|
2239
|
+
async function escapesRoot(fs, rootLexical, rootReal, entry) {
|
|
2240
|
+
const link = await fs.readlink(entry.path);
|
|
2241
|
+
if (!link.ok) {
|
|
2242
|
+
return true;
|
|
2243
|
+
}
|
|
2244
|
+
const target = path2.resolve(path2.dirname(entry.path), link.value);
|
|
2245
|
+
if (!isPathInside(rootLexical, target)) {
|
|
2246
|
+
return true;
|
|
2247
|
+
}
|
|
2248
|
+
const real = await fs.realpath(target);
|
|
2249
|
+
if (real.ok) {
|
|
2250
|
+
return !isPathInside(rootReal, real.value);
|
|
2251
|
+
}
|
|
2252
|
+
return false;
|
|
2253
|
+
}
|
|
2254
|
+
function licenseFromEntries(entries) {
|
|
2255
|
+
return entries.find(
|
|
2256
|
+
(entry) => entry.kind === "file" && !entry.isSymlink && !entry.relativePath.includes("/") && LICENSE_FILE_PATTERN2.test(entry.relativePath)
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
async function detectLicense2(fs, sourceDir, entries) {
|
|
2260
|
+
const skillText = await fs.readText(path2.join(sourceDir, SKILL_FILE_NAME));
|
|
2261
|
+
if (skillText.ok) {
|
|
2262
|
+
try {
|
|
2263
|
+
const declared = parseFrontmatter(skillText.value).data.license;
|
|
2264
|
+
if (typeof declared === "string" && declared.trim() !== "") {
|
|
2265
|
+
return declared.trim();
|
|
2266
|
+
}
|
|
2267
|
+
} catch {
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
const licenseEntry = licenseFromEntries(entries);
|
|
2271
|
+
if (licenseEntry === void 0) {
|
|
2272
|
+
return void 0;
|
|
2273
|
+
}
|
|
2274
|
+
const text = await fs.readText(licenseEntry.path);
|
|
2275
|
+
if (!text.ok) {
|
|
2276
|
+
return void 0;
|
|
2277
|
+
}
|
|
2278
|
+
const firstLine = text.value.split(/\r?\n/).map((line) => line.trim()).find((line) => line !== "");
|
|
2279
|
+
return firstLine === void 0 || firstLine === "" ? void 0 : firstLine.slice(0, 200);
|
|
2280
|
+
}
|
|
2281
|
+
async function inspectSource(fs, sourceDir) {
|
|
2282
|
+
const lexicalRoot = path2.resolve(sourceDir);
|
|
2283
|
+
const stat2 = await fs.stat(lexicalRoot);
|
|
2284
|
+
if (!stat2.ok) {
|
|
2285
|
+
throw inspectError(
|
|
2286
|
+
"source_not_found",
|
|
2287
|
+
`Folder sumber tidak bisa dibaca: ${sourceDir} (${stat2.error.code}).`,
|
|
2288
|
+
stat2.error
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
if (stat2.value.kind !== "directory") {
|
|
2292
|
+
throw inspectError("source_not_directory", `Sumber impor bukan folder: ${sourceDir}.`);
|
|
2293
|
+
}
|
|
2294
|
+
const real = await fs.realpath(lexicalRoot);
|
|
2295
|
+
const realRoot = real.ok ? real.value : lexicalRoot;
|
|
2296
|
+
const entries = await scanSource2(fs, lexicalRoot);
|
|
2297
|
+
const hasScriptsDir = entries.some(
|
|
2298
|
+
(entry) => entry.kind === "directory" && !entry.isSymlink && path2.posix.basename(entry.relativePath).toLowerCase() === "scripts"
|
|
2299
|
+
);
|
|
2300
|
+
const hasBinary = entries.some(isBinaryFile);
|
|
2301
|
+
const hasEscapingSymlink = (await Promise.all(
|
|
2302
|
+
entries.filter((entry) => entry.isSymlink).map((entry) => escapesRoot(fs, lexicalRoot, realRoot, entry))
|
|
2303
|
+
)).some(Boolean);
|
|
2304
|
+
const fileEntries = entries.filter((entry) => entry.kind === "file" && !entry.isSymlink);
|
|
2305
|
+
const totalBytes = fileEntries.reduce((total, entry) => total + entry.size, 0);
|
|
2306
|
+
const license = await detectLicense2(fs, lexicalRoot, entries);
|
|
2307
|
+
const risks = [];
|
|
2308
|
+
if (hasScriptsDir) {
|
|
2309
|
+
risks.push(
|
|
2310
|
+
"Folder berisi direktori scripts/; isinya tidak dieksekusi saat impor, tetapi periksa sebelum dipakai."
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
if (hasBinary) {
|
|
2314
|
+
risks.push("Folder berisi berkas biner; hanya isi teks yang bisa diperiksa otomatis.");
|
|
2315
|
+
}
|
|
2316
|
+
if (hasEscapingSymlink) {
|
|
2317
|
+
risks.push("Ada tautan yang menunjuk keluar folder skill; impor folder akan menolaknya.");
|
|
2318
|
+
}
|
|
2319
|
+
return {
|
|
2320
|
+
files: buildTree(entries),
|
|
2321
|
+
hasScriptsDir,
|
|
2322
|
+
hasBinary,
|
|
2323
|
+
hasEscapingSymlink,
|
|
2324
|
+
totalBytes,
|
|
2325
|
+
fileCount: fileEntries.length,
|
|
2326
|
+
...license === void 0 ? {} : { license },
|
|
2327
|
+
risks
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
function sourceError(code, message, details) {
|
|
2331
|
+
return new SkillManagerError({ code, message, ...details === void 0 ? {} : { details } });
|
|
2332
|
+
}
|
|
2333
|
+
async function hasSkillFile(fs, dir) {
|
|
2334
|
+
const stat2 = await fs.stat(path2.join(dir, SKILL_FILE_NAME));
|
|
2335
|
+
return stat2.ok && stat2.value.kind === "file";
|
|
2336
|
+
}
|
|
2337
|
+
async function findSkillDirectory(fs, root) {
|
|
2338
|
+
if (await hasSkillFile(fs, root)) {
|
|
2339
|
+
return root;
|
|
2340
|
+
}
|
|
2341
|
+
const listing = await fs.readdir(root);
|
|
2342
|
+
if (!listing.ok) {
|
|
2343
|
+
throw sourceError(
|
|
2344
|
+
"source_unreadable",
|
|
2345
|
+
`Folder hasil unduhan tidak bisa dibaca: ${root} (${listing.error.code}).`,
|
|
2346
|
+
listing.error
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
const candidates = [];
|
|
2350
|
+
for (const entry of listing.value) {
|
|
2351
|
+
if (entry.kind !== "directory" || entry.isSymlink) {
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
const candidate = path2.join(root, entry.name);
|
|
2355
|
+
if (await hasSkillFile(fs, candidate)) {
|
|
2356
|
+
candidates.push(candidate);
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
if (candidates.length === 0) {
|
|
2360
|
+
throw sourceError(
|
|
2361
|
+
"skill_md_missing",
|
|
2362
|
+
`Tidak ada ${SKILL_FILE_NAME} di root atau di satu subdirektori sumber impor.`
|
|
2363
|
+
);
|
|
2364
|
+
}
|
|
2365
|
+
if (candidates.length > 1) {
|
|
2366
|
+
const names = candidates.map((candidate) => path2.basename(candidate)).join(", ");
|
|
2367
|
+
throw sourceError(
|
|
2368
|
+
"skill_md_ambiguous",
|
|
2369
|
+
`Ditemukan lebih dari satu kandidat skill (${names}); impor membutuhkan tepat satu ${SKILL_FILE_NAME}.`,
|
|
2370
|
+
{ candidates: names }
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
return candidates[0];
|
|
2374
|
+
}
|
|
2375
|
+
async function readSkillName(fs, skillDir) {
|
|
2376
|
+
const text = await fs.readText(path2.join(skillDir, SKILL_FILE_NAME));
|
|
2377
|
+
if (!text.ok) {
|
|
2378
|
+
throw sourceError(
|
|
2379
|
+
"source_unreadable",
|
|
2380
|
+
`${SKILL_FILE_NAME} tidak bisa dibaca: ${text.error.message}`,
|
|
2381
|
+
text.error
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
const frontmatter = parseFrontmatter(text.value);
|
|
2385
|
+
const raw = frontmatter.data.name;
|
|
2386
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
2387
|
+
throw sourceError(
|
|
2388
|
+
"name_missing",
|
|
2389
|
+
`Frontmatter ${SKILL_FILE_NAME} tidak memiliki field "name" yang bisa dipakai.`
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
return raw.trim();
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// src/library/import-git.ts
|
|
2396
|
+
var GIT_BINARY = "git";
|
|
2397
|
+
var GIT_CLONE_TIMEOUT_MS = 12e4;
|
|
2398
|
+
var GIT_COMMAND_TIMEOUT_MS = 6e4;
|
|
2399
|
+
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["https:", "http:"]);
|
|
2400
|
+
var SHA_PATTERN = /^[0-9a-f]{7,64}$/i;
|
|
2401
|
+
var COMMIT_PATTERN = /^[0-9a-f]{7,64}$/;
|
|
2402
|
+
var STDERR_EXCERPT_LENGTH = 400;
|
|
2403
|
+
function gitError(code, message, details) {
|
|
2404
|
+
return new SkillManagerError({ code, message, ...details === void 0 ? {} : { details } });
|
|
2405
|
+
}
|
|
2406
|
+
function validateGitUrl(raw) {
|
|
2407
|
+
const candidate = raw.trim();
|
|
2408
|
+
let parsed;
|
|
2409
|
+
try {
|
|
2410
|
+
parsed = new URL(candidate);
|
|
2411
|
+
} catch {
|
|
2412
|
+
throw gitError(
|
|
2413
|
+
"invalid_git_url",
|
|
2414
|
+
`URL git tidak valid: "${raw}". Gunakan URL https:// atau http://.`
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol) || parsed.hostname === "") {
|
|
2418
|
+
throw gitError(
|
|
2419
|
+
"invalid_git_url",
|
|
2420
|
+
`Skema URL git "${parsed.protocol}" tidak didukung. v0.1 hanya menerima https:// dan http:// (tanpa SSH/file).`
|
|
2421
|
+
);
|
|
2422
|
+
}
|
|
2423
|
+
return candidate;
|
|
2424
|
+
}
|
|
2425
|
+
function resolveRef(raw) {
|
|
2426
|
+
const ref = raw?.trim();
|
|
2427
|
+
if (ref === void 0 || ref === "") {
|
|
2428
|
+
return "HEAD";
|
|
2429
|
+
}
|
|
2430
|
+
if (ref.startsWith("-") || /\s/.test(ref)) {
|
|
2431
|
+
throw gitError("invalid_git_ref", `Ref git tidak valid: "${ref}".`);
|
|
2432
|
+
}
|
|
2433
|
+
return ref;
|
|
2434
|
+
}
|
|
2435
|
+
function stderrExcerpt(result) {
|
|
2436
|
+
const text = result.stderr.trim() || result.stdout.trim();
|
|
2437
|
+
return text === "" ? "(tanpa keluaran)" : text.slice(0, STDERR_EXCERPT_LENGTH);
|
|
2438
|
+
}
|
|
2439
|
+
async function runGit(runner, args, cwd, timeoutMs) {
|
|
2440
|
+
const result = await runner.run(
|
|
2441
|
+
GIT_BINARY,
|
|
2442
|
+
args,
|
|
2443
|
+
cwd === void 0 ? { timeoutMs } : { cwd, timeoutMs }
|
|
2444
|
+
);
|
|
2445
|
+
if (!result.ok) {
|
|
2446
|
+
if (result.error.code === "process_not_found") {
|
|
2447
|
+
throw gitError(
|
|
2448
|
+
"git_not_available",
|
|
2449
|
+
"Git tidak ditemukan di PATH. Pasang Git lalu ulangi impor.",
|
|
2450
|
+
result.error
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
throw gitError(
|
|
2454
|
+
"git_clone_failed",
|
|
2455
|
+
`Gagal menjalankan git: ${result.error.message}`,
|
|
2456
|
+
result.error
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
return result.value;
|
|
2460
|
+
}
|
|
2461
|
+
function cloneArgs(url, ref, destination) {
|
|
2462
|
+
const base = ["clone", "--depth", "1"];
|
|
2463
|
+
if (ref !== "HEAD" && !SHA_PATTERN.test(ref)) {
|
|
2464
|
+
base.push("--branch", ref);
|
|
2465
|
+
}
|
|
2466
|
+
base.push(url, destination);
|
|
2467
|
+
return base;
|
|
2468
|
+
}
|
|
2469
|
+
async function checkoutCommit(runner, tempDir, sha) {
|
|
2470
|
+
const direct = await runGit(runner, ["checkout", sha], tempDir, GIT_COMMAND_TIMEOUT_MS);
|
|
2471
|
+
if (direct.code === 0) {
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const fetched = await runGit(
|
|
2475
|
+
runner,
|
|
2476
|
+
["fetch", "--depth", "1", "origin", sha],
|
|
2477
|
+
tempDir,
|
|
2478
|
+
GIT_COMMAND_TIMEOUT_MS
|
|
2479
|
+
);
|
|
2480
|
+
if (fetched.code !== 0) {
|
|
2481
|
+
throw gitError(
|
|
2482
|
+
"git_ref_not_found",
|
|
2483
|
+
`Commit "${sha}" tidak ditemukan di repositori. Periksa ref lalu ulangi impor.`
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
const retried = await runGit(runner, ["checkout", "FETCH_HEAD"], tempDir, GIT_COMMAND_TIMEOUT_MS);
|
|
2487
|
+
if (retried.code !== 0) {
|
|
2488
|
+
throw gitError("git_ref_not_found", `Commit "${sha}" tidak bisa di-checkout.`);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
async function cloneGitTo(runner, options, destination) {
|
|
2492
|
+
const url = validateGitUrl(options.url);
|
|
2493
|
+
const ref = resolveRef(options.ref);
|
|
2494
|
+
const clone = await runGit(
|
|
2495
|
+
runner,
|
|
2496
|
+
cloneArgs(url, ref, destination),
|
|
2497
|
+
void 0,
|
|
2498
|
+
GIT_CLONE_TIMEOUT_MS
|
|
2499
|
+
);
|
|
2500
|
+
if (clone.timedOut) {
|
|
2501
|
+
throw gitError(
|
|
2502
|
+
"git_timeout",
|
|
2503
|
+
`git clone melewati batas waktu ${GIT_CLONE_TIMEOUT_MS} ms. Periksa jaringan lalu ulangi.`
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
if (clone.code !== 0) {
|
|
2507
|
+
throw gitError(
|
|
2508
|
+
"git_clone_failed",
|
|
2509
|
+
`git clone gagal (exit ${clone.code ?? "null"}): ${stderrExcerpt(clone)}.`
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
if (ref !== "HEAD" && SHA_PATTERN.test(ref)) {
|
|
2513
|
+
await checkoutCommit(runner, destination, ref);
|
|
2514
|
+
}
|
|
2515
|
+
const resolved = await runGit(runner, ["rev-parse", "HEAD"], destination, GIT_COMMAND_TIMEOUT_MS);
|
|
2516
|
+
const commit = resolved.stdout.trim();
|
|
2517
|
+
if (resolved.code !== 0 || !COMMIT_PATTERN.test(commit)) {
|
|
2518
|
+
throw gitError(
|
|
2519
|
+
"git_clone_failed",
|
|
2520
|
+
`Commit HEAD tidak bisa dibaca dari hasil clone: ${stderrExcerpt(resolved)}.`
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
return { url, ref, commit };
|
|
2524
|
+
}
|
|
2525
|
+
var EOCD_SIGNATURE = 101010256;
|
|
2526
|
+
var CENTRAL_SIGNATURE = 33639248;
|
|
2527
|
+
var LOCAL_SIGNATURE = 67324752;
|
|
2528
|
+
var EOCD_MIN_SIZE = 22;
|
|
2529
|
+
var CENTRAL_MIN_SIZE = 46;
|
|
2530
|
+
var LOCAL_MIN_SIZE = 30;
|
|
2531
|
+
var MAX_COMMENT_LENGTH = 65535;
|
|
2532
|
+
var ZIP64_SENTINEL = 4294967295;
|
|
2533
|
+
var ZIP64_ENTRY_COUNT = 65535;
|
|
2534
|
+
var FILE_TYPE_MASK = 61440;
|
|
2535
|
+
var REGULAR_FILE = 32768;
|
|
2536
|
+
var DIRECTORY = 16384;
|
|
2537
|
+
var SYMLINK = 40960;
|
|
2538
|
+
var DEFAULT_FILE_MODE = 420;
|
|
2539
|
+
function zipError(code, message, details) {
|
|
2540
|
+
return new SkillManagerError({ code, message, ...details === void 0 ? {} : { details } });
|
|
2541
|
+
}
|
|
2542
|
+
function invalid2(message) {
|
|
2543
|
+
return zipError("zip_invalid", message);
|
|
2544
|
+
}
|
|
2545
|
+
function readU16(bytes, offset) {
|
|
2546
|
+
return bytes[offset] | bytes[offset + 1] << 8;
|
|
2547
|
+
}
|
|
2548
|
+
function readU32(bytes, offset) {
|
|
2549
|
+
return (bytes[offset] | bytes[offset + 1] << 8 | bytes[offset + 2] << 16 | bytes[offset + 3] << 24) >>> 0;
|
|
2550
|
+
}
|
|
2551
|
+
function findEndOfCentralDirectory(bytes) {
|
|
2552
|
+
if (bytes.length < EOCD_MIN_SIZE) {
|
|
2553
|
+
throw invalid2("Berkas terlalu kecil untuk sebuah arsip zip.");
|
|
2554
|
+
}
|
|
2555
|
+
const earliest = Math.max(0, bytes.length - EOCD_MIN_SIZE - MAX_COMMENT_LENGTH);
|
|
2556
|
+
for (let offset = bytes.length - EOCD_MIN_SIZE; offset >= earliest; offset -= 1) {
|
|
2557
|
+
if (readU32(bytes, offset) !== EOCD_SIGNATURE) {
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2560
|
+
const commentLength = readU16(bytes, offset + 20);
|
|
2561
|
+
if (offset + EOCD_MIN_SIZE + commentLength <= bytes.length) {
|
|
2562
|
+
return offset;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
throw invalid2("Tidak menemukan End of Central Directory; berkas bukan zip yang valid.");
|
|
2566
|
+
}
|
|
2567
|
+
function decodeEntryName(raw) {
|
|
2568
|
+
try {
|
|
2569
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
2570
|
+
} catch {
|
|
2571
|
+
throw zipError(
|
|
2572
|
+
"zip_unsupported",
|
|
2573
|
+
"Nama entri zip bukan UTF-8; arsip dengan nama CP437 tidak didukung."
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
function normalizeEntryName(raw) {
|
|
2578
|
+
if (raw === "") {
|
|
2579
|
+
throw invalid2("Arsip berisi entri tanpa nama.");
|
|
2580
|
+
}
|
|
2581
|
+
if (raw.includes("\0")) {
|
|
2582
|
+
throw invalid2("Nama entri zip mengandung byte NUL.");
|
|
2583
|
+
}
|
|
2584
|
+
if (raw.includes("\\")) {
|
|
2585
|
+
throw zipError(
|
|
2586
|
+
"unsafe_zip_entry",
|
|
2587
|
+
`Entri zip memakai pemisah Windows: "${raw}". Hanya "/" yang diterima.`
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw)) {
|
|
2591
|
+
throw zipError("unsafe_zip_entry", `Entri zip memakai path absolut: "${raw}". Impor ditolak.`);
|
|
2592
|
+
}
|
|
2593
|
+
const segments = raw.split("/");
|
|
2594
|
+
if (segments.some((segment) => segment === "..")) {
|
|
2595
|
+
throw zipError(
|
|
2596
|
+
"unsafe_zip_entry",
|
|
2597
|
+
`Entri zip menunjuk keluar folder tujuan: "${raw}". Impor ditolak.`
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
const normalized = path2.posix.normalize(raw);
|
|
2601
|
+
if (normalized === "." || normalized === "") {
|
|
2602
|
+
return "";
|
|
2603
|
+
}
|
|
2604
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) {
|
|
2605
|
+
throw zipError(
|
|
2606
|
+
"unsafe_zip_entry",
|
|
2607
|
+
`Entri zip menunjuk keluar folder tujuan: "${raw}". Impor ditolak.`
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
return normalized;
|
|
2611
|
+
}
|
|
2612
|
+
function assertSingleDisk(bytes, eocd) {
|
|
2613
|
+
const diskNumber = readU16(bytes, eocd + 4);
|
|
2614
|
+
const cdStartDisk = readU16(bytes, eocd + 6);
|
|
2615
|
+
const entriesOnDisk = readU16(bytes, eocd + 8);
|
|
2616
|
+
const entriesTotal = readU16(bytes, eocd + 10);
|
|
2617
|
+
const cdSize = readU32(bytes, eocd + 12);
|
|
2618
|
+
const cdOffset = readU32(bytes, eocd + 16);
|
|
2619
|
+
if (diskNumber !== 0 || cdStartDisk !== 0 || entriesOnDisk !== entriesTotal) {
|
|
2620
|
+
throw zipError("zip_unsupported", "Arsip zip multi-disk tidak didukung.");
|
|
2621
|
+
}
|
|
2622
|
+
if (entriesTotal === ZIP64_ENTRY_COUNT || cdSize === ZIP64_SENTINEL || cdOffset === ZIP64_SENTINEL) {
|
|
2623
|
+
throw zipError("zip_unsupported", "Arsip zip64 tidak didukung v0.1.");
|
|
2624
|
+
}
|
|
2625
|
+
if (cdOffset + cdSize > bytes.length) {
|
|
2626
|
+
throw invalid2("Central directory zip melewati akhir berkas.");
|
|
2627
|
+
}
|
|
2628
|
+
return { entriesTotal, cdSize, cdOffset };
|
|
2629
|
+
}
|
|
2630
|
+
function parseZipEntries(bytes) {
|
|
2631
|
+
const eocd = findEndOfCentralDirectory(bytes);
|
|
2632
|
+
const { entriesTotal, cdSize, cdOffset } = assertSingleDisk(bytes, eocd);
|
|
2633
|
+
const entries = [];
|
|
2634
|
+
const fileNames = /* @__PURE__ */ new Set();
|
|
2635
|
+
let cursor = cdOffset;
|
|
2636
|
+
for (let index = 0; index < entriesTotal; index += 1) {
|
|
2637
|
+
if (cursor + CENTRAL_MIN_SIZE > bytes.length) {
|
|
2638
|
+
throw invalid2("Entri central directory terpotong.");
|
|
2639
|
+
}
|
|
2640
|
+
if (readU32(bytes, cursor) !== CENTRAL_SIGNATURE) {
|
|
2641
|
+
throw invalid2("Tanda tangan central directory tidak dikenal.");
|
|
2642
|
+
}
|
|
2643
|
+
const flags = readU16(bytes, cursor + 8);
|
|
2644
|
+
const method = readU16(bytes, cursor + 10);
|
|
2645
|
+
const crc = readU32(bytes, cursor + 16);
|
|
2646
|
+
const compressedSize = readU32(bytes, cursor + 20);
|
|
2647
|
+
const uncompressedSize = readU32(bytes, cursor + 24);
|
|
2648
|
+
const nameLength = readU16(bytes, cursor + 28);
|
|
2649
|
+
const extraLength = readU16(bytes, cursor + 30);
|
|
2650
|
+
const commentLength = readU16(bytes, cursor + 32);
|
|
2651
|
+
const externalAttributes = readU32(bytes, cursor + 38);
|
|
2652
|
+
const localOffset = readU32(bytes, cursor + 42);
|
|
2653
|
+
const next = cursor + CENTRAL_MIN_SIZE + nameLength + extraLength + commentLength;
|
|
2654
|
+
if (next > cdOffset + cdSize || next > bytes.length) {
|
|
2655
|
+
throw invalid2("Central directory zip melewati batas arsip.");
|
|
2656
|
+
}
|
|
2657
|
+
if ((flags & 1) !== 0) {
|
|
2658
|
+
throw zipError("zip_unsupported", "Arsip zip terenkripsi tidak didukung.");
|
|
2659
|
+
}
|
|
2660
|
+
if (compressedSize === ZIP64_SENTINEL || uncompressedSize === ZIP64_SENTINEL || localOffset === ZIP64_SENTINEL || nameLength === ZIP64_ENTRY_COUNT) {
|
|
2661
|
+
throw zipError("zip_unsupported", "Entri zip64 tidak didukung v0.1.");
|
|
2662
|
+
}
|
|
2663
|
+
const rawName = decodeEntryName(
|
|
2664
|
+
bytes.subarray(cursor + CENTRAL_MIN_SIZE, cursor + CENTRAL_MIN_SIZE + nameLength)
|
|
2665
|
+
);
|
|
2666
|
+
const name = normalizeEntryName(rawName);
|
|
2667
|
+
const unixMode = externalAttributes >>> 16 & 65535;
|
|
2668
|
+
const fileType = unixMode & FILE_TYPE_MASK;
|
|
2669
|
+
const isDirectory = rawName.endsWith("/") || (externalAttributes & 16) !== 0 || fileType === DIRECTORY;
|
|
2670
|
+
if (fileType === SYMLINK) {
|
|
2671
|
+
throw zipError("unsafe_symlink", `Entri zip adalah tautan ("${rawName}"). Impor ditolak.`);
|
|
2672
|
+
}
|
|
2673
|
+
if (fileType !== 0 && fileType !== REGULAR_FILE && fileType !== DIRECTORY) {
|
|
2674
|
+
throw zipError("zip_unsupported", `Entri zip bertipe khusus ("${rawName}") tidak didukung.`);
|
|
2675
|
+
}
|
|
2676
|
+
if (name === "") {
|
|
2677
|
+
cursor = next;
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
if (!isDirectory) {
|
|
2681
|
+
if (fileNames.has(name)) {
|
|
2682
|
+
throw invalid2(`Arsip berisi entri ganda: "${name}".`);
|
|
2683
|
+
}
|
|
2684
|
+
fileNames.add(name);
|
|
2685
|
+
}
|
|
2686
|
+
entries.push({
|
|
2687
|
+
name,
|
|
2688
|
+
method,
|
|
2689
|
+
compressedSize,
|
|
2690
|
+
uncompressedSize,
|
|
2691
|
+
crc,
|
|
2692
|
+
localOffset,
|
|
2693
|
+
permissions: unixMode & 511,
|
|
2694
|
+
isDirectory
|
|
2695
|
+
});
|
|
2696
|
+
cursor = next;
|
|
2697
|
+
}
|
|
2698
|
+
return entries;
|
|
2699
|
+
}
|
|
2700
|
+
function assertLimits(archiveSize, entries) {
|
|
2701
|
+
if (archiveSize > MAX_SOURCE_BYTES) {
|
|
2702
|
+
throw zipError(
|
|
2703
|
+
"source_too_large",
|
|
2704
|
+
`Berkas zip melebihi batas ${MAX_SOURCE_BYTES} byte (10 MiB). Impor ditolak.`
|
|
2705
|
+
);
|
|
2706
|
+
}
|
|
2707
|
+
if (entries.length > MAX_SOURCE_ENTRIES) {
|
|
2708
|
+
throw zipError(
|
|
2709
|
+
"source_too_many_files",
|
|
2710
|
+
`Arsip zip melebihi batas ${MAX_SOURCE_ENTRIES} entri. Impor ditolak.`
|
|
2711
|
+
);
|
|
2712
|
+
}
|
|
2713
|
+
let total = 0;
|
|
2714
|
+
for (const entry of entries) {
|
|
2715
|
+
if (entry.isDirectory) {
|
|
2716
|
+
continue;
|
|
2717
|
+
}
|
|
2718
|
+
total += entry.uncompressedSize;
|
|
2719
|
+
if (total > MAX_SOURCE_BYTES) {
|
|
2720
|
+
throw zipError(
|
|
2721
|
+
"source_too_large",
|
|
2722
|
+
`Isi zip melebihi batas ${MAX_SOURCE_BYTES} byte (10 MiB) setelah diekstrak. Impor ditolak.`
|
|
2723
|
+
);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
function entryData(bytes, entry) {
|
|
2728
|
+
if (entry.localOffset + LOCAL_MIN_SIZE > bytes.length) {
|
|
2729
|
+
throw invalid2(`Local header entri "${entry.name}" terpotong.`);
|
|
2730
|
+
}
|
|
2731
|
+
if (readU32(bytes, entry.localOffset) !== LOCAL_SIGNATURE) {
|
|
2732
|
+
throw invalid2(`Local header entri "${entry.name}" tidak dikenal.`);
|
|
2733
|
+
}
|
|
2734
|
+
const nameLength = readU16(bytes, entry.localOffset + 26);
|
|
2735
|
+
const extraLength = readU16(bytes, entry.localOffset + 28);
|
|
2736
|
+
const start = entry.localOffset + LOCAL_MIN_SIZE + nameLength + extraLength;
|
|
2737
|
+
const end = start + entry.compressedSize;
|
|
2738
|
+
if (end > bytes.length) {
|
|
2739
|
+
throw invalid2(`Data entri "${entry.name}" melewati akhir berkas.`);
|
|
2740
|
+
}
|
|
2741
|
+
const compressed = bytes.subarray(start, end);
|
|
2742
|
+
if (entry.method === 0) {
|
|
2743
|
+
if (compressed.length !== entry.uncompressedSize) {
|
|
2744
|
+
throw invalid2(`Ukuran entri "${entry.name}" tidak cocok dengan central directory.`);
|
|
2745
|
+
}
|
|
2746
|
+
return compressed;
|
|
2747
|
+
}
|
|
2748
|
+
if (entry.method !== 8) {
|
|
2749
|
+
throw zipError(
|
|
2750
|
+
"zip_unsupported",
|
|
2751
|
+
`Metode kompresi ${entry.method} pada entri "${entry.name}" tidak didukung (hanya stored/deflate).`
|
|
2752
|
+
);
|
|
2753
|
+
}
|
|
2754
|
+
let data;
|
|
2755
|
+
try {
|
|
2756
|
+
data = inflateRawSync(compressed, {
|
|
2757
|
+
maxOutputLength: Math.max(1, entry.uncompressedSize)
|
|
2758
|
+
});
|
|
2759
|
+
} catch {
|
|
2760
|
+
throw invalid2(
|
|
2761
|
+
`Entri "${entry.name}" tidak bisa didekompresi atau melebihi ukuran yang dideklarasikan.`
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
if (data.length !== entry.uncompressedSize) {
|
|
2765
|
+
throw invalid2(`Ukuran entri "${entry.name}" tidak cocok dengan central directory.`);
|
|
2766
|
+
}
|
|
2767
|
+
return new Uint8Array(data);
|
|
2768
|
+
}
|
|
2769
|
+
async function writeEntry(fs, bytes, entry, destination) {
|
|
2770
|
+
const target = path2.join(destination, ...entry.name.split("/"));
|
|
2771
|
+
if (!isPathInside(destination, target)) {
|
|
2772
|
+
throw zipError(
|
|
2773
|
+
"unsafe_zip_entry",
|
|
2774
|
+
`Entri zip menunjuk keluar folder tujuan: "${entry.name}". Impor ditolak.`
|
|
2775
|
+
);
|
|
2776
|
+
}
|
|
2777
|
+
if (entry.isDirectory) {
|
|
2778
|
+
const created = await fs.mkdir(target, { recursive: true });
|
|
2779
|
+
if (!created.ok) {
|
|
2780
|
+
throw extractError(created.error.message, created.error);
|
|
2781
|
+
}
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
const data = entryData(bytes, entry);
|
|
2785
|
+
if (crc32(data) !== entry.crc) {
|
|
2786
|
+
throw invalid2(`CRC entri "${entry.name}" tidak cocok; arsip rusak.`);
|
|
2787
|
+
}
|
|
2788
|
+
const parent = await fs.mkdir(path2.dirname(target), { recursive: true });
|
|
2789
|
+
if (!parent.ok) {
|
|
2790
|
+
throw extractError(parent.error.message, parent.error);
|
|
2791
|
+
}
|
|
2792
|
+
const written = await fs.write(target, data, {
|
|
2793
|
+
mode: entry.permissions === 0 ? DEFAULT_FILE_MODE : entry.permissions
|
|
2794
|
+
});
|
|
2795
|
+
if (!written.ok) {
|
|
2796
|
+
throw extractError(written.error.message, written.error);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
function extractError(message, cause) {
|
|
2800
|
+
return zipError(
|
|
2801
|
+
"zip_extract_failed",
|
|
2802
|
+
`Gagal mengekstrak arsip zip ke direktori sementara: ${message}`,
|
|
2803
|
+
cause
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
async function extractZipTo(fs, zipPath, destination) {
|
|
2807
|
+
const resolved = path2.resolve(zipPath);
|
|
2808
|
+
const archive = await fs.read(resolved);
|
|
2809
|
+
if (!archive.ok) {
|
|
2810
|
+
throw zipError(
|
|
2811
|
+
"source_unreadable",
|
|
2812
|
+
`Arsip zip tidak bisa dibaca: ${resolved} (${archive.error.code}).`,
|
|
2813
|
+
archive.error
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
const entries = parseZipEntries(archive.value);
|
|
2817
|
+
assertLimits(archive.value.length, entries);
|
|
2818
|
+
const created = await fs.mkdir(destination, { recursive: true });
|
|
2819
|
+
if (!created.ok) {
|
|
2820
|
+
throw extractError(created.error.message, created.error);
|
|
2821
|
+
}
|
|
2822
|
+
for (const entry of entries) {
|
|
2823
|
+
await writeEntry(fs, archive.value, entry, destination);
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
// src/library/staging.ts
|
|
2828
|
+
var STAGING_DIR_NAME = "staging";
|
|
2829
|
+
var STAGING_PREFIX = "stg_";
|
|
2830
|
+
var META_FILE_NAME = "meta.json";
|
|
2831
|
+
function stagingError(code, message, details) {
|
|
2832
|
+
return new SkillManagerError({ code, message, ...details === void 0 ? {} : { details } });
|
|
2833
|
+
}
|
|
2834
|
+
function stagingRoot(dataRoot) {
|
|
2835
|
+
return path2.join(dataRoot, STAGING_DIR_NAME);
|
|
2836
|
+
}
|
|
2837
|
+
function assertStagingId(stagingId) {
|
|
2838
|
+
if (!/^stg_[A-Za-z0-9-]+$/.test(stagingId)) {
|
|
2839
|
+
throw stagingError("staging_invalid", `Id staging tidak valid: "${stagingId}".`);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
function newId(options) {
|
|
2843
|
+
const raw = (options.newStagingId ?? (() => randomUUID()))();
|
|
2844
|
+
const stagingId = `${STAGING_PREFIX}${raw}`;
|
|
2845
|
+
assertStagingId(stagingId);
|
|
2846
|
+
return stagingId;
|
|
2847
|
+
}
|
|
2848
|
+
async function prepare(fs, dataRoot, options) {
|
|
2849
|
+
const stagingId = newId(options);
|
|
2850
|
+
const root = path2.join(stagingRoot(dataRoot), stagingId);
|
|
2851
|
+
const created = await fs.mkdir(root, { recursive: true });
|
|
2852
|
+
if (!created.ok) {
|
|
2853
|
+
throw stagingError(
|
|
2854
|
+
"staging_invalid",
|
|
2855
|
+
`Direktori staging tidak bisa dibuat: ${created.error.message}`,
|
|
2856
|
+
created.error
|
|
2857
|
+
);
|
|
2858
|
+
}
|
|
2859
|
+
return { stagingId, root };
|
|
2860
|
+
}
|
|
2861
|
+
async function finalize(fs, root, skillDir, stagingId, provenance, options) {
|
|
2862
|
+
const suggestedName = await readSkillName(fs, skillDir);
|
|
2863
|
+
const inspect = await inspectSource(fs, skillDir);
|
|
2864
|
+
const meta = {
|
|
2865
|
+
provenance,
|
|
2866
|
+
suggestedName,
|
|
2867
|
+
createdAt: (options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))()
|
|
2868
|
+
};
|
|
2869
|
+
const written = await fs.write(`${root}/${META_FILE_NAME}`, `${JSON.stringify(meta)}
|
|
2870
|
+
`);
|
|
2871
|
+
if (!written.ok) {
|
|
2872
|
+
throw stagingError("staging_invalid", "Metadata staging tidak bisa ditulis.", written.error);
|
|
2873
|
+
}
|
|
2874
|
+
return { stagingId, inspect, provenance, suggestedName };
|
|
2875
|
+
}
|
|
2876
|
+
async function stageFromFolder(fs, dataRoot, sourceDir, options = {}) {
|
|
2877
|
+
const { stagingId, root } = await prepare(fs, dataRoot, options);
|
|
2878
|
+
const target = path2.join(root, "source");
|
|
2879
|
+
try {
|
|
2880
|
+
const copied = await fs.copy(path2.resolve(sourceDir), target);
|
|
2881
|
+
if (!copied.ok) {
|
|
2882
|
+
throw copied.error;
|
|
2883
|
+
}
|
|
2884
|
+
const provenance = { type: "folder", path: path2.resolve(sourceDir) };
|
|
2885
|
+
return await finalize(fs, root, target, stagingId, provenance, options);
|
|
2886
|
+
} catch (error) {
|
|
2887
|
+
await fs.remove(root, { recursive: true });
|
|
2888
|
+
throw error;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
async function stageFromGit(fs, dataRoot, runner, input, options = {}) {
|
|
2892
|
+
const { stagingId, root } = await prepare(fs, dataRoot, options);
|
|
2893
|
+
try {
|
|
2894
|
+
const cloned = await cloneGitTo(runner, input, root);
|
|
2895
|
+
const skillDir = await findSkillDirectory(fs, root);
|
|
2896
|
+
const provenance = {
|
|
2897
|
+
type: "git",
|
|
2898
|
+
url: cloned.url,
|
|
2899
|
+
ref: cloned.ref,
|
|
2900
|
+
commit: cloned.commit
|
|
2901
|
+
};
|
|
2902
|
+
return await finalize(fs, root, skillDir, stagingId, provenance, options);
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
await fs.remove(root, { recursive: true });
|
|
2905
|
+
throw error;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
async function stageFromZip(fs, dataRoot, zipPath, options = {}) {
|
|
2909
|
+
const { stagingId, root } = await prepare(fs, dataRoot, options);
|
|
2910
|
+
try {
|
|
2911
|
+
await extractZipTo(fs, zipPath, root);
|
|
2912
|
+
const skillDir = await findSkillDirectory(fs, root);
|
|
2913
|
+
const provenance = { type: "zip", path: path2.resolve(zipPath) };
|
|
2914
|
+
return await finalize(fs, root, skillDir, stagingId, provenance, options);
|
|
2915
|
+
} catch (error) {
|
|
2916
|
+
await fs.remove(root, { recursive: true });
|
|
2917
|
+
throw error;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
async function readStaged(fs, dataRoot, stagingId) {
|
|
2921
|
+
assertStagingId(stagingId);
|
|
2922
|
+
const root = path2.join(stagingRoot(dataRoot), stagingId);
|
|
2923
|
+
const raw = await fs.readText(path2.join(root, META_FILE_NAME));
|
|
2924
|
+
if (!raw.ok) {
|
|
2925
|
+
throw stagingError(
|
|
2926
|
+
"staging_not_found",
|
|
2927
|
+
`Staging "${stagingId}" tidak ditemukan atau sudah kedaluwarsa. Ulangi pratinjau.`
|
|
2928
|
+
);
|
|
2929
|
+
}
|
|
2930
|
+
let meta;
|
|
2931
|
+
try {
|
|
2932
|
+
meta = JSON.parse(raw.value);
|
|
2933
|
+
} catch {
|
|
2934
|
+
throw stagingError("staging_invalid", `Metadata staging "${stagingId}" rusak.`);
|
|
2935
|
+
}
|
|
2936
|
+
const skillDir = await findSkillDirectory(fs, root);
|
|
2937
|
+
const inspect = await inspectSource(fs, skillDir);
|
|
2938
|
+
return { stagingId, root, skillDir, meta, inspect };
|
|
2939
|
+
}
|
|
2940
|
+
async function importStaged(fs, dataRoot, stagingId, options = {}) {
|
|
2941
|
+
const staged = await readStaged(fs, dataRoot, stagingId);
|
|
2942
|
+
try {
|
|
2943
|
+
const skillDir = await findSkillDirectory(fs, staged.root);
|
|
2944
|
+
return await importFromDirectory(fs, dataRoot, {
|
|
2945
|
+
sourceDir: skillDir,
|
|
2946
|
+
provenance: staged.meta.provenance,
|
|
2947
|
+
name: staged.meta.suggestedName,
|
|
2948
|
+
...options.newSkillId === void 0 ? {} : { newSkillId: options.newSkillId },
|
|
2949
|
+
...options.now === void 0 ? {} : { now: options.now }
|
|
2950
|
+
});
|
|
2951
|
+
} finally {
|
|
2952
|
+
await fs.remove(staged.root, { recursive: true });
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
async function cleanStaleStaging(fs, dataRoot, maxAgeMs = 24 * 60 * 60 * 1e3, now = () => Date.now()) {
|
|
2956
|
+
const root = stagingRoot(dataRoot);
|
|
2957
|
+
const listing = await fs.readdir(root);
|
|
2958
|
+
if (!listing.ok) {
|
|
2959
|
+
return [];
|
|
2960
|
+
}
|
|
2961
|
+
const removed = [];
|
|
2962
|
+
for (const entry of listing.value) {
|
|
2963
|
+
if (entry.kind !== "directory" || !entry.name.startsWith(STAGING_PREFIX)) {
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
const target = path2.join(root, entry.name);
|
|
2967
|
+
const raw = await fs.readText(path2.join(target, META_FILE_NAME));
|
|
2968
|
+
let createdAt = Number.NaN;
|
|
2969
|
+
if (raw.ok) {
|
|
2970
|
+
try {
|
|
2971
|
+
createdAt = Date.parse(JSON.parse(raw.value).createdAt);
|
|
2972
|
+
} catch {
|
|
2973
|
+
createdAt = Number.NaN;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
if (Number.isNaN(createdAt) || now() - createdAt > maxAgeMs) {
|
|
2977
|
+
await fs.remove(target, { recursive: true });
|
|
2978
|
+
removed.push(target);
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
return removed;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/server/routes/skills.ts
|
|
2985
|
+
var nameSchema = z.string().min(1, "nama skill wajib diisi").max(200).refine((value) => value !== "." && value !== ".." && !/[\\/]/.test(value), {
|
|
2986
|
+
message: "nama skill tidak boleh berupa path"
|
|
2987
|
+
});
|
|
2988
|
+
function summarizeSkill(skill) {
|
|
2989
|
+
return {
|
|
2990
|
+
id: skill.id,
|
|
2991
|
+
name: skill.name,
|
|
2992
|
+
dirName: skill.dirName,
|
|
2993
|
+
contentHash: skill.contentHash,
|
|
2994
|
+
source: skill.source,
|
|
2995
|
+
importedAt: skill.importedAt,
|
|
2996
|
+
...skill.license === void 0 ? {} : { license: skill.license }
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
function flatten(nodes, result = []) {
|
|
3000
|
+
for (const node of nodes) {
|
|
3001
|
+
if (node.kind === "directory") {
|
|
3002
|
+
flatten(node.children ?? [], result);
|
|
3003
|
+
continue;
|
|
3004
|
+
}
|
|
3005
|
+
result.push({
|
|
3006
|
+
path: node.path,
|
|
3007
|
+
bytes: node.size,
|
|
3008
|
+
...node.kind === "symlink" ? { risk: "symlink" } : {}
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
return result;
|
|
3012
|
+
}
|
|
3013
|
+
function riskyOf(inspect) {
|
|
3014
|
+
return {
|
|
3015
|
+
scripts: inspect.hasScriptsDir,
|
|
3016
|
+
binary: inspect.hasBinary,
|
|
3017
|
+
escapingSymlink: inspect.hasEscapingSymlink,
|
|
3018
|
+
messages: inspect.risks
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
async function findSkill(deps, name) {
|
|
3022
|
+
const { skill } = await findLibrarySkill(deps.fs, deps.dataRoot, name);
|
|
3023
|
+
return skill;
|
|
3024
|
+
}
|
|
3025
|
+
function skillDirectory(deps, skill) {
|
|
3026
|
+
const root = libraryRoot(deps.dataRoot);
|
|
3027
|
+
const directory = path2.join(root, skill.dirName);
|
|
3028
|
+
if (!isPathInside(root, directory)) {
|
|
3029
|
+
throw new SkillManagerError({
|
|
3030
|
+
code: "invalid_path",
|
|
3031
|
+
message: `Nama folder skill "${skill.dirName}" menunjuk keluar Library.`,
|
|
3032
|
+
details: { path: directory }
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
return directory;
|
|
3036
|
+
}
|
|
3037
|
+
var inspectSchema = z.discriminatedUnion("type", [
|
|
3038
|
+
z.object({ type: z.literal("folder"), path: z.string().min(1) }),
|
|
3039
|
+
z.object({
|
|
3040
|
+
type: z.literal("git"),
|
|
3041
|
+
url: z.string().min(1),
|
|
3042
|
+
ref: z.string().min(1).optional()
|
|
3043
|
+
}),
|
|
3044
|
+
z.object({ type: z.literal("zip"), path: z.string().min(1) })
|
|
3045
|
+
]);
|
|
3046
|
+
var importSchema = z.union([
|
|
3047
|
+
z.object({ stagingId: z.string().min(1) }),
|
|
3048
|
+
z.object({ type: z.literal("folder"), path: z.string().min(1) })
|
|
3049
|
+
]);
|
|
3050
|
+
function registerSkillRoutes(app, deps) {
|
|
3051
|
+
let stagingCleaned = false;
|
|
3052
|
+
const cleanStagingOnce = async () => {
|
|
3053
|
+
if (stagingCleaned) {
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
stagingCleaned = true;
|
|
3057
|
+
await cleanStaleStaging(deps.fs, deps.dataRoot);
|
|
3058
|
+
};
|
|
3059
|
+
app.get("/api/skills", async (c) => {
|
|
3060
|
+
const { state } = await loadState(deps.fs, deps.dataRoot);
|
|
3061
|
+
return c.json({ skills: state.library.map((skill) => summarizeSkill(skill)) });
|
|
3062
|
+
});
|
|
3063
|
+
app.get("/api/skills/:name", async (c) => {
|
|
3064
|
+
const parsed = nameSchema.safeParse(c.req.param("name"));
|
|
3065
|
+
if (!parsed.success) {
|
|
3066
|
+
throw new SkillManagerError({
|
|
3067
|
+
code: "bad_request",
|
|
3068
|
+
message: "Nama skill pada URL tidak valid.",
|
|
3069
|
+
details: { issues: parsed.error.issues.map((issue) => issue.message) }
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
const skill = await findSkill(deps, parsed.data);
|
|
3073
|
+
const directory = skillDirectory(deps, skill);
|
|
3074
|
+
const inspected = await inspectSource(deps.fs, directory);
|
|
3075
|
+
const planned = await planDependencies(deps.fs, directory, {
|
|
3076
|
+
dataRoot: deps.dataRoot,
|
|
3077
|
+
skillId: skill.dirName
|
|
3078
|
+
});
|
|
3079
|
+
return c.json({
|
|
3080
|
+
skill: {
|
|
3081
|
+
...summarizeSkill(skill),
|
|
3082
|
+
...skill.license === void 0 && inspected.license === void 0 ? {} : { license: skill.license ?? inspected.license },
|
|
3083
|
+
// Provenance sudah utuh di `source` (dari summarizeSkill); field
|
|
3084
|
+
// terpisah di sini hanya akan menduplikasi objek yang sama.
|
|
3085
|
+
files: flatten(inspected.files),
|
|
3086
|
+
...planned.ok ? { dependencies: planned.value } : {}
|
|
3087
|
+
}
|
|
3088
|
+
});
|
|
3089
|
+
});
|
|
3090
|
+
app.post("/api/skills/inspect", async (c) => {
|
|
3091
|
+
const body = await readJson(c, inspectSchema);
|
|
3092
|
+
await cleanStagingOnce();
|
|
3093
|
+
let staged;
|
|
3094
|
+
try {
|
|
3095
|
+
if (body.type === "folder") {
|
|
3096
|
+
staged = await stageFromFolder(deps.fs, deps.dataRoot, body.path);
|
|
3097
|
+
} else if (body.type === "git") {
|
|
3098
|
+
staged = await stageFromGit(
|
|
3099
|
+
deps.fs,
|
|
3100
|
+
deps.dataRoot,
|
|
3101
|
+
deps.runner,
|
|
3102
|
+
body.ref === void 0 ? { url: body.url } : { url: body.url, ref: body.ref }
|
|
3103
|
+
);
|
|
3104
|
+
} else {
|
|
3105
|
+
staged = await stageFromZip(deps.fs, deps.dataRoot, body.path);
|
|
3106
|
+
}
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
throw asDomainError(error);
|
|
3109
|
+
}
|
|
3110
|
+
return c.json({
|
|
3111
|
+
stagingId: staged.stagingId,
|
|
3112
|
+
files: staged.inspect.files,
|
|
3113
|
+
risky: riskyOf(staged.inspect),
|
|
3114
|
+
totalBytes: staged.inspect.totalBytes,
|
|
3115
|
+
...staged.inspect.license === void 0 ? {} : { license: staged.inspect.license },
|
|
3116
|
+
suggestedName: staged.suggestedName,
|
|
3117
|
+
provenance: staged.provenance
|
|
3118
|
+
});
|
|
3119
|
+
});
|
|
3120
|
+
app.post("/api/skills/import", async (c) => {
|
|
3121
|
+
const body = await readJson(c, importSchema);
|
|
3122
|
+
const imported = "stagingId" in body ? await importStaged(deps.fs, deps.dataRoot, body.stagingId) : await importFolderSkill(deps.fs, deps.dataRoot, { sourceDir: body.path });
|
|
3123
|
+
return c.json({ skill: summarizeSkill(imported.skill) }, 201);
|
|
3124
|
+
});
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
// src/server/routes/deps.ts
|
|
3128
|
+
var planSchema = z.object({ skill: z.string().min(1) });
|
|
3129
|
+
var runSchema = planSchema.extend({ allowScripts: z.boolean().optional() });
|
|
3130
|
+
function registerDepsRoutes(app, deps) {
|
|
3131
|
+
app.post("/api/deps/plan", async (c) => {
|
|
3132
|
+
const body = await readJson(c, planSchema);
|
|
3133
|
+
const skill = await findSkill(deps, body.skill);
|
|
3134
|
+
const commands = unwrap(
|
|
3135
|
+
await planDependencies(deps.fs, skillDirectory(deps, skill), {
|
|
3136
|
+
dataRoot: deps.dataRoot,
|
|
3137
|
+
skillId: skill.dirName
|
|
3138
|
+
})
|
|
3139
|
+
);
|
|
3140
|
+
return c.json({ commands });
|
|
3141
|
+
});
|
|
3142
|
+
app.post("/api/deps/run", async (c) => {
|
|
3143
|
+
const body = await readJson(c, runSchema);
|
|
3144
|
+
const skill = await findSkill(deps, body.skill);
|
|
3145
|
+
const report = await runDependencies({
|
|
3146
|
+
fs: deps.fs,
|
|
3147
|
+
runner: deps.runner,
|
|
3148
|
+
dataRoot: deps.dataRoot,
|
|
3149
|
+
skillId: skill.dirName,
|
|
3150
|
+
skillDir: skillDirectory(deps, skill),
|
|
3151
|
+
...body.allowScripts === void 0 ? {} : { allowScripts: body.allowScripts }
|
|
3152
|
+
});
|
|
3153
|
+
return c.json({ ok: report.ok, output: report.results });
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
function toEntryError(error) {
|
|
3157
|
+
return { code: error.code, message: error.message };
|
|
3158
|
+
}
|
|
3159
|
+
function isWantedSkill(skill, filter) {
|
|
3160
|
+
return filter === void 0 || skill.name === filter || skill.id === filter;
|
|
3161
|
+
}
|
|
3162
|
+
async function computeDrift(fs, dataRoot, options) {
|
|
3163
|
+
const { state } = await loadState(fs, dataRoot);
|
|
3164
|
+
const stores = listStores(options.adapters, {
|
|
3165
|
+
home: options.home,
|
|
3166
|
+
platform: options.platform
|
|
3167
|
+
});
|
|
3168
|
+
const storesById = new Map(stores.map((store) => [store.id, store]));
|
|
3169
|
+
const skills = new Map(state.library.map((skill) => [skill.id, skill]));
|
|
3170
|
+
const filter = options.skill;
|
|
3171
|
+
const entries = [];
|
|
3172
|
+
let staleCount = 0;
|
|
3173
|
+
for (const installation of state.installations) {
|
|
3174
|
+
const skill = skills.get(installation.skillId);
|
|
3175
|
+
if (filter !== void 0 && (skill === void 0 || !isWantedSkill(skill, filter))) {
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
const store = storesById.get(installation.storeId);
|
|
3179
|
+
const skillName = skill?.name;
|
|
3180
|
+
if (skill === void 0 || store === void 0) {
|
|
3181
|
+
entries.push({
|
|
3182
|
+
status: "missing",
|
|
3183
|
+
storeId: installation.storeId,
|
|
3184
|
+
path: "",
|
|
3185
|
+
skillId: installation.skillId,
|
|
3186
|
+
skillName,
|
|
3187
|
+
installedHash: installation.installedHash,
|
|
3188
|
+
error: skill === void 0 ? { code: "skill_not_found", message: "Entri Library untuk Installation ini hilang." } : { code: "unknown_store", message: "Store Installation ini tidak dikenal adapter." }
|
|
3189
|
+
});
|
|
3190
|
+
continue;
|
|
3191
|
+
}
|
|
3192
|
+
const destination = path2.join(store.path, skill.dirName);
|
|
3193
|
+
const stat2 = await fs.stat(destination);
|
|
3194
|
+
if (!stat2.ok) {
|
|
3195
|
+
const notFound = stat2.error.code === "not_found";
|
|
3196
|
+
entries.push({
|
|
3197
|
+
status: notFound ? "missing" : "modified",
|
|
3198
|
+
storeId: store.id,
|
|
3199
|
+
path: destination,
|
|
3200
|
+
skillId: skill.id,
|
|
3201
|
+
skillName,
|
|
3202
|
+
installedHash: installation.installedHash,
|
|
3203
|
+
...notFound ? {} : { error: toEntryError(stat2.error) }
|
|
3204
|
+
});
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
if (stat2.value.kind !== "directory") {
|
|
3208
|
+
entries.push({
|
|
3209
|
+
status: "modified",
|
|
3210
|
+
storeId: store.id,
|
|
3211
|
+
path: destination,
|
|
3212
|
+
skillId: skill.id,
|
|
3213
|
+
skillName,
|
|
3214
|
+
installedHash: installation.installedHash,
|
|
3215
|
+
error: { code: "not_a_directory", message: "Path skill di Store bukan direktori." }
|
|
3216
|
+
});
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
const hash = await contentHash(fs, destination);
|
|
3220
|
+
if (!hash.ok) {
|
|
3221
|
+
entries.push({
|
|
3222
|
+
status: "modified",
|
|
3223
|
+
storeId: store.id,
|
|
3224
|
+
path: destination,
|
|
3225
|
+
skillId: skill.id,
|
|
3226
|
+
skillName,
|
|
3227
|
+
installedHash: installation.installedHash,
|
|
3228
|
+
error: toEntryError(hash.error)
|
|
3229
|
+
});
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
const behindLibrary = hash.value !== skill.contentHash;
|
|
3233
|
+
if (behindLibrary) {
|
|
3234
|
+
staleCount += 1;
|
|
3235
|
+
}
|
|
3236
|
+
entries.push({
|
|
3237
|
+
status: hash.value === installation.installedHash ? "clean" : "modified",
|
|
3238
|
+
storeId: store.id,
|
|
3239
|
+
path: destination,
|
|
3240
|
+
skillId: skill.id,
|
|
3241
|
+
skillName,
|
|
3242
|
+
installedHash: installation.installedHash,
|
|
3243
|
+
storeHash: hash.value,
|
|
3244
|
+
behindLibrary
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
if (filter === void 0) {
|
|
3248
|
+
entries.push(...await unmanagedEntries(fs, state.installations, skills, stores));
|
|
3249
|
+
}
|
|
3250
|
+
return { entries, staleCount };
|
|
3251
|
+
}
|
|
3252
|
+
async function unmanagedEntries(fs, installations, skills, stores) {
|
|
3253
|
+
const recorded = /* @__PURE__ */ new Map();
|
|
3254
|
+
for (const installation of installations) {
|
|
3255
|
+
const skill = skills.get(installation.skillId);
|
|
3256
|
+
if (skill === void 0) {
|
|
3257
|
+
continue;
|
|
3258
|
+
}
|
|
3259
|
+
const names = recorded.get(installation.storeId) ?? /* @__PURE__ */ new Set();
|
|
3260
|
+
names.add(skill.dirName);
|
|
3261
|
+
recorded.set(installation.storeId, names);
|
|
3262
|
+
}
|
|
3263
|
+
const entries = [];
|
|
3264
|
+
for (const store of stores) {
|
|
3265
|
+
const listing = await fs.readdir(store.path);
|
|
3266
|
+
if (!listing.ok) {
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
const known = recorded.get(store.id) ?? /* @__PURE__ */ new Set();
|
|
3270
|
+
for (const entry of listing.value) {
|
|
3271
|
+
if (entry.kind !== "directory" || known.has(entry.name)) {
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
if (entry.name.startsWith(TEMP_PREFIX)) {
|
|
3275
|
+
continue;
|
|
3276
|
+
}
|
|
3277
|
+
entries.push({
|
|
3278
|
+
status: "unmanaged",
|
|
3279
|
+
storeId: store.id,
|
|
3280
|
+
path: path2.join(store.path, entry.name),
|
|
3281
|
+
skillName: entry.name
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
return entries;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// src/maintenance/doctor.ts
|
|
3289
|
+
async function tempDirectories(fs, options) {
|
|
3290
|
+
const stores = listStores(options.adapters, {
|
|
3291
|
+
home: options.home,
|
|
3292
|
+
platform: options.platform
|
|
3293
|
+
});
|
|
3294
|
+
const found = [];
|
|
3295
|
+
for (const store of stores) {
|
|
3296
|
+
const listing = await fs.readdir(store.path);
|
|
3297
|
+
if (!listing.ok) {
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
for (const entry of listing.value) {
|
|
3301
|
+
if (entry.kind === "directory" && entry.name.startsWith(TEMP_PREFIX)) {
|
|
3302
|
+
found.push({ storePath: store.path, dir: path2.join(store.path, entry.name) });
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
return found;
|
|
3307
|
+
}
|
|
3308
|
+
async function missingLibraryEntries(fs, dataRoot) {
|
|
3309
|
+
const { state } = await loadState(fs, dataRoot);
|
|
3310
|
+
const root = libraryRoot(dataRoot);
|
|
3311
|
+
const found = [];
|
|
3312
|
+
for (const skill of state.library) {
|
|
3313
|
+
const dir = path2.join(root, skill.dirName);
|
|
3314
|
+
const stat2 = await fs.stat(dir);
|
|
3315
|
+
if (!stat2.ok || stat2.value.kind !== "directory") {
|
|
3316
|
+
found.push({ skillId: skill.id, name: skill.name, path: dir });
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
return found;
|
|
3320
|
+
}
|
|
3321
|
+
async function runDoctor(fs, dataRoot, options) {
|
|
3322
|
+
const drift = await computeDrift(fs, dataRoot, options);
|
|
3323
|
+
const tempDirs = (await tempDirectories(fs, options)).map((entry) => entry.dir);
|
|
3324
|
+
return {
|
|
3325
|
+
missing: drift.entries.filter((entry) => entry.status === "missing"),
|
|
3326
|
+
unmanaged: drift.entries.filter((entry) => entry.status === "unmanaged"),
|
|
3327
|
+
stale: drift.entries.filter((entry) => entry.behindLibrary === true),
|
|
3328
|
+
tempDirs,
|
|
3329
|
+
libraryMissing: await missingLibraryEntries(fs, dataRoot)
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
async function cleanTempDirs(fs, _dataRoot, options) {
|
|
3333
|
+
const removed = [];
|
|
3334
|
+
const stores = listStores(options.adapters, {
|
|
3335
|
+
home: options.home,
|
|
3336
|
+
platform: options.platform
|
|
3337
|
+
});
|
|
3338
|
+
for (const store of stores) {
|
|
3339
|
+
const listing = await fs.readdir(store.path);
|
|
3340
|
+
if (!listing.ok) {
|
|
3341
|
+
continue;
|
|
3342
|
+
}
|
|
3343
|
+
for (const entry of listing.value) {
|
|
3344
|
+
if (entry.kind !== "directory" || !entry.name.startsWith(TEMP_PREFIX)) {
|
|
3345
|
+
continue;
|
|
3346
|
+
}
|
|
3347
|
+
const dir = path2.join(store.path, entry.name);
|
|
3348
|
+
const result = await removeWithinStore(fs, store.path, dir);
|
|
3349
|
+
if (result.ok) {
|
|
3350
|
+
removed.push(dir);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
return removed;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// src/server/routes/doctor.ts
|
|
3358
|
+
function registerDoctorRoutes(app, deps) {
|
|
3359
|
+
const options = async () => ({
|
|
3360
|
+
adapters: (await loadAdapters({ fs: deps.fs, dataRoot: deps.dataRoot })).adapters,
|
|
3361
|
+
home: deps.home
|
|
3362
|
+
});
|
|
3363
|
+
app.get("/api/doctor", async (c) => {
|
|
3364
|
+
const report = await runDoctor(deps.fs, deps.dataRoot, await options());
|
|
3365
|
+
return c.json(report);
|
|
3366
|
+
});
|
|
3367
|
+
app.post("/api/doctor/clean-temp", async (c) => {
|
|
3368
|
+
const removed = await cleanTempDirs(deps.fs, deps.dataRoot, await options());
|
|
3369
|
+
return c.json({ removed });
|
|
3370
|
+
});
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// src/server/routes/harnesses.ts
|
|
3374
|
+
function targetsFor(adapters, harness) {
|
|
3375
|
+
const adapter = adapters.find((entry) => entry.id === harness.id);
|
|
3376
|
+
if (adapter === void 0 || adapterCapability(adapter) !== "managed") {
|
|
3377
|
+
return [];
|
|
3378
|
+
}
|
|
3379
|
+
const targets = [];
|
|
3380
|
+
for (const store of adapter.stores) {
|
|
3381
|
+
try {
|
|
3382
|
+
const target = resolveTarget(adapters, { harnessId: adapter.id, scope: store.scope });
|
|
3383
|
+
targets.push({
|
|
3384
|
+
id: `${target.harnessId}/${target.scope}`,
|
|
3385
|
+
harnessId: target.harnessId,
|
|
3386
|
+
scope: target.scope,
|
|
3387
|
+
storeId: target.storeId
|
|
3388
|
+
});
|
|
3389
|
+
} catch {
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
return targets;
|
|
3393
|
+
}
|
|
3394
|
+
function toView(adapters, harness) {
|
|
3395
|
+
return {
|
|
3396
|
+
id: harness.id,
|
|
3397
|
+
name: harness.name,
|
|
3398
|
+
capability: harness.capability,
|
|
3399
|
+
detected: harness.detected,
|
|
3400
|
+
detectionReasons: [...harness.detectionReasons],
|
|
3401
|
+
storeIds: [...harness.storeIds],
|
|
3402
|
+
targets: targetsFor(adapters, harness),
|
|
3403
|
+
...harness.connect === void 0 ? {} : { connect: { installUrl: harness.connect.installUrl } }
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
function registerHarnessRoutes(app, deps) {
|
|
3407
|
+
const detect = async () => {
|
|
3408
|
+
const result = await recheck({
|
|
3409
|
+
fs: deps.fs,
|
|
3410
|
+
runner: deps.runner,
|
|
3411
|
+
dataRoot: deps.dataRoot,
|
|
3412
|
+
home: deps.home
|
|
3413
|
+
});
|
|
3414
|
+
return { harnesses: result.harnesses.map((harness) => toView(result.adapters, harness)) };
|
|
3415
|
+
};
|
|
3416
|
+
app.get("/api/harnesses", async (c) => c.json(await detect()));
|
|
3417
|
+
app.post("/api/harnesses/recheck", async (c) => c.json(await detect()));
|
|
3418
|
+
}
|
|
3419
|
+
function fromFs(error) {
|
|
3420
|
+
return new SkillManagerError({
|
|
3421
|
+
code: error.code,
|
|
3422
|
+
message: error.message,
|
|
3423
|
+
details: { path: error.path }
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
function toTargetError(error) {
|
|
3427
|
+
if (error instanceof SkillManagerError) {
|
|
3428
|
+
return { code: error.code, message: error.message, details: error.details };
|
|
3429
|
+
}
|
|
3430
|
+
return {
|
|
3431
|
+
code: "io_error",
|
|
3432
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
async function ensureStoreDirectory(fs, storePath) {
|
|
3436
|
+
const stat2 = await fs.stat(storePath);
|
|
3437
|
+
if (stat2.ok) {
|
|
3438
|
+
if (stat2.value.kind !== "directory") {
|
|
3439
|
+
throw new SkillManagerError({
|
|
3440
|
+
code: "not_a_directory",
|
|
3441
|
+
message: `Store ${storePath} bukan direktori.`,
|
|
3442
|
+
details: { path: storePath }
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
if (stat2.error.code !== "not_found") {
|
|
3448
|
+
throw fromFs(stat2.error);
|
|
3449
|
+
}
|
|
3450
|
+
const made = await fs.mkdir(storePath, { recursive: true });
|
|
3451
|
+
if (!made.ok) {
|
|
3452
|
+
throw fromFs(made.error);
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
function populateFrom(fs, source) {
|
|
3456
|
+
return async (tempDir) => {
|
|
3457
|
+
const listing = await fs.readdir(source);
|
|
3458
|
+
if (!listing.ok) {
|
|
3459
|
+
return listing;
|
|
3460
|
+
}
|
|
3461
|
+
for (const entry of listing.value) {
|
|
3462
|
+
const copied = await fs.copy(path2.join(source, entry.name), path2.join(tempDir, entry.name));
|
|
3463
|
+
if (!copied.ok) {
|
|
3464
|
+
return copied;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
return fsOk(void 0);
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
function findOwner(state, storeId, dirName) {
|
|
3471
|
+
for (const installation of state.installations) {
|
|
3472
|
+
if (installation.storeId !== storeId) {
|
|
3473
|
+
continue;
|
|
3474
|
+
}
|
|
3475
|
+
const owner = state.library.find(
|
|
3476
|
+
(entry) => entry.id === installation.skillId && entry.dirName === dirName
|
|
3477
|
+
);
|
|
3478
|
+
if (owner !== void 0) {
|
|
3479
|
+
return { skillId: owner.id, name: owner.name };
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
return void 0;
|
|
3483
|
+
}
|
|
3484
|
+
function conflictError2(destination, storeId, owner) {
|
|
3485
|
+
const owned = owner === void 0 ? "tidak tercatat sebagai Installation" : `milik skill "${owner.name}"`;
|
|
3486
|
+
return new SkillManagerError({
|
|
3487
|
+
code: "install_conflict",
|
|
3488
|
+
message: `${destination} sudah ada di Store ${storeId} (${owned}). Tidak ada yang ditimpa.`,
|
|
3489
|
+
details: { path: destination, storeId, owner: owner ?? null }
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
async function installOne(context, store, installedAt) {
|
|
3493
|
+
const destination = path2.join(store.path, context.skill.dirName);
|
|
3494
|
+
await ensureStoreDirectory(context.fs, store.path);
|
|
3495
|
+
const guarded = await guardStorePath(context.fs, store.path, destination);
|
|
3496
|
+
if (!guarded.ok) {
|
|
3497
|
+
throw fromFs(guarded.error);
|
|
3498
|
+
}
|
|
3499
|
+
if (await context.fs.exists(guarded.value.path)) {
|
|
3500
|
+
throw conflictError2(
|
|
3501
|
+
guarded.value.path,
|
|
3502
|
+
store.id,
|
|
3503
|
+
findOwner(context.state, store.id, context.skill.dirName)
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
const written = await writeDirectoryAtomic(
|
|
3507
|
+
context.fs,
|
|
3508
|
+
guarded.value.path,
|
|
3509
|
+
populateFrom(context.fs, context.source)
|
|
3510
|
+
);
|
|
3511
|
+
if (!written.ok) {
|
|
3512
|
+
if (written.error.code === "already_exists") {
|
|
3513
|
+
throw conflictError2(
|
|
3514
|
+
guarded.value.path,
|
|
3515
|
+
store.id,
|
|
3516
|
+
findOwner(context.state, store.id, context.skill.dirName)
|
|
3517
|
+
);
|
|
3518
|
+
}
|
|
3519
|
+
throw fromFs(written.error);
|
|
3520
|
+
}
|
|
3521
|
+
const hash = await contentHash(context.fs, guarded.value.path);
|
|
3522
|
+
if (!hash.ok) {
|
|
3523
|
+
await removeWithinStore(context.fs, store.path, guarded.value.path);
|
|
3524
|
+
throw fromFs(hash.error);
|
|
3525
|
+
}
|
|
3526
|
+
return {
|
|
3527
|
+
skillId: context.skill.id,
|
|
3528
|
+
storeId: store.id,
|
|
3529
|
+
mode: "copy",
|
|
3530
|
+
installedHash: hash.value,
|
|
3531
|
+
installedAt
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
function resolvePlanEntry(options, targetId, stores) {
|
|
3535
|
+
try {
|
|
3536
|
+
const target = resolveTarget(options.adapters, targetId, {
|
|
3537
|
+
shared: options.shared
|
|
3538
|
+
});
|
|
3539
|
+
const store = stores.get(target.storeId);
|
|
3540
|
+
if (store === void 0) {
|
|
3541
|
+
throw new SkillManagerError({
|
|
3542
|
+
code: "unknown_store",
|
|
3543
|
+
message: `Store "${target.storeId}" untuk Target "${targetId}" tidak dikenal.`
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
return { targetId, store };
|
|
3547
|
+
} catch (error) {
|
|
3548
|
+
return { targetId, error: toTargetError(error) };
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
function assertSharedAcknowledged(plan, acknowledgeShared) {
|
|
3552
|
+
if (acknowledgeShared === true) {
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
const shared = plan.filter((entry) => entry.store !== void 0 && !entry.store.exclusive);
|
|
3556
|
+
if (shared.length === 0) {
|
|
3557
|
+
return;
|
|
3558
|
+
}
|
|
3559
|
+
const storeList = /* @__PURE__ */ new Map();
|
|
3560
|
+
for (const entry of shared) {
|
|
3561
|
+
if (entry.store !== void 0) {
|
|
3562
|
+
storeList.set(entry.store.id, entry.store);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
const stores = [...storeList.values()];
|
|
3566
|
+
const described = stores.map((store) => `${store.id} (member: ${store.members.join(", ")})`);
|
|
3567
|
+
const targets = shared.map((entry) => entry.targetId);
|
|
3568
|
+
throw new SkillManagerError({
|
|
3569
|
+
code: "shared_confirmation_required",
|
|
3570
|
+
message: `Memasang ke grup shared akan terlihat oleh semua member terdampak: ${described.join("; ")}. Setujui dulu dengan acknowledgeShared.`,
|
|
3571
|
+
details: {
|
|
3572
|
+
stores: stores.map((store) => ({ storeId: store.id, members: [...store.members] })),
|
|
3573
|
+
targets
|
|
3574
|
+
}
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
function installationKey(installation) {
|
|
3578
|
+
return `${installation.skillId}::${installation.storeId}`;
|
|
3579
|
+
}
|
|
3580
|
+
async function persistInstallations(fs, dataRoot, state, installations) {
|
|
3581
|
+
if (installations.length === 0) {
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
const replaced = new Set(installations.map(installationKey));
|
|
3585
|
+
const kept = state.installations.filter(
|
|
3586
|
+
(installation) => !replaced.has(installationKey(installation))
|
|
3587
|
+
);
|
|
3588
|
+
const saved = await saveState(fs, dataRoot, {
|
|
3589
|
+
...state,
|
|
3590
|
+
installations: [...kept, ...installations]
|
|
3591
|
+
});
|
|
3592
|
+
if (!saved.ok) {
|
|
3593
|
+
throw new SkillManagerError({
|
|
3594
|
+
code: "state_write_failed",
|
|
3595
|
+
message: `Installation berhasil disalin tetapi state.json gagal ditulis: ${saved.error.message}`,
|
|
3596
|
+
details: saved.error
|
|
3597
|
+
});
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
async function installSkill(options) {
|
|
3601
|
+
const { fs, dataRoot } = options;
|
|
3602
|
+
const { skill, source, state } = await resolveLibrarySkill(fs, dataRoot, options.skill);
|
|
3603
|
+
const stores = new Map(
|
|
3604
|
+
listStores(options.adapters, { home: options.home, platform: options.platform }).map(
|
|
3605
|
+
(store) => [store.id, store]
|
|
3606
|
+
)
|
|
3607
|
+
);
|
|
3608
|
+
const plan = options.targets.map((targetId) => resolvePlanEntry(options, targetId, stores));
|
|
3609
|
+
assertSharedAcknowledged(plan, options.acknowledgeShared);
|
|
3610
|
+
const context = { fs, state, skill, source };
|
|
3611
|
+
const results = [];
|
|
3612
|
+
const installations = [];
|
|
3613
|
+
for (const entry of plan) {
|
|
3614
|
+
if (entry.error !== void 0) {
|
|
3615
|
+
results.push({ targetId: entry.targetId, ok: false, error: entry.error });
|
|
3616
|
+
continue;
|
|
3617
|
+
}
|
|
3618
|
+
try {
|
|
3619
|
+
const installation = await installOne(context, entry.store, (/* @__PURE__ */ new Date()).toISOString());
|
|
3620
|
+
installations.push(installation);
|
|
3621
|
+
results.push({ targetId: entry.targetId, ok: true, installation });
|
|
3622
|
+
} catch (error) {
|
|
3623
|
+
results.push({ targetId: entry.targetId, ok: false, error: toTargetError(error) });
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
await persistInstallations(fs, dataRoot, state, installations);
|
|
3627
|
+
return results;
|
|
3628
|
+
}
|
|
3629
|
+
function fromFs2(error) {
|
|
3630
|
+
return new SkillManagerError({
|
|
3631
|
+
code: error.code,
|
|
3632
|
+
message: error.message,
|
|
3633
|
+
details: { path: error.path }
|
|
3634
|
+
});
|
|
3635
|
+
}
|
|
3636
|
+
function toTargetError2(error) {
|
|
3637
|
+
if (error instanceof SkillManagerError) {
|
|
3638
|
+
return { code: error.code, message: error.message, details: error.details };
|
|
3639
|
+
}
|
|
3640
|
+
return {
|
|
3641
|
+
code: "io_error",
|
|
3642
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3643
|
+
};
|
|
3644
|
+
}
|
|
3645
|
+
function resolvePlanEntry2(options, targetId, stores) {
|
|
3646
|
+
try {
|
|
3647
|
+
const target = resolveTarget(options.adapters, targetId);
|
|
3648
|
+
const store = stores.get(target.storeId);
|
|
3649
|
+
if (store === void 0) {
|
|
3650
|
+
throw new SkillManagerError({
|
|
3651
|
+
code: "unknown_store",
|
|
3652
|
+
message: `Store "${target.storeId}" untuk Target "${targetId}" tidak dikenal.`
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
return { targetId, store };
|
|
3656
|
+
} catch (error) {
|
|
3657
|
+
return { targetId, error: toTargetError2(error) };
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
function assertSharedAcknowledged2(plan, acknowledgeShared) {
|
|
3661
|
+
if (acknowledgeShared === true) {
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
const shared = plan.filter((entry) => entry.store !== void 0 && !entry.store.exclusive);
|
|
3665
|
+
if (shared.length === 0) {
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
const storeList = /* @__PURE__ */ new Map();
|
|
3669
|
+
for (const entry of shared) {
|
|
3670
|
+
if (entry.store !== void 0) {
|
|
3671
|
+
storeList.set(entry.store.id, entry.store);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
const stores = [...storeList.values()];
|
|
3675
|
+
const described = stores.map((store) => `${store.id} (member: ${store.members.join(", ")})`);
|
|
3676
|
+
throw new SkillManagerError({
|
|
3677
|
+
code: "shared_confirmation_required",
|
|
3678
|
+
message: `Mencabut dari grup shared akan menghapus salinan untuk semua member terdampak: ${described.join("; ")}. Setujui dulu dengan acknowledgeShared.`,
|
|
3679
|
+
details: {
|
|
3680
|
+
stores: stores.map((store) => ({ storeId: store.id, members: [...store.members] })),
|
|
3681
|
+
targets: shared.map((entry) => entry.targetId)
|
|
3682
|
+
}
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
async function uninstallOne(fs, state, skill, store, force) {
|
|
3686
|
+
const installation = state.installations.find(
|
|
3687
|
+
(entry) => entry.skillId === skill.id && entry.storeId === store.id
|
|
3688
|
+
);
|
|
3689
|
+
if (installation === void 0) {
|
|
3690
|
+
throw new SkillManagerError({
|
|
3691
|
+
code: "installation_not_found",
|
|
3692
|
+
message: `Skill "${skill.name}" tidak tercatat terpasang di Store ${store.id}.`,
|
|
3693
|
+
details: { storeId: store.id, skillId: skill.id }
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
const destination = path2.join(store.path, skill.dirName);
|
|
3697
|
+
const guarded = await guardStorePath(fs, store.path, destination, { mustExist: true });
|
|
3698
|
+
if (!guarded.ok) {
|
|
3699
|
+
if (guarded.error.code === "not_found") {
|
|
3700
|
+
return installation;
|
|
3701
|
+
}
|
|
3702
|
+
throw fromFs2(guarded.error);
|
|
3703
|
+
}
|
|
3704
|
+
const hash = await contentHash(fs, guarded.value.path);
|
|
3705
|
+
if (!hash.ok) {
|
|
3706
|
+
throw fromFs2(hash.error);
|
|
3707
|
+
}
|
|
3708
|
+
if (hash.value !== installation.installedHash && !force) {
|
|
3709
|
+
throw new SkillManagerError({
|
|
3710
|
+
code: "drift_detected",
|
|
3711
|
+
message: `Isi ${guarded.value.path} sudah berubah sejak dipasang (hash berbeda). Tidak ada yang dihapus; setujui force untuk menghapus tetap.`,
|
|
3712
|
+
details: {
|
|
3713
|
+
storeId: store.id,
|
|
3714
|
+
path: guarded.value.path,
|
|
3715
|
+
expectedHash: installation.installedHash,
|
|
3716
|
+
actualHash: hash.value
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
const removed = await removeWithinStore(fs, store.path, guarded.value.path);
|
|
3721
|
+
if (!removed.ok) {
|
|
3722
|
+
throw fromFs2(removed.error);
|
|
3723
|
+
}
|
|
3724
|
+
return installation;
|
|
3725
|
+
}
|
|
3726
|
+
function installationKey2(installation) {
|
|
3727
|
+
return `${installation.skillId}::${installation.storeId}`;
|
|
3728
|
+
}
|
|
3729
|
+
async function persistRemovals(fs, dataRoot, state, removed) {
|
|
3730
|
+
if (removed.length === 0) {
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
const keys = new Set(removed.map(installationKey2));
|
|
3734
|
+
const saved = await saveState(fs, dataRoot, {
|
|
3735
|
+
...state,
|
|
3736
|
+
installations: state.installations.filter(
|
|
3737
|
+
(installation) => !keys.has(installationKey2(installation))
|
|
3738
|
+
)
|
|
3739
|
+
});
|
|
3740
|
+
if (!saved.ok) {
|
|
3741
|
+
throw new SkillManagerError({
|
|
3742
|
+
code: "state_write_failed",
|
|
3743
|
+
message: `Salinan berhasil dihapus tetapi state.json gagal ditulis: ${saved.error.message}`,
|
|
3744
|
+
details: saved.error
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
async function uninstallSkill(options) {
|
|
3749
|
+
const { fs, dataRoot } = options;
|
|
3750
|
+
const { skill, state } = await findLibrarySkill(fs, dataRoot, options.skill);
|
|
3751
|
+
const stores = new Map(
|
|
3752
|
+
listStores(options.adapters, { home: options.home, platform: options.platform }).map(
|
|
3753
|
+
(store) => [store.id, store]
|
|
3754
|
+
)
|
|
3755
|
+
);
|
|
3756
|
+
const plan = options.targets.map((targetId) => resolvePlanEntry2(options, targetId, stores));
|
|
3757
|
+
assertSharedAcknowledged2(plan, options.acknowledgeShared);
|
|
3758
|
+
const results = [];
|
|
3759
|
+
const removed = [];
|
|
3760
|
+
for (const entry of plan) {
|
|
3761
|
+
if (entry.error !== void 0) {
|
|
3762
|
+
results.push({ targetId: entry.targetId, ok: false, error: entry.error });
|
|
3763
|
+
continue;
|
|
3764
|
+
}
|
|
3765
|
+
try {
|
|
3766
|
+
const installation = await uninstallOne(
|
|
3767
|
+
fs,
|
|
3768
|
+
state,
|
|
3769
|
+
skill,
|
|
3770
|
+
entry.store,
|
|
3771
|
+
options.force === true
|
|
3772
|
+
);
|
|
3773
|
+
removed.push(installation);
|
|
3774
|
+
results.push({ targetId: entry.targetId, ok: true, installation });
|
|
3775
|
+
} catch (error) {
|
|
3776
|
+
results.push({ targetId: entry.targetId, ok: false, error: toTargetError2(error) });
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
await persistRemovals(fs, dataRoot, state, removed);
|
|
3780
|
+
return results;
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
// src/server/routes/installations.ts
|
|
3784
|
+
var targetSchema = z.string().min(1);
|
|
3785
|
+
var installSchema = z.object({
|
|
3786
|
+
skill: z.string().min(1),
|
|
3787
|
+
targets: z.array(targetSchema).min(1, "minimal satu Target"),
|
|
3788
|
+
acknowledgeShared: z.boolean().optional()
|
|
3789
|
+
});
|
|
3790
|
+
var uninstallSchema = installSchema.extend({
|
|
3791
|
+
force: z.boolean().optional()
|
|
3792
|
+
});
|
|
3793
|
+
function targetIdsByStore(adapters) {
|
|
3794
|
+
const map = /* @__PURE__ */ new Map();
|
|
3795
|
+
for (const adapter of adapters) {
|
|
3796
|
+
if (adapterCapability(adapter) !== "managed") {
|
|
3797
|
+
continue;
|
|
3798
|
+
}
|
|
3799
|
+
for (const store of adapter.stores) {
|
|
3800
|
+
if (map.has(store.path)) {
|
|
3801
|
+
continue;
|
|
3802
|
+
}
|
|
3803
|
+
try {
|
|
3804
|
+
const target = resolveTarget(adapters, { harnessId: adapter.id, scope: store.scope });
|
|
3805
|
+
map.set(target.storeId, `${target.harnessId}/${target.scope}`);
|
|
3806
|
+
} catch {
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
return map;
|
|
3811
|
+
}
|
|
3812
|
+
async function assertNoDrift(deps, adapters, skill, targets) {
|
|
3813
|
+
const report = await computeDrift(deps.fs, deps.dataRoot, {
|
|
3814
|
+
adapters,
|
|
3815
|
+
skill,
|
|
3816
|
+
home: deps.home
|
|
3817
|
+
});
|
|
3818
|
+
const byStore = new Map(report.entries.map((entry) => [entry.storeId, entry]));
|
|
3819
|
+
const requested = /* @__PURE__ */ new Set();
|
|
3820
|
+
for (const targetId of targets) {
|
|
3821
|
+
try {
|
|
3822
|
+
requested.add(resolveTarget(adapters, targetId).storeId);
|
|
3823
|
+
} catch {
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
for (const storeId of requested) {
|
|
3827
|
+
const entry = byStore.get(storeId);
|
|
3828
|
+
if (entry === void 0 || entry.status !== "modified") {
|
|
3829
|
+
continue;
|
|
3830
|
+
}
|
|
3831
|
+
throw new SkillManagerError({
|
|
3832
|
+
code: "drift_detected",
|
|
3833
|
+
message: `Isi ${entry.path} sudah berubah sejak dipasang (hash berbeda). Tidak ada yang dihapus; setujui force untuk menghapus tetap.`,
|
|
3834
|
+
details: {
|
|
3835
|
+
storeId,
|
|
3836
|
+
path: entry.path,
|
|
3837
|
+
expectedHash: entry.installedHash,
|
|
3838
|
+
actualHash: entry.storeHash
|
|
3839
|
+
}
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
function registerInstallationRoutes(app, deps) {
|
|
3844
|
+
const adaptersOf = async () => (await loadAdapters({ fs: deps.fs, dataRoot: deps.dataRoot })).adapters;
|
|
3845
|
+
app.get("/api/installations", async (c) => {
|
|
3846
|
+
const adapters = await adaptersOf();
|
|
3847
|
+
const { state } = await loadState(deps.fs, deps.dataRoot);
|
|
3848
|
+
const names = new Map(state.library.map((skill) => [skill.id, skill.name]));
|
|
3849
|
+
const targetIds = targetIdsByStore(adapters);
|
|
3850
|
+
const drift = await computeDrift(deps.fs, deps.dataRoot, { adapters, home: deps.home });
|
|
3851
|
+
const driftByKey = new Map(
|
|
3852
|
+
drift.entries.filter((entry) => entry.skillId !== void 0).map((entry) => [`${entry.skillId}::${entry.storeId}`, entry.status])
|
|
3853
|
+
);
|
|
3854
|
+
const installations = [];
|
|
3855
|
+
for (const installation of state.installations) {
|
|
3856
|
+
const skillName = names.get(installation.skillId);
|
|
3857
|
+
if (skillName === void 0) {
|
|
3858
|
+
continue;
|
|
3859
|
+
}
|
|
3860
|
+
const status = driftByKey.get(`${installation.skillId}::${installation.storeId}`);
|
|
3861
|
+
installations.push({
|
|
3862
|
+
skillId: installation.skillId,
|
|
3863
|
+
skillName,
|
|
3864
|
+
targetId: targetIds.get(installation.storeId) ?? installation.storeId,
|
|
3865
|
+
storeId: installation.storeId,
|
|
3866
|
+
mode: installation.mode,
|
|
3867
|
+
installedHash: installation.installedHash,
|
|
3868
|
+
installedAt: installation.installedAt,
|
|
3869
|
+
...status === void 0 ? {} : { drift: status }
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
return c.json({ installations });
|
|
3873
|
+
});
|
|
3874
|
+
app.post("/api/installations", async (c) => {
|
|
3875
|
+
const body = await readJson(c, installSchema);
|
|
3876
|
+
const adapters = await adaptersOf();
|
|
3877
|
+
const results = await installSkill({
|
|
3878
|
+
fs: deps.fs,
|
|
3879
|
+
dataRoot: deps.dataRoot,
|
|
3880
|
+
adapters,
|
|
3881
|
+
home: deps.home,
|
|
3882
|
+
skill: body.skill,
|
|
3883
|
+
targets: body.targets,
|
|
3884
|
+
...body.acknowledgeShared === void 0 ? {} : { acknowledgeShared: body.acknowledgeShared }
|
|
3885
|
+
});
|
|
3886
|
+
return c.json({ results });
|
|
3887
|
+
});
|
|
3888
|
+
app.delete("/api/installations", async (c) => {
|
|
3889
|
+
const body = await readJson(c, uninstallSchema);
|
|
3890
|
+
const adapters = await adaptersOf();
|
|
3891
|
+
if (body.force !== true) {
|
|
3892
|
+
await assertNoDrift(deps, adapters, body.skill, body.targets);
|
|
3893
|
+
}
|
|
3894
|
+
const results = await uninstallSkill({
|
|
3895
|
+
fs: deps.fs,
|
|
3896
|
+
dataRoot: deps.dataRoot,
|
|
3897
|
+
adapters,
|
|
3898
|
+
home: deps.home,
|
|
3899
|
+
skill: body.skill,
|
|
3900
|
+
targets: body.targets,
|
|
3901
|
+
...body.force === void 0 ? {} : { force: body.force },
|
|
3902
|
+
...body.acknowledgeShared === void 0 ? {} : { acknowledgeShared: body.acknowledgeShared }
|
|
3903
|
+
});
|
|
3904
|
+
return c.json({ results });
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
function fromFs3(error) {
|
|
3908
|
+
return new SkillManagerError({
|
|
3909
|
+
code: error.code,
|
|
3910
|
+
message: error.message,
|
|
3911
|
+
details: { path: error.path }
|
|
3912
|
+
});
|
|
3913
|
+
}
|
|
3914
|
+
function toTargetError3(error) {
|
|
3915
|
+
if (error instanceof SkillManagerError) {
|
|
3916
|
+
return { code: error.code, message: error.message, details: error.details };
|
|
3917
|
+
}
|
|
3918
|
+
return {
|
|
3919
|
+
code: "io_error",
|
|
3920
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
function installationKey3(installation) {
|
|
3924
|
+
return `${installation.skillId}::${installation.storeId}`;
|
|
3925
|
+
}
|
|
3926
|
+
function targetIdForStore(adapters, storeId) {
|
|
3927
|
+
for (const adapter of adapters) {
|
|
3928
|
+
if (adapterCapability(adapter) !== "managed") {
|
|
3929
|
+
continue;
|
|
3930
|
+
}
|
|
3931
|
+
for (const store of adapter.stores) {
|
|
3932
|
+
if (store.path === storeId) {
|
|
3933
|
+
return `${adapter.id}/${store.scope}`;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return void 0;
|
|
3938
|
+
}
|
|
3939
|
+
function resolvePlan(options, state, skill, stores) {
|
|
3940
|
+
const findInstallation = (storeId) => state.installations.find(
|
|
3941
|
+
(installation) => installation.skillId === skill.id && installation.storeId === storeId
|
|
3942
|
+
);
|
|
3943
|
+
if (options.targets !== void 0) {
|
|
3944
|
+
return options.targets.map((targetId) => {
|
|
3945
|
+
try {
|
|
3946
|
+
const target = resolveTarget(options.adapters, targetId);
|
|
3947
|
+
const store = stores.get(target.storeId);
|
|
3948
|
+
if (store === void 0) {
|
|
3949
|
+
throw new SkillManagerError({
|
|
3950
|
+
code: "unknown_store",
|
|
3951
|
+
message: `Store "${target.storeId}" untuk Target "${targetId}" tidak dikenal.`
|
|
3952
|
+
});
|
|
3953
|
+
}
|
|
3954
|
+
const installation = findInstallation(store.id);
|
|
3955
|
+
if (installation === void 0) {
|
|
3956
|
+
throw new SkillManagerError({
|
|
3957
|
+
code: "installation_not_found",
|
|
3958
|
+
message: `Skill "${skill.name}" tidak tercatat terpasang di Store ${store.id}.`,
|
|
3959
|
+
details: { storeId: store.id, skillId: skill.id }
|
|
3960
|
+
});
|
|
3961
|
+
}
|
|
3962
|
+
return { targetId, store, installation };
|
|
3963
|
+
} catch (error) {
|
|
3964
|
+
return { targetId, error: toTargetError3(error) };
|
|
3965
|
+
}
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
return state.installations.filter((installation) => installation.skillId === skill.id).map((installation) => {
|
|
3969
|
+
const store = stores.get(installation.storeId);
|
|
3970
|
+
const targetId = targetIdForStore(options.adapters, installation.storeId) ?? installation.storeId;
|
|
3971
|
+
if (store === void 0) {
|
|
3972
|
+
return {
|
|
3973
|
+
targetId,
|
|
3974
|
+
error: {
|
|
3975
|
+
code: "unknown_store",
|
|
3976
|
+
message: `Store "${installation.storeId}" tidak dikenal adapter.`
|
|
3977
|
+
}
|
|
3978
|
+
};
|
|
3979
|
+
}
|
|
3980
|
+
return { targetId, store, installation };
|
|
3981
|
+
});
|
|
3982
|
+
}
|
|
3983
|
+
async function ensureStoreDirectory2(fs, storePath) {
|
|
3984
|
+
const stat2 = await fs.stat(storePath);
|
|
3985
|
+
if (stat2.ok) {
|
|
3986
|
+
if (stat2.value.kind !== "directory") {
|
|
3987
|
+
throw new SkillManagerError({
|
|
3988
|
+
code: "not_a_directory",
|
|
3989
|
+
message: `Store ${storePath} bukan direktori.`,
|
|
3990
|
+
details: { path: storePath }
|
|
3991
|
+
});
|
|
3992
|
+
}
|
|
3993
|
+
return;
|
|
3994
|
+
}
|
|
3995
|
+
if (stat2.error.code !== "not_found") {
|
|
3996
|
+
throw fromFs3(stat2.error);
|
|
3997
|
+
}
|
|
3998
|
+
const made = await fs.mkdir(storePath, { recursive: true });
|
|
3999
|
+
if (!made.ok) {
|
|
4000
|
+
throw fromFs3(made.error);
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
function populateFrom2(fs, source) {
|
|
4004
|
+
return async (tempDir) => {
|
|
4005
|
+
const listing = await fs.readdir(source);
|
|
4006
|
+
if (!listing.ok) {
|
|
4007
|
+
return listing;
|
|
4008
|
+
}
|
|
4009
|
+
for (const entry of listing.value) {
|
|
4010
|
+
const copied = await fs.copy(path2.join(source, entry.name), path2.join(tempDir, entry.name));
|
|
4011
|
+
if (!copied.ok) {
|
|
4012
|
+
return copied;
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
return { ok: true, value: void 0 };
|
|
4016
|
+
};
|
|
4017
|
+
}
|
|
4018
|
+
async function swapDirectory(fs, containerDir, destination, label, populate) {
|
|
4019
|
+
const staging = path2.join(containerDir, `${TEMP_PREFIX}${label}-${randomUUID()}`);
|
|
4020
|
+
const written = await writeDirectoryAtomic(fs, staging, populate);
|
|
4021
|
+
if (!written.ok) {
|
|
4022
|
+
throw fromFs3(written.error);
|
|
4023
|
+
}
|
|
4024
|
+
const backup = path2.join(containerDir, `${TEMP_PREFIX}backup-${randomUUID()}`);
|
|
4025
|
+
const hadTarget = await fs.exists(destination);
|
|
4026
|
+
if (hadTarget) {
|
|
4027
|
+
const moved = await fs.rename(destination, backup);
|
|
4028
|
+
if (!moved.ok) {
|
|
4029
|
+
await fs.remove(staging, { recursive: true });
|
|
4030
|
+
throw fromFs3(moved.error);
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
const swapped = await fs.rename(staging, destination);
|
|
4034
|
+
if (!swapped.ok) {
|
|
4035
|
+
if (hadTarget) {
|
|
4036
|
+
await fs.rename(backup, destination);
|
|
4037
|
+
}
|
|
4038
|
+
await fs.remove(staging, { recursive: true });
|
|
4039
|
+
throw fromFs3(swapped.error);
|
|
4040
|
+
}
|
|
4041
|
+
if (hadTarget) {
|
|
4042
|
+
await fs.remove(backup, { recursive: true });
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
async function restoreOne(fs, skill, source, store, installation) {
|
|
4046
|
+
await ensureStoreDirectory2(fs, store.path);
|
|
4047
|
+
const destination = path2.join(store.path, skill.dirName);
|
|
4048
|
+
const guarded = await guardStorePath(fs, store.path, destination);
|
|
4049
|
+
if (!guarded.ok) {
|
|
4050
|
+
throw fromFs3(guarded.error);
|
|
4051
|
+
}
|
|
4052
|
+
const target = guarded.value.path;
|
|
4053
|
+
await swapDirectory(fs, store.path, target, "restore", populateFrom2(fs, source));
|
|
4054
|
+
const hash = await contentHash(fs, target);
|
|
4055
|
+
if (!hash.ok) {
|
|
4056
|
+
throw fromFs3(hash.error);
|
|
4057
|
+
}
|
|
4058
|
+
return { ...installation, installedHash: hash.value };
|
|
4059
|
+
}
|
|
4060
|
+
async function adoptOne(fs, dataRoot, skill, store) {
|
|
4061
|
+
const destination = path2.join(store.path, skill.dirName);
|
|
4062
|
+
const stat2 = await fs.stat(destination);
|
|
4063
|
+
if (!stat2.ok) {
|
|
4064
|
+
throw new SkillManagerError({
|
|
4065
|
+
code: "drift_missing",
|
|
4066
|
+
message: `Isi ${destination} tidak ada; tidak ada yang bisa diadopsi.`,
|
|
4067
|
+
details: { path: destination, storeId: store.id }
|
|
4068
|
+
});
|
|
4069
|
+
}
|
|
4070
|
+
if (stat2.value.kind !== "directory") {
|
|
4071
|
+
throw new SkillManagerError({
|
|
4072
|
+
code: "not_a_directory",
|
|
4073
|
+
message: `${destination} bukan direktori; tidak ada yang bisa diadopsi.`,
|
|
4074
|
+
details: { path: destination, storeId: store.id }
|
|
4075
|
+
});
|
|
4076
|
+
}
|
|
4077
|
+
const libraryDir = libraryRoot(dataRoot);
|
|
4078
|
+
const made = await fs.mkdir(libraryDir, { recursive: true });
|
|
4079
|
+
if (!made.ok) {
|
|
4080
|
+
throw fromFs3(made.error);
|
|
4081
|
+
}
|
|
4082
|
+
await swapDirectory(
|
|
4083
|
+
fs,
|
|
4084
|
+
libraryDir,
|
|
4085
|
+
path2.join(libraryDir, skill.dirName),
|
|
4086
|
+
"adopt",
|
|
4087
|
+
populateFrom2(fs, destination)
|
|
4088
|
+
);
|
|
4089
|
+
const hash = await contentHash(fs, path2.join(libraryDir, skill.dirName));
|
|
4090
|
+
if (!hash.ok) {
|
|
4091
|
+
throw fromFs3(hash.error);
|
|
4092
|
+
}
|
|
4093
|
+
return hash.value;
|
|
4094
|
+
}
|
|
4095
|
+
async function persist(fs, dataRoot, state, skillId, libraryHash, installations) {
|
|
4096
|
+
if (installations.length === 0 && libraryHash === void 0) {
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
const replaced = new Set(installations.map(installationKey3));
|
|
4100
|
+
const kept = state.installations.filter(
|
|
4101
|
+
(installation) => !replaced.has(installationKey3(installation))
|
|
4102
|
+
);
|
|
4103
|
+
const library = libraryHash === void 0 ? state.library : state.library.map(
|
|
4104
|
+
(skill) => skill.id === skillId ? { ...skill, contentHash: libraryHash } : skill
|
|
4105
|
+
);
|
|
4106
|
+
const saved = await saveState(fs, dataRoot, {
|
|
4107
|
+
...state,
|
|
4108
|
+
library,
|
|
4109
|
+
installations: [...kept, ...installations]
|
|
4110
|
+
});
|
|
4111
|
+
if (!saved.ok) {
|
|
4112
|
+
throw new SkillManagerError({
|
|
4113
|
+
code: "state_write_failed",
|
|
4114
|
+
message: `Sinkron selesai tetapi state.json gagal ditulis: ${saved.error.message}`,
|
|
4115
|
+
details: saved.error
|
|
4116
|
+
});
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
async function syncSkill(options) {
|
|
4120
|
+
const { fs, dataRoot } = options;
|
|
4121
|
+
if (options.confirm !== true) {
|
|
4122
|
+
throw new SkillManagerError({
|
|
4123
|
+
code: "confirmation_required",
|
|
4124
|
+
message: options.action === "restore" ? "Restore menimpa perubahan user di Store. Setujui dulu dengan confirm: true." : "Adopt mengubah hash Library. Setujui dulu dengan confirm: true.",
|
|
4125
|
+
details: { action: options.action }
|
|
4126
|
+
});
|
|
4127
|
+
}
|
|
4128
|
+
const { state } = await loadState(fs, dataRoot);
|
|
4129
|
+
const skill = state.library.find(
|
|
4130
|
+
(entry) => entry.name === options.skill || entry.id === options.skill
|
|
4131
|
+
);
|
|
4132
|
+
if (skill === void 0) {
|
|
4133
|
+
throw new SkillManagerError({
|
|
4134
|
+
code: "skill_not_found",
|
|
4135
|
+
message: `Skill "${options.skill}" tidak ada di Library.`
|
|
4136
|
+
});
|
|
4137
|
+
}
|
|
4138
|
+
let source = "";
|
|
4139
|
+
if (options.action === "restore") {
|
|
4140
|
+
source = path2.join(libraryRoot(dataRoot), skill.dirName);
|
|
4141
|
+
const stat2 = await fs.stat(source);
|
|
4142
|
+
if (!stat2.ok || stat2.value.kind !== "directory") {
|
|
4143
|
+
throw new SkillManagerError({
|
|
4144
|
+
code: "skill_not_found",
|
|
4145
|
+
message: `Isi skill "${skill.name}" tidak ditemukan di Library (${source}).`,
|
|
4146
|
+
details: { path: source }
|
|
4147
|
+
});
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
const stores = new Map(
|
|
4151
|
+
listStores(options.adapters, { home: options.home, platform: options.platform }).map(
|
|
4152
|
+
(store) => [store.id, store]
|
|
4153
|
+
)
|
|
4154
|
+
);
|
|
4155
|
+
const plan = resolvePlan(options, state, skill, stores);
|
|
4156
|
+
const results = [];
|
|
4157
|
+
const updated = [];
|
|
4158
|
+
let libraryHash;
|
|
4159
|
+
for (const entry of plan) {
|
|
4160
|
+
if (entry.error !== void 0) {
|
|
4161
|
+
results.push({ targetId: entry.targetId, ok: false, error: entry.error });
|
|
4162
|
+
continue;
|
|
4163
|
+
}
|
|
4164
|
+
try {
|
|
4165
|
+
if (options.action === "restore") {
|
|
4166
|
+
const installation2 = await restoreOne(fs, skill, source, entry.store, entry.installation);
|
|
4167
|
+
updated.push(installation2);
|
|
4168
|
+
results.push({ targetId: entry.targetId, ok: true, installation: installation2 });
|
|
4169
|
+
continue;
|
|
4170
|
+
}
|
|
4171
|
+
const hash = await adoptOne(fs, dataRoot, skill, entry.store);
|
|
4172
|
+
if (libraryHash !== void 0 && libraryHash !== hash) {
|
|
4173
|
+
throw new SkillManagerError({
|
|
4174
|
+
code: "adopt_conflict",
|
|
4175
|
+
message: "Isi Target lain berbeda; satu Library tidak bisa mengadopsi dua isi berbeda. Adopsi Target ini secara terpisah setelah membereskan perbedaannya.",
|
|
4176
|
+
details: { storeId: entry.store.id, targetId: entry.targetId }
|
|
4177
|
+
});
|
|
4178
|
+
}
|
|
4179
|
+
libraryHash = hash;
|
|
4180
|
+
const installation = { ...entry.installation, installedHash: hash };
|
|
4181
|
+
updated.push(installation);
|
|
4182
|
+
results.push({ targetId: entry.targetId, ok: true, installation });
|
|
4183
|
+
} catch (error) {
|
|
4184
|
+
results.push({ targetId: entry.targetId, ok: false, error: toTargetError3(error) });
|
|
4185
|
+
}
|
|
4186
|
+
}
|
|
4187
|
+
await persist(fs, dataRoot, state, skill.id, libraryHash, updated);
|
|
4188
|
+
return results;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
// src/server/routes/sync.ts
|
|
4192
|
+
var syncSchema = z.object({
|
|
4193
|
+
skill: z.string().min(1),
|
|
4194
|
+
action: z.enum(["restore", "adopt"]),
|
|
4195
|
+
/**
|
|
4196
|
+
* Wajib `true`. Tanpa ini domain menolak dengan `confirmation_required`
|
|
4197
|
+
* (AC-8: "tanpa konfirmasi tidak ada penimpaan"), sehingga gerbang
|
|
4198
|
+
* konfirmasi tetap berlaku juga bagi pemanggil HTTP langsung — bukan hanya
|
|
4199
|
+
* bagi UI yang kebetulan menampilkan dialog.
|
|
4200
|
+
*/
|
|
4201
|
+
confirm: z.boolean().optional()
|
|
4202
|
+
});
|
|
4203
|
+
function registerSyncRoutes(app, deps) {
|
|
4204
|
+
app.post("/api/sync", async (c) => {
|
|
4205
|
+
const body = await readJson(c, syncSchema);
|
|
4206
|
+
const { adapters } = await loadAdapters({ fs: deps.fs, dataRoot: deps.dataRoot });
|
|
4207
|
+
await syncSkill({
|
|
4208
|
+
fs: deps.fs,
|
|
4209
|
+
dataRoot: deps.dataRoot,
|
|
4210
|
+
adapters,
|
|
4211
|
+
home: deps.home,
|
|
4212
|
+
skill: body.skill,
|
|
4213
|
+
action: body.action,
|
|
4214
|
+
confirm: body.confirm === true
|
|
4215
|
+
});
|
|
4216
|
+
const { state } = await loadState(deps.fs, deps.dataRoot);
|
|
4217
|
+
const skill = state.library.find(
|
|
4218
|
+
(entry) => entry.name === body.skill || entry.id === body.skill
|
|
4219
|
+
);
|
|
4220
|
+
if (skill === void 0) {
|
|
4221
|
+
throw new SkillManagerError({
|
|
4222
|
+
code: "skill_not_found",
|
|
4223
|
+
message: `Skill "${body.skill}" tidak ada di Library.`
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
return c.json({ skill: summarizeSkill(skill) });
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
4229
|
+
|
|
4230
|
+
// src/server/app.ts
|
|
4231
|
+
function defaultServerDeps() {
|
|
4232
|
+
return {
|
|
4233
|
+
fs: new NodeFileSystem(),
|
|
4234
|
+
runner: new NodeProcessRunner(),
|
|
4235
|
+
dataRoot: resolveDataRoot(),
|
|
4236
|
+
home: homedir()
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
var SESSION_COOKIE = "sm_session";
|
|
4240
|
+
var APP_VERSION = package_default.version;
|
|
4241
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1"]);
|
|
4242
|
+
var STATUS_BY_CODE = {
|
|
4243
|
+
bad_request: 400,
|
|
4244
|
+
unauthorized: 401,
|
|
4245
|
+
forbidden: 403,
|
|
4246
|
+
not_found: 404,
|
|
4247
|
+
source_not_found: 404,
|
|
4248
|
+
skill_not_found: 404,
|
|
4249
|
+
installation_not_found: 404,
|
|
4250
|
+
staging_not_found: 404,
|
|
4251
|
+
harness_not_found: 404,
|
|
4252
|
+
web_not_built: 404,
|
|
4253
|
+
conflict: 409,
|
|
4254
|
+
install_conflict: 409,
|
|
4255
|
+
name_conflict: 409,
|
|
4256
|
+
drift_detected: 409,
|
|
4257
|
+
adopt_conflict: 409,
|
|
4258
|
+
shared_confirmation_required: 409,
|
|
4259
|
+
confirmation_required: 409,
|
|
4260
|
+
already_exists: 409,
|
|
4261
|
+
not_empty: 409,
|
|
4262
|
+
unsupported_version: 500,
|
|
4263
|
+
state_unsupported_version: 500,
|
|
4264
|
+
state_write_failed: 500,
|
|
4265
|
+
import_failed: 500,
|
|
4266
|
+
io_error: 500,
|
|
4267
|
+
permission_denied: 500,
|
|
4268
|
+
process_failed: 500,
|
|
4269
|
+
dependency_run_failed: 500
|
|
4270
|
+
};
|
|
4271
|
+
function statusForCode(code) {
|
|
4272
|
+
return STATUS_BY_CODE[code] ?? 400;
|
|
4273
|
+
}
|
|
4274
|
+
function errorBody(code, message, details) {
|
|
4275
|
+
if (details === void 0) {
|
|
4276
|
+
return { error: { code, message } };
|
|
4277
|
+
}
|
|
4278
|
+
return { error: { code, message, details } };
|
|
4279
|
+
}
|
|
4280
|
+
function tokensMatch(given, expected) {
|
|
4281
|
+
const givenBuffer = Buffer.from(given);
|
|
4282
|
+
const expectedBuffer = Buffer.from(expected);
|
|
4283
|
+
if (givenBuffer.length !== expectedBuffer.length) {
|
|
4284
|
+
return false;
|
|
4285
|
+
}
|
|
4286
|
+
return timingSafeEqual(givenBuffer, expectedBuffer);
|
|
4287
|
+
}
|
|
4288
|
+
function validPort(value) {
|
|
4289
|
+
if (!/^\d{1,5}$/.test(value)) {
|
|
4290
|
+
return false;
|
|
4291
|
+
}
|
|
4292
|
+
const port = Number(value);
|
|
4293
|
+
return port >= 0 && port <= 65535;
|
|
4294
|
+
}
|
|
4295
|
+
function parseHostHeader(value) {
|
|
4296
|
+
if (value.startsWith("[")) {
|
|
4297
|
+
const closingBracket = value.indexOf("]");
|
|
4298
|
+
if (closingBracket === -1) {
|
|
4299
|
+
return void 0;
|
|
4300
|
+
}
|
|
4301
|
+
const hostname2 = value.slice(1, closingBracket);
|
|
4302
|
+
if (!hostname2) {
|
|
4303
|
+
return void 0;
|
|
4304
|
+
}
|
|
4305
|
+
const remainder = value.slice(closingBracket + 1);
|
|
4306
|
+
if (remainder !== "") {
|
|
4307
|
+
if (!remainder.startsWith(":")) {
|
|
4308
|
+
return void 0;
|
|
4309
|
+
}
|
|
4310
|
+
if (!validPort(remainder.slice(1))) {
|
|
4311
|
+
return void 0;
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
return hostname2;
|
|
4315
|
+
}
|
|
4316
|
+
const parts = value.split(":");
|
|
4317
|
+
if (parts.length > 2) {
|
|
4318
|
+
return void 0;
|
|
4319
|
+
}
|
|
4320
|
+
const hostname = parts[0] ?? "";
|
|
4321
|
+
if (!hostname) {
|
|
4322
|
+
return void 0;
|
|
4323
|
+
}
|
|
4324
|
+
const port = parts[1];
|
|
4325
|
+
if (port !== void 0 && !validPort(port)) {
|
|
4326
|
+
return void 0;
|
|
4327
|
+
}
|
|
4328
|
+
return hostname;
|
|
4329
|
+
}
|
|
4330
|
+
function checkHost(hostHeader) {
|
|
4331
|
+
if (hostHeader === void 0) {
|
|
4332
|
+
return 400;
|
|
4333
|
+
}
|
|
4334
|
+
const value = hostHeader.trim().toLowerCase();
|
|
4335
|
+
if (!value) {
|
|
4336
|
+
return 400;
|
|
4337
|
+
}
|
|
4338
|
+
const hostname = parseHostHeader(value);
|
|
4339
|
+
if (hostname === void 0) {
|
|
4340
|
+
return 400;
|
|
4341
|
+
}
|
|
4342
|
+
return LOOPBACK_HOSTNAMES.has(hostname) ? "ok" : 403;
|
|
4343
|
+
}
|
|
4344
|
+
function isSpaNavigation(c) {
|
|
4345
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
4346
|
+
return false;
|
|
4347
|
+
}
|
|
4348
|
+
const accept = c.req.header("accept")?.toLowerCase() ?? "";
|
|
4349
|
+
if (!accept.includes("text/html")) {
|
|
4350
|
+
return false;
|
|
4351
|
+
}
|
|
4352
|
+
return extname(c.req.path) === "";
|
|
4353
|
+
}
|
|
4354
|
+
function handleAppError(error, c) {
|
|
4355
|
+
if (error instanceof SkillManagerError) {
|
|
4356
|
+
return c.json(errorBody(error.code, error.message, error.details), statusForCode(error.code));
|
|
4357
|
+
}
|
|
4358
|
+
return c.json(errorBody("internal_error", "Terjadi kesalahan internal."), 500);
|
|
4359
|
+
}
|
|
4360
|
+
function createApp(options) {
|
|
4361
|
+
const app = new Hono();
|
|
4362
|
+
const deps = options.deps ?? defaultServerDeps();
|
|
4363
|
+
app.use("*", async (c, next) => {
|
|
4364
|
+
const host = checkHost(c.req.header("host"));
|
|
4365
|
+
if (host === 400) {
|
|
4366
|
+
return c.json(errorBody("bad_request", "Header Host tidak valid."), 400);
|
|
4367
|
+
}
|
|
4368
|
+
if (host === 403) {
|
|
4369
|
+
return c.json(
|
|
4370
|
+
errorBody("forbidden", "Header Host harus menunjuk ke 127.0.0.1 atau localhost."),
|
|
4371
|
+
403
|
|
4372
|
+
);
|
|
4373
|
+
}
|
|
4374
|
+
await next();
|
|
4375
|
+
});
|
|
4376
|
+
app.get("/auth", (c) => {
|
|
4377
|
+
const candidate = c.req.query("token");
|
|
4378
|
+
if (!candidate || !tokensMatch(candidate, options.token)) {
|
|
4379
|
+
return c.json(errorBody("unauthorized", "Token tidak valid."), 401);
|
|
4380
|
+
}
|
|
4381
|
+
setCookie(c, SESSION_COOKIE, options.token, {
|
|
4382
|
+
httpOnly: true,
|
|
4383
|
+
sameSite: "Strict",
|
|
4384
|
+
path: "/"
|
|
4385
|
+
});
|
|
4386
|
+
c.header("Cache-Control", "no-store");
|
|
4387
|
+
return c.redirect("/", 302);
|
|
4388
|
+
});
|
|
4389
|
+
app.use("*", async (c, next) => {
|
|
4390
|
+
const session = getCookie(c, SESSION_COOKIE);
|
|
4391
|
+
if (!session || !tokensMatch(session, options.token)) {
|
|
4392
|
+
return c.json(
|
|
4393
|
+
errorBody("unauthorized", "Sesi tidak valid. Buka URL ber-token yang dicetak server."),
|
|
4394
|
+
401
|
|
4395
|
+
);
|
|
4396
|
+
}
|
|
4397
|
+
await next();
|
|
4398
|
+
});
|
|
4399
|
+
app.get("/api/health", (c) => c.json({ ok: true, version: APP_VERSION }));
|
|
4400
|
+
registerHarnessRoutes(app, deps);
|
|
4401
|
+
registerSkillRoutes(app, deps);
|
|
4402
|
+
registerInstallationRoutes(app, deps);
|
|
4403
|
+
registerSyncRoutes(app, deps);
|
|
4404
|
+
registerDoctorRoutes(app, deps);
|
|
4405
|
+
registerDepsRoutes(app, deps);
|
|
4406
|
+
registerConnectRoutes(app, deps);
|
|
4407
|
+
app.use("*", serveStatic({ root: options.webRoot }));
|
|
4408
|
+
app.all("*", async (c) => {
|
|
4409
|
+
if (c.req.path === "/api" || c.req.path.startsWith("/api/") || !isSpaNavigation(c)) {
|
|
4410
|
+
return c.notFound();
|
|
4411
|
+
}
|
|
4412
|
+
try {
|
|
4413
|
+
const html = await readFile(join(options.webRoot, "index.html"), "utf8");
|
|
4414
|
+
return c.html(html);
|
|
4415
|
+
} catch {
|
|
4416
|
+
return c.json(
|
|
4417
|
+
errorBody("web_not_built", "UI belum dibangun. Jalankan `pnpm run build` lebih dulu."),
|
|
4418
|
+
404
|
|
4419
|
+
);
|
|
4420
|
+
}
|
|
4421
|
+
});
|
|
4422
|
+
app.notFound(
|
|
4423
|
+
(c) => c.json(errorBody("not_found", `Rute ${c.req.method} ${c.req.path} tidak ditemukan.`), 404)
|
|
4424
|
+
);
|
|
4425
|
+
app.onError(handleAppError);
|
|
4426
|
+
return app;
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
// src/server/index.ts
|
|
4430
|
+
function resolveDefaultWebRoot() {
|
|
4431
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
4432
|
+
const bundled = join(moduleDir, "web");
|
|
4433
|
+
if (existsSync(join(bundled, "index.html"))) {
|
|
4434
|
+
return bundled;
|
|
4435
|
+
}
|
|
4436
|
+
const fromSource = join(moduleDir, "..", "web");
|
|
4437
|
+
if (existsSync(join(fromSource, "index.html"))) {
|
|
4438
|
+
return fromSource;
|
|
4439
|
+
}
|
|
4440
|
+
return bundled;
|
|
4441
|
+
}
|
|
4442
|
+
var DEFAULT_WEB_ROOT = resolveDefaultWebRoot();
|
|
4443
|
+
async function createServer(options = {}) {
|
|
4444
|
+
const host = "127.0.0.1";
|
|
4445
|
+
const requestedPort = options.port ?? 0;
|
|
4446
|
+
const token = options.token ?? randomBytes(32).toString("hex");
|
|
4447
|
+
const webRoot = options.webRoot ?? DEFAULT_WEB_ROOT;
|
|
4448
|
+
const app = createApp({ token, webRoot });
|
|
4449
|
+
const server = createServer$1(getRequestListener(app.fetch));
|
|
4450
|
+
await new Promise((resolve, reject) => {
|
|
4451
|
+
const onError = (error) => {
|
|
4452
|
+
server.off("listening", onListening);
|
|
4453
|
+
reject(error);
|
|
4454
|
+
};
|
|
4455
|
+
const onListening = () => {
|
|
4456
|
+
server.off("error", onError);
|
|
4457
|
+
resolve();
|
|
4458
|
+
};
|
|
4459
|
+
server.once("error", onError);
|
|
4460
|
+
server.once("listening", onListening);
|
|
4461
|
+
server.listen(requestedPort, host);
|
|
4462
|
+
});
|
|
4463
|
+
const address = server.address();
|
|
4464
|
+
if (address === null || typeof address === "string") {
|
|
4465
|
+
server.close();
|
|
4466
|
+
throw new Error("Server tidak mengembalikan alamat TCP.");
|
|
4467
|
+
}
|
|
4468
|
+
const port = address.port;
|
|
4469
|
+
const origin = `http://${host}:${port}`;
|
|
4470
|
+
return {
|
|
4471
|
+
server,
|
|
4472
|
+
host,
|
|
4473
|
+
port,
|
|
4474
|
+
token,
|
|
4475
|
+
origin,
|
|
4476
|
+
url: `${origin}/auth?token=${token}`,
|
|
4477
|
+
close: () => new Promise((resolve, reject) => {
|
|
4478
|
+
server.close((error) => {
|
|
4479
|
+
if (error) {
|
|
4480
|
+
reject(error);
|
|
4481
|
+
} else {
|
|
4482
|
+
resolve();
|
|
4483
|
+
}
|
|
4484
|
+
});
|
|
4485
|
+
server.closeAllConnections();
|
|
4486
|
+
})
|
|
4487
|
+
};
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4490
|
+
export { createServer };
|