@vincemakes/kiso-web 0.0.0-stage → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/main/agent.js +80 -0
  4. package/dist/main/approvals.js +68 -0
  5. package/dist/main/artifacts.js +166 -0
  6. package/dist/main/auth.js +109 -0
  7. package/dist/main/context.js +20 -0
  8. package/dist/main/desk-api.js +249 -0
  9. package/dist/main/env.js +104 -0
  10. package/dist/main/faux.js +12 -0
  11. package/dist/main/files.js +156 -0
  12. package/dist/main/folder-info.js +36 -0
  13. package/dist/main/folders.js +102 -0
  14. package/dist/main/fork.js +25 -0
  15. package/dist/main/fs-routes.js +69 -0
  16. package/dist/main/git.js +117 -0
  17. package/dist/main/headless.js +128 -0
  18. package/dist/main/history-worker.js +18 -0
  19. package/dist/main/history.js +151 -0
  20. package/dist/main/host.js +210 -0
  21. package/dist/main/login.js +27 -0
  22. package/dist/main/names.js +15 -0
  23. package/dist/main/observed-revision.js +69 -0
  24. package/dist/main/preview.js +141 -0
  25. package/dist/main/sanitize.js +28 -0
  26. package/dist/main/settings.js +242 -0
  27. package/dist/main/space.js +198 -0
  28. package/dist/main/tasks.js +92 -0
  29. package/dist/main/times.js +28 -0
  30. package/dist/main/usage.js +102 -0
  31. package/dist/main/works.js +21 -0
  32. package/dist/shared/files.js +2 -0
  33. package/dist/shared/frames.js +13 -0
  34. package/dist/shared/git.js +2 -0
  35. package/dist/shared/history.js +5 -0
  36. package/dist/shared/settings.js +2 -0
  37. package/out/renderer/assets/index-C3fCoBdq.css +1 -0
  38. package/out/renderer/assets/index-DiDTmq9b.js +45 -0
  39. package/out/renderer/favicon-32.png +0 -0
  40. package/out/renderer/favicon.svg +1 -0
  41. package/out/renderer/icon.png +0 -0
  42. package/out/renderer/index.html +18 -0
  43. package/out/renderer/manifest.webmanifest +10 -0
  44. package/package.json +64 -5
@@ -0,0 +1,249 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { canonicalPath, claimProjectDir, projectDirFor } from "@vincemakes/kiso-code/config";
4
+ import { atFiles, deskRead, SAFE_ID } from "./fs-routes.js";
5
+ import { contextParts } from "./context.js";
6
+ import { rename } from "./names.js";
7
+ import { forkWork } from "./fork.js";
8
+ import { WORK_ACTS, workAct } from "./works.js";
9
+ import { commit, gitOf, openPr, prDraft } from "./git.js";
10
+ import { existing, Refusal, resolveInside } from "./files.js";
11
+ import { artifactKind } from "./artifacts.js";
12
+ import { listDirs, validateFolder } from "./folders.js";
13
+ import { folderInfo } from "./folder-info.js";
14
+ const json = (status, body) => ({ status, body });
15
+ const BODY_CAP = 16 * 1024;
16
+ async function readJson(req) {
17
+ let text = "";
18
+ for await (const chunk of req) {
19
+ text += chunk;
20
+ if (text.length > BODY_CAP)
21
+ throw new Refusal(413, "too large");
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(text);
25
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
26
+ return parsed;
27
+ }
28
+ catch {
29
+ // fall through
30
+ }
31
+ throw new Refusal(400, "a JSON object is expected");
32
+ }
33
+ const str = (v) => (typeof v === "string" ? v : undefined);
34
+ export async function deskApi(ctx, req, url) {
35
+ const path = url.pathname;
36
+ const where = url.searchParams.get("key");
37
+ const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
38
+ const post = req.method === "POST";
39
+ try {
40
+ if (path === "/desk/at-files") {
41
+ if (space === null)
42
+ return json(404, {});
43
+ return req.method === "GET" ? json(200, atFiles(space)) : json(405, {});
44
+ }
45
+ if (path === "/desk/context") {
46
+ if (space === null)
47
+ return json(404, {});
48
+ const session = url.searchParams.get("session") ?? undefined;
49
+ let window = null;
50
+ try {
51
+ const m = ctx.settings.model(session === undefined ? undefined : { sessionId: session, sessionsDir: space.dir });
52
+ window = m.kind === "profile" ? (m.contextWindow ?? null) : null;
53
+ }
54
+ catch {
55
+ // no model: the parts are still known, the compaction sizes are not
56
+ }
57
+ return req.method === "GET" ? json(200, contextParts(space.workspace, ctx.settings.tierOf(session), window)) : json(405, {});
58
+ }
59
+ if (path === "/desk/git") {
60
+ if (space === null)
61
+ return json(404, {});
62
+ return req.method === "GET" ? json(200, url.searchParams.has("draft") ? await prDraft(space.workspace) : await gitOf(space.workspace)) : json(405, {});
63
+ }
64
+ if (path === "/desk/commit" || path === "/desk/pr") {
65
+ if (!post)
66
+ return json(405, {});
67
+ if (space === null)
68
+ return json(404, {});
69
+ const body = await readJson(req);
70
+ if (path === "/desk/commit")
71
+ return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
72
+ return json(200, await openPr(space.workspace, str(body["title"]) ?? "", str(body["body"]) ?? ""));
73
+ }
74
+ if (path === "/desk/rename") {
75
+ if (!post)
76
+ return json(405, {});
77
+ if (space === null)
78
+ return json(404, {});
79
+ const body = await readJson(req);
80
+ const session = str(body["session"]) ?? "";
81
+ if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
82
+ return json(404, {});
83
+ return json(200, { name: rename(space.dir, session, str(body["name"]) ?? "") });
84
+ }
85
+ if (path === "/desk/fork") {
86
+ if (!post)
87
+ return json(405, {});
88
+ if (space === null)
89
+ return json(404, {});
90
+ const body = await readJson(req);
91
+ const session = str(body["session"]) ?? "";
92
+ if (!SAFE_ID.test(session))
93
+ return json(404, {});
94
+ const atSeq = typeof body["atSeq"] === "number" ? body["atSeq"] : undefined;
95
+ return json(200, { id: forkWork(space, ctx.settings, session, atSeq).id });
96
+ }
97
+ if (path === "/desk/work") {
98
+ if (!post)
99
+ return json(405, {});
100
+ if (space === null)
101
+ return json(404, {});
102
+ const body = await readJson(req);
103
+ const session = str(body["session"]) ?? "";
104
+ const act = str(body["act"]) ?? "";
105
+ if (!SAFE_ID.test(session) || !WORK_ACTS.includes(act))
106
+ return json(404, {});
107
+ workAct(space, ctx.settings, session, act);
108
+ return json(200, { ok: true });
109
+ }
110
+ if (path === "/desk/task-stop") {
111
+ if (!post)
112
+ return json(405, {});
113
+ if (space === null)
114
+ return json(404, {});
115
+ const body = await readJson(req);
116
+ const session = str(body["session"]) ?? "";
117
+ const task = str(body["task"]) ?? "";
118
+ if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
119
+ return json(404, {});
120
+ return json(200, { stopped: space.tasks.managerFor(session).stop(task, "person") });
121
+ }
122
+ if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
123
+ if (space === null)
124
+ return json(404, {});
125
+ return req.method === "GET" ? deskRead(url, space) : json(405, {});
126
+ }
127
+ if (path === "/desk/exists") {
128
+ if (space === null)
129
+ return json(404, {});
130
+ return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
131
+ }
132
+ // an image block of a result, from the Work's log: its bytes, or an https address passed on
133
+ const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+|k\d+)$/.exec(path);
134
+ if (image !== null) {
135
+ if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
136
+ return json(404, {});
137
+ const b = space.images.block(image[1], Number(image[2]), image[3].startsWith("k") ? image[3] : Number(image[3]));
138
+ if (b === null)
139
+ return json(404, {});
140
+ return "url" in b ? { status: 302, body: null, headers: { location: b.url } } : { status: 200, body: b.bytes, headers: { "content-type": b.type, "x-content-type-options": "nosniff", "cache-control": "private, max-age=86400" } };
141
+ }
142
+ // a page, a pdf or media of the folder on its own preview server (preview.ts); the address it answers
143
+ if (path === "/desk/preview" && post) {
144
+ if (space === null)
145
+ return json(404, {});
146
+ const entry = resolveInside(realpathSync(space.workspace), String((await readJson(req))["path"] ?? ""));
147
+ if (!statSync(entry).isFile() || artifactKind(entry) === undefined)
148
+ throw new Refusal(400, "not a page, a document or media");
149
+ const host = req.headers.host ?? "";
150
+ return json(200, { url: await ctx.previews.open(entry, host, `http://${host}`) });
151
+ }
152
+ if (path === "/desk/preview-close" && post) {
153
+ ctx.previews.close(String((await readJson(req))["url"] ?? ""));
154
+ return json(200, { ok: true });
155
+ }
156
+ // the line the person may send next after the run that ended at `seq` (asked once, after that run; "" for none)
157
+ if (path === "/desk/followup") {
158
+ if (space === null)
159
+ return json(404, {});
160
+ if (req.method !== "GET")
161
+ return json(405, {});
162
+ const session = url.searchParams.get("session") ?? "";
163
+ const seq = Number(url.searchParams.get("seq"));
164
+ if (!space.store.ids().includes(session) || !Number.isInteger(seq))
165
+ return json(404, {});
166
+ return json(200, { line: await space.followUp(session, seq) });
167
+ }
168
+ if (path === "/desk/env") {
169
+ if (space === null)
170
+ return json(404, {});
171
+ if (req.method !== "GET")
172
+ return json(405, {});
173
+ const session = url.searchParams.get("session") ?? undefined;
174
+ const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
175
+ return json(200, { ...folderInfo(space.workspace, ctx.settings.tierOf(session)), ...(fork === undefined ? {} : { fork: { parent: fork.parent, title: fork.title, at: fork.at } }) });
176
+ }
177
+ if (path === "/desk/history")
178
+ return req.method === "GET" ? json(200, await ctx.history.snapshot({ key: ctx.current.key, dir: ctx.current.dir }, url.searchParams.get("fresh") ?? undefined)) : json(405, {});
179
+ if (path === "/desk/settings") {
180
+ let session = url.searchParams.get("session") ?? undefined;
181
+ if (post) {
182
+ const patch = (await readJson(req));
183
+ // a patch for one Work is answered with that Work's settings
184
+ session ??= patch.session;
185
+ const before = patch.session === undefined ? undefined : ctx.settings.workOf(patch.session);
186
+ const work = ctx.settings.apply(patch);
187
+ // a Work's model changed: rebuild it from its log so its next run uses it; a run in flight refuses, and the choice is put back
188
+ if (work !== null && space !== null && space.opened().has(work)) {
189
+ try {
190
+ await space.service.reopen(work);
191
+ }
192
+ catch {
193
+ ctx.settings.restoreWork(work, before);
194
+ return json(409, { message: "a run is going: switch the model when it ends" });
195
+ }
196
+ }
197
+ }
198
+ else if (req.method !== "GET")
199
+ return json(405, {});
200
+ return json(200, ctx.settings.snapshot(session));
201
+ }
202
+ if (path === "/desk/folder" && post) {
203
+ const workspace = validateFolder((await readJson(req))["path"]);
204
+ const dir = projectDirFor(ctx.home, workspace);
205
+ claimProjectDir(dir, workspace);
206
+ ctx.settings.addRecent(workspace);
207
+ ctx.settings.write({ lastFolder: workspace });
208
+ const opened = ctx.spaceFor(basename(dir));
209
+ return opened === null ? json(500, {}) : json(200, { key: opened.key, folder: basename(workspace) });
210
+ }
211
+ if (path === "/desk/recent" && req.method === "GET") {
212
+ const rows = ctx.settings.recents().flatMap((p) => {
213
+ try {
214
+ const real = validateFolder(p);
215
+ return [{ folder: basename(real), path: real, key: basename(projectDirFor(ctx.home, canonicalPath(real))) }];
216
+ }
217
+ catch {
218
+ return [];
219
+ }
220
+ });
221
+ return json(200, { recent: rows });
222
+ }
223
+ if (path === "/desk/login" && post) {
224
+ const b = await readJson(req);
225
+ ctx.settings.saveKey(str(b["profile"]) ?? "", b["key"]);
226
+ return json(200, { ok: true });
227
+ }
228
+ if (path === "/desk/demo" && post) {
229
+ await ctx.settings.startDemo();
230
+ return json(200, { ok: true });
231
+ }
232
+ if (path === "/desk/notices" && req.method === "GET")
233
+ return json(200, ctx.notices(Number(url.searchParams.get("after") ?? "0") || 0));
234
+ if (path === "/desk/dirs" && req.method === "GET")
235
+ return json(200, listDirs(url.searchParams.get("root"), url.searchParams.get("path") ?? ""));
236
+ if (path === "/desk/presence" && post) {
237
+ const b = await readJson(req);
238
+ const key = str(b["key"]) ?? "";
239
+ ctx.setPresence({ key: key === "" ? ctx.current.key : key, session: str(b["session"]) ?? "", focused: b["focused"] === true, at: Date.now() });
240
+ return json(200, { ok: true });
241
+ }
242
+ return json(404, {});
243
+ }
244
+ catch (error) {
245
+ if (error instanceof Refusal)
246
+ return json(error.status, { code: "refused", message: error.message });
247
+ return json(500, { code: "internal", message: "failed" });
248
+ }
249
+ }
@@ -0,0 +1,104 @@
1
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { canonicalPath, projectDirFor } from "@vincemakes/kiso-code/config";
5
+ import { kisoHome } from "@vincemakes/kiso-runtime";
6
+ import { fauxSkip } from "./faux.js";
7
+ import { startHost } from "./host.js";
8
+ import { Settings } from "./settings.js";
9
+ /**
10
+ * The host's configuration from the environment (the `kiso-web` command
11
+ * reads its flags in headless.ts), so a credential never rides argv:
12
+ *
13
+ * KISO_WEB_DATA the kiso home: `projects/<enc>/` under it holds the Works (default: the terminal's own home)
14
+ * KISO_WEB_WORKSPACE the folder a new task starts in (default: the folder used last, else the working directory)
15
+ * KISO_WEB_FAUX path to a JSON FauxScript (the keyless model)
16
+ * KISO_WEB_FAUX_SKIP a session id: skip the script turns its log already consumed
17
+ * KISO_WEB_MODE the permission tier: default | accept-edits | plan | full-access
18
+ * KISO_WEB_NOTIFY_LOG a file: notices (an approval waits, a run ended) are also appended to it, as JSON lines (the proofs)
19
+ * KISO_WEB_REQUEST_LOG, KISO_WEB_PREVIEW_LOG files: each request the host, or a preview server, received (the proofs)
20
+ * KISO_WEB_SANITIZER "default" | "web"
21
+ * KISO_WEB_THINKING 0: leave reasoning text off the wire (default: on)
22
+ * KISO_WEB_KEEPALIVE keepalive period in ms
23
+ * KISO_WEB_MIN_FG_MS the shell's least foreground wait (0 keeps the model's value)
24
+ * KISO_WEB_PORT the port (default 4317; 0: the system picks), and KISO_WEB_TOKEN the scripts' bearer
25
+ * token (default: a fresh one per launch, never printed) — the crash script restarts
26
+ * the host where a live client expects it
27
+ *
28
+ */
29
+ /** A scripted model, for the proofs; otherwise null: the model comes from the shared configuration (settings.ts). */
30
+ async function fauxFromEnv(sessionsDir) {
31
+ const fauxPath = process.env["KISO_WEB_FAUX"];
32
+ if (fauxPath === undefined || fauxPath === "")
33
+ return null;
34
+ const { createFauxProvider } = await import("@vincemakes/kiso-evals");
35
+ const script = JSON.parse(readFileSync(fauxPath, "utf8"));
36
+ const skipFor = process.env["KISO_WEB_FAUX_SKIP"];
37
+ const skip = skipFor === undefined || skipFor === "" ? 0 : fauxSkip(sessionsDir, skipFor);
38
+ return { kind: "adapter", adapter: createFauxProvider(script.slice(skip)), model: "faux", route: "adapter" };
39
+ }
40
+ const MODES = ["default", "accept-edits", "plan", "full-access"];
41
+ function modeFromEnv() {
42
+ const raw = process.env["KISO_WEB_MODE"];
43
+ if (raw === undefined || raw === "")
44
+ return "default";
45
+ const mode = MODES.find((m) => m === raw);
46
+ if (mode === undefined)
47
+ throw new Error(`KISO_WEB_MODE must be one of ${MODES.join(", ")}`);
48
+ return mode;
49
+ }
50
+ const isFolder = (path) => {
51
+ try {
52
+ return statSync(path).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ };
58
+ /** The folder a new task starts in: named by the environment, else the one used last while it still exists, else where the command was started. */
59
+ function workspaceFor(settings) {
60
+ const named = process.env["KISO_WEB_WORKSPACE"];
61
+ if (named !== undefined && named !== "")
62
+ return named;
63
+ // a remembered folder can be gone (a deleted scratch folder); "/" is never a place to work: both fall back to the home folder
64
+ const last = settings.lastFolder();
65
+ if (last !== undefined && isFolder(last))
66
+ return last;
67
+ const cwd = process.cwd();
68
+ return cwd === "/" || last !== undefined ? homedir() : cwd;
69
+ }
70
+ /** A copy of every notice in a file, for the proofs. */
71
+ function noticeLog() {
72
+ const log = process.env["KISO_WEB_NOTIFY_LOG"];
73
+ return log === undefined || log === "" ? undefined : (n) => appendFileSync(log, `${JSON.stringify(n)}\n`);
74
+ }
75
+ export async function startFromEnv(options = {}) {
76
+ const home = process.env["KISO_WEB_DATA"] ?? kisoHome();
77
+ // the terminal's configuration and sign-in are read from the one root the terminal uses
78
+ process.env["KISO_HOME"] ??= home;
79
+ const keepalive = Number(process.env["KISO_WEB_KEEPALIVE"] ?? "");
80
+ const minFg = Number(process.env["KISO_WEB_MIN_FG_MS"] ?? "");
81
+ // settings first: its saved tier and folder memory are read before anything is opened
82
+ const probe = new Settings(home, null, modeFromEnv());
83
+ const workspace = canonicalPath(workspaceFor(probe));
84
+ const faux = await fauxFromEnv(projectDirFor(home, workspace));
85
+ const settings = faux === null ? probe : new Settings(home, faux, modeFromEnv());
86
+ // the folder used last is only ever one the person chose (POST /desk/folder): a folder named by the environment, a proof's or a
87
+ // scratch run's, is not remembered for them
88
+ return startHost({
89
+ home,
90
+ workspace,
91
+ settings,
92
+ ...(noticeLog() !== undefined ? { notify: noticeLog() } : {}),
93
+ exposeThinking: process.env["KISO_WEB_THINKING"] !== "0",
94
+ requestLog: process.env["KISO_WEB_REQUEST_LOG"] || undefined,
95
+ previewLog: process.env["KISO_WEB_PREVIEW_LOG"] || undefined,
96
+ ...(options.port !== undefined ? { port: options.port } : {}),
97
+ ...(process.env["KISO_WEB_TOKEN"] !== undefined ? { token: process.env["KISO_WEB_TOKEN"] } : {}),
98
+ sanitizer: process.env["KISO_WEB_SANITIZER"] === "default" ? "default" : "web",
99
+ followUps: process.env["KISO_WEB_FOLLOWUP"] !== "off",
100
+ titles: process.env["KISO_AUTO_TITLE"] !== "off",
101
+ ...(Number.isFinite(keepalive) && keepalive > 0 ? { keepaliveMs: keepalive } : {}),
102
+ ...(process.env["KISO_WEB_MIN_FG_MS"] !== undefined && Number.isFinite(minFg) ? { toolLimits: { minForegroundMs: minFg } } : {}),
103
+ });
104
+ }
@@ -0,0 +1,12 @@
1
+ import { SessionStore } from "@vincemakes/kiso-runtime";
2
+ /** How many faux-script turns a session's log has consumed (the same rule the terminal's fauxSkip states: a turn is consumed by a tool
3
+ * result or an end_turn stop, or by an unfinished call the recovery completes). */
4
+ export function fauxSkip(sessionsDir, sessionId) {
5
+ const store = new SessionStore(sessionsDir);
6
+ const events = store.load(sessionId).map((r) => r.event);
7
+ store.closeAll();
8
+ const results = new Set(events.filter((e) => e.type === "tool_result").map((e) => e.callId));
9
+ return (events.filter((e) => e.type === "tool_result").length +
10
+ events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
11
+ events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
12
+ }
@@ -0,0 +1,156 @@
1
+ import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { extname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { canonicalPath } from "@vincemakes/kiso-code/config";
4
+ import { kisoHome } from "@vincemakes/kiso-runtime";
5
+ import { protectedFiles } from "./agent.js";
6
+ /**
7
+ * The read-only doors to a Work's folder, for the Files and Changes panels.
8
+ *
9
+ * What may be read is decided here, never by the page: a path is relative to
10
+ * the folder's real path; it is resolved through every symbolic link and must
11
+ * land inside the folder; the kiso home (credentials, sessions) and the
12
+ * files the policy protects are refused even when they sit inside the folder;
13
+ * a file is read up to a size cap and a file with a NUL byte is never read as
14
+ * text. A link that leads outside is listed as such and never followed.
15
+ */
16
+ export const TEXT_CAP = 256 * 1024;
17
+ export const IMAGE_CAP = 2 * 1024 * 1024;
18
+ const LIST_CAP = 500;
19
+ const LOG_CAP = 64 * 1024;
20
+ const IMAGES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml" };
21
+ /** A refusal with the status the host answers. */
22
+ export class Refusal extends Error {
23
+ status;
24
+ constructor(status, message) {
25
+ super(message);
26
+ this.status = status;
27
+ }
28
+ }
29
+ const inside = (root, real) => real === root || real.startsWith(root + sep);
30
+ const folded = (name, dir) => dir && (name === "node_modules" || name.startsWith("."));
31
+ /** Places no door reads: the kiso home and the policy's protected files, by real path. */
32
+ function protectedPaths() {
33
+ return { dirs: [canonicalPath(kisoHome())], files: protectedFiles().map(canonicalPath) };
34
+ }
35
+ function isProtected(real) {
36
+ const p = protectedPaths();
37
+ return p.dirs.some((d) => inside(d, real)) || p.files.includes(real);
38
+ }
39
+ /** The real path of `rel` inside `root` (a real path), or a Refusal. */
40
+ export function resolveInside(root, rel) {
41
+ if (rel.includes("\0") || isAbsolute(rel))
42
+ throw new Refusal(400, "the path must be relative to the folder");
43
+ const target = resolve(root, rel);
44
+ if (!inside(root, target))
45
+ throw new Refusal(403, "outside the folder");
46
+ // a folder the listing does not open is not read through either (a dot folder can hold credentials)
47
+ const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
48
+ if (parts.slice(0, -1).some((p) => folded(p, true)))
49
+ throw new Refusal(403, "not listed");
50
+ let real;
51
+ try {
52
+ real = realpathSync(target);
53
+ }
54
+ catch {
55
+ throw new Refusal(404, "no such file");
56
+ }
57
+ if (!inside(root, real))
58
+ throw new Refusal(403, "outside the folder");
59
+ if (isProtected(real))
60
+ throw new Refusal(403, "protected");
61
+ return real;
62
+ }
63
+ export function listDir(root, rel) {
64
+ const dir = resolveInside(root, rel);
65
+ if (!statSync(dir).isDirectory())
66
+ throw new Refusal(400, "not a folder");
67
+ const last = rel.split("/").filter((p) => p !== "" && p !== ".").at(-1);
68
+ if (last !== undefined && folded(last, true))
69
+ throw new Refusal(403, "not listed");
70
+ const entries = [];
71
+ let more = 0;
72
+ const names = readdirSync(dir, { withFileTypes: true }).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
73
+ for (const ent of names) {
74
+ const full = join(dir, ent.name);
75
+ let real;
76
+ try {
77
+ real = realpathSync(full);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (isProtected(real))
83
+ continue;
84
+ if (entries.length >= LIST_CAP) {
85
+ more += 1;
86
+ continue;
87
+ }
88
+ const link = lstatSync(full).isSymbolicLink();
89
+ if (!inside(root, real)) {
90
+ entries.push({ name: ent.name, kind: "link", size: 0, outside: true });
91
+ continue;
92
+ }
93
+ const stat = statSync(real);
94
+ const isDir = stat.isDirectory();
95
+ entries.push({ name: ent.name, kind: link && !isDir ? "link" : isDir ? "dir" : "file", size: isDir ? 0 : stat.size, ...(folded(ent.name, isDir) ? { folded: true } : {}) });
96
+ }
97
+ return { root, path: rel, entries, more };
98
+ }
99
+ function readHead(path, length) {
100
+ const fd = openSync(path, "r");
101
+ try {
102
+ const buf = Buffer.alloc(Math.min(length, fstatSync(fd).size));
103
+ readSync(fd, buf, 0, buf.length, 0);
104
+ return buf;
105
+ }
106
+ finally {
107
+ closeSync(fd);
108
+ }
109
+ }
110
+ export function readPreview(root, rel) {
111
+ const file = resolveInside(root, rel);
112
+ const stat = statSync(file);
113
+ if (!stat.isFile())
114
+ throw new Refusal(400, "not a file");
115
+ const mime = IMAGES[extname(file).toLowerCase()];
116
+ if (mime !== undefined)
117
+ return stat.size > IMAGE_CAP ? { kind: "toolarge", size: stat.size } : { kind: "image", size: stat.size, dataUrl: `data:${mime};base64,${readHead(file, IMAGE_CAP).toString("base64")}` };
118
+ const head = readHead(file, TEXT_CAP);
119
+ // a NUL byte in the first 8 KB: not text, however it is named
120
+ if (head.subarray(0, 8192).includes(0))
121
+ return { kind: "binary", size: stat.size };
122
+ const markdown = /\.(md|markdown)$/i.test(file);
123
+ return { kind: markdown ? "markdown" : "text", size: stat.size, text: head.toString("utf8"), truncated: stat.size > TEXT_CAP };
124
+ }
125
+ /** Which of the paths name a file inside the folder (the stream's chips): each judged as a read would be; a refusal is "no". */
126
+ export function existing(root, paths) {
127
+ if (!Array.isArray(paths) || paths.length > 64)
128
+ throw new Refusal(400, "at most 64 paths");
129
+ return paths.filter((p) => typeof p === "string" && p.length <= 512).filter((rel) => {
130
+ try {
131
+ return statSync(resolveInside(root, rel)).isFile();
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ });
137
+ }
138
+ /** The last part of a task's output file, as lines. */
139
+ export function readTaskLog(outputPath) {
140
+ try {
141
+ const size = statSync(outputPath).size;
142
+ const fd = openSync(outputPath, "r");
143
+ try {
144
+ const length = Math.min(size, LOG_CAP);
145
+ const buf = Buffer.alloc(length);
146
+ readSync(fd, buf, 0, length, size - length);
147
+ return { lines: buf.toString("utf8").split("\n").filter((l, i, all) => l !== "" || i < all.length - 1), truncated: size > LOG_CAP };
148
+ }
149
+ finally {
150
+ closeSync(fd);
151
+ }
152
+ }
153
+ catch {
154
+ return { lines: [], truncated: false };
155
+ }
156
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ /** The git directory of a folder (it or the nearest parent holding `.git`, a directory or a worktree's pointer file). */
5
+ function gitDir(from) {
6
+ for (let dir = from;; dir = dirname(dir)) {
7
+ const dot = join(dir, ".git");
8
+ if (existsSync(dot)) {
9
+ if (statSync(dot).isDirectory())
10
+ return dot;
11
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, "utf8"));
12
+ return pointer === null ? null : join(dir, pointer[1].trim());
13
+ }
14
+ if (dirname(dir) === dir)
15
+ return null;
16
+ }
17
+ }
18
+ /** The branch a folder is on (a short commit when detached); null outside a git folder. */
19
+ function branchOf(workspace) {
20
+ try {
21
+ const git = gitDir(workspace);
22
+ if (git === null)
23
+ return null;
24
+ const head = readFileSync(join(git, "HEAD"), "utf8").trim();
25
+ const ref = /^ref: refs\/heads\/(.+)$/.exec(head);
26
+ return ref !== null ? ref[1] : head.slice(0, 7);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** What the rail's footer says about a Work's folder: its name, its path (the home shortened), its branch. */
33
+ export function folderInfo(workspace, mode) {
34
+ const home = homedir();
35
+ return { folder: basename(workspace), path: workspace.startsWith(`${home}/`) ? `~${workspace.slice(home.length)}` : workspace, root: workspace, branch: branchOf(workspace), mode };
36
+ }
@@ -0,0 +1,102 @@
1
+ import { accessSync, constants, readdirSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, parse, relative, sep } from "node:path";
4
+ import { canonicalPath } from "@vincemakes/kiso-code/config";
5
+ import { kisoHome } from "@vincemakes/kiso-runtime";
6
+ import { Refusal } from "./files.js";
7
+ /** A folder the person picked, judged here and never trusted from the page: absolute, there, a folder, and not the kiso home or inside it. Returns its real path. */
8
+ export function validateFolder(raw) {
9
+ if (typeof raw !== "string" || raw === "" || raw.includes("\0") || !isAbsolute(raw))
10
+ throw new Refusal(400, "pick a folder");
11
+ let real;
12
+ try {
13
+ real = canonicalPath(raw);
14
+ if (!statSync(real).isDirectory())
15
+ throw new Error("not a folder");
16
+ }
17
+ catch {
18
+ throw new Refusal(400, "that is not a folder that exists");
19
+ }
20
+ if (real === parse(real).root)
21
+ throw new Refusal(400, "the whole disk is not a project folder");
22
+ const home = canonicalPath(kisoHome());
23
+ if (real === home || real.startsWith(home + sep))
24
+ throw new Refusal(400, "the kiso home holds credentials and sessions: pick a project folder");
25
+ return real;
26
+ }
27
+ const DIRS_CAP = 500;
28
+ const inside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
29
+ /** A root of the folder browser: the home folder, or a typed absolute path that exists, is a readable folder, is not the whole disk and not the kiso home. */
30
+ function browseRoot(raw) {
31
+ if (raw === null || raw === "")
32
+ return realpathSync(homedir());
33
+ if (raw.includes("\0") || !isAbsolute(raw))
34
+ throw new Refusal(400, "type a full path, starting with /");
35
+ let real;
36
+ try {
37
+ real = realpathSync(raw);
38
+ if (!statSync(real).isDirectory())
39
+ throw new Error("not a folder");
40
+ accessSync(real, constants.R_OK | constants.X_OK);
41
+ }
42
+ catch {
43
+ throw new Refusal(400, "that is not a folder that exists and can be read");
44
+ }
45
+ if (real === parse(real).root)
46
+ throw new Refusal(400, "the whole disk is not a place to browse: type a folder in it");
47
+ if (inside(realpathSync(kisoHome()), real))
48
+ throw new Refusal(400, "the kiso home holds credentials and sessions");
49
+ return real;
50
+ }
51
+ /**
52
+ * One folder of the in-page folder browser: the folders in it, by name, nothing else (no files, no contents). The path is relative to
53
+ * the root and resolved through every link; it must stay inside the root, so a link never leads the browsing out of it (such a link is
54
+ * listed as `outside`). Dot folders and the kiso home are not listed.
55
+ */
56
+ export function listDirs(rootRaw, rel) {
57
+ const root = browseRoot(rootRaw);
58
+ if (rel.includes("\0") || isAbsolute(rel) || rel.split(/[\\/]/).includes(".."))
59
+ throw new Refusal(400, "a path inside the root is expected");
60
+ let full;
61
+ try {
62
+ full = realpathSync(join(root, rel));
63
+ if (!statSync(full).isDirectory())
64
+ throw new Error("not a folder");
65
+ }
66
+ catch {
67
+ throw new Refusal(404, "no such folder");
68
+ }
69
+ const home = realpathSync(kisoHome());
70
+ if (!inside(root, full) || inside(home, full))
71
+ throw new Refusal(403, "outside the folder you are browsing");
72
+ const dirs = [];
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(full, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ throw new Refusal(403, "that folder cannot be read");
79
+ }
80
+ for (const entry of entries) {
81
+ if (entry.name.startsWith("."))
82
+ continue;
83
+ const path = join(full, entry.name);
84
+ if (entry.isDirectory()) {
85
+ if (!inside(home, path))
86
+ dirs.push({ name: entry.name });
87
+ }
88
+ else if (entry.isSymbolicLink()) {
89
+ try {
90
+ const target = realpathSync(path);
91
+ if (!statSync(target).isDirectory() || inside(home, target))
92
+ continue;
93
+ dirs.push(inside(root, target) ? { name: entry.name } : { name: entry.name, outside: true });
94
+ }
95
+ catch {
96
+ // a broken link is not a folder
97
+ }
98
+ }
99
+ }
100
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
101
+ return { root, path: relative(root, full), full, dirs: dirs.slice(0, DIRS_CAP), more: Math.max(0, dirs.length - DIRS_CAP) };
102
+ }